Compare commits

..

47 Commits

Author SHA1 Message Date
59401e07ad release: 2024.12.5 2025-04-08 14:54:17 -03:00
4008f0f28f Revert "core: fix non-exploitable open redirect (#13696)" (#13827) 2025-04-08 19:16:35 +02:00
210fbb0067 release: 2024.12.4 2025-03-28 14:48:01 +01:00
9c081d084d security: fix CVE-2025-29928 (cherry-pick #13695) (#13701)
security: fix CVE-2025-29928 (#13695)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2025-03-28 14:45:42 +01:00
c7c75dc195 core: fix non-exploitable open redirect (#13696)
discovered by @dominic-r

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
# Conflicts:
#	authentik/core/sources/flow_manager.py
2025-03-28 14:19:55 +01:00
5448dc93db flows: fix inspector permission check (#12907)
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-02-01 01:54:55 +01:00
6d152bcc60 core: fix generic sources not being fetchable by pk (#12896)
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-02-01 00:40:53 +01:00
f1b7a9f934 release: 2024.12.3 2025-01-29 21:47:30 +01:00
4af75d0979 ci: fix missing dockerhub login
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-01-29 21:47:23 +01:00
af0a314e0b ci: fix permissions for release-publish pipeline
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-01-29 19:24:17 +01:00
6c7f901220 ci: fix test_docker.sh (cherry-pick #12880) (#12881)
ci: fix test_docker.sh (#12880)

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2025-01-29 18:52:54 +01:00
3570bfa39d ci: fix test_docker.sh (cherry-pick #12878) (#12879)
ci: fix test_docker.sh (#12878)

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2025-01-29 18:42:20 +01:00
35ab51e4c5 ci: fix test_docker.sh failing due to empty .env (cherry-pick #12876) (#12877)
ci: fix test_docker.sh failing due to empty .env (#12876)

Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2025-01-29 18:33:04 +01:00
22eaf97d62 ci: fix test_docker.sh failing due to missing .env (cherry-pick #12873) (#12874)
ci: fix test_docker.sh failing due to missing .env (#12873)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2025-01-29 17:10:29 +01:00
764b211bd4 lifecycle: better pre release test (cherry-pick #12806) (#12808)
lifecycle: better pre release test (#12806)

* move pre-release docker test to script



* set pipefail in ak



* don't reinstall wheels since they don't exist anymore



* fix image



* fix config error on startup



---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2025-01-25 02:28:23 +01:00
7afc59d691 rbac: exclude permissions for internal models (cherry-pick #12803) (#12807)
rbac: exclude permissions for internal models (#12803)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2025-01-25 02:07:16 +01:00
349572bfe4 flows: clear flow state before redirecting to final URL (cherry-pick #12788) (#12801)
flows: clear flow state before redirecting to final URL (#12788)

* providers/oauth2: clear flow state before redirecting to final URL



* make flow executor invocation correct



* actually we can do this centrally



* make sure the state is really clean



---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2025-01-24 18:40:02 +01:00
bef55bc3a5 core: fix permissions for admin device listing (cherry-pick #12787) (#12791)
core: fix permissions for admin device listing (#12787)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2025-01-24 15:05:23 +01:00
d86da24c01 lifecycle: update python to 3.12.8 (cherry-pick #12783) (#12786)
lifecycle: update python to 3.12.8 (#12783)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2025-01-23 23:37:48 +01:00
25d0ee02a8 core: fix application entitlements not createable with blueprints (cherry-pick #12673) (#12784)
core: fix application entitlements not createable with blueprints (#12673)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2025-01-23 16:55:16 +01:00
bccfb0b48c sources: allow uuid or slug to be used for retrieving a source (2024.12 fix) (#12772)
sources: allow uuid or slug to be used for retrieving a source

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-01-23 12:26:48 +01:00
4b14eca2da ci: fix missing build args for dev and release (cherry-pick #12760) (#12761)
ci: fix missing build args for dev and release (#12760)

* ci: fix missing build args for dev and release



* fix?



---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2025-01-22 14:32:15 +01:00
7d5cfb6356 lifecycle: fix cryptography's OpenSSL path (cherry-pick #12753) (#12759)
lifecycle: fix cryptography's OpenSSL path (#12753)

* lifecycle: make it work



* sigh



* I dont know why this works but it works



---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2025-01-22 02:32:44 +01:00
7ce46ccbe0 stages/redirect: fix query parameter when redirecting to flow (cherry-pick #12750) (#12752)
stages/redirect: fix query parameter when redirecting to flow (#12750)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2025-01-21 18:07:59 +01:00
d0217c9135 lifecycle: build binary dependencies which link against SSL directly (#12724)
* lifecycle: install binary dependencies in dockerfile directly

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* install ua-parser-builtins manually as its only distributed as binary

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* build duo_client from scratch, sigh

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* deps for kadmin

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* ok fine

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* run on arm runner?

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix yaml format

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* rewrite release pipeline to use re-usable workflows

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix typo

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* re-usable multi-arch build?

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* also add suffix for amd64

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* parameterise image name

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* re-use workflow for CI images...?

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix missing checkout

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* inherit secrets

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* temp build directly

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* get cache-to from python script

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* better name?

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* matrix for merging images?

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* re-add build dep

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* use multi-image tag

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* include arch in buildcache

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
# Conflicts:
#	.github/workflows/ci-main.yml
#	.github/workflows/release-publish.yml
2025-01-21 15:39:32 +01:00
d82ba344d9 ci: release: fix AWS cfn template permissions (cherry-pick #12576) (#12739)
ci: release: fix AWS cfn template permissions (#12576)

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2025-01-20 17:28:36 +01:00
01959132e8 enterprise/rac: Improve client connection status & bugfixes (cherry-pick #12684) (#12727)
enterprise/rac: Improve client connection status & bugfixes (#12684)

* enterprise/rac: improve status message when connecting/connection failed



* set fixed DPI



* automatically set resize method for RDP



---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2025-01-17 18:27:31 +01:00
9d81f0598c release: 2024.12.2 2025-01-09 17:43:00 +01:00
cbe429f3fa providers/saml: fix invalid SAML Response when assertion and response are signed (cherry-pick #12611) (#12613)
providers/saml: fix invalid SAML Response when assertion and response are signed (#12611)

* providers/saml: fix invalid SAML Response when assertion and response are signed



* validate against schema too



---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2025-01-09 16:20:49 +01:00
1cf0f57608 core: fix error when creating new user with default path (cherry-pick #12609) (#12612)
core: fix error when creating new user with default path (#12609)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2025-01-09 15:32:18 +01:00
052da72acf rbac: permissions endpoint: allow authenticated users (cherry-pick #12608) (#12610)
rbac: permissions endpoint: allow authenticated users (#12608)

Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2025-01-09 15:01:54 +01:00
9a1c76efe7 sources/kerberos: authenticate with the user's username instead of the first username in authentik (cherry-pick #12497) (#12579)
sources/kerberos: authenticate with the user's username instead of the first username in authentik (#12497)

Co-authored-by: natural-hair <github@natural-hair.net>
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2025-01-06 15:22:15 +01:00
96b5bee912 web: fix source selection and outpost integration health (#12530)
* fix source selector

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix service connection health not updating fully

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix logo alt not translated

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
# Conflicts:
#	web/src/admin/AdminInterface/AboutModal.ts
2025-01-03 01:38:29 +01:00
09b3a1d0bd internal: fix missing trailing slash in outpost websocket (cherry-pick #12470) (#12471)
internal: fix missing trailing slash in outpost websocket (#12470)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2024-12-24 00:56:31 +01:00
e87a17fd81 release: 2024.12.1 2024-12-23 14:08:59 +01:00
bb1bcb29cd internal: fix URL generation for websocket connection (cherry-pick #12439) (#12440)
internal: fix URL generation for websocket connection (#12439)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2024-12-20 20:08:25 +01:00
0a5bdad972 website/docs: add content about bindings (cherry-pick #11787) (#12428)
website/docs: add content about bindings (#11787)

Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
Co-authored-by: Tana M Berry <tana@goauthentik.com>
Co-authored-by: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
2024-12-19 20:36:29 +01:00
d313225956 website/docs: add new section about impersonation (cherry-pick #12328) (#12424)
website/docs: add new section about impersonation (#12328)

Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
Co-authored-by: Tana M Berry <tana@goauthentik.com>
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2024-12-19 19:59:44 +01:00
249dc276d4 release: 2024.12.0 2024-12-19 19:18:31 +01:00
5fb7dc4cb3 website/docs: prepare for 2024.12.0 (cherry-pick #12420) (#12422)
website/docs: prepare for 2024.12.0 (#12420)

Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2024-12-19 19:18:03 +01:00
82930ee807 root: expose CONN_MAX_AGE, CONN_HEALTH_CHECKS and DISABLE_SERVER_SIDE_CURSORS for PostgreSQL config (cherry-pick #10159) (#12419)
root: expose CONN_MAX_AGE, CONN_HEALTH_CHECKS and DISABLE_SERVER_SIDE_CURSORS for PostgreSQL config (#10159)

Co-authored-by: Tomás Farías Santana <tomas@tomasfarias.dev>
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Co-authored-by: Tana M Berry <tana@goauthentik.com>
2024-12-19 19:01:06 +01:00
ac25fbab54 events: notification_cleanup: avoid unnecessary loop (cherry-pick #12417) (#12418)
events: notification_cleanup: avoid unnecessary loop (#12417)

Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2024-12-19 18:49:30 +01:00
15cb6b18f6 translate: Updates for file web/xliff/en.xlf in zh_CN (cherry-pick #12402) (#12411)
* translate: Updates for file web/xliff/en.xlf in zh_CN (#12402)

Translate web/xliff/en.xlf in zh_CN

100% translated source file: 'web/xliff/en.xlf'
on 'zh_CN'.

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>

* ci: dont run codeql on cherry-picked prs

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
2024-12-19 13:17:46 +01:00
fdd39b4b4c translate: Updates for file locale/en/LC_MESSAGES/django.po in zh_CN (cherry-pick #12399) (#12408)
translate: Updates for file locale/en/LC_MESSAGES/django.po in zh_CN (#12399)

Translate locale/en/LC_MESSAGES/django.po in zh_CN

100% translated source file: 'locale/en/LC_MESSAGES/django.po'
on 'zh_CN'.

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2024-12-19 12:43:26 +01:00
589304df4f translate: Updates for file locale/en/LC_MESSAGES/django.po in zh-Hans (cherry-pick #12400) (#12409)
translate: Updates for file locale/en/LC_MESSAGES/django.po in zh-Hans (#12400)

Translate django.po in zh-Hans

100% translated source file: 'django.po'
on 'zh-Hans'.

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2024-12-19 12:43:16 +01:00
4d920ff477 translate: Updates for file web/xliff/en.xlf in zh-Hans (cherry-pick #12401) (#12410)
translate: Updates for file web/xliff/en.xlf in zh-Hans (#12401)

Translate web/xliff/en.xlf in zh-Hans

100% translated source file: 'web/xliff/en.xlf'
on 'zh-Hans'.

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2024-12-19 12:43:04 +01:00
88dc616c5e release: 2024.12.0-rc1 2024-12-18 19:35:21 +01:00
328 changed files with 5278 additions and 19277 deletions

View File

@ -1,16 +1,16 @@
[bumpversion] [bumpversion]
current_version = 2024.12.2 current_version = 2024.12.5
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*))?
serialize = serialize =
{major}.{minor}.{patch}-{rc_t}{rc_n} {major}.{minor}.{patch}-{rc_t}{rc_n}
{major}.{minor}.{patch} {major}.{minor}.{patch}
message = release: {new_version} message = release: {new_version}
tag_name = version/{new_version} tag_name = version/{new_version}
[bumpversion:part:rc_t] [bumpversion:part:rc_t]
values = values =
rc rc
final final
optional_value = final optional_value = final

View File

@ -35,14 +35,6 @@ runs:
AUTHENTIK_OUTPOSTS__CONTAINER_IMAGE_BASE=ghcr.io/goauthentik/dev-%(type)s:gh-%(build_hash)s AUTHENTIK_OUTPOSTS__CONTAINER_IMAGE_BASE=ghcr.io/goauthentik/dev-%(type)s:gh-%(build_hash)s
``` ```
For arm64, use these values:
```shell
AUTHENTIK_IMAGE=ghcr.io/goauthentik/dev-server
AUTHENTIK_TAG=${{ inputs.tag }}-arm64
AUTHENTIK_OUTPOSTS__CONTAINER_IMAGE_BASE=ghcr.io/goauthentik/dev-%(type)s:gh-%(build_hash)s
```
Afterwards, run the upgrade commands from the latest release notes. Afterwards, run the upgrade commands from the latest release notes.
</details> </details>
<details> <details>
@ -60,18 +52,6 @@ runs:
tag: ${{ inputs.tag }} tag: ${{ inputs.tag }}
``` ```
For arm64, use these values:
```yaml
authentik:
outposts:
container_image_base: ghcr.io/goauthentik/dev-%(type)s:gh-%(build_hash)s
global:
image:
repository: ghcr.io/goauthentik/dev-server
tag: ${{ inputs.tag }}-arm64
```
Afterwards, run the upgrade commands from the latest release notes. Afterwards, run the upgrade commands from the latest release notes.
</details> </details>
edit-mode: replace edit-mode: replace

View File

@ -9,6 +9,9 @@ inputs:
image-arch: image-arch:
required: false required: false
description: "Docker image arch" description: "Docker image arch"
release:
required: true
description: "True if this is a release build, false if this is a dev/PR build"
outputs: outputs:
shouldPush: shouldPush:
@ -29,15 +32,24 @@ outputs:
imageTags: imageTags:
description: "Docker image tags" description: "Docker image tags"
value: ${{ steps.ev.outputs.imageTags }} value: ${{ steps.ev.outputs.imageTags }}
imageTagsJSON:
description: "Docker image tags, as a JSON array"
value: ${{ steps.ev.outputs.imageTagsJSON }}
attestImageNames: attestImageNames:
description: "Docker image names used for attestation" description: "Docker image names used for attestation"
value: ${{ steps.ev.outputs.attestImageNames }} value: ${{ steps.ev.outputs.attestImageNames }}
cacheTo:
description: "cache-to value for the docker build step"
value: ${{ steps.ev.outputs.cacheTo }}
imageMainTag: imageMainTag:
description: "Docker image main tag" description: "Docker image main tag"
value: ${{ steps.ev.outputs.imageMainTag }} value: ${{ steps.ev.outputs.imageMainTag }}
imageMainName: imageMainName:
description: "Docker image main name" description: "Docker image main name"
value: ${{ steps.ev.outputs.imageMainName }} value: ${{ steps.ev.outputs.imageMainName }}
imageBuildArgs:
description: "Docker image build args"
value: ${{ steps.ev.outputs.imageBuildArgs }}
runs: runs:
using: "composite" using: "composite"
@ -48,6 +60,8 @@ runs:
env: env:
IMAGE_NAME: ${{ inputs.image-name }} IMAGE_NAME: ${{ inputs.image-name }}
IMAGE_ARCH: ${{ inputs.image-arch }} IMAGE_ARCH: ${{ inputs.image-arch }}
RELEASE: ${{ inputs.release }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
REF: ${{ github.ref }}
run: | run: |
python3 ${{ github.action_path }}/push_vars.py python3 ${{ github.action_path }}/push_vars.py

View File

@ -2,6 +2,7 @@
import configparser import configparser
import os import os
from json import dumps
from time import time from time import time
parser = configparser.ConfigParser() parser = configparser.ConfigParser()
@ -48,7 +49,7 @@ if is_release:
] ]
else: else:
suffix = "" suffix = ""
if image_arch and image_arch != "amd64": if image_arch:
suffix = f"-{image_arch}" suffix = f"-{image_arch}"
for name in image_names: for name in image_names:
image_tags += [ image_tags += [
@ -70,12 +71,31 @@ def get_attest_image_names(image_with_tags: list[str]):
return ",".join(set(image_tags)) return ",".join(set(image_tags))
# Generate `cache-to` param
cache_to = ""
if should_push:
_cache_tag = "buildcache"
if image_arch:
_cache_tag += f"-{image_arch}"
cache_to = f"type=registry,ref={get_attest_image_names(image_tags)}:{_cache_tag},mode=max"
image_build_args = []
if os.getenv("RELEASE", "false").lower() == "true":
image_build_args = [f"VERSION={os.getenv('REF')}"]
else:
image_build_args = [f"GIT_BUILD_HASH={sha}"]
image_build_args = "\n".join(image_build_args)
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"shouldPush={str(should_push).lower()}", file=_output) print(f"shouldPush={str(should_push).lower()}", file=_output)
print(f"sha={sha}", file=_output) print(f"sha={sha}", file=_output)
print(f"version={version}", file=_output) print(f"version={version}", file=_output)
print(f"prerelease={prerelease}", file=_output) print(f"prerelease={prerelease}", file=_output)
print(f"imageTags={','.join(image_tags)}", file=_output) print(f"imageTags={','.join(image_tags)}", file=_output)
print(f"imageTagsJSON={dumps(image_tags)}", file=_output)
print(f"attestImageNames={get_attest_image_names(image_tags)}", file=_output) print(f"attestImageNames={get_attest_image_names(image_tags)}", file=_output)
print(f"imageMainTag={image_main_tag}", file=_output) print(f"imageMainTag={image_main_tag}", file=_output)
print(f"imageMainName={image_tags[0]}", file=_output) print(f"imageMainName={image_tags[0]}", file=_output)
print(f"cacheTo={cache_to}", file=_output)
print(f"imageBuildArgs={image_build_args}", file=_output)

View File

@ -1,7 +1,18 @@
#!/bin/bash -x #!/bin/bash -x
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
# Non-pushing PR
GITHUB_OUTPUT=/dev/stdout \ GITHUB_OUTPUT=/dev/stdout \
GITHUB_REF=ref \ GITHUB_REF=ref \
GITHUB_SHA=sha \ GITHUB_SHA=sha \
IMAGE_NAME=ghcr.io/goauthentik/server,beryju/authentik \ IMAGE_NAME=ghcr.io/goauthentik/server,beryju/authentik \
GITHUB_REPOSITORY=goauthentik/authentik \
python $SCRIPT_DIR/push_vars.py
# Pushing PR/main
GITHUB_OUTPUT=/dev/stdout \
GITHUB_REF=ref \
GITHUB_SHA=sha \
IMAGE_NAME=ghcr.io/goauthentik/server,beryju/authentik \
GITHUB_REPOSITORY=goauthentik/authentik \
DOCKER_USERNAME=foo \
python $SCRIPT_DIR/push_vars.py python $SCRIPT_DIR/push_vars.py

View File

@ -0,0 +1,95 @@
# Re-usable workflow for a single-architecture build
name: Single-arch Container build
on:
workflow_call:
inputs:
image_name:
required: true
type: string
image_arch:
required: true
type: string
runs-on:
required: true
type: string
registry_dockerhub:
default: false
type: boolean
registry_ghcr:
default: false
type: boolean
release:
default: false
type: boolean
outputs:
image-digest:
value: ${{ jobs.build.outputs.image-digest }}
jobs:
build:
name: Build ${{ inputs.image_arch }}
runs-on: ${{ inputs.runs-on }}
outputs:
image-digest: ${{ steps.push.outputs.digest }}
permissions:
# Needed to upload container images to ghcr.io
packages: write
# Needed for attestation
id-token: write
attestations: write
steps:
- uses: actions/checkout@v4
- uses: docker/setup-qemu-action@v3.3.0
- uses: docker/setup-buildx-action@v3
- name: prepare variables
uses: ./.github/actions/docker-push-variables
id: ev
env:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
with:
image-name: ${{ inputs.image_name }}
image-arch: ${{ inputs.image_arch }}
release: ${{ inputs.release }}
- name: Login to Docker Hub
if: ${{ inputs.registry_dockerhub }}
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub Container Registry
if: ${{ inputs.registry_ghcr }}
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: make empty clients
if: ${{ inputs.release }}
run: |
mkdir -p ./gen-ts-api
mkdir -p ./gen-go-api
- name: generate ts client
if: ${{ !inputs.release }}
run: make gen-client-ts
- name: Build Docker Image
uses: docker/build-push-action@v6
id: push
with:
context: .
push: true
secrets: |
GEOIPUPDATE_ACCOUNT_ID=${{ secrets.GEOIPUPDATE_ACCOUNT_ID }}
GEOIPUPDATE_LICENSE_KEY=${{ secrets.GEOIPUPDATE_LICENSE_KEY }}
build-args: |
${{ steps.ev.outputs.imageBuildArgs }}
tags: ${{ steps.ev.outputs.imageTags }}
platforms: linux/${{ inputs.image_arch }}
cache-from: type=registry,ref=${{ steps.ev.outputs.attestImageNames }}:buildcache-${{ inputs.image_arch }}
cache-to: ${{ steps.ev.outputs.cacheTo }}
- uses: actions/attest-build-provenance@v2
id: attest
with:
subject-name: ${{ steps.ev.outputs.attestImageNames }}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true

View File

@ -0,0 +1,102 @@
# Re-usable workflow for a multi-architecture build
name: Multi-arch container build
on:
workflow_call:
inputs:
image_name:
required: true
type: string
registry_dockerhub:
default: false
type: boolean
registry_ghcr:
default: true
type: boolean
release:
default: false
type: boolean
outputs: {}
jobs:
build-server-amd64:
uses: ./.github/workflows/_reusable-docker-build-single.yaml
secrets: inherit
with:
image_name: ${{ inputs.image_name }}
image_arch: amd64
runs-on: ubuntu-latest
registry_dockerhub: ${{ inputs.registry_dockerhub }}
registry_ghcr: ${{ inputs.registry_ghcr }}
release: ${{ inputs.release }}
build-server-arm64:
uses: ./.github/workflows/_reusable-docker-build-single.yaml
secrets: inherit
with:
image_name: ${{ inputs.image_name }}
image_arch: arm64
runs-on: ubuntu-22.04-arm
registry_dockerhub: ${{ inputs.registry_dockerhub }}
registry_ghcr: ${{ inputs.registry_ghcr }}
release: ${{ inputs.release }}
get-tags:
runs-on: ubuntu-latest
needs:
- build-server-amd64
- build-server-arm64
outputs:
tags: ${{ steps.ev.outputs.imageTagsJSON }}
steps:
- uses: actions/checkout@v4
- name: prepare variables
uses: ./.github/actions/docker-push-variables
id: ev
env:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
with:
image-name: ${{ inputs.image_name }}
merge-server:
runs-on: ubuntu-latest
needs:
- get-tags
- build-server-amd64
- build-server-arm64
strategy:
fail-fast: false
matrix:
tag: ${{ fromJson(needs.get-tags.outputs.tags) }}
steps:
- uses: actions/checkout@v4
- name: prepare variables
uses: ./.github/actions/docker-push-variables
id: ev
env:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
with:
image-name: ${{ inputs.image_name }}
- name: Login to Docker Hub
if: ${{ inputs.registry_dockerhub }}
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub Container Registry
if: ${{ inputs.registry_ghcr }}
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: int128/docker-manifest-create-action@v2
id: build
with:
tags: ${{ matrix.tag }}
sources: |
${{ steps.ev.outputs.attestImageNames }}@${{ needs.build-server-amd64.outputs.image-digest }}
${{ steps.ev.outputs.attestImageNames }}@${{ needs.build-server-arm64.outputs.image-digest }}
- uses: actions/attest-build-provenance@v2
id: attest
with:
subject-name: ${{ steps.ev.outputs.attestImageNames }}
subject-digest: ${{ steps.build.outputs.digest }}
push-to-registry: true

View File

@ -134,7 +134,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.12.0 uses: helm/kind-action@v1.11.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
@ -223,68 +223,18 @@ jobs:
with: with:
jobs: ${{ toJSON(needs) }} jobs: ${{ toJSON(needs) }}
build: build:
strategy:
fail-fast: false
matrix:
arch:
- amd64
- arm64
needs: ci-core-mark
runs-on: ubuntu-latest
permissions: permissions:
# Needed to upload contianer images to ghcr.io # Needed to upload container images to ghcr.io
packages: write packages: write
# Needed for attestation # Needed for attestation
id-token: write id-token: write
attestations: write attestations: write
timeout-minutes: 120 needs: ci-core-mark
steps: uses: ./.github/workflows/_reusable-docker-build.yaml
- uses: actions/checkout@v4 secrets: inherit
with: with:
ref: ${{ github.event.pull_request.head.sha }} image_name: ghcr.io/goauthentik/dev-server
- name: Set up QEMU release: false
uses: docker/setup-qemu-action@v3.3.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: prepare variables
uses: ./.github/actions/docker-push-variables
id: ev
env:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
with:
image-name: ghcr.io/goauthentik/dev-server
image-arch: ${{ matrix.arch }}
- name: Login to Container Registry
if: ${{ steps.ev.outputs.shouldPush == 'true' }}
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: generate ts client
run: make gen-client-ts
- name: Build Docker Image
uses: docker/build-push-action@v6
id: push
with:
context: .
secrets: |
GEOIPUPDATE_ACCOUNT_ID=${{ secrets.GEOIPUPDATE_ACCOUNT_ID }}
GEOIPUPDATE_LICENSE_KEY=${{ secrets.GEOIPUPDATE_LICENSE_KEY }}
tags: ${{ steps.ev.outputs.imageTags }}
push: ${{ steps.ev.outputs.shouldPush == 'true' }}
build-args: |
GIT_BUILD_HASH=${{ steps.ev.outputs.sha }}
cache-from: type=registry,ref=ghcr.io/goauthentik/dev-server:buildcache
cache-to: ${{ steps.ev.outputs.shouldPush == 'true' && 'type=registry,ref=ghcr.io/goauthentik/dev-server:buildcache,mode=max' || '' }}
platforms: linux/${{ matrix.arch }}
- uses: actions/attest-build-provenance@v2
id: attest
if: ${{ steps.ev.outputs.shouldPush == 'true' }}
with:
subject-name: ${{ steps.ev.outputs.attestImageNames }}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true
pr-comment: pr-comment:
needs: needs:
- build - build

View File

@ -72,7 +72,7 @@ jobs:
- rac - rac
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
# Needed to upload contianer images to ghcr.io # Needed to upload container images to ghcr.io
packages: write packages: write
# Needed for attestation # Needed for attestation
id-token: write id-token: write
@ -82,7 +82,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.3.0 uses: docker/setup-qemu-action@v3.2.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

View File

@ -2,7 +2,7 @@ name: "CodeQL"
on: on:
push: push:
branches: [main, "*", next, version*] branches: [main, next, version*]
pull_request: pull_request:
branches: [main] branches: [main]
schedule: schedule:

View File

@ -7,64 +7,23 @@ on:
jobs: jobs:
build-server: build-server:
runs-on: ubuntu-latest uses: ./.github/workflows/_reusable-docker-build.yaml
secrets: inherit
permissions: permissions:
# Needed to upload contianer images to ghcr.io # Needed to upload container images to ghcr.io
packages: write packages: write
# Needed for attestation # Needed for attestation
id-token: write id-token: write
attestations: write attestations: write
steps: with:
- uses: actions/checkout@v4 image_name: ghcr.io/goauthentik/server,beryju/authentik
- name: Set up QEMU release: true
uses: docker/setup-qemu-action@v3.3.0 registry_dockerhub: true
- name: Set up Docker Buildx registry_ghcr: true
uses: docker/setup-buildx-action@v3
- name: prepare variables
uses: ./.github/actions/docker-push-variables
id: ev
env:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
with:
image-name: ghcr.io/goauthentik/server,beryju/authentik
- name: Docker Login Registry
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: make empty clients
run: |
mkdir -p ./gen-ts-api
mkdir -p ./gen-go-api
- name: Build Docker Image
uses: docker/build-push-action@v6
id: push
with:
context: .
push: true
secrets: |
GEOIPUPDATE_ACCOUNT_ID=${{ secrets.GEOIPUPDATE_ACCOUNT_ID }}
GEOIPUPDATE_LICENSE_KEY=${{ secrets.GEOIPUPDATE_LICENSE_KEY }}
build-args: |
VERSION=${{ github.ref }}
tags: ${{ steps.ev.outputs.imageTags }}
platforms: linux/amd64,linux/arm64
- uses: actions/attest-build-provenance@v2
id: attest
with:
subject-name: ${{ steps.ev.outputs.attestImageNames }}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true
build-outpost: build-outpost:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
# Needed to upload contianer images to ghcr.io # Needed to upload container images to ghcr.io
packages: write packages: write
# Needed for attestation # Needed for attestation
id-token: write id-token: write
@ -83,7 +42,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.3.0 uses: docker/setup-qemu-action@v3.2.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

View File

@ -14,16 +14,7 @@ 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 make test-docker
echo "AUTHENTIK_SECRET_KEY=$(openssl rand 32 | base64 -w 0)" >> .env
docker buildx install
mkdir -p ./gen-ts-api
docker build -t testing:latest .
echo "AUTHENTIK_IMAGE=testing" >> .env
echo "AUTHENTIK_TAG=latest" >> .env
docker compose up --no-start
docker compose start postgresql redis
docker compose run -u root server test-all
- id: generate_token - id: generate_token
uses: tibdex/github-app-token@v2 uses: tibdex/github-app-token@v2
with: with:

View File

@ -94,7 +94,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.7-slim-bookworm-fips-full AS python-deps FROM ghcr.io/goauthentik/fips-python:3.12.8-slim-bookworm-fips AS python-deps
ARG TARGETARCH ARG TARGETARCH
ARG TARGETVARIANT ARG TARGETVARIANT
@ -116,15 +116,30 @@ 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 \
--mount=type=cache,target=/root/.cache/pip \ --mount=type=cache,target=/root/.cache/pip \
--mount=type=cache,target=/root/.cache/pypoetry \ --mount=type=cache,target=/root/.cache/pypoetry \
pip install --no-cache cffi && \
apt-get update && \
apt-get install -y --no-install-recommends \
build-essential libffi-dev \
# Required for cryptography
curl pkg-config \
# Required for lxml
libxslt-dev zlib1g-dev \
# Required for xmlsec
libltdl-dev \
# Required for kadmin
sccache clang && \
curl https://sh.rustup.rs -sSf | sh -s -- -y && \
. "$HOME/.cargo/env" && \
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 poetry && \
pip3 install poetry && \ poetry config --local installer.no-binary cryptography,xmlsec,lxml,python-kadmin-rs && \
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/*" pip uninstall cryptography -y && \
poetry install --only=main --no-ansi --no-interaction --no-root"
# Stage 6: Run # Stage 6: Run
FROM ghcr.io/goauthentik/fips-python:3.12.7-slim-bookworm-fips-full AS final-image FROM ghcr.io/goauthentik/fips-python:3.12.8-slim-bookworm-fips AS final-image
ARG VERSION ARG VERSION
ARG GIT_BUILD_HASH ARG GIT_BUILD_HASH
@ -141,7 +156,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 libkrb5-3 libkadm5clnt-mit12 libkdb5-10 && \ apt-get install -y --no-install-recommends libpq5 libmaxminddb0 ca-certificates libkrb5-3 libkadm5clnt-mit12 libkdb5-10 libltdl7 libxslt1.1 && \
# 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 && \
@ -176,9 +191,8 @@ ENV TMPDIR=/dev/shm/ \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
PATH="/ak-root/venv/bin:/lifecycle:$PATH" \ PATH="/ak-root/venv/bin:/lifecycle:$PATH" \
VENV_PATH="/ak-root/venv" \ VENV_PATH="/ak-root/venv" \
POETRY_VIRTUALENVS_CREATE=false POETRY_VIRTUALENVS_CREATE=false \
GOFIPS=1
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" ]

View File

@ -45,15 +45,6 @@ help: ## Show this help
go-test: go-test:
go test -timeout 0 -v -race -cover ./... go test -timeout 0 -v -race -cover ./...
test-docker: ## Run all tests in a docker-compose
echo "PG_PASS=$(shell openssl rand 32 | base64 -w 0)" >> .env
echo "AUTHENTIK_SECRET_KEY=$(shell openssl rand 32 | base64 -w 0)" >> .env
docker compose pull -q
docker compose up --no-start
docker compose start postgresql redis
docker compose run -u root server test-all
rm -f .env
test: ## Run the server tests and produce a coverage report (locally) test: ## Run the server tests and produce a coverage report (locally)
coverage run manage.py test --keepdb authentik coverage run manage.py test --keepdb authentik
coverage html coverage html
@ -263,6 +254,9 @@ docker: ## Build a docker image of the current source tree
mkdir -p ${GEN_API_TS} 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}
test-docker:
./scripts/test_docker.sh
######################### #########################
## CI ## CI
######################### #########################

View File

@ -2,7 +2,7 @@
from os import environ from os import environ
__version__ = "2024.12.2" __version__ = "2024.12.5"
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH" ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
@ -16,5 +16,5 @@ def get_full_version() -> str:
"""Get full version, with build hash appended""" """Get full version, with build hash appended"""
version = __version__ version = __version__
if (build_hash := get_build_hash()) != "": if (build_hash := get_build_hash()) != "":
return f"{version}+{build_hash}" version += "." + build_hash
return version return version

View File

@ -7,9 +7,7 @@ from sys import version as python_version
from typing import TypedDict from typing import TypedDict
from cryptography.hazmat.backends.openssl.backend import backend from cryptography.hazmat.backends.openssl.backend import backend
from django.conf import settings
from django.utils.timezone import now from django.utils.timezone import now
from django.views.debug import SafeExceptionReporterFilter
from drf_spectacular.utils import extend_schema from drf_spectacular.utils import extend_schema
from rest_framework.fields import SerializerMethodField from rest_framework.fields import SerializerMethodField
from rest_framework.request import Request from rest_framework.request import Request
@ -54,16 +52,10 @@ class SystemInfoSerializer(PassiveSerializer):
def get_http_headers(self, request: Request) -> dict[str, str]: def get_http_headers(self, request: Request) -> dict[str, str]:
"""Get HTTP Request headers""" """Get HTTP Request headers"""
headers = {} headers = {}
raw_session = request._request.COOKIES.get(settings.SESSION_COOKIE_NAME)
for key, value in request.META.items(): for key, value in request.META.items():
if not isinstance(value, str): if not isinstance(value, str):
continue continue
actual_value = value headers[key] = value
if raw_session in actual_value:
actual_value = actual_value.replace(
raw_session, SafeExceptionReporterFilter.cleansed_substitute
)
headers[key] = actual_value
return headers return headers
def get_http_host(self, request: Request) -> str: def get_http_host(self, request: Request) -> str:

View File

@ -1,16 +1,12 @@
"""authentik administration overview""" """authentik administration overview"""
from socket import gethostname
from django.conf import settings from django.conf import settings
from drf_spectacular.utils import extend_schema, inline_serializer from drf_spectacular.utils import extend_schema, inline_serializer
from packaging.version import parse from rest_framework.fields import IntegerField
from rest_framework.fields import BooleanField, CharField
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.rbac.permissions import HasPermission from authentik.rbac.permissions import HasPermission
from authentik.root.celery import CELERY_APP from authentik.root.celery import CELERY_APP
@ -20,38 +16,11 @@ class WorkerView(APIView):
permission_classes = [HasPermission("authentik_rbac.view_system_info")] permission_classes = [HasPermission("authentik_rbac.view_system_info")]
@extend_schema( @extend_schema(responses=inline_serializer("Workers", fields={"count": IntegerField()}))
responses=inline_serializer(
"Worker",
fields={
"worker_id": CharField(),
"version": CharField(),
"version_matching": BooleanField(),
},
many=True,
)
)
def get(self, request: Request) -> Response: def get(self, request: Request) -> Response:
"""Get currently connected worker count.""" """Get currently connected worker count."""
raw: list[dict[str, dict]] = CELERY_APP.control.ping(timeout=0.5) count = len(CELERY_APP.control.ping(timeout=0.5))
our_version = parse(get_full_version())
response = []
for worker in raw:
key = list(worker.keys())[0]
version = worker[key].get("version")
version_matching = False
if version:
version_matching = parse(version) == our_version
response.append(
{"worker_id": key, "version": version, "version_matching": version_matching}
)
# In debug we run with `task_always_eager`, so tasks are ran on the main process # In debug we run with `task_always_eager`, so tasks are ran on the main process
if settings.DEBUG: # pragma: no cover if settings.DEBUG: # pragma: no cover
response.append( count += 1
{ return Response({"count": count})
"worker_id": f"authentik-debug@{gethostname()}",
"version": get_full_version(),
"version_matching": True,
}
)
return Response(response)

View File

@ -1,10 +1,11 @@
"""authentik admin app config""" """authentik admin app config"""
from prometheus_client import Info from prometheus_client import Gauge, Info
from authentik.blueprints.apps import ManagedAppConfig from authentik.blueprints.apps import ManagedAppConfig
PROM_INFO = Info("authentik_version", "Currently running authentik version") PROM_INFO = Info("authentik_version", "Currently running authentik version")
GAUGE_WORKERS = Gauge("authentik_admin_workers", "Currently connected workers")
class AuthentikAdminConfig(ManagedAppConfig): class AuthentikAdminConfig(ManagedAppConfig):

View File

@ -1,35 +1,14 @@
"""admin signals""" """admin signals"""
from django.dispatch import receiver from django.dispatch import receiver
from packaging.version import parse
from prometheus_client import Gauge
from authentik import get_full_version from authentik.admin.apps import GAUGE_WORKERS
from authentik.root.celery import CELERY_APP from authentik.root.celery import CELERY_APP
from authentik.root.monitoring import monitoring_set from authentik.root.monitoring import monitoring_set
GAUGE_WORKERS = Gauge(
"authentik_admin_workers",
"Currently connected workers, their versions and if they are the same version as authentik",
["version", "version_matched"],
)
_version = parse(get_full_version())
@receiver(monitoring_set) @receiver(monitoring_set)
def monitoring_set_workers(sender, **kwargs): def monitoring_set_workers(sender, **kwargs):
"""Set worker gauge""" """Set worker gauge"""
raw: list[dict[str, dict]] = CELERY_APP.control.ping(timeout=0.5) count = len(CELERY_APP.control.ping(timeout=0.5))
worker_version_count = {} GAUGE_WORKERS.set(count)
for worker in raw:
key = list(worker.keys())[0]
version = worker[key].get("version")
version_matching = False
if version:
version_matching = parse(version) == _version
worker_version_count.setdefault(version, {"count": 0, "matching": version_matching})
worker_version_count[version]["count"] += 1
for version, stats in worker_version_count.items():
GAUGE_WORKERS.labels(version, stats["matching"]).set(stats["count"])

View File

@ -34,7 +34,7 @@ class TestAdminAPI(TestCase):
response = self.client.get(reverse("authentik_api:admin_workers")) response = self.client.get(reverse("authentik_api:admin_workers"))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
body = loads(response.content) body = loads(response.content)
self.assertEqual(len(body), 0) self.assertEqual(body["count"], 0)
def test_metrics(self): def test_metrics(self):
"""Test metrics API""" """Test metrics API"""

View File

@ -0,0 +1,67 @@
"""API Authorization"""
from django.conf import settings
from django.db.models import Model
from django.db.models.query import QuerySet
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.authentication import get_authorization_header
from rest_framework.filters import BaseFilterBackend
from rest_framework.permissions import BasePermission
from rest_framework.request import Request
from authentik.api.authentication import validate_auth
from authentik.rbac.filters import ObjectFilter
class OwnerFilter(BaseFilterBackend):
"""Filter objects by their owner"""
owner_key = "user"
def filter_queryset(self, request: Request, queryset: QuerySet, view) -> QuerySet:
if request.user.is_superuser:
return queryset
return queryset.filter(**{self.owner_key: request.user})
class SecretKeyFilter(DjangoFilterBackend):
"""Allow access to all objects when authenticated with secret key as token.
Replaces both DjangoFilterBackend and ObjectFilter"""
def filter_queryset(self, request: Request, queryset: QuerySet, view) -> QuerySet:
auth_header = get_authorization_header(request)
token = validate_auth(auth_header)
if token and token == settings.SECRET_KEY:
return queryset
queryset = ObjectFilter().filter_queryset(request, queryset, view)
return super().filter_queryset(request, queryset, view)
class OwnerPermissions(BasePermission):
"""Authorize requests by an object's owner matching the requesting user"""
owner_key = "user"
def has_permission(self, request: Request, view) -> bool:
"""If the user is authenticated, we allow all requests here. For listing, the
object-level permissions are done by the filter backend"""
return request.user.is_authenticated
def has_object_permission(self, request: Request, view, obj: Model) -> bool:
"""Check if the object's owner matches the currently logged in user"""
if not hasattr(obj, self.owner_key):
return False
owner = getattr(obj, self.owner_key)
if owner != request.user:
return False
return True
class OwnerSuperuserPermissions(OwnerPermissions):
"""Similar to OwnerPermissions, except always allow access for superusers"""
def has_object_permission(self, request: Request, view, obj: Model) -> bool:
if request.user.is_superuser:
return True
return super().has_object_permission(request, view, obj)

View File

@ -1,68 +0,0 @@
"""Test and debug Blueprints"""
import atexit
import readline
from pathlib import Path
from pprint import pformat
from sys import exit as sysexit
from textwrap import indent
from django.core.management.base import BaseCommand, no_translations
from structlog.stdlib import get_logger
from yaml import load
from authentik.blueprints.v1.common import BlueprintLoader, EntryInvalidError
from authentik.core.management.commands.shell import get_banner_text
from authentik.lib.utils.errors import exception_to_string
LOGGER = get_logger()
class Command(BaseCommand):
"""Test and debug Blueprints"""
lines = []
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
histfolder = Path("~").expanduser() / Path(".local/share/authentik")
histfolder.mkdir(parents=True, exist_ok=True)
histfile = histfolder / Path("blueprint_shell_history")
readline.parse_and_bind("tab: complete")
readline.parse_and_bind("set editing-mode vi")
try:
readline.read_history_file(str(histfile))
except FileNotFoundError:
pass
atexit.register(readline.write_history_file, str(histfile))
@no_translations
def handle(self, *args, **options):
"""Interactively debug blueprint files"""
self.stdout.write(get_banner_text("Blueprint shell"))
self.stdout.write("Type '.eval' to evaluate previously entered statement(s).")
def do_eval():
yaml_input = "\n".join([line for line in self.lines if line])
data = load(yaml_input, BlueprintLoader)
self.stdout.write(pformat(data))
self.lines = []
while True:
try:
line = input("> ")
if line == ".eval":
do_eval()
else:
self.lines.append(line)
except EntryInvalidError as exc:
self.stdout.write("Failed to evaluate expression:")
self.stdout.write(indent(exception_to_string(exc), prefix=" "))
except EOFError:
break
except KeyboardInterrupt:
self.stdout.write()
sysexit(0)
self.stdout.write()

View File

@ -126,7 +126,7 @@ class Command(BaseCommand):
def_name_perm = f"model_{model_path}_permissions" def_name_perm = f"model_{model_path}_permissions"
def_path_perm = f"#/$defs/{def_name_perm}" def_path_perm = f"#/$defs/{def_name_perm}"
self.schema["$defs"][def_name_perm] = self.model_permissions(model) self.schema["$defs"][def_name_perm] = self.model_permissions(model)
template = { return {
"type": "object", "type": "object",
"required": ["model", "identifiers"], "required": ["model", "identifiers"],
"properties": { "properties": {
@ -143,11 +143,6 @@ class Command(BaseCommand):
"identifiers": {"$ref": def_path}, "identifiers": {"$ref": def_path},
}, },
} }
# Meta models don't require identifiers, as there's no matching database model to find
if issubclass(model, BaseMetaModel):
del template["properties"]["identifiers"]
template["required"].remove("identifiers")
return template
def field_to_jsonschema(self, field: Field) -> dict: def field_to_jsonschema(self, field: Field) -> dict:
"""Convert a single field to json schema""" """Convert a single field to json schema"""

View File

@ -202,9 +202,6 @@ class Blueprint:
class YAMLTag: class YAMLTag:
"""Base class for all YAML Tags""" """Base class for all YAML Tags"""
def __repr__(self) -> str:
return str(self.resolve(BlueprintEntry(""), Blueprint()))
def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any: def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any:
"""Implement yaml tag logic""" """Implement yaml tag logic"""
raise NotImplementedError raise NotImplementedError

View File

@ -14,10 +14,10 @@ from rest_framework.response import Response
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.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 ModelSerializer, PassiveSerializer
from authentik.rbac.filters import SecretKeyFilter
from authentik.tenants.utils import get_current_tenant from authentik.tenants.utils import get_current_tenant

View File

@ -209,7 +209,7 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
@extend_schema( @extend_schema(
parameters=[ parameters=[
OpenApiParameter( OpenApiParameter(
name="list_rbac", name="superuser_full_list",
location=OpenApiParameter.QUERY, location=OpenApiParameter.QUERY,
type=OpenApiTypes.BOOL, type=OpenApiTypes.BOOL,
), ),
@ -229,8 +229,10 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
"""Custom list method that checks Policy based access instead of guardian""" """Custom list method that checks Policy based access instead of guardian"""
should_cache = request.query_params.get("search", "") == "" should_cache = request.query_params.get("search", "") == ""
list_rbac = str(request.query_params.get("list_rbac", "false")).lower() == "true" superuser_full_list = (
if list_rbac: str(request.query_params.get("superuser_full_list", "false")).lower() == "true"
)
if superuser_full_list and request.user.is_superuser:
return super().list(request) return super().list(request)
only_with_launch_url = str( only_with_launch_url = str(

View File

@ -2,12 +2,16 @@
from typing import TypedDict from typing import TypedDict
from django_filters.rest_framework import DjangoFilterBackend
from guardian.utils import get_anonymous_user
from rest_framework import mixins 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.request import Request from rest_framework.request import Request
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.core.api.used_by import UsedByMixin from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import ModelSerializer from authentik.core.api.utils import ModelSerializer
from authentik.core.models import AuthenticatedSession from authentik.core.models import AuthenticatedSession
@ -106,4 +110,11 @@ class AuthenticatedSessionViewSet(
search_fields = ["user__username", "last_ip", "last_user_agent"] search_fields = ["user__username", "last_ip", "last_user_agent"]
filterset_fields = ["user__username", "last_ip", "last_user_agent"] filterset_fields = ["user__username", "last_ip", "last_user_agent"]
ordering = ["user__username"] ordering = ["user__username"]
owner_field = "user" permission_classes = [OwnerSuperuserPermissions]
filter_backends = [DjangoFilterBackend, OrderingFilter, SearchFilter]
def get_queryset(self):
user = self.request.user if self.request else get_anonymous_user()
if user.is_superuser:
return super().get_queryset()
return super().get_queryset().filter(user=user.pk)

View File

@ -3,6 +3,7 @@
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema from drf_spectacular.utils import OpenApiParameter, extend_schema
from guardian.shortcuts import get_objects_for_user
from rest_framework.fields import ( from rest_framework.fields import (
BooleanField, BooleanField,
CharField, CharField,
@ -16,7 +17,6 @@ from rest_framework.viewsets import ViewSet
from authentik.core.api.utils import MetaNameSerializer from authentik.core.api.utils import MetaNameSerializer
from authentik.enterprise.stages.authenticator_endpoint_gdtc.models import EndpointDevice from authentik.enterprise.stages.authenticator_endpoint_gdtc.models import EndpointDevice
from authentik.rbac.decorators import permission_required
from authentik.stages.authenticator import device_classes, devices_for_user from authentik.stages.authenticator import device_classes, devices_for_user
from authentik.stages.authenticator.models import Device from authentik.stages.authenticator.models import Device
from authentik.stages.authenticator_webauthn.models import WebAuthnDevice from authentik.stages.authenticator_webauthn.models import WebAuthnDevice
@ -73,7 +73,9 @@ class AdminDeviceViewSet(ViewSet):
def get_devices(self, **kwargs): def get_devices(self, **kwargs):
"""Get all devices in all child classes""" """Get all devices in all child classes"""
for model in device_classes(): for model in device_classes():
device_set = model.objects.filter(**kwargs) device_set = get_objects_for_user(
self.request.user, f"{model._meta.app_label}.view_{model._meta.model_name}", model
).filter(**kwargs)
yield from device_set yield from device_set
@extend_schema( @extend_schema(
@ -86,10 +88,6 @@ class AdminDeviceViewSet(ViewSet):
], ],
responses={200: DeviceSerializer(many=True)}, responses={200: DeviceSerializer(many=True)},
) )
@permission_required(
None,
[f"{model._meta.app_label}.view_{model._meta.model_name}" for model in device_classes()],
)
def list(self, request: Request) -> Response: def list(self, request: Request) -> Response:
"""Get all devices for current user""" """Get all devices for current user"""
kwargs = {} kwargs = {}

View File

@ -2,22 +2,26 @@
from collections.abc import Iterable from collections.abc import Iterable
from django_filters.rest_framework import DjangoFilterBackend
from drf_spectacular.utils import OpenApiResponse, extend_schema from drf_spectacular.utils import OpenApiResponse, extend_schema
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.fields import CharField, ReadOnlyField, SerializerMethodField from rest_framework.fields import CharField, ReadOnlyField, SerializerMethodField
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.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.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.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, ModelSerializer
from authentik.core.models import GroupSourceConnection, Source, UserSourceConnection from authentik.core.models import GroupSourceConnection, Source, UserSourceConnection
from authentik.core.types import UserSettingSerializer from authentik.core.types import UserSettingSerializer
from authentik.lib.api import MultipleFieldLookupMixin
from authentik.lib.utils.file import ( from authentik.lib.utils.file import (
FilePathSerializer, FilePathSerializer,
FileUploadSerializer, FileUploadSerializer,
@ -72,6 +76,7 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer):
class SourceViewSet( class SourceViewSet(
MultipleFieldLookupMixin,
TypesMixin, TypesMixin,
mixins.RetrieveModelMixin, mixins.RetrieveModelMixin,
mixins.DestroyModelMixin, mixins.DestroyModelMixin,
@ -84,6 +89,7 @@ class SourceViewSet(
queryset = Source.objects.none() queryset = Source.objects.none()
serializer_class = SourceSerializer serializer_class = SourceSerializer
lookup_field = "slug" lookup_field = "slug"
lookup_fields = ["slug", "pbm_uuid"]
search_fields = ["slug", "name"] search_fields = ["slug", "name"]
filterset_fields = ["slug", "name", "managed"] filterset_fields = ["slug", "name", "managed"]
@ -186,10 +192,11 @@ class UserSourceConnectionViewSet(
queryset = UserSourceConnection.objects.all() queryset = UserSourceConnection.objects.all()
serializer_class = UserSourceConnectionSerializer serializer_class = UserSourceConnectionSerializer
permission_classes = [OwnerSuperuserPermissions]
filterset_fields = ["user", "source__slug"] filterset_fields = ["user", "source__slug"]
search_fields = ["source__slug"] search_fields = ["source__slug"]
filter_backends = [OwnerFilter, DjangoFilterBackend, OrderingFilter, SearchFilter]
ordering = ["source__slug", "pk"] ordering = ["source__slug", "pk"]
owner_field = "user"
class GroupSourceConnectionSerializer(SourceSerializer): class GroupSourceConnectionSerializer(SourceSerializer):
@ -224,7 +231,8 @@ class GroupSourceConnectionViewSet(
queryset = GroupSourceConnection.objects.all() queryset = GroupSourceConnection.objects.all()
serializer_class = GroupSourceConnectionSerializer serializer_class = GroupSourceConnectionSerializer
permission_classes = [OwnerSuperuserPermissions]
filterset_fields = ["group", "source__slug"] filterset_fields = ["group", "source__slug"]
search_fields = ["source__slug"] search_fields = ["source__slug"]
filter_backends = [OwnerFilter, DjangoFilterBackend, OrderingFilter, SearchFilter]
ordering = ["source__slug", "pk"] ordering = ["source__slug", "pk"]
owner_field = "user"

View File

@ -3,15 +3,18 @@
from typing import Any from typing import Any
from django.utils.timezone import now from django.utils.timezone import now
from django_filters.rest_framework import DjangoFilterBackend
from drf_spectacular.utils import OpenApiResponse, extend_schema, inline_serializer from drf_spectacular.utils import OpenApiResponse, extend_schema, inline_serializer
from guardian.shortcuts import assign_perm from guardian.shortcuts import assign_perm, get_anonymous_user
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 CharField from rest_framework.fields import CharField
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.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
from authentik.api.authorization import OwnerSuperuserPermissions
from authentik.blueprints.api import ManagedSerializer 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
@ -135,11 +138,16 @@ class TokenViewSet(UsedByMixin, ModelViewSet):
"managed", "managed",
] ]
ordering = ["identifier", "expires"] ordering = ["identifier", "expires"]
owner_field = "user" permission_classes = [OwnerSuperuserPermissions]
rbac_allow_create_without_perm = True filter_backends = [DjangoFilterBackend, OrderingFilter, SearchFilter]
def get_queryset(self):
user = self.request.user if self.request else get_anonymous_user()
if user.is_superuser:
return super().get_queryset()
return super().get_queryset().filter(user=user.pk)
def perform_create(self, serializer: TokenSerializer): def perform_create(self, serializer: TokenSerializer):
# TODO: better permission check
if not self.request.user.is_superuser: if not self.request.user.is_superuser:
instance = serializer.save( instance = serializer.save(
user=self.request.user, user=self.request.user,

View File

@ -1,13 +1,14 @@
"""User API Views""" """User API Views"""
from datetime import timedelta from datetime import timedelta
from importlib import import_module
from json import loads from json import loads
from typing import Any from typing import Any
from django.conf import settings
from django.contrib.auth import update_session_auth_hash from django.contrib.auth import update_session_auth_hash
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.contrib.sessions.backends.cache import KEY_PREFIX from django.contrib.sessions.backends.base import SessionBase
from django.core.cache import cache
from django.db.models.functions import ExtractHour from django.db.models.functions import ExtractHour
from django.db.transaction import atomic from django.db.transaction import atomic
from django.db.utils import IntegrityError from django.db.utils import IntegrityError
@ -91,6 +92,7 @@ from authentik.stages.email.tasks import send_mails
from authentik.stages.email.utils import TemplateEmailMessage from authentik.stages.email.utils import TemplateEmailMessage
LOGGER = get_logger() LOGGER = get_logger()
SessionStore: SessionBase = import_module(settings.SESSION_ENGINE).SessionStore
class UserGroupSerializer(ModelSerializer): class UserGroupSerializer(ModelSerializer):
@ -585,7 +587,7 @@ class UserViewSet(UsedByMixin, ModelViewSet):
"""Set password for user""" """Set password for user"""
user: User = self.get_object() user: User = self.get_object()
try: try:
user.set_password(request.data.get("password"), request=request) user.set_password(request.data.get("password"))
user.save() user.save()
except (ValidationError, IntegrityError) as exc: except (ValidationError, IntegrityError) as exc:
LOGGER.debug("Failed to set password", exc=exc) LOGGER.debug("Failed to set password", exc=exc)
@ -767,7 +769,8 @@ class UserViewSet(UsedByMixin, ModelViewSet):
if not instance.is_active: if not instance.is_active:
sessions = AuthenticatedSession.objects.filter(user=instance) sessions = AuthenticatedSession.objects.filter(user=instance)
session_ids = sessions.values_list("session_key", flat=True) session_ids = sessions.values_list("session_key", flat=True)
cache.delete_many(f"{KEY_PREFIX}{session}" for session in session_ids) for session in session_ids:
SessionStore(session).delete()
sessions.delete() sessions.delete()
LOGGER.debug("Deleted user's sessions", user=instance.username) LOGGER.debug("Deleted user's sessions", user=instance.username)
return response return response

View File

@ -44,12 +44,13 @@ class TokenBackend(InbuiltBackend):
self, request: HttpRequest, username: str | None, password: str | None, **kwargs: Any self, request: HttpRequest, username: str | None, password: str | None, **kwargs: Any
) -> User | None: ) -> User | None:
try: try:
user = User._default_manager.get_by_natural_key(username) user = User._default_manager.get_by_natural_key(username)
except User.DoesNotExist: except User.DoesNotExist:
# Run the default password hasher once to reduce the timing # Run the default password hasher once to reduce the timing
# difference between an existing and a nonexistent user (#20760). # difference between an existing and a nonexistent user (#20760).
User().set_password(password, request=request) User().set_password(password)
return None return None
tokens = Token.filter_not_expired( tokens = Token.filter_not_expired(

View File

@ -58,7 +58,6 @@ class PropertyMappingEvaluator(BaseEvaluator):
self._context["user"] = user self._context["user"] = user
if request: if request:
req.http_request = request req.http_request = request
self._context["http_request"] = request
req.context.update(**kwargs) req.context.update(**kwargs)
self._context["request"] = req self._context["request"] = req
self._context.update(**kwargs) self._context.update(**kwargs)

View File

@ -17,9 +17,7 @@ from authentik.events.middleware import should_log_model
from authentik.events.models import Event, EventAction from authentik.events.models import Event, EventAction
from authentik.events.utils import model_to_dict from authentik.events.utils import model_to_dict
BANNER_TEXT = f"""### authentik shell ({get_full_version()})
def get_banner_text(shell_type="shell") -> str:
return f"""### authentik {shell_type} ({get_full_version()})
### Node {platform.node()} | Arch {platform.machine()} | Python {platform.python_version()} """ ### Node {platform.node()} | Arch {platform.machine()} | Python {platform.python_version()} """
@ -116,4 +114,4 @@ class Command(BaseCommand):
readline.parse_and_bind("tab: complete") readline.parse_and_bind("tab: complete")
# Run interactive shell # Run interactive shell
code.interact(banner=get_banner_text(), local=namespace) code.interact(banner=BANNER_TEXT, local=namespace)

View File

@ -1,45 +0,0 @@
# Generated by Django 5.0.10 on 2025-01-13 18:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0041_applicationentitlement"),
]
operations = [
migrations.AddIndex(
model_name="authenticatedsession",
index=models.Index(fields=["expires"], name="authentik_c_expires_08251d_idx"),
),
migrations.AddIndex(
model_name="authenticatedsession",
index=models.Index(fields=["expiring"], name="authentik_c_expirin_9cd839_idx"),
),
migrations.AddIndex(
model_name="authenticatedsession",
index=models.Index(
fields=["expiring", "expires"], name="authentik_c_expirin_195a84_idx"
),
),
migrations.AddIndex(
model_name="authenticatedsession",
index=models.Index(fields=["session_key"], name="authentik_c_session_d0f005_idx"),
),
migrations.AddIndex(
model_name="token",
index=models.Index(fields=["expires"], name="authentik_c_expires_a62b4b_idx"),
),
migrations.AddIndex(
model_name="token",
index=models.Index(fields=["expiring"], name="authentik_c_expirin_a1b838_idx"),
),
migrations.AddIndex(
model_name="token",
index=models.Index(
fields=["expiring", "expires"], name="authentik_c_expirin_ba04d9_idx"
),
),
]

View File

@ -1,57 +0,0 @@
# Generated by Django 5.0.10 on 2025-01-08 17:39
from django.db import migrations
from django.apps.registry import Apps
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
def migrate_user_debug_attribute(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
from django.apps import apps as real_apps
from django.contrib.auth.management import create_permissions
db_alias = schema_editor.connection.alias
User = apps.get_model("authentik_core", "User")
USER_ATTRIBUTE_DEBUG = "goauthentik.io/user/debug"
# Permissions are only created _after_ migrations are run
# - https://github.com/django/django/blob/43cdfa8b20e567a801b7d0a09ec67ddd062d5ea4/django/contrib/auth/apps.py#L19
# - https://stackoverflow.com/a/72029063/1870445
create_permissions(real_apps.get_app_config("authentik_core"), using=db_alias)
Permission = apps.get_model("auth", "Permission")
new_prem = Permission.objects.using(db_alias).get(codename="user_view_debug")
db_alias = schema_editor.connection.alias
for user in User.objects.using(db_alias).filter(
**{f"attributes__{USER_ATTRIBUTE_DEBUG}": True}
):
user.permissions.add(new_prem)
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0042_authenticatedsession_authentik_c_expires_08251d_idx_and_more"),
]
operations = [
migrations.AlterModelOptions(
name="user",
options={
"permissions": [
("reset_user_password", "Reset Password"),
("impersonate", "Can impersonate other users"),
("assign_user_permissions", "Can assign permissions to users"),
("unassign_user_permissions", "Can unassign permissions from users"),
("preview_user", "Can preview user data sent to providers"),
("view_user_applications", "View applications the user has access to"),
("user_view_debug", "User receives additional details for error messages"),
],
"verbose_name": "User",
"verbose_name_plural": "Users",
},
),
migrations.RunPython(migrate_user_debug_attribute),
]

View File

@ -41,6 +41,7 @@ from authentik.tenants.models import DEFAULT_TOKEN_DURATION, DEFAULT_TOKEN_LENGT
from authentik.tenants.utils import get_current_tenant, get_unique_identifier from authentik.tenants.utils import get_current_tenant, get_unique_identifier
LOGGER = get_logger() LOGGER = get_logger()
USER_ATTRIBUTE_DEBUG = "goauthentik.io/user/debug"
USER_ATTRIBUTE_GENERATED = "goauthentik.io/user/generated" USER_ATTRIBUTE_GENERATED = "goauthentik.io/user/generated"
USER_ATTRIBUTE_EXPIRES = "goauthentik.io/user/expires" USER_ATTRIBUTE_EXPIRES = "goauthentik.io/user/expires"
USER_ATTRIBUTE_DELETE_ON_LOGOUT = "goauthentik.io/user/delete-on-logout" USER_ATTRIBUTE_DELETE_ON_LOGOUT = "goauthentik.io/user/delete-on-logout"
@ -281,7 +282,6 @@ class User(SerializerModel, GuardianUserMixin, AttributesMixin, AbstractUser):
("unassign_user_permissions", _("Can unassign permissions from users")), ("unassign_user_permissions", _("Can unassign permissions from users")),
("preview_user", _("Can preview user data sent to providers")), ("preview_user", _("Can preview user data sent to providers")),
("view_user_applications", _("View applications the user has access to")), ("view_user_applications", _("View applications the user has access to")),
("user_view_debug", _("User receives additional details for error messages")),
] ]
indexes = [ indexes = [
models.Index(fields=["last_login"]), models.Index(fields=["last_login"]),
@ -356,13 +356,13 @@ class User(SerializerModel, GuardianUserMixin, AttributesMixin, AbstractUser):
"""superuser == staff user""" """superuser == staff user"""
return self.is_superuser # type: ignore return self.is_superuser # type: ignore
def set_password(self, raw_password, signal=True, sender=None, request=None): def set_password(self, raw_password, signal=True, sender=None):
if self.pk and signal: if self.pk and signal:
from authentik.core.signals import password_changed from authentik.core.signals import password_changed
if not sender: if not sender:
sender = self sender = self
password_changed.send(sender=sender, user=self, password=raw_password, request=request) password_changed.send(sender=sender, user=self, password=raw_password)
self.password_change_date = now() self.password_change_date = now()
return super().set_password(raw_password) return super().set_password(raw_password)
@ -846,11 +846,6 @@ class ExpiringModel(models.Model):
class Meta: class Meta:
abstract = True abstract = True
indexes = [
models.Index(fields=["expires"]),
models.Index(fields=["expiring"]),
models.Index(fields=["expiring", "expires"]),
]
def expire_action(self, *args, **kwargs): def expire_action(self, *args, **kwargs):
"""Handler which is called when this object is expired. By """Handler which is called when this object is expired. By
@ -906,7 +901,7 @@ class Token(SerializerModel, ManagedModel, ExpiringModel):
class Meta: class Meta:
verbose_name = _("Token") verbose_name = _("Token")
verbose_name_plural = _("Tokens") verbose_name_plural = _("Tokens")
indexes = ExpiringModel.Meta.indexes + [ indexes = [
models.Index(fields=["identifier"]), models.Index(fields=["identifier"]),
models.Index(fields=["key"]), models.Index(fields=["key"]),
] ]
@ -1006,9 +1001,6 @@ class AuthenticatedSession(ExpiringModel):
class Meta: class Meta:
verbose_name = _("Authenticated Session") verbose_name = _("Authenticated Session")
verbose_name_plural = _("Authenticated Sessions") verbose_name_plural = _("Authenticated Sessions")
indexes = ExpiringModel.Meta.indexes + [
models.Index(fields=["session_key"]),
]
def __str__(self) -> str: def __str__(self) -> str:
return f"Authenticated Session {self.session_key[:10]}" return f"Authenticated Session {self.session_key[:10]}"

View File

@ -1,7 +1,10 @@
"""authentik core signals""" """authentik core signals"""
from importlib import import_module
from django.conf import settings
from django.contrib.auth.signals import user_logged_in, user_logged_out from django.contrib.auth.signals import user_logged_in, user_logged_out
from django.contrib.sessions.backends.cache import KEY_PREFIX from django.contrib.sessions.backends.base import SessionBase
from django.core.cache import cache from django.core.cache import cache
from django.core.signals import Signal from django.core.signals import Signal
from django.db.models import Model from django.db.models import Model
@ -25,6 +28,7 @@ password_changed = Signal()
login_failed = Signal() login_failed = Signal()
LOGGER = get_logger() LOGGER = get_logger()
SessionStore: SessionBase = import_module(settings.SESSION_ENGINE).SessionStore
@receiver(post_save, sender=Application) @receiver(post_save, sender=Application)
@ -60,8 +64,7 @@ def user_logged_out_session(sender, request: HttpRequest, user: User, **_):
@receiver(pre_delete, sender=AuthenticatedSession) @receiver(pre_delete, sender=AuthenticatedSession)
def authenticated_session_delete(sender: type[Model], instance: "AuthenticatedSession", **_): def authenticated_session_delete(sender: type[Model], instance: "AuthenticatedSession", **_):
"""Delete session when authenticated session is deleted""" """Delete session when authenticated session is deleted"""
cache_key = f"{KEY_PREFIX}{instance.session_key}" SessionStore(instance.session_key).delete()
cache.delete(cache_key)
@receiver(pre_save) @receiver(pre_save)

View File

@ -28,6 +28,7 @@ from rest_framework.validators import UniqueValidator
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.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 ModelSerializer, PassiveSerializer
from authentik.crypto.apps import MANAGED_KEY from authentik.crypto.apps import MANAGED_KEY
@ -35,7 +36,7 @@ from authentik.crypto.builder import CertificateBuilder, PrivateKeyAlg
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
from authentik.rbac.filters import ObjectFilter, SecretKeyFilter from authentik.rbac.filters import ObjectFilter
LOGGER = get_logger() LOGGER = get_logger()

View File

@ -1,27 +0,0 @@
# Generated by Django 5.0.10 on 2025-01-13 18:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_enterprise", "0003_remove_licenseusage_within_limits_and_more"),
]
operations = [
migrations.AddIndex(
model_name="licenseusage",
index=models.Index(fields=["expires"], name="authentik_e_expires_3f2956_idx"),
),
migrations.AddIndex(
model_name="licenseusage",
index=models.Index(fields=["expiring"], name="authentik_e_expirin_11d3d7_idx"),
),
migrations.AddIndex(
model_name="licenseusage",
index=models.Index(
fields=["expiring", "expires"], name="authentik_e_expirin_4d558f_idx"
),
),
]

View File

@ -93,4 +93,3 @@ class LicenseUsage(ExpiringModel):
class Meta: class Meta:
verbose_name = _("License Usage") verbose_name = _("License Usage")
verbose_name_plural = _("License Usage Records") verbose_name_plural = _("License Usage Records")
indexes = ExpiringModel.Meta.indexes

View File

@ -1,8 +1,11 @@
"""RAC Provider API Views""" """RAC Provider API Views"""
from django_filters.rest_framework.backends import DjangoFilterBackend
from rest_framework import mixins from rest_framework import mixins
from rest_framework.filters import OrderingFilter, SearchFilter
from rest_framework.viewsets import GenericViewSet from rest_framework.viewsets import GenericViewSet
from authentik.api.authorization import OwnerFilter, OwnerSuperuserPermissions
from authentik.core.api.groups import GroupMemberSerializer from authentik.core.api.groups import GroupMemberSerializer
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.api.utils import ModelSerializer
@ -31,6 +34,12 @@ class ConnectionTokenSerializer(EnterpriseRequiredMixin, ModelSerializer):
] ]
class ConnectionTokenOwnerFilter(OwnerFilter):
"""Owner filter for connection tokens (checks session's user)"""
owner_key = "session__user"
class ConnectionTokenViewSet( class ConnectionTokenViewSet(
mixins.RetrieveModelMixin, mixins.RetrieveModelMixin,
mixins.UpdateModelMixin, mixins.UpdateModelMixin,
@ -46,4 +55,10 @@ class ConnectionTokenViewSet(
filterset_fields = ["endpoint", "session__user", "provider"] filterset_fields = ["endpoint", "session__user", "provider"]
search_fields = ["endpoint__name", "provider__name"] search_fields = ["endpoint__name", "provider__name"]
ordering = ["endpoint__name", "provider__name"] ordering = ["endpoint__name", "provider__name"]
owner_field = "session__user" permission_classes = [OwnerSuperuserPermissions]
filter_backends = [
ConnectionTokenOwnerFilter,
DjangoFilterBackend,
OrderingFilter,
SearchFilter,
]

View File

@ -96,7 +96,7 @@ class EndpointViewSet(UsedByMixin, ModelViewSet):
OpenApiTypes.STR, OpenApiTypes.STR,
), ),
OpenApiParameter( OpenApiParameter(
name="list_rbac", name="superuser_full_list",
location=OpenApiParameter.QUERY, location=OpenApiParameter.QUERY,
type=OpenApiTypes.BOOL, type=OpenApiTypes.BOOL,
), ),
@ -110,8 +110,8 @@ class EndpointViewSet(UsedByMixin, ModelViewSet):
"""List accessible endpoints""" """List accessible endpoints"""
should_cache = request.GET.get("search", "") == "" should_cache = request.GET.get("search", "") == ""
list_rbac = str(request.GET.get("list_rbac", "false")).lower() == "true" superuser_full_list = str(request.GET.get("superuser_full_list", "false")).lower() == "true"
if list_rbac: if superuser_full_list and request.user.is_superuser:
return super().list(request) return super().list(request)
queryset = self._filter_queryset_for_list(self.get_queryset()) queryset = self._filter_queryset_for_list(self.get_queryset())

View File

@ -1,28 +0,0 @@
# Generated by Django 5.0.10 on 2025-01-13 18:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0042_authenticatedsession_authentik_c_expires_08251d_idx_and_more"),
("authentik_providers_rac", "0005_alter_racpropertymapping_options"),
]
operations = [
migrations.AddIndex(
model_name="connectiontoken",
index=models.Index(fields=["expires"], name="authentik_p_expires_91f148_idx"),
),
migrations.AddIndex(
model_name="connectiontoken",
index=models.Index(fields=["expiring"], name="authentik_p_expirin_59a5a7_idx"),
),
migrations.AddIndex(
model_name="connectiontoken",
index=models.Index(
fields=["expiring", "expires"], name="authentik_p_expirin_aed3ca_idx"
),
),
]

View File

@ -211,4 +211,3 @@ class ConnectionToken(ExpiringModel):
class Meta: class Meta:
verbose_name = _("RAC Connection token") verbose_name = _("RAC Connection token")
verbose_name_plural = _("RAC Connection tokens") verbose_name_plural = _("RAC Connection tokens")
indexes = ExpiringModel.Meta.indexes

View File

@ -1,11 +1,14 @@
"""AuthenticatorEndpointGDTCStage API Views""" """AuthenticatorEndpointGDTCStage API Views"""
from django_filters.rest_framework.backends import DjangoFilterBackend
from rest_framework import mixins from rest_framework import mixins
from rest_framework.filters import OrderingFilter, SearchFilter
from rest_framework.permissions import IsAdminUser from rest_framework.permissions import IsAdminUser
from rest_framework.serializers import ModelSerializer from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import GenericViewSet, ModelViewSet from rest_framework.viewsets import GenericViewSet, ModelViewSet
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.api.authorization import OwnerFilter, OwnerPermissions
from authentik.core.api.used_by import UsedByMixin from authentik.core.api.used_by import UsedByMixin
from authentik.enterprise.api import EnterpriseRequiredMixin from authentik.enterprise.api import EnterpriseRequiredMixin
from authentik.enterprise.stages.authenticator_endpoint_gdtc.models import ( from authentik.enterprise.stages.authenticator_endpoint_gdtc.models import (
@ -64,7 +67,8 @@ class EndpointDeviceViewSet(
search_fields = ["name"] search_fields = ["name"]
filterset_fields = ["name"] filterset_fields = ["name"]
ordering = ["name"] ordering = ["name"]
owner_field = "user" permission_classes = [OwnerPermissions]
filter_backends = [OwnerFilter, DjangoFilterBackend, OrderingFilter, SearchFilter]
class EndpointAdminDeviceViewSet(ModelViewSet): class EndpointAdminDeviceViewSet(ModelViewSet):

View File

@ -1,15 +1,17 @@
"""Notification API Views""" """Notification API Views"""
from django_filters.rest_framework import DjangoFilterBackend
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiResponse, extend_schema from drf_spectacular.utils import OpenApiResponse, extend_schema
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.fields import ReadOnlyField from rest_framework.fields import ReadOnlyField
from rest_framework.permissions import IsAuthenticated 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.viewsets import GenericViewSet from rest_framework.viewsets import GenericViewSet
from authentik.api.authorization import OwnerFilter, OwnerPermissions
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.api.utils import ModelSerializer
from authentik.events.api.events import EventSerializer from authentik.events.api.events import EventSerializer
@ -55,7 +57,8 @@ class NotificationViewSet(
"seen", "seen",
"user", "user",
] ]
owner_field = "user" permission_classes = [OwnerPermissions]
filter_backends = [OwnerFilter, DjangoFilterBackend, OrderingFilter, SearchFilter]
@extend_schema( @extend_schema(
request=OpenApiTypes.NONE, request=OpenApiTypes.NONE,
@ -63,7 +66,7 @@ class NotificationViewSet(
204: OpenApiResponse(description="Marked tasks as read successfully."), 204: OpenApiResponse(description="Marked tasks as read successfully."),
}, },
) )
@action(detail=False, methods=["post"], permission_classes=[IsAuthenticated]) @action(detail=False, methods=["post"])
def mark_all_seen(self, request: Request) -> Response: def mark_all_seen(self, request: Request) -> Response:
"""Mark all the user's notifications as seen""" """Mark all the user's notifications as seen"""
Notification.objects.filter(user=request.user, seen=False).update(seen=True) Notification.objects.filter(user=request.user, seen=False).update(seen=True)

View File

@ -1,41 +0,0 @@
# Generated by Django 5.0.10 on 2025-01-13 18:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_events", "0007_event_authentik_e_action_9a9dd9_idx_and_more"),
]
operations = [
migrations.AddIndex(
model_name="event",
index=models.Index(fields=["expires"], name="authentik_e_expires_8c73a8_idx"),
),
migrations.AddIndex(
model_name="event",
index=models.Index(fields=["expiring"], name="authentik_e_expirin_b5cb5e_idx"),
),
migrations.AddIndex(
model_name="event",
index=models.Index(
fields=["expiring", "expires"], name="authentik_e_expirin_e37180_idx"
),
),
migrations.AddIndex(
model_name="systemtask",
index=models.Index(fields=["expires"], name="authentik_e_expires_4d3985_idx"),
),
migrations.AddIndex(
model_name="systemtask",
index=models.Index(fields=["expiring"], name="authentik_e_expirin_81d649_idx"),
),
migrations.AddIndex(
model_name="systemtask",
index=models.Index(
fields=["expiring", "expires"], name="authentik_e_expirin_eb3598_idx"
),
),
]

View File

@ -306,7 +306,7 @@ class Event(SerializerModel, ExpiringModel):
class Meta: class Meta:
verbose_name = _("Event") verbose_name = _("Event")
verbose_name_plural = _("Events") verbose_name_plural = _("Events")
indexes = ExpiringModel.Meta.indexes + [ indexes = [
models.Index(fields=["action"]), models.Index(fields=["action"]),
models.Index(fields=["user"]), models.Index(fields=["user"]),
models.Index(fields=["app"]), models.Index(fields=["app"]),
@ -694,4 +694,3 @@ class SystemTask(SerializerModel, ExpiringModel):
permissions = [("run_task", _("Run task"))] permissions = [("run_task", _("Run task"))]
verbose_name = _("System Task") verbose_name = _("System Task")
verbose_name_plural = _("System Tasks") verbose_name_plural = _("System Tasks")
indexes = ExpiringModel.Meta.indexes

View File

@ -106,9 +106,9 @@ def on_invitation_used(sender, request: HttpRequest, invitation: Invitation, **_
@receiver(password_changed) @receiver(password_changed)
def on_password_changed(sender, user: User, password: str, request: HttpRequest | None, **_): def on_password_changed(sender, user: User, password: str, **_):
"""Log password change""" """Log password change"""
Event.new(EventAction.PASSWORD_SET).from_http(request, user=user) Event.new(EventAction.PASSWORD_SET).from_http(None, user=user)
@receiver(post_save, sender=Event) @receiver(post_save, sender=Event)

View File

@ -1,7 +1,5 @@
"""Flow Stage API Views""" """Flow Stage API Views"""
from uuid import uuid4
from django.urls.base import reverse from django.urls.base import reverse
from drf_spectacular.utils import extend_schema from drf_spectacular.utils import extend_schema
from rest_framework import mixins from rest_framework import mixins
@ -29,11 +27,6 @@ class StageSerializer(ModelSerializer, MetaNameSerializer):
component = SerializerMethodField() component = SerializerMethodField()
flow_set = FlowSetSerializer(many=True, required=False) flow_set = FlowSetSerializer(many=True, required=False)
def to_representation(self, instance: Stage):
if isinstance(instance, Stage) and instance.is_in_memory:
instance.stage_uuid = uuid4()
return super().to_representation(instance)
def get_component(self, obj: Stage) -> str: def get_component(self, obj: Stage) -> str:
"""Get object type so that we know how to edit the object""" """Get object type so that we know how to edit the object"""
if obj.__class__ == Stage: if obj.__class__ == Stage:

View File

@ -97,9 +97,12 @@ class FlowErrorChallenge(Challenge):
if not request or not error: if not request or not error:
return return
self.initial_data["request_id"] = request.request_id self.initial_data["request_id"] = request.request_id
from authentik.core.models import USER_ATTRIBUTE_DEBUG
if request.user and request.user.is_authenticated: if request.user and request.user.is_authenticated:
if request.user.has_perm("authentik_core.user_view_debug"): if request.user.is_superuser or request.user.group_attributes(request).get(
USER_ATTRIBUTE_DEBUG, False
):
self.initial_data["error"] = str(error) self.initial_data["error"] = str(error)
self.initial_data["traceback"] = exception_to_string(error) self.initial_data["traceback"] = exception_to_string(error)

View File

@ -88,8 +88,7 @@ class Migration(migrations.Migration):
model_name="flowstagebinding", model_name="flowstagebinding",
name="re_evaluate_policies", name="re_evaluate_policies",
field=models.BooleanField( field=models.BooleanField(
default=False, default=False, help_text="Evaluate policies when the Stage is present to the user."
help_text="Evaluate policies when the Stage is presented to the user.",
), ),
), ),
migrations.AddField( migrations.AddField(

View File

@ -20,7 +20,7 @@ class Migration(migrations.Migration):
model_name="flowstagebinding", model_name="flowstagebinding",
name="re_evaluate_policies", name="re_evaluate_policies",
field=models.BooleanField( field=models.BooleanField(
default=True, help_text="Evaluate policies when the Stage is presented to the user." default=True, help_text="Evaluate policies when the Stage is present to the user."
), ),
), ),
] ]

View File

@ -102,12 +102,8 @@ class Stage(SerializerModel):
user settings are available, or a challenge.""" user settings are available, or a challenge."""
return None return None
@property
def is_in_memory(self):
return hasattr(self, "__in_memory_type")
def __str__(self): def __str__(self):
if self.is_in_memory: if hasattr(self, "__in_memory_type"):
return f"In-memory Stage {getattr(self, '__in_memory_type')}" return f"In-memory Stage {getattr(self, '__in_memory_type')}"
return f"Stage {self.name}" return f"Stage {self.name}"
@ -231,7 +227,7 @@ class FlowStageBinding(SerializerModel, PolicyBindingModel):
) )
re_evaluate_policies = models.BooleanField( re_evaluate_policies = models.BooleanField(
default=True, default=True,
help_text=_("Evaluate policies when the Stage is presented to the user."), help_text=_("Evaluate policies when the Stage is present to the user."),
) )
invalid_response_action = models.TextField( invalid_response_action = models.TextField(

View File

@ -109,6 +109,8 @@ class FlowPlan:
def pop(self): def pop(self):
"""Pop next pending stage from bottom of list""" """Pop next pending stage from bottom of list"""
if not self.markers and not self.bindings:
return
self.markers.pop(0) self.markers.pop(0)
self.bindings.pop(0) self.bindings.pop(0)
@ -156,20 +158,17 @@ class FlowPlan:
final_stage: type[StageView] = self.bindings[-1].stage.view final_stage: type[StageView] = self.bindings[-1].stage.view
temp_exec = FlowExecutorView(flow=flow, request=request, plan=self) temp_exec = FlowExecutorView(flow=flow, request=request, plan=self)
temp_exec.current_stage = self.bindings[-1].stage temp_exec.current_stage = self.bindings[-1].stage
temp_exec.current_stage_view = final_stage
temp_exec.setup(request, flow.slug)
stage = final_stage(request=request, executor=temp_exec) stage = final_stage(request=request, executor=temp_exec)
return stage.dispatch(request) response = stage.dispatch(request)
# Ensure we clean the flow state we have in the session before we redirect away
get_qs = request.GET.copy() temp_exec.stage_ok()
if request.user.is_authenticated and ( return response
# Object-scoped permission or global permission
request.user.has_perm("authentik_flows.inspect_flow", flow)
or request.user.has_perm("authentik_flows.inspect_flow")
):
get_qs["inspector"] = "available"
return redirect_with_qs( return redirect_with_qs(
"authentik_core:if-flow", "authentik_core:if-flow",
get_qs, request.GET,
flow_slug=flow.slug, flow_slug=flow.slug,
) )

View File

@ -7,8 +7,8 @@ from django.http import HttpRequest, HttpResponse
from django.test.client import RequestFactory from django.test.client import RequestFactory
from django.urls import reverse from django.urls import reverse
from authentik.core.models import Group, User from authentik.core.models import User
from authentik.core.tests.utils import create_test_flow, create_test_user from authentik.core.tests.utils import create_test_flow
from authentik.flows.markers import ReevaluateMarker, StageMarker from authentik.flows.markers import ReevaluateMarker, StageMarker
from authentik.flows.models import ( from authentik.flows.models import (
FlowDeniedAction, FlowDeniedAction,
@ -255,11 +255,7 @@ class TestFlowExecutor(FlowTestCase):
) )
binding = FlowStageBinding.objects.create( binding = FlowStageBinding.objects.create(
target=flow, target=flow, stage=DummyStage.objects.create(name=generate_id()), order=0
stage=DummyStage.objects.create(name=generate_id()),
order=0,
evaluate_on_plan=True,
re_evaluate_policies=False,
) )
binding2 = FlowStageBinding.objects.create( binding2 = FlowStageBinding.objects.create(
target=flow, target=flow,
@ -282,8 +278,8 @@ class TestFlowExecutor(FlowTestCase):
self.assertEqual(plan.bindings[0], binding) self.assertEqual(plan.bindings[0], binding)
self.assertEqual(plan.bindings[1], binding2) self.assertEqual(plan.bindings[1], binding2)
self.assertEqual(plan.markers[0].__class__, StageMarker) self.assertIsInstance(plan.markers[0], StageMarker)
self.assertEqual(plan.markers[1].__class__, ReevaluateMarker) self.assertIsInstance(plan.markers[1], ReevaluateMarker)
# Second request, this passes the first dummy stage # Second request, this passes the first dummy stage
response = self.client.post(exec_url) response = self.client.post(exec_url)
@ -305,11 +301,7 @@ class TestFlowExecutor(FlowTestCase):
) )
binding = FlowStageBinding.objects.create( binding = FlowStageBinding.objects.create(
target=flow, target=flow, stage=DummyStage.objects.create(name=generate_id()), order=0
stage=DummyStage.objects.create(name=generate_id()),
order=0,
evaluate_on_plan=True,
re_evaluate_policies=False,
) )
binding2 = FlowStageBinding.objects.create( binding2 = FlowStageBinding.objects.create(
target=flow, target=flow,
@ -318,11 +310,7 @@ class TestFlowExecutor(FlowTestCase):
re_evaluate_policies=True, re_evaluate_policies=True,
) )
binding3 = FlowStageBinding.objects.create( binding3 = FlowStageBinding.objects.create(
target=flow, target=flow, stage=DummyStage.objects.create(name=generate_id()), order=2
stage=DummyStage.objects.create(name=generate_id()),
order=2,
evaluate_on_plan=True,
re_evaluate_policies=False,
) )
PolicyBinding.objects.create(policy=false_policy, target=binding2, order=0) PolicyBinding.objects.create(policy=false_policy, target=binding2, order=0)
@ -340,9 +328,9 @@ class TestFlowExecutor(FlowTestCase):
self.assertEqual(plan.bindings[1], binding2) self.assertEqual(plan.bindings[1], binding2)
self.assertEqual(plan.bindings[2], binding3) self.assertEqual(plan.bindings[2], binding3)
self.assertEqual(plan.markers[0].__class__, StageMarker) self.assertIsInstance(plan.markers[0], StageMarker)
self.assertEqual(plan.markers[1].__class__, ReevaluateMarker) self.assertIsInstance(plan.markers[1], ReevaluateMarker)
self.assertEqual(plan.markers[2].__class__, StageMarker) self.assertIsInstance(plan.markers[2], StageMarker)
# Second request, this passes the first dummy stage # Second request, this passes the first dummy stage
response = self.client.post(exec_url) response = self.client.post(exec_url)
@ -353,8 +341,8 @@ class TestFlowExecutor(FlowTestCase):
self.assertEqual(plan.bindings[0], binding2) self.assertEqual(plan.bindings[0], binding2)
self.assertEqual(plan.bindings[1], binding3) self.assertEqual(plan.bindings[1], binding3)
self.assertEqual(plan.markers[0].__class__, ReevaluateMarker) self.assertIsInstance(plan.markers[0], StageMarker)
self.assertEqual(plan.markers[1].__class__, StageMarker) self.assertIsInstance(plan.markers[1], StageMarker)
# third request, this should trigger the re-evaluate # third request, this should trigger the re-evaluate
# We do this request without the patch, so the policy results in false # We do this request without the patch, so the policy results in false
@ -372,11 +360,7 @@ class TestFlowExecutor(FlowTestCase):
) )
binding = FlowStageBinding.objects.create( binding = FlowStageBinding.objects.create(
target=flow, target=flow, stage=DummyStage.objects.create(name=generate_id()), order=0
stage=DummyStage.objects.create(name=generate_id()),
order=0,
evaluate_on_plan=True,
re_evaluate_policies=False,
) )
binding2 = FlowStageBinding.objects.create( binding2 = FlowStageBinding.objects.create(
target=flow, target=flow,
@ -385,11 +369,7 @@ class TestFlowExecutor(FlowTestCase):
re_evaluate_policies=True, re_evaluate_policies=True,
) )
binding3 = FlowStageBinding.objects.create( binding3 = FlowStageBinding.objects.create(
target=flow, target=flow, stage=DummyStage.objects.create(name=generate_id()), order=2
stage=DummyStage.objects.create(name=generate_id()),
order=2,
evaluate_on_plan=True,
re_evaluate_policies=False,
) )
PolicyBinding.objects.create(policy=true_policy, target=binding2, order=0) PolicyBinding.objects.create(policy=true_policy, target=binding2, order=0)
@ -407,9 +387,9 @@ class TestFlowExecutor(FlowTestCase):
self.assertEqual(plan.bindings[1], binding2) self.assertEqual(plan.bindings[1], binding2)
self.assertEqual(plan.bindings[2], binding3) self.assertEqual(plan.bindings[2], binding3)
self.assertEqual(plan.markers[0].__class__, StageMarker) self.assertIsInstance(plan.markers[0], StageMarker)
self.assertEqual(plan.markers[1].__class__, ReevaluateMarker) self.assertIsInstance(plan.markers[1], ReevaluateMarker)
self.assertEqual(plan.markers[2].__class__, StageMarker) self.assertIsInstance(plan.markers[2], StageMarker)
# Second request, this passes the first dummy stage # Second request, this passes the first dummy stage
response = self.client.post(exec_url) response = self.client.post(exec_url)
@ -420,8 +400,8 @@ class TestFlowExecutor(FlowTestCase):
self.assertEqual(plan.bindings[0], binding2) self.assertEqual(plan.bindings[0], binding2)
self.assertEqual(plan.bindings[1], binding3) self.assertEqual(plan.bindings[1], binding3)
self.assertEqual(plan.markers[0].__class__, ReevaluateMarker) self.assertIsInstance(plan.markers[0], StageMarker)
self.assertEqual(plan.markers[1].__class__, StageMarker) self.assertIsInstance(plan.markers[1], StageMarker)
# Third request, this passes the first dummy stage # Third request, this passes the first dummy stage
response = self.client.post(exec_url) response = self.client.post(exec_url)
@ -431,7 +411,7 @@ class TestFlowExecutor(FlowTestCase):
self.assertEqual(plan.bindings[0], binding3) self.assertEqual(plan.bindings[0], binding3)
self.assertEqual(plan.markers[0].__class__, StageMarker) self.assertIsInstance(plan.markers[0], StageMarker)
# third request, this should trigger the re-evaluate # third request, this should trigger the re-evaluate
# We do this request without the patch, so the policy results in false # We do this request without the patch, so the policy results in false
@ -449,11 +429,7 @@ class TestFlowExecutor(FlowTestCase):
) )
binding = FlowStageBinding.objects.create( binding = FlowStageBinding.objects.create(
target=flow, target=flow, stage=DummyStage.objects.create(name=generate_id()), order=0
stage=DummyStage.objects.create(name=generate_id()),
order=0,
evaluate_on_plan=True,
re_evaluate_policies=False,
) )
binding2 = FlowStageBinding.objects.create( binding2 = FlowStageBinding.objects.create(
target=flow, target=flow,
@ -468,11 +444,7 @@ class TestFlowExecutor(FlowTestCase):
re_evaluate_policies=True, re_evaluate_policies=True,
) )
binding4 = FlowStageBinding.objects.create( binding4 = FlowStageBinding.objects.create(
target=flow, target=flow, stage=DummyStage.objects.create(name=generate_id()), order=2
stage=DummyStage.objects.create(name=generate_id()),
order=2,
evaluate_on_plan=True,
re_evaluate_policies=False,
) )
PolicyBinding.objects.create(policy=false_policy, target=binding2, order=0) PolicyBinding.objects.create(policy=false_policy, target=binding2, order=0)
@ -493,10 +465,10 @@ class TestFlowExecutor(FlowTestCase):
self.assertEqual(plan.bindings[2], binding3) self.assertEqual(plan.bindings[2], binding3)
self.assertEqual(plan.bindings[3], binding4) self.assertEqual(plan.bindings[3], binding4)
self.assertEqual(plan.markers[0].__class__, StageMarker) self.assertIsInstance(plan.markers[0], StageMarker)
self.assertEqual(plan.markers[1].__class__, ReevaluateMarker) self.assertIsInstance(plan.markers[1], ReevaluateMarker)
self.assertEqual(plan.markers[2].__class__, ReevaluateMarker) self.assertIsInstance(plan.markers[2], ReevaluateMarker)
self.assertEqual(plan.markers[3].__class__, StageMarker) self.assertIsInstance(plan.markers[3], StageMarker)
# Second request, this passes the first dummy stage # Second request, this passes the first dummy stage
response = self.client.post(exec_url) response = self.client.post(exec_url)
@ -547,9 +519,9 @@ class TestFlowExecutor(FlowTestCase):
) )
# Stage 0 is a deny stage that is added dynamically # Stage 0 is a deny stage that is added dynamically
# when the reputation policy says so # when the reputation policy says so
deny_stage = DenyStage.objects.create(name=generate_id()) deny_stage = DenyStage.objects.create(name="deny")
reputation_policy = ReputationPolicy.objects.create( reputation_policy = ReputationPolicy.objects.create(
name=generate_id(), threshold=-1, check_ip=False name="reputation", threshold=-1, check_ip=False
) )
deny_binding = FlowStageBinding.objects.create( deny_binding = FlowStageBinding.objects.create(
target=flow, target=flow,
@ -562,7 +534,7 @@ class TestFlowExecutor(FlowTestCase):
# Stage 1 is an identification stage # Stage 1 is an identification stage
ident_stage = IdentificationStage.objects.create( ident_stage = IdentificationStage.objects.create(
name=generate_id(), name="ident",
user_fields=[UserFields.E_MAIL], user_fields=[UserFields.E_MAIL],
pretend_user_exists=False, pretend_user_exists=False,
) )
@ -587,64 +559,3 @@ class TestFlowExecutor(FlowTestCase):
) )
response = self.client.post(exec_url, {"uid_field": "invalid-string"}, follow=True) response = self.client.post(exec_url, {"uid_field": "invalid-string"}, follow=True)
self.assertStageResponse(response, flow, component="ak-stage-access-denied") self.assertStageResponse(response, flow, component="ak-stage-access-denied")
def test_re_evaluate_group_binding(self):
"""Test re-evaluate stage binding that has a policy binding to a group"""
flow = create_test_flow()
user_group_membership = create_test_user()
user_direct_binding = create_test_user()
user_other = create_test_user()
group_a = Group.objects.create(name=generate_id())
user_group_membership.ak_groups.add(group_a)
# Stage 0 is an identification stage
ident_stage = IdentificationStage.objects.create(
name=generate_id(),
user_fields=[UserFields.USERNAME],
pretend_user_exists=False,
)
FlowStageBinding.objects.create(
target=flow,
stage=ident_stage,
order=0,
)
# Stage 1 is a dummy stage that is only shown for users in group_a
dummy_stage = DummyStage.objects.create(name=generate_id())
dummy_binding = FlowStageBinding.objects.create(target=flow, stage=dummy_stage, order=1)
PolicyBinding.objects.create(group=group_a, target=dummy_binding, order=0)
PolicyBinding.objects.create(user=user_direct_binding, target=dummy_binding, order=0)
# Stage 2 is a deny stage that (in this case) only user_b will see
deny_stage = DenyStage.objects.create(name=generate_id())
FlowStageBinding.objects.create(target=flow, stage=deny_stage, order=2)
exec_url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug})
with self.subTest(f"Test user access through group: {user_group_membership}"):
self.client.logout()
# First request, run the planner
response = self.client.get(exec_url)
self.assertStageResponse(response, flow, component="ak-stage-identification")
response = self.client.post(
exec_url, {"uid_field": user_group_membership.username}, follow=True
)
self.assertStageResponse(response, flow, component="ak-stage-dummy")
with self.subTest(f"Test user access through user: {user_direct_binding}"):
self.client.logout()
# First request, run the planner
response = self.client.get(exec_url)
self.assertStageResponse(response, flow, component="ak-stage-identification")
response = self.client.post(
exec_url, {"uid_field": user_direct_binding.username}, follow=True
)
self.assertStageResponse(response, flow, component="ak-stage-dummy")
with self.subTest(f"Test user has no access: {user_other}"):
self.client.logout()
# First request, run the planner
response = self.client.get(exec_url)
self.assertStageResponse(response, flow, component="ak-stage-identification")
response = self.client.post(exec_url, {"uid_field": user_other.username}, follow=True)
self.assertStageResponse(response, flow, component="ak-stage-access-denied")

View File

@ -8,7 +8,6 @@ from rest_framework.test import APITestCase
from authentik.core.tests.utils import create_test_admin_user, create_test_flow from authentik.core.tests.utils import create_test_admin_user, create_test_flow
from authentik.flows.models import FlowDesignation, FlowStageBinding, InvalidResponseAction from authentik.flows.models import FlowDesignation, FlowStageBinding, InvalidResponseAction
from authentik.lib.generators import generate_id
from authentik.stages.dummy.models import DummyStage from authentik.stages.dummy.models import DummyStage
from authentik.stages.identification.models import IdentificationStage, UserFields from authentik.stages.identification.models import IdentificationStage, UserFields
@ -27,7 +26,7 @@ class TestFlowInspector(APITestCase):
# Stage 1 is an identification stage # Stage 1 is an identification stage
ident_stage = IdentificationStage.objects.create( ident_stage = IdentificationStage.objects.create(
name=generate_id(), name="ident",
user_fields=[UserFields.USERNAME], user_fields=[UserFields.USERNAME],
) )
FlowStageBinding.objects.create( FlowStageBinding.objects.create(
@ -36,8 +35,9 @@ class TestFlowInspector(APITestCase):
order=1, order=1,
invalid_response_action=InvalidResponseAction.RESTART_WITH_CONTEXT, invalid_response_action=InvalidResponseAction.RESTART_WITH_CONTEXT,
) )
dummy_stage = DummyStage.objects.create(name=generate_id()) FlowStageBinding.objects.create(
FlowStageBinding.objects.create(target=flow, stage=dummy_stage, order=1) target=flow, stage=DummyStage.objects.create(name="dummy2"), order=1
)
res = self.client.get( res = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
@ -68,11 +68,9 @@ class TestFlowInspector(APITestCase):
) )
content = loads(ins.content) content = loads(ins.content)
self.assertEqual(content["is_completed"], False) self.assertEqual(content["is_completed"], False)
self.assertEqual(content["current_plan"]["current_stage"]["stage_obj"]["name"], "ident")
self.assertEqual( self.assertEqual(
content["current_plan"]["current_stage"]["stage_obj"]["name"], ident_stage.name content["current_plan"]["next_planned_stage"]["stage_obj"]["name"], "dummy2"
)
self.assertEqual(
content["current_plan"]["next_planned_stage"]["stage_obj"]["name"], dummy_stage.name
) )
self.client.post( self.client.post(
@ -86,12 +84,8 @@ class TestFlowInspector(APITestCase):
) )
content = loads(ins.content) content = loads(ins.content)
self.assertEqual(content["is_completed"], False) self.assertEqual(content["is_completed"], False)
self.assertEqual( self.assertEqual(content["plans"][0]["current_stage"]["stage_obj"]["name"], "ident")
content["plans"][0]["current_stage"]["stage_obj"]["name"], ident_stage.name self.assertEqual(content["current_plan"]["current_stage"]["stage_obj"]["name"], "dummy2")
)
self.assertEqual(
content["current_plan"]["current_stage"]["stage_obj"]["name"], dummy_stage.name
)
self.assertEqual( self.assertEqual(
content["current_plan"]["plan_context"]["pending_user"]["username"], self.admin.username content["current_plan"]["plan_context"]["pending_user"]["username"], self.admin.username
) )

View File

@ -29,7 +29,6 @@ from authentik.flows.planner import (
cache_key, cache_key,
) )
from authentik.flows.stage import StageView from authentik.flows.stage import StageView
from authentik.lib.generators import generate_id
from authentik.lib.tests.utils import dummy_get_response from authentik.lib.tests.utils import dummy_get_response
from authentik.outposts.apps import MANAGED_OUTPOST from authentik.outposts.apps import MANAGED_OUTPOST
from authentik.outposts.models import Outpost from authentik.outposts.models import Outpost
@ -154,7 +153,7 @@ class TestFlowPlanner(TestCase):
"""Test planner cache""" """Test planner cache"""
flow = create_test_flow(FlowDesignation.AUTHENTICATION) flow = create_test_flow(FlowDesignation.AUTHENTICATION)
FlowStageBinding.objects.create( FlowStageBinding.objects.create(
target=flow, stage=DummyStage.objects.create(name=generate_id()), order=0 target=flow, stage=DummyStage.objects.create(name="dummy"), order=0
) )
request = self.request_factory.get( request = self.request_factory.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
@ -173,7 +172,7 @@ class TestFlowPlanner(TestCase):
"""Test planner with default_context""" """Test planner with default_context"""
flow = create_test_flow() flow = create_test_flow()
FlowStageBinding.objects.create( FlowStageBinding.objects.create(
target=flow, stage=DummyStage.objects.create(name=generate_id()), order=0 target=flow, stage=DummyStage.objects.create(name="dummy"), order=0
) )
user = User.objects.create(username="test-user") user = User.objects.create(username="test-user")
@ -192,7 +191,7 @@ class TestFlowPlanner(TestCase):
FlowStageBinding.objects.create( FlowStageBinding.objects.create(
target=flow, target=flow,
stage=DummyStage.objects.create(name=generate_id()), stage=DummyStage.objects.create(name="dummy1"),
order=0, order=0,
re_evaluate_policies=True, re_evaluate_policies=True,
) )
@ -205,7 +204,7 @@ class TestFlowPlanner(TestCase):
planner = FlowPlanner(flow) planner = FlowPlanner(flow)
plan = planner.plan(request) plan = planner.plan(request)
self.assertEqual(plan.markers[0].__class__, ReevaluateMarker) self.assertIsInstance(plan.markers[0], ReevaluateMarker)
def test_planner_reevaluate_actual(self): def test_planner_reevaluate_actual(self):
"""Test planner with re-evaluate""" """Test planner with re-evaluate"""
@ -213,14 +212,11 @@ class TestFlowPlanner(TestCase):
false_policy = DummyPolicy.objects.create(result=False, wait_min=1, wait_max=2) false_policy = DummyPolicy.objects.create(result=False, wait_min=1, wait_max=2)
binding = FlowStageBinding.objects.create( binding = FlowStageBinding.objects.create(
target=flow, target=flow, stage=DummyStage.objects.create(name="dummy1"), order=0
stage=DummyStage.objects.create(name=generate_id()),
order=0,
re_evaluate_policies=False,
) )
binding2 = FlowStageBinding.objects.create( binding2 = FlowStageBinding.objects.create(
target=flow, target=flow,
stage=DummyStage.objects.create(name=generate_id()), stage=DummyStage.objects.create(name="dummy2"),
order=1, order=1,
re_evaluate_policies=True, re_evaluate_policies=True,
) )
@ -244,8 +240,6 @@ class TestFlowPlanner(TestCase):
self.assertEqual(plan.bindings[0], binding) self.assertEqual(plan.bindings[0], binding)
self.assertEqual(plan.bindings[1], binding2) self.assertEqual(plan.bindings[1], binding2)
self.assertEqual(plan.markers[0].__class__, StageMarker)
self.assertEqual(plan.markers[1].__class__, ReevaluateMarker)
self.assertIsInstance(plan.markers[0], StageMarker) self.assertIsInstance(plan.markers[0], StageMarker)
self.assertIsInstance(plan.markers[1], ReevaluateMarker) self.assertIsInstance(plan.markers[1], ReevaluateMarker)

View File

@ -103,7 +103,7 @@ class FlowExecutorView(APIView):
permission_classes = [AllowAny] permission_classes = [AllowAny]
flow: Flow flow: Flow = None
plan: FlowPlan | None = None plan: FlowPlan | None = None
current_binding: FlowStageBinding | None = None current_binding: FlowStageBinding | None = None
@ -114,7 +114,8 @@ class FlowExecutorView(APIView):
def setup(self, request: HttpRequest, flow_slug: str): def setup(self, request: HttpRequest, flow_slug: str):
super().setup(request, flow_slug=flow_slug) super().setup(request, flow_slug=flow_slug)
self.flow = get_object_or_404(Flow.objects.select_related(), slug=flow_slug) if not self.flow:
self.flow = get_object_or_404(Flow.objects.select_related(), slug=flow_slug)
self._logger = get_logger().bind(flow_slug=flow_slug) self._logger = get_logger().bind(flow_slug=flow_slug)
set_tag("authentik.flow", self.flow.slug) set_tag("authentik.flow", self.flow.slug)

View File

@ -78,9 +78,9 @@ class FlowInspectorView(APIView):
self.flow = get_object_or_404(Flow.objects.select_related(), slug=flow_slug) self.flow = get_object_or_404(Flow.objects.select_related(), slug=flow_slug)
if settings.DEBUG: if settings.DEBUG:
return return
if request.user.has_perm( if request.user.has_perm("authentik_flows.inspect_flow") or request.user.has_perm(
"authentik_flows.inspect_flow", self.flow "authentik_flows.inspect_flow", self.flow
) or request.user.has_perm("authentik_flows.inspect_flow"): ):
return return
raise Http404 raise Http404
@ -96,9 +96,6 @@ class FlowInspectorView(APIView):
"""Get current flow state and record it""" """Get current flow state and record it"""
plans = [] plans = []
for plan in request.session.get(SESSION_KEY_HISTORY, []): for plan in request.session.get(SESSION_KEY_HISTORY, []):
plan: FlowPlan
if plan.flow_pk != self.flow.pk.hex:
continue
plan_serializer = FlowInspectorPlanSerializer( plan_serializer = FlowInspectorPlanSerializer(
instance=plan, context={"request": request} instance=plan, context={"request": request}
) )

37
authentik/lib/api.py Normal file
View File

@ -0,0 +1,37 @@
from collections.abc import Callable, Sequence
from typing import Self
from uuid import UUID
from django.db.models import Model, Q, QuerySet, UUIDField
from django.shortcuts import get_object_or_404
class MultipleFieldLookupMixin:
"""Helper mixin class to add support for multiple lookup_fields.
`lookup_fields` needs to be set which specifies the actual fields to query, `lookup_field`
is only used to generate the URL."""
lookup_field: str
lookup_fields: str | Sequence[str]
get_queryset: Callable[[Self], QuerySet]
filter_queryset: Callable[[Self, QuerySet], QuerySet]
def get_object(self):
queryset: QuerySet = self.get_queryset()
queryset = self.filter_queryset(queryset)
if isinstance(self.lookup_fields, str):
self.lookup_fields = [self.lookup_fields]
query = Q()
model: Model = queryset.model
for field in self.lookup_fields:
field_inst = model._meta.get_field(field)
# Sanity check, if the field we're filtering again, only apply the filter if
# our value looks like a UUID
if isinstance(field_inst, UUIDField):
try:
UUID(self.kwargs[self.lookup_field])
except ValueError:
continue
query |= Q(**{field: self.kwargs[self.lookup_field]})
return get_object_or_404(queryset, query)

View File

@ -283,7 +283,8 @@ class ConfigLoader:
def get_optional_int(self, path: str, default=None) -> int | None: def get_optional_int(self, path: str, default=None) -> int | None:
"""Wrapper for get that converts value into int or None if set""" """Wrapper for get that converts value into int or None if set"""
value = self.get(path, default) value = self.get(path, default)
if value is UNSET:
return default
try: try:
return int(value) return int(value)
except (ValueError, TypeError) as exc: except (ValueError, TypeError) as exc:

View File

@ -9,25 +9,20 @@ from typing import Any
from cachetools import TLRUCache, cached from cachetools import TLRUCache, cached
from django.core.exceptions import FieldError from django.core.exceptions import FieldError
from django.http import HttpRequest
from django.utils.text import slugify from django.utils.text import slugify
from django.utils.timezone import now
from guardian.shortcuts import get_anonymous_user from guardian.shortcuts import get_anonymous_user
from rest_framework.serializers import ValidationError from rest_framework.serializers import ValidationError
from sentry_sdk import start_span from sentry_sdk import start_span
from sentry_sdk.tracing import Span from sentry_sdk.tracing import Span
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.core.models import AuthenticatedSession, User from authentik.core.models import User
from authentik.events.models import Event from authentik.events.models import Event
from authentik.lib.expression.exceptions import ControlFlowException from authentik.lib.expression.exceptions import ControlFlowException
from authentik.lib.utils.http import get_http_session from authentik.lib.utils.http import get_http_session
from authentik.lib.utils.time import timedelta_from_string
from authentik.policies.models import Policy, PolicyBinding from authentik.policies.models import Policy, PolicyBinding
from authentik.policies.process import PolicyProcess from authentik.policies.process import PolicyProcess
from authentik.policies.types import PolicyRequest, PolicyResult from authentik.policies.types import PolicyRequest, PolicyResult
from authentik.providers.oauth2.id_token import IDToken
from authentik.providers.oauth2.models import AccessToken, OAuth2Provider
from authentik.stages.authenticator import devices_for_user from authentik.stages.authenticator import devices_for_user
LOGGER = get_logger() LOGGER = get_logger()
@ -61,7 +56,6 @@ class BaseEvaluator:
"ak_logger": get_logger(self._filename).bind(), "ak_logger": get_logger(self._filename).bind(),
"ak_user_by": BaseEvaluator.expr_user_by, "ak_user_by": BaseEvaluator.expr_user_by,
"ak_user_has_authenticator": BaseEvaluator.expr_func_user_has_authenticator, "ak_user_has_authenticator": BaseEvaluator.expr_func_user_has_authenticator,
"ak_create_jwt": self.expr_create_jwt,
"ip_address": ip_address, "ip_address": ip_address,
"ip_network": ip_network, "ip_network": ip_network,
"list_flatten": BaseEvaluator.expr_flatten, "list_flatten": BaseEvaluator.expr_flatten,
@ -188,36 +182,6 @@ class BaseEvaluator:
proc = PolicyProcess(PolicyBinding(policy=policy), request=req, connection=None) proc = PolicyProcess(PolicyBinding(policy=policy), request=req, connection=None)
return proc.profiling_wrapper() return proc.profiling_wrapper()
def expr_create_jwt(
self,
user: User,
provider: OAuth2Provider | str,
scopes: list[str],
validity: str = "seconds=60",
) -> str | None:
"""Issue a JWT for a given provider"""
request: HttpRequest = self._context.get("http_request")
if not request:
return None
if not isinstance(provider, OAuth2Provider):
provider = OAuth2Provider.objects.get(name=provider)
session = None
if hasattr(request, "session") and request.session.session_key:
session = AuthenticatedSession.objects.filter(
session_key=request.session.session_key
).first()
access_token = AccessToken(
provider=provider,
user=user,
expires=now() + timedelta_from_string(validity),
scope=scopes,
auth_time=now(),
session=session,
)
access_token.id_token = IDToken.new(provider, access_token, request)
access_token.save()
return access_token.token
def wrap_expression(self, expression: str) -> str: def wrap_expression(self, expression: str) -> str:
"""Wrap expression in a function, call it, and save the result as `result`""" """Wrap expression in a function, call it, and save the result as `result`"""
handler_signature = ",".join(sanitize_arg(x) for x in self._context.keys()) handler_signature = ",".join(sanitize_arg(x) for x in self._context.keys())

View File

@ -1,15 +1,11 @@
"""Test Evaluator base functions""" """Test Evaluator base functions"""
from django.test import RequestFactory, TestCase from django.test import TestCase
from django.urls import reverse
from jwt import decode
from authentik.blueprints.tests import apply_blueprint from authentik.core.tests.utils import create_test_admin_user
from authentik.core.tests.utils import create_test_admin_user, create_test_flow, create_test_user
from authentik.events.models import Event from authentik.events.models import Event
from authentik.lib.expression.evaluator import BaseEvaluator from authentik.lib.expression.evaluator import BaseEvaluator
from authentik.lib.generators import generate_id from authentik.lib.generators import generate_id
from authentik.providers.oauth2.models import OAuth2Provider, ScopeMapping
class TestEvaluator(TestCase): class TestEvaluator(TestCase):
@ -45,35 +41,3 @@ class TestEvaluator(TestCase):
event = Event.objects.filter(action="custom_foo").first() event = Event.objects.filter(action="custom_foo").first()
self.assertIsNotNone(event) self.assertIsNotNone(event)
self.assertEqual(event.context, {"bar": "baz", "foo": "bar"}) self.assertEqual(event.context, {"bar": "baz", "foo": "bar"})
@apply_blueprint("system/providers-oauth2.yaml")
def test_expr_create_jwt(self):
"""Test expr_create_jwt"""
rf = RequestFactory()
user = create_test_user()
provider = OAuth2Provider.objects.create(
name=generate_id(),
authorization_flow=create_test_flow(),
)
provider.property_mappings.set(
ScopeMapping.objects.filter(
managed__in=[
"goauthentik.io/providers/oauth2/scope-openid",
"goauthentik.io/providers/oauth2/scope-email",
"goauthentik.io/providers/oauth2/scope-profile",
]
)
)
evaluator = BaseEvaluator(generate_id())
evaluator._context = {
"http_request": rf.get(reverse("authentik_core:root-redirect")),
"user": user,
"provider": provider.name,
}
jwt = evaluator.evaluate(
"return ak_create_jwt(user, provider, ['openid', 'email', 'profile'])"
)
decoded = decode(
jwt, provider.client_secret, algorithms=["HS256"], audience=provider.client_id
)
self.assertEqual(decoded["preferred_username"], user.username)

View File

@ -13,7 +13,6 @@ from paramiko.ssh_exception import SSHException
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from yaml import safe_dump from yaml import safe_dump
from authentik import __version__
from authentik.outposts.apps import MANAGED_OUTPOST from authentik.outposts.apps import MANAGED_OUTPOST
from authentik.outposts.controllers.base import BaseClient, BaseController, ControllerException from authentik.outposts.controllers.base import BaseClient, BaseController, ControllerException
from authentik.outposts.docker_ssh import DockerInlineSSH, SSHManagedExternallyException from authentik.outposts.docker_ssh import DockerInlineSSH, SSHManagedExternallyException
@ -183,16 +182,10 @@ class DockerController(BaseController):
`outposts.container_image_base`, but fall back to known-good images""" `outposts.container_image_base`, but fall back to known-good images"""
image = self.get_container_image() image = self.get_container_image()
try: try:
# See if the image exists... self.client.images.pull(image)
self.client.images.get(image) except DockerException: # pragma: no cover
except DockerException: image = f"ghcr.io/goauthentik/{self.outpost.type}:latest"
try: self.client.images.pull(image)
# ...otherwise try to pull it...
self.client.images.pull(image)
except DockerException:
# ...and as a fallback to that default to a sane standard
image = f"ghcr.io/goauthentik/{self.outpost.type}:{__version__}"
self.client.images.pull(image)
return image return image
def _get_container(self) -> tuple[Container, bool]: def _get_container(self) -> tuple[Container, bool]:

View File

@ -207,7 +207,7 @@ class KubernetesObjectReconciler(Generic[T]):
"app.kubernetes.io/instance": slugify(self.controller.outpost.name), "app.kubernetes.io/instance": slugify(self.controller.outpost.name),
"app.kubernetes.io/managed-by": "goauthentik.io", "app.kubernetes.io/managed-by": "goauthentik.io",
"app.kubernetes.io/name": f"authentik-{self.controller.outpost.type.lower()}", "app.kubernetes.io/name": f"authentik-{self.controller.outpost.type.lower()}",
"app.kubernetes.io/version": get_version().replace("+", "-"), "app.kubernetes.io/version": get_version(),
"goauthentik.io/outpost-name": slugify(self.controller.outpost.name), "goauthentik.io/outpost-name": slugify(self.controller.outpost.name),
"goauthentik.io/outpost-type": str(self.controller.outpost.type), "goauthentik.io/outpost-type": str(self.controller.outpost.type),
"goauthentik.io/outpost-uuid": self.controller.outpost.uuid.hex, "goauthentik.io/outpost-uuid": self.controller.outpost.uuid.hex,

View File

@ -94,7 +94,7 @@ class DeploymentReconciler(KubernetesObjectReconciler[V1Deployment]):
meta = self.get_object_meta(name=self.name) meta = self.get_object_meta(name=self.name)
image_name = self.controller.get_container_image() image_name = self.controller.get_container_image()
image_pull_secrets = self.outpost.config.kubernetes_image_pull_secrets image_pull_secrets = self.outpost.config.kubernetes_image_pull_secrets
version = get_full_version().replace("+", "-") version = get_full_version()
return V1Deployment( return V1Deployment(
metadata=meta, metadata=meta,
spec=V1DeploymentSpec( spec=V1DeploymentSpec(

View File

@ -13,7 +13,7 @@ if TYPE_CHECKING:
from authentik.outposts.controllers.kubernetes import KubernetesController from authentik.outposts.controllers.kubernetes import KubernetesController
@dataclass(slots=True) @dataclass
class PrometheusServiceMonitorSpecEndpoint: class PrometheusServiceMonitorSpecEndpoint:
"""Prometheus ServiceMonitor endpoint spec""" """Prometheus ServiceMonitor endpoint spec"""
@ -21,14 +21,14 @@ class PrometheusServiceMonitorSpecEndpoint:
path: str = field(default="/metrics") path: str = field(default="/metrics")
@dataclass(slots=True) @dataclass
class PrometheusServiceMonitorSpecSelector: class PrometheusServiceMonitorSpecSelector:
"""Prometheus ServiceMonitor selector spec""" """Prometheus ServiceMonitor selector spec"""
matchLabels: dict matchLabels: dict
@dataclass(slots=True) @dataclass
class PrometheusServiceMonitorSpec: class PrometheusServiceMonitorSpec:
"""Prometheus ServiceMonitor spec""" """Prometheus ServiceMonitor spec"""
@ -37,7 +37,7 @@ class PrometheusServiceMonitorSpec:
selector: PrometheusServiceMonitorSpecSelector selector: PrometheusServiceMonitorSpecSelector
@dataclass(slots=True) @dataclass
class PrometheusServiceMonitorMetadata: class PrometheusServiceMonitorMetadata:
"""Prometheus ServiceMonitor metadata""" """Prometheus ServiceMonitor metadata"""
@ -46,7 +46,7 @@ class PrometheusServiceMonitorMetadata:
labels: dict = field(default_factory=dict) labels: dict = field(default_factory=dict)
@dataclass(slots=True) @dataclass
class PrometheusServiceMonitor: class PrometheusServiceMonitor:
"""Prometheus ServiceMonitor""" """Prometheus ServiceMonitor"""

View File

@ -7,7 +7,7 @@ from django.template.response import TemplateResponse
from django.urls import reverse from django.urls import reverse
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from authentik.core.models import User from authentik.core.models import USER_ATTRIBUTE_DEBUG
from authentik.policies.types import PolicyResult from authentik.policies.types import PolicyResult
@ -31,11 +31,12 @@ class AccessDeniedResponse(TemplateResponse):
if self.error_message: if self.error_message:
context["error"] = self.error_message context["error"] = self.error_message
# Only show policy result if user is authenticated and # Only show policy result if user is authenticated and
# has permissions to see them # either superuser or has USER_ATTRIBUTE_DEBUG set
if self.policy_result: if self.policy_result:
if self._request.user and self._request.user.is_authenticated: if self._request.user and self._request.user.is_authenticated:
user: User = self._request.user if self._request.user.is_superuser or self._request.user.group_attributes(
if user.has_perm("authentik_core.user_view_debug"): self._request
).get(USER_ATTRIBUTE_DEBUG, False):
context["policy_result"] = self.policy_result context["policy_result"] = self.policy_result
context["cancel"] = reverse("authentik_flows:cancel") context["cancel"] = reverse("authentik_flows:cancel")
return context return context

View File

@ -1,30 +0,0 @@
# Generated by Django 5.0.10 on 2025-01-13 18:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
(
"authentik_policies_reputation",
"0007_reputation_authentik_p_identif_9434d7_idx_and_more",
),
]
operations = [
migrations.AddIndex(
model_name="reputation",
index=models.Index(fields=["expires"], name="authentik_p_expires_da493f_idx"),
),
migrations.AddIndex(
model_name="reputation",
index=models.Index(fields=["expiring"], name="authentik_p_expirin_2ab34f_idx"),
),
migrations.AddIndex(
model_name="reputation",
index=models.Index(
fields=["expiring", "expires"], name="authentik_p_expirin_2a8ec7_idx"
),
),
]

View File

@ -96,7 +96,7 @@ class Reputation(ExpiringModel, SerializerModel):
verbose_name = _("Reputation Score") verbose_name = _("Reputation Score")
verbose_name_plural = _("Reputation Scores") verbose_name_plural = _("Reputation Scores")
unique_together = ("identifier", "ip") unique_together = ("identifier", "ip")
indexes = ExpiringModel.Meta.indexes + [ indexes = [
models.Index(fields=["identifier"]), models.Index(fields=["identifier"]),
models.Index(fields=["ip"]), models.Index(fields=["ip"]),
models.Index(fields=["ip", "identifier"]), models.Index(fields=["ip", "identifier"]),

View File

@ -2,8 +2,11 @@
from json import dumps from json import dumps
from django_filters.rest_framework import DjangoFilterBackend
from guardian.utils import get_anonymous_user
from rest_framework import mixins from rest_framework import mixins
from rest_framework.fields import CharField, ListField, SerializerMethodField from rest_framework.fields import CharField, ListField, SerializerMethodField
from rest_framework.filters import OrderingFilter, SearchFilter
from rest_framework.viewsets import GenericViewSet from rest_framework.viewsets import GenericViewSet
from authentik.core.api.used_by import UsedByMixin from authentik.core.api.used_by import UsedByMixin
@ -63,7 +66,17 @@ class AuthorizationCodeViewSet(
serializer_class = ExpiringBaseGrantModelSerializer serializer_class = ExpiringBaseGrantModelSerializer
filterset_fields = ["user", "provider"] filterset_fields = ["user", "provider"]
ordering = ["provider", "expires"] ordering = ["provider", "expires"]
owner_field = "user" filter_backends = [
DjangoFilterBackend,
OrderingFilter,
SearchFilter,
]
def get_queryset(self):
user = self.request.user if self.request else get_anonymous_user()
if user.is_superuser:
return super().get_queryset()
return super().get_queryset().filter(user=user.pk)
class RefreshTokenViewSet( class RefreshTokenViewSet(
@ -79,7 +92,17 @@ class RefreshTokenViewSet(
serializer_class = TokenModelSerializer serializer_class = TokenModelSerializer
filterset_fields = ["user", "provider"] filterset_fields = ["user", "provider"]
ordering = ["provider", "expires"] ordering = ["provider", "expires"]
owner_field = "user" filter_backends = [
DjangoFilterBackend,
OrderingFilter,
SearchFilter,
]
def get_queryset(self):
user = self.request.user if self.request else get_anonymous_user()
if user.is_superuser:
return super().get_queryset()
return super().get_queryset().filter(user=user.pk)
class AccessTokenViewSet( class AccessTokenViewSet(
@ -95,4 +118,14 @@ class AccessTokenViewSet(
serializer_class = TokenModelSerializer serializer_class = TokenModelSerializer
filterset_fields = ["user", "provider"] filterset_fields = ["user", "provider"]
ordering = ["provider", "expires"] ordering = ["provider", "expires"]
owner_field = "user" filter_backends = [
DjangoFilterBackend,
OrderingFilter,
SearchFilter,
]
def get_queryset(self):
user = self.request.user if self.request else get_anonymous_user()
if user.is_superuser:
return super().get_queryset()
return super().get_queryset().filter(user=user.pk)

View File

@ -1,72 +0,0 @@
# Generated by Django 5.0.10 on 2025-01-13 18:05
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0042_authenticatedsession_authentik_c_expires_08251d_idx_and_more"),
("authentik_providers_oauth2", "0026_alter_accesstoken_session_and_more"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddIndex(
model_name="accesstoken",
index=models.Index(fields=["expires"], name="authentik_p_expires_9f24a5_idx"),
),
migrations.AddIndex(
model_name="accesstoken",
index=models.Index(fields=["expiring"], name="authentik_p_expirin_2d9205_idx"),
),
migrations.AddIndex(
model_name="accesstoken",
index=models.Index(
fields=["expiring", "expires"], name="authentik_p_expirin_c74005_idx"
),
),
migrations.AddIndex(
model_name="authorizationcode",
index=models.Index(fields=["expires"], name="authentik_p_expires_f594b2_idx"),
),
migrations.AddIndex(
model_name="authorizationcode",
index=models.Index(fields=["expiring"], name="authentik_p_expirin_6a5e2c_idx"),
),
migrations.AddIndex(
model_name="authorizationcode",
index=models.Index(
fields=["expiring", "expires"], name="authentik_p_expirin_c0f353_idx"
),
),
migrations.AddIndex(
model_name="devicetoken",
index=models.Index(fields=["expires"], name="authentik_p_expires_961437_idx"),
),
migrations.AddIndex(
model_name="devicetoken",
index=models.Index(fields=["expiring"], name="authentik_p_expirin_4fd278_idx"),
),
migrations.AddIndex(
model_name="devicetoken",
index=models.Index(
fields=["expiring", "expires"], name="authentik_p_expirin_cd6b1c_idx"
),
),
migrations.AddIndex(
model_name="refreshtoken",
index=models.Index(fields=["expires"], name="authentik_p_expires_c479a7_idx"),
),
migrations.AddIndex(
model_name="refreshtoken",
index=models.Index(fields=["expiring"], name="authentik_p_expirin_d4d17f_idx"),
),
migrations.AddIndex(
model_name="refreshtoken",
index=models.Index(
fields=["expiring", "expires"], name="authentik_p_expirin_acb4a5_idx"
),
),
]

View File

@ -425,7 +425,6 @@ class AuthorizationCode(SerializerModel, ExpiringModel, BaseGrantModel):
class Meta: class Meta:
verbose_name = _("Authorization Code") verbose_name = _("Authorization Code")
verbose_name_plural = _("Authorization Codes") verbose_name_plural = _("Authorization Codes")
indexes = ExpiringModel.Meta.indexes
def __str__(self): def __str__(self):
return f"Authorization code for {self.provider_id} for user {self.user_id}" return f"Authorization code for {self.provider_id} for user {self.user_id}"
@ -454,7 +453,7 @@ class AccessToken(SerializerModel, ExpiringModel, BaseGrantModel):
_id_token = models.TextField() _id_token = models.TextField()
class Meta: class Meta:
indexes = ExpiringModel.Meta.indexes + [ indexes = [
HashIndex(fields=["token"]), HashIndex(fields=["token"]),
] ]
verbose_name = _("OAuth2 Access Token") verbose_name = _("OAuth2 Access Token")
@ -505,7 +504,7 @@ class RefreshToken(SerializerModel, ExpiringModel, BaseGrantModel):
) )
class Meta: class Meta:
indexes = ExpiringModel.Meta.indexes + [ indexes = [
HashIndex(fields=["token"]), HashIndex(fields=["token"]),
] ]
verbose_name = _("OAuth2 Refresh Token") verbose_name = _("OAuth2 Refresh Token")
@ -557,7 +556,6 @@ class DeviceToken(ExpiringModel):
class Meta: class Meta:
verbose_name = _("Device Token") verbose_name = _("Device Token")
verbose_name_plural = _("Device Tokens") verbose_name_plural = _("Device Tokens")
indexes = ExpiringModel.Meta.indexes
def __str__(self): def __str__(self):
return f"Device Token for {self.provider_id}" return f"Device Token for {self.provider_id}"

View File

@ -49,9 +49,7 @@ class TesOAuth2DeviceInit(OAuthTestCase):
kwargs={ kwargs={
"flow_slug": self.device_flow.slug, "flow_slug": self.device_flow.slug,
}, },
) ),
+ "?"
+ urlencode({"inspector": "available"}),
) )
def test_device_init_post(self): def test_device_init_post(self):
@ -65,9 +63,7 @@ class TesOAuth2DeviceInit(OAuthTestCase):
kwargs={ kwargs={
"flow_slug": self.device_flow.slug, "flow_slug": self.device_flow.slug,
}, },
) ),
+ "?"
+ urlencode({"inspector": "available"}),
) )
res = self.api_client.get( res = self.api_client.get(
reverse( reverse(
@ -122,9 +118,7 @@ class TesOAuth2DeviceInit(OAuthTestCase):
kwargs={ kwargs={
"flow_slug": provider.authorization_flow.slug, "flow_slug": provider.authorization_flow.slug,
}, },
) ),
+ "?"
+ urlencode({"inspector": "available"}),
}, },
) )
@ -156,7 +150,7 @@ class TesOAuth2DeviceInit(OAuthTestCase):
}, },
) )
+ "?" + "?"
+ urlencode({QS_KEY_CODE: token.user_code, "inspector": "available"}), + urlencode({QS_KEY_CODE: token.user_code}),
) )
def test_device_init_denied(self): def test_device_init_denied(self):

View File

@ -12,7 +12,6 @@ from authentik.core.tests.utils import create_test_admin_user, create_test_cert,
from authentik.lib.generators import generate_id from authentik.lib.generators import generate_id
from authentik.providers.oauth2.models import ( from authentik.providers.oauth2.models import (
AccessToken, AccessToken,
ClientTypes,
IDToken, IDToken,
OAuth2Provider, OAuth2Provider,
RedirectURI, RedirectURI,
@ -109,29 +108,3 @@ class TesOAuth2Revoke(OAuthTestCase):
}, },
) )
self.assertEqual(res.status_code, 401) self.assertEqual(res.status_code, 401)
def test_revoke_public(self):
"""Test revoke public client"""
self.provider.client_type = ClientTypes.PUBLIC
self.provider.save()
token: AccessToken = AccessToken.objects.create(
provider=self.provider,
user=self.user,
token=generate_id(),
auth_time=timezone.now(),
_scope="openid user profile",
_id_token=json.dumps(
asdict(
IDToken("foo", "bar"),
)
),
)
auth_public = b64encode(f"{self.provider.client_id}:{generate_id()}".encode()).decode()
res = self.client.post(
reverse("authentik_providers_oauth2:token-revoke"),
HTTP_AUTHORIZATION=f"Basic {auth_public}",
data={
"token": token.token,
},
)
self.assertEqual(res.status_code, 200)

View File

@ -178,18 +178,12 @@ def protected_resource_view(scopes: list[str]):
return wrapper return wrapper
def provider_from_request(request: HttpRequest) -> tuple[OAuth2Provider | None, str, str]:
"""Get provider from Basic auth of client_id:client_secret. Does not perform authentication"""
client_id, client_secret = extract_client_auth(request)
if client_id == client_secret == "":
return None, "", ""
provider: OAuth2Provider | None = OAuth2Provider.objects.filter(client_id=client_id).first()
return provider, client_id, client_secret
def authenticate_provider(request: HttpRequest) -> OAuth2Provider | None: def authenticate_provider(request: HttpRequest) -> OAuth2Provider | None:
"""Attempt to authenticate via Basic auth of client_id:client_secret""" """Attempt to authenticate via Basic auth of client_id:client_secret"""
provider, client_id, client_secret = provider_from_request(request) client_id, client_secret = extract_client_auth(request)
if client_id == client_secret == "":
return None
provider: OAuth2Provider | None = OAuth2Provider.objects.filter(client_id=client_id).first()
if not provider: if not provider:
return None return None
if client_id != provider.client_id or client_secret != provider.client_secret: if client_id != provider.client_id or client_secret != provider.client_secret:

View File

@ -499,11 +499,11 @@ class OAuthFulfillmentStage(StageView):
) )
challenge.is_valid() challenge.is_valid()
self.executor.stage_ok()
return HttpChallengeResponse( return HttpChallengeResponse(
challenge=challenge, challenge=challenge,
) )
self.executor.stage_ok()
return HttpResponseRedirectScheme(uri, allowed_schemes=[parsed.scheme]) return HttpResponseRedirectScheme(uri, allowed_schemes=[parsed.scheme])
def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:

View File

@ -9,12 +9,8 @@ from django.views.decorators.csrf import csrf_exempt
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.providers.oauth2.errors import TokenRevocationError from authentik.providers.oauth2.errors import TokenRevocationError
from authentik.providers.oauth2.models import AccessToken, ClientTypes, OAuth2Provider, RefreshToken from authentik.providers.oauth2.models import AccessToken, OAuth2Provider, RefreshToken
from authentik.providers.oauth2.utils import ( from authentik.providers.oauth2.utils import TokenResponse, authenticate_provider
TokenResponse,
authenticate_provider,
provider_from_request,
)
LOGGER = get_logger() LOGGER = get_logger()
@ -31,9 +27,7 @@ class TokenRevocationParams:
"""Extract required Parameters from HTTP Request""" """Extract required Parameters from HTTP Request"""
raw_token = request.POST.get("token") raw_token = request.POST.get("token")
provider, _, _ = provider_from_request(request) provider = authenticate_provider(request)
if provider and provider.client_type == ClientTypes.CONFIDENTIAL:
provider = authenticate_provider(request)
if not provider: if not provider:
raise TokenRevocationError("invalid_client") raise TokenRevocationError("invalid_client")

View File

@ -15,7 +15,7 @@ if TYPE_CHECKING:
from authentik.outposts.controllers.kubernetes import KubernetesController from authentik.outposts.controllers.kubernetes import KubernetesController
@dataclass(slots=True) @dataclass
class TraefikMiddlewareSpecForwardAuth: class TraefikMiddlewareSpecForwardAuth:
"""traefik middleware forwardAuth spec""" """traefik middleware forwardAuth spec"""
@ -28,14 +28,14 @@ class TraefikMiddlewareSpecForwardAuth:
trustForwardHeader: bool = field(default=True) trustForwardHeader: bool = field(default=True)
@dataclass(slots=True) @dataclass
class TraefikMiddlewareSpec: class TraefikMiddlewareSpec:
"""Traefik middleware spec""" """Traefik middleware spec"""
forwardAuth: TraefikMiddlewareSpecForwardAuth forwardAuth: TraefikMiddlewareSpecForwardAuth
@dataclass(slots=True) @dataclass
class TraefikMiddlewareMetadata: class TraefikMiddlewareMetadata:
"""Traefik Middleware metadata""" """Traefik Middleware metadata"""
@ -44,7 +44,7 @@ class TraefikMiddlewareMetadata:
labels: dict = field(default_factory=dict) labels: dict = field(default_factory=dict)
@dataclass(slots=True) @dataclass
class TraefikMiddleware: class TraefikMiddleware:
"""Traefik Middleware""" """Traefik Middleware"""

View File

@ -16,7 +16,6 @@ from rest_framework.decorators import action
from rest_framework.fields import CharField, FileField, SerializerMethodField from rest_framework.fields import CharField, FileField, SerializerMethodField
from rest_framework.parsers import MultiPartParser from rest_framework.parsers import MultiPartParser
from rest_framework.permissions import AllowAny from rest_framework.permissions import AllowAny
from rest_framework.renderers import BaseRenderer, JSONRenderer
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 PrimaryKeyRelatedField, ValidationError from rest_framework.serializers import PrimaryKeyRelatedField, ValidationError
@ -39,16 +38,6 @@ from authentik.sources.saml.processors.constants import SAML_BINDING_POST, SAML_
LOGGER = get_logger() LOGGER = get_logger()
class RawXMLDataRenderer(BaseRenderer):
"""Renderer to allow application/xml as value for 'Accept' in the metadata endpoint."""
media_type = "application/xml"
format = "xml"
def render(self, data, accepted_media_type=None, renderer_context=None):
return data
class SAMLProviderSerializer(ProviderSerializer): class SAMLProviderSerializer(ProviderSerializer):
"""SAMLProvider Serializer""" """SAMLProvider Serializer"""
@ -65,23 +54,9 @@ class SAMLProviderSerializer(ProviderSerializer):
if "request" not in self._context: if "request" not in self._context:
return "" return ""
request: HttpRequest = self._context["request"]._request request: HttpRequest = self._context["request"]._request
try: return request.build_absolute_uri(
return request.build_absolute_uri( reverse("authentik_api:samlprovider-metadata", kwargs={"pk": instance.pk}) + "?download"
reverse( )
"authentik_providers_saml:metadata-download",
kwargs={"application_slug": instance.application.slug},
)
)
except Provider.application.RelatedObjectDoesNotExist:
return request.build_absolute_uri(
reverse(
"authentik_api:samlprovider-metadata",
kwargs={
"pk": instance.pk,
},
)
+ "?download"
)
def get_url_sso_post(self, instance: SAMLProvider) -> str: def get_url_sso_post(self, instance: SAMLProvider) -> str:
"""Get SSO Post URL""" """Get SSO Post URL"""
@ -249,21 +224,9 @@ class SAMLProviderViewSet(UsedByMixin, ModelViewSet):
], ],
description="Optionally force the metadata to only include one binding.", description="Optionally force the metadata to only include one binding.",
), ),
# Explicitly excluded, because otherwise spectacular automatically
# add it when using multiple renderer_classes
OpenApiParameter(
name="format",
exclude=True,
required=False,
),
], ],
) )
@action( @action(methods=["GET"], detail=True, permission_classes=[AllowAny])
methods=["GET"],
detail=True,
permission_classes=[AllowAny],
renderer_classes=[JSONRenderer, RawXMLDataRenderer],
)
def metadata(self, request: Request, pk: int) -> Response: def metadata(self, request: Request, pk: int) -> Response:
"""Return metadata as XML string""" """Return metadata as XML string"""
# We don't use self.get_object() on purpose as this view is un-authenticated # We don't use self.get_object() on purpose as this view is un-authenticated
@ -281,9 +244,9 @@ class SAMLProviderViewSet(UsedByMixin, ModelViewSet):
f'attachment; filename="{provider.name}_authentik_meta.xml"' f'attachment; filename="{provider.name}_authentik_meta.xml"'
) )
return response return response
return Response({"metadata": metadata}, content_type="application/json") return Response({"metadata": metadata})
except Provider.application.RelatedObjectDoesNotExist: except Provider.application.RelatedObjectDoesNotExist:
return Response({"metadata": ""}, content_type="application/json") return Response({"metadata": ""})
@permission_required( @permission_required(
None, None,

View File

@ -104,22 +104,6 @@ class TestSAMLProviderAPI(APITestCase):
) )
self.assertEqual(200, response.status_code) self.assertEqual(200, response.status_code)
self.assertIn("Content-Disposition", response) self.assertIn("Content-Disposition", response)
# Test download with Accept: application/xml
response = self.client.get(
reverse("authentik_api:samlprovider-metadata", kwargs={"pk": provider.pk})
+ "?download",
HTTP_ACCEPT="application/xml",
)
self.assertEqual(200, response.status_code)
self.assertIn("Content-Disposition", response)
response = self.client.get(
reverse("authentik_api:samlprovider-metadata", kwargs={"pk": provider.pk})
+ "?download",
HTTP_ACCEPT="application/xml;charset=UTF-8",
)
self.assertEqual(200, response.status_code)
self.assertIn("Content-Disposition", response)
def test_metadata_invalid(self): def test_metadata_invalid(self):
"""Test metadata export (invalid)""" """Test metadata export (invalid)"""
@ -137,11 +121,6 @@ class TestSAMLProviderAPI(APITestCase):
reverse("authentik_api:samlprovider-metadata", kwargs={"pk": "abc"}), reverse("authentik_api:samlprovider-metadata", kwargs={"pk": "abc"}),
) )
self.assertEqual(404, response.status_code) self.assertEqual(404, response.status_code)
response = self.client.get(
reverse("authentik_api:samlprovider-metadata", kwargs={"pk": provider.pk}),
HTTP_ACCEPT="application/invalid-mime-type",
)
self.assertEqual(406, response.status_code)
def test_import_success(self): def test_import_success(self):
"""Test metadata import (success case)""" """Test metadata import (success case)"""

View File

@ -2,7 +2,7 @@
from django.apps import apps from django.apps import apps
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.db.models import QuerySet from django.db.models import Q, QuerySet
from django_filters.filters import ModelChoiceFilter from django_filters.filters import ModelChoiceFilter
from django_filters.filterset import FilterSet from django_filters.filterset import FilterSet
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
@ -18,6 +18,7 @@ from rest_framework.filters import OrderingFilter, SearchFilter
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from rest_framework.viewsets import ReadOnlyModelViewSet from rest_framework.viewsets import ReadOnlyModelViewSet
from authentik.blueprints.v1.importer import excluded_models
from authentik.core.api.utils import ModelSerializer, PassiveSerializer from authentik.core.api.utils import ModelSerializer, PassiveSerializer
from authentik.core.models import User from authentik.core.models import User
from authentik.lib.validators import RequiredTogetherValidator from authentik.lib.validators import RequiredTogetherValidator
@ -105,13 +106,13 @@ class RBACPermissionViewSet(ReadOnlyModelViewSet):
] ]
def get_queryset(self) -> QuerySet: def get_queryset(self) -> QuerySet:
return ( query = Q()
Permission.objects.all() for model in excluded_models():
.select_related("content_type") query |= Q(
.filter( content_type__app_label=model._meta.app_label,
content_type__app_label__startswith="authentik", content_type__model=model._meta.model_name,
) )
) return Permission.objects.all().select_related("content_type").exclude(query)
class PermissionAssignSerializer(PassiveSerializer): class PermissionAssignSerializer(PassiveSerializer):

View File

@ -1,15 +1,10 @@
"""RBAC API Filter""" """RBAC API Filter"""
from django.conf import settings
from django.db.models import QuerySet from django.db.models import QuerySet
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.authentication import get_authorization_header
from rest_framework.exceptions import PermissionDenied from rest_framework.exceptions import PermissionDenied
from rest_framework.request import Request from rest_framework.request import Request
from rest_framework.views import APIView
from rest_framework_guardian.filters import ObjectPermissionsFilter from rest_framework_guardian.filters import ObjectPermissionsFilter
from authentik.api.authentication import validate_auth
from authentik.core.models import UserTypes from authentik.core.models import UserTypes
@ -17,7 +12,7 @@ class ObjectFilter(ObjectPermissionsFilter):
"""Object permission filter that grants global permission higher priority than """Object permission filter that grants global permission higher priority than
per-object permissions""" per-object permissions"""
def filter_queryset(self, request: Request, queryset: QuerySet, view: APIView) -> QuerySet: def filter_queryset(self, request: Request, queryset: QuerySet, view) -> QuerySet:
permission = self.perm_format % { permission = self.perm_format % {
"app_label": queryset.model._meta.app_label, "app_label": queryset.model._meta.app_label,
"model_name": queryset.model._meta.model_name, "model_name": queryset.model._meta.model_name,
@ -26,9 +21,6 @@ class ObjectFilter(ObjectPermissionsFilter):
# per-object permissions # per-object permissions
if request.user.has_perm(permission): if request.user.has_perm(permission):
return queryset return queryset
# User does not have permissions, but we have an owner field defined, so filter by that
if owner_field := getattr(view, "owner_field", None):
return queryset.filter(**{owner_field: request.user})
queryset = super().filter_queryset(request, queryset, view) queryset = super().filter_queryset(request, queryset, view)
# Outposts (which are the only objects using internal service accounts) # Outposts (which are the only objects using internal service accounts)
# except requests to return an empty list when they have no objects # except requests to return an empty list when they have no objects
@ -40,17 +32,3 @@ class ObjectFilter(ObjectPermissionsFilter):
# and also no object permissions assigned (directly or via role) # and also no object permissions assigned (directly or via role)
raise PermissionDenied() raise PermissionDenied()
return queryset return queryset
class SecretKeyFilter(DjangoFilterBackend):
"""Allow access to all objects when authenticated with secret key as token.
Replaces both DjangoFilterBackend and ObjectFilter"""
def filter_queryset(self, request: Request, queryset: QuerySet, view) -> QuerySet:
auth_header = get_authorization_header(request)
token = validate_auth(auth_header)
if token and token == settings.SECRET_KEY:
return queryset
queryset = ObjectFilter().filter_queryset(request, queryset, view)
return super().filter_queryset(request, queryset, view)

View File

@ -15,17 +15,6 @@ class ObjectPermissions(DjangoObjectPermissions):
lookup = getattr(view, "lookup_url_kwarg", None) or getattr(view, "lookup_field", None) lookup = getattr(view, "lookup_url_kwarg", None) or getattr(view, "lookup_field", None)
if lookup and lookup in view.kwargs: if lookup and lookup in view.kwargs:
return True return True
# Legacy behaviour:
# Allow creation of objects even without explicit permission
queryset = self._queryset(view)
required_perms = self.get_required_permissions(request.method, queryset.model)
if (
len(required_perms) == 1
and f"{queryset.model._meta.app_label}.add_{queryset.model._meta.model_name}"
in required_perms
and getattr(view, "rbac_allow_create_without_perm", False)
):
return True
return super().has_permission(request, view) return super().has_permission(request, view)
def has_object_permission(self, request: Request, view, obj: Model) -> bool: def has_object_permission(self, request: Request, view, obj: Model) -> bool:
@ -35,10 +24,6 @@ class ObjectPermissions(DjangoObjectPermissions):
# Rank global permissions higher than per-object permissions # Rank global permissions higher than per-object permissions
if request.user.has_perms(perms): if request.user.has_perms(perms):
return True return True
# Allow access for owners if configured
if owner_field := getattr(view, "owner_field", None):
if getattr(obj, owner_field) == request.user:
return True
return super().has_object_permission(request, view, obj) return super().has_object_permission(request, view, obj)

View File

@ -18,7 +18,6 @@ from celery.signals import (
task_prerun, task_prerun,
worker_ready, worker_ready,
) )
from celery.worker.control import inspect_command
from django.conf import settings from django.conf import settings
from django.db import ProgrammingError from django.db import ProgrammingError
from django_tenants.utils import get_public_schema_name from django_tenants.utils import get_public_schema_name
@ -26,7 +25,6 @@ from structlog.contextvars import STRUCTLOG_KEY_PREFIX
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from tenant_schemas_celery.app import CeleryApp as TenantAwareCeleryApp from tenant_schemas_celery.app import CeleryApp as TenantAwareCeleryApp
from authentik import get_full_version
from authentik.lib.sentry import before_send from authentik.lib.sentry import before_send
from authentik.lib.utils.errors import exception_to_string from authentik.lib.utils.errors import exception_to_string
@ -161,12 +159,6 @@ class LivenessProbe(bootsteps.StartStopStep):
HEARTBEAT_FILE.touch() HEARTBEAT_FILE.touch()
@inspect_command(default_timeout=0.2)
def ping(state, **kwargs):
"""Ping worker(s)."""
return {"ok": "pong", "version": get_full_version()}
CELERY_APP.config_from_object(settings.CELERY) CELERY_APP.config_from_object(settings.CELERY)
# Load task modules from all registered Django app configs. # Load task modules from all registered Django app configs.

View File

@ -13,6 +13,7 @@ from authentik.core.api.sources import SourceSerializer
from authentik.core.api.used_by import UsedByMixin from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import PassiveSerializer from authentik.core.api.utils import PassiveSerializer
from authentik.events.api.tasks import SystemTaskSerializer from authentik.events.api.tasks import SystemTaskSerializer
from authentik.lib.api import MultipleFieldLookupMixin
from authentik.sources.kerberos.models import KerberosSource from authentik.sources.kerberos.models import KerberosSource
from authentik.sources.kerberos.tasks import CACHE_KEY_STATUS from authentik.sources.kerberos.tasks import CACHE_KEY_STATUS
@ -59,12 +60,13 @@ class KerberosSyncStatusSerializer(PassiveSerializer):
tasks = SystemTaskSerializer(many=True, read_only=True) tasks = SystemTaskSerializer(many=True, read_only=True)
class KerberosSourceViewSet(UsedByMixin, ModelViewSet): class KerberosSourceViewSet(MultipleFieldLookupMixin, UsedByMixin, ModelViewSet):
"""Kerberos Source Viewset""" """Kerberos Source Viewset"""
queryset = KerberosSource.objects.all() queryset = KerberosSource.objects.all()
serializer_class = KerberosSourceSerializer serializer_class = KerberosSourceSerializer
lookup_field = "slug" lookup_field = "slug"
lookup_fields = ["slug", "pbm_uuid"]
filterset_fields = [ filterset_fields = [
"name", "name",
"slug", "slug",

View File

@ -1,7 +1,10 @@
"""Kerberos Source Serializer""" """Kerberos Source Serializer"""
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.filters import OrderingFilter, SearchFilter
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
from authentik.api.authorization import OwnerFilter, OwnerSuperuserPermissions
from authentik.core.api.sources import ( from authentik.core.api.sources import (
GroupSourceConnectionSerializer, GroupSourceConnectionSerializer,
GroupSourceConnectionViewSet, GroupSourceConnectionViewSet,
@ -29,8 +32,9 @@ class UserKerberosSourceConnectionViewSet(UsedByMixin, ModelViewSet):
serializer_class = UserKerberosSourceConnectionSerializer serializer_class = UserKerberosSourceConnectionSerializer
filterset_fields = ["source__slug"] filterset_fields = ["source__slug"]
search_fields = ["source__slug"] search_fields = ["source__slug"]
permission_classes = [OwnerSuperuserPermissions]
filter_backends = [OwnerFilter, DjangoFilterBackend, OrderingFilter, SearchFilter]
ordering = ["source__slug"] ordering = ["source__slug"]
owner_field = "user"
class GroupKerberosSourceConnectionSerializer(GroupSourceConnectionSerializer): class GroupKerberosSourceConnectionSerializer(GroupSourceConnectionSerializer):

View File

@ -28,14 +28,14 @@ class KerberosBackend(InbuiltBackend):
if "@" in username: if "@" in username:
username, realm = username.rsplit("@", 1) username, realm = username.rsplit("@", 1)
user, source = self.auth_user(request, username, realm, **kwargs) user, source = self.auth_user(username, realm, **kwargs)
if user: if user:
self.set_method("kerberos", request, source=source) self.set_method("kerberos", request, source=source)
return user return user
return None return None
def auth_user( def auth_user(
self, request: HttpRequest, username: str, realm: str | None, password: str, **filters self, username: str, realm: str | None, password: str, **filters
) -> tuple[User | None, KerberosSource | None]: ) -> tuple[User | None, KerberosSource | None]:
sources = KerberosSource.objects.filter(enabled=True) sources = KerberosSource.objects.filter(enabled=True)
user = User.objects.filter( user = User.objects.filter(
@ -76,7 +76,7 @@ class KerberosBackend(InbuiltBackend):
user=user_source_connection.user, user=user_source_connection.user,
) )
user_source_connection.user.set_password( user_source_connection.user.set_password(
password, sender=user_source_connection.source, request=request password, sender=user_source_connection.source
) )
user_source_connection.user.save() user_source_connection.user.save()
return user_source_connection.user, user_source_connection.source return user_source_connection.user, user_source_connection.source

View File

@ -18,6 +18,7 @@ from authentik.core.api.property_mappings import PropertyMappingFilterSet, Prope
from authentik.core.api.sources import SourceSerializer from authentik.core.api.sources import SourceSerializer
from authentik.core.api.used_by import UsedByMixin from authentik.core.api.used_by import UsedByMixin
from authentik.crypto.models import CertificateKeyPair from authentik.crypto.models import CertificateKeyPair
from authentik.lib.api import MultipleFieldLookupMixin
from authentik.lib.sync.outgoing.api import SyncStatusSerializer from authentik.lib.sync.outgoing.api import SyncStatusSerializer
from authentik.sources.ldap.models import LDAPSource, LDAPSourcePropertyMapping from authentik.sources.ldap.models import LDAPSource, LDAPSourcePropertyMapping
from authentik.sources.ldap.tasks import CACHE_KEY_STATUS, SYNC_CLASSES from authentik.sources.ldap.tasks import CACHE_KEY_STATUS, SYNC_CLASSES
@ -103,12 +104,13 @@ class LDAPSourceSerializer(SourceSerializer):
extra_kwargs = {"bind_password": {"write_only": True}} extra_kwargs = {"bind_password": {"write_only": True}}
class LDAPSourceViewSet(UsedByMixin, ModelViewSet): class LDAPSourceViewSet(MultipleFieldLookupMixin, UsedByMixin, ModelViewSet):
"""LDAP Source Viewset""" """LDAP Source Viewset"""
queryset = LDAPSource.objects.all() queryset = LDAPSource.objects.all()
serializer_class = LDAPSourceSerializer serializer_class = LDAPSourceSerializer
lookup_field = "slug" lookup_field = "slug"
lookup_fields = ["slug", "pbm_uuid"]
filterset_fields = [ filterset_fields = [
"name", "name",
"slug", "slug",

View File

@ -20,15 +20,13 @@ class LDAPBackend(InbuiltBackend):
return None return None
for source in LDAPSource.objects.filter(enabled=True): for source in LDAPSource.objects.filter(enabled=True):
LOGGER.debug("LDAP Auth attempt", source=source) LOGGER.debug("LDAP Auth attempt", source=source)
user = self.auth_user(request, source, **kwargs) user = self.auth_user(source, **kwargs)
if user: if user:
self.set_method("ldap", request, source=source) self.set_method("ldap", request, source=source)
return user return user
return None return None
def auth_user( def auth_user(self, source: LDAPSource, password: str, **filters: str) -> User | None:
self, request: HttpRequest, source: LDAPSource, password: str, **filters: str
) -> User | None:
"""Try to bind as either user_dn or mail with password. """Try to bind as either user_dn or mail with password.
Returns True on success, otherwise False""" Returns True on success, otherwise False"""
users = User.objects.filter(**filters) users = User.objects.filter(**filters)
@ -45,7 +43,7 @@ class LDAPBackend(InbuiltBackend):
if source.password_login_update_internal_password: if source.password_login_update_internal_password:
# Password given successfully binds to LDAP, so we save it in our Database # Password given successfully binds to LDAP, so we save it in our Database
LOGGER.debug("Updating user's password in DB", user=user) LOGGER.debug("Updating user's password in DB", user=user)
user.set_password(password, sender=source, request=request) user.set_password(password, sender=source)
user.save() user.save()
return user return user
# Password doesn't match # Password doesn't match

View File

@ -16,6 +16,7 @@ from rest_framework.viewsets import ModelViewSet
from authentik.core.api.sources import SourceSerializer from authentik.core.api.sources import SourceSerializer
from authentik.core.api.used_by import UsedByMixin from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import PassiveSerializer from authentik.core.api.utils import PassiveSerializer
from authentik.lib.api import MultipleFieldLookupMixin
from authentik.lib.utils.http import get_http_session from authentik.lib.utils.http import get_http_session
from authentik.sources.oauth.models import OAuthSource from authentik.sources.oauth.models import OAuthSource
from authentik.sources.oauth.types.registry import SourceType, registry from authentik.sources.oauth.types.registry import SourceType, registry
@ -170,12 +171,13 @@ class OAuthSourceFilter(FilterSet):
] ]
class OAuthSourceViewSet(UsedByMixin, ModelViewSet): class OAuthSourceViewSet(MultipleFieldLookupMixin, UsedByMixin, ModelViewSet):
"""Source Viewset""" """Source Viewset"""
queryset = OAuthSource.objects.all() queryset = OAuthSource.objects.all()
serializer_class = OAuthSourceSerializer serializer_class = OAuthSourceSerializer
lookup_field = "slug" lookup_field = "slug"
lookup_fields = ["slug", "pbm_uuid"]
filterset_class = OAuthSourceFilter filterset_class = OAuthSourceFilter
search_fields = ["name", "slug"] search_fields = ["name", "slug"]
ordering = ["name"] ordering = ["name"]

View File

@ -18,6 +18,7 @@ from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import PassiveSerializer from authentik.core.api.utils import PassiveSerializer
from authentik.flows.challenge import RedirectChallenge from authentik.flows.challenge import RedirectChallenge
from authentik.flows.views.executor import to_stage_response from authentik.flows.views.executor import to_stage_response
from authentik.lib.api import MultipleFieldLookupMixin
from authentik.rbac.decorators import permission_required from authentik.rbac.decorators import permission_required
from authentik.sources.plex.models import PlexSource, UserPlexSourceConnection from authentik.sources.plex.models import PlexSource, UserPlexSourceConnection
from authentik.sources.plex.plex import PlexAuth, PlexSourceFlowManager from authentik.sources.plex.plex import PlexAuth, PlexSourceFlowManager
@ -45,12 +46,13 @@ class PlexTokenRedeemSerializer(PassiveSerializer):
plex_token = CharField() plex_token = CharField()
class PlexSourceViewSet(UsedByMixin, ModelViewSet): class PlexSourceViewSet(MultipleFieldLookupMixin, UsedByMixin, ModelViewSet):
"""Plex source Viewset""" """Plex source Viewset"""
queryset = PlexSource.objects.all() queryset = PlexSource.objects.all()
serializer_class = PlexSourceSerializer serializer_class = PlexSourceSerializer
lookup_field = "slug" lookup_field = "slug"
lookup_fields = ["slug", "pbm_uuid"]
filterset_fields = [ filterset_fields = [
"name", "name",
"slug", "slug",

View File

@ -9,6 +9,7 @@ from rest_framework.viewsets import ModelViewSet
from authentik.core.api.sources import SourceSerializer from authentik.core.api.sources import SourceSerializer
from authentik.core.api.used_by import UsedByMixin from authentik.core.api.used_by import UsedByMixin
from authentik.lib.api import MultipleFieldLookupMixin
from authentik.providers.saml.api.providers import SAMLMetadataSerializer from authentik.providers.saml.api.providers import SAMLMetadataSerializer
from authentik.sources.saml.models import SAMLSource from authentik.sources.saml.models import SAMLSource
from authentik.sources.saml.processors.metadata import MetadataProcessor from authentik.sources.saml.processors.metadata import MetadataProcessor
@ -37,12 +38,13 @@ class SAMLSourceSerializer(SourceSerializer):
] ]
class SAMLSourceViewSet(UsedByMixin, ModelViewSet): class SAMLSourceViewSet(MultipleFieldLookupMixin, UsedByMixin, ModelViewSet):
"""SAMLSource Viewset""" """SAMLSource Viewset"""
queryset = SAMLSource.objects.all() queryset = SAMLSource.objects.all()
serializer_class = SAMLSourceSerializer serializer_class = SAMLSourceSerializer
lookup_field = "slug" lookup_field = "slug"
lookup_fields = ["slug", "pbm_uuid"]
filterset_fields = [ filterset_fields = [
"name", "name",
"slug", "slug",

View File

@ -7,6 +7,7 @@ from rest_framework.viewsets import ModelViewSet
from authentik.core.api.sources import SourceSerializer from authentik.core.api.sources import SourceSerializer
from authentik.core.api.tokens import TokenSerializer from authentik.core.api.tokens import TokenSerializer
from authentik.core.api.used_by import UsedByMixin from authentik.core.api.used_by import UsedByMixin
from authentik.lib.api import MultipleFieldLookupMixin
from authentik.sources.scim.models import SCIMSource from authentik.sources.scim.models import SCIMSource
@ -47,12 +48,13 @@ class SCIMSourceSerializer(SourceSerializer):
] ]
class SCIMSourceViewSet(UsedByMixin, ModelViewSet): class SCIMSourceViewSet(MultipleFieldLookupMixin, UsedByMixin, ModelViewSet):
"""SCIMSource Viewset""" """SCIMSource Viewset"""
queryset = SCIMSource.objects.all() queryset = SCIMSource.objects.all()
serializer_class = SCIMSourceSerializer serializer_class = SCIMSourceSerializer
lookup_field = "slug" lookup_field = "slug"
lookup_fields = ["slug", "pbm_uuid"]
filterset_fields = ["name", "slug"] filterset_fields = ["name", "slug"]
search_fields = ["name", "slug", "token__identifier", "token__user__username"] search_fields = ["name", "slug", "token__identifier", "token__user__username"]
ordering = ["name"] ordering = ["name"]

View File

@ -88,55 +88,6 @@ class TestSCIMUsers(APITestCase):
).exists() ).exists()
) )
def test_user_create_duplicate_by_username(self):
"""Test user create"""
user = create_test_user()
username = generate_id()
obj1 = {
"userName": username,
"externalId": generate_id(),
"emails": [
{
"primary": True,
"value": user.email,
}
],
}
obj2 = obj1.copy()
obj2.update({"externalId": generate_id()})
response = self.client.post(
reverse(
"authentik_sources_scim:v2-users",
kwargs={
"source_slug": self.source.slug,
},
),
data=dumps(obj1),
content_type=SCIM_CONTENT_TYPE,
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
)
self.assertEqual(response.status_code, 201)
self.assertTrue(
SCIMSourceUser.objects.filter(source=self.source, user__username=username).exists()
)
self.assertTrue(
Event.objects.filter(
action=EventAction.MODEL_CREATED, user__username=self.source.token.user.username
).exists()
)
response = self.client.post(
reverse(
"authentik_sources_scim:v2-users",
kwargs={
"source_slug": self.source.slug,
},
),
data=dumps(obj2),
content_type=SCIM_CONTENT_TYPE,
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
)
self.assertEqual(response.status_code, 409)
def test_user_property_mappings(self): def test_user_property_mappings(self):
"""Test user property_mappings""" """Test user property_mappings"""
self.source.user_property_mappings.set( self.source.user_property_mappings.set(

View File

@ -2,7 +2,6 @@
from uuid import uuid4 from uuid import uuid4
from django.db.models import Q
from django.db.transaction import atomic from django.db.transaction import atomic
from django.http import Http404, QueryDict from django.http import Http404, QueryDict
from django.urls import reverse from django.urls import reverse
@ -114,11 +113,8 @@ class UsersView(SCIMObjectView):
def post(self, request: Request, **kwargs) -> Response: def post(self, request: Request, **kwargs) -> Response:
"""Create user handler""" """Create user handler"""
connection = SCIMSourceUser.objects.filter( connection = SCIMSourceUser.objects.filter(
Q(
Q(user__uuid=request.data.get("id"))
| Q(user__username=request.data.get("userName"))
),
source=self.source, source=self.source,
user__uuid=request.data.get("id"),
).first() ).first()
if connection: if connection:
self.logger.debug("Found existing user") self.logger.debug("Found existing user")

View File

@ -1,18 +1,20 @@
"""AuthenticatorDuoStage API Views""" """AuthenticatorDuoStage API Views"""
from django.http import Http404 from django.http import Http404
from django_filters.rest_framework.backends import DjangoFilterBackend
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiResponse, extend_schema, inline_serializer from drf_spectacular.utils import OpenApiResponse, extend_schema, inline_serializer
from guardian.shortcuts import get_objects_for_user 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.fields import CharField, ChoiceField, IntegerField from rest_framework.fields import CharField, ChoiceField, IntegerField
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.viewsets import GenericViewSet, ModelViewSet from rest_framework.viewsets import GenericViewSet, ModelViewSet
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.core.api.groups import GroupMemberSerializer from authentik.api.authorization import OwnerFilter, OwnerPermissions
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.api.utils import ModelSerializer
from authentik.flows.api.stages import StageSerializer from authentik.flows.api.stages import StageSerializer
@ -166,11 +168,9 @@ class AuthenticatorDuoStageViewSet(UsedByMixin, ModelViewSet):
class DuoDeviceSerializer(ModelSerializer): class DuoDeviceSerializer(ModelSerializer):
"""Serializer for Duo authenticator devices""" """Serializer for Duo authenticator devices"""
user = GroupMemberSerializer(read_only=True)
class Meta: class Meta:
model = DuoDevice model = DuoDevice
fields = ["pk", "name", "user"] fields = ["pk", "name"]
depth = 2 depth = 2
@ -189,7 +189,8 @@ class DuoDeviceViewSet(
search_fields = ["name"] search_fields = ["name"]
filterset_fields = ["name"] filterset_fields = ["name"]
ordering = ["name"] ordering = ["name"]
owner_field = "user" permission_classes = [OwnerPermissions]
filter_backends = [OwnerFilter, DjangoFilterBackend, OrderingFilter, SearchFilter]
class DuoAdminDeviceViewSet(ModelViewSet): class DuoAdminDeviceViewSet(ModelViewSet):

View File

@ -1,9 +1,11 @@
"""AuthenticatorSMSStage API Views""" """AuthenticatorSMSStage API Views"""
from django_filters.rest_framework.backends import DjangoFilterBackend
from rest_framework import mixins from rest_framework import mixins
from rest_framework.filters import OrderingFilter, SearchFilter
from rest_framework.viewsets import GenericViewSet, ModelViewSet from rest_framework.viewsets import GenericViewSet, ModelViewSet
from authentik.core.api.groups import GroupMemberSerializer from authentik.api.authorization import OwnerFilter, OwnerPermissions
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.api.utils import ModelSerializer
from authentik.flows.api.stages import StageSerializer from authentik.flows.api.stages import StageSerializer
@ -42,11 +44,9 @@ class AuthenticatorSMSStageViewSet(UsedByMixin, ModelViewSet):
class SMSDeviceSerializer(ModelSerializer): class SMSDeviceSerializer(ModelSerializer):
"""Serializer for sms authenticator devices""" """Serializer for sms authenticator devices"""
user = GroupMemberSerializer(read_only=True)
class Meta: class Meta:
model = SMSDevice model = SMSDevice
fields = ["name", "pk", "phone_number", "user"] fields = ["name", "pk", "phone_number"]
depth = 2 depth = 2
extra_kwargs = { extra_kwargs = {
"phone_number": {"read_only": True}, "phone_number": {"read_only": True},
@ -65,10 +65,11 @@ class SMSDeviceViewSet(
queryset = SMSDevice.objects.all() queryset = SMSDevice.objects.all()
serializer_class = SMSDeviceSerializer serializer_class = SMSDeviceSerializer
permission_classes = [OwnerPermissions]
filter_backends = [OwnerFilter, DjangoFilterBackend, OrderingFilter, SearchFilter]
search_fields = ["name"] search_fields = ["name"]
filterset_fields = ["name"] filterset_fields = ["name"]
ordering = ["name"] ordering = ["name"]
owner_field = "user"
class SMSAdminDeviceViewSet(ModelViewSet): class SMSAdminDeviceViewSet(ModelViewSet):

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