Compare commits

..

21 Commits

Author SHA1 Message Date
ca70c963e5 release: 2024.4.1 2024-04-26 17:39:46 +02:00
4c89d4a4a4 website/docs: update release notes for 2024.4.1 again (cherry-pick #9471) (#9472)
website/docs: update release notes for 2024.4.1 again (#9471)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L <jens@goauthentik.io>
2024-04-26 17:39:25 +02:00
8a47acac3a sources/scim: fix service account user path (cherry-pick #9463) (#9470)
sources/scim: fix service account user path (#9463)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L <jens@goauthentik.io>
2024-04-26 17:19:30 +02:00
4a3b22491c web/admin: fix disabled button color with dark theme (cherry-pick #9465) (#9468)
web/admin: fix disabled button color with dark theme (#9465)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L <jens@goauthentik.io>
2024-04-26 17:02:41 +02:00
f991d656c7 web/admin: show user internal service account as disabled (cherry-pick #9464) (#9467)
web/admin: show user internal service account as disabled (#9464)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L <jens@goauthentik.io>
2024-04-26 17:02:26 +02:00
e86aa11131 website/docs: prepare 2024.4.1 (cherry-pick #9459) (#9461)
website/docs: prepare 2024.4.1 (#9459)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L <jens@goauthentik.io>
2024-04-26 14:39:44 +02:00
03725ae086 lifecycle: always try custom redis URL (cherry-pick #9441) (#9458)
lifecycle: always try custom redis URL (#9441)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L <jens@goauthentik.io>
2024-04-26 13:26:52 +02:00
f2a37e8c7c web/common: fix locale detection for user-set locale (cherry-pick #9436) (#9439)
web/common: fix locale detection for user-set locale (#9436)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L <jens@goauthentik.io>
2024-04-25 22:37:56 +02:00
e935690b1b ci: fix ci pipeline (cherry-pick #9427) (#9429)
ci: fix ci pipeline (#9427)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L <jens@goauthentik.io>
2024-04-25 16:06:12 +02:00
02709e4ede core: fix logic for token expiration (cherry-pick #9426) (#9428)
core: fix logic for token expiration (#9426)

* core: fix logic for token expiration



* bump default token expiration



* fix frontend



* fix



---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L <jens@goauthentik.io>
2024-04-25 16:05:30 +02:00
f78adab9d1 web: Add resolved and integrity fields back to package-lock.json (cherry-pick #9419) (#9421)
web: Add resolved and integrity fields back to package-lock.json (#9419)

* web: Fix missing resolved and integrity fields in package-lock.json

* web,website: Add lockfile lint to CI

Co-authored-by: Jan van Brügge <supermanitu@gmail.com>
2024-04-25 12:41:31 +02:00
61f3a72fd9 stages/identification: don't check source component (cherry-pick #9410) (#9420)
stages/identification: don't check source component (#9410)

* Do not include the built-in source in this check



* Update authentik/stages/identification/stage.py



---------

Signed-off-by: PythonCoderAS <13932583+PythonCoderAS@users.noreply.github.com>
Signed-off-by: Jens L. <jens@beryju.org>
Co-authored-by: PythonCoderAS <13932583+PythonCoderAS@users.noreply.github.com>
Co-authored-by: Jens L <jens@beryju.org>
2024-04-25 11:55:49 +02:00
541becfe30 sources/oauth: ensure all UI sources return a valid source (cherry-pick #9401) (#9406)
sources/oauth: ensure all UI sources return a valid source (#9401)

* web/admin: prevent selection of inbuilt source in identification stage



* fix apple source



* also fix plex challenge



---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L <jens@goauthentik.io>
2024-04-24 23:00:24 +02:00
11ff7955f7 web: markdown: display markdown even when frontmatter is missing (cherry-pick #9404) (#9405)
web: markdown: display markdown even when frontmatter is missing (#9404)

* web: fix esbuild issue with style sheets

Getting ESBuild, Lit, and Storybook to all agree on how to read and parse stylesheets is a serious
pain. This fix better identifies the value types (instances) being passed from various sources in
the repo to the three *different* kinds of style processors we're using (the native one, the
polyfill one, and whatever the heck Storybook does internally).

Falling back to using older CSS instantiating techniques one era at a time seems to do the trick.
It's ugly, but in the face of the aggressive styling we use to avoid Flashes of Unstyled Content
(FLoUC), it's the logic with which we're left.

In standard mode, the following warning appears on the console when running a Flow:

```
Autofocus processing was blocked because a document already has a focused element.
```

In compatibility mode, the following **error** appears on the console when running a Flow:

```
crawler-inject.js:1106 Uncaught TypeError: Failed to execute 'observe' on 'MutationObserver': parameter 1 is not of type 'Node'.
    at initDomMutationObservers (crawler-inject.js:1106:18)
    at crawler-inject.js:1114:24
    at Array.forEach (<anonymous>)
    at initDomMutationObservers (crawler-inject.js:1114:10)
    at crawler-inject.js:1549:1
initDomMutationObservers @ crawler-inject.js:1106
(anonymous) @ crawler-inject.js:1114
initDomMutationObservers @ crawler-inject.js:1114
(anonymous) @ crawler-inject.js:1549
```

Despite this error, nothing seems to be broken and flows work as anticipated.

* web: markdown: display markdown even when frontmatter is missing

Make the check for the document title comprehensive across the
entire demeter.  If there is no front matter, `data` will be missing,
not just `data.title`.

Co-authored-by: Ken Sternberg <133134217+kensternberg-authentik@users.noreply.github.com>
2024-04-24 22:54:51 +02:00
afa4234036 release: 2024.4.0 2024-04-24 17:42:10 +02:00
ca22a4deaf website/docs: finalize 2024.4 release notes (cherry-pick #9396) (#9398)
website/docs: finalize 2024.4 release notes (#9396)

* website/docs: finalize 2024.4 release notes



* escape curly braces manually



---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L <jens@goauthentik.io>
2024-04-24 17:41:29 +02:00
7b7a3d34ec web/admin: fix document title for admin interface (cherry-pick #9362) (#9365)
web/admin: fix document title for admin interface (#9362)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L <jens@goauthentik.io>
2024-04-20 23:05:23 +02:00
b1ca579397 website/docs: release notes 2024.4: add performance improvements values (cherry-pick #9356) (#9357)
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2024-04-19 16:53:15 +00:00
c8072579c8 release: 2024.4.0-rc1 2024-04-19 16:05:20 +02:00
378a701fb9 root: bump blueprint schema version
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2024-04-19 16:05:15 +02:00
bba793d94c lifecycle: fix ak test-all command
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2024-04-19 16:04:39 +02:00
784 changed files with 18879 additions and 55627 deletions

View File

@ -1,5 +1,5 @@
[bumpversion] [bumpversion]
current_version = 2024.6.0 current_version = 2024.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*))?
@ -17,8 +17,6 @@ optional_value = final
[bumpversion:file:pyproject.toml] [bumpversion:file:pyproject.toml]
[bumpversion:file:package.json]
[bumpversion:file:docker-compose.yml] [bumpversion:file:docker-compose.yml]
[bumpversion:file:schema.yml] [bumpversion:file:schema.yml]

View File

@ -12,7 +12,7 @@ should_build = str(os.environ.get("DOCKER_USERNAME", None) is not None).lower()
branch_name = os.environ["GITHUB_REF"] branch_name = os.environ["GITHUB_REF"]
if os.environ.get("GITHUB_HEAD_REF", "") != "": if os.environ.get("GITHUB_HEAD_REF", "") != "":
branch_name = os.environ["GITHUB_HEAD_REF"] branch_name = os.environ["GITHUB_HEAD_REF"]
safe_branch_name = branch_name.replace("refs/heads/", "").replace("/", "-").replace("'", "-") safe_branch_name = branch_name.replace("refs/heads/", "").replace("/", "-")
image_names = os.getenv("IMAGE_NAME").split(",") image_names = os.getenv("IMAGE_NAME").split(",")
image_arch = os.getenv("IMAGE_ARCH") or None image_arch = os.getenv("IMAGE_ARCH") or None
@ -54,9 +54,9 @@ image_main_tag = image_tags[0]
image_tags_rendered = ",".join(image_tags) image_tags_rendered = ",".join(image_tags)
with open(os.environ["GITHUB_OUTPUT"], "a+", encoding="utf-8") as _output: with open(os.environ["GITHUB_OUTPUT"], "a+", encoding="utf-8") as _output:
print(f"shouldBuild={should_build}", file=_output) print("shouldBuild=%s" % should_build, file=_output)
print(f"sha={sha}", file=_output) print("sha=%s" % sha, file=_output)
print(f"version={version}", file=_output) print("version=%s" % version, file=_output)
print(f"prerelease={prerelease}", file=_output) print("prerelease=%s" % prerelease, file=_output)
print(f"imageTags={image_tags_rendered}", file=_output) print("imageTags=%s" % image_tags_rendered, file=_output)
print(f"imageMainTag={image_main_tag}", file=_output) print("imageMainTag=%s" % image_main_tag, file=_output)

View File

@ -1,3 +1,5 @@
version: "3.7"
services: services:
postgresql: postgresql:
image: docker.io/library/postgres:${PSQL_TAG:-16} image: docker.io/library/postgres:${PSQL_TAG:-16}

View File

@ -4,4 +4,3 @@ hass
warmup warmup
ontext ontext
singed singed
assertIn

View File

@ -21,10 +21,7 @@ updates:
labels: labels:
- dependencies - dependencies
- package-ecosystem: npm - package-ecosystem: npm
directories: directory: "/web"
- "/web"
- "/tests/wdio"
- "/web/sfe"
schedule: schedule:
interval: daily interval: daily
time: "04:00" time: "04:00"
@ -33,6 +30,7 @@ updates:
open-pull-requests-limit: 10 open-pull-requests-limit: 10
commit-message: commit-message:
prefix: "web:" prefix: "web:"
# TODO: deduplicate these groups
groups: groups:
sentry: sentry:
patterns: patterns:
@ -58,6 +56,38 @@ updates:
patterns: patterns:
- "@rollup/*" - "@rollup/*"
- "rollup-*" - "rollup-*"
- package-ecosystem: npm
directory: "/tests/wdio"
schedule:
interval: daily
time: "04:00"
labels:
- dependencies
open-pull-requests-limit: 10
commit-message:
prefix: "web:"
# TODO: deduplicate these groups
groups:
sentry:
patterns:
- "@sentry/*"
- "@spotlightjs/*"
babel:
patterns:
- "@babel/*"
- "babel-*"
eslint:
patterns:
- "@typescript-eslint/*"
- "eslint"
- "eslint-*"
storybook:
patterns:
- "@storybook/*"
- "*storybook*"
esbuild:
patterns:
- "@esbuild/*"
wdio: wdio:
patterns: patterns:
- "@wdio/*" - "@wdio/*"

View File

@ -31,12 +31,7 @@ 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 working-directory: web/
run: |
export VERSION=`node -e 'console.log(require("../gen-ts-api/package.json").version)'`
npm i @goauthentik/api@$VERSION
- name: Upgrade /web/sfe
working-directory: web/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

View File

@ -50,6 +50,7 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
psql: psql:
- 12-alpine
- 15-alpine - 15-alpine
- 16-alpine - 16-alpine
steps: steps:
@ -103,6 +104,7 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
psql: psql:
- 12-alpine
- 15-alpine - 15-alpine
- 16-alpine - 16-alpine
steps: steps:
@ -128,7 +130,7 @@ jobs:
- name: Setup authentik env - name: Setup authentik env
uses: ./.github/actions/setup uses: ./.github/actions/setup
- name: Create k8s Kind Cluster - name: Create k8s Kind Cluster
uses: helm/kind-action@v1.10.0 uses: helm/kind-action@v1.9.0
- name: run integration - name: run integration
run: | run: |
poetry run coverage run manage.py test tests/integration poetry run coverage run manage.py test tests/integration
@ -219,7 +221,7 @@ jobs:
with: with:
ref: ${{ github.event.pull_request.head.sha }} ref: ${{ github.event.pull_request.head.sha }}
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3.1.0 uses: docker/setup-qemu-action@v3.0.0
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: prepare variables - name: prepare variables
@ -240,7 +242,7 @@ jobs:
- name: generate ts client - name: generate ts client
run: make gen-client-ts run: make gen-client-ts
- name: Build Docker Image - name: Build Docker Image
uses: docker/build-push-action@v6 uses: docker/build-push-action@v5
with: with:
context: . context: .
secrets: | secrets: |
@ -250,8 +252,8 @@ jobs:
push: ${{ steps.ev.outputs.shouldBuild == 'true' }} push: ${{ steps.ev.outputs.shouldBuild == 'true' }}
build-args: | build-args: |
GIT_BUILD_HASH=${{ steps.ev.outputs.sha }} GIT_BUILD_HASH=${{ steps.ev.outputs.sha }}
cache-from: type=registry,ref=ghcr.io/goauthentik/dev-server:buildcache cache-from: type=gha
cache-to: type=registry,ref=ghcr.io/goauthentik/dev-server:buildcache,mode=max cache-to: type=gha,mode=max
platforms: linux/${{ matrix.arch }} platforms: linux/${{ matrix.arch }}
pr-comment: pr-comment:
needs: needs:

View File

@ -29,7 +29,7 @@ jobs:
- name: Generate API - name: Generate API
run: make gen-client-go run: make gen-client-go
- name: golangci-lint - name: golangci-lint
uses: golangci/golangci-lint-action@v6 uses: golangci/golangci-lint-action@v4
with: with:
version: v1.54.2 version: v1.54.2
args: --timeout 5000s --verbose args: --timeout 5000s --verbose
@ -76,7 +76,7 @@ jobs:
with: with:
ref: ${{ github.event.pull_request.head.sha }} ref: ${{ github.event.pull_request.head.sha }}
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3.1.0 uses: docker/setup-qemu-action@v3.0.0
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: prepare variables - name: prepare variables
@ -96,7 +96,7 @@ jobs:
- name: Generate API - name: Generate API
run: make gen-client-go run: make gen-client-go
- name: Build Docker Image - name: Build Docker Image
uses: docker/build-push-action@v6 uses: docker/build-push-action@v5
with: with:
tags: ${{ steps.ev.outputs.imageTags }} tags: ${{ steps.ev.outputs.imageTags }}
file: ${{ matrix.type }}.Dockerfile file: ${{ matrix.type }}.Dockerfile
@ -105,8 +105,8 @@ jobs:
GIT_BUILD_HASH=${{ steps.ev.outputs.sha }} GIT_BUILD_HASH=${{ steps.ev.outputs.sha }}
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
context: . context: .
cache-from: type=registry,ref=ghcr.io/goauthentik/dev-${{ matrix.type }}:buildcache cache-from: type=gha
cache-to: type=registry,ref=ghcr.io/goauthentik/dev-${{ matrix.type }}:buildcache,mode=max cache-to: type=gha,mode=max
build-binary: build-binary:
timeout-minutes: 120 timeout-minutes: 120
needs: needs:

View File

@ -12,36 +12,14 @@ on:
- version-* - version-*
jobs: jobs:
lint: lint-eslint:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
command:
- lint
- lint:lockfile
- tsc
- prettier-check
project: project:
- web - web
- tests/wdio - tests/wdio
include:
- command: tsc
project: web
extra_setup: |
cd sfe/ && npm ci
- command: lit-analyse
project: web
extra_setup: |
# lit-analyse doesn't understand path rewrites, so make it
# belive it's an actual module
cd node_modules/@goauthentik
ln -s ../../src/ web
exclude:
- command: lint:lockfile
project: tests/wdio
- command: tsc
project: tests/wdio
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
@ -50,17 +28,85 @@ jobs:
cache: "npm" cache: "npm"
cache-dependency-path: ${{ matrix.project }}/package-lock.json cache-dependency-path: ${{ matrix.project }}/package-lock.json
- working-directory: ${{ matrix.project }}/ - working-directory: ${{ matrix.project }}/
run: | run: npm ci
npm ci
${{ matrix.extra_setup }}
- name: Generate API - name: Generate API
run: make gen-client-ts run: make gen-client-ts
- name: Lint - name: Eslint
working-directory: ${{ matrix.project }}/ working-directory: ${{ matrix.project }}/
run: npm run ${{ matrix.command }} run: npm run lint
lint-lockfile:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- working-directory: web/
run: |
[ -z "$(jq -r '.packages | to_entries[] | select((.key | startswith("node_modules")) and (.value | has("resolved") | not)) | .key' < package-lock.json)" ]
lint-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: web/package.json
cache: "npm"
cache-dependency-path: web/package-lock.json
- working-directory: web/
run: npm ci
- name: Generate API
run: make gen-client-ts
- name: TSC
working-directory: web/
run: npm run tsc
lint-prettier:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
project:
- web
- tests/wdio
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: ${{ matrix.project }}/package.json
cache: "npm"
cache-dependency-path: ${{ matrix.project }}/package-lock.json
- working-directory: ${{ matrix.project }}/
run: npm ci
- name: Generate API
run: make gen-client-ts
- name: prettier
working-directory: ${{ matrix.project }}/
run: npm run prettier-check
lint-lit-analyse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: web/package.json
cache: "npm"
cache-dependency-path: web/package-lock.json
- working-directory: web/
run: |
npm ci
# lit-analyse doesn't understand path rewrites, so make it
# belive it's an actual module
cd node_modules/@goauthentik
ln -s ../../src/ web
- name: Generate API
run: make gen-client-ts
- name: lit-analyse
working-directory: web/
run: npm run lit-analyse
ci-web-mark: ci-web-mark:
needs: needs:
- lint - lint-lockfile
- lint-eslint
- lint-prettier
- lint-lit-analyse
- lint-build
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- run: echo mark - run: echo mark
@ -82,21 +128,3 @@ jobs:
- name: build - name: build
working-directory: web/ working-directory: web/
run: npm run build run: npm run build
test:
needs:
- ci-web-mark
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: web/package.json
cache: "npm"
cache-dependency-path: web/package-lock.json
- working-directory: web/
run: npm ci
- name: Generate API
run: make gen-client-ts
- name: test
working-directory: web/
run: npm run test

View File

@ -12,21 +12,27 @@ on:
- version-* - version-*
jobs: jobs:
lint: lint-lockfile:
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/
run: |
[ -z "$(jq -r '.packages | to_entries[] | select((.key | startswith("node_modules")) and (.value | has("resolved") | not)) | .key' < package-lock.json)" ]
lint-prettier:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: website/package.json
cache: "npm"
cache-dependency-path: website/package-lock.json
- working-directory: website/ - working-directory: website/
run: npm ci run: npm ci
- name: Lint - name: prettier
working-directory: website/ working-directory: website/
run: npm run ${{ matrix.command }} run: npm run prettier-check
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@ -63,7 +69,8 @@ jobs:
run: npm run ${{ matrix.job }} run: npm run ${{ matrix.job }}
ci-website-mark: ci-website-mark:
needs: needs:
- lint - lint-lockfile
- lint-prettier
- test - test
- build - build
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@ -14,7 +14,7 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3.1.0 uses: docker/setup-qemu-action@v3.0.0
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: prepare variables - name: prepare variables
@ -40,7 +40,7 @@ jobs:
mkdir -p ./gen-ts-api mkdir -p ./gen-ts-api
mkdir -p ./gen-go-api mkdir -p ./gen-go-api
- name: Build Docker Image - name: Build Docker Image
uses: docker/build-push-action@v6 uses: docker/build-push-action@v5
with: with:
context: . context: .
push: true push: true
@ -68,7 +68,7 @@ jobs:
with: with:
go-version-file: "go.mod" go-version-file: "go.mod"
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3.1.0 uses: docker/setup-qemu-action@v3.0.0
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: prepare variables - name: prepare variables
@ -94,7 +94,7 @@ jobs:
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Docker Image - name: Build Docker Image
uses: docker/build-push-action@v6 uses: docker/build-push-action@v5
with: with:
push: true push: true
tags: ${{ steps.ev.outputs.imageTags }} tags: ${{ steps.ev.outputs.imageTags }}
@ -155,8 +155,8 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Run test suite in final docker images - name: Run test suite in final docker images
run: | run: |
echo "PG_PASS=$(openssl rand 32 | base64 -w 0)" >> .env echo "PG_PASS=$(openssl rand -base64 32)" >> .env
echo "AUTHENTIK_SECRET_KEY=$(openssl rand 32 | base64 -w 0)" >> .env echo "AUTHENTIK_SECRET_KEY=$(openssl rand -base64 32)" >> .env
docker compose pull -q docker compose pull -q
docker compose up --no-start docker compose up --no-start
docker compose start postgresql redis docker compose start postgresql redis

View File

@ -14,8 +14,8 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Pre-release test - name: Pre-release test
run: | run: |
echo "PG_PASS=$(openssl rand 32 | base64 -w 0)" >> .env echo "PG_PASS=$(openssl rand -base64 32)" >> .env
echo "AUTHENTIK_SECRET_KEY=$(openssl rand 32 | base64 -w 0)" >> .env echo "AUTHENTIK_SECRET_KEY=$(openssl rand -base64 32)" >> .env
docker buildx install docker buildx install
mkdir -p ./gen-ts-api mkdir -p ./gen-ts-api
docker build -t testing:latest . docker build -t testing:latest .

13
.vscode/settings.json vendored
View File

@ -4,21 +4,20 @@
"asgi", "asgi",
"authentik", "authentik",
"authn", "authn",
"entra",
"goauthentik", "goauthentik",
"jwks", "jwks",
"kubernetes",
"oidc", "oidc",
"openid", "openid",
"passwordless",
"plex", "plex",
"saml", "saml",
"scim",
"slo",
"sso",
"totp", "totp",
"traefik",
"webauthn", "webauthn",
"traefik",
"passwordless",
"kubernetes",
"sso",
"slo",
"scim",
], ],
"todo-tree.tree.showCountsInTree": true, "todo-tree.tree.showCountsInTree": true,
"todo-tree.tree.showBadges": true, "todo-tree.tree.showBadges": true,

View File

@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
# Stage 1: Build website # Stage 1: Build website
FROM --platform=${BUILDPLATFORM} docker.io/node:22 as website-builder FROM --platform=${BUILDPLATFORM} docker.io/node:21 as website-builder
ENV NODE_ENV=production ENV NODE_ENV=production
@ -20,35 +20,25 @@ COPY ./SECURITY.md /work/
RUN npm run build-bundled RUN npm run build-bundled
# Stage 2: Build webui # Stage 2: Build webui
FROM --platform=${BUILDPLATFORM} docker.io/node:22 as web-builder FROM --platform=${BUILDPLATFORM} docker.io/node:21 as web-builder
ARG GIT_BUILD_HASH
ENV GIT_BUILD_HASH=$GIT_BUILD_HASH
ENV NODE_ENV=production ENV NODE_ENV=production
WORKDIR /work/web WORKDIR /work/web
RUN --mount=type=bind,target=/work/web/package.json,src=./web/package.json \ 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/package-lock.json,src=./web/package-lock.json \
--mount=type=bind,target=/work/web/sfe/package.json,src=./web/sfe/package.json \
--mount=type=bind,target=/work/web/sfe/package-lock.json,src=./web/sfe/package-lock.json \
--mount=type=bind,target=/work/web/scripts,src=./web/scripts \
--mount=type=cache,id=npm-web,sharing=shared,target=/root/.npm \ --mount=type=cache,id=npm-web,sharing=shared,target=/root/.npm \
npm ci --include=dev && \
cd sfe && \
npm ci --include=dev npm ci --include=dev
COPY ./package.json /work
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/web/node_modules/@goauthentik/api
RUN npm run build && \ RUN npm run build
cd sfe && \
npm run build
# Stage 3: Build go proxy # Stage 3: Build go proxy
FROM --platform=${BUILDPLATFORM} mcr.microsoft.com/oss/go/microsoft/golang:1.22-fips-bookworm AS go-builder FROM --platform=${BUILDPLATFORM} docker.io/golang:1.22.2-bookworm AS go-builder
ARG TARGETOS ARG TARGETOS
ARG TARGETARCH ARG TARGETARCH
@ -59,11 +49,6 @@ ARG GOARCH=$TARGETARCH
WORKDIR /go/src/goauthentik.io WORKDIR /go/src/goauthentik.io
RUN --mount=type=cache,id=apt-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/var/cache/apt \
dpkg --add-architecture arm64 && \
apt-get update && \
apt-get install -y --no-install-recommends crossbuild-essential-arm64 gcc-aarch64-linux-gnu
RUN --mount=type=bind,target=/go/src/goauthentik.io/go.mod,src=./go.mod \ RUN --mount=type=bind,target=/go/src/goauthentik.io/go.mod,src=./go.mod \
--mount=type=bind,target=/go/src/goauthentik.io/go.sum,src=./go.sum \ --mount=type=bind,target=/go/src/goauthentik.io/go.sum,src=./go.sum \
--mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/go/pkg/mod \
@ -78,11 +63,11 @@ 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
ENV CGO_ENABLED=0
RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \ RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \
--mount=type=cache,id=go-build-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/root/.cache/go-build \ --mount=type=cache,id=go-build-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/root/.cache/go-build \
if [ "$TARGETARCH" = "arm64" ]; then export CC=aarch64-linux-gnu-gcc && export CC_FOR_TARGET=gcc-aarch64-linux-gnu; fi && \ GOARM="${TARGETVARIANT#v}" go build -o /go/authentik ./cmd/server
CGO_ENABLED=1 GOEXPERIMENT="systemcrypto" GOFLAGS="-tags=requirefips" GOARM="${TARGETVARIANT#v}" \
go build -o /go/authentik ./cmd/server
# Stage 4: MaxMind GeoIP # Stage 4: MaxMind GeoIP
FROM --platform=${BUILDPLATFORM} ghcr.io/maxmind/geoipupdate:v7.0.1 as geoip FROM --platform=${BUILDPLATFORM} ghcr.io/maxmind/geoipupdate:v7.0.1 as geoip
@ -99,7 +84,7 @@ RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \
/bin/sh -c "/usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0" /bin/sh -c "/usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0"
# Stage 5: Python dependencies # Stage 5: Python dependencies
FROM ghcr.io/goauthentik/fips-python:3.12.3-slim-bookworm-fips-full AS python-deps FROM docker.io/python:3.12.3-slim-bookworm AS python-deps
WORKDIR /ak-root/poetry WORKDIR /ak-root/poetry
@ -112,7 +97,7 @@ RUN rm -f /etc/apt/apt.conf.d/docker-clean; echo 'Binary::apt::APT::Keep-Downloa
RUN --mount=type=cache,id=apt-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/var/cache/apt \ RUN --mount=type=cache,id=apt-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/var/cache/apt \
apt-get update && \ apt-get update && \
# Required for installing pip packages # Required for installing pip packages
apt-get install -y --no-install-recommends build-essential pkg-config libpq-dev apt-get install -y --no-install-recommends build-essential pkg-config libxmlsec1-dev zlib1g-dev libpq-dev
RUN --mount=type=bind,target=./pyproject.toml,src=./pyproject.toml \ RUN --mount=type=bind,target=./pyproject.toml,src=./pyproject.toml \
--mount=type=bind,target=./poetry.lock,src=./poetry.lock \ --mount=type=bind,target=./poetry.lock,src=./poetry.lock \
@ -120,13 +105,12 @@ RUN --mount=type=bind,target=./pyproject.toml,src=./pyproject.toml \
--mount=type=cache,target=/root/.cache/pypoetry \ --mount=type=cache,target=/root/.cache/pypoetry \
python -m venv /ak-root/venv/ && \ python -m venv /ak-root/venv/ && \
bash -c "source ${VENV_PATH}/bin/activate && \ bash -c "source ${VENV_PATH}/bin/activate && \
pip3 install --upgrade pip && \ pip3 install --upgrade pip && \
pip3 install poetry && \ pip3 install poetry && \
poetry install --only=main --no-ansi --no-interaction --no-root && \ poetry install --only=main --no-ansi --no-interaction --no-root"
pip install --force-reinstall /wheels/*"
# Stage 6: Run # Stage 6: Run
FROM ghcr.io/goauthentik/fips-python:3.12.3-slim-bookworm-fips-full AS final-image FROM docker.io/python:3.12.3-slim-bookworm AS final-image
ARG GIT_BUILD_HASH ARG GIT_BUILD_HASH
ARG VERSION ARG VERSION
@ -143,7 +127,7 @@ WORKDIR /
# We cannot cache this layer otherwise we'll end up with a bigger image # We cannot cache this layer otherwise we'll end up with a bigger image
RUN apt-get update && \ RUN apt-get update && \
# Required for runtime # Required for runtime
apt-get install -y --no-install-recommends libpq5 libmaxminddb0 ca-certificates && \ apt-get install -y --no-install-recommends libpq5 openssl libxmlsec1-openssl libmaxminddb0 ca-certificates && \
# Required for bootstrap & healtcheck # Required for bootstrap & healtcheck
apt-get install -y --no-install-recommends runit && \ apt-get install -y --no-install-recommends runit && \
apt-get clean && \ apt-get clean && \
@ -179,8 +163,6 @@ ENV TMPDIR=/dev/shm/ \
VENV_PATH="/ak-root/venv" \ VENV_PATH="/ak-root/venv" \
POETRY_VIRTUALENVS_CREATE=false POETRY_VIRTUALENVS_CREATE=false
ENV GOFIPS=1
HEALTHCHECK --interval=30s --timeout=30s --start-period=60s --retries=3 CMD [ "ak", "healthcheck" ] HEALTHCHECK --interval=30s --timeout=30s --start-period=60s --retries=3 CMD [ "ak", "healthcheck" ]
ENTRYPOINT [ "dumb-init", "--", "ak" ] ENTRYPOINT [ "dumb-init", "--", "ak" ]

View File

@ -19,7 +19,6 @@ pg_name := $(shell python -m authentik.lib.config postgresql.name 2>/dev/null)
CODESPELL_ARGS = -D - -D .github/codespell-dictionary.txt \ CODESPELL_ARGS = -D - -D .github/codespell-dictionary.txt \
-I .github/codespell-words.txt \ -I .github/codespell-words.txt \
-S 'web/src/locales/**' \ -S 'web/src/locales/**' \
-S 'website/developer-docs/api/reference/**' \
authentik \ authentik \
internal \ internal \
cmd \ cmd \
@ -47,8 +46,8 @@ test-go:
go test -timeout 0 -v -race -cover ./... go test -timeout 0 -v -race -cover ./...
test-docker: ## Run all tests in a docker-compose test-docker: ## Run all tests in a docker-compose
echo "PG_PASS=$(shell openssl rand 32 | base64 -w 0)" >> .env echo "PG_PASS=$(openssl rand -base64 32)" >> .env
echo "AUTHENTIK_SECRET_KEY=$(shell openssl rand 32 | base64 -w 0)" >> .env echo "AUTHENTIK_SECRET_KEY=$(openssl rand -base64 32)" >> .env
docker compose pull -q docker compose pull -q
docker compose up --no-start docker compose up --no-start
docker compose start postgresql redis docker compose start postgresql redis
@ -60,11 +59,9 @@ test: ## Run the server tests and produce a coverage report (locally)
coverage html coverage html
coverage report coverage report
lint-fix: lint-codespell ## Lint and automatically fix errors in the python source code. Reports spelling errors. lint-fix: ## Lint and automatically fix errors in the python source code. Reports spelling errors.
black $(PY_SOURCES) black $(PY_SOURCES)
ruff check --fix $(PY_SOURCES) ruff check --fix $(PY_SOURCES)
lint-codespell: ## Reports spelling errors.
codespell -w $(CODESPELL_ARGS) codespell -w $(CODESPELL_ARGS)
lint: ## Lint the python and golang sources lint: ## Lint the python and golang sources
@ -241,7 +238,7 @@ website: website-lint-fix website-build ## Automatically fix formatting issues
website-install: website-install:
cd website && npm ci cd website && npm ci
website-lint-fix: lint-codespell website-lint-fix:
cd website && npm run prettier cd website && npm run prettier
website-build: website-build:
@ -255,7 +252,6 @@ website-watch: ## Build and watch the documentation website, updating automatic
######################### #########################
docker: ## Build a docker image of the current source tree docker: ## Build a docker image of the current source tree
mkdir -p ${GEN_API_TS}
DOCKER_BUILDKIT=1 docker build . --progress plain --tag ${DOCKER_IMAGE} DOCKER_BUILDKIT=1 docker build . --progress plain --tag ${DOCKER_IMAGE}
######################### #########################

View File

@ -18,10 +18,10 @@ Even if the issue is not a CVE, we still greatly appreciate your help in hardeni
(.x being the latest patch release for each version) (.x being the latest patch release for each version)
| Version | Supported | | Version | Supported |
| -------- | --------- | | --------- | --------- |
| 2024.4.x | ✅ | | 2023.10.x | ✅ |
| 2024.6.x | ✅ | | 2024.2.x | ✅ |
## Reporting a Vulnerability ## Reporting a Vulnerability

View File

@ -2,7 +2,7 @@
from os import environ from os import environ
__version__ = "2024.6.0" __version__ = "2024.4.1"
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH" ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"

View File

@ -2,21 +2,18 @@
import platform import platform
from datetime import datetime from datetime import datetime
from ssl import OPENSSL_VERSION
from sys import version as python_version from sys import version as python_version
from typing import TypedDict from typing import TypedDict
from cryptography.hazmat.backends.openssl.backend import backend
from django.utils.timezone import now from django.utils.timezone import now
from drf_spectacular.utils import extend_schema from drf_spectacular.utils import extend_schema
from gunicorn import version_info as gunicorn_version
from rest_framework.fields import SerializerMethodField from rest_framework.fields import SerializerMethodField
from rest_framework.request import Request from rest_framework.request import Request
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
from authentik import get_full_version
from authentik.core.api.utils import PassiveSerializer from authentik.core.api.utils import PassiveSerializer
from authentik.enterprise.license import LicenseKey
from authentik.lib.config import CONFIG from authentik.lib.config import CONFIG
from authentik.lib.utils.reflection import get_env from authentik.lib.utils.reflection import get_env
from authentik.outposts.apps import MANAGED_OUTPOST from authentik.outposts.apps import MANAGED_OUTPOST
@ -28,13 +25,11 @@ class RuntimeDict(TypedDict):
"""Runtime information""" """Runtime information"""
python_version: str python_version: str
gunicorn_version: str
environment: str environment: str
architecture: str architecture: str
platform: str platform: str
uname: str uname: str
openssl_version: str
openssl_fips_enabled: bool | None
authentik_version: str
class SystemInfoSerializer(PassiveSerializer): class SystemInfoSerializer(PassiveSerializer):
@ -69,15 +64,11 @@ class SystemInfoSerializer(PassiveSerializer):
def get_runtime(self, request: Request) -> RuntimeDict: def get_runtime(self, request: Request) -> RuntimeDict:
"""Get versions""" """Get versions"""
return { return {
"architecture": platform.machine(),
"authentik_version": get_full_version(),
"environment": get_env(),
"openssl_fips_enabled": (
backend._fips_enabled if LicenseKey.get_total().is_valid() else None
),
"openssl_version": OPENSSL_VERSION,
"platform": platform.platform(),
"python_version": python_version, "python_version": python_version,
"gunicorn_version": ".".join(str(x) for x in gunicorn_version),
"environment": get_env(),
"architecture": platform.machine(),
"platform": platform.platform(),
"uname": " ".join(platform.uname()), "uname": " ".join(platform.uname()),
} }

View File

@ -1,13 +1,13 @@
{% extends "base/skeleton.html" %} {% extends "base/skeleton.html" %}
{% load authentik_core %} {% load static %}
{% block title %} {% block title %}
API Browser - {{ brand.branding_title }} API Browser - {{ brand.branding_title }}
{% endblock %} {% endblock %}
{% block head %} {% block head %}
{% versioned_script "dist/standalone/api-browser/index-%v.js" %} <script src="{% static 'dist/standalone/api-browser/index.js' %}?version={{ version }}" type="module"></script>
<meta name="theme-color" content="#151515" media="(prefers-color-scheme: light)"> <meta name="theme-color" content="#151515" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#151515" media="(prefers-color-scheme: dark)"> <meta name="theme-color" content="#151515" media="(prefers-color-scheme: dark)">
{% endblock %} {% endblock %}

View File

@ -75,7 +75,7 @@ class BlueprintEntry:
_state: BlueprintEntryState = field(default_factory=BlueprintEntryState) _state: BlueprintEntryState = field(default_factory=BlueprintEntryState)
def __post_init__(self, *args, **kwargs) -> None: def __post_init__(self, *args, **kwargs) -> None:
self.__tag_contexts: list[YAMLTagContext] = [] self.__tag_contexts: list["YAMLTagContext"] = []
@staticmethod @staticmethod
def from_model(model: SerializerModel, *extra_identifier_names: str) -> "BlueprintEntry": def from_model(model: SerializerModel, *extra_identifier_names: str) -> "BlueprintEntry":

View File

@ -39,14 +39,6 @@ from authentik.core.models import (
) )
from authentik.enterprise.license import LicenseKey from authentik.enterprise.license import LicenseKey
from authentik.enterprise.models import LicenseUsage from authentik.enterprise.models import LicenseUsage
from authentik.enterprise.providers.google_workspace.models import (
GoogleWorkspaceProviderGroup,
GoogleWorkspaceProviderUser,
)
from authentik.enterprise.providers.microsoft_entra.models import (
MicrosoftEntraProviderGroup,
MicrosoftEntraProviderUser,
)
from authentik.enterprise.providers.rac.models import ConnectionToken from authentik.enterprise.providers.rac.models import ConnectionToken
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.models import SystemTask
@ -58,7 +50,7 @@ from authentik.outposts.models import OutpostServiceConnection
from authentik.policies.models import Policy, PolicyBindingModel from authentik.policies.models import Policy, PolicyBindingModel
from authentik.policies.reputation.models import Reputation from authentik.policies.reputation.models import Reputation
from authentik.providers.oauth2.models import AccessToken, AuthorizationCode, RefreshToken from authentik.providers.oauth2.models import AccessToken, AuthorizationCode, RefreshToken
from authentik.providers.scim.models import SCIMProviderGroup, SCIMProviderUser from authentik.providers.scim.models import SCIMGroup, SCIMUser
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.tenants.models import Tenant from authentik.tenants.models import Tenant
@ -94,11 +86,10 @@ def excluded_models() -> list[type[Model]]:
# Classes that have other dependencies # Classes that have other dependencies
AuthenticatedSession, AuthenticatedSession,
# Classes which are only internally managed # Classes which are only internally managed
# FIXME: these shouldn't need to be explicitly listed, but rather based off of a mixin
FlowToken, FlowToken,
LicenseUsage, LicenseUsage,
SCIMProviderGroup, SCIMGroup,
SCIMProviderUser, SCIMUser,
Tenant, Tenant,
SystemTask, SystemTask,
ConnectionToken, ConnectionToken,
@ -109,10 +100,6 @@ def excluded_models() -> list[type[Model]]:
WebAuthnDeviceType, WebAuthnDeviceType,
SCIMSourceUser, SCIMSourceUser,
SCIMSourceGroup, SCIMSourceGroup,
GoogleWorkspaceProviderUser,
GoogleWorkspaceProviderGroup,
MicrosoftEntraProviderUser,
MicrosoftEntraProviderGroup,
) )

View File

@ -11,20 +11,21 @@ from rest_framework.filters import OrderingFilter, SearchFilter
from rest_framework.permissions import AllowAny from rest_framework.permissions import AllowAny
from rest_framework.request import Request from rest_framework.request import Request
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.serializers import ModelSerializer
from rest_framework.validators import UniqueValidator from rest_framework.validators import UniqueValidator
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
from authentik.api.authorization import SecretKeyFilter from authentik.api.authorization import SecretKeyFilter
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
from authentik.core.api.utils import ModelSerializer, PassiveSerializer from authentik.core.api.utils import PassiveSerializer
from authentik.tenants.utils import get_current_tenant from authentik.tenants.utils import get_current_tenant
class FooterLinkSerializer(PassiveSerializer): class FooterLinkSerializer(PassiveSerializer):
"""Links returned in Config API""" """Links returned in Config API"""
href = CharField(read_only=True, allow_null=True) href = CharField(read_only=True)
name = CharField(read_only=True) name = CharField(read_only=True)

View File

@ -17,6 +17,7 @@ from rest_framework.fields import CharField, ReadOnlyField, SerializerMethodFiel
from rest_framework.parsers import MultiPartParser from rest_framework.parsers import MultiPartParser
from rest_framework.request import Request from rest_framework.request import Request
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
@ -25,7 +26,6 @@ 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
from authentik.core.api.used_by import UsedByMixin from authentik.core.api.used_by import UsedByMixin
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.events.models import EventAction
@ -103,7 +103,7 @@ class ApplicationSerializer(ModelSerializer):
class ApplicationViewSet(UsedByMixin, ModelViewSet): class ApplicationViewSet(UsedByMixin, ModelViewSet):
"""Application Viewset""" """Application Viewset"""
queryset = Application.objects.all().prefetch_related("provider").prefetch_related("policies") queryset = Application.objects.all().prefetch_related("provider")
serializer_class = ApplicationSerializer serializer_class = ApplicationSerializer
search_fields = [ search_fields = [
"name", "name",

View File

@ -8,12 +8,12 @@ from rest_framework import mixins
from rest_framework.fields import SerializerMethodField from rest_framework.fields import SerializerMethodField
from rest_framework.filters import OrderingFilter, SearchFilter from rest_framework.filters import OrderingFilter, SearchFilter
from rest_framework.request import Request from rest_framework.request import Request
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import GenericViewSet from rest_framework.viewsets import GenericViewSet
from ua_parser import user_agent_parser from ua_parser import user_agent_parser
from authentik.api.authorization import OwnerSuperuserPermissions from authentik.api.authorization import OwnerSuperuserPermissions
from authentik.core.api.used_by import UsedByMixin from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import ModelSerializer
from authentik.core.models import AuthenticatedSession from authentik.core.models import AuthenticatedSession
from authentik.events.context_processors.asn import ASN_CONTEXT_PROCESSOR, ASNDict from authentik.events.context_processors.asn import ASN_CONTEXT_PROCESSOR, ASNDict
from authentik.events.context_processors.geoip import GEOIP_CONTEXT_PROCESSOR, GeoIPDict from authentik.events.context_processors.geoip import GEOIP_CONTEXT_PROCESSOR, GeoIPDict

View File

@ -2,7 +2,6 @@
from json import loads from json import loads
from django.db.models import Prefetch
from django.http import Http404 from django.http import Http404
from django_filters.filters import CharFilter, ModelMultipleChoiceFilter from django_filters.filters import CharFilter, ModelMultipleChoiceFilter
from django_filters.filterset import FilterSet from django_filters.filterset import FilterSet
@ -17,12 +16,11 @@ from rest_framework.decorators import action
from rest_framework.fields import CharField, IntegerField, SerializerMethodField from rest_framework.fields import CharField, IntegerField, SerializerMethodField
from rest_framework.request import Request from rest_framework.request import Request
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.serializers import ListSerializer, ValidationError from rest_framework.serializers import ListSerializer, ModelSerializer, ValidationError
from rest_framework.validators import UniqueValidator
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
from authentik.core.api.used_by import UsedByMixin from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import JSONDictField, ModelSerializer, PassiveSerializer from authentik.core.api.utils import JSONDictField, PassiveSerializer
from authentik.core.models import Group, User from authentik.core.models import Group, User
from authentik.rbac.api.roles import RoleSerializer from authentik.rbac.api.roles import RoleSerializer
from authentik.rbac.decorators import permission_required from authentik.rbac.decorators import permission_required
@ -102,10 +100,7 @@ class GroupSerializer(ModelSerializer):
extra_kwargs = { extra_kwargs = {
"users": { "users": {
"default": list, "default": list,
}, }
# TODO: This field isn't unique on the database which is hard to backport
# hence we just validate the uniqueness here
"name": {"validators": [UniqueValidator(Group.objects.all())]},
} }
@ -159,24 +154,12 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
pk = IntegerField(required=True) pk = IntegerField(required=True)
queryset = Group.objects.none() queryset = Group.objects.all().select_related("parent").prefetch_related("users")
serializer_class = GroupSerializer serializer_class = GroupSerializer
search_fields = ["name", "is_superuser"] search_fields = ["name", "is_superuser"]
filterset_class = GroupFilter filterset_class = GroupFilter
ordering = ["name"] ordering = ["name"]
def get_queryset(self):
base_qs = Group.objects.all().select_related("parent").prefetch_related("roles")
if self.serializer_class(context={"request": self.request})._should_include_users:
base_qs = base_qs.prefetch_related("users")
else:
base_qs = base_qs.prefetch_related(
Prefetch("users", queryset=User.objects.all().only("id"))
)
return base_qs
@extend_schema( @extend_schema(
parameters=[ parameters=[
OpenApiParameter("include_users", bool, default=True), OpenApiParameter("include_users", bool, default=True),
@ -185,14 +168,6 @@ class GroupViewSet(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)
@extend_schema(
parameters=[
OpenApiParameter("include_users", bool, default=True),
]
)
def retrieve(self, request, *args, **kwargs):
return super().retrieve(request, *args, **kwargs)
@permission_required("authentik_core.add_user_to_group") @permission_required("authentik_core.add_user_to_group")
@extend_schema( @extend_schema(
request=UserAccountSerializer, request=UserAccountSerializer,

View File

@ -1,79 +0,0 @@
"""API Utilities"""
from drf_spectacular.utils import extend_schema
from rest_framework.decorators import action
from rest_framework.fields import (
BooleanField,
CharField,
)
from rest_framework.request import Request
from rest_framework.response import Response
from authentik.core.api.utils import PassiveSerializer
from authentik.enterprise.apps import EnterpriseConfig
from authentik.lib.utils.reflection import all_subclasses
class TypeCreateSerializer(PassiveSerializer):
"""Types of an object that can be created"""
name = CharField(required=True)
description = CharField(required=True)
component = CharField(required=True)
model_name = CharField(required=True)
icon_url = CharField(required=False)
requires_enterprise = BooleanField(default=False)
class CreatableType:
"""Class to inherit from to mark a model as creatable, even if the model itself is marked
as abstract"""
class NonCreatableType:
"""Class to inherit from to mark a model as non-creatable even if it is not abstract"""
class TypesMixin:
"""Mixin which adds an API endpoint to list all possible types that can be created"""
@extend_schema(responses={200: TypeCreateSerializer(many=True)})
@action(detail=False, pagination_class=None, filter_backends=[])
def types(self, request: Request, additional: list[dict] | None = None) -> Response:
"""Get all creatable types"""
data = []
for subclass in all_subclasses(self.queryset.model):
instance = None
if subclass._meta.abstract:
if not issubclass(subclass, CreatableType):
continue
# Circumvent the django protection for not being able to instantiate
# abstract models. We need a model instance to access .component
# and further down .icon_url
instance = subclass.__new__(subclass)
# Django re-sets abstract = False so we need to override that
instance.Meta.abstract = True
else:
if issubclass(subclass, NonCreatableType):
continue
instance = subclass()
try:
data.append(
{
"name": subclass._meta.verbose_name,
"description": subclass.__doc__,
"component": instance.component,
"model_name": subclass._meta.model_name,
"icon_url": getattr(instance, "icon_url", None),
"requires_enterprise": isinstance(
subclass._meta.app_config, EnterpriseConfig
),
}
)
except NotImplementedError:
continue
if additional:
data.extend(additional)
data = sorted(data, key=lambda x: x["name"])
return Response(TypeCreateSerializer(data, many=True).data)

View File

@ -8,23 +8,19 @@ from guardian.shortcuts import get_objects_for_user
from rest_framework import mixins from rest_framework import mixins
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied from rest_framework.exceptions import PermissionDenied
from rest_framework.fields import BooleanField, CharField, SerializerMethodField from rest_framework.fields import BooleanField, CharField
from rest_framework.relations import PrimaryKeyRelatedField
from rest_framework.request import Request from rest_framework.request import Request
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.serializers import ModelSerializer, SerializerMethodField
from rest_framework.viewsets import GenericViewSet from rest_framework.viewsets import GenericViewSet
from authentik.blueprints.api import ManagedSerializer from authentik.blueprints.api import ManagedSerializer
from authentik.core.api.object_types import TypesMixin
from authentik.core.api.used_by import UsedByMixin from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import ( from authentik.core.api.utils import MetaNameSerializer, PassiveSerializer, TypeCreateSerializer
MetaNameSerializer,
ModelSerializer,
PassiveSerializer,
)
from authentik.core.expression.evaluator import PropertyMappingEvaluator from authentik.core.expression.evaluator import PropertyMappingEvaluator
from authentik.core.models import Group, PropertyMapping, User from authentik.core.models import PropertyMapping
from authentik.events.utils import sanitize_item from authentik.events.utils import sanitize_item
from authentik.lib.utils.reflection import all_subclasses
from authentik.policies.api.exec import PolicyTestSerializer from authentik.policies.api.exec import PolicyTestSerializer
from authentik.rbac.decorators import permission_required from authentik.rbac.decorators import permission_required
@ -68,7 +64,6 @@ class PropertyMappingSerializer(ManagedSerializer, ModelSerializer, MetaNameSeri
class PropertyMappingViewSet( class PropertyMappingViewSet(
TypesMixin,
mixins.RetrieveModelMixin, mixins.RetrieveModelMixin,
mixins.DestroyModelMixin, mixins.DestroyModelMixin,
UsedByMixin, UsedByMixin,
@ -77,15 +72,7 @@ class PropertyMappingViewSet(
): ):
"""PropertyMapping Viewset""" """PropertyMapping Viewset"""
class PropertyMappingTestSerializer(PolicyTestSerializer): queryset = PropertyMapping.objects.none()
"""Test property mapping execution for a user/group with context"""
user = PrimaryKeyRelatedField(queryset=User.objects.all(), required=False, allow_null=True)
group = PrimaryKeyRelatedField(
queryset=Group.objects.all(), required=False, allow_null=True
)
queryset = PropertyMapping.objects.select_subclasses()
serializer_class = PropertyMappingSerializer serializer_class = PropertyMappingSerializer
search_fields = [ search_fields = [
"name", "name",
@ -93,9 +80,29 @@ class PropertyMappingViewSet(
filterset_fields = {"managed": ["isnull"]} filterset_fields = {"managed": ["isnull"]}
ordering = ["name"] ordering = ["name"]
def get_queryset(self): # pragma: no cover
return PropertyMapping.objects.select_subclasses()
@extend_schema(responses={200: TypeCreateSerializer(many=True)})
@action(detail=False, pagination_class=None, filter_backends=[])
def types(self, request: Request) -> Response:
"""Get all creatable property-mapping types"""
data = []
for subclass in all_subclasses(self.queryset.model):
subclass: PropertyMapping
data.append(
{
"name": subclass._meta.verbose_name,
"description": subclass.__doc__,
"component": subclass().component,
"model_name": subclass._meta.model_name,
}
)
return Response(TypeCreateSerializer(data, many=True).data)
@permission_required("authentik_core.view_propertymapping") @permission_required("authentik_core.view_propertymapping")
@extend_schema( @extend_schema(
request=PropertyMappingTestSerializer(), request=PolicyTestSerializer(),
responses={ responses={
200: PropertyMappingTestResultSerializer, 200: PropertyMappingTestResultSerializer,
400: OpenApiResponse(description="Invalid parameters"), 400: OpenApiResponse(description="Invalid parameters"),
@ -113,39 +120,29 @@ class PropertyMappingViewSet(
"""Test Property Mapping""" """Test Property Mapping"""
_mapping: PropertyMapping = self.get_object() _mapping: PropertyMapping = self.get_object()
# Use `get_subclass` to get correct class and correct `.evaluate` implementation # Use `get_subclass` to get correct class and correct `.evaluate` implementation
mapping: PropertyMapping = PropertyMapping.objects.get_subclass(pk=_mapping.pk) mapping = PropertyMapping.objects.get_subclass(pk=_mapping.pk)
# FIXME: when we separate policy mappings between ones for sources # FIXME: when we separate policy mappings between ones for sources
# and ones for providers, we need to make the user field optional for the source mapping # and ones for providers, we need to make the user field optional for the source mapping
test_params = self.PropertyMappingTestSerializer(data=request.data) test_params = PolicyTestSerializer(data=request.data)
if not test_params.is_valid(): if not test_params.is_valid():
return Response(test_params.errors, status=400) return Response(test_params.errors, status=400)
format_result = str(request.GET.get("format_result", "false")).lower() == "true" format_result = str(request.GET.get("format_result", "false")).lower() == "true"
context: dict = test_params.validated_data.get("context", {}) # User permission check, only allow mapping testing for users that are readable
context.setdefault("user", None) users = get_objects_for_user(request.user, "authentik_core.view_user").filter(
pk=test_params.validated_data["user"].pk
if user := test_params.validated_data.get("user"): )
# User permission check, only allow mapping testing for users that are readable if not users.exists():
users = get_objects_for_user(request.user, "authentik_core.view_user").filter( raise PermissionDenied()
pk=user.pk
)
if not users.exists():
raise PermissionDenied()
context["user"] = user
if group := test_params.validated_data.get("group"):
# Group permission check, only allow mapping testing for groups that are readable
groups = get_objects_for_user(request.user, "authentik_core.view_group").filter(
pk=group.pk
)
if not groups.exists():
raise PermissionDenied()
context["group"] = group
context["request"] = self.request
response_data = {"successful": True, "result": ""} response_data = {"successful": True, "result": ""}
try: try:
result = mapping.evaluate(**context) result = mapping.evaluate(
users.first(),
self.request,
**test_params.validated_data.get("context", {}),
)
response_data["result"] = dumps( response_data["result"] = dumps(
sanitize_item(result), indent=(4 if format_result else None) sanitize_item(result), indent=(4 if format_result else None)
) )

View File

@ -5,14 +5,20 @@ from django.db.models.query import Q
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django_filters.filters import BooleanFilter from django_filters.filters import BooleanFilter
from django_filters.filterset import FilterSet from django_filters.filterset import FilterSet
from drf_spectacular.utils import extend_schema
from rest_framework import mixins from rest_framework import mixins
from rest_framework.fields import ReadOnlyField, SerializerMethodField from rest_framework.decorators import action
from rest_framework.fields import ReadOnlyField
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import ModelSerializer, SerializerMethodField
from rest_framework.viewsets import GenericViewSet from rest_framework.viewsets import GenericViewSet
from authentik.core.api.object_types import TypesMixin
from authentik.core.api.used_by import UsedByMixin from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import MetaNameSerializer, ModelSerializer from authentik.core.api.utils import MetaNameSerializer, TypeCreateSerializer
from authentik.core.models import Provider from authentik.core.models import Provider
from authentik.enterprise.apps import EnterpriseConfig
from authentik.lib.utils.reflection import all_subclasses
class ProviderSerializer(ModelSerializer, MetaNameSerializer): class ProviderSerializer(ModelSerializer, MetaNameSerializer):
@ -57,12 +63,8 @@ class ProviderFilter(FilterSet):
"""Filter for providers""" """Filter for providers"""
application__isnull = BooleanFilter(method="filter_application__isnull") application__isnull = BooleanFilter(method="filter_application__isnull")
backchannel = BooleanFilter( backchannel_only = BooleanFilter(
method="filter_backchannel", method="filter_backchannel_only",
label=_(
"When not set all providers are returned. When set to true, only backchannel "
"providers are returned. When set to false, backchannel providers are excluded"
),
) )
def filter_application__isnull(self, queryset: QuerySet, name, value): def filter_application__isnull(self, queryset: QuerySet, name, value):
@ -73,14 +75,12 @@ class ProviderFilter(FilterSet):
| Q(application__isnull=value) | Q(application__isnull=value)
) )
def filter_backchannel(self, queryset: QuerySet, name, value): def filter_backchannel_only(self, queryset: QuerySet, name, value):
"""By default all providers are returned. When set to true, only backchannel providers are """Only return backchannel providers"""
returned. When set to false, backchannel providers are excluded"""
return queryset.filter(is_backchannel=value) return queryset.filter(is_backchannel=value)
class ProviderViewSet( class ProviderViewSet(
TypesMixin,
mixins.RetrieveModelMixin, mixins.RetrieveModelMixin,
mixins.DestroyModelMixin, mixins.DestroyModelMixin,
UsedByMixin, UsedByMixin,
@ -99,3 +99,31 @@ class ProviderViewSet(
def get_queryset(self): # pragma: no cover def get_queryset(self): # pragma: no cover
return Provider.objects.select_subclasses() return Provider.objects.select_subclasses()
@extend_schema(responses={200: TypeCreateSerializer(many=True)})
@action(detail=False, pagination_class=None, filter_backends=[])
def types(self, request: Request) -> Response:
"""Get all creatable provider types"""
data = []
for subclass in all_subclasses(self.queryset.model):
subclass: Provider
if subclass._meta.abstract:
continue
data.append(
{
"name": subclass._meta.verbose_name,
"description": subclass.__doc__,
"component": subclass().component,
"model_name": subclass._meta.model_name,
"requires_enterprise": isinstance(subclass._meta.app_config, EnterpriseConfig),
}
)
data.append(
{
"name": _("SAML Provider from Metadata"),
"description": _("Create a SAML Provider by importing its Metadata."),
"component": "ak-provider-saml-import-form",
"model_name": "",
}
)
return Response(TypeCreateSerializer(data, many=True).data)

View File

@ -11,14 +11,14 @@ from rest_framework.filters import OrderingFilter, SearchFilter
from rest_framework.parsers import MultiPartParser from rest_framework.parsers import MultiPartParser
from rest_framework.request import Request from rest_framework.request import Request
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import GenericViewSet from rest_framework.viewsets import GenericViewSet
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.api.authorization import OwnerFilter, OwnerSuperuserPermissions from authentik.api.authorization import OwnerFilter, OwnerSuperuserPermissions
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
from authentik.core.api.object_types import TypesMixin
from authentik.core.api.used_by import UsedByMixin from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import MetaNameSerializer, ModelSerializer from authentik.core.api.utils import MetaNameSerializer, TypeCreateSerializer
from authentik.core.models import Source, UserSourceConnection from authentik.core.models import Source, UserSourceConnection
from authentik.core.types import UserSettingSerializer from authentik.core.types import UserSettingSerializer
from authentik.lib.utils.file import ( from authentik.lib.utils.file import (
@ -27,6 +27,7 @@ from authentik.lib.utils.file import (
set_file, set_file,
set_file_url, set_file_url,
) )
from authentik.lib.utils.reflection import all_subclasses
from authentik.policies.engine import PolicyEngine from authentik.policies.engine import PolicyEngine
from authentik.rbac.decorators import permission_required from authentik.rbac.decorators import permission_required
@ -73,7 +74,6 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer):
class SourceViewSet( class SourceViewSet(
TypesMixin,
mixins.RetrieveModelMixin, mixins.RetrieveModelMixin,
mixins.DestroyModelMixin, mixins.DestroyModelMixin,
UsedByMixin, UsedByMixin,
@ -132,6 +132,30 @@ class SourceViewSet(
source: Source = self.get_object() source: Source = self.get_object()
return set_file_url(request, source, "icon") return set_file_url(request, source, "icon")
@extend_schema(responses={200: TypeCreateSerializer(many=True)})
@action(detail=False, pagination_class=None, filter_backends=[])
def types(self, request: Request) -> Response:
"""Get all creatable source types"""
data = []
for subclass in all_subclasses(self.queryset.model):
subclass: Source
component = ""
if len(subclass.__subclasses__()) > 0:
continue
if subclass._meta.abstract:
component = subclass.__bases__[0]().component
else:
component = subclass().component
data.append(
{
"name": subclass._meta.verbose_name,
"description": subclass.__doc__,
"component": component,
"model_name": subclass._meta.model_name,
}
)
return Response(TypeCreateSerializer(data, many=True).data)
@extend_schema(responses={200: UserSettingSerializer(many=True)}) @extend_schema(responses={200: UserSettingSerializer(many=True)})
@action(detail=False, pagination_class=None, filter_backends=[]) @action(detail=False, pagination_class=None, filter_backends=[])
def user_settings(self, request: Request) -> Response: def user_settings(self, request: Request) -> Response:

View File

@ -12,6 +12,7 @@ from rest_framework.fields import CharField
from rest_framework.filters import OrderingFilter, SearchFilter from rest_framework.filters import OrderingFilter, SearchFilter
from rest_framework.request import Request from rest_framework.request import Request
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
from authentik.api.authorization import OwnerSuperuserPermissions from authentik.api.authorization import OwnerSuperuserPermissions
@ -19,7 +20,7 @@ from authentik.blueprints.api import ManagedSerializer
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
from authentik.core.api.used_by import UsedByMixin from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.users import UserSerializer from authentik.core.api.users import UserSerializer
from authentik.core.api.utils import ModelSerializer, PassiveSerializer from authentik.core.api.utils import PassiveSerializer
from authentik.core.models import ( from authentik.core.models import (
USER_ATTRIBUTE_TOKEN_EXPIRING, USER_ATTRIBUTE_TOKEN_EXPIRING,
USER_ATTRIBUTE_TOKEN_MAXIMUM_LIFETIME, USER_ATTRIBUTE_TOKEN_MAXIMUM_LIFETIME,
@ -44,13 +45,6 @@ class TokenSerializer(ManagedSerializer, ModelSerializer):
if SERIALIZER_CONTEXT_BLUEPRINT in self.context: if SERIALIZER_CONTEXT_BLUEPRINT in self.context:
self.fields["key"] = CharField(required=False) self.fields["key"] = CharField(required=False)
def validate_user(self, user: User):
"""Ensure user of token cannot be changed"""
if self.instance and self.instance.user_id:
if user.pk != self.instance.user_id:
raise ValidationError("User cannot be changed")
return user
def validate(self, attrs: dict[Any, str]) -> dict[Any, str]: def validate(self, attrs: dict[Any, str]) -> dict[Any, str]:
"""Ensure only API or App password tokens are created.""" """Ensure only API or App password tokens are created."""
request: Request = self.context.get("request") request: Request = self.context.get("request")

View File

@ -39,12 +39,12 @@ def get_delete_action(manager: Manager) -> str:
"""Get the delete action from the Foreign key, falls back to cascade""" """Get the delete action from the Foreign key, falls back to cascade"""
if hasattr(manager, "field"): if hasattr(manager, "field"):
if manager.field.remote_field.on_delete.__name__ == SET_NULL.__name__: if manager.field.remote_field.on_delete.__name__ == SET_NULL.__name__:
return DeleteAction.SET_NULL.value return DeleteAction.SET_NULL.name
if manager.field.remote_field.on_delete.__name__ == SET_DEFAULT.__name__: if manager.field.remote_field.on_delete.__name__ == SET_DEFAULT.__name__:
return DeleteAction.SET_DEFAULT.value return DeleteAction.SET_DEFAULT.name
if hasattr(manager, "source_field"): if hasattr(manager, "source_field"):
return DeleteAction.CASCADE_MANY.value return DeleteAction.CASCADE_MANY.name
return DeleteAction.CASCADE.value return DeleteAction.CASCADE.name
class UsedByMixin: class UsedByMixin:

View File

@ -40,6 +40,7 @@ from rest_framework.serializers import (
BooleanField, BooleanField,
DateTimeField, DateTimeField,
ListSerializer, ListSerializer,
ModelSerializer,
PrimaryKeyRelatedField, PrimaryKeyRelatedField,
ValidationError, ValidationError,
) )
@ -51,12 +52,7 @@ 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
from authentik.core.api.utils import ( from authentik.core.api.utils import JSONDictField, LinkSerializer, PassiveSerializer
JSONDictField,
LinkSerializer,
ModelSerializer,
PassiveSerializer,
)
from authentik.core.middleware import ( from authentik.core.middleware import (
SESSION_KEY_IMPERSONATE_ORIGINAL_USER, SESSION_KEY_IMPERSONATE_ORIGINAL_USER,
SESSION_KEY_IMPERSONATE_USER, SESSION_KEY_IMPERSONATE_USER,
@ -411,11 +407,8 @@ class UserViewSet(UsedByMixin, ModelViewSet):
search_fields = ["username", "name", "is_active", "email", "uuid"] search_fields = ["username", "name", "is_active", "email", "uuid"]
filterset_class = UsersFilter filterset_class = UsersFilter
def get_queryset(self): def get_queryset(self): # pragma: no cover
base_qs = User.objects.all().exclude_anonymous() return User.objects.all().exclude_anonymous().prefetch_related("ak_groups")
if self.serializer_class(context={"request": self.request})._should_include_groups:
base_qs = base_qs.prefetch_related("ak_groups")
return base_qs
@extend_schema( @extend_schema(
parameters=[ parameters=[

View File

@ -6,19 +6,8 @@ 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
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
from rest_framework.fields import ( from rest_framework.fields import BooleanField, CharField, IntegerField, JSONField
CharField, from rest_framework.serializers import Serializer, SerializerMethodField, ValidationError
IntegerField,
JSONField,
SerializerMethodField,
)
from rest_framework.serializers import ModelSerializer as BaseModelSerializer
from rest_framework.serializers import (
Serializer,
ValidationError,
model_meta,
raise_errors_on_nested_writes,
)
def is_dict(value: Any): def is_dict(value: Any):
@ -28,39 +17,6 @@ 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 ModelSerializer(BaseModelSerializer):
def update(self, instance: Model, validated_data):
raise_errors_on_nested_writes("update", self, validated_data)
info = model_meta.get_field_info(instance)
# Simply set each attribute on the instance, and then save it.
# Note that unlike `.create()` we don't need to treat many-to-many
# relationships as being a special case. During updates we already
# have an instance pk for the relationships to be associated with.
m2m_fields = []
for attr, value in validated_data.items():
if attr in info.relations and info.relations[attr].to_many:
m2m_fields.append((attr, value))
else:
setattr(instance, attr, value)
instance.save()
# Note that many-to-many fields are set after updating instance.
# Setting m2m fields triggers signals which could potentially change
# updated instance and we do not want it to collide with .update()
for attr, value in m2m_fields:
field = getattr(instance, attr)
# We can't check for inheritance here as m2m managers are generated dynamically
if field.__class__.__name__ == "RelatedManager":
field.set(value, bulk=False)
else:
field.set(value)
return instance
class JSONDictField(JSONField): class JSONDictField(JSONField):
"""JSON Field which only allows dictionaries""" """JSON Field which only allows dictionaries"""
@ -112,6 +68,16 @@ class MetaNameSerializer(PassiveSerializer):
return f"{obj._meta.app_label}.{obj._meta.model_name}" return f"{obj._meta.app_label}.{obj._meta.model_name}"
class TypeCreateSerializer(PassiveSerializer):
"""Types of an object that can be created"""
name = CharField(required=True)
description = CharField(required=True)
component = CharField(required=True)
model_name = CharField(required=True)
requires_enterprise = BooleanField(default=False)
class CacheSerializer(PassiveSerializer): class CacheSerializer(PassiveSerializer):
"""Generic cache stats for an object""" """Generic cache stats for an object"""

View File

@ -31,9 +31,8 @@ class InbuiltBackend(ModelBackend):
# Since we can't directly pass other variables to signals, and we want to log the method # Since we can't directly pass other variables to signals, and we want to log the method
# and the token used, we assume we're running in a flow and set a variable in the context # and the token used, we assume we're running in a flow and set a variable in the context
flow_plan: FlowPlan = request.session.get(SESSION_KEY_PLAN, FlowPlan("")) flow_plan: FlowPlan = request.session.get(SESSION_KEY_PLAN, FlowPlan(""))
flow_plan.context.setdefault(PLAN_CONTEXT_METHOD, method) flow_plan.context[PLAN_CONTEXT_METHOD] = method
flow_plan.context.setdefault(PLAN_CONTEXT_METHOD_ARGS, {}) flow_plan.context[PLAN_CONTEXT_METHOD_ARGS] = cleanse_dict(sanitize_dict(kwargs))
flow_plan.context[PLAN_CONTEXT_METHOD_ARGS].update(cleanse_dict(sanitize_dict(kwargs)))
request.session[SESSION_KEY_PLAN] = flow_plan request.session[SESSION_KEY_PLAN] = flow_plan

View File

@ -0,0 +1,7 @@
"""authentik core exceptions"""
from authentik.lib.sentry import SentryIgnoredException
class PropertyMappingExpressionException(SentryIgnoredException):
"""Error when a PropertyMapping Exception expression could not be parsed or evaluated."""

View File

@ -1,13 +1,11 @@
"""Property Mapping Evaluator""" """Property Mapping Evaluator"""
from types import CodeType
from typing import Any from typing import Any
from django.db.models import Model from django.db.models import Model
from django.http import HttpRequest from django.http import HttpRequest
from prometheus_client import Histogram from prometheus_client import Histogram
from authentik.core.expression.exceptions import SkipObjectException
from authentik.core.models import User from authentik.core.models import User
from authentik.events.models import Event, EventAction from authentik.events.models import Event, EventAction
from authentik.lib.expression.evaluator import BaseEvaluator from authentik.lib.expression.evaluator import BaseEvaluator
@ -25,8 +23,6 @@ class PropertyMappingEvaluator(BaseEvaluator):
"""Custom Evaluator that adds some different context variables.""" """Custom Evaluator that adds some different context variables."""
dry_run: bool dry_run: bool
model: Model
_compiled: CodeType | None = None
def __init__( def __init__(
self, self,
@ -36,32 +32,22 @@ class PropertyMappingEvaluator(BaseEvaluator):
dry_run: bool | None = False, dry_run: bool | None = False,
**kwargs, **kwargs,
): ):
self.model = model
if hasattr(model, "name"): if hasattr(model, "name"):
_filename = model.name _filename = model.name
else: else:
_filename = str(model) _filename = str(model)
super().__init__(filename=_filename) super().__init__(filename=_filename)
self.dry_run = dry_run
self.set_context(user, request, **kwargs)
def set_context(
self,
user: User | None = None,
request: HttpRequest | None = None,
**kwargs,
):
req = PolicyRequest(user=User()) req = PolicyRequest(user=User())
req.obj = self.model req.obj = model
if user: if user:
req.user = user req.user = user
self._context["user"] = user self._context["user"] = user
if request: if request:
req.http_request = request req.http_request = request
req.context.update(**kwargs)
self._context["request"] = req self._context["request"] = req
req.context.update(**kwargs)
self._context.update(**kwargs) self._context.update(**kwargs)
self._globals["SkipObject"] = SkipObjectException self.dry_run = dry_run
def handle_error(self, exc: Exception, expression_source: str): def handle_error(self, exc: Exception, expression_source: str):
"""Exception Handler""" """Exception Handler"""
@ -76,19 +62,10 @@ class PropertyMappingEvaluator(BaseEvaluator):
) )
if "request" in self._context: if "request" in self._context:
req: PolicyRequest = self._context["request"] req: PolicyRequest = self._context["request"]
if req.http_request: event.from_http(req.http_request, req.user)
event.from_http(req.http_request, req.user) return
return
elif req.user:
event.set_user(req.user)
event.save() event.save()
def evaluate(self, *args, **kwargs) -> Any: def evaluate(self, *args, **kwargs) -> Any:
with PROPERTY_MAPPING_TIME.labels(mapping_name=self._filename).time(): with PROPERTY_MAPPING_TIME.labels(mapping_name=self._filename).time():
return super().evaluate(*args, **kwargs) return super().evaluate(*args, **kwargs)
def compile(self, expression: str | None = None) -> Any:
if not self._compiled:
compiled = super().compile(expression or self.model.expression)
self._compiled = compiled
return self._compiled

View File

@ -1,19 +0,0 @@
"""authentik core exceptions"""
from authentik.lib.expression.exceptions import ControlFlowException
from authentik.lib.sentry import SentryIgnoredException
class PropertyMappingExpressionException(SentryIgnoredException):
"""Error when a PropertyMapping Exception expression could not be parsed or evaluated."""
def __init__(self, exc: Exception, mapping) -> None:
super().__init__()
self.exc = exc
self.mapping = mapping
class SkipObjectException(ControlFlowException):
"""Exception which can be raised in a property mapping to skip syncing an object.
Only applies to Property mappings which sync objects, and not on mappings which transitively
apply to a single user"""

View File

@ -7,10 +7,9 @@ from django.db.backends.base.schema import BaseDatabaseSchemaEditor
def backport_is_backchannel(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): def backport_is_backchannel(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
from authentik.providers.ldap.models import LDAPProvider from authentik.core.models import BackchannelProvider
from authentik.providers.scim.models import SCIMProvider
for model in [LDAPProvider, SCIMProvider]: for model in BackchannelProvider.__subclasses__():
try: try:
for obj in model.objects.only("is_backchannel"): for obj in model.objects.only("is_backchannel"):
obj.is_backchannel = True obj.is_backchannel = True

View File

@ -15,7 +15,6 @@ 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 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
@ -23,10 +22,9 @@ from rest_framework.serializers import Serializer
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.blueprints.models import ManagedModel from authentik.blueprints.models import ManagedModel
from authentik.core.expression.exceptions import PropertyMappingExpressionException from authentik.core.exceptions import PropertyMappingExpressionException
from authentik.core.types import UILoginButton, UserSettingSerializer from authentik.core.types import UILoginButton, UserSettingSerializer
from authentik.lib.avatars import get_avatar from authentik.lib.avatars import get_avatar
from authentik.lib.expression.exceptions import ControlFlowException
from authentik.lib.generators import generate_id from authentik.lib.generators import generate_id
from authentik.lib.models import ( from authentik.lib.models import (
CreatedUpdatedModel, CreatedUpdatedModel,
@ -58,8 +56,6 @@ options.DEFAULT_NAMES = options.DEFAULT_NAMES + (
"authentik_used_by_shadows", "authentik_used_by_shadows",
) )
GROUP_RECURSION_LIMIT = 20
def default_token_duration() -> datetime: def default_token_duration() -> datetime:
"""Default duration a Token is valid""" """Default duration a Token is valid"""
@ -100,40 +96,6 @@ class UserTypes(models.TextChoices):
INTERNAL_SERVICE_ACCOUNT = "internal_service_account" INTERNAL_SERVICE_ACCOUNT = "internal_service_account"
class GroupQuerySet(CTEQuerySet):
def with_children_recursive(self):
"""Recursively get all groups that have the current queryset as parents
or are indirectly related."""
def make_cte(cte):
"""Build the query that ends up in WITH RECURSIVE"""
# Start from self, aka the current query
# Add a depth attribute to limit the recursion
return self.annotate(
relative_depth=models.Value(0, output_field=models.IntegerField())
).union(
# Here is the recursive part of the query. cte refers to the previous iteration
# Only select groups for which the parent is part of the previous iteration
# and increase the depth
# Finally, limit the depth
cte.join(Group, group_uuid=cte.col.parent_id)
.annotate(
relative_depth=models.ExpressionWrapper(
cte.col.relative_depth
+ models.Value(1, output_field=models.IntegerField()),
output_field=models.IntegerField(),
)
)
.filter(relative_depth__lt=GROUP_RECURSION_LIMIT),
all=True,
)
# Build the recursive query, see above
cte = With.recursive(make_cte)
# Return the result, as a usable queryset for Group.
return cte.join(Group, group_uuid=cte.col.group_uuid).with_cte(cte)
class Group(SerializerModel): class Group(SerializerModel):
"""Group model which supports a basic hierarchy and has attributes""" """Group model which supports a basic hierarchy and has attributes"""
@ -156,8 +118,6 @@ class Group(SerializerModel):
) )
attributes = models.JSONField(default=dict, blank=True) attributes = models.JSONField(default=dict, blank=True)
objects = GroupQuerySet.as_manager()
@property @property
def serializer(self) -> Serializer: def serializer(self) -> Serializer:
from authentik.core.api.groups import GroupSerializer from authentik.core.api.groups import GroupSerializer
@ -176,11 +136,36 @@ class Group(SerializerModel):
return user.all_groups().filter(group_uuid=self.group_uuid).exists() return user.all_groups().filter(group_uuid=self.group_uuid).exists()
def children_recursive(self: Self | QuerySet["Group"]) -> QuerySet["Group"]: def children_recursive(self: Self | QuerySet["Group"]) -> QuerySet["Group"]:
"""Compatibility layer for Group.objects.with_children_recursive()""" """Recursively get all groups that have this as parent or are indirectly related"""
qs = self direct_groups = []
if not isinstance(self, QuerySet): if isinstance(self, QuerySet):
qs = Group.objects.filter(group_uuid=self.group_uuid) direct_groups = list(x for x in self.all().values_list("pk", flat=True).iterator())
return qs.with_children_recursive() else:
direct_groups = [self.pk]
if len(direct_groups) < 1:
return Group.objects.none()
query = """
WITH RECURSIVE parents AS (
SELECT authentik_core_group.*, 0 AS relative_depth
FROM authentik_core_group
WHERE authentik_core_group.group_uuid = ANY(%s)
UNION ALL
SELECT authentik_core_group.*, parents.relative_depth + 1
FROM authentik_core_group, parents
WHERE (
authentik_core_group.group_uuid = parents.parent_id and
parents.relative_depth < 20
)
)
SELECT group_uuid
FROM parents
GROUP BY group_uuid, name
ORDER BY name;
"""
group_pks = [group.pk for group in Group.objects.raw(query, [direct_groups]).iterator()]
return Group.objects.filter(pk__in=group_pks)
def __str__(self): def __str__(self):
return f"Group {self.name}" return f"Group {self.name}"
@ -247,8 +232,10 @@ class User(SerializerModel, GuardianUserMixin, AbstractUser):
return User._meta.get_field("path").default return User._meta.get_field("path").default
def all_groups(self) -> QuerySet[Group]: def all_groups(self) -> QuerySet[Group]:
"""Recursively get all groups this user is a member of.""" """Recursively get all groups this user is a member of.
return self.ak_groups.all().with_children_recursive() At least one query is done to get the direct groups of the user, with groups
there are at most 3 queries done"""
return Group.children_recursive(self.ak_groups.all())
def group_attributes(self, request: HttpRequest | None = None) -> dict[str, Any]: def group_attributes(self, request: HttpRequest | None = None) -> dict[str, Any]:
"""Get a dictionary containing the attributes from all groups the user belongs to, """Get a dictionary containing the attributes from all groups the user belongs to,
@ -390,10 +377,6 @@ class Provider(SerializerModel):
Can return None for providers that are not URL-based""" Can return None for providers that are not URL-based"""
return None return None
@property
def icon_url(self) -> str | None:
return None
@property @property
def component(self) -> str: def component(self) -> str:
"""Return component used to edit this object""" """Return component used to edit this object"""
@ -649,7 +632,7 @@ class UserSourceConnection(SerializerModel, CreatedUpdatedModel):
raise NotImplementedError raise NotImplementedError
def __str__(self) -> str: def __str__(self) -> str:
return f"User-source connection (user={self.user_id}, source={self.source_id})" return f"User-source connection (user={self.user.username}, source={self.source.slug})"
class Meta: class Meta:
unique_together = (("user", "source"),) unique_together = (("user", "source"),)
@ -784,10 +767,8 @@ class PropertyMapping(SerializerModel, ManagedModel):
evaluator = PropertyMappingEvaluator(self, user, request, **kwargs) evaluator = PropertyMappingEvaluator(self, user, request, **kwargs)
try: try:
return evaluator.evaluate(self.expression) return evaluator.evaluate(self.expression)
except ControlFlowException as exc:
raise exc
except Exception as exc: except Exception as exc:
raise PropertyMappingExpressionException(self, exc) from exc raise PropertyMappingExpressionException(exc) from exc
def __str__(self): def __str__(self):
return f"Property Mapping {self.name}" return f"Property Mapping {self.name}"

View File

@ -13,7 +13,7 @@ from django.utils.translation import gettext as _
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.core.models import Source, SourceUserMatchingModes, User, UserSourceConnection from authentik.core.models import Source, SourceUserMatchingModes, User, UserSourceConnection
from authentik.core.sources.stage import PLAN_CONTEXT_SOURCES_CONNECTION, PostSourceStage from authentik.core.sources.stage import PLAN_CONTEXT_SOURCES_CONNECTION, PostUserEnrollmentStage
from authentik.events.models import Event, EventAction from authentik.events.models import Event, EventAction
from authentik.flows.exceptions import FlowNonApplicableException from authentik.flows.exceptions import FlowNonApplicableException
from authentik.flows.models import Flow, FlowToken, Stage, in_memory_stage from authentik.flows.models import Flow, FlowToken, Stage, in_memory_stage
@ -100,6 +100,8 @@ class SourceFlowManager:
if self.request.user.is_authenticated: if self.request.user.is_authenticated:
new_connection.user = self.request.user new_connection.user = self.request.user
new_connection = self.update_connection(new_connection, **kwargs) new_connection = self.update_connection(new_connection, **kwargs)
new_connection.save()
return Action.LINK, new_connection return Action.LINK, new_connection
existing_connections = self.connection_type.objects.filter( existing_connections = self.connection_type.objects.filter(
@ -146,6 +148,7 @@ class SourceFlowManager:
]: ]:
new_connection.user = user new_connection.user = user
new_connection = self.update_connection(new_connection, **kwargs) new_connection = self.update_connection(new_connection, **kwargs)
new_connection.save()
return Action.LINK, new_connection return Action.LINK, new_connection
if self.source.user_matching_mode in [ if self.source.user_matching_mode in [
SourceUserMatchingModes.EMAIL_DENY, SourceUserMatchingModes.EMAIL_DENY,
@ -206,9 +209,13 @@ class SourceFlowManager:
def get_stages_to_append(self, flow: Flow) -> list[Stage]: def get_stages_to_append(self, flow: Flow) -> list[Stage]:
"""Hook to override stages which are appended to the flow""" """Hook to override stages which are appended to the flow"""
return [ if not self.source.enrollment_flow:
in_memory_stage(PostSourceStage), return []
] if flow.slug == self.source.enrollment_flow.slug:
return [
in_memory_stage(PostUserEnrollmentStage),
]
return []
def _prepare_flow( def _prepare_flow(
self, self,
@ -262,9 +269,6 @@ class SourceFlowManager:
) )
# We run the Flow planner here so we can pass the Pending user in the context # We run the Flow planner here so we can pass the Pending user in the context
planner = FlowPlanner(flow) planner = FlowPlanner(flow)
# We append some stages so the initial flow we get might be empty
planner.allow_empty_flows = True
planner.use_cache = False
plan = planner.plan(self.request, kwargs) plan = planner.plan(self.request, kwargs)
for stage in self.get_stages_to_append(flow): for stage in self.get_stages_to_append(flow):
plan.append_stage(stage) plan.append_stage(stage)
@ -309,7 +313,7 @@ class SourceFlowManager:
# When request isn't authenticated we jump straight to auth # When request isn't authenticated we jump straight to auth
if not self.request.user.is_authenticated: if not self.request.user.is_authenticated:
return self.handle_auth(connection) return self.handle_auth(connection)
connection.save() # Connection has already been saved
Event.new( Event.new(
EventAction.SOURCE_LINKED, EventAction.SOURCE_LINKED,
message="Linked Source", message="Linked Source",
@ -323,7 +327,7 @@ class SourceFlowManager:
reverse( reverse(
"authentik_core:if-user", "authentik_core:if-user",
) )
+ "#/settings;page-sources" + f"#/settings;page-{self.source.slug}"
) )
def handle_enroll( def handle_enroll(

View File

@ -10,7 +10,7 @@ from authentik.flows.stage import StageView
PLAN_CONTEXT_SOURCES_CONNECTION = "goauthentik.io/sources/connection" PLAN_CONTEXT_SOURCES_CONNECTION = "goauthentik.io/sources/connection"
class PostSourceStage(StageView): class PostUserEnrollmentStage(StageView):
"""Dynamically injected stage which saves the Connection after """Dynamically injected stage which saves the Connection after
the user has been enrolled.""" the user has been enrolled."""
@ -21,12 +21,10 @@ class PostSourceStage(StageView):
] ]
user: User = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] user: User = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER]
connection.user = user connection.user = user
linked = connection.pk is None
connection.save() connection.save()
if linked: Event.new(
Event.new( EventAction.SOURCE_LINKED,
EventAction.SOURCE_LINKED, message="Linked Source",
message="Linked Source", source=connection.source,
source=connection.source, ).from_http(self.request)
).from_http(self.request)
return self.executor.stage_ok() return self.executor.stage_ok()

View File

@ -2,9 +2,7 @@
from datetime import datetime, timedelta from datetime import datetime, timedelta
from django.conf import ImproperlyConfigured
from django.contrib.sessions.backends.cache import KEY_PREFIX from django.contrib.sessions.backends.cache import KEY_PREFIX
from django.contrib.sessions.backends.db import SessionStore as DBSessionStore
from django.core.cache import cache from django.core.cache import cache
from django.utils.timezone import now from django.utils.timezone import now
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
@ -17,7 +15,6 @@ from authentik.core.models import (
User, User,
) )
from authentik.events.system_tasks import SystemTask, TaskStatus, prefill_task from authentik.events.system_tasks import SystemTask, TaskStatus, prefill_task
from authentik.lib.config import CONFIG
from authentik.root.celery import CELERY_APP from authentik.root.celery import CELERY_APP
LOGGER = get_logger() LOGGER = get_logger()
@ -42,31 +39,16 @@ def clean_expired_models(self: SystemTask):
amount = 0 amount = 0
for session in AuthenticatedSession.objects.all(): for session in AuthenticatedSession.objects.all():
match CONFIG.get("session_storage", "cache"): cache_key = f"{KEY_PREFIX}{session.session_key}"
case "cache": value = None
cache_key = f"{KEY_PREFIX}{session.session_key}" try:
value = None value = cache.get(cache_key)
try:
value = cache.get(cache_key)
except Exception as exc: except Exception as exc:
LOGGER.debug("Failed to get session from cache", exc=exc) LOGGER.debug("Failed to get session from cache", exc=exc)
if not value: if not value:
session.delete() session.delete()
amount += 1 amount += 1
case "db":
if not (
DBSessionStore.get_model_class()
.objects.filter(session_key=session.session_key, expire_date__gt=now())
.exists()
):
session.delete()
amount += 1
case _:
# Should never happen, as we check for other values in authentik/root/settings.py
raise ImproperlyConfigured(
"Invalid session_storage setting, allowed values are db and cache"
)
LOGGER.debug("Expired sessions", model=AuthenticatedSession, amount=amount) LOGGER.debug("Expired sessions", model=AuthenticatedSession, amount=amount)
messages.append(f"Expired {amount} {AuthenticatedSession._meta.verbose_name_plural}") messages.append(f"Expired {amount} {AuthenticatedSession._meta.verbose_name_plural}")

View File

@ -10,7 +10,7 @@
versionSubdomain: "{{ version_subdomain }}", versionSubdomain: "{{ version_subdomain }}",
build: "{{ build }}", build: "{{ build }}",
}; };
window.addEventListener("DOMContentLoaded", function () { window.addEventListener("DOMContentLoaded", () => {
{% for message in messages %} {% for message in messages %}
window.dispatchEvent( window.dispatchEvent(
new CustomEvent("ak-message", { new CustomEvent("ak-message", {

View File

@ -1,6 +1,5 @@
{% load static %} {% load static %}
{% load i18n %} {% load i18n %}
{% load authentik_core %}
<!DOCTYPE html> <!DOCTYPE html>
@ -15,8 +14,8 @@
{% endblock %} {% endblock %}
<link rel="stylesheet" type="text/css" href="{% static 'dist/authentik.css' %}"> <link rel="stylesheet" type="text/css" href="{% static 'dist/authentik.css' %}">
<link rel="stylesheet" type="text/css" href="{% static 'dist/custom.css' %}" data-inject> <link rel="stylesheet" type="text/css" href="{% static 'dist/custom.css' %}" data-inject>
{% versioned_script "dist/poly-%v.js" %} <script src="{% static 'dist/poly.js' %}?version={{ version }}" type="module"></script>
{% versioned_script "dist/standalone/loading/index-%v.js" %} <script src="{% static 'dist/standalone/loading/index.js' %}?version={{ version }}" type="module"></script>
{% block head %} {% block head %}
{% endblock %} {% endblock %}
<meta name="sentry-trace" content="{{ sentry_trace }}" /> <meta name="sentry-trace" content="{{ sentry_trace }}" />

View File

@ -1,9 +1,9 @@
{% extends "base/skeleton.html" %} {% extends "base/skeleton.html" %}
{% load authentik_core %} {% load static %}
{% block head %} {% block head %}
{% versioned_script "dist/admin/AdminInterface-%v.js" %} <script src="{% static 'dist/admin/AdminInterface.js' %}?version={{ version }}" type="module"></script>
<meta name="theme-color" content="#18191a" media="(prefers-color-scheme: dark)"> <meta name="theme-color" content="#18191a" media="(prefers-color-scheme: dark)">
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)"> <meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
{% include "base/header_js.html" %} {% include "base/header_js.html" %}

View File

@ -1,7 +1,6 @@
{% extends "base/skeleton.html" %} {% extends "base/skeleton.html" %}
{% load static %} {% load static %}
{% load authentik_core %}
{% block head_before %} {% block head_before %}
{{ block.super }} {{ block.super }}
@ -18,7 +17,7 @@ window.authentik.flow = {
{% endblock %} {% endblock %}
{% block head %} {% block head %}
{% versioned_script "dist/flow/FlowInterface-%v.js" %} <script src="{% static 'dist/flow/FlowInterface.js' %}?version={{ version }}" type="module"></script>
<style> <style>
:root { :root {
--ak-flow-background: url("{{ flow.background_url }}"); --ak-flow-background: url("{{ flow.background_url }}");

View File

@ -1,9 +1,9 @@
{% extends "base/skeleton.html" %} {% extends "base/skeleton.html" %}
{% load authentik_core %} {% load static %}
{% block head %} {% block head %}
{% versioned_script "dist/user/UserInterface-%v.js" %} <script src="{% static 'dist/user/UserInterface.js' %}?version={{ version }}" type="module"></script>
<meta name="theme-color" content="#1c1e21" media="(prefers-color-scheme: light)"> <meta name="theme-color" content="#1c1e21" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#1c1e21" media="(prefers-color-scheme: dark)"> <meta name="theme-color" content="#1c1e21" media="(prefers-color-scheme: dark)">
{% include "base/header_js.html" %} {% include "base/header_js.html" %}

View File

@ -71,9 +71,9 @@
</li> </li>
{% endfor %} {% endfor %}
<li> <li>
<span> <a href="https://goauthentik.io?utm_source=authentik">
{% trans 'Powered by authentik' %} {% trans 'Powered by authentik' %}
</span> </a>
</li> </li>
</ul> </ul>
</footer> </footer>

View File

@ -1,21 +0,0 @@
"""authentik core tags"""
from django import template
from django.templatetags.static import static as static_loader
from django.utils.safestring import mark_safe
from authentik import get_full_version
register = template.Library()
@register.simple_tag()
def versioned_script(path: str) -> str:
"""Wrapper around {% static %} tag that supports setting the version"""
returned_lines = [
(
f'<script src="{static_loader(path.replace("%v", get_full_version()))}'
'" type="module"></script>'
),
]
return mark_safe("".join(returned_lines)) # nosec

View File

@ -5,7 +5,7 @@ from guardian.shortcuts import assign_perm
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
from authentik.core.models import Group, User from authentik.core.models import Group, User
from authentik.core.tests.utils import create_test_admin_user, create_test_user from authentik.core.tests.utils import create_test_user
from authentik.lib.generators import generate_id from authentik.lib.generators import generate_id
@ -16,24 +16,6 @@ class TestGroupsAPI(APITestCase):
self.login_user = create_test_user() self.login_user = create_test_user()
self.user = User.objects.create(username="test-user") self.user = User.objects.create(username="test-user")
def test_list_with_users(self):
"""Test listing with users"""
admin = create_test_admin_user()
self.client.force_login(admin)
response = self.client.get(reverse("authentik_api:group-list"), {"include_users": "true"})
self.assertEqual(response.status_code, 200)
def test_retrieve_with_users(self):
"""Test retrieve with users"""
admin = create_test_admin_user()
group = Group.objects.create(name=generate_id())
self.client.force_login(admin)
response = self.client.get(
reverse("authentik_api:group-detail", kwargs={"pk": group.pk}),
{"include_users": "true"},
)
self.assertEqual(response.status_code, 200)
def test_add_user(self): def test_add_user(self):
"""Test add_user""" """Test add_user"""
group = Group.objects.create(name=generate_id()) group = Group.objects.create(name=generate_id())

View File

@ -1,14 +1,14 @@
"""authentik core models tests""" """authentik core models tests"""
from collections.abc import Callable from collections.abc import Callable
from datetime import timedelta from time import sleep
from django.test import RequestFactory, TestCase from django.test import RequestFactory, TestCase
from django.utils.timezone import now from django.utils.timezone import now
from freezegun import freeze_time
from guardian.shortcuts import get_anonymous_user from guardian.shortcuts import get_anonymous_user
from authentik.core.models import Provider, Source, Token from authentik.core.models import Provider, Source, Token
from authentik.flows.models import Stage
from authentik.lib.utils.reflection import all_subclasses from authentik.lib.utils.reflection import all_subclasses
@ -17,20 +17,18 @@ class TestModels(TestCase):
def test_token_expire(self): def test_token_expire(self):
"""Test token expiring""" """Test token expiring"""
with freeze_time() as freeze: token = Token.objects.create(expires=now(), user=get_anonymous_user())
token = Token.objects.create(expires=now(), user=get_anonymous_user()) sleep(0.5)
freeze.tick(timedelta(seconds=1)) self.assertTrue(token.is_expired)
self.assertTrue(token.is_expired)
def test_token_expire_no_expire(self): def test_token_expire_no_expire(self):
"""Test token expiring with "expiring" set""" """Test token expiring with "expiring" set"""
with freeze_time() as freeze: token = Token.objects.create(expires=now(), user=get_anonymous_user(), expiring=False)
token = Token.objects.create(expires=now(), user=get_anonymous_user(), expiring=False) sleep(0.5)
freeze.tick(timedelta(seconds=1)) self.assertFalse(token.is_expired)
self.assertFalse(token.is_expired)
def source_tester_factory(test_model: type[Source]) -> Callable: def source_tester_factory(test_model: type[Stage]) -> Callable:
"""Test source""" """Test source"""
factory = RequestFactory() factory = RequestFactory()
@ -38,19 +36,19 @@ def source_tester_factory(test_model: type[Source]) -> Callable:
def tester(self: TestModels): def tester(self: TestModels):
model_class = None model_class = None
if test_model._meta.abstract: if test_model._meta.abstract: # pragma: no cover
model_class = [x for x in test_model.__bases__ if issubclass(x, Source)][0]() model_class = test_model.__bases__[0]()
else: else:
model_class = test_model() model_class = test_model()
model_class.slug = "test" model_class.slug = "test"
self.assertIsNotNone(model_class.component) self.assertIsNotNone(model_class.component)
model_class.ui_login_button(request) _ = model_class.ui_login_button(request)
model_class.ui_user_settings() _ = model_class.ui_user_settings()
return tester return tester
def provider_tester_factory(test_model: type[Provider]) -> Callable: def provider_tester_factory(test_model: type[Stage]) -> Callable:
"""Test provider""" """Test provider"""
def tester(self: TestModels): def tester(self: TestModels):

View File

@ -3,10 +3,7 @@
from django.test import RequestFactory, TestCase from django.test import RequestFactory, TestCase
from guardian.shortcuts import get_anonymous_user from guardian.shortcuts import get_anonymous_user
from authentik.core.expression.exceptions import ( from authentik.core.exceptions import PropertyMappingExpressionException
PropertyMappingExpressionException,
SkipObjectException,
)
from authentik.core.models import PropertyMapping from authentik.core.models import PropertyMapping
from authentik.core.tests.utils import create_test_admin_user from authentik.core.tests.utils import create_test_admin_user
from authentik.events.models import Event, EventAction from authentik.events.models import Event, EventAction
@ -45,17 +42,6 @@ class TestPropertyMappings(TestCase):
self.assertTrue(events.exists()) self.assertTrue(events.exists())
self.assertEqual(len(events), 1) self.assertEqual(len(events), 1)
def test_expression_skip(self):
"""Test expression error"""
expr = "raise SkipObject"
mapping = PropertyMapping.objects.create(name=generate_id(), expression=expr)
with self.assertRaises(SkipObjectException):
mapping.evaluate(None, None)
events = Event.objects.filter(
action=EventAction.PROPERTY_MAPPING_EXCEPTION, context__expression=expr
)
self.assertFalse(events.exists())
def test_expression_error_extended(self): def test_expression_error_extended(self):
"""Test expression error (with user and http request""" """Test expression error (with user and http request"""
expr = "return aaa" expr = "return aaa"
@ -80,11 +66,14 @@ class TestPropertyMappings(TestCase):
expression="return request.http_request.path", expression="return request.http_request.path",
) )
http_request = self.factory.get("/") http_request = self.factory.get("/")
tmpl = f""" tmpl = (
res = ak_call_policy('{expr.name}') """
res = ak_call_policy('%s')
result = [request.http_request.path, res.raw_result] result = [request.http_request.path, res.raw_result]
return result return result
""" """
% expr.name
)
evaluator = PropertyMapping(expression=tmpl, name=generate_id()) evaluator = PropertyMapping(expression=tmpl, name=generate_id())
res = evaluator.evaluate(self.user, http_request) res = evaluator.evaluate(self.user, http_request)
self.assertEqual(res, ["/", "/"]) self.assertEqual(res, ["/", "/"])

View File

@ -6,10 +6,9 @@ from django.urls import reverse
from rest_framework.serializers import ValidationError from rest_framework.serializers import ValidationError
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
from authentik.core.api.property_mappings import PropertyMappingSerializer from authentik.core.api.propertymappings import PropertyMappingSerializer
from authentik.core.models import Group, PropertyMapping from authentik.core.models import PropertyMapping
from authentik.core.tests.utils import create_test_admin_user from authentik.core.tests.utils import create_test_admin_user
from authentik.lib.generators import generate_id
class TestPropertyMappingAPI(APITestCase): class TestPropertyMappingAPI(APITestCase):
@ -17,40 +16,23 @@ class TestPropertyMappingAPI(APITestCase):
def setUp(self) -> None: def setUp(self) -> None:
super().setUp() super().setUp()
self.mapping = PropertyMapping.objects.create(
name="dummy", expression="""return {'foo': 'bar'}"""
)
self.user = create_test_admin_user() self.user = create_test_admin_user()
self.client.force_login(self.user) self.client.force_login(self.user)
def test_test_call(self): def test_test_call(self):
"""Test PropertyMappings's test endpoint""" """Test PropertMappings's test endpoint"""
mapping = PropertyMapping.objects.create(
name="dummy", expression="""return {'foo': 'bar', 'baz': user.username}"""
)
response = self.client.post( response = self.client.post(
reverse("authentik_api:propertymapping-test", kwargs={"pk": mapping.pk}), reverse("authentik_api:propertymapping-test", kwargs={"pk": self.mapping.pk}),
data={ data={
"user": self.user.pk, "user": self.user.pk,
}, },
) )
self.assertJSONEqual( self.assertJSONEqual(
response.content.decode(), response.content.decode(),
{"result": dumps({"foo": "bar", "baz": self.user.username}), "successful": True}, {"result": dumps({"foo": "bar"}), "successful": True},
)
def test_test_call_group(self):
"""Test PropertyMappings's test endpoint"""
mapping = PropertyMapping.objects.create(
name="dummy", expression="""return {'foo': 'bar', 'baz': group.name}"""
)
group = Group.objects.create(name=generate_id())
response = self.client.post(
reverse("authentik_api:propertymapping-test", kwargs={"pk": mapping.pk}),
data={
"group": group.pk,
},
)
self.assertJSONEqual(
response.content.decode(),
{"result": dumps({"foo": "bar", "baz": group.name}), "successful": True},
) )
def test_validate(self): def test_validate(self):

View File

@ -2,15 +2,11 @@
from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import AnonymousUser
from django.test import TestCase from django.test import TestCase
from django.urls import reverse
from guardian.utils import get_anonymous_user from guardian.utils import get_anonymous_user
from authentik.core.models import SourceUserMatchingModes, User from authentik.core.models import SourceUserMatchingModes, User
from authentik.core.sources.flow_manager import Action from authentik.core.sources.flow_manager import Action
from authentik.core.sources.stage import PostSourceStage
from authentik.core.tests.utils import create_test_flow from authentik.core.tests.utils import create_test_flow
from authentik.flows.planner import FlowPlan
from authentik.flows.views.executor import SESSION_KEY_PLAN
from authentik.lib.generators import generate_id from authentik.lib.generators import generate_id
from authentik.lib.tests.utils import get_request from authentik.lib.tests.utils import get_request
from authentik.policies.denied import AccessDeniedResponse from authentik.policies.denied import AccessDeniedResponse
@ -25,62 +21,42 @@ class TestSourceFlowManager(TestCase):
def setUp(self) -> None: def setUp(self) -> None:
super().setUp() super().setUp()
self.authentication_flow = create_test_flow() self.source: OAuthSource = OAuthSource.objects.create(name="test")
self.enrollment_flow = create_test_flow()
self.source: OAuthSource = OAuthSource.objects.create(
name=generate_id(),
slug=generate_id(),
authentication_flow=self.authentication_flow,
enrollment_flow=self.enrollment_flow,
)
self.identifier = generate_id() self.identifier = generate_id()
def test_unauthenticated_enroll(self): def test_unauthenticated_enroll(self):
"""Test un-authenticated user enrolling""" """Test un-authenticated user enrolling"""
request = get_request("/", user=AnonymousUser()) flow_manager = OAuthSourceFlowManager(
flow_manager = OAuthSourceFlowManager(self.source, request, self.identifier, {}) self.source, get_request("/", user=AnonymousUser()), self.identifier, {}
)
action, _ = flow_manager.get_action() action, _ = flow_manager.get_action()
self.assertEqual(action, Action.ENROLL) self.assertEqual(action, Action.ENROLL)
response = flow_manager.get_flow() flow_manager.get_flow()
self.assertEqual(response.status_code, 302)
flow_plan: FlowPlan = request.session[SESSION_KEY_PLAN]
self.assertEqual(flow_plan.bindings[0].stage.view, PostSourceStage)
def test_unauthenticated_auth(self): def test_unauthenticated_auth(self):
"""Test un-authenticated user authenticating""" """Test un-authenticated user authenticating"""
UserOAuthSourceConnection.objects.create( UserOAuthSourceConnection.objects.create(
user=get_anonymous_user(), source=self.source, identifier=self.identifier user=get_anonymous_user(), source=self.source, identifier=self.identifier
) )
request = get_request("/", user=AnonymousUser())
flow_manager = OAuthSourceFlowManager(self.source, request, self.identifier, {}) flow_manager = OAuthSourceFlowManager(
self.source, get_request("/", user=AnonymousUser()), self.identifier, {}
)
action, _ = flow_manager.get_action() action, _ = flow_manager.get_action()
self.assertEqual(action, Action.AUTH) self.assertEqual(action, Action.AUTH)
response = flow_manager.get_flow() flow_manager.get_flow()
self.assertEqual(response.status_code, 302)
flow_plan: FlowPlan = request.session[SESSION_KEY_PLAN]
self.assertEqual(flow_plan.bindings[0].stage.view, PostSourceStage)
def test_authenticated_link(self): def test_authenticated_link(self):
"""Test authenticated user linking""" """Test authenticated user linking"""
user = User.objects.create(username="foo", email="foo@bar.baz") UserOAuthSourceConnection.objects.create(
request = get_request("/", user=user) user=get_anonymous_user(), source=self.source, identifier=self.identifier
flow_manager = OAuthSourceFlowManager(self.source, request, self.identifier, {})
action, connection = flow_manager.get_action()
self.assertEqual(action, Action.LINK)
self.assertIsNone(connection.pk)
response = flow_manager.get_flow()
self.assertEqual(response.status_code, 302)
self.assertEqual(
response.url,
reverse("authentik_core:if-user") + "#/settings;page-sources",
) )
user = User.objects.create(username="foo", email="foo@bar.baz")
def test_unauthenticated_link(self): flow_manager = OAuthSourceFlowManager(
"""Test un-authenticated user linking""" self.source, get_request("/", user=user), self.identifier, {}
flow_manager = OAuthSourceFlowManager(self.source, get_request("/"), self.identifier, {}) )
action, connection = flow_manager.get_action() action, _ = flow_manager.get_action()
self.assertEqual(action, Action.LINK) self.assertEqual(action, Action.LINK)
self.assertIsNone(connection.pk)
flow_manager.get_flow() flow_manager.get_flow()
def test_unauthenticated_enroll_email(self): def test_unauthenticated_enroll_email(self):

View File

@ -13,8 +13,9 @@ from authentik.core.models import (
USER_ATTRIBUTE_TOKEN_MAXIMUM_LIFETIME, USER_ATTRIBUTE_TOKEN_MAXIMUM_LIFETIME,
Token, Token,
TokenIntents, TokenIntents,
User,
) )
from authentik.core.tests.utils import create_test_admin_user, create_test_user from authentik.core.tests.utils import create_test_admin_user
from authentik.lib.generators import generate_id from authentik.lib.generators import generate_id
@ -23,7 +24,7 @@ class TestTokenAPI(APITestCase):
def setUp(self) -> None: def setUp(self) -> None:
super().setUp() super().setUp()
self.user = create_test_user() self.user = User.objects.create(username="testuser")
self.admin = create_test_admin_user() self.admin = create_test_admin_user()
self.client.force_login(self.user) self.client.force_login(self.user)
@ -153,24 +154,6 @@ class TestTokenAPI(APITestCase):
self.assertEqual(token.expiring, True) self.assertEqual(token.expiring, True)
self.assertNotEqual(token.expires.timestamp(), expires.timestamp()) self.assertNotEqual(token.expires.timestamp(), expires.timestamp())
def test_token_change_user(self):
"""Test creating a token and then changing the user"""
ident = generate_id()
response = self.client.post(reverse("authentik_api:token-list"), {"identifier": ident})
self.assertEqual(response.status_code, 201)
token = Token.objects.get(identifier=ident)
self.assertEqual(token.user, self.user)
self.assertEqual(token.intent, TokenIntents.INTENT_API)
self.assertEqual(token.expiring, True)
self.assertTrue(self.user.has_perm("authentik_core.view_token_key", token))
response = self.client.put(
reverse("authentik_api:token-detail", kwargs={"identifier": ident}),
data={"identifier": "user_token_poc_v3", "intent": "api", "user": self.admin.pk},
)
self.assertEqual(response.status_code, 400)
token.refresh_from_db()
self.assertEqual(token.user, self.user)
def test_list(self): def test_list(self):
"""Test Token List (Test normal authentication)""" """Test Token List (Test normal authentication)"""
Token.objects.all().delete() Token.objects.all().delete()

View File

@ -41,12 +41,6 @@ class TestUsersAPI(APITestCase):
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
def test_list_with_groups(self):
"""Test listing with groups"""
self.client.force_login(self.admin)
response = self.client.get(reverse("authentik_api:user-list"), {"include_groups": "true"})
self.assertEqual(response.status_code, 200)
def test_metrics(self): def test_metrics(self):
"""Test user's metrics""" """Test user's metrics"""
self.client.force_login(self.admin) self.client.force_login(self.admin)

View File

@ -8,6 +8,7 @@ from rest_framework.test import APITestCase
from authentik.core.models import User from authentik.core.models import User
from authentik.core.tests.utils import create_test_admin_user from authentik.core.tests.utils import create_test_admin_user
from authentik.lib.config import CONFIG
from authentik.tenants.utils import get_current_tenant from authentik.tenants.utils import get_current_tenant
@ -24,6 +25,7 @@ class TestUsersAvatars(APITestCase):
tenant.avatars = mode tenant.avatars = mode
tenant.save() tenant.save()
@CONFIG.patch("avatars", "none")
def test_avatars_none(self): def test_avatars_none(self):
"""Test avatars none""" """Test avatars none"""
self.set_avatar_mode("none") self.set_avatar_mode("none")
@ -42,8 +44,8 @@ class TestUsersAvatars(APITestCase):
with Mocker() as mocker: with Mocker() as mocker:
mocker.head( mocker.head(
( (
"https://www.gravatar.com/avatar/76eb3c74c8beb6faa037f1b6e2ecb3e252bdac" "https://secure.gravatar.com/avatar/84730f9c1851d1ea03f1a"
"6cf71fb567ae36025a9d4ea86b?size=158&rating=g&default=404" "a9ed85bd1ea?size=158&rating=g&default=404"
), ),
text="foo", text="foo",
) )

View File

@ -4,7 +4,7 @@ from django.utils.text import slugify
from authentik.brands.models import Brand from authentik.brands.models import Brand
from authentik.core.models import Group, User from authentik.core.models import Group, User
from authentik.crypto.builder import CertificateBuilder, PrivateKeyAlg from authentik.crypto.builder import CertificateBuilder
from authentik.crypto.models import CertificateKeyPair from authentik.crypto.models import CertificateKeyPair
from authentik.flows.models import Flow, FlowDesignation from authentik.flows.models import Flow, FlowDesignation
from authentik.lib.generators import generate_id from authentik.lib.generators import generate_id
@ -50,10 +50,12 @@ def create_test_brand(**kwargs) -> Brand:
return Brand.objects.create(domain=uid, default=True, **kwargs) return Brand.objects.create(domain=uid, default=True, **kwargs)
def create_test_cert(alg=PrivateKeyAlg.RSA) -> CertificateKeyPair: def create_test_cert(use_ec_private_key=False) -> CertificateKeyPair:
"""Generate a certificate for testing""" """Generate a certificate for testing"""
builder = CertificateBuilder(f"{generate_id()}.self-signed.goauthentik.io") builder = CertificateBuilder(
builder.alg = alg name=f"{generate_id()}.self-signed.goauthentik.io",
use_ec_private_key=use_ec_private_key,
)
builder.build( builder.build(
subject_alt_names=[f"{generate_id()}.self-signed.goauthentik.io"], subject_alt_names=[f"{generate_id()}.self-signed.goauthentik.io"],
validity_days=360, validity_days=360,

View File

@ -12,7 +12,7 @@ from authentik.core.api.applications import ApplicationViewSet
from authentik.core.api.authenticated_sessions import AuthenticatedSessionViewSet from authentik.core.api.authenticated_sessions import AuthenticatedSessionViewSet
from authentik.core.api.devices import AdminDeviceViewSet, DeviceViewSet from authentik.core.api.devices import AdminDeviceViewSet, DeviceViewSet
from authentik.core.api.groups import GroupViewSet from authentik.core.api.groups import GroupViewSet
from authentik.core.api.property_mappings import PropertyMappingViewSet from authentik.core.api.propertymappings import PropertyMappingViewSet
from authentik.core.api.providers import ProviderViewSet from authentik.core.api.providers import ProviderViewSet
from authentik.core.api.sources import SourceViewSet, UserSourceConnectionViewSet from authentik.core.api.sources import SourceViewSet, UserSourceConnectionViewSet
from authentik.core.api.tokens import TokenViewSet from authentik.core.api.tokens import TokenViewSet
@ -20,9 +20,8 @@ from authentik.core.api.transactional_applications import TransactionalApplicati
from authentik.core.api.users import UserViewSet from authentik.core.api.users import UserViewSet
from authentik.core.views import apps from authentik.core.views import apps
from authentik.core.views.debug import AccessDeniedView from authentik.core.views.debug import AccessDeniedView
from authentik.core.views.interface import InterfaceView from authentik.core.views.interface import FlowInterfaceView, InterfaceView
from authentik.core.views.session import EndSessionView from authentik.core.views.session import EndSessionView
from authentik.flows.views.interface import FlowInterfaceView
from authentik.root.asgi_middleware import SessionMiddleware from authentik.root.asgi_middleware import SessionMiddleware
from authentik.root.messages.consumer import MessageConsumer from authentik.root.messages.consumer import MessageConsumer
from authentik.root.middleware import ChannelsLoggingMiddleware from authentik.root.middleware import ChannelsLoggingMiddleware
@ -54,8 +53,6 @@ urlpatterns = [
), ),
path( path(
"if/flow/<slug:flow_slug>/", "if/flow/<slug:flow_slug>/",
# FIXME: move this url to the flows app...also will cause all
# of the reverse calls to be adjusted
ensure_csrf_cookie(FlowInterfaceView.as_view()), ensure_csrf_cookie(FlowInterfaceView.as_view()),
name="if-flow", name="if-flow",
), ),

View File

@ -3,6 +3,7 @@
from json import dumps from json import dumps
from typing import Any from typing import Any
from django.shortcuts import get_object_or_404
from django.views.generic.base import TemplateView from django.views.generic.base import TemplateView
from rest_framework.request import Request from rest_framework.request import Request
@ -10,6 +11,7 @@ from authentik import get_build_hash
from authentik.admin.tasks import LOCAL_VERSION from authentik.admin.tasks import LOCAL_VERSION
from authentik.api.v3.config import ConfigView from authentik.api.v3.config import ConfigView
from authentik.brands.api import CurrentBrandSerializer from authentik.brands.api import CurrentBrandSerializer
from authentik.flows.models import Flow
class InterfaceView(TemplateView): class InterfaceView(TemplateView):
@ -23,3 +25,14 @@ class InterfaceView(TemplateView):
kwargs["build"] = get_build_hash() kwargs["build"] = get_build_hash()
kwargs["url_kwargs"] = self.kwargs kwargs["url_kwargs"] = self.kwargs
return super().get_context_data(**kwargs) return super().get_context_data(**kwargs)
class FlowInterfaceView(InterfaceView):
"""Flow interface"""
template_name = "if/flow.html"
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
kwargs["flow"] = get_object_or_404(Flow, slug=self.kwargs.get("flow_slug"))
kwargs["inspector"] = "inspector" in self.request.GET
return super().get_context_data(**kwargs)

View File

@ -14,24 +14,19 @@ from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
from rest_framework.fields import ( from rest_framework.fields import CharField, DateTimeField, IntegerField, SerializerMethodField
CharField,
ChoiceField,
DateTimeField,
IntegerField,
SerializerMethodField,
)
from rest_framework.filters import OrderingFilter, SearchFilter from rest_framework.filters import OrderingFilter, SearchFilter
from rest_framework.request import Request from rest_framework.request import Request
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.serializers import ModelSerializer
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.api.authorization import SecretKeyFilter from authentik.api.authorization import SecretKeyFilter
from authentik.core.api.used_by import UsedByMixin from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import ModelSerializer, PassiveSerializer from authentik.core.api.utils import PassiveSerializer
from authentik.crypto.apps import MANAGED_KEY from authentik.crypto.apps import MANAGED_KEY
from authentik.crypto.builder import CertificateBuilder, PrivateKeyAlg from authentik.crypto.builder import CertificateBuilder
from authentik.crypto.models import CertificateKeyPair from authentik.crypto.models import CertificateKeyPair
from authentik.events.models import Event, EventAction from authentik.events.models import Event, EventAction
from authentik.rbac.decorators import permission_required from authentik.rbac.decorators import permission_required
@ -183,7 +178,6 @@ class CertificateGenerationSerializer(PassiveSerializer):
common_name = CharField() common_name = CharField()
subject_alt_name = CharField(required=False, allow_blank=True, label=_("Subject-alt name")) subject_alt_name = CharField(required=False, allow_blank=True, label=_("Subject-alt name"))
validity_days = IntegerField(initial=365) validity_days = IntegerField(initial=365)
alg = ChoiceField(default=PrivateKeyAlg.RSA, choices=PrivateKeyAlg.choices)
class CertificateKeyPairFilter(FilterSet): class CertificateKeyPairFilter(FilterSet):
@ -246,7 +240,6 @@ class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet):
raw_san = data.validated_data.get("subject_alt_name", "") raw_san = data.validated_data.get("subject_alt_name", "")
sans = raw_san.split(",") if raw_san != "" else [] sans = raw_san.split(",") if raw_san != "" else []
builder = CertificateBuilder(data.validated_data["common_name"]) builder = CertificateBuilder(data.validated_data["common_name"])
builder.alg = data.validated_data["alg"]
builder.build( builder.build(
subject_alt_names=sans, subject_alt_names=sans,
validity_days=int(data.validated_data["validity_days"]), validity_days=int(data.validated_data["validity_days"]),

View File

@ -9,28 +9,20 @@ from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec, rsa from cryptography.hazmat.primitives.asymmetric import ec, rsa
from cryptography.hazmat.primitives.asymmetric.types import PrivateKeyTypes from cryptography.hazmat.primitives.asymmetric.types import PrivateKeyTypes
from cryptography.x509.oid import NameOID from cryptography.x509.oid import NameOID
from django.db import models
from django.utils.translation import gettext_lazy as _
from authentik import __version__ from authentik import __version__
from authentik.crypto.models import CertificateKeyPair from authentik.crypto.models import CertificateKeyPair
class PrivateKeyAlg(models.TextChoices):
"""Algorithm to create private key with"""
RSA = "rsa", _("rsa")
ECDSA = "ecdsa", _("ecdsa")
class CertificateBuilder: class CertificateBuilder:
"""Build self-signed certificates""" """Build self-signed certificates"""
common_name: str common_name: str
alg: PrivateKeyAlg
def __init__(self, name: str): _use_ec_private_key: bool
self.alg = PrivateKeyAlg.RSA
def __init__(self, name: str, use_ec_private_key=False):
self._use_ec_private_key = use_ec_private_key
self.__public_key = None self.__public_key = None
self.__private_key = None self.__private_key = None
self.__builder = None self.__builder = None
@ -50,13 +42,11 @@ class CertificateBuilder:
def generate_private_key(self) -> PrivateKeyTypes: def generate_private_key(self) -> PrivateKeyTypes:
"""Generate private key""" """Generate private key"""
if self.alg == PrivateKeyAlg.ECDSA: if self._use_ec_private_key:
return ec.generate_private_key(curve=ec.SECP256R1()) return ec.generate_private_key(curve=ec.SECP256R1())
if self.alg == PrivateKeyAlg.RSA: return rsa.generate_private_key(
return rsa.generate_private_key( public_exponent=65537, key_size=4096, backend=default_backend()
public_exponent=65537, key_size=4096, backend=default_backend() )
)
raise ValueError(f"Invalid alg: {self.alg}")
def build( def build(
self, self,

View File

@ -92,11 +92,7 @@ class CertificateKeyPair(SerializerModel, ManagedModel, CreatedUpdatedModel):
@property @property
def kid(self): def kid(self):
"""Get Key ID used for JWKS""" """Get Key ID used for JWKS"""
return ( return md5(self.key_data.encode("utf-8")).hexdigest() if self.key_data else "" # nosec
md5(self.key_data.encode("utf-8"), usedforsecurity=False).hexdigest()
if self.key_data
else ""
) # nosec
def __str__(self) -> str: def __str__(self) -> str:
return f"Certificate-Key Pair {self.name}" return f"Certificate-Key Pair {self.name}"

View File

@ -241,7 +241,7 @@ class TestCrypto(APITestCase):
"model_name": "oauth2provider", "model_name": "oauth2provider",
"pk": str(provider.pk), "pk": str(provider.pk),
"name": str(provider), "name": str(provider),
"action": DeleteAction.SET_NULL.value, "action": DeleteAction.SET_NULL.name,
} }
], ],
) )

View File

@ -13,10 +13,11 @@ from rest_framework.fields import CharField, IntegerField
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request from rest_framework.request import Request
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
from authentik.core.api.used_by import UsedByMixin from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import ModelSerializer, PassiveSerializer from authentik.core.api.utils import PassiveSerializer
from authentik.core.models import User, UserTypes from authentik.core.models import User, UserTypes
from authentik.enterprise.license import LicenseKey, LicenseSummarySerializer from authentik.enterprise.license import LicenseKey, LicenseSummarySerializer
from authentik.enterprise.models import License from authentik.enterprise.models import License

View File

@ -2,12 +2,11 @@
from copy import deepcopy from copy import deepcopy
from functools import partial from functools import partial
from typing import Any
from django.apps.registry import apps from django.apps.registry import apps
from django.core.files import File from django.core.files import File
from django.db import connection from django.db import connection
from django.db.models import ManyToManyRel, Model from django.db.models import Model
from django.db.models.expressions import BaseExpression, Combinable from django.db.models.expressions import BaseExpression, Combinable
from django.db.models.signals import post_init from django.db.models.signals import post_init
from django.http import HttpRequest from django.http import HttpRequest
@ -45,7 +44,7 @@ class EnterpriseAuditMiddleware(AuditMiddleware):
post_init.disconnect(dispatch_uid=request.request_id) post_init.disconnect(dispatch_uid=request.request_id)
def serialize_simple(self, model: Model) -> dict: def serialize_simple(self, model: Model) -> dict:
"""Serialize a model in a very simple way. No ForeignKeys or other relationships are """Serialize a model in a very simple way. No ForeginKeys or other relationships are
resolved""" resolved"""
data = {} data = {}
deferred_fields = model.get_deferred_fields() deferred_fields = model.get_deferred_fields()
@ -71,9 +70,6 @@ class EnterpriseAuditMiddleware(AuditMiddleware):
for key, value in before.items(): for key, value in before.items():
if after.get(key) != value: if after.get(key) != value:
diff[key] = {"previous_value": value, "new_value": after.get(key)} diff[key] = {"previous_value": value, "new_value": after.get(key)}
for key, value in after.items():
if key not in before and key not in diff and before.get(key) != value:
diff[key] = {"previous_value": before.get(key), "new_value": value}
return sanitize_item(diff) return sanitize_item(diff)
def post_init_handler(self, request: HttpRequest, sender, instance: Model, **_): def post_init_handler(self, request: HttpRequest, sender, instance: Model, **_):
@ -102,37 +98,8 @@ class EnterpriseAuditMiddleware(AuditMiddleware):
thread_kwargs = {} thread_kwargs = {}
if hasattr(instance, "_previous_state") or created: if hasattr(instance, "_previous_state") or created:
prev_state = getattr(instance, "_previous_state", {}) prev_state = getattr(instance, "_previous_state", {})
if created:
prev_state = {}
# Get current state # Get current state
new_state = self.serialize_simple(instance) new_state = self.serialize_simple(instance)
diff = self.diff(prev_state, new_state) diff = self.diff(prev_state, new_state)
thread_kwargs["diff"] = diff thread_kwargs["diff"] = diff
return super().post_save_handler(request, sender, instance, created, thread_kwargs, **_) return super().post_save_handler(request, sender, instance, created, thread_kwargs, **_)
def m2m_changed_handler( # noqa: PLR0913
self,
request: HttpRequest,
sender,
instance: Model,
action: str,
pk_set: set[Any],
thread_kwargs: dict | None = None,
**_,
):
thread_kwargs = {}
m2m_field = None
# For the audit log we don't care about `pre_` or `post_` so we trim that part off
_, _, action_direction = action.partition("_")
# resolve the "through" model to an actual field
for field in instance._meta.get_fields():
if not isinstance(field, ManyToManyRel):
continue
if field.through == sender:
m2m_field = field
if m2m_field:
# If we're clearing we just set the "flag" to True
if action_direction == "clear":
pk_set = True
thread_kwargs["diff"] = {m2m_field.related_name: {action_direction: pk_set}}
return super().m2m_changed_handler(request, sender, instance, action, thread_kwargs)

View File

@ -1,22 +1,9 @@
from unittest.mock import PropertyMock, patch
from django.apps import apps from django.apps import apps
from django.conf import settings from django.conf import settings
from django.urls import reverse from django.test import TestCase
from rest_framework.test import APITestCase
from authentik.core.models import Group, User
from authentik.core.tests.utils import create_test_admin_user
from authentik.events.models import Event, EventAction
from authentik.events.utils import sanitize_item
from authentik.lib.generators import generate_id
class TestEnterpriseAudit(APITestCase): class TestEnterpriseAudit(TestCase):
"""Test audit middleware"""
def setUp(self) -> None:
self.user = create_test_admin_user()
def test_import(self): def test_import(self):
"""Ensure middleware is imported when app.ready is called""" """Ensure middleware is imported when app.ready is called"""
@ -29,182 +16,3 @@ class TestEnterpriseAudit(APITestCase):
self.assertIn( self.assertIn(
"authentik.enterprise.audit.middleware.EnterpriseAuditMiddleware", settings.MIDDLEWARE "authentik.enterprise.audit.middleware.EnterpriseAuditMiddleware", settings.MIDDLEWARE
) )
@patch(
"authentik.enterprise.audit.middleware.EnterpriseAuditMiddleware.enabled",
PropertyMock(return_value=True),
)
def test_create(self):
"""Test create audit log"""
self.client.force_login(self.user)
username = generate_id()
response = self.client.post(
reverse("authentik_api:user-list"),
data={"name": generate_id(), "username": username, "groups": [], "path": "foo"},
)
user = User.objects.get(username=username)
self.assertEqual(response.status_code, 201)
events = Event.objects.filter(
action=EventAction.MODEL_CREATED,
context__model__model_name="user",
context__model__app="authentik_core",
context__model__pk=user.pk,
)
event = events.first()
self.assertIsNotNone(event)
self.assertIsNotNone(event.context["diff"])
diff = event.context["diff"]
self.assertEqual(
diff,
{
"name": {
"new_value": user.name,
"previous_value": None,
},
"path": {"new_value": "foo", "previous_value": None},
"type": {"new_value": "internal", "previous_value": None},
"uuid": {
"new_value": user.uuid.hex,
"previous_value": None,
},
"email": {"new_value": "", "previous_value": None},
"username": {
"new_value": user.username,
"previous_value": None,
},
"is_active": {"new_value": True, "previous_value": None},
"attributes": {"new_value": {}, "previous_value": None},
"date_joined": {
"new_value": sanitize_item(user.date_joined),
"previous_value": None,
},
"first_name": {"new_value": "", "previous_value": None},
"id": {"new_value": user.pk, "previous_value": None},
"last_name": {"new_value": "", "previous_value": None},
"password": {"new_value": "********************", "previous_value": None},
"password_change_date": {
"new_value": sanitize_item(user.password_change_date),
"previous_value": None,
},
},
)
@patch(
"authentik.enterprise.audit.middleware.EnterpriseAuditMiddleware.enabled",
PropertyMock(return_value=True),
)
def test_update(self):
"""Test update audit log"""
self.client.force_login(self.user)
user = create_test_admin_user()
current_name = user.name
new_name = generate_id()
response = self.client.patch(
reverse("authentik_api:user-detail", kwargs={"pk": user.id}),
data={"name": new_name},
)
user.refresh_from_db()
self.assertEqual(response.status_code, 200)
events = Event.objects.filter(
action=EventAction.MODEL_UPDATED,
context__model__model_name="user",
context__model__app="authentik_core",
context__model__pk=user.pk,
)
event = events.first()
self.assertIsNotNone(event)
self.assertIsNotNone(event.context["diff"])
diff = event.context["diff"]
self.assertEqual(
diff,
{
"name": {
"new_value": new_name,
"previous_value": current_name,
},
},
)
@patch(
"authentik.enterprise.audit.middleware.EnterpriseAuditMiddleware.enabled",
PropertyMock(return_value=True),
)
def test_delete(self):
"""Test delete audit log"""
self.client.force_login(self.user)
user = create_test_admin_user()
response = self.client.delete(
reverse("authentik_api:user-detail", kwargs={"pk": user.id}),
)
self.assertEqual(response.status_code, 204)
events = Event.objects.filter(
action=EventAction.MODEL_DELETED,
context__model__model_name="user",
context__model__app="authentik_core",
context__model__pk=user.pk,
)
event = events.first()
self.assertIsNotNone(event)
self.assertNotIn("diff", event.context)
@patch(
"authentik.enterprise.audit.middleware.EnterpriseAuditMiddleware.enabled",
PropertyMock(return_value=True),
)
def test_m2m_add(self):
"""Test m2m add audit log"""
self.client.force_login(self.user)
user = create_test_admin_user()
group = Group.objects.create(name=generate_id())
response = self.client.post(
reverse("authentik_api:group-add-user", kwargs={"pk": group.group_uuid}),
data={
"pk": user.pk,
},
)
self.assertEqual(response.status_code, 204)
events = Event.objects.filter(
action=EventAction.MODEL_UPDATED,
context__model__model_name="group",
context__model__app="authentik_core",
context__model__pk=group.pk.hex,
)
event = events.first()
self.assertIsNotNone(event)
self.assertIsNotNone(event.context["diff"])
diff = event.context["diff"]
self.assertEqual(
diff,
{"users": {"add": [user.pk]}},
)
@patch(
"authentik.enterprise.audit.middleware.EnterpriseAuditMiddleware.enabled",
PropertyMock(return_value=True),
)
def test_m2m_remove(self):
"""Test m2m remove audit log"""
self.client.force_login(self.user)
user = create_test_admin_user()
group = Group.objects.create(name=generate_id())
response = self.client.post(
reverse("authentik_api:group-remove-user", kwargs={"pk": group.group_uuid}),
data={
"pk": user.pk,
},
)
self.assertEqual(response.status_code, 204)
events = Event.objects.filter(
action=EventAction.MODEL_UPDATED,
context__model__model_name="group",
context__model__app="authentik_core",
context__model__pk=group.pk.hex,
)
event = events.first()
self.assertIsNotNone(event)
self.assertIsNotNone(event.context["diff"])
diff = event.context["diff"]
self.assertEqual(
diff,
{"users": {"remove": [user.pk]}},
)

View File

@ -1,47 +0,0 @@
"""GoogleWorkspaceProviderGroup API Views"""
from rest_framework import mixins
from rest_framework.viewsets import GenericViewSet
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.users import UserGroupSerializer
from authentik.core.api.utils import ModelSerializer
from authentik.enterprise.providers.google_workspace.models import GoogleWorkspaceProviderGroup
from authentik.lib.sync.outgoing.api import OutgoingSyncConnectionCreateMixin
class GoogleWorkspaceProviderGroupSerializer(ModelSerializer):
"""GoogleWorkspaceProviderGroup Serializer"""
group_obj = UserGroupSerializer(source="group", read_only=True)
class Meta:
model = GoogleWorkspaceProviderGroup
fields = [
"id",
"google_id",
"group",
"group_obj",
"provider",
"attributes",
]
extra_kwargs = {"attributes": {"read_only": True}}
class GoogleWorkspaceProviderGroupViewSet(
mixins.CreateModelMixin,
OutgoingSyncConnectionCreateMixin,
mixins.RetrieveModelMixin,
mixins.DestroyModelMixin,
UsedByMixin,
mixins.ListModelMixin,
GenericViewSet,
):
"""GoogleWorkspaceProviderGroup Viewset"""
queryset = GoogleWorkspaceProviderGroup.objects.all().select_related("group")
serializer_class = GoogleWorkspaceProviderGroupSerializer
filterset_fields = ["provider__id", "group__name", "group__group_uuid"]
search_fields = ["provider__name", "group__name"]
ordering = ["group__name"]

View File

@ -1,39 +0,0 @@
"""google Property mappings API Views"""
from django_filters.filters import AllValuesMultipleFilter
from django_filters.filterset import FilterSet
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field
from rest_framework.viewsets import ModelViewSet
from authentik.core.api.property_mappings import PropertyMappingSerializer
from authentik.core.api.used_by import UsedByMixin
from authentik.enterprise.providers.google_workspace.models import GoogleWorkspaceProviderMapping
class GoogleWorkspaceProviderMappingSerializer(PropertyMappingSerializer):
"""GoogleWorkspaceProviderMapping Serializer"""
class Meta:
model = GoogleWorkspaceProviderMapping
fields = PropertyMappingSerializer.Meta.fields
class GoogleWorkspaceProviderMappingFilter(FilterSet):
"""Filter for GoogleWorkspaceProviderMapping"""
managed = extend_schema_field(OpenApiTypes.STR)(AllValuesMultipleFilter(field_name="managed"))
class Meta:
model = GoogleWorkspaceProviderMapping
fields = "__all__"
class GoogleWorkspaceProviderMappingViewSet(UsedByMixin, ModelViewSet):
"""GoogleWorkspaceProviderMapping Viewset"""
queryset = GoogleWorkspaceProviderMapping.objects.all()
serializer_class = GoogleWorkspaceProviderMappingSerializer
filterset_class = GoogleWorkspaceProviderMappingFilter
search_fields = ["name"]
ordering = ["name"]

View File

@ -1,54 +0,0 @@
"""Google Provider API Views"""
from rest_framework.viewsets import ModelViewSet
from authentik.core.api.providers import ProviderSerializer
from authentik.core.api.used_by import UsedByMixin
from authentik.enterprise.api import EnterpriseRequiredMixin
from authentik.enterprise.providers.google_workspace.models import GoogleWorkspaceProvider
from authentik.enterprise.providers.google_workspace.tasks import google_workspace_sync
from authentik.lib.sync.outgoing.api import OutgoingSyncProviderStatusMixin
class GoogleWorkspaceProviderSerializer(EnterpriseRequiredMixin, ProviderSerializer):
"""GoogleWorkspaceProvider Serializer"""
class Meta:
model = GoogleWorkspaceProvider
fields = [
"pk",
"name",
"property_mappings",
"property_mappings_group",
"component",
"assigned_backchannel_application_slug",
"assigned_backchannel_application_name",
"verbose_name",
"verbose_name_plural",
"meta_model_name",
"delegated_subject",
"credentials",
"scopes",
"exclude_users_service_account",
"filter_group",
"user_delete_action",
"group_delete_action",
"default_group_email_domain",
]
extra_kwargs = {}
class GoogleWorkspaceProviderViewSet(OutgoingSyncProviderStatusMixin, UsedByMixin, ModelViewSet):
"""GoogleWorkspaceProvider Viewset"""
queryset = GoogleWorkspaceProvider.objects.all()
serializer_class = GoogleWorkspaceProviderSerializer
filterset_fields = [
"name",
"exclude_users_service_account",
"delegated_subject",
"filter_group",
]
search_fields = ["name"]
ordering = ["name"]
sync_single_task = google_workspace_sync

View File

@ -1,47 +0,0 @@
"""GoogleWorkspaceProviderUser API Views"""
from rest_framework import mixins
from rest_framework.viewsets import GenericViewSet
from authentik.core.api.groups import GroupMemberSerializer
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import ModelSerializer
from authentik.enterprise.providers.google_workspace.models import GoogleWorkspaceProviderUser
from authentik.lib.sync.outgoing.api import OutgoingSyncConnectionCreateMixin
class GoogleWorkspaceProviderUserSerializer(ModelSerializer):
"""GoogleWorkspaceProviderUser Serializer"""
user_obj = GroupMemberSerializer(source="user", read_only=True)
class Meta:
model = GoogleWorkspaceProviderUser
fields = [
"id",
"google_id",
"user",
"user_obj",
"provider",
"attributes",
]
extra_kwargs = {"attributes": {"read_only": True}}
class GoogleWorkspaceProviderUserViewSet(
mixins.CreateModelMixin,
OutgoingSyncConnectionCreateMixin,
mixins.RetrieveModelMixin,
mixins.DestroyModelMixin,
UsedByMixin,
mixins.ListModelMixin,
GenericViewSet,
):
"""GoogleWorkspaceProviderUser Viewset"""
queryset = GoogleWorkspaceProviderUser.objects.all().select_related("user")
serializer_class = GoogleWorkspaceProviderUserSerializer
filterset_fields = ["provider__id", "user__username", "user__id"]
search_fields = ["provider__name", "user__username"]
ordering = ["user__username"]

View File

@ -1,9 +0,0 @@
from authentik.enterprise.apps import EnterpriseConfig
class AuthentikEnterpriseProviderGoogleConfig(EnterpriseConfig):
name = "authentik.enterprise.providers.google_workspace"
label = "authentik_providers_google_workspace"
verbose_name = "authentik Enterprise.Providers.Google Workspace"
default = True

View File

@ -1,74 +0,0 @@
from django.db.models import Model
from django.http import HttpResponseBadRequest, HttpResponseNotFound
from google.auth.exceptions import GoogleAuthError, TransportError
from googleapiclient.discovery import build
from googleapiclient.errors import Error, HttpError
from googleapiclient.http import HttpRequest
from httplib2 import HttpLib2Error, HttpLib2ErrorWithResponse
from authentik.enterprise.providers.google_workspace.models import GoogleWorkspaceProvider
from authentik.lib.sync.outgoing import HTTP_CONFLICT
from authentik.lib.sync.outgoing.base import BaseOutgoingSyncClient
from authentik.lib.sync.outgoing.exceptions import (
BadRequestSyncException,
NotFoundSyncException,
ObjectExistsSyncException,
StopSync,
TransientSyncException,
)
class GoogleWorkspaceSyncClient[TModel: Model, TConnection: Model, TSchema: dict](
BaseOutgoingSyncClient[TModel, TConnection, TSchema, GoogleWorkspaceProvider]
):
"""Base client for syncing to google workspace"""
domains: list
def __init__(self, provider: GoogleWorkspaceProvider) -> None:
super().__init__(provider)
self.directory_service = build(
"admin",
"directory_v1",
cache_discovery=False,
**provider.google_credentials(),
)
self.__prefetch_domains()
def __prefetch_domains(self):
self.domains = []
domains = self._request(self.directory_service.domains().list(customer="my_customer"))
for domain in domains.get("domains", []):
domain_name = domain.get("domainName")
self.domains.append(domain_name)
def _request(self, request: HttpRequest):
try:
response = request.execute()
except GoogleAuthError as exc:
if isinstance(exc, TransportError):
raise TransientSyncException(f"Failed to send request: {str(exc)}") from exc
raise StopSync(exc) from exc
except HttpLib2Error as exc:
if isinstance(exc, HttpLib2ErrorWithResponse):
self._response_handle_status_code(request.body, exc.response.status, exc)
raise TransientSyncException(f"Failed to send request: {str(exc)}") from exc
except HttpError as exc:
self._response_handle_status_code(request.body, exc.status_code, exc)
raise TransientSyncException(f"Failed to send request: {str(exc)}") from exc
except Error as exc:
raise TransientSyncException(f"Failed to send request: {str(exc)}") from exc
return response
def _response_handle_status_code(self, request: dict, status_code: int, root_exc: Exception):
if status_code == HttpResponseNotFound.status_code:
raise NotFoundSyncException("Object not found") from root_exc
if status_code == HTTP_CONFLICT:
raise ObjectExistsSyncException("Object exists") from root_exc
if status_code == HttpResponseBadRequest.status_code:
raise BadRequestSyncException("Bad request", request) from root_exc
def check_email_valid(self, *emails: str):
for email in emails:
if not any(email.endswith(f"@{domain_name}") for domain_name in self.domains):
raise BadRequestSyncException(f"Invalid email domain: {email}")

View File

@ -1,220 +0,0 @@
from django.db import transaction
from django.utils.text import slugify
from authentik.core.models import Group
from authentik.enterprise.providers.google_workspace.clients.base import GoogleWorkspaceSyncClient
from authentik.enterprise.providers.google_workspace.models import (
GoogleWorkspaceProvider,
GoogleWorkspaceProviderGroup,
GoogleWorkspaceProviderMapping,
GoogleWorkspaceProviderUser,
)
from authentik.lib.sync.mapper import PropertyMappingManager
from authentik.lib.sync.outgoing.base import Direction
from authentik.lib.sync.outgoing.exceptions import (
NotFoundSyncException,
ObjectExistsSyncException,
TransientSyncException,
)
from authentik.lib.sync.outgoing.models import OutgoingSyncDeleteAction
class GoogleWorkspaceGroupClient(
GoogleWorkspaceSyncClient[Group, GoogleWorkspaceProviderGroup, dict]
):
"""Google client for groups"""
connection_type = GoogleWorkspaceProviderGroup
connection_type_query = "group"
can_discover = True
def __init__(self, provider: GoogleWorkspaceProvider) -> None:
super().__init__(provider)
self.mapper = PropertyMappingManager(
self.provider.property_mappings_group.all().order_by("name").select_subclasses(),
GoogleWorkspaceProviderMapping,
["group", "provider", "connection"],
)
def to_schema(self, obj: Group, connection: GoogleWorkspaceProviderGroup) -> dict:
"""Convert authentik group"""
return super().to_schema(
obj,
connection=connection,
email=f"{slugify(obj.name)}@{self.provider.default_group_email_domain}",
)
def delete(self, obj: Group):
"""Delete group"""
google_group = GoogleWorkspaceProviderGroup.objects.filter(
provider=self.provider, group=obj
).first()
if not google_group:
self.logger.debug("Group does not exist in Google, skipping")
return None
with transaction.atomic():
if self.provider.group_delete_action == OutgoingSyncDeleteAction.DELETE:
self._request(
self.directory_service.groups().delete(groupKey=google_group.google_id)
)
google_group.delete()
def create(self, group: Group):
"""Create group from scratch and create a connection object"""
google_group = self.to_schema(group, None)
self.check_email_valid(google_group["email"])
with transaction.atomic():
try:
response = self._request(self.directory_service.groups().insert(body=google_group))
except ObjectExistsSyncException:
# group already exists in google workspace, so we can connect them manually
# for groups we need to fetch the group from google as we connect on
# ID and not group email
group_data = self._request(
self.directory_service.groups().get(groupKey=google_group["email"])
)
return GoogleWorkspaceProviderGroup.objects.create(
provider=self.provider,
group=group,
google_id=group_data["id"],
attributes=group_data,
)
else:
return GoogleWorkspaceProviderGroup.objects.create(
provider=self.provider,
group=group,
google_id=response["id"],
attributes=response,
)
def update(self, group: Group, connection: GoogleWorkspaceProviderGroup):
"""Update existing group"""
google_group = self.to_schema(group, connection)
self.check_email_valid(google_group["email"])
try:
response = self._request(
self.directory_service.groups().update(
groupKey=connection.google_id,
body=google_group,
)
)
connection.attributes = response
connection.save()
except NotFoundSyncException:
# Resource missing is handled by self.write, which will re-create the group
raise
def write(self, obj: Group):
google_group, created = super().write(obj)
self.create_sync_members(obj, google_group)
return google_group, created
def create_sync_members(self, obj: Group, google_group: GoogleWorkspaceProviderGroup):
"""Sync all members after a group was created"""
users = list(obj.users.order_by("id").values_list("id", flat=True))
connections = GoogleWorkspaceProviderUser.objects.filter(
provider=self.provider, user__pk__in=users
).values_list("google_id", flat=True)
self._patch(google_group.google_id, Direction.add, connections)
def update_group(self, group: Group, action: Direction, users_set: set[int]):
"""Update a groups members"""
if action == Direction.add:
return self._patch_add_users(group, users_set)
if action == Direction.remove:
return self._patch_remove_users(group, users_set)
def _patch(self, google_group_id: str, direction: Direction, members: list[str]):
for user in members:
try:
if direction == Direction.add:
self._request(
self.directory_service.members().insert(
groupKey=google_group_id, body={"email": user}
)
)
if direction == Direction.remove:
self._request(
self.directory_service.members().delete(
groupKey=google_group_id, memberKey=user
)
)
except ObjectExistsSyncException:
pass
except TransientSyncException:
raise
def _patch_add_users(self, group: Group, users_set: set[int]):
"""Add users in users_set to group"""
if len(users_set) < 1:
return
google_group = GoogleWorkspaceProviderGroup.objects.filter(
provider=self.provider, group=group
).first()
if not google_group:
self.logger.warning(
"could not sync group membership, group does not exist", group=group
)
return
user_ids = list(
GoogleWorkspaceProviderUser.objects.filter(
user__pk__in=users_set, provider=self.provider
).values_list("google_id", flat=True)
)
if len(user_ids) < 1:
return
self._patch(google_group.google_id, Direction.add, user_ids)
def _patch_remove_users(self, group: Group, users_set: set[int]):
"""Remove users in users_set from group"""
if len(users_set) < 1:
return
google_group = GoogleWorkspaceProviderGroup.objects.filter(
provider=self.provider, group=group
).first()
if not google_group:
self.logger.warning(
"could not sync group membership, group does not exist", group=group
)
return
user_ids = list(
GoogleWorkspaceProviderUser.objects.filter(
user__pk__in=users_set, provider=self.provider
).values_list("google_id", flat=True)
)
if len(user_ids) < 1:
return
self._patch(google_group.google_id, Direction.remove, user_ids)
def discover(self):
"""Iterate through all groups and connect them with authentik groups if possible"""
request = self.directory_service.groups().list(
customer="my_customer", maxResults=500, orderBy="email"
)
while request:
response = request.execute()
for group in response.get("groups", []):
self._discover_single_group(group)
request = self.directory_service.groups().list_next(
previous_request=request, previous_response=response
)
def _discover_single_group(self, group: dict):
"""handle discovery of a single group"""
google_name = group["name"]
google_id = group["id"]
matching_authentik_group = (
self.provider.get_object_qs(Group).filter(name=google_name).first()
)
if not matching_authentik_group:
return
GoogleWorkspaceProviderGroup.objects.get_or_create(
provider=self.provider,
group=matching_authentik_group,
google_id=google_id,
attributes=group,
)
def update_single_attribute(self, connection: GoogleWorkspaceProviderUser):
group = self.directory_service.groups().get(connection.google_id)
connection.attributes = group

View File

@ -1,41 +0,0 @@
from json import dumps
from httplib2 import Response
class MockHTTP:
_recorded_requests = []
_responses = {}
def __init__(
self,
raise_on_unrecorded=True,
) -> None:
self._recorded_requests = []
self._responses = {}
self.raise_on_unrecorded = raise_on_unrecorded
def add_response(self, uri: str, body: str | dict = "", meta: dict | None = None, method="GET"):
if isinstance(body, dict):
body = dumps(body)
self._responses[(uri, method.upper())] = (body, meta or {"status": "200"})
def requests(self):
return self._recorded_requests
def request(
self,
uri,
method="GET",
body=None,
headers=None,
redirections=1,
connection_type=None,
):
key = (uri, method.upper())
self._recorded_requests.append((uri, method, body, headers))
if key not in self._responses and self.raise_on_unrecorded:
raise AssertionError(key)
body, meta = self._responses[key]
return Response(meta), body.encode("utf-8")

View File

@ -1,125 +0,0 @@
from django.db import transaction
from authentik.core.models import User
from authentik.enterprise.providers.google_workspace.clients.base import GoogleWorkspaceSyncClient
from authentik.enterprise.providers.google_workspace.models import (
GoogleWorkspaceProvider,
GoogleWorkspaceProviderMapping,
GoogleWorkspaceProviderUser,
)
from authentik.lib.sync.mapper import PropertyMappingManager
from authentik.lib.sync.outgoing.exceptions import (
ObjectExistsSyncException,
TransientSyncException,
)
from authentik.lib.sync.outgoing.models import OutgoingSyncDeleteAction
from authentik.policies.utils import delete_none_values
class GoogleWorkspaceUserClient(GoogleWorkspaceSyncClient[User, GoogleWorkspaceProviderUser, dict]):
"""Sync authentik users into google workspace"""
connection_type = GoogleWorkspaceProviderUser
connection_type_query = "user"
can_discover = True
def __init__(self, provider: GoogleWorkspaceProvider) -> None:
super().__init__(provider)
self.mapper = PropertyMappingManager(
self.provider.property_mappings.all().order_by("name").select_subclasses(),
GoogleWorkspaceProviderMapping,
["provider", "connection"],
)
def to_schema(self, obj: User, connection: GoogleWorkspaceProviderUser) -> dict:
"""Convert authentik user"""
return delete_none_values(super().to_schema(obj, connection, primaryEmail=obj.email))
def delete(self, obj: User):
"""Delete user"""
google_user = GoogleWorkspaceProviderUser.objects.filter(
provider=self.provider, user=obj
).first()
if not google_user:
self.logger.debug("User does not exist in Google, skipping")
return None
with transaction.atomic():
response = None
if self.provider.user_delete_action == OutgoingSyncDeleteAction.DELETE:
response = self._request(
self.directory_service.users().delete(userKey=google_user.google_id)
)
elif self.provider.user_delete_action == OutgoingSyncDeleteAction.SUSPEND:
response = self._request(
self.directory_service.users().update(
userKey=google_user.google_id, body={"suspended": True}
)
)
google_user.delete()
return response
def create(self, user: User):
"""Create user from scratch and create a connection object"""
google_user = self.to_schema(user, None)
self.check_email_valid(
google_user["primaryEmail"], *[x["address"] for x in google_user.get("emails", [])]
)
with transaction.atomic():
try:
response = self._request(self.directory_service.users().insert(body=google_user))
except ObjectExistsSyncException:
# user already exists in google workspace, so we can connect them manually
return GoogleWorkspaceProviderUser.objects.create(
provider=self.provider, user=user, google_id=user.email, attributes={}
)
except TransientSyncException as exc:
raise exc
else:
return GoogleWorkspaceProviderUser.objects.create(
provider=self.provider,
user=user,
google_id=response["primaryEmail"],
attributes=response,
)
def update(self, user: User, connection: GoogleWorkspaceProviderUser):
"""Update existing user"""
google_user = self.to_schema(user, connection)
self.check_email_valid(
google_user["primaryEmail"], *[x["address"] for x in google_user.get("emails", [])]
)
response = self._request(
self.directory_service.users().update(userKey=connection.google_id, body=google_user)
)
connection.attributes = response
connection.save()
def discover(self):
"""Iterate through all users and connect them with authentik users if possible"""
request = self.directory_service.users().list(
customer="my_customer", maxResults=500, orderBy="email"
)
while request:
response = request.execute()
for user in response.get("users", []):
self._discover_single_user(user)
request = self.directory_service.users().list_next(
previous_request=request, previous_response=response
)
def _discover_single_user(self, user: dict):
"""handle discovery of a single user"""
email = user["primaryEmail"]
matching_authentik_user = self.provider.get_object_qs(User).filter(email=email).first()
if not matching_authentik_user:
return
GoogleWorkspaceProviderUser.objects.get_or_create(
provider=self.provider,
user=matching_authentik_user,
google_id=email,
attributes=user,
)
def update_single_attribute(self, connection: GoogleWorkspaceProviderUser):
user = self.directory_service.users().get(connection.google_id)
connection.attributes = user

View File

@ -1,167 +0,0 @@
# Generated by Django 5.0.4 on 2024-05-07 16:03
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("authentik_core", "0035_alter_group_options_and_more"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="GoogleWorkspaceProviderMapping",
fields=[
(
"propertymapping_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="authentik_core.propertymapping",
),
),
],
options={
"verbose_name": "Google Workspace Provider Mapping",
"verbose_name_plural": "Google Workspace Provider Mappings",
},
bases=("authentik_core.propertymapping",),
),
migrations.CreateModel(
name="GoogleWorkspaceProvider",
fields=[
(
"provider_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="authentik_core.provider",
),
),
("delegated_subject", models.EmailField(max_length=254)),
("credentials", models.JSONField()),
(
"scopes",
models.TextField(
default="https://www.googleapis.com/auth/admin.directory.user,https://www.googleapis.com/auth/admin.directory.group,https://www.googleapis.com/auth/admin.directory.group.member,https://www.googleapis.com/auth/admin.directory.domain.readonly"
),
),
("default_group_email_domain", models.TextField()),
("exclude_users_service_account", models.BooleanField(default=False)),
(
"user_delete_action",
models.TextField(
choices=[
("do_nothing", "Do Nothing"),
("delete", "Delete"),
("suspend", "Suspend"),
],
default="delete",
),
),
(
"group_delete_action",
models.TextField(
choices=[
("do_nothing", "Do Nothing"),
("delete", "Delete"),
("suspend", "Suspend"),
],
default="delete",
),
),
(
"filter_group",
models.ForeignKey(
default=None,
null=True,
on_delete=django.db.models.deletion.SET_DEFAULT,
to="authentik_core.group",
),
),
(
"property_mappings_group",
models.ManyToManyField(
blank=True,
default=None,
help_text="Property mappings used for group creation/updating.",
to="authentik_core.propertymapping",
),
),
],
options={
"verbose_name": "Google Workspace Provider",
"verbose_name_plural": "Google Workspace Providers",
},
bases=("authentik_core.provider", models.Model),
),
migrations.CreateModel(
name="GoogleWorkspaceProviderGroup",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4, editable=False, primary_key=True, serialize=False
),
),
("google_id", models.TextField()),
(
"group",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="authentik_core.group"
),
),
(
"provider",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="authentik_providers_google_workspace.googleworkspaceprovider",
),
),
],
options={
"unique_together": {("google_id", "group", "provider")},
},
),
migrations.CreateModel(
name="GoogleWorkspaceProviderUser",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4, editable=False, primary_key=True, serialize=False
),
),
("google_id", models.TextField()),
(
"provider",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="authentik_providers_google_workspace.googleworkspaceprovider",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
),
),
],
options={
"unique_together": {("google_id", "user", "provider")},
},
),
]

View File

@ -1,179 +0,0 @@
# Generated by Django 5.0.6 on 2024-05-09 12:57
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
replaces = [
("authentik_providers_google_workspace", "0001_initial"),
(
"authentik_providers_google_workspace",
"0002_alter_googleworkspaceprovidergroup_options_and_more",
),
]
initial = True
dependencies = [
("authentik_core", "0035_alter_group_options_and_more"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="GoogleWorkspaceProviderMapping",
fields=[
(
"propertymapping_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="authentik_core.propertymapping",
),
),
],
options={
"verbose_name": "Google Workspace Provider Mapping",
"verbose_name_plural": "Google Workspace Provider Mappings",
},
bases=("authentik_core.propertymapping",),
),
migrations.CreateModel(
name="GoogleWorkspaceProvider",
fields=[
(
"provider_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="authentik_core.provider",
),
),
("delegated_subject", models.EmailField(max_length=254)),
("credentials", models.JSONField()),
(
"scopes",
models.TextField(
default="https://www.googleapis.com/auth/admin.directory.user,https://www.googleapis.com/auth/admin.directory.group,https://www.googleapis.com/auth/admin.directory.group.member,https://www.googleapis.com/auth/admin.directory.domain.readonly"
),
),
("default_group_email_domain", models.TextField()),
("exclude_users_service_account", models.BooleanField(default=False)),
(
"user_delete_action",
models.TextField(
choices=[
("do_nothing", "Do Nothing"),
("delete", "Delete"),
("suspend", "Suspend"),
],
default="delete",
),
),
(
"group_delete_action",
models.TextField(
choices=[
("do_nothing", "Do Nothing"),
("delete", "Delete"),
("suspend", "Suspend"),
],
default="delete",
),
),
(
"filter_group",
models.ForeignKey(
default=None,
null=True,
on_delete=django.db.models.deletion.SET_DEFAULT,
to="authentik_core.group",
),
),
(
"property_mappings_group",
models.ManyToManyField(
blank=True,
default=None,
help_text="Property mappings used for group creation/updating.",
to="authentik_core.propertymapping",
),
),
],
options={
"verbose_name": "Google Workspace Provider",
"verbose_name_plural": "Google Workspace Providers",
},
bases=("authentik_core.provider", models.Model),
),
migrations.CreateModel(
name="GoogleWorkspaceProviderGroup",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4, editable=False, primary_key=True, serialize=False
),
),
("google_id", models.TextField()),
(
"group",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="authentik_core.group"
),
),
(
"provider",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="authentik_providers_google_workspace.googleworkspaceprovider",
),
),
],
options={
"unique_together": {("google_id", "group", "provider")},
"verbose_name": "Google Workspace Provider Group",
"verbose_name_plural": "Google Workspace Provider Groups",
},
),
migrations.CreateModel(
name="GoogleWorkspaceProviderUser",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4, editable=False, primary_key=True, serialize=False
),
),
("google_id", models.TextField()),
(
"provider",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="authentik_providers_google_workspace.googleworkspaceprovider",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
),
),
],
options={
"unique_together": {("google_id", "user", "provider")},
"verbose_name": "Google Workspace Provider User",
"verbose_name_plural": "Google Workspace Provider Users",
},
),
]

View File

@ -1,27 +0,0 @@
# Generated by Django 5.0.6 on 2024-05-08 14:35
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("authentik_providers_google_workspace", "0001_initial"),
]
operations = [
migrations.AlterModelOptions(
name="googleworkspaceprovidergroup",
options={
"verbose_name": "Google Workspace Provider Group",
"verbose_name_plural": "Google Workspace Provider Groups",
},
),
migrations.AlterModelOptions(
name="googleworkspaceprovideruser",
options={
"verbose_name": "Google Workspace Provider User",
"verbose_name_plural": "Google Workspace Provider Users",
},
),
]

View File

@ -1,26 +0,0 @@
# Generated by Django 5.0.6 on 2024-05-23 20:48
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
(
"authentik_providers_google_workspace",
"0001_squashed_0002_alter_googleworkspaceprovidergroup_options_and_more",
),
]
operations = [
migrations.AddField(
model_name="googleworkspaceprovidergroup",
name="attributes",
field=models.JSONField(default=dict),
),
migrations.AddField(
model_name="googleworkspaceprovideruser",
name="attributes",
field=models.JSONField(default=dict),
),
]

View File

@ -1,199 +0,0 @@
"""Google workspace sync provider"""
from typing import Any, Self
from uuid import uuid4
from django.db import models
from django.db.models import QuerySet
from django.templatetags.static import static
from django.utils.translation import gettext_lazy as _
from google.oauth2.service_account import Credentials
from rest_framework.serializers import Serializer
from authentik.core.models import (
BackchannelProvider,
Group,
PropertyMapping,
User,
UserTypes,
)
from authentik.lib.models import SerializerModel
from authentik.lib.sync.outgoing.base import BaseOutgoingSyncClient
from authentik.lib.sync.outgoing.models import OutgoingSyncDeleteAction, OutgoingSyncProvider
def default_scopes() -> list[str]:
return [
"https://www.googleapis.com/auth/admin.directory.user",
"https://www.googleapis.com/auth/admin.directory.group",
"https://www.googleapis.com/auth/admin.directory.group.member",
"https://www.googleapis.com/auth/admin.directory.domain.readonly",
]
class GoogleWorkspaceProviderUser(SerializerModel):
"""Mapping of a user and provider to a Google user ID"""
id = models.UUIDField(primary_key=True, editable=False, default=uuid4)
google_id = models.TextField()
user = models.ForeignKey(User, on_delete=models.CASCADE)
provider = models.ForeignKey("GoogleWorkspaceProvider", on_delete=models.CASCADE)
attributes = models.JSONField(default=dict)
@property
def serializer(self) -> type[Serializer]:
from authentik.enterprise.providers.google_workspace.api.users import (
GoogleWorkspaceProviderUserSerializer,
)
return GoogleWorkspaceProviderUserSerializer
class Meta:
verbose_name = _("Google Workspace Provider User")
verbose_name_plural = _("Google Workspace Provider Users")
unique_together = (("google_id", "user", "provider"),)
def __str__(self) -> str:
return f"Google Workspace Provider User {self.user_id} to {self.provider_id}"
class GoogleWorkspaceProviderGroup(SerializerModel):
"""Mapping of a group and provider to a Google group ID"""
id = models.UUIDField(primary_key=True, editable=False, default=uuid4)
google_id = models.TextField()
group = models.ForeignKey(Group, on_delete=models.CASCADE)
provider = models.ForeignKey("GoogleWorkspaceProvider", on_delete=models.CASCADE)
attributes = models.JSONField(default=dict)
@property
def serializer(self) -> type[Serializer]:
from authentik.enterprise.providers.google_workspace.api.groups import (
GoogleWorkspaceProviderGroupSerializer,
)
return GoogleWorkspaceProviderGroupSerializer
class Meta:
verbose_name = _("Google Workspace Provider Group")
verbose_name_plural = _("Google Workspace Provider Groups")
unique_together = (("google_id", "group", "provider"),)
def __str__(self) -> str:
return f"Google Workspace Provider Group {self.group_id} to {self.provider_id}"
class GoogleWorkspaceProvider(OutgoingSyncProvider, BackchannelProvider):
"""Sync users from authentik into Google Workspace."""
delegated_subject = models.EmailField()
credentials = models.JSONField()
scopes = models.TextField(default=",".join(default_scopes()))
default_group_email_domain = models.TextField()
exclude_users_service_account = models.BooleanField(default=False)
user_delete_action = models.TextField(
choices=OutgoingSyncDeleteAction.choices, default=OutgoingSyncDeleteAction.DELETE
)
group_delete_action = models.TextField(
choices=OutgoingSyncDeleteAction.choices, default=OutgoingSyncDeleteAction.DELETE
)
filter_group = models.ForeignKey(
"authentik_core.group", on_delete=models.SET_DEFAULT, default=None, null=True
)
property_mappings_group = models.ManyToManyField(
PropertyMapping,
default=None,
blank=True,
help_text=_("Property mappings used for group creation/updating."),
)
def client_for_model(
self,
model: type[User | Group | GoogleWorkspaceProviderUser | GoogleWorkspaceProviderGroup],
) -> BaseOutgoingSyncClient[User | Group, Any, Any, Self]:
if issubclass(model, User | GoogleWorkspaceProviderUser):
from authentik.enterprise.providers.google_workspace.clients.users import (
GoogleWorkspaceUserClient,
)
return GoogleWorkspaceUserClient(self)
if issubclass(model, Group | GoogleWorkspaceProviderGroup):
from authentik.enterprise.providers.google_workspace.clients.groups import (
GoogleWorkspaceGroupClient,
)
return GoogleWorkspaceGroupClient(self)
raise ValueError(f"Invalid model {model}")
def get_object_qs(self, type: type[User | Group]) -> QuerySet[User | Group]:
if type == User:
# Get queryset of all users with consistent ordering
# according to the provider's settings
base = User.objects.all().exclude_anonymous()
if self.exclude_users_service_account:
base = base.exclude(type=UserTypes.SERVICE_ACCOUNT).exclude(
type=UserTypes.INTERNAL_SERVICE_ACCOUNT
)
if self.filter_group:
base = base.filter(ak_groups__in=[self.filter_group])
return base.order_by("pk")
if type == Group:
# Get queryset of all groups with consistent ordering
return Group.objects.all().order_by("pk")
raise ValueError(f"Invalid type {type}")
def google_credentials(self):
return {
"credentials": Credentials.from_service_account_info(
self.credentials, scopes=self.scopes.split(",")
).with_subject(self.delegated_subject),
}
@property
def icon_url(self) -> str | None:
return static("authentik/sources/google.svg")
@property
def component(self) -> str:
return "ak-provider-google-workspace-form"
@property
def serializer(self) -> type[Serializer]:
from authentik.enterprise.providers.google_workspace.api.providers import (
GoogleWorkspaceProviderSerializer,
)
return GoogleWorkspaceProviderSerializer
def __str__(self):
return f"Google Workspace Provider {self.name}"
class Meta:
verbose_name = _("Google Workspace Provider")
verbose_name_plural = _("Google Workspace Providers")
class GoogleWorkspaceProviderMapping(PropertyMapping):
"""Map authentik data to outgoing Google requests"""
@property
def component(self) -> str:
return "ak-property-mapping-google-workspace-form"
@property
def serializer(self) -> type[Serializer]:
from authentik.enterprise.providers.google_workspace.api.property_mappings import (
GoogleWorkspaceProviderMappingSerializer,
)
return GoogleWorkspaceProviderMappingSerializer
def __str__(self):
return f"Google Workspace Provider Mapping {self.name}"
class Meta:
verbose_name = _("Google Workspace Provider Mapping")
verbose_name_plural = _("Google Workspace Provider Mappings")

View File

@ -1,13 +0,0 @@
"""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"},
},
}

View File

@ -1,16 +0,0 @@
"""Google provider signals"""
from authentik.enterprise.providers.google_workspace.models import GoogleWorkspaceProvider
from authentik.enterprise.providers.google_workspace.tasks import (
google_workspace_sync,
google_workspace_sync_direct,
google_workspace_sync_m2m,
)
from authentik.lib.sync.outgoing.signals import register_signals
register_signals(
GoogleWorkspaceProvider,
task_sync_single=google_workspace_sync,
task_sync_direct=google_workspace_sync_direct,
task_sync_m2m=google_workspace_sync_m2m,
)

View File

@ -1,37 +0,0 @@
"""Google Provider tasks"""
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.root.celery import CELERY_APP
sync_tasks = SyncTasks(GoogleWorkspaceProvider)
@CELERY_APP.task(autoretry_for=(TransientSyncException,), retry_backoff=True)
def google_workspace_sync_objects(*args, **kwargs):
return sync_tasks.sync_objects(*args, **kwargs)
@CELERY_APP.task(
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"""
return sync_tasks.sync_single(self, provider_pk, google_workspace_sync_objects)
@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):
return sync_tasks.sync_signal_direct(*args, **kwargs)
@CELERY_APP.task(autoretry_for=(TransientSyncException,), retry_backoff=True)
def google_workspace_sync_m2m(*args, **kwargs):
return sync_tasks.sync_signal_m2m(*args, **kwargs)

View File

@ -1,14 +0,0 @@
{
"kind": "admin#directory#domains",
"etag": "\"a1kA7zE2sFLsHiFwgXN9G3effoc9grR2OwUu8_95xD4/uvC5HsKHylhnUtnRV6ZxINODtV0\"",
"domains": [
{
"kind": "admin#directory#domain",
"etag": "\"a1kA7zE2sFLsHiFwgXN9G3effoc9grR2OwUu8_95xD4/V4koSPWBFIWuIpAmUamO96QhTLo\"",
"domainName": "goauthentik.io",
"isPrimary": true,
"verified": true,
"creationTime": "1543048869840"
}
]
}

View File

@ -1,334 +0,0 @@
"""Google Workspace Group tests"""
from unittest.mock import MagicMock, patch
from django.test import TestCase
from authentik.blueprints.tests import apply_blueprint
from authentik.core.models import Application, Group, User
from authentik.core.tests.utils import create_test_user
from authentik.enterprise.providers.google_workspace.clients.test_http import MockHTTP
from authentik.enterprise.providers.google_workspace.models import (
GoogleWorkspaceProvider,
GoogleWorkspaceProviderGroup,
GoogleWorkspaceProviderMapping,
)
from authentik.enterprise.providers.google_workspace.tasks import google_workspace_sync
from authentik.events.models import Event, EventAction
from authentik.lib.generators import generate_id
from authentik.lib.sync.outgoing.models import OutgoingSyncDeleteAction
from authentik.lib.tests.utils import load_fixture
from authentik.tenants.models import Tenant
domains_list_v1_mock = load_fixture("fixtures/domains_list_v1.json")
class GoogleWorkspaceGroupTests(TestCase):
"""Google workspace Group tests"""
@apply_blueprint("system/providers-google-workspace.yaml")
def setUp(self) -> None:
# Delete all groups and groups as the mocked HTTP responses only return one ID
# which will cause errors with multiple groups
Tenant.objects.update(avatars="none")
User.objects.all().exclude_anonymous().delete()
Group.objects.all().delete()
self.provider: GoogleWorkspaceProvider = GoogleWorkspaceProvider.objects.create(
name=generate_id(),
credentials={},
delegated_subject="",
exclude_users_service_account=True,
default_group_email_domain="goauthentik.io",
)
self.app: Application = Application.objects.create(
name=generate_id(),
slug=generate_id(),
)
self.app.backchannel_providers.add(self.provider)
self.provider.property_mappings.add(
GoogleWorkspaceProviderMapping.objects.get(
managed="goauthentik.io/providers/google_workspace/user"
)
)
self.provider.property_mappings_group.add(
GoogleWorkspaceProviderMapping.objects.get(
managed="goauthentik.io/providers/google_workspace/group"
)
)
self.api_key = generate_id()
def test_group_create(self):
"""Test group creation"""
uid = generate_id()
http = MockHTTP()
http.add_response(
f"https://admin.googleapis.com/admin/directory/v1/customer/my_customer/domains?key={self.api_key}&alt=json",
domains_list_v1_mock,
)
http.add_response(
f"https://admin.googleapis.com/admin/directory/v1/groups?key={self.api_key}&alt=json",
method="POST",
body={"id": generate_id()},
)
with patch(
"authentik.enterprise.providers.google_workspace.models.GoogleWorkspaceProvider.google_credentials",
MagicMock(return_value={"developerKey": self.api_key, "http": http}),
):
group = Group.objects.create(name=uid)
google_group = GoogleWorkspaceProviderGroup.objects.filter(
provider=self.provider, group=group
).first()
self.assertIsNotNone(google_group)
self.assertFalse(Event.objects.filter(action=EventAction.SYSTEM_EXCEPTION).exists())
self.assertEqual(len(http.requests()), 2)
def test_group_not_created(self):
"""Test without group property mappings, no group is created"""
self.provider.property_mappings_group.clear()
uid = generate_id()
http = MockHTTP()
http.add_response(
f"https://admin.googleapis.com/admin/directory/v1/customer/my_customer/domains?key={self.api_key}&alt=json",
domains_list_v1_mock,
)
with patch(
"authentik.enterprise.providers.google_workspace.models.GoogleWorkspaceProvider.google_credentials",
MagicMock(return_value={"developerKey": self.api_key, "http": http}),
):
group = Group.objects.create(name=uid)
google_group = GoogleWorkspaceProviderGroup.objects.filter(
provider=self.provider, group=group
).first()
self.assertIsNone(google_group)
self.assertFalse(Event.objects.filter(action=EventAction.SYSTEM_EXCEPTION).exists())
self.assertEqual(len(http.requests()), 1)
def test_group_create_update(self):
"""Test group updating"""
uid = generate_id()
ext_id = generate_id()
http = MockHTTP()
http.add_response(
f"https://admin.googleapis.com/admin/directory/v1/customer/my_customer/domains?key={self.api_key}&alt=json",
domains_list_v1_mock,
)
http.add_response(
f"https://admin.googleapis.com/admin/directory/v1/groups?key={self.api_key}&alt=json",
method="POST",
body={"id": ext_id},
)
http.add_response(
f"https://admin.googleapis.com/admin/directory/v1/groups/{ext_id}?key={self.api_key}&alt=json",
method="PUT",
body={"id": ext_id},
)
with patch(
"authentik.enterprise.providers.google_workspace.models.GoogleWorkspaceProvider.google_credentials",
MagicMock(return_value={"developerKey": self.api_key, "http": http}),
):
group = Group.objects.create(name=uid)
google_group = GoogleWorkspaceProviderGroup.objects.filter(
provider=self.provider, group=group
).first()
self.assertIsNotNone(google_group)
group.name = "new name"
group.save()
self.assertFalse(Event.objects.filter(action=EventAction.SYSTEM_EXCEPTION).exists())
self.assertEqual(len(http.requests()), 4)
def test_group_create_delete(self):
"""Test group deletion"""
uid = generate_id()
ext_id = generate_id()
http = MockHTTP()
http.add_response(
f"https://admin.googleapis.com/admin/directory/v1/customer/my_customer/domains?key={self.api_key}&alt=json",
domains_list_v1_mock,
)
http.add_response(
f"https://admin.googleapis.com/admin/directory/v1/groups?key={self.api_key}&alt=json",
method="POST",
body={"id": ext_id},
)
http.add_response(
f"https://admin.googleapis.com/admin/directory/v1/groups/{ext_id}?key={self.api_key}",
method="DELETE",
)
with patch(
"authentik.enterprise.providers.google_workspace.models.GoogleWorkspaceProvider.google_credentials",
MagicMock(return_value={"developerKey": self.api_key, "http": http}),
):
group = Group.objects.create(name=uid)
google_group = GoogleWorkspaceProviderGroup.objects.filter(
provider=self.provider, group=group
).first()
self.assertIsNotNone(google_group)
group.delete()
self.assertFalse(Event.objects.filter(action=EventAction.SYSTEM_EXCEPTION).exists())
self.assertEqual(len(http.requests()), 4)
def test_group_create_member_add(self):
"""Test group creation"""
uid = generate_id()
ext_id = generate_id()
http = MockHTTP()
http.add_response(
f"https://admin.googleapis.com/admin/directory/v1/customer/my_customer/domains?key={self.api_key}&alt=json",
domains_list_v1_mock,
)
http.add_response(
f"https://admin.googleapis.com/admin/directory/v1/groups?key={self.api_key}&alt=json",
method="POST",
body={"id": ext_id},
)
http.add_response(
f"https://admin.googleapis.com/admin/directory/v1/users?key={self.api_key}&alt=json",
method="POST",
body={"primaryEmail": f"{uid}@goauthentik.io"},
)
http.add_response(
f"https://admin.googleapis.com/admin/directory/v1/users/{uid}%40goauthentik.io?key={self.api_key}&alt=json",
method="PUT",
body={"primaryEmail": f"{uid}@goauthentik.io"},
)
http.add_response(
f"https://admin.googleapis.com/admin/directory/v1/groups/{ext_id}/members?key={self.api_key}&alt=json",
method="POST",
)
with patch(
"authentik.enterprise.providers.google_workspace.models.GoogleWorkspaceProvider.google_credentials",
MagicMock(return_value={"developerKey": self.api_key, "http": http}),
):
user = create_test_user(uid)
group = Group.objects.create(name=uid)
group.users.add(user)
google_group = GoogleWorkspaceProviderGroup.objects.filter(
provider=self.provider, group=group
).first()
self.assertIsNotNone(google_group)
self.assertFalse(Event.objects.filter(action=EventAction.SYSTEM_EXCEPTION).exists())
self.assertEqual(len(http.requests()), 8)
def test_group_create_member_remove(self):
"""Test group creation"""
uid = generate_id()
ext_id = generate_id()
http = MockHTTP()
http.add_response(
f"https://admin.googleapis.com/admin/directory/v1/customer/my_customer/domains?key={self.api_key}&alt=json",
domains_list_v1_mock,
)
http.add_response(
f"https://admin.googleapis.com/admin/directory/v1/groups?key={self.api_key}&alt=json",
method="POST",
body={"id": ext_id},
)
http.add_response(
f"https://admin.googleapis.com/admin/directory/v1/users?key={self.api_key}&alt=json",
method="POST",
body={"primaryEmail": f"{uid}@goauthentik.io"},
)
http.add_response(
f"https://admin.googleapis.com/admin/directory/v1/users/{uid}%40goauthentik.io?key={self.api_key}&alt=json",
method="PUT",
body={"primaryEmail": f"{uid}@goauthentik.io"},
)
http.add_response(
f"https://admin.googleapis.com/admin/directory/v1/groups/{ext_id}/members/{uid}%40goauthentik.io?key={self.api_key}",
method="DELETE",
)
http.add_response(
f"https://admin.googleapis.com/admin/directory/v1/groups/{ext_id}/members?key={self.api_key}&alt=json",
method="POST",
)
with patch(
"authentik.enterprise.providers.google_workspace.models.GoogleWorkspaceProvider.google_credentials",
MagicMock(return_value={"developerKey": self.api_key, "http": http}),
):
user = create_test_user(uid)
group = Group.objects.create(name=uid)
group.users.add(user)
google_group = GoogleWorkspaceProviderGroup.objects.filter(
provider=self.provider, group=group
).first()
self.assertIsNotNone(google_group)
group.users.remove(user)
self.assertFalse(Event.objects.filter(action=EventAction.SYSTEM_EXCEPTION).exists())
self.assertEqual(len(http.requests()), 10)
def test_group_create_delete_do_nothing(self):
"""Test group deletion (delete action = do nothing)"""
self.provider.group_delete_action = OutgoingSyncDeleteAction.DO_NOTHING
self.provider.save()
uid = generate_id()
http = MockHTTP()
http.add_response(
f"https://admin.googleapis.com/admin/directory/v1/customer/my_customer/domains?key={self.api_key}&alt=json",
domains_list_v1_mock,
)
http.add_response(
f"https://admin.googleapis.com/admin/directory/v1/groups?key={self.api_key}&alt=json",
method="POST",
body={"id": uid},
)
with patch(
"authentik.enterprise.providers.google_workspace.models.GoogleWorkspaceProvider.google_credentials",
MagicMock(return_value={"developerKey": self.api_key, "http": http}),
):
group = Group.objects.create(name=uid)
google_group = GoogleWorkspaceProviderGroup.objects.filter(
provider=self.provider, group=group
).first()
self.assertIsNotNone(google_group)
group.delete()
self.assertEqual(len(http.requests()), 3)
self.assertFalse(
GoogleWorkspaceProviderGroup.objects.filter(
provider=self.provider, group__name=uid
).exists()
)
def test_sync_task(self):
"""Test group discovery"""
uid = generate_id()
http = MockHTTP()
http.add_response(
f"https://admin.googleapis.com/admin/directory/v1/customer/my_customer/domains?key={self.api_key}&alt=json",
domains_list_v1_mock,
)
http.add_response(
f"https://admin.googleapis.com/admin/directory/v1/users?customer=my_customer&maxResults=500&orderBy=email&key={self.api_key}&alt=json",
method="GET",
body={"users": []},
)
http.add_response(
f"https://admin.googleapis.com/admin/directory/v1/groups?customer=my_customer&maxResults=500&orderBy=email&key={self.api_key}&alt=json",
method="GET",
body={"groups": [{"id": uid, "name": uid}]},
)
http.add_response(
f"https://admin.googleapis.com/admin/directory/v1/groups/{uid}?key={self.api_key}&alt=json",
method="PUT",
body={"id": uid},
)
self.app.backchannel_providers.remove(self.provider)
different_group = Group.objects.create(
name=uid,
)
self.app.backchannel_providers.add(self.provider)
with patch(
"authentik.enterprise.providers.google_workspace.models.GoogleWorkspaceProvider.google_credentials",
MagicMock(return_value={"developerKey": self.api_key, "http": http}),
):
google_workspace_sync.delay(self.provider.pk).get()
self.assertTrue(
GoogleWorkspaceProviderGroup.objects.filter(
group=different_group, provider=self.provider
).exists()
)
self.assertFalse(Event.objects.filter(action=EventAction.SYSTEM_EXCEPTION).exists())
self.assertEqual(len(http.requests()), 5)

View File

@ -1,312 +0,0 @@
"""Google Workspace User tests"""
from json import loads
from unittest.mock import MagicMock, patch
from django.test import TestCase
from authentik.blueprints.tests import apply_blueprint
from authentik.core.models import Application, Group, User
from authentik.enterprise.providers.google_workspace.clients.test_http import MockHTTP
from authentik.enterprise.providers.google_workspace.models import (
GoogleWorkspaceProvider,
GoogleWorkspaceProviderMapping,
GoogleWorkspaceProviderUser,
)
from authentik.enterprise.providers.google_workspace.tasks import google_workspace_sync
from authentik.events.models import Event, EventAction
from authentik.lib.generators import generate_id
from authentik.lib.sync.outgoing.models import OutgoingSyncDeleteAction
from authentik.lib.tests.utils import load_fixture
from authentik.tenants.models import Tenant
domains_list_v1_mock = load_fixture("fixtures/domains_list_v1.json")
class GoogleWorkspaceUserTests(TestCase):
"""Google workspace User tests"""
@apply_blueprint("system/providers-google-workspace.yaml")
def setUp(self) -> None:
# Delete all users and groups as the mocked HTTP responses only return one ID
# which will cause errors with multiple users
Tenant.objects.update(avatars="none")
User.objects.all().exclude_anonymous().delete()
Group.objects.all().delete()
self.provider: GoogleWorkspaceProvider = GoogleWorkspaceProvider.objects.create(
name=generate_id(),
credentials={},
delegated_subject="",
exclude_users_service_account=True,
default_group_email_domain="goauthentik.io",
)
self.app: Application = Application.objects.create(
name=generate_id(),
slug=generate_id(),
)
self.app.backchannel_providers.add(self.provider)
self.provider.property_mappings.add(
GoogleWorkspaceProviderMapping.objects.get(
managed="goauthentik.io/providers/google_workspace/user"
)
)
self.provider.property_mappings_group.add(
GoogleWorkspaceProviderMapping.objects.get(
managed="goauthentik.io/providers/google_workspace/group"
)
)
self.api_key = generate_id()
def test_user_create(self):
"""Test user creation"""
uid = generate_id()
http = MockHTTP()
http.add_response(
f"https://admin.googleapis.com/admin/directory/v1/customer/my_customer/domains?key={self.api_key}&alt=json",
domains_list_v1_mock,
)
http.add_response(
f"https://admin.googleapis.com/admin/directory/v1/users?key={self.api_key}&alt=json",
method="POST",
body={"primaryEmail": f"{uid}@goauthentik.io"},
)
with patch(
"authentik.enterprise.providers.google_workspace.models.GoogleWorkspaceProvider.google_credentials",
MagicMock(return_value={"developerKey": self.api_key, "http": http}),
):
user = User.objects.create(
username=uid,
name=f"{uid} {uid}",
email=f"{uid}@goauthentik.io",
)
google_user = GoogleWorkspaceProviderUser.objects.filter(
provider=self.provider, user=user
).first()
self.assertIsNotNone(google_user)
self.assertFalse(Event.objects.filter(action=EventAction.SYSTEM_EXCEPTION).exists())
self.assertEqual(len(http.requests()), 2)
def test_user_not_created(self):
"""Test without property mappings, no group is created"""
self.provider.property_mappings.clear()
uid = generate_id()
http = MockHTTP()
http.add_response(
f"https://admin.googleapis.com/admin/directory/v1/customer/my_customer/domains?key={self.api_key}&alt=json",
domains_list_v1_mock,
)
with patch(
"authentik.enterprise.providers.google_workspace.models.GoogleWorkspaceProvider.google_credentials",
MagicMock(return_value={"developerKey": self.api_key, "http": http}),
):
user = User.objects.create(
username=uid,
name=f"{uid} {uid}",
email=f"{uid}@goauthentik.io",
)
google_user = GoogleWorkspaceProviderUser.objects.filter(
provider=self.provider, user=user
).first()
self.assertIsNone(google_user)
self.assertFalse(Event.objects.filter(action=EventAction.SYSTEM_EXCEPTION).exists())
self.assertEqual(len(http.requests()), 1)
def test_user_create_update(self):
"""Test user updating"""
uid = generate_id()
http = MockHTTP()
http.add_response(
f"https://admin.googleapis.com/admin/directory/v1/customer/my_customer/domains?key={self.api_key}&alt=json",
domains_list_v1_mock,
)
http.add_response(
f"https://admin.googleapis.com/admin/directory/v1/users?key={self.api_key}&alt=json",
method="POST",
body={"primaryEmail": f"{uid}@goauthentik.io"},
)
http.add_response(
f"https://admin.googleapis.com/admin/directory/v1/users/{uid}%40goauthentik.io?key={self.api_key}&alt=json",
method="PUT",
body={"primaryEmail": f"{uid}@goauthentik.io"},
)
with patch(
"authentik.enterprise.providers.google_workspace.models.GoogleWorkspaceProvider.google_credentials",
MagicMock(return_value={"developerKey": self.api_key, "http": http}),
):
user = User.objects.create(
username=uid,
name=f"{uid} {uid}",
email=f"{uid}@goauthentik.io",
)
google_user = GoogleWorkspaceProviderUser.objects.filter(
provider=self.provider, user=user
).first()
self.assertIsNotNone(google_user)
user.name = "new name"
user.save()
self.assertFalse(Event.objects.filter(action=EventAction.SYSTEM_EXCEPTION).exists())
self.assertEqual(len(http.requests()), 4)
def test_user_create_delete(self):
"""Test user deletion"""
uid = generate_id()
http = MockHTTP()
http.add_response(
f"https://admin.googleapis.com/admin/directory/v1/customer/my_customer/domains?key={self.api_key}&alt=json",
domains_list_v1_mock,
)
http.add_response(
f"https://admin.googleapis.com/admin/directory/v1/users?key={self.api_key}&alt=json",
method="POST",
body={"primaryEmail": f"{uid}@goauthentik.io"},
)
http.add_response(
f"https://admin.googleapis.com/admin/directory/v1/users/{uid}%40goauthentik.io?key={self.api_key}",
method="DELETE",
)
with patch(
"authentik.enterprise.providers.google_workspace.models.GoogleWorkspaceProvider.google_credentials",
MagicMock(return_value={"developerKey": self.api_key, "http": http}),
):
user = User.objects.create(
username=uid,
name=f"{uid} {uid}",
email=f"{uid}@goauthentik.io",
)
google_user = GoogleWorkspaceProviderUser.objects.filter(
provider=self.provider, user=user
).first()
self.assertIsNotNone(google_user)
user.delete()
self.assertFalse(Event.objects.filter(action=EventAction.SYSTEM_EXCEPTION).exists())
self.assertEqual(len(http.requests()), 4)
def test_user_create_delete_suspend(self):
"""Test user deletion (delete action = Suspend)"""
self.provider.user_delete_action = OutgoingSyncDeleteAction.SUSPEND
self.provider.save()
uid = generate_id()
http = MockHTTP()
http.add_response(
f"https://admin.googleapis.com/admin/directory/v1/customer/my_customer/domains?key={self.api_key}&alt=json",
domains_list_v1_mock,
)
http.add_response(
f"https://admin.googleapis.com/admin/directory/v1/users?key={self.api_key}&alt=json",
method="POST",
body={"primaryEmail": f"{uid}@goauthentik.io"},
)
http.add_response(
f"https://admin.googleapis.com/admin/directory/v1/users/{uid}%40goauthentik.io?key={self.api_key}&alt=json",
method="PUT",
body={"primaryEmail": f"{uid}@goauthentik.io"},
)
with patch(
"authentik.enterprise.providers.google_workspace.models.GoogleWorkspaceProvider.google_credentials",
MagicMock(return_value={"developerKey": self.api_key, "http": http}),
):
user = User.objects.create(
username=uid,
name=f"{uid} {uid}",
email=f"{uid}@goauthentik.io",
)
google_user = GoogleWorkspaceProviderUser.objects.filter(
provider=self.provider, user=user
).first()
self.assertIsNotNone(google_user)
user.delete()
self.assertEqual(len(http.requests()), 4)
_, _, body, _ = http.requests()[3]
self.assertEqual(
loads(body),
{
"suspended": True,
},
)
self.assertFalse(
GoogleWorkspaceProviderUser.objects.filter(
provider=self.provider, user__username=uid
).exists()
)
def test_user_create_delete_do_nothing(self):
"""Test user deletion (delete action = do nothing)"""
self.provider.user_delete_action = OutgoingSyncDeleteAction.DO_NOTHING
self.provider.save()
uid = generate_id()
http = MockHTTP()
http.add_response(
f"https://admin.googleapis.com/admin/directory/v1/customer/my_customer/domains?key={self.api_key}&alt=json",
domains_list_v1_mock,
)
http.add_response(
f"https://admin.googleapis.com/admin/directory/v1/users?key={self.api_key}&alt=json",
method="POST",
body={"primaryEmail": f"{uid}@goauthentik.io"},
)
with patch(
"authentik.enterprise.providers.google_workspace.models.GoogleWorkspaceProvider.google_credentials",
MagicMock(return_value={"developerKey": self.api_key, "http": http}),
):
user = User.objects.create(
username=uid,
name=f"{uid} {uid}",
email=f"{uid}@goauthentik.io",
)
google_user = GoogleWorkspaceProviderUser.objects.filter(
provider=self.provider, user=user
).first()
self.assertIsNotNone(google_user)
user.delete()
self.assertEqual(len(http.requests()), 3)
self.assertFalse(
GoogleWorkspaceProviderUser.objects.filter(
provider=self.provider, user__username=uid
).exists()
)
def test_sync_task(self):
"""Test user discovery"""
uid = generate_id()
http = MockHTTP()
http.add_response(
f"https://admin.googleapis.com/admin/directory/v1/customer/my_customer/domains?key={self.api_key}&alt=json",
domains_list_v1_mock,
)
http.add_response(
f"https://admin.googleapis.com/admin/directory/v1/users?customer=my_customer&maxResults=500&orderBy=email&key={self.api_key}&alt=json",
method="GET",
body={"users": [{"primaryEmail": f"{uid}@goauthentik.io"}]},
)
http.add_response(
f"https://admin.googleapis.com/admin/directory/v1/groups?customer=my_customer&maxResults=500&orderBy=email&key={self.api_key}&alt=json",
method="GET",
body={"groups": []},
)
http.add_response(
f"https://admin.googleapis.com/admin/directory/v1/users/{uid}%40goauthentik.io?key={self.api_key}&alt=json",
method="PUT",
body={"primaryEmail": f"{uid}@goauthentik.io"},
)
self.app.backchannel_providers.remove(self.provider)
different_user = User.objects.create(
username=uid,
email=f"{uid}@goauthentik.io",
)
self.app.backchannel_providers.add(self.provider)
with patch(
"authentik.enterprise.providers.google_workspace.models.GoogleWorkspaceProvider.google_credentials",
MagicMock(return_value={"developerKey": self.api_key, "http": http}),
):
google_workspace_sync.delay(self.provider.pk).get()
self.assertTrue(
GoogleWorkspaceProviderUser.objects.filter(
user=different_user, provider=self.provider
).exists()
)
self.assertFalse(Event.objects.filter(action=EventAction.SYSTEM_EXCEPTION).exists())
self.assertEqual(len(http.requests()), 5)

View File

@ -1,21 +0,0 @@
"""google provider urls"""
from authentik.enterprise.providers.google_workspace.api.groups import (
GoogleWorkspaceProviderGroupViewSet,
)
from authentik.enterprise.providers.google_workspace.api.property_mappings import (
GoogleWorkspaceProviderMappingViewSet,
)
from authentik.enterprise.providers.google_workspace.api.providers import (
GoogleWorkspaceProviderViewSet,
)
from authentik.enterprise.providers.google_workspace.api.users import (
GoogleWorkspaceProviderUserViewSet,
)
api_urlpatterns = [
("providers/google_workspace", GoogleWorkspaceProviderViewSet),
("providers/google_workspace_users", GoogleWorkspaceProviderUserViewSet),
("providers/google_workspace_groups", GoogleWorkspaceProviderGroupViewSet),
("propertymappings/provider/google_workspace", GoogleWorkspaceProviderMappingViewSet),
]

View File

@ -1,47 +0,0 @@
"""MicrosoftEntraProviderGroup API Views"""
from rest_framework import mixins
from rest_framework.viewsets import GenericViewSet
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.users import UserGroupSerializer
from authentik.core.api.utils import ModelSerializer
from authentik.enterprise.providers.microsoft_entra.models import MicrosoftEntraProviderGroup
from authentik.lib.sync.outgoing.api import OutgoingSyncConnectionCreateMixin
class MicrosoftEntraProviderGroupSerializer(ModelSerializer):
"""MicrosoftEntraProviderGroup Serializer"""
group_obj = UserGroupSerializer(source="group", read_only=True)
class Meta:
model = MicrosoftEntraProviderGroup
fields = [
"id",
"microsoft_id",
"group",
"group_obj",
"provider",
"attributes",
]
extra_kwargs = {"attributes": {"read_only": True}}
class MicrosoftEntraProviderGroupViewSet(
mixins.CreateModelMixin,
OutgoingSyncConnectionCreateMixin,
mixins.RetrieveModelMixin,
mixins.DestroyModelMixin,
UsedByMixin,
mixins.ListModelMixin,
GenericViewSet,
):
"""MicrosoftEntraProviderGroup Viewset"""
queryset = MicrosoftEntraProviderGroup.objects.all().select_related("group")
serializer_class = MicrosoftEntraProviderGroupSerializer
filterset_fields = ["provider__id", "group__name", "group__group_uuid"]
search_fields = ["provider__name", "group__name"]
ordering = ["group__name"]

View File

@ -1,39 +0,0 @@
"""microsoft Property mappings API Views"""
from django_filters.filters import AllValuesMultipleFilter
from django_filters.filterset import FilterSet
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field
from rest_framework.viewsets import ModelViewSet
from authentik.core.api.property_mappings import PropertyMappingSerializer
from authentik.core.api.used_by import UsedByMixin
from authentik.enterprise.providers.microsoft_entra.models import MicrosoftEntraProviderMapping
class MicrosoftEntraProviderMappingSerializer(PropertyMappingSerializer):
"""MicrosoftEntraProviderMapping Serializer"""
class Meta:
model = MicrosoftEntraProviderMapping
fields = PropertyMappingSerializer.Meta.fields
class MicrosoftEntraProviderMappingFilter(FilterSet):
"""Filter for MicrosoftEntraProviderMapping"""
managed = extend_schema_field(OpenApiTypes.STR)(AllValuesMultipleFilter(field_name="managed"))
class Meta:
model = MicrosoftEntraProviderMapping
fields = "__all__"
class MicrosoftEntraProviderMappingViewSet(UsedByMixin, ModelViewSet):
"""MicrosoftEntraProviderMapping Viewset"""
queryset = MicrosoftEntraProviderMapping.objects.all()
serializer_class = MicrosoftEntraProviderMappingSerializer
filterset_class = MicrosoftEntraProviderMappingFilter
search_fields = ["name"]
ordering = ["name"]

View File

@ -1,52 +0,0 @@
"""Microsoft Provider API Views"""
from rest_framework.viewsets import ModelViewSet
from authentik.core.api.providers import ProviderSerializer
from authentik.core.api.used_by import UsedByMixin
from authentik.enterprise.api import EnterpriseRequiredMixin
from authentik.enterprise.providers.microsoft_entra.models import MicrosoftEntraProvider
from authentik.enterprise.providers.microsoft_entra.tasks import microsoft_entra_sync
from authentik.lib.sync.outgoing.api import OutgoingSyncProviderStatusMixin
class MicrosoftEntraProviderSerializer(EnterpriseRequiredMixin, ProviderSerializer):
"""MicrosoftEntraProvider Serializer"""
class Meta:
model = MicrosoftEntraProvider
fields = [
"pk",
"name",
"property_mappings",
"property_mappings_group",
"component",
"assigned_backchannel_application_slug",
"assigned_backchannel_application_name",
"verbose_name",
"verbose_name_plural",
"meta_model_name",
"client_id",
"client_secret",
"tenant_id",
"exclude_users_service_account",
"filter_group",
"user_delete_action",
"group_delete_action",
]
extra_kwargs = {}
class MicrosoftEntraProviderViewSet(OutgoingSyncProviderStatusMixin, UsedByMixin, ModelViewSet):
"""MicrosoftEntraProvider Viewset"""
queryset = MicrosoftEntraProvider.objects.all()
serializer_class = MicrosoftEntraProviderSerializer
filterset_fields = [
"name",
"exclude_users_service_account",
"filter_group",
]
search_fields = ["name"]
ordering = ["name"]
sync_single_task = microsoft_entra_sync

View File

@ -1,47 +0,0 @@
"""MicrosoftEntraProviderUser API Views"""
from rest_framework import mixins
from rest_framework.viewsets import GenericViewSet
from authentik.core.api.groups import GroupMemberSerializer
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import ModelSerializer
from authentik.enterprise.providers.microsoft_entra.models import MicrosoftEntraProviderUser
from authentik.lib.sync.outgoing.api import OutgoingSyncConnectionCreateMixin
class MicrosoftEntraProviderUserSerializer(ModelSerializer):
"""MicrosoftEntraProviderUser Serializer"""
user_obj = GroupMemberSerializer(source="user", read_only=True)
class Meta:
model = MicrosoftEntraProviderUser
fields = [
"id",
"microsoft_id",
"user",
"user_obj",
"provider",
"attributes",
]
extra_kwargs = {"attributes": {"read_only": True}}
class MicrosoftEntraProviderUserViewSet(
OutgoingSyncConnectionCreateMixin,
mixins.CreateModelMixin,
mixins.RetrieveModelMixin,
mixins.DestroyModelMixin,
UsedByMixin,
mixins.ListModelMixin,
GenericViewSet,
):
"""MicrosoftEntraProviderUser Viewset"""
queryset = MicrosoftEntraProviderUser.objects.all().select_related("user")
serializer_class = MicrosoftEntraProviderUserSerializer
filterset_fields = ["provider__id", "user__username", "user__id"]
search_fields = ["provider__name", "user__username"]
ordering = ["user__username"]

View File

@ -1,9 +0,0 @@
from authentik.enterprise.apps import EnterpriseConfig
class AuthentikEnterpriseProviderMicrosoftEntraConfig(EnterpriseConfig):
name = "authentik.enterprise.providers.microsoft_entra"
label = "authentik_providers_microsoft_entra"
verbose_name = "authentik Enterprise.Providers.Microsoft Entra"
default = True

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