Compare commits

..

49 Commits

Author SHA1 Message Date
Jens Langhammer 1cd000dfe2
release: 2023.10.6 2024-01-09 18:50:48 +01:00
gcp-cherry-pick-bot[bot] 00ae97944a
providers/oauth2: fix CVE-2024-21637 (cherry-pick #8104) (#8105)
* providers/oauth2: fix CVE-2024-21637 (#8104)

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

* update changelog

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

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L <jens@goauthentik.io>
2024-01-09 18:32:03 +01:00
gcp-cherry-pick-bot[bot] 9f3ccfb7c7
web/flows: fix device picker incorrect foreground color (cherry-pick #8067) (#8069)
web/flows: fix device picker incorrect foreground color (#8067)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L <jens@goauthentik.io>
2024-01-05 15:31:08 +01:00
gcp-cherry-pick-bot[bot] 9ed9c39ac8
rbac: fix error when looking up permissions for now uninstalled apps (cherry-pick #8068) (#8070)
rbac: fix error when looking up permissions for now uninstalled apps (#8068)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L <jens@goauthentik.io>
2024-01-05 15:30:59 +01:00
gcp-cherry-pick-bot[bot] 30b6eeee9f
outposts: disable deployment and secret reconciler for embedded outpost in code instead of in config (cherry-pick #8021) (#8024)
outposts: disable deployment and secret reconciler for embedded outpost in code instead of in config (#8021)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L <jens@goauthentik.io>
2023-12-30 21:40:54 +01:00
gcp-cherry-pick-bot[bot] afe2621783
providers/proxy: use access token (cherry-pick #8022) (#8023)
providers/proxy: use access token (#8022)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L <jens@goauthentik.io>
2023-12-30 18:30:42 +01:00
gcp-cherry-pick-bot[bot] 8b12c6a01a
outposts: fix Outpost reconcile not re-assigning managed attribute (cherry-pick #8014) (#8020)
outposts: fix Outpost reconcile not re-assigning managed attribute (#8014)

* outposts: fix Outpost reconcile not re-assigning managed attribute



* rework reconcile to find both name and managed outpost



---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L <jens@goauthentik.io>
2023-12-30 15:37:36 +01:00
Jens Langhammer f63adfed96
core: fix PropertyMapping context not being available in request context
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2023-12-23 03:05:29 +01:00
gcp-cherry-pick-bot[bot] 9c8fec21cf
providers/oauth2: remember session_id from initial token (cherry-pick #7976) (#7977)
providers/oauth2: remember session_id from initial token (#7976)

* providers/oauth2: remember session_id original token was created with for future access/refresh tokens



* providers/proxy: use hashed session as `sid`



---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L <jens@goauthentik.io>
2023-12-23 01:20:48 +01:00
Jens L 4776d2bcc5
sources/oauth: fix missing get_user_id for OIDC-like sources (Azure AD) (#7970)
* lib: add debug requests session that shows all sent requests

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

* sources/oauth: fix missing get_user_id for OIDC-like OAuth Sources

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

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
# Conflicts:
#	authentik/lib/utils/http.py
2023-12-22 00:13:42 +01:00
Jens Langhammer a15a040362
release: 2023.10.5 2023-12-21 14:18:36 +01:00
Jens L fcd6dc1d60
events: fix lint (#7700)
* events: fix lint

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

* test without explicit poetry env use?

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

* delete previous poetry env

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

* prevent invalid cached poetry envs

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

* run test-from-stable as matrix and make required

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

* fix missing postgres version

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

* sigh

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

* idk

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

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
# Conflicts:
#	.github/actions/setup/action.yml
#	.github/workflows/ci-main.yml
2023-12-19 18:40:14 +01:00
gcp-cherry-pick-bot[bot] acc3b59869
events: add better fallback for sanitize_item to ensure everything can be saved as JSON (cherry-pick #7694) (#7937)
events: add better fallback for sanitize_item to ensure everything can be saved as JSON (#7694)

* events: fix events sanitizing not handling all types



* remove some leftover prints



---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L <jens@goauthentik.io>
2023-12-19 18:31:20 +01:00
gcp-cherry-pick-bot[bot] d9d5ac10e6
events: include user agent in events (cherry-pick #7693) (#7938)
events: include user agent in events (#7693)

* events: include user agent in events



* fix tests



---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L <jens@goauthentik.io>
2023-12-19 18:31:06 +01:00
gcp-cherry-pick-bot[bot] 750669dcab
stages/email: improve error handling for incorrect template syntax (cherry-pick #7758) (#7936)
stages/email: improve error handling for incorrect template syntax (#7758)

* stages/email: improve error handling for incorrect template syntax



* add tests



---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L <jens@goauthentik.io>
2023-12-19 18:30:56 +01:00
gcp-cherry-pick-bot[bot] 88a3eed67e
root: don't show warning when app has no URLs to import (cherry-pick #7765) (#7935)
root: don't show warning when app has no URLs to import (#7765)

Co-authored-by: Jens L <jens@goauthentik.io>
2023-12-19 18:30:49 +01:00
gcp-cherry-pick-bot[bot] 6c214fffc4
blueprints: improve file change handler (cherry-pick #7813) (#7934)
blueprints: improve file change handler (#7813)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L <jens@goauthentik.io>
2023-12-19 18:30:37 +01:00
gcp-cherry-pick-bot[bot] 70100fc105
web/user: fix search not updating app (cherry-pick #7825) (#7933)
web/user: fix search not updating app (#7825)

web/user: fix app not updating

so when using two classes in a classMap directive, the update fails (basically saying that each class must be separated), however this error only shows when directly calling requestUpdate and is swallowed somewhere when relying on the default render cycle

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L <jens@goauthentik.io>
2023-12-19 18:30:23 +01:00
gcp-cherry-pick-bot[bot] 3c1163fabd
root: Fix cache related image build issues (cherry-pick #7831) (#7932)
Fix cache related image build issues

Co-authored-by: Philipp Kolberg <philipp.kolberg@t-online.de>
2023-12-19 18:30:15 +01:00
gcp-cherry-pick-bot[bot] 539e8242ff
web: fix overflow glitch on ak-page-header (cherry-pick #7883) (#7931)
web: fix overflow glitch on ak-page-header (#7883)

By adding 'grow' but not 'shrink' to the header section, the page was allowed to allocate
as much width as was available when the window opened, but not allowed to resize the width
if it was pushed closed by zoom, page resize, or summon sidebar.

This commit adds 'shrink' to the capabilities of the header.

Co-authored-by: Ken Sternberg <133134217+kensternberg-authentik@users.noreply.github.com>
2023-12-19 18:30:04 +01:00
gcp-cherry-pick-bot[bot] 2648333590
providers/scim: change familyName default (cherry-pick #7904) (#7930)
providers/scim: change familyName default (#7904)

* Update providers-scim.yaml



* fix: add formatted to match the givenName & familyName



* fix, update tests



---------

Signed-off-by: Antoine <antoine+github@jiveoff.fr>
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L <jens@goauthentik.io>
Co-authored-by: Antoine <antoine+github@jiveoff.fr>
2023-12-19 18:29:55 +01:00
gcp-cherry-pick-bot[bot] fe828ef993
tests: fix flaky tests (cherry-pick #7676) (#7939)
tests: fix flaky tests (#7676)

* tests: fix flaky tests



* make test-from-stable use actual latest version



* fix checkout



* remove hardcoded seed



* ignore tests for now i guess idk



---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L <jens@goauthentik.io>
2023-12-19 18:29:44 +01:00
Jens L 29a6530742
web: dark/light theme fixes (#7872)
* web: fix css for user tree-view

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

* fix unrelated things

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

* fix header button colors

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

* fix missing fallback not showing default slant

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

* move global theme-dark css to only use for SSR rendered pages

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

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
# Conflicts:
#	.github/workflows/ci-main.yml
#	web/xliff/fr.xlf
2023-12-19 18:18:19 +01:00
Jens L a6b9274c4f
web/admin: always show oidc well-known URL fields when they're set (#7560)
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
# Conflicts:
#	web/xliff/de.xlf
#	web/xliff/en.xlf
#	web/xliff/es.xlf
#	web/xliff/fr.xlf
#	web/xliff/pl.xlf
#	web/xliff/pseudo-LOCALE.xlf
#	web/xliff/tr.xlf
#	web/xliff/zh-Hans.xlf
#	web/xliff/zh-Hant.xlf
#	web/xliff/zh_TW.xlf
2023-12-19 18:10:40 +01:00
Jens Langhammer a2a67161ac
release: 2023.10.4 2023-11-21 18:38:24 +01:00
Jens Langhammer 2e8263a99b
web: fix locale
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2023-11-21 18:20:41 +01:00
gcp-cherry-pick-bot[bot] 6b9afed21f
security: fix CVE-2023-48228 (cherry-pick #7666) (#7668)
security: fix CVE-2023-48228 (#7666)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L <jens@goauthentik.io>
2023-11-21 18:13:54 +01:00
Jens L 1eb1f4e0b8
web/admin: fix admins not able to delete MFA devices (#7660)
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
# Conflicts:
#	web/xliff/zh-Hans.xlf
2023-11-21 15:24:37 +01:00
gcp-cherry-pick-bot[bot] 7c3d60ec3a
events: don't update internal service accounts unless needed (cherry-pick #7611) (#7640)
events: stop spam (#7611)

* events: don't log updates to internal service accounts



* dont log reputation updates



* don't actually ignore things, stop updating outpost user when not required



* prevent updating internal service account users



* fix setattr call



---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L <jens@goauthentik.io>
2023-11-20 19:43:30 +01:00
Jens L a494c6b6e8
root: specify node and python versions in respective config files, deduplicate in CI (#7620)
* root: specify node and python versions in respective config files, deduplicate in CI

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

* fix engines missing for wdio

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

* bump setup python version

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

* actually don't bump a bunch of things

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

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
# Conflicts:
#	poetry.lock
#	website/package.json
2023-11-19 00:35:55 +01:00
gcp-cherry-pick-bot[bot] 6604d3577f
core: bump golang from 1.21.3-bookworm to 1.21.4-bookworm (cherry-pick #7483) (#7622)
core: bump golang from 1.21.3-bookworm to 1.21.4-bookworm

Bumps golang from 1.21.3-bookworm to 1.21.4-bookworm.

---
updated-dependencies:
- dependency-name: golang
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-11-19 00:33:07 +01:00
gcp-cherry-pick-bot[bot] f8bfa7e16a
ci: fix permissions for release pipeline to publish binaries (cherry-pick #7512) (#7621)
ci: fix permissions for release pipeline to publish binaries (#7512)

ci: fix permissions

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L <jens@goauthentik.io>
2023-11-19 00:31:20 +01:00
gcp-cherry-pick-bot[bot] ea6cf6eabf
events: fix missing model_* events when not directly authenticated (cherry-pick #7588) (#7597)
events: fix missing model_* events when not directly authenticated (#7588)

* events: fix missing model_* events when not directly authenticated



* defer accessing database



---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L <jens@goauthentik.io>
2023-11-16 12:59:41 +01:00
gcp-cherry-pick-bot[bot] 769ce3ce7b
providers/scim: fix missing schemas attribute for User and Group (cherry-pick #7477) (#7596)
providers/scim: fix missing schemas attribute for User and Group (#7477)

* providers/scim: fix missing schemas attribute for User and Group



* make things actually work



---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L <jens@goauthentik.io>
2023-11-16 12:06:01 +01:00
gcp-cherry-pick-bot[bot] 3891fb3fa8
events: sanitize functions (cherry-pick #7587) (#7589)
events: sanitize functions (#7587)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L <jens@goauthentik.io>
2023-11-15 23:24:13 +01:00
gcp-cherry-pick-bot[bot] 41eb965350
stages/email: use uuid for email confirmation token instead of username (cherry-pick #7581) (#7584)
stages/email: use uuid for email confirmation token instead of username (#7581)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L <jens@goauthentik.io>
2023-11-15 21:57:05 +01:00
gcp-cherry-pick-bot[bot] 8d95612287
providers/proxy: Fix duplicate cookies when using file system store. (cherry-pick #7541) (#7544)
providers/proxy: Fix duplicate cookies when using file system store. (#7541)

Fix duplicate cookies when using file system store.

Co-authored-by: thijs_a <thijs@thijsalders.nl>
2023-11-13 16:02:35 +01:00
Jens Langhammer 82b5274b15
release: 2023.10.3 2023-11-09 18:37:22 +01:00
gcp-cherry-pick-bot[bot] af56ce3d78
core: fix worker beat toggle inverted (cherry-pick #7508) (#7509)
core: fix worker beat toggle inverted (#7508)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L <jens@goauthentik.io>
2023-11-09 18:36:56 +01:00
gcp-cherry-pick-bot[bot] f5c6e7aeb0
Web: bugfix: broken backchannel selector (cherry-pick #7480) (#7507)
Web: bugfix: broken backchannel selector (#7480)

* web: break circular dependency between AKElement & Interface.

This commit changes the way the root node of the web application shell is
discovered by child components, such that the base class shared by both
no longer results in a circular dependency between the two models.

I've run this in isolation and have seen no failures of discovery; the identity
token exists as soon as the Interface is constructed and is found by every item
on the page.

* web: fix broken typescript references

This built... and then it didn't?  Anyway, the current fix is to
provide type information the AkInterface for the data that consumers
require.

* web: rollback dependabot's upgrade of context

The most frustrating part of this is that I RAN THIS, dammit, with the updated
context and the current Wizard, and it finished the End-to-End tests without
complaint.

* web: bugfix: broken backchannel selector

There were two bugs here, both of them introduced by me because I didn't understand the
system well enough the first time through, and because I didn't test thoroughly enough.

The first is that I was calling the wrong confirmation code; the resulting syntax survived
because `confirm()` is actually a legitimate function call in the context of the DOM Window,
a legacy survivor similar to `alert()` but with a yes/no return value. Bleah.

The second is that the confirm code doesn't appear to pass back a dictionary with the
`{ items: Array<Provider> }` list, it passes back just the `items` as an Array.

Co-authored-by: Ken Sternberg <133134217+kensternberg-authentik@users.noreply.github.com>
2023-11-09 17:58:38 +01:00
gcp-cherry-pick-bot[bot] 3809400e93
events: fix gdpr compliance always running (cherry-pick #7491) (#7505)
events: fix gdpr compliance always running

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2023-11-09 17:57:25 +01:00
gcp-cherry-pick-bot[bot] 1def9865cf
web/flows: attempt to fix bitwareden android compatibility (cherry-pick #7455) (#7457)
web/flows: attempt to fix bitwareden android compatibility (#7455)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L <jens@goauthentik.io>
2023-11-06 23:58:44 +01:00
gcp-cherry-pick-bot[bot] 3716298639
sources/oauth: fix patreon (cherry-pick #7454) (#7456)
sources/oauth: fix patreon (#7454)

* web/admin: add note for potentially confusing consumer key/secret



* sources/oauth: fix patreon default scopes



---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L <jens@goauthentik.io>
2023-11-06 16:36:22 +01:00
gcp-cherry-pick-bot[bot] c16317d7cf
providers/proxy: fix closed redis client (cherry-pick #7385) (#7429)
providers/proxy: fix closed redis client (#7385)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L <jens@goauthentik.io>
2023-11-03 15:46:17 +01:00
gcp-cherry-pick-bot[bot] bbb8fa8269
ci: explicitly give write permissions to packages (cherry-pick #7428) (#7430)
ci: explicitly give write permissions to packages (#7428)

* ci: explicitly give write permissions to packages



* run full CI on cherry-picks



---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L <jens@goauthentik.io>
2023-11-03 15:46:00 +01:00
gcp-cherry-pick-bot[bot] e4c251a178
web/admin: fix html error on oauth2 provider page (cherry-pick #7384) (#7424)
web/admin: fix html error on oauth2 provider page (#7384)

* web: break circular dependency between AKElement & Interface.

This commit changes the way the root node of the web application shell is
discovered by child components, such that the base class shared by both
no longer results in a circular dependency between the two models.

I've run this in isolation and have seen no failures of discovery; the identity
token exists as soon as the Interface is constructed and is found by every item
on the page.

* web: fix broken typescript references

This built... and then it didn't?  Anyway, the current fix is to
provide type information the AkInterface for the data that consumers
require.

* \# Details

Extra `>` symbol screwed up the reading of the rest of the component.  Unfortunately,
too many fields in an input are optional, so it was easy for this bug to bypass any
checks by the validators.  I should have caught it myself, though.

Co-authored-by: Ken Sternberg <133134217+kensternberg-authentik@users.noreply.github.com>
2023-11-03 13:17:26 +01:00
gcp-cherry-pick-bot[bot] 0fefd5f522
stages/email: fix duplicate querystring encoding (cherry-pick #7386) (#7425)
stages/email: fix duplicate querystring encoding (#7386)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L <jens@goauthentik.io>
2023-11-03 13:17:18 +01:00
gcp-cherry-pick-bot[bot] 88057db0b0
providers/oauth2: set auth_via for token and other endpoints (cherry-pick #7417) (#7427)
providers/oauth2: set auth_via for token and other endpoints (#7417)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L <jens@goauthentik.io>
2023-11-03 13:17:10 +01:00
gcp-cherry-pick-bot[bot] 91cb6c9beb
root: Improve multi arch Docker image build speed (cherry-pick #7355) (#7426)
root: Improve multi arch Docker image build speed (#7355)

* Improve multi arch Docker image build speed

Use only host architecture for GeoIP database update and for Go cross-compilation

* Speedup Go multi-arch compilation for other images

* Speedup multi-arch ldap image build

Co-authored-by: Philipp Kolberg <39984529+PKizzle@users.noreply.github.com>
2023-11-03 13:16:54 +01:00
725 changed files with 32680 additions and 58000 deletions

View File

@ -9,4 +9,3 @@ blueprints/local
.git .git
!gen-ts-api/node_modules !gen-ts-api/node_modules
!gen-ts-api/dist/** !gen-ts-api/dist/**
!gen-go-api/

View File

@ -4,7 +4,7 @@ description: "Setup authentik testing environment"
inputs: inputs:
postgresql_version: postgresql_version:
description: "Optional postgresql image tag" description: "Optional postgresql image tag"
default: "16" default: "12"
runs: runs:
using: "composite" using: "composite"
@ -18,7 +18,7 @@ runs:
- name: Setup python and restore poetry - name: Setup python and restore poetry
uses: actions/setup-python@v4 uses: actions/setup-python@v4
with: with:
python-version-file: "pyproject.toml" python-version-file: 'pyproject.toml'
cache: "poetry" cache: "poetry"
- name: Setup node - name: Setup node
uses: actions/setup-node@v3 uses: actions/setup-node@v3

View File

@ -2,7 +2,7 @@ version: "3.7"
services: services:
postgresql: postgresql:
image: docker.io/library/postgres:${PSQL_TAG:-16} image: docker.io/library/postgres:${PSQL_TAG:-12}
volumes: volumes:
- db-data:/var/lib/postgresql/data - db-data:/var/lib/postgresql/data
environment: environment:

View File

@ -2,4 +2,3 @@ keypair
keypairs keypairs
hass hass
warmup warmup
ontext

View File

@ -35,7 +35,6 @@ updates:
sentry: sentry:
patterns: patterns:
- "@sentry/*" - "@sentry/*"
- "@spotlightjs/*"
babel: babel:
patterns: patterns:
- "@babel/*" - "@babel/*"
@ -67,7 +66,6 @@ updates:
sentry: sentry:
patterns: patterns:
- "@sentry/*" - "@sentry/*"
- "@spotlightjs/*"
babel: babel:
patterns: patterns:
- "@babel/*" - "@babel/*"

View File

@ -61,6 +61,10 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Setup authentik env
uses: ./.github/actions/setup
with:
postgresql_version: ${{ matrix.psql }}
- name: checkout stable - name: checkout stable
run: | run: |
# Delete all poetry envs # Delete all poetry envs
@ -72,7 +76,7 @@ jobs:
git checkout version/$(python -c "from authentik import __version__; print(__version__)") git checkout version/$(python -c "from authentik import __version__; print(__version__)")
rm -rf .github/ scripts/ rm -rf .github/ scripts/
mv ../.github ../scripts . mv ../.github ../scripts .
- name: Setup authentik env (stable) - name: Setup authentik env (ensure stable deps are installed)
uses: ./.github/actions/setup uses: ./.github/actions/setup
with: with:
postgresql_version: ${{ matrix.psql }} postgresql_version: ${{ matrix.psql }}
@ -86,20 +90,14 @@ jobs:
git clean -d -fx . git clean -d -fx .
git checkout $GITHUB_SHA git checkout $GITHUB_SHA
# Delete previous poetry env # Delete previous poetry env
rm -rf /home/runner/.cache/pypoetry/virtualenvs/* rm -rf $(poetry env info --path)
poetry install
- name: Setup authentik env (ensure latest deps are installed) - name: Setup authentik env (ensure latest deps are installed)
uses: ./.github/actions/setup uses: ./.github/actions/setup
with: with:
postgresql_version: ${{ matrix.psql }} postgresql_version: ${{ matrix.psql }}
- name: migrate to latest - name: migrate to latest
run: | run: poetry run python -m lifecycle.migrate
poetry run python -m lifecycle.migrate
- name: run tests
env:
# Test in the main database that we just migrated from the previous stable version
AUTHENTIK_POSTGRESQL__TEST__NAME: authentik
run: |
poetry run make test
test-unittest: test-unittest:
name: test-unittest - PostgreSQL ${{ matrix.psql }} name: test-unittest - PostgreSQL ${{ matrix.psql }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -249,6 +247,12 @@ jobs:
VERSION_FAMILY=${{ steps.ev.outputs.versionFamily }} VERSION_FAMILY=${{ steps.ev.outputs.versionFamily }}
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
- name: Comment on PR
if: github.event_name == 'pull_request'
continue-on-error: true
uses: ./.github/actions/comment-pr-instructions
with:
tag: gh-${{ steps.ev.outputs.branchNameContainer }}-${{ steps.ev.outputs.timestamp }}-${{ steps.ev.outputs.shortHash }}
build-arm64: build-arm64:
needs: ci-core-mark needs: ci-core-mark
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -297,26 +301,3 @@ jobs:
platforms: linux/arm64 platforms: linux/arm64
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
pr-comment:
needs:
- build
- build-arm64
runs-on: ubuntu-latest
if: ${{ github.event_name == 'pull_request' }}
permissions:
# Needed to write comments on PRs
pull-requests: write
timeout-minutes: 120
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: prepare variables
uses: ./.github/actions/docker-push-variables
id: ev
env:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
- name: Comment on PR
uses: ./.github/actions/comment-pr-instructions
with:
tag: gh-${{ steps.ev.outputs.branchNameContainer }}-${{ steps.ev.outputs.timestamp }}-${{ steps.ev.outputs.shortHash }}

View File

@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-go@v5 - uses: actions/setup-go@v4
with: with:
go-version-file: "go.mod" go-version-file: "go.mod"
- name: Prepare and generate API - name: Prepare and generate API
@ -37,7 +37,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-go@v5 - uses: actions/setup-go@v4
with: with:
go-version-file: "go.mod" go-version-file: "go.mod"
- name: Setup authentik env - name: Setup authentik env
@ -65,7 +65,6 @@ jobs:
- proxy - proxy
- ldap - ldap
- radius - radius
- rac
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
# Needed to upload contianer images to ghcr.io # Needed to upload contianer images to ghcr.io
@ -120,14 +119,13 @@ jobs:
- proxy - proxy
- ldap - ldap
- radius - radius
- rac
goos: [linux] goos: [linux]
goarch: [amd64, arm64] goarch: [amd64, arm64]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
ref: ${{ github.event.pull_request.head.sha }} ref: ${{ github.event.pull_request.head.sha }}
- uses: actions/setup-go@v5 - uses: actions/setup-go@v4
with: with:
go-version-file: "go.mod" go-version-file: "go.mod"
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4

View File

@ -27,10 +27,10 @@ jobs:
- name: Setup authentik env - name: Setup authentik env
uses: ./.github/actions/setup uses: ./.github/actions/setup
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@v3 uses: github/codeql-action/init@v2
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
- name: Autobuild - name: Autobuild
uses: github/codeql-action/autobuild@v3 uses: github/codeql-action/autobuild@v2
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3 uses: github/codeql-action/analyze@v2

View File

@ -6,10 +6,6 @@ on:
types: types:
- closed - closed
permissions:
# Permission to delete cache
actions: write
jobs: jobs:
cleanup: cleanup:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@ -65,10 +65,9 @@ jobs:
- proxy - proxy
- ldap - ldap
- radius - radius
- rac
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-go@v5 - uses: actions/setup-go@v4
with: with:
go-version-file: "go.mod" go-version-file: "go.mod"
- name: Set up QEMU - name: Set up QEMU
@ -127,7 +126,7 @@ jobs:
goarch: [amd64, arm64] goarch: [amd64, arm64]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-go@v5 - uses: actions/setup-go@v4
with: with:
go-version-file: "go.mod" go-version-file: "go.mod"
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4

View File

@ -30,7 +30,7 @@ jobs:
private_key: ${{ secrets.GH_APP_PRIVATE_KEY }} private_key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Extract version number - name: Extract version number
id: get_version id: get_version
uses: actions/github-script@v7 uses: actions/github-script@v6
with: with:
github-token: ${{ steps.generate_token.outputs.token }} github-token: ${{ steps.generate_token.outputs.token }}
script: | script: |

View File

@ -18,7 +18,7 @@ jobs:
with: with:
app_id: ${{ secrets.GH_APP_ID }} app_id: ${{ secrets.GH_APP_ID }}
private_key: ${{ secrets.GH_APP_PRIVATE_KEY }} private_key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- uses: actions/stale@v9 - uses: actions/stale@v8
with: with:
repo-token: ${{ steps.generate_token.outputs.token }} repo-token: ${{ steps.generate_token.outputs.token }}
days-before-stale: 60 days-before-stale: 60

View File

@ -7,12 +7,7 @@ on:
paths: paths:
- "!**" - "!**"
- "locale/**" - "locale/**"
- "!locale/en/**" - "web/src/locales/**"
- "web/xliff/**"
permissions:
# Permission to write comment
pull-requests: write
jobs: jobs:
post-comment: post-comment:

View File

@ -6,10 +6,6 @@ on:
pull_request: pull_request:
types: [opened, reopened] types: [opened, reopened]
permissions:
# Permission to rename PR
pull-requests: write
jobs: jobs:
rename_pr: rename_pr:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@ -14,7 +14,6 @@
"ms-python.pylint", "ms-python.pylint",
"ms-python.python", "ms-python.python",
"ms-python.vscode-pylance", "ms-python.vscode-pylance",
"ms-python.black-formatter",
"redhat.vscode-yaml", "redhat.vscode-yaml",
"Tobermory.es6-string-html", "Tobermory.es6-string-html",
"unifiedjs.vscode-mdx", "unifiedjs.vscode-mdx",

View File

@ -19,8 +19,10 @@
"slo", "slo",
"scim", "scim",
], ],
"python.linting.pylintEnabled": true,
"todo-tree.tree.showCountsInTree": true, "todo-tree.tree.showCountsInTree": true,
"todo-tree.tree.showBadges": true, "todo-tree.tree.showBadges": true,
"python.formatting.provider": "black",
"yaml.customTags": [ "yaml.customTags": [
"!Find sequence", "!Find sequence",
"!KeyOf scalar", "!KeyOf scalar",

View File

@ -37,7 +37,7 @@ COPY ./gen-ts-api /work/web/node_modules/@goauthentik/api
RUN npm run build RUN npm run build
# Stage 3: Build go proxy # Stage 3: Build go proxy
FROM --platform=${BUILDPLATFORM} docker.io/golang:1.21.6-bookworm AS go-builder FROM --platform=${BUILDPLATFORM} docker.io/golang:1.21.4-bookworm AS go-builder
ARG TARGETOS ARG TARGETOS
ARG TARGETARCH ARG TARGETARCH
@ -69,9 +69,9 @@ RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \
GOARM="${TARGETVARIANT#v}" go build -o /go/authentik ./cmd/server GOARM="${TARGETVARIANT#v}" go build -o /go/authentik ./cmd/server
# Stage 4: MaxMind GeoIP # Stage 4: MaxMind GeoIP
FROM --platform=${BUILDPLATFORM} ghcr.io/maxmind/geoipupdate:v6.1 as geoip FROM --platform=${BUILDPLATFORM} ghcr.io/maxmind/geoipupdate:v6.0 as geoip
ENV GEOIPUPDATE_EDITION_IDS="GeoLite2-City GeoLite2-ASN" ENV GEOIPUPDATE_EDITION_IDS="GeoLite2-City"
ENV GEOIPUPDATE_VERBOSE="true" ENV GEOIPUPDATE_VERBOSE="true"
ENV GEOIPUPDATE_ACCOUNT_ID_FILE="/run/secrets/GEOIPUPDATE_ACCOUNT_ID" ENV GEOIPUPDATE_ACCOUNT_ID_FILE="/run/secrets/GEOIPUPDATE_ACCOUNT_ID"
ENV GEOIPUPDATE_LICENSE_KEY_FILE="/run/secrets/GEOIPUPDATE_LICENSE_KEY" ENV GEOIPUPDATE_LICENSE_KEY_FILE="/run/secrets/GEOIPUPDATE_LICENSE_KEY"
@ -83,7 +83,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 docker.io/python:3.12.1-slim-bookworm AS python-deps FROM docker.io/python:3.11.5-bookworm AS python-deps
WORKDIR /ak-root/poetry WORKDIR /ak-root/poetry
@ -108,7 +108,7 @@ RUN --mount=type=bind,target=./pyproject.toml,src=./pyproject.toml \
poetry install --only=main --no-ansi --no-interaction poetry install --only=main --no-ansi --no-interaction
# Stage 6: Run # Stage 6: Run
FROM docker.io/python:3.12.1-slim-bookworm AS final-image FROM docker.io/python:3.11.5-slim-bookworm AS final-image
ARG GIT_BUILD_HASH ARG GIT_BUILD_HASH
ARG VERSION ARG VERSION
@ -125,7 +125,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 openssl libxmlsec1-openssl libmaxminddb0 ca-certificates && \ apt-get install -y --no-install-recommends libpq5 openssl libxmlsec1-openssl libmaxminddb0 && \
# 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 && \

View File

@ -58,7 +58,7 @@ test: ## Run the server tests and produce a coverage report (locally)
lint-fix: ## Lint and automatically fix errors in the python source code. Reports spelling errors. lint-fix: ## Lint and automatically fix errors in the python source code. Reports spelling errors.
isort $(PY_SOURCES) isort $(PY_SOURCES)
black $(PY_SOURCES) black $(PY_SOURCES)
ruff --fix $(PY_SOURCES) ruff $(PY_SOURCES)
codespell -w $(CODESPELL_ARGS) codespell -w $(CODESPELL_ARGS)
lint: ## Lint the python and golang sources lint: ## Lint the python and golang sources
@ -94,14 +94,8 @@ dev-reset: dev-drop-db dev-create-db migrate ## Drop and restore the Authentik
######################### #########################
gen-build: ## Extract the schema from the database gen-build: ## Extract the schema from the database
AUTHENTIK_DEBUG=true \ AUTHENTIK_DEBUG=true ak make_blueprint_schema > blueprints/schema.json
AUTHENTIK_TENANTS__ENABLED=true \ AUTHENTIK_DEBUG=true ak spectacular --file schema.yml
AUTHENTIK_OUTPOSTS__DISABLE_EMBEDDED_OUTPOST=true \
ak make_blueprint_schema > blueprints/schema.json
AUTHENTIK_DEBUG=true \
AUTHENTIK_TENANTS__ENABLED=true \
AUTHENTIK_OUTPOSTS__DISABLE_EMBEDDED_OUTPOST=true \
ak spectacular --file schema.yml
gen-changelog: ## (Release) generate the changelog based from the commits since the last tag gen-changelog: ## (Release) generate the changelog based from the commits since the last tag
git log --pretty=format:" - %s" $(shell git describe --tags $(shell git rev-list --tags --max-count=1))...$(shell git branch --show-current) | sort > changelog.md git log --pretty=format:" - %s" $(shell git describe --tags $(shell git rev-list --tags --max-count=1))...$(shell git branch --show-current) | sort > changelog.md
@ -116,20 +110,13 @@ gen-diff: ## (Release) generate the changelog diff between the current schema a
--markdown /local/diff.md \ --markdown /local/diff.md \
/local/old_schema.yml /local/schema.yml /local/old_schema.yml /local/schema.yml
rm old_schema.yml rm old_schema.yml
sed -i 's/{/&#123;/g' diff.md
sed -i 's/}/&#125;/g' diff.md
npx prettier --write diff.md npx prettier --write diff.md
gen-clean-ts: ## Remove generated API client for Typescript gen-clean:
rm -rf gen-ts-api/ rm -rf web/api/src/
rm -rf web/node_modules/@goauthentik/api/ rm -rf api/
gen-clean-go: ## Remove generated API client for Go gen-client-ts: ## Build and install the authentik API for Typescript into the authentik UI Application
rm -rf gen-go-api/
gen-clean: gen-clean-ts gen-clean-go ## Remove generated API clients
gen-client-ts: gen-clean-ts ## Build and install the authentik API for Typescript into the authentik UI Application
docker run \ docker run \
--rm -v ${PWD}:/local \ --rm -v ${PWD}:/local \
--user ${UID}:${GID} \ --user ${UID}:${GID} \
@ -145,7 +132,7 @@ gen-client-ts: gen-clean-ts ## Build and install the authentik API for Typescri
cd gen-ts-api && npm i cd gen-ts-api && npm i
\cp -rfv gen-ts-api/* web/node_modules/@goauthentik/api \cp -rfv gen-ts-api/* web/node_modules/@goauthentik/api
gen-client-go: gen-clean-go ## Build and install the authentik API for Golang gen-client-go: ## Build and install the authentik API for Golang
mkdir -p ./gen-go-api ./gen-go-api/templates mkdir -p ./gen-go-api ./gen-go-api/templates
wget https://raw.githubusercontent.com/goauthentik/client-go/main/config.yaml -O ./gen-go-api/config.yaml wget https://raw.githubusercontent.com/goauthentik/client-go/main/config.yaml -O ./gen-go-api/config.yaml
wget https://raw.githubusercontent.com/goauthentik/client-go/main/templates/README.mustache -O ./gen-go-api/templates/README.mustache wget https://raw.githubusercontent.com/goauthentik/client-go/main/templates/README.mustache -O ./gen-go-api/templates/README.mustache
@ -165,7 +152,7 @@ gen-client-go: gen-clean-go ## Build and install the authentik API for Golang
gen-dev-config: ## Generate a local development config file gen-dev-config: ## Generate a local development config file
python -m scripts.generate_config python -m scripts.generate_config
gen: gen-build gen-client-ts gen: gen-build gen-clean gen-client-ts
######################### #########################
## Web ## Web

View File

@ -1,9 +1,5 @@
authentik takes security very seriously. We follow the rules of [responsible disclosure](https://en.wikipedia.org/wiki/Responsible_disclosure), and we urge our community to do so as well, instead of reporting vulnerabilities publicly. This allows us to patch the issue quickly, announce it's existence and release the fixed version. authentik takes security very seriously. We follow the rules of [responsible disclosure](https://en.wikipedia.org/wiki/Responsible_disclosure), and we urge our community to do so as well, instead of reporting vulnerabilities publicly. This allows us to patch the issue quickly, announce it's existence and release the fixed version.
## Independent audits and pentests
In May/June of 2023 [Cure53](https://cure53.de) conducted an audit and pentest. The [results](https://cure53.de/pentest-report_authentik.pdf) are published on the [Cure53 website](https://cure53.de/#publications-2023). For more details about authentik's response to the findings of the audit refer to [2023-06 Cure53 Code audit](https://goauthentik.io/docs/security/2023-06-cure53).
## What authentik classifies as a CVE ## What authentik classifies as a CVE
CVE (Common Vulnerability and Exposure) is a system designed to aggregate all vulnerabilities. As such, a CVE will be issued when there is a either vulnerability or exposure. Per NIST, A vulnerability is: CVE (Common Vulnerability and Exposure) is a system designed to aggregate all vulnerabilities. As such, a CVE will be issued when there is a either vulnerability or exposure. Per NIST, A vulnerability is:

View File

@ -13,7 +13,6 @@ from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
from authentik.core.api.utils import PassiveSerializer from authentik.core.api.utils import PassiveSerializer
from authentik.lib.config import CONFIG
from authentik.lib.utils.reflection import get_env from authentik.lib.utils.reflection import get_env
from authentik.outposts.apps import MANAGED_OUTPOST from authentik.outposts.apps import MANAGED_OUTPOST
from authentik.outposts.models import Outpost from authentik.outposts.models import Outpost
@ -31,16 +30,15 @@ class RuntimeDict(TypedDict):
uname: str uname: str
class SystemInfoSerializer(PassiveSerializer): class SystemSerializer(PassiveSerializer):
"""Get system information.""" """Get system information."""
http_headers = SerializerMethodField() http_headers = SerializerMethodField()
http_host = SerializerMethodField() http_host = SerializerMethodField()
http_is_secure = SerializerMethodField() http_is_secure = SerializerMethodField()
runtime = SerializerMethodField() runtime = SerializerMethodField()
brand = SerializerMethodField() tenant = SerializerMethodField()
server_time = SerializerMethodField() server_time = SerializerMethodField()
embedded_outpost_disabled = SerializerMethodField()
embedded_outpost_host = SerializerMethodField() embedded_outpost_host = SerializerMethodField()
def get_http_headers(self, request: Request) -> dict[str, str]: def get_http_headers(self, request: Request) -> dict[str, str]:
@ -71,18 +69,14 @@ class SystemInfoSerializer(PassiveSerializer):
"uname": " ".join(platform.uname()), "uname": " ".join(platform.uname()),
} }
def get_brand(self, request: Request) -> str: def get_tenant(self, request: Request) -> str:
"""Currently active brand""" """Currently active tenant"""
return str(request._request.brand) return str(request._request.tenant)
def get_server_time(self, request: Request) -> datetime: def get_server_time(self, request: Request) -> datetime:
"""Current server time""" """Current server time"""
return now() return now()
def get_embedded_outpost_disabled(self, request: Request) -> bool:
"""Whether the embedded outpost is disabled"""
return CONFIG.get_bool("outposts.disable_embedded_outpost", False)
def get_embedded_outpost_host(self, request: Request) -> str: def get_embedded_outpost_host(self, request: Request) -> str:
"""Get the FQDN configured on the embedded outpost""" """Get the FQDN configured on the embedded outpost"""
outposts = Outpost.objects.filter(managed=MANAGED_OUTPOST) outposts = Outpost.objects.filter(managed=MANAGED_OUTPOST)
@ -97,14 +91,14 @@ class SystemView(APIView):
permission_classes = [HasPermission("authentik_rbac.view_system_info")] permission_classes = [HasPermission("authentik_rbac.view_system_info")]
pagination_class = None pagination_class = None
filter_backends = [] filter_backends = []
serializer_class = SystemInfoSerializer serializer_class = SystemSerializer
@extend_schema(responses={200: SystemInfoSerializer(many=False)}) @extend_schema(responses={200: SystemSerializer(many=False)})
def get(self, request: Request) -> Response: def get(self, request: Request) -> Response:
"""Get system information.""" """Get system information."""
return Response(SystemInfoSerializer(request).data) return Response(SystemSerializer(request).data)
@extend_schema(responses={200: SystemInfoSerializer(many=False)}) @extend_schema(responses={200: SystemSerializer(many=False)})
def post(self, request: Request) -> Response: def post(self, request: Request) -> Response:
"""Get system information.""" """Get system information."""
return Response(SystemInfoSerializer(request).data) return Response(SystemSerializer(request).data)

View File

@ -15,6 +15,6 @@ class AuthentikAdminConfig(ManagedAppConfig):
verbose_name = "authentik Admin" verbose_name = "authentik Admin"
default = True default = True
def reconcile_global_load_admin_signals(self): def reconcile_load_admin_signals(self):
"""Load admin signals""" """Load admin signals"""
self.import_module("authentik.admin.signals") self.import_module("authentik.admin.signals")

View File

@ -3,7 +3,7 @@
{% load static %} {% load static %}
{% block title %} {% block title %}
API Browser - {{ brand.branding_title }} API Browser - {{ tenant.branding_title }}
{% endblock %} {% endblock %}
{% block head %} {% block head %}

View File

@ -12,8 +12,6 @@ from authentik.blueprints.tests import reconcile_app
from authentik.core.models import Token, TokenIntents, User, UserTypes from authentik.core.models import Token, TokenIntents, User, UserTypes
from authentik.core.tests.utils import create_test_admin_user, create_test_flow from authentik.core.tests.utils import create_test_admin_user, create_test_flow
from authentik.lib.generators import generate_id from authentik.lib.generators import generate_id
from authentik.outposts.apps import MANAGED_OUTPOST
from authentik.outposts.models import Outpost
from authentik.providers.oauth2.constants import SCOPE_AUTHENTIK_API from authentik.providers.oauth2.constants import SCOPE_AUTHENTIK_API
from authentik.providers.oauth2.models import AccessToken, OAuth2Provider from authentik.providers.oauth2.models import AccessToken, OAuth2Provider
@ -51,12 +49,8 @@ class TestAPIAuth(TestCase):
with self.assertRaises(AuthenticationFailed): with self.assertRaises(AuthenticationFailed):
bearer_auth(f"Bearer {token.key}".encode()) bearer_auth(f"Bearer {token.key}".encode())
@reconcile_app("authentik_outposts") def test_managed_outpost(self):
def test_managed_outpost_fail(self):
"""Test managed outpost""" """Test managed outpost"""
outpost = Outpost.objects.filter(managed=MANAGED_OUTPOST).first()
outpost.user.delete()
outpost.delete()
with self.assertRaises(AuthenticationFailed): with self.assertRaises(AuthenticationFailed):
bearer_auth(f"Bearer {settings.SECRET_KEY}".encode()) bearer_auth(f"Bearer {settings.SECRET_KEY}".encode())

View File

@ -19,7 +19,7 @@ from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
from authentik.core.api.utils import PassiveSerializer from authentik.core.api.utils import PassiveSerializer
from authentik.events.context_processors.base import get_context_processors from authentik.events.geo import GEOIP_READER
from authentik.lib.config import CONFIG from authentik.lib.config import CONFIG
capabilities = Signal() capabilities = Signal()
@ -30,7 +30,6 @@ class Capabilities(models.TextChoices):
CAN_SAVE_MEDIA = "can_save_media" CAN_SAVE_MEDIA = "can_save_media"
CAN_GEO_IP = "can_geo_ip" CAN_GEO_IP = "can_geo_ip"
CAN_ASN = "can_asn"
CAN_IMPERSONATE = "can_impersonate" CAN_IMPERSONATE = "can_impersonate"
CAN_DEBUG = "can_debug" CAN_DEBUG = "can_debug"
IS_ENTERPRISE = "is_enterprise" IS_ENTERPRISE = "is_enterprise"
@ -69,10 +68,9 @@ class ConfigView(APIView):
deb_test = settings.DEBUG or settings.TEST deb_test = settings.DEBUG or settings.TEST
if Path(settings.MEDIA_ROOT).is_mount() or deb_test: if Path(settings.MEDIA_ROOT).is_mount() or deb_test:
caps.append(Capabilities.CAN_SAVE_MEDIA) caps.append(Capabilities.CAN_SAVE_MEDIA)
for processor in get_context_processors(): if GEOIP_READER.enabled:
if cap := processor.capability(): caps.append(Capabilities.CAN_GEO_IP)
caps.append(cap) if CONFIG.get_bool("impersonation"):
if self.request.tenant.impersonation:
caps.append(Capabilities.CAN_IMPERSONATE) caps.append(Capabilities.CAN_IMPERSONATE)
if settings.DEBUG: # pragma: no cover if settings.DEBUG: # pragma: no cover
caps.append(Capabilities.CAN_DEBUG) caps.append(Capabilities.CAN_DEBUG)
@ -95,10 +93,10 @@ class ConfigView(APIView):
"traces_sample_rate": float(CONFIG.get("error_reporting.sample_rate", 0.4)), "traces_sample_rate": float(CONFIG.get("error_reporting.sample_rate", 0.4)),
}, },
"capabilities": self.get_capabilities(), "capabilities": self.get_capabilities(),
"cache_timeout": CONFIG.get_int("cache.timeout"), "cache_timeout": CONFIG.get_int("redis.cache_timeout"),
"cache_timeout_flows": CONFIG.get_int("cache.timeout_flows"), "cache_timeout_flows": CONFIG.get_int("redis.cache_timeout_flows"),
"cache_timeout_policies": CONFIG.get_int("cache.timeout_policies"), "cache_timeout_policies": CONFIG.get_int("redis.cache_timeout_policies"),
"cache_timeout_reputation": CONFIG.get_int("cache.timeout_reputation"), "cache_timeout_reputation": CONFIG.get_int("redis.cache_timeout_reputation"),
} }
) )

View File

@ -3,7 +3,7 @@ from django.utils.translation import gettext_lazy as _
from drf_spectacular.utils import extend_schema, inline_serializer from drf_spectacular.utils import extend_schema, inline_serializer
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, DateTimeField from rest_framework.fields import CharField, DateTimeField, JSONField
from rest_framework.request import Request from rest_framework.request import Request
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.serializers import ListSerializer, ModelSerializer from rest_framework.serializers import ListSerializer, ModelSerializer
@ -15,7 +15,7 @@ from authentik.blueprints.v1.importer import Importer
from authentik.blueprints.v1.oci import OCI_PREFIX from authentik.blueprints.v1.oci import OCI_PREFIX
from authentik.blueprints.v1.tasks import apply_blueprint, blueprints_find_dict from authentik.blueprints.v1.tasks import apply_blueprint, blueprints_find_dict
from authentik.core.api.used_by import UsedByMixin from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import JSONDictField, PassiveSerializer from authentik.core.api.utils import PassiveSerializer
class ManagedSerializer: class ManagedSerializer:
@ -28,7 +28,7 @@ class MetadataSerializer(PassiveSerializer):
"""Serializer for blueprint metadata""" """Serializer for blueprint metadata"""
name = CharField() name = CharField()
labels = JSONDictField() labels = JSONField()
class BlueprintInstanceSerializer(ModelSerializer): class BlueprintInstanceSerializer(ModelSerializer):

View File

@ -13,23 +13,21 @@ class ManagedAppConfig(AppConfig):
_logger: BoundLogger _logger: BoundLogger
RECONCILE_GLOBAL_PREFIX: str = "reconcile_global_"
RECONCILE_TENANT_PREFIX: str = "reconcile_tenant_"
def __init__(self, app_name: str, *args, **kwargs) -> None: def __init__(self, app_name: str, *args, **kwargs) -> None:
super().__init__(app_name, *args, **kwargs) super().__init__(app_name, *args, **kwargs)
self._logger = get_logger().bind(app_name=app_name) self._logger = get_logger().bind(app_name=app_name)
def ready(self) -> None: def ready(self) -> None:
self.reconcile_global() self.reconcile()
self.reconcile_tenant()
return super().ready() return super().ready()
def import_module(self, path: str): def import_module(self, path: str):
"""Load module""" """Load module"""
import_module(path) import_module(path)
def _reconcile(self, prefix: str) -> None: def reconcile(self) -> None:
"""reconcile ourselves"""
prefix = "reconcile_"
for meth_name in dir(self): for meth_name in dir(self):
meth = getattr(self, meth_name) meth = getattr(self, meth_name)
if not ismethod(meth): if not ismethod(meth):
@ -44,29 +42,6 @@ class ManagedAppConfig(AppConfig):
except (DatabaseError, ProgrammingError, InternalError) as exc: except (DatabaseError, ProgrammingError, InternalError) as exc:
self._logger.warning("Failed to run reconcile", name=name, exc=exc) self._logger.warning("Failed to run reconcile", name=name, exc=exc)
def reconcile_tenant(self) -> None:
"""reconcile ourselves for tenanted methods"""
from authentik.tenants.models import Tenant
try:
tenants = list(Tenant.objects.filter(ready=True))
except (DatabaseError, ProgrammingError, InternalError) as exc:
self._logger.debug("Failed to get tenants to run reconcile", exc=exc)
return
for tenant in tenants:
with tenant:
self._reconcile(self.RECONCILE_TENANT_PREFIX)
def reconcile_global(self) -> None:
"""
reconcile ourselves for global methods.
Used for signals, tasks, etc. Database queries should not be made in here.
"""
from django_tenants.utils import get_public_schema_name, schema_context
with schema_context(get_public_schema_name()):
self._reconcile(self.RECONCILE_GLOBAL_PREFIX)
class AuthentikBlueprintsConfig(ManagedAppConfig): class AuthentikBlueprintsConfig(ManagedAppConfig):
"""authentik Blueprints app""" """authentik Blueprints app"""
@ -76,11 +51,11 @@ class AuthentikBlueprintsConfig(ManagedAppConfig):
verbose_name = "authentik Blueprints" verbose_name = "authentik Blueprints"
default = True default = True
def reconcile_global_load_blueprints_v1_tasks(self): def reconcile_load_blueprints_v1_tasks(self):
"""Load v1 tasks""" """Load v1 tasks"""
self.import_module("authentik.blueprints.v1.tasks") self.import_module("authentik.blueprints.v1.tasks")
def reconcile_tenant_blueprints_discovery(self): def reconcile_blueprints_discovery(self):
"""Run blueprint discovery""" """Run blueprint discovery"""
from authentik.blueprints.v1.tasks import blueprints_discovery, clear_failed_blueprints from authentik.blueprints.v1.tasks import blueprints_discovery, clear_failed_blueprints

View File

@ -6,7 +6,6 @@ from structlog.stdlib import get_logger
from authentik.blueprints.models import BlueprintInstance from authentik.blueprints.models import BlueprintInstance
from authentik.blueprints.v1.importer import Importer from authentik.blueprints.v1.importer import Importer
from authentik.tenants.models import Tenant
LOGGER = get_logger() LOGGER = get_logger()
@ -17,8 +16,6 @@ class Command(BaseCommand):
@no_translations @no_translations
def handle(self, *args, **options): def handle(self, *args, **options):
"""Apply all blueprints in order, abort when one fails to import""" """Apply all blueprints in order, abort when one fails to import"""
for tenant in Tenant.objects.filter(ready=True):
with tenant:
for blueprint_path in options.get("blueprints", []): for blueprint_path in options.get("blueprints", []):
content = BlueprintInstance(path=blueprint_path).retrieve() content = BlueprintInstance(path=blueprint_path).retrieve()
importer = Importer.from_string(content) importer = Importer.from_string(content)

View File

@ -1,18 +1,17 @@
"""Export blueprint of current authentik install""" """Export blueprint of current authentik install"""
from django.core.management.base import no_translations from django.core.management.base import BaseCommand, no_translations
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.blueprints.v1.exporter import Exporter from authentik.blueprints.v1.exporter import Exporter
from authentik.tenants.management import TenantCommand
LOGGER = get_logger() LOGGER = get_logger()
class Command(TenantCommand): class Command(BaseCommand):
"""Export blueprint of current authentik install""" """Export blueprint of current authentik install"""
@no_translations @no_translations
def handle_per_tenant(self, *args, **options): def handle(self, *args, **options):
"""Export blueprint of current authentik install""" """Export blueprint of current authentik install"""
exporter = Exporter() exporter = Exporter()
self.stdout.write(exporter.export_to_string()) self.stdout.write(exporter.export_to_string())

View File

@ -14,7 +14,7 @@ from authentik.blueprints.v1.labels import LABEL_AUTHENTIK_SYSTEM
from authentik.lib.config import CONFIG from authentik.lib.config import CONFIG
def check_blueprint_v1_file(BlueprintInstance: type, db_alias, path: Path): def check_blueprint_v1_file(BlueprintInstance: type, path: Path):
"""Check if blueprint should be imported""" """Check if blueprint should be imported"""
from authentik.blueprints.models import BlueprintInstanceStatus from authentik.blueprints.models import BlueprintInstanceStatus
from authentik.blueprints.v1.common import BlueprintLoader, BlueprintMetadata from authentik.blueprints.v1.common import BlueprintLoader, BlueprintMetadata
@ -29,9 +29,7 @@ def check_blueprint_v1_file(BlueprintInstance: type, db_alias, path: Path):
if version != 1: if version != 1:
return return
blueprint_file.seek(0) blueprint_file.seek(0)
instance: BlueprintInstance = ( instance: BlueprintInstance = BlueprintInstance.objects.filter(path=path).first()
BlueprintInstance.objects.using(db_alias).filter(path=path).first()
)
rel_path = path.relative_to(Path(CONFIG.get("blueprints_dir"))) rel_path = path.relative_to(Path(CONFIG.get("blueprints_dir")))
meta = None meta = None
if metadata: if metadata:
@ -39,7 +37,7 @@ def check_blueprint_v1_file(BlueprintInstance: type, db_alias, path: Path):
if meta.labels.get(LABEL_AUTHENTIK_INSTANTIATE, "").lower() == "false": if meta.labels.get(LABEL_AUTHENTIK_INSTANTIATE, "").lower() == "false":
return return
if not instance: if not instance:
BlueprintInstance.objects.using(db_alias).create( instance = BlueprintInstance(
name=meta.name if meta else str(rel_path), name=meta.name if meta else str(rel_path),
path=str(rel_path), path=str(rel_path),
context={}, context={},
@ -49,6 +47,7 @@ def check_blueprint_v1_file(BlueprintInstance: type, db_alias, path: Path):
last_applied_hash="", last_applied_hash="",
metadata=metadata or {}, metadata=metadata or {},
) )
instance.save()
def migration_blueprint_import(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): def migration_blueprint_import(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
@ -57,7 +56,7 @@ def migration_blueprint_import(apps: Apps, schema_editor: BaseDatabaseSchemaEdit
db_alias = schema_editor.connection.alias db_alias = schema_editor.connection.alias
for file in glob(f"{CONFIG.get('blueprints_dir')}/**/*.yaml", recursive=True): for file in glob(f"{CONFIG.get('blueprints_dir')}/**/*.yaml", recursive=True):
check_blueprint_v1_file(BlueprintInstance, db_alias, Path(file)) check_blueprint_v1_file(BlueprintInstance, Path(file))
for blueprint in BlueprintInstance.objects.using(db_alias).all(): for blueprint in BlueprintInstance.objects.using(db_alias).all():
# If we already have flows (and we should always run before flow migrations) # If we already have flows (and we should always run before flow migrations)

View File

@ -38,7 +38,7 @@ def reconcile_app(app_name: str):
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
config = apps.get_app_config(app_name) config = apps.get_app_config(app_name)
if isinstance(config, ManagedAppConfig): if isinstance(config, ManagedAppConfig):
config.ready() config.reconcile()
return func(*args, **kwargs) return func(*args, **kwargs)
return wrapper return wrapper

View File

@ -7,16 +7,16 @@ from django.test import TransactionTestCase
from authentik.blueprints.models import BlueprintInstance from authentik.blueprints.models import BlueprintInstance
from authentik.blueprints.tests import apply_blueprint from authentik.blueprints.tests import apply_blueprint
from authentik.blueprints.v1.importer import Importer from authentik.blueprints.v1.importer import Importer
from authentik.brands.models import Brand from authentik.tenants.models import Tenant
class TestPackaged(TransactionTestCase): class TestPackaged(TransactionTestCase):
"""Empty class, test methods are added dynamically""" """Empty class, test methods are added dynamically"""
@apply_blueprint("default/default-brand.yaml") @apply_blueprint("default/default-tenant.yaml")
def test_decorator_static(self): def test_decorator_static(self):
"""Test @apply_blueprint decorator""" """Test @apply_blueprint decorator"""
self.assertTrue(Brand.objects.filter(domain="authentik-default").exists()) self.assertTrue(Tenant.objects.filter(domain="authentik-default").exists())
def blueprint_tester(file_name: Path) -> Callable: def blueprint_tester(file_name: Path) -> Callable:

View File

@ -35,7 +35,7 @@ from authentik.core.models import (
Source, Source,
UserSourceConnection, UserSourceConnection,
) )
from authentik.enterprise.models import LicenseKey, LicenseUsage from authentik.enterprise.models import LicenseUsage
from authentik.events.utils import cleanse_dict from authentik.events.utils import cleanse_dict
from authentik.flows.models import FlowToken, Stage from authentik.flows.models import FlowToken, Stage
from authentik.lib.models import SerializerModel from authentik.lib.models import SerializerModel
@ -43,7 +43,6 @@ from authentik.lib.sentry import SentryIgnoredException
from authentik.outposts.models import OutpostServiceConnection from authentik.outposts.models import OutpostServiceConnection
from authentik.policies.models import Policy, PolicyBindingModel from authentik.policies.models import Policy, PolicyBindingModel
from authentik.providers.scim.models import SCIMGroup, SCIMUser from authentik.providers.scim.models import SCIMGroup, SCIMUser
from authentik.tenants.models import Tenant
# Context set when the serializer is created in a blueprint context # Context set when the serializer is created in a blueprint context
# Update website/developer-docs/blueprints/v1/models.md when used # Update website/developer-docs/blueprints/v1/models.md when used
@ -58,7 +57,6 @@ def excluded_models() -> list[type[Model]]:
from django.contrib.auth.models import User as DjangoUser from django.contrib.auth.models import User as DjangoUser
return ( return (
Tenant,
DjangoUser, DjangoUser,
DjangoGroup, DjangoGroup,
# Base classes # Base classes
@ -110,16 +108,12 @@ class Importer:
self.__pk_map: dict[Any, Model] = {} self.__pk_map: dict[Any, Model] = {}
self._import = blueprint self._import = blueprint
self.logger = get_logger() self.logger = get_logger()
ctx = self.default_context() ctx = {}
always_merger.merge(ctx, self._import.context) always_merger.merge(ctx, self._import.context)
if context: if context:
always_merger.merge(ctx, context) always_merger.merge(ctx, context)
self._import.context = ctx self._import.context = ctx
def default_context(self):
"""Default context"""
return {"goauthentik.io/enterprise/licensed": LicenseKey.get_total().is_valid()}
@staticmethod @staticmethod
def from_string(yaml_input: str, context: dict | None = None) -> "Importer": def from_string(yaml_input: str, context: dict | None = None) -> "Importer":
"""Parse YAML string and create blueprint importer from it""" """Parse YAML string and create blueprint importer from it"""

View File

@ -2,11 +2,11 @@
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
from rest_framework.fields import BooleanField from rest_framework.fields import BooleanField, JSONField
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.blueprints.v1.meta.registry import BaseMetaModel, MetaResult, registry from authentik.blueprints.v1.meta.registry import BaseMetaModel, MetaResult, registry
from authentik.core.api.utils import JSONDictField, PassiveSerializer from authentik.core.api.utils import PassiveSerializer, is_dict
if TYPE_CHECKING: if TYPE_CHECKING:
from authentik.blueprints.models import BlueprintInstance from authentik.blueprints.models import BlueprintInstance
@ -17,7 +17,7 @@ LOGGER = get_logger()
class ApplyBlueprintMetaSerializer(PassiveSerializer): class ApplyBlueprintMetaSerializer(PassiveSerializer):
"""Serializer for meta apply blueprint model""" """Serializer for meta apply blueprint model"""
identifiers = JSONDictField() identifiers = JSONField(validators=[is_dict])
required = BooleanField(default=True) required = BooleanField(default=True)
# We cannot override `instance` as that will confuse rest_framework # We cannot override `instance` as that will confuse rest_framework

View File

@ -38,7 +38,6 @@ from authentik.events.monitored_tasks import (
from authentik.events.utils import sanitize_dict from authentik.events.utils import sanitize_dict
from authentik.lib.config import CONFIG from authentik.lib.config import CONFIG
from authentik.root.celery import CELERY_APP from authentik.root.celery import CELERY_APP
from authentik.tenants.models import Tenant
LOGGER = get_logger() LOGGER = get_logger()
_file_watcher_started = False _file_watcher_started = False
@ -79,11 +78,6 @@ class BlueprintEventHandler(FileSystemEventHandler):
root = Path(CONFIG.get("blueprints_dir")).absolute() root = Path(CONFIG.get("blueprints_dir")).absolute()
path = Path(event.src_path).absolute() path = Path(event.src_path).absolute()
rel_path = str(path.relative_to(root)) rel_path = str(path.relative_to(root))
for tenant in Tenant.objects.filter(ready=True):
with tenant:
root = Path(CONFIG.get("blueprints_dir")).absolute()
path = Path(event.src_path).absolute()
rel_path = str(path.relative_to(root))
if isinstance(event, FileCreatedEvent): if isinstance(event, FileCreatedEvent):
LOGGER.debug("new blueprint file created, starting discovery", path=rel_path) LOGGER.debug("new blueprint file created, starting discovery", path=rel_path)
blueprints_discovery.delay(rel_path) blueprints_discovery.delay(rel_path)

View File

@ -1,10 +0,0 @@
"""authentik brands app"""
from django.apps import AppConfig
class AuthentikBrandsConfig(AppConfig):
"""authentik Brand app"""
name = "authentik.brands"
label = "authentik_brands"
verbose_name = "authentik Brands"

View File

@ -1,26 +0,0 @@
"""Inject brand into current request"""
from typing import Callable
from django.http.request import HttpRequest
from django.http.response import HttpResponse
from django.utils.translation import activate
from authentik.brands.utils import get_brand_for_request
class BrandMiddleware:
"""Add current brand to http request"""
get_response: Callable[[HttpRequest], HttpResponse]
def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]):
self.get_response = get_response
def __call__(self, request: HttpRequest) -> HttpResponse:
if not hasattr(request, "brand"):
brand = get_brand_for_request(request)
setattr(request, "brand", brand)
locale = brand.default_locale
if locale != "":
activate(locale)
return self.get_response(request)

View File

@ -1,21 +0,0 @@
# Generated by Django 4.2.7 on 2023-12-12 06:41
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("authentik_brands", "0004_tenant_flow_device_code"),
]
operations = [
migrations.RenameField(
model_name="brand",
old_name="tenant_uuid",
new_name="brand_uuid",
),
migrations.RemoveField(
model_name="brand",
name="event_retention",
),
]

View File

@ -1,85 +0,0 @@
"""brand models"""
from uuid import uuid4
from django.db import models
from django.utils.translation import gettext_lazy as _
from rest_framework.serializers import Serializer
from structlog.stdlib import get_logger
from authentik.crypto.models import CertificateKeyPair
from authentik.flows.models import Flow
from authentik.lib.models import SerializerModel
LOGGER = get_logger()
class Brand(SerializerModel):
"""Single brand"""
brand_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
domain = models.TextField(
help_text=_(
"Domain that activates this brand. Can be a superset, i.e. `a.b` for `aa.b` and `ba.b`"
)
)
default = models.BooleanField(
default=False,
)
branding_title = models.TextField(default="authentik")
branding_logo = models.TextField(default="/static/dist/assets/icons/icon_left_brand.svg")
branding_favicon = models.TextField(default="/static/dist/assets/icons/icon.png")
flow_authentication = models.ForeignKey(
Flow, null=True, on_delete=models.SET_NULL, related_name="brand_authentication"
)
flow_invalidation = models.ForeignKey(
Flow, null=True, on_delete=models.SET_NULL, related_name="brand_invalidation"
)
flow_recovery = models.ForeignKey(
Flow, null=True, on_delete=models.SET_NULL, related_name="brand_recovery"
)
flow_unenrollment = models.ForeignKey(
Flow, null=True, on_delete=models.SET_NULL, related_name="brand_unenrollment"
)
flow_user_settings = models.ForeignKey(
Flow, null=True, on_delete=models.SET_NULL, related_name="brand_user_settings"
)
flow_device_code = models.ForeignKey(
Flow, null=True, on_delete=models.SET_NULL, related_name="brand_device_code"
)
web_certificate = models.ForeignKey(
CertificateKeyPair,
null=True,
default=None,
on_delete=models.SET_DEFAULT,
help_text=_("Web Certificate used by the authentik Core webserver."),
)
attributes = models.JSONField(default=dict, blank=True)
@property
def serializer(self) -> Serializer:
from authentik.brands.api import BrandSerializer
return BrandSerializer
@property
def default_locale(self) -> str:
"""Get default locale"""
try:
return self.attributes.get("settings", {}).get("locale", "")
# pylint: disable=broad-except
except Exception as exc:
LOGGER.warning("Failed to get default locale", exc=exc)
return ""
def __str__(self) -> str:
if self.default:
return "Default brand"
return f"Brand {self.domain}"
class Meta:
verbose_name = _("Brand")
verbose_name_plural = _("Brands")

View File

@ -1,76 +0,0 @@
"""Test brands"""
from django.urls import reverse
from rest_framework.test import APITestCase
from authentik.brands.api import Themes
from authentik.brands.models import Brand
from authentik.core.tests.utils import create_test_admin_user, create_test_brand
class TestBrands(APITestCase):
"""Test brands"""
def test_current_brand(self):
"""Test Current brand API"""
brand = create_test_brand()
self.assertJSONEqual(
self.client.get(reverse("authentik_api:brand-current")).content.decode(),
{
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
"branding_favicon": "/static/dist/assets/icons/icon.png",
"branding_title": "authentik",
"matched_domain": brand.domain,
"ui_footer_links": [],
"ui_theme": Themes.AUTOMATIC,
"default_locale": "",
},
)
def test_brand_subdomain(self):
"""Test Current brand API"""
Brand.objects.all().delete()
Brand.objects.create(domain="bar.baz", branding_title="custom")
self.assertJSONEqual(
self.client.get(
reverse("authentik_api:brand-current"), HTTP_HOST="foo.bar.baz"
).content.decode(),
{
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
"branding_favicon": "/static/dist/assets/icons/icon.png",
"branding_title": "custom",
"matched_domain": "bar.baz",
"ui_footer_links": [],
"ui_theme": Themes.AUTOMATIC,
"default_locale": "",
},
)
def test_fallback(self):
"""Test fallback brand"""
Brand.objects.all().delete()
self.assertJSONEqual(
self.client.get(reverse("authentik_api:brand-current")).content.decode(),
{
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
"branding_favicon": "/static/dist/assets/icons/icon.png",
"branding_title": "authentik",
"matched_domain": "fallback",
"ui_footer_links": [],
"ui_theme": Themes.AUTOMATIC,
"default_locale": "",
},
)
def test_create_default_multiple(self):
"""Test attempted creation of multiple default brands"""
Brand.objects.create(
domain="foo",
default=True,
branding_title="custom",
)
user = create_test_admin_user()
self.client.force_login(user)
response = self.client.post(
reverse("authentik_api:brand-list"), data={"domain": "bar", "default": True}
)
self.assertEqual(response.status_code, 400)

View File

@ -1,6 +0,0 @@
"""API URLs"""
from authentik.brands.api import BrandViewSet
api_urlpatterns = [
("core/brands", BrandViewSet),
]

View File

@ -1,42 +0,0 @@
"""Brand utilities"""
from typing import Any
from django.db.models import F, Q
from django.db.models import Value as V
from django.http.request import HttpRequest
from sentry_sdk.hub import Hub
from authentik import get_full_version
from authentik.brands.models import Brand
from authentik.tenants.utils import get_current_tenant
_q_default = Q(default=True)
DEFAULT_BRAND = Brand(domain="fallback")
def get_brand_for_request(request: HttpRequest) -> Brand:
"""Get brand object for current request"""
db_brands = (
Brand.objects.annotate(host_domain=V(request.get_host()))
.filter(Q(host_domain__iendswith=F("domain")) | _q_default)
.order_by("default")
)
brands = list(db_brands.all())
if len(brands) < 1:
return DEFAULT_BRAND
return brands[0]
def context_processor(request: HttpRequest) -> dict[str, Any]:
"""Context Processor that injects brand object into every template"""
brand = getattr(request, "brand", DEFAULT_BRAND)
trace = ""
span = Hub.current.scope.span
if span:
trace = span.to_traceparent()
return {
"brand": brand,
"footer_links": get_current_tenant().footer_links,
"sentry_trace": trace,
"version": get_full_version(),
}

View File

@ -14,8 +14,7 @@ from ua_parser import user_agent_parser
from authentik.api.authorization import OwnerSuperuserPermissions from authentik.api.authorization import OwnerSuperuserPermissions
from authentik.core.api.used_by import UsedByMixin from authentik.core.api.used_by import UsedByMixin
from authentik.core.models import AuthenticatedSession from authentik.core.models import AuthenticatedSession
from authentik.events.context_processors.asn import ASN_CONTEXT_PROCESSOR, ASNDict from authentik.events.geo import GEOIP_READER, GeoIPDict
from authentik.events.context_processors.geoip import GEOIP_CONTEXT_PROCESSOR, GeoIPDict
class UserAgentDeviceDict(TypedDict): class UserAgentDeviceDict(TypedDict):
@ -60,7 +59,6 @@ class AuthenticatedSessionSerializer(ModelSerializer):
current = SerializerMethodField() current = SerializerMethodField()
user_agent = SerializerMethodField() user_agent = SerializerMethodField()
geo_ip = SerializerMethodField() geo_ip = SerializerMethodField()
asn = SerializerMethodField()
def get_current(self, instance: AuthenticatedSession) -> bool: def get_current(self, instance: AuthenticatedSession) -> bool:
"""Check if session is currently active session""" """Check if session is currently active session"""
@ -72,12 +70,8 @@ class AuthenticatedSessionSerializer(ModelSerializer):
return user_agent_parser.Parse(instance.last_user_agent) return user_agent_parser.Parse(instance.last_user_agent)
def get_geo_ip(self, instance: AuthenticatedSession) -> Optional[GeoIPDict]: # pragma: no cover def get_geo_ip(self, instance: AuthenticatedSession) -> Optional[GeoIPDict]: # pragma: no cover
"""Get GeoIP Data""" """Get parsed user agent"""
return GEOIP_CONTEXT_PROCESSOR.city_dict(instance.last_ip) return GEOIP_READER.city_dict(instance.last_ip)
def get_asn(self, instance: AuthenticatedSession) -> Optional[ASNDict]: # pragma: no cover
"""Get ASN Data"""
return ASN_CONTEXT_PROCESSOR.asn_dict(instance.last_ip)
class Meta: class Meta:
model = AuthenticatedSession model = AuthenticatedSession
@ -86,7 +80,6 @@ class AuthenticatedSessionSerializer(ModelSerializer):
"current", "current",
"user_agent", "user_agent",
"geo_ip", "geo_ip",
"asn",
"user", "user",
"last_ip", "last_ip",
"last_user_agent", "last_user_agent",

View File

@ -8,7 +8,7 @@ from django_filters.filterset import FilterSet
from drf_spectacular.utils import OpenApiResponse, extend_schema from drf_spectacular.utils import OpenApiResponse, extend_schema
from guardian.shortcuts import get_objects_for_user from guardian.shortcuts import get_objects_for_user
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.fields import CharField, IntegerField from rest_framework.fields import CharField, IntegerField, JSONField
from rest_framework.request import Request from rest_framework.request import Request
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.serializers import ListSerializer, ModelSerializer, ValidationError from rest_framework.serializers import ListSerializer, ModelSerializer, ValidationError
@ -16,7 +16,7 @@ from rest_framework.viewsets import ModelViewSet
from authentik.api.decorators import permission_required from authentik.api.decorators import permission_required
from authentik.core.api.used_by import UsedByMixin from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import JSONDictField, PassiveSerializer from authentik.core.api.utils import PassiveSerializer, is_dict
from authentik.core.models import Group, User from authentik.core.models import Group, User
from authentik.rbac.api.roles import RoleSerializer from authentik.rbac.api.roles import RoleSerializer
@ -24,7 +24,7 @@ from authentik.rbac.api.roles import RoleSerializer
class GroupMemberSerializer(ModelSerializer): class GroupMemberSerializer(ModelSerializer):
"""Stripped down user serializer to show relevant users for groups""" """Stripped down user serializer to show relevant users for groups"""
attributes = JSONDictField(required=False) attributes = JSONField(validators=[is_dict], required=False)
uid = CharField(read_only=True) uid = CharField(read_only=True)
class Meta: class Meta:
@ -44,7 +44,7 @@ class GroupMemberSerializer(ModelSerializer):
class GroupSerializer(ModelSerializer): class GroupSerializer(ModelSerializer):
"""Group Serializer""" """Group Serializer"""
attributes = JSONDictField(required=False) attributes = JSONField(validators=[is_dict], required=False)
users_obj = ListSerializer( users_obj = ListSerializer(
child=GroupMemberSerializer(), read_only=True, source="users", required=False child=GroupMemberSerializer(), read_only=True, source="users", required=False
) )

View File

@ -19,7 +19,6 @@ from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import MetaNameSerializer, PassiveSerializer, TypeCreateSerializer from authentik.core.api.utils import MetaNameSerializer, PassiveSerializer, TypeCreateSerializer
from authentik.core.expression.evaluator import PropertyMappingEvaluator from authentik.core.expression.evaluator import PropertyMappingEvaluator
from authentik.core.models import PropertyMapping from authentik.core.models import PropertyMapping
from authentik.enterprise.apps import EnterpriseConfig
from authentik.events.utils import sanitize_item from authentik.events.utils import sanitize_item
from authentik.lib.utils.reflection import all_subclasses from authentik.lib.utils.reflection import all_subclasses
from authentik.policies.api.exec import PolicyTestSerializer from authentik.policies.api.exec import PolicyTestSerializer
@ -96,7 +95,6 @@ class PropertyMappingViewSet(
"description": subclass.__doc__, "description": subclass.__doc__,
"component": subclass().component, "component": subclass().component,
"model_name": subclass._meta.model_name, "model_name": subclass._meta.model_name,
"requires_enterprise": isinstance(subclass._meta.app_config, EnterpriseConfig),
} }
) )
return Response(TypeCreateSerializer(data, many=True).data) return Response(TypeCreateSerializer(data, many=True).data)

View File

@ -16,7 +16,6 @@ from rest_framework.viewsets import GenericViewSet
from authentik.core.api.used_by import UsedByMixin from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import MetaNameSerializer, TypeCreateSerializer from authentik.core.api.utils import MetaNameSerializer, TypeCreateSerializer
from authentik.core.models import Provider from authentik.core.models import Provider
from authentik.enterprise.apps import EnterpriseConfig
from authentik.lib.utils.reflection import all_subclasses from authentik.lib.utils.reflection import all_subclasses
@ -114,7 +113,6 @@ class ProviderViewSet(
"description": subclass.__doc__, "description": subclass.__doc__,
"component": subclass().component, "component": subclass().component,
"model_name": subclass._meta.model_name, "model_name": subclass._meta.model_name,
"requires_enterprise": isinstance(subclass._meta.app_config, EnterpriseConfig),
} }
) )
data.append( data.append(

View File

@ -32,7 +32,13 @@ from drf_spectacular.utils import (
) )
from guardian.shortcuts import get_anonymous_user, get_objects_for_user from guardian.shortcuts import get_anonymous_user, get_objects_for_user
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.fields import CharField, IntegerField, ListField, SerializerMethodField from rest_framework.fields import (
CharField,
IntegerField,
JSONField,
ListField,
SerializerMethodField,
)
from rest_framework.request import Request from rest_framework.request import Request
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.serializers import ( from rest_framework.serializers import (
@ -50,9 +56,8 @@ from structlog.stdlib import get_logger
from authentik.admin.api.metrics import CoordinateSerializer from authentik.admin.api.metrics import CoordinateSerializer
from authentik.api.decorators import permission_required from authentik.api.decorators import permission_required
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
from authentik.brands.models import Brand
from authentik.core.api.used_by import UsedByMixin from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import JSONDictField, LinkSerializer, PassiveSerializer from authentik.core.api.utils import LinkSerializer, PassiveSerializer, is_dict
from authentik.core.middleware import ( from authentik.core.middleware import (
SESSION_KEY_IMPERSONATE_ORIGINAL_USER, SESSION_KEY_IMPERSONATE_ORIGINAL_USER,
SESSION_KEY_IMPERSONATE_USER, SESSION_KEY_IMPERSONATE_USER,
@ -72,9 +77,11 @@ from authentik.flows.exceptions import FlowNonApplicableException
from authentik.flows.models import FlowToken from authentik.flows.models import FlowToken
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner
from authentik.flows.views.executor import QS_KEY_TOKEN from authentik.flows.views.executor import QS_KEY_TOKEN
from authentik.lib.config import CONFIG
from authentik.stages.email.models import EmailStage from authentik.stages.email.models import EmailStage
from authentik.stages.email.tasks import send_mails from authentik.stages.email.tasks import send_mails
from authentik.stages.email.utils import TemplateEmailMessage from authentik.stages.email.utils import TemplateEmailMessage
from authentik.tenants.models import Tenant
LOGGER = get_logger() LOGGER = get_logger()
@ -82,7 +89,7 @@ LOGGER = get_logger()
class UserGroupSerializer(ModelSerializer): class UserGroupSerializer(ModelSerializer):
"""Simplified Group Serializer for user's groups""" """Simplified Group Serializer for user's groups"""
attributes = JSONDictField(required=False) attributes = JSONField(required=False)
parent_name = CharField(source="parent.name", read_only=True) parent_name = CharField(source="parent.name", read_only=True)
class Meta: class Meta:
@ -103,7 +110,7 @@ class UserSerializer(ModelSerializer):
is_superuser = BooleanField(read_only=True) is_superuser = BooleanField(read_only=True)
avatar = CharField(read_only=True) avatar = CharField(read_only=True)
attributes = JSONDictField(required=False) attributes = JSONField(validators=[is_dict], required=False)
groups = PrimaryKeyRelatedField( groups = PrimaryKeyRelatedField(
allow_empty=True, many=True, source="ak_groups", queryset=Group.objects.all(), default=list allow_empty=True, many=True, source="ak_groups", queryset=Group.objects.all(), default=list
) )
@ -220,7 +227,7 @@ class UserSelfSerializer(ModelSerializer):
} }
def get_settings(self, user: User) -> dict[str, Any]: def get_settings(self, user: User) -> dict[str, Any]:
"""Get user settings with brand and group settings applied""" """Get user settings with tenant and group settings applied"""
return user.group_attributes(self._context["request"]).get("settings", {}) return user.group_attributes(self._context["request"]).get("settings", {})
def get_system_permissions(self, user: User) -> list[str]: def get_system_permissions(self, user: User) -> list[str]:
@ -381,11 +388,11 @@ class UserViewSet(UsedByMixin, ModelViewSet):
return User.objects.all().exclude(pk=get_anonymous_user().pk) return User.objects.all().exclude(pk=get_anonymous_user().pk)
def _create_recovery_link(self) -> tuple[Optional[str], Optional[Token]]: def _create_recovery_link(self) -> tuple[Optional[str], Optional[Token]]:
"""Create a recovery link (when the current brand has a recovery flow set), """Create a recovery link (when the current tenant has a recovery flow set),
that can either be shown to an admin or sent to the user directly""" that can either be shown to an admin or sent to the user directly"""
brand: Brand = self.request._request.brand tenant: Tenant = self.request._request.tenant
# Check that there is a recovery flow, if not return an error # Check that there is a recovery flow, if not return an error
flow = brand.flow_recovery flow = tenant.flow_recovery
if not flow: if not flow:
LOGGER.debug("No recovery flow set") LOGGER.debug("No recovery flow set")
return None, None return None, None
@ -617,7 +624,7 @@ class UserViewSet(UsedByMixin, ModelViewSet):
@action(detail=True, methods=["POST"]) @action(detail=True, methods=["POST"])
def impersonate(self, request: Request, pk: int) -> Response: def impersonate(self, request: Request, pk: int) -> Response:
"""Impersonate a user""" """Impersonate a user"""
if not request.tenant.impersonation: if not CONFIG.get_bool("impersonation"):
LOGGER.debug("User attempted to impersonate", user=request.user) LOGGER.debug("User attempted to impersonate", user=request.user)
return Response(status=401) return Response(status=401)
if not request.user.has_perm("impersonate"): if not request.user.has_perm("impersonate"):

View File

@ -2,10 +2,7 @@
from typing import Any from typing import Any
from django.db.models import Model from django.db.models import Model
from drf_spectacular.extensions import OpenApiSerializerFieldExtension from rest_framework.fields import CharField, IntegerField, JSONField
from drf_spectacular.plumbing import build_basic_type
from drf_spectacular.types import OpenApiTypes
from rest_framework.fields import BooleanField, CharField, IntegerField, JSONField
from rest_framework.serializers import Serializer, SerializerMethodField, ValidationError from rest_framework.serializers import Serializer, SerializerMethodField, ValidationError
@ -16,21 +13,6 @@ def is_dict(value: Any):
raise ValidationError("Value must be a dictionary, and not have any duplicate keys.") raise ValidationError("Value must be a dictionary, and not have any duplicate keys.")
class JSONDictField(JSONField):
"""JSON Field which only allows dictionaries"""
default_validators = [is_dict]
class JSONExtension(OpenApiSerializerFieldExtension):
"""Generate API Schema for JSON fields as"""
target_class = "authentik.core.api.utils.JSONDictField"
def map_serializer_field(self, auto_schema, direction):
return build_basic_type(OpenApiTypes.OBJECT)
class PassiveSerializer(Serializer): class PassiveSerializer(Serializer):
"""Base serializer class which doesn't implement create/update methods""" """Base serializer class which doesn't implement create/update methods"""
@ -44,7 +26,7 @@ class PassiveSerializer(Serializer):
class PropertyMappingPreviewSerializer(PassiveSerializer): class PropertyMappingPreviewSerializer(PassiveSerializer):
"""Preview how the current user is mapped via the property mappings selected in a provider""" """Preview how the current user is mapped via the property mappings selected in a provider"""
preview = JSONDictField(read_only=True) preview = JSONField(read_only=True)
class MetaNameSerializer(PassiveSerializer): class MetaNameSerializer(PassiveSerializer):
@ -74,7 +56,6 @@ class TypeCreateSerializer(PassiveSerializer):
description = CharField(required=True) description = CharField(required=True)
component = CharField(required=True) component = CharField(required=True)
model_name = CharField(required=True) model_name = CharField(required=True)
requires_enterprise = BooleanField(default=False)
class CacheSerializer(PassiveSerializer): class CacheSerializer(PassiveSerializer):

View File

@ -13,18 +13,18 @@ class AuthentikCoreConfig(ManagedAppConfig):
mountpoint = "" mountpoint = ""
default = True default = True
def reconcile_global_load_core_signals(self): def reconcile_load_core_signals(self):
"""Load core signals""" """Load core signals"""
self.import_module("authentik.core.signals") self.import_module("authentik.core.signals")
def reconcile_global_debug_worker_hook(self): def reconcile_debug_worker_hook(self):
"""Dispatch startup tasks inline when debugging""" """Dispatch startup tasks inline when debugging"""
if settings.DEBUG: if settings.DEBUG:
from authentik.root.celery import worker_ready_hook from authentik.root.celery import worker_ready_hook
worker_ready_hook() worker_ready_hook()
def reconcile_tenant_source_inbuilt(self): def reconcile_source_inbuilt(self):
"""Reconcile inbuilt source""" """Reconcile inbuilt source"""
from authentik.core.models import Source from authentik.core.models import Source

View File

@ -1,29 +1,22 @@
"""Channels base classes""" """Channels base classes"""
from channels.db import database_sync_to_async
from channels.exceptions import DenyConnection from channels.exceptions import DenyConnection
from channels.generic.websocket import JsonWebsocketConsumer
from rest_framework.exceptions import AuthenticationFailed from rest_framework.exceptions import AuthenticationFailed
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.api.authentication import bearer_auth from authentik.api.authentication import bearer_auth
from authentik.core.models import User
LOGGER = get_logger() LOGGER = get_logger()
class TokenOutpostMiddleware: class AuthJsonConsumer(JsonWebsocketConsumer):
"""Authorize a client with a token""" """Authorize a client with a token"""
def __init__(self, inner): user: User
self.inner = inner
async def __call__(self, scope, receive, send): def connect(self):
scope = dict(scope) headers = dict(self.scope["headers"])
await self.auth(scope)
return await self.inner(scope, receive, send)
@database_sync_to_async
def auth(self, scope):
"""Authenticate request from header"""
headers = dict(scope["headers"])
if b"authorization" not in headers: if b"authorization" not in headers:
LOGGER.warning("WS Request without authorization header") LOGGER.warning("WS Request without authorization header")
raise DenyConnection() raise DenyConnection()
@ -39,4 +32,4 @@ class TokenOutpostMiddleware:
LOGGER.warning("Failed to authenticate", exc=exc) LOGGER.warning("Failed to authenticate", exc=exc)
raise DenyConnection() raise DenyConnection()
scope["user"] = user self.user = user

View File

@ -1,20 +1,13 @@
"""Run bootstrap tasks""" """Run bootstrap tasks"""
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django_tenants.utils import get_public_schema_name
from authentik.root.celery import _get_startup_tasks_all_tenants, _get_startup_tasks_default_tenant from authentik.root.celery import _get_startup_tasks
from authentik.tenants.models import Tenant
class Command(BaseCommand): class Command(BaseCommand):
"""Run bootstrap tasks to ensure certain objects are created""" """Run bootstrap tasks to ensure certain objects are created"""
def handle(self, **options): def handle(self, **options):
for task in _get_startup_tasks_default_tenant(): tasks = _get_startup_tasks()
with Tenant.objects.get(schema_name=get_public_schema_name()): for task in tasks:
task()
for task in _get_startup_tasks_all_tenants():
for tenant in Tenant.objects.filter(ready=True):
with tenant:
task() task()

View File

@ -4,8 +4,6 @@ from django.contrib.auth.management import create_permissions
from django.core.management.base import BaseCommand, no_translations from django.core.management.base import BaseCommand, no_translations
from guardian.management import create_anonymous_user from guardian.management import create_anonymous_user
from authentik.tenants.models import Tenant
class Command(BaseCommand): class Command(BaseCommand):
"""Repair missing permissions""" """Repair missing permissions"""
@ -13,8 +11,6 @@ class Command(BaseCommand):
@no_translations @no_translations
def handle(self, *args, **options): def handle(self, *args, **options):
"""Check permissions for all apps""" """Check permissions for all apps"""
for tenant in Tenant.objects.filter(ready=True):
with tenant:
for app in apps.get_app_configs(): for app in apps.get_app_configs():
self.stdout.write(f"Checking app {app.name} ({app.label})\n") self.stdout.write(f"Checking app {app.name} ({app.label})\n")
create_permissions(app, verbosity=0) create_permissions(app, verbosity=0)

View File

@ -30,6 +30,7 @@ from authentik.lib.models import (
DomainlessFormattedURLValidator, DomainlessFormattedURLValidator,
SerializerModel, SerializerModel,
) )
from authentik.lib.utils.http import get_client_ip
from authentik.policies.models import PolicyBindingModel from authentik.policies.models import PolicyBindingModel
from authentik.root.install_id import get_install_id from authentik.root.install_id import get_install_id
@ -201,8 +202,8 @@ class User(SerializerModel, GuardianUserMixin, AbstractUser):
"""Get a dictionary containing the attributes from all groups the user belongs to, """Get a dictionary containing the attributes from all groups the user belongs to,
including the users attributes""" including the users attributes"""
final_attributes = {} final_attributes = {}
if request and hasattr(request, "brand"): if request and hasattr(request, "tenant"):
always_merger.merge(final_attributes, request.brand.attributes) always_merger.merge(final_attributes, request.tenant.attributes)
for group in self.all_groups().order_by("name"): for group in self.all_groups().order_by("name"):
always_merger.merge(final_attributes, group.attributes) always_merger.merge(final_attributes, group.attributes)
always_merger.merge(final_attributes, self.attributes) always_merger.merge(final_attributes, self.attributes)
@ -261,7 +262,7 @@ class User(SerializerModel, GuardianUserMixin, AbstractUser):
except Exception as exc: except Exception as exc:
LOGGER.warning("Failed to get default locale", exc=exc) LOGGER.warning("Failed to get default locale", exc=exc)
if request: if request:
return request.brand.locale return request.tenant.locale
return "" return ""
@property @property
@ -516,7 +517,7 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
objects = InheritanceManager() objects = InheritanceManager()
@property @property
def icon_url(self) -> Optional[str]: def get_icon(self) -> Optional[str]:
"""Get the URL to the Icon. If the name is /static or """Get the URL to the Icon. If the name is /static or
starts with http it is returned as-is""" starts with http it is returned as-is"""
if not self.icon: if not self.icon:
@ -747,14 +748,12 @@ class AuthenticatedSession(ExpiringModel):
@staticmethod @staticmethod
def from_request(request: HttpRequest, user: User) -> Optional["AuthenticatedSession"]: def from_request(request: HttpRequest, user: User) -> Optional["AuthenticatedSession"]:
"""Create a new session from a http request""" """Create a new session from a http request"""
from authentik.root.middleware import ClientIPMiddleware
if not hasattr(request, "session") or not request.session.session_key: if not hasattr(request, "session") or not request.session.session_key:
return None return None
return AuthenticatedSession( return AuthenticatedSession(
session_key=request.session.session_key, session_key=request.session.session_key,
user=user, user=user,
last_ip=ClientIPMiddleware.get_client_ip(request), last_ip=get_client_ip(request),
last_user_agent=request.META.get("HTTP_USER_AGENT", ""), last_user_agent=request.META.get("HTTP_USER_AGENT", ""),
expires=request.session.get_expiry_date(), expires=request.session.get_expiry_date(),
) )

View File

@ -5,7 +5,7 @@
window.authentik = { window.authentik = {
locale: "{{ LANGUAGE_CODE }}", locale: "{{ LANGUAGE_CODE }}",
config: JSON.parse('{{ config_json|escapejs }}'), config: JSON.parse('{{ config_json|escapejs }}'),
brand: JSON.parse('{{ brand_json|escapejs }}'), tenant: JSON.parse('{{ tenant_json|escapejs }}'),
versionFamily: "{{ version_family }}", versionFamily: "{{ version_family }}",
versionSubdomain: "{{ version_subdomain }}", versionSubdomain: "{{ version_subdomain }}",
build: "{{ build }}", build: "{{ build }}",

View File

@ -7,9 +7,9 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<title>{% block title %}{% trans title|default:brand.branding_title %}{% endblock %}</title> <title>{% block title %}{% trans title|default:tenant.branding_title %}{% endblock %}</title>
<link rel="icon" href="{{ brand.branding_favicon }}"> <link rel="icon" href="{{ tenant.branding_favicon }}">
<link rel="shortcut icon" href="{{ brand.branding_favicon }}"> <link rel="shortcut icon" href="{{ tenant.branding_favicon }}">
{% block head_before %} {% block head_before %}
{% endblock %} {% endblock %}
<link rel="stylesheet" type="text/css" href="{% static 'dist/authentik.css' %}"> <link rel="stylesheet" type="text/css" href="{% static 'dist/authentik.css' %}">

View File

@ -4,7 +4,7 @@
{% load i18n %} {% load i18n %}
{% block title %} {% block title %}
{% trans 'End session' %} - {{ brand.branding_title }} {% trans 'End session' %} - {{ tenant.branding_title }}
{% endblock %} {% endblock %}
{% block card_title %} {% block card_title %}
@ -16,7 +16,7 @@ You've logged out of {{ application }}.
{% block card %} {% block card %}
<form method="POST" class="pf-c-form"> <form method="POST" class="pf-c-form">
<p> <p>
{% blocktrans with application=application.name branding_title=brand.branding_title %} {% blocktrans with application=application.name branding_title=tenant.branding_title %}
You've logged out of {{ application }}. You can go back to the overview to launch another application, or log out of your {{ branding_title }} account. You've logged out of {{ application }}. You can go back to the overview to launch another application, or log out of your {{ branding_title }} account.
{% endblocktrans %} {% endblocktrans %}
</p> </p>
@ -26,7 +26,7 @@ You've logged out of {{ application }}.
</a> </a>
<a id="logout" href="{% url 'authentik_flows:default-invalidation' %}" class="pf-c-button pf-m-secondary"> <a id="logout" href="{% url 'authentik_flows:default-invalidation' %}" class="pf-c-button pf-m-secondary">
{% blocktrans with branding_title=brand.branding_title %} {% blocktrans with branding_title=tenant.branding_title %}
Log out of {{ branding_title }} Log out of {{ branding_title }}
{% endblocktrans %} {% endblocktrans %}
</a> </a>

View File

@ -4,7 +4,7 @@
{% load i18n %} {% load i18n %}
{% block title %} {% block title %}
{{ brand.branding_title }} {{ tenant.branding_title }}
{% endblock %} {% endblock %}
{% block card_title %} {% block card_title %}

View File

@ -27,7 +27,7 @@ window.authentik.flow = {
{% block body %} {% block body %}
<ak-message-container></ak-message-container> <ak-message-container></ak-message-container>
<ak-flow-executor flowSlug="{{ flow.slug }}"> <ak-flow-executor>
<ak-loading></ak-loading> <ak-loading></ak-loading>
</ak-flow-executor> </ak-flow-executor>
{% endblock %} {% endblock %}

View File

@ -44,14 +44,28 @@
{% block body %} {% block body %}
<div class="pf-c-background-image"> <div class="pf-c-background-image">
<svg xmlns="http://www.w3.org/2000/svg" class="pf-c-background-image__filter" width="0" height="0">
<filter id="image_overlay">
<feColorMatrix in="SourceGraphic" type="matrix" values="1.3 0 0 0 0 0 1.3 0 0 0 0 0 1.3 0 0 0 0 0 1 0" />
<feComponentTransfer color-interpolation-filters="sRGB" result="duotone">
<feFuncR type="table" tableValues="0.086274509803922 0.43921568627451"></feFuncR>
<feFuncG type="table" tableValues="0.086274509803922 0.43921568627451"></feFuncG>
<feFuncB type="table" tableValues="0.086274509803922 0.43921568627451"></feFuncB>
<feFuncA type="table" tableValues="0 1"></feFuncA>
</feComponentTransfer>
</filter>
</svg>
</div> </div>
<ak-message-container></ak-message-container> <ak-message-container></ak-message-container>
<div class="pf-c-login stacked"> <div class="pf-c-login">
<div class="ak-login-container"> <div class="ak-login-container">
<main class="pf-c-login__main"> <header class="pf-c-login__header">
<div class="pf-c-login__main-header pf-c-brand ak-brand"> <div class="pf-c-brand ak-brand">
<img src="{{ brand.branding_logo }}" alt="authentik Logo" /> <img src="{{ tenant.branding_logo }}" alt="authentik Logo" />
</div> </div>
</header>
{% block main_container %}
<main class="pf-c-login__main">
<header class="pf-c-login__main-header"> <header class="pf-c-login__main-header">
<h1 class="pf-c-title pf-m-3xl"> <h1 class="pf-c-title pf-m-3xl">
{% block card_title %} {% block card_title %}
@ -63,6 +77,7 @@
{% endblock %} {% endblock %}
</div> </div>
</main> </main>
{% endblock %}
<footer class="pf-c-login__footer"> <footer class="pf-c-login__footer">
<ul class="pf-c-list pf-m-inline"> <ul class="pf-c-list pf-m-inline">
{% for link in footer_links %} {% for link in footer_links %}

View File

@ -3,10 +3,10 @@ from unittest.mock import MagicMock, patch
from django.urls import reverse from django.urls import reverse
from authentik.brands.models import Brand
from authentik.core.models import Application from authentik.core.models import Application
from authentik.core.tests.utils import create_test_admin_user, create_test_brand, create_test_flow from authentik.core.tests.utils import create_test_admin_user, create_test_flow, create_test_tenant
from authentik.flows.tests import FlowTestCase from authentik.flows.tests import FlowTestCase
from authentik.tenants.models import Tenant
class TestApplicationsViews(FlowTestCase): class TestApplicationsViews(FlowTestCase):
@ -21,9 +21,9 @@ class TestApplicationsViews(FlowTestCase):
def test_check_redirect(self): def test_check_redirect(self):
"""Test redirect""" """Test redirect"""
empty_flow = create_test_flow() empty_flow = create_test_flow()
brand: Brand = create_test_brand() tenant: Tenant = create_test_tenant()
brand.flow_authentication = empty_flow tenant.flow_authentication = empty_flow
brand.save() tenant.save()
response = self.client.get( response = self.client.get(
reverse( reverse(
"authentik_core:application-launch", "authentik_core:application-launch",
@ -45,9 +45,9 @@ class TestApplicationsViews(FlowTestCase):
"""Test redirect""" """Test redirect"""
self.client.force_login(self.user) self.client.force_login(self.user)
empty_flow = create_test_flow() empty_flow = create_test_flow()
brand: Brand = create_test_brand() tenant: Tenant = create_test_tenant()
brand.flow_authentication = empty_flow tenant.flow_authentication = empty_flow
brand.save() tenant.save()
response = self.client.get( response = self.client.get(
reverse( reverse(
"authentik_core:application-launch", "authentik_core:application-launch",

View File

@ -6,7 +6,7 @@ from rest_framework.test import APITestCase
from authentik.core.models import User from authentik.core.models import User
from authentik.core.tests.utils import create_test_admin_user from authentik.core.tests.utils import create_test_admin_user
from authentik.tenants.utils import get_current_tenant from authentik.lib.config import CONFIG
class TestImpersonation(APITestCase): class TestImpersonation(APITestCase):
@ -56,11 +56,9 @@ class TestImpersonation(APITestCase):
response_body = loads(response.content.decode()) response_body = loads(response.content.decode())
self.assertEqual(response_body["user"]["username"], self.other_user.username) self.assertEqual(response_body["user"]["username"], self.other_user.username)
@CONFIG.patch("impersonation", False)
def test_impersonate_disabled(self): def test_impersonate_disabled(self):
"""test impersonation that is disabled""" """test impersonation that is disabled"""
tenant = get_current_tenant()
tenant.impersonation = False
tenant.save()
self.client.force_login(self.user) self.client.force_login(self.user)
response = self.client.post( response = self.client.post(

View File

@ -7,7 +7,6 @@ from django.core.cache import cache
from django.urls.base import reverse from django.urls.base import reverse
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
from authentik.brands.models import Brand
from authentik.core.models import ( from authentik.core.models import (
USER_ATTRIBUTE_TOKEN_EXPIRING, USER_ATTRIBUTE_TOKEN_EXPIRING,
AuthenticatedSession, AuthenticatedSession,
@ -15,10 +14,11 @@ from authentik.core.models import (
User, User,
UserTypes, UserTypes,
) )
from authentik.core.tests.utils import create_test_admin_user, create_test_brand, create_test_flow from authentik.core.tests.utils import create_test_admin_user, create_test_flow, create_test_tenant
from authentik.flows.models import FlowDesignation from authentik.flows.models import FlowDesignation
from authentik.lib.generators import generate_id, generate_key from authentik.lib.generators import generate_id, generate_key
from authentik.stages.email.models import EmailStage from authentik.stages.email.models import EmailStage
from authentik.tenants.models import Tenant
class TestUsersAPI(APITestCase): class TestUsersAPI(APITestCase):
@ -80,9 +80,9 @@ class TestUsersAPI(APITestCase):
def test_recovery(self): def test_recovery(self):
"""Test user recovery link (no recovery flow set)""" """Test user recovery link (no recovery flow set)"""
flow = create_test_flow(FlowDesignation.RECOVERY) flow = create_test_flow(FlowDesignation.RECOVERY)
brand: Brand = create_test_brand() tenant: Tenant = create_test_tenant()
brand.flow_recovery = flow tenant.flow_recovery = flow
brand.save() tenant.save()
self.client.force_login(self.admin) self.client.force_login(self.admin)
response = self.client.get( response = self.client.get(
reverse("authentik_api:user-recovery", kwargs={"pk": self.user.pk}) reverse("authentik_api:user-recovery", kwargs={"pk": self.user.pk})
@ -108,9 +108,9 @@ class TestUsersAPI(APITestCase):
self.user.email = "foo@bar.baz" self.user.email = "foo@bar.baz"
self.user.save() self.user.save()
flow = create_test_flow(designation=FlowDesignation.RECOVERY) flow = create_test_flow(designation=FlowDesignation.RECOVERY)
brand: Brand = create_test_brand() tenant: Tenant = create_test_tenant()
brand.flow_recovery = flow tenant.flow_recovery = flow
brand.save() tenant.save()
self.client.force_login(self.admin) self.client.force_login(self.admin)
response = self.client.get( response = self.client.get(
reverse("authentik_api:user-recovery-email", kwargs={"pk": self.user.pk}) reverse("authentik_api:user-recovery-email", kwargs={"pk": self.user.pk})
@ -122,9 +122,9 @@ class TestUsersAPI(APITestCase):
self.user.email = "foo@bar.baz" self.user.email = "foo@bar.baz"
self.user.save() self.user.save()
flow = create_test_flow(FlowDesignation.RECOVERY) flow = create_test_flow(FlowDesignation.RECOVERY)
brand: Brand = create_test_brand() tenant: Tenant = create_test_tenant()
brand.flow_recovery = flow tenant.flow_recovery = flow
brand.save() tenant.save()
stage = EmailStage.objects.create(name="email") stage = EmailStage.objects.create(name="email")

View File

@ -8,7 +8,6 @@ from rest_framework.test import APITestCase
from authentik.core.models import User from authentik.core.models import User
from authentik.core.tests.utils import create_test_admin_user from authentik.core.tests.utils import create_test_admin_user
from authentik.lib.config import CONFIG from authentik.lib.config import CONFIG
from authentik.tenants.utils import get_current_tenant
class TestUsersAvatars(APITestCase): class TestUsersAvatars(APITestCase):
@ -18,25 +17,18 @@ class TestUsersAvatars(APITestCase):
self.admin = create_test_admin_user() self.admin = create_test_admin_user()
self.user = User.objects.create(username="test-user") self.user = User.objects.create(username="test-user")
def set_avatar_mode(self, mode: str):
"""Set the avatar mode on the current tenant."""
tenant = get_current_tenant()
tenant.avatars = mode
tenant.save()
@CONFIG.patch("avatars", "none") @CONFIG.patch("avatars", "none")
def test_avatars_none(self): def test_avatars_none(self):
"""Test avatars none""" """Test avatars none"""
self.set_avatar_mode("none")
self.client.force_login(self.admin) self.client.force_login(self.admin)
response = self.client.get(reverse("authentik_api:user-me")) response = self.client.get(reverse("authentik_api:user-me"))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
body = loads(response.content.decode()) body = loads(response.content.decode())
self.assertEqual(body["user"]["avatar"], "/static/dist/assets/images/user_default.png") self.assertEqual(body["user"]["avatar"], "/static/dist/assets/images/user_default.png")
@CONFIG.patch("avatars", "gravatar")
def test_avatars_gravatar(self): def test_avatars_gravatar(self):
"""Test avatars gravatar""" """Test avatars gravatar"""
self.set_avatar_mode("gravatar")
self.admin.email = "static@t.goauthentik.io" self.admin.email = "static@t.goauthentik.io"
self.admin.save() self.admin.save()
self.client.force_login(self.admin) self.client.force_login(self.admin)
@ -53,27 +45,27 @@ class TestUsersAvatars(APITestCase):
body = loads(response.content.decode()) body = loads(response.content.decode())
self.assertIn("gravatar", body["user"]["avatar"]) self.assertIn("gravatar", body["user"]["avatar"])
@CONFIG.patch("avatars", "initials")
def test_avatars_initials(self): def test_avatars_initials(self):
"""Test avatars initials""" """Test avatars initials"""
self.set_avatar_mode("initials")
self.client.force_login(self.admin) self.client.force_login(self.admin)
response = self.client.get(reverse("authentik_api:user-me")) response = self.client.get(reverse("authentik_api:user-me"))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
body = loads(response.content.decode()) body = loads(response.content.decode())
self.assertIn("data:image/svg+xml;base64,", body["user"]["avatar"]) self.assertIn("data:image/svg+xml;base64,", body["user"]["avatar"])
@CONFIG.patch("avatars", "foo://%(username)s")
def test_avatars_custom(self): def test_avatars_custom(self):
"""Test avatars custom""" """Test avatars custom"""
self.set_avatar_mode("foo://%(username)s")
self.client.force_login(self.admin) self.client.force_login(self.admin)
response = self.client.get(reverse("authentik_api:user-me")) response = self.client.get(reverse("authentik_api:user-me"))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
body = loads(response.content.decode()) body = loads(response.content.decode())
self.assertEqual(body["user"]["avatar"], f"foo://{self.admin.username}") self.assertEqual(body["user"]["avatar"], f"foo://{self.admin.username}")
@CONFIG.patch("avatars", "attributes.foo.avatar")
def test_avatars_attributes(self): def test_avatars_attributes(self):
"""Test avatars attributes""" """Test avatars attributes"""
self.set_avatar_mode("attributes.foo.avatar")
self.admin.attributes = {"foo": {"avatar": "bar"}} self.admin.attributes = {"foo": {"avatar": "bar"}}
self.admin.save() self.admin.save()
self.client.force_login(self.admin) self.client.force_login(self.admin)
@ -82,9 +74,9 @@ class TestUsersAvatars(APITestCase):
body = loads(response.content.decode()) body = loads(response.content.decode())
self.assertEqual(body["user"]["avatar"], "bar") self.assertEqual(body["user"]["avatar"], "bar")
@CONFIG.patch("avatars", "attributes.foo.avatar,initials")
def test_avatars_fallback(self): def test_avatars_fallback(self):
"""Test fallback""" """Test fallback"""
self.set_avatar_mode("attributes.foo.avatar,initials")
self.client.force_login(self.admin) self.client.force_login(self.admin)
response = self.client.get(reverse("authentik_api:user-me")) response = self.client.get(reverse("authentik_api:user-me"))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)

View File

@ -3,12 +3,12 @@ from typing import Optional
from django.utils.text import slugify from django.utils.text import slugify
from authentik.brands.models import Brand
from authentik.core.models import Group, User from authentik.core.models import Group, User
from authentik.crypto.builder import CertificateBuilder from authentik.crypto.builder import CertificateBuilder
from authentik.crypto.models import CertificateKeyPair from authentik.crypto.models import CertificateKeyPair
from authentik.flows.models import Flow, FlowDesignation from authentik.flows.models import Flow, FlowDesignation
from authentik.lib.generators import generate_id from authentik.lib.generators import generate_id
from authentik.tenants.models import Tenant
def create_test_flow( def create_test_flow(
@ -43,12 +43,12 @@ def create_test_admin_user(name: Optional[str] = None, **kwargs) -> User:
return user return user
def create_test_brand(**kwargs) -> Brand: def create_test_tenant(**kwargs) -> Tenant:
"""Generate a test brand, removing all other brands to make sure this one """Generate a test tenant, removing all other tenants to make sure this one
matches.""" matches."""
uid = generate_id(20) uid = generate_id(20)
Brand.objects.all().delete() Tenant.objects.all().delete()
return Brand.objects.create(domain=uid, default=True, **kwargs) return Tenant.objects.create(domain=uid, default=True, **kwargs)
def create_test_cert(use_ec_private_key=False) -> CertificateKeyPair: def create_test_cert(use_ec_private_key=False) -> CertificateKeyPair:

View File

@ -9,8 +9,8 @@ from rest_framework.request import Request
from authentik import get_build_hash from authentik import get_build_hash
from authentik.admin.tasks import LOCAL_VERSION from authentik.admin.tasks import LOCAL_VERSION
from authentik.api.v3.config import ConfigView from authentik.api.v3.config import ConfigView
from authentik.brands.api import CurrentBrandSerializer
from authentik.flows.models import Flow from authentik.flows.models import Flow
from authentik.tenants.api import CurrentTenantSerializer
class InterfaceView(TemplateView): class InterfaceView(TemplateView):
@ -18,11 +18,10 @@ class InterfaceView(TemplateView):
def get_context_data(self, **kwargs: Any) -> dict[str, Any]: def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
kwargs["config_json"] = dumps(ConfigView(request=Request(self.request)).get_config().data) kwargs["config_json"] = dumps(ConfigView(request=Request(self.request)).get_config().data)
kwargs["brand_json"] = dumps(CurrentBrandSerializer(self.request.brand).data) kwargs["tenant_json"] = dumps(CurrentTenantSerializer(self.request.tenant).data)
kwargs["version_family"] = f"{LOCAL_VERSION.major}.{LOCAL_VERSION.minor}" kwargs["version_family"] = f"{LOCAL_VERSION.major}.{LOCAL_VERSION.minor}"
kwargs["version_subdomain"] = f"version-{LOCAL_VERSION.major}-{LOCAL_VERSION.minor}" kwargs["version_subdomain"] = f"version-{LOCAL_VERSION.major}-{LOCAL_VERSION.minor}"
kwargs["build"] = get_build_hash() kwargs["build"] = get_build_hash()
kwargs["url_kwargs"] = self.kwargs
return super().get_context_data(**kwargs) return super().get_context_data(**kwargs)

View File

@ -16,7 +16,7 @@ class AuthentikCryptoConfig(ManagedAppConfig):
verbose_name = "authentik Crypto" verbose_name = "authentik Crypto"
default = True default = True
def reconcile_global_load_crypto_tasks(self): def reconcile_load_crypto_tasks(self):
"""Load crypto tasks""" """Load crypto tasks"""
self.import_module("authentik.crypto.tasks") self.import_module("authentik.crypto.tasks")
@ -39,7 +39,7 @@ class AuthentikCryptoConfig(ManagedAppConfig):
}, },
) )
def reconcile_tenant_managed_jwt_cert(self): def reconcile_managed_jwt_cert(self):
"""Ensure managed JWT certificate""" """Ensure managed JWT certificate"""
from authentik.crypto.models import CertificateKeyPair from authentik.crypto.models import CertificateKeyPair
@ -52,7 +52,7 @@ class AuthentikCryptoConfig(ManagedAppConfig):
): ):
self._create_update_cert() self._create_update_cert()
def reconcile_tenant_self_signed(self): def reconcile_self_signed(self):
"""Create self-signed keypair""" """Create self-signed keypair"""
from authentik.crypto.builder import CertificateBuilder from authentik.crypto.builder import CertificateBuilder
from authentik.crypto.models import CertificateKeyPair from authentik.crypto.models import CertificateKeyPair

View File

@ -1,22 +1,21 @@
"""Import certificate""" """Import certificate"""
from sys import exit as sys_exit from sys import exit as sys_exit
from django.core.management.base import no_translations from django.core.management.base import BaseCommand, no_translations
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.crypto.api import CertificateKeyPairSerializer from authentik.crypto.api import CertificateKeyPairSerializer
from authentik.crypto.models import CertificateKeyPair from authentik.crypto.models import CertificateKeyPair
from authentik.tenants.management import TenantCommand
LOGGER = get_logger() LOGGER = get_logger()
class Command(TenantCommand): class Command(BaseCommand):
"""Import certificate""" """Import certificate"""
@no_translations @no_translations
def handle_per_tenant(self, *args, **options): def handle(self, *args, **options):
"""Import certificate""" """Import certificate"""
keypair = CertificateKeyPair.objects.filter(name=options["name"]).first() keypair = CertificateKeyPair.objects.filter(name=options["name"]).first()
dirty = False dirty = False

View File

@ -2,11 +2,9 @@
from datetime import datetime, timedelta from datetime import datetime, timedelta
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import gettext as _
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema, inline_serializer from drf_spectacular.utils import extend_schema, inline_serializer
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.fields import BooleanField, CharField, DateTimeField, IntegerField from rest_framework.fields import BooleanField, CharField, DateTimeField, IntegerField
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request from rest_framework.request import Request
@ -22,18 +20,6 @@ from authentik.enterprise.models import License, LicenseKey
from authentik.root.install_id import get_install_id from authentik.root.install_id import get_install_id
class EnterpriseRequiredMixin:
"""Mixin to validate that a valid enterprise license
exists before allowing to safe the object"""
def validate(self, attrs: dict) -> dict:
"""Check that a valid license exists"""
total = LicenseKey.get_total()
if not total.is_valid():
raise ValidationError(_("Enterprise is required to create/update this object."))
return super().validate(attrs)
class LicenseSerializer(ModelSerializer): class LicenseSerializer(ModelSerializer):
"""License Serializer""" """License Serializer"""

View File

@ -2,11 +2,7 @@
from authentik.blueprints.apps import ManagedAppConfig from authentik.blueprints.apps import ManagedAppConfig
class EnterpriseConfig(ManagedAppConfig): class AuthentikEnterpriseConfig(ManagedAppConfig):
"""Base app config for all enterprise apps"""
class AuthentikEnterpriseConfig(EnterpriseConfig):
"""Enterprise app config""" """Enterprise app config"""
name = "authentik.enterprise" name = "authentik.enterprise"
@ -14,6 +10,6 @@ class AuthentikEnterpriseConfig(EnterpriseConfig):
verbose_name = "authentik Enterprise" verbose_name = "authentik Enterprise"
default = True default = True
def reconcile_global_load_enterprise_signals(self): def reconcile_load_enterprise_signals(self):
"""Load enterprise signals""" """Load enterprise signals"""
self.import_module("authentik.enterprise.signals") self.import_module("authentik.enterprise.signals")

View File

@ -1,8 +1,6 @@
"""Enterprise license policies""" """Enterprise license policies"""
from typing import Optional from typing import Optional
from django.utils.translation import gettext_lazy as _
from authentik.core.models import User, UserTypes from authentik.core.models import User, UserTypes
from authentik.enterprise.models import LicenseKey from authentik.enterprise.models import LicenseKey
from authentik.policies.types import PolicyRequest, PolicyResult from authentik.policies.types import PolicyRequest, PolicyResult
@ -15,10 +13,10 @@ class EnterprisePolicyAccessView(PolicyAccessView):
def check_license(self): def check_license(self):
"""Check license""" """Check license"""
if not LicenseKey.get_total().is_valid(): if not LicenseKey.get_total().is_valid():
return PolicyResult(False, _("Enterprise required to access this feature.")) return False
if self.request.user.type != UserTypes.INTERNAL: if self.request.user.type != UserTypes.INTERNAL:
return PolicyResult(False, _("Feature only accessible for internal users.")) return False
return PolicyResult(True) return True
def user_has_access(self, user: Optional[User] = None) -> PolicyResult: def user_has_access(self, user: Optional[User] = None) -> PolicyResult:
user = user or self.request.user user = user or self.request.user
@ -26,7 +24,7 @@ class EnterprisePolicyAccessView(PolicyAccessView):
request.http_request = self.request request.http_request = self.request
result = super().user_has_access(user) result = super().user_has_access(user)
enterprise_result = self.check_license() enterprise_result = self.check_license()
if not enterprise_result.passing: if not enterprise_result:
return enterprise_result return enterprise_result
return result return result

View File

@ -1,135 +0,0 @@
"""RAC Provider API Views"""
from typing import Optional
from django.core.cache import cache
from django.db.models import QuerySet
from django.urls import reverse
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
from rest_framework.fields import SerializerMethodField
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet
from structlog.stdlib import get_logger
from authentik.core.api.used_by import UsedByMixin
from authentik.core.models import Provider
from authentik.enterprise.api import EnterpriseRequiredMixin
from authentik.enterprise.providers.rac.api.providers import RACProviderSerializer
from authentik.enterprise.providers.rac.models import Endpoint
from authentik.policies.engine import PolicyEngine
from authentik.rbac.filters import ObjectFilter
LOGGER = get_logger()
def user_endpoint_cache_key(user_pk: str) -> str:
"""Cache key where endpoint list for user is saved"""
return f"goauthentik.io/providers/rac/endpoint_access/{user_pk}"
class EndpointSerializer(EnterpriseRequiredMixin, ModelSerializer):
"""Endpoint Serializer"""
provider_obj = RACProviderSerializer(source="provider", read_only=True)
launch_url = SerializerMethodField()
def get_launch_url(self, endpoint: Endpoint) -> Optional[str]:
"""Build actual launch URL (the provider itself does not have one, just
individual endpoints)"""
try:
# pylint: disable=no-member
return reverse(
"authentik_providers_rac:start",
kwargs={"app": endpoint.provider.application.slug, "endpoint": endpoint.pk},
)
except Provider.application.RelatedObjectDoesNotExist:
return None
class Meta:
model = Endpoint
fields = [
"pk",
"name",
"provider",
"provider_obj",
"protocol",
"host",
"settings",
"property_mappings",
"auth_mode",
"launch_url",
"maximum_connections",
]
class EndpointViewSet(UsedByMixin, ModelViewSet):
"""Endpoint Viewset"""
queryset = Endpoint.objects.all()
serializer_class = EndpointSerializer
filterset_fields = ["name", "provider"]
search_fields = ["name", "protocol"]
ordering = ["name", "protocol"]
def _filter_queryset_for_list(self, queryset: QuerySet) -> QuerySet:
"""Custom filter_queryset method which ignores guardian, but still supports sorting"""
for backend in list(self.filter_backends):
if backend == ObjectFilter:
continue
queryset = backend().filter_queryset(self.request, queryset, self)
return queryset
def _get_allowed_endpoints(self, queryset: QuerySet) -> list[Endpoint]:
endpoints = []
for endpoint in queryset:
engine = PolicyEngine(endpoint, self.request.user, self.request)
engine.build()
if engine.passing:
endpoints.append(endpoint)
return endpoints
@extend_schema(
parameters=[
OpenApiParameter(
"search",
OpenApiTypes.STR,
),
OpenApiParameter(
name="superuser_full_list",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.BOOL,
),
],
responses={
200: EndpointSerializer(many=True),
400: OpenApiResponse(description="Bad request"),
},
)
def list(self, request: Request, *args, **kwargs) -> Response:
"""List accessible endpoints"""
should_cache = request.GET.get("search", "") == ""
superuser_full_list = str(request.GET.get("superuser_full_list", "false")).lower() == "true"
if superuser_full_list and request.user.is_superuser:
return super().list(request)
queryset = self._filter_queryset_for_list(self.get_queryset())
self.paginate_queryset(queryset)
allowed_endpoints = []
if not should_cache:
allowed_endpoints = self._get_allowed_endpoints(queryset)
if should_cache:
allowed_endpoints = cache.get(user_endpoint_cache_key(self.request.user.pk))
if not allowed_endpoints:
LOGGER.debug("Caching allowed endpoint list")
allowed_endpoints = self._get_allowed_endpoints(queryset)
cache.set(
user_endpoint_cache_key(self.request.user.pk),
allowed_endpoints,
timeout=86400,
)
serializer = self.get_serializer(allowed_endpoints, many=True)
return self.get_paginated_response(serializer.data)

View File

@ -1,49 +0,0 @@
"""RAC Provider API Views"""
from django_filters.filters import AllValuesMultipleFilter
from django_filters.filterset import FilterSet
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field
from rest_framework.fields import CharField
from rest_framework.viewsets import ModelViewSet
from authentik.core.api.propertymappings import PropertyMappingSerializer
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import JSONDictField
from authentik.enterprise.providers.rac.models import RACPropertyMapping
class RACPropertyMappingSerializer(PropertyMappingSerializer):
"""RACPropertyMapping Serializer"""
static_settings = JSONDictField()
expression = CharField(allow_blank=True, required=False)
def validate_expression(self, expression: str) -> str:
"""Test Syntax"""
if expression == "":
return expression
return super().validate_expression(expression)
class Meta:
model = RACPropertyMapping
fields = PropertyMappingSerializer.Meta.fields + ["static_settings"]
class RACPropertyMappingFilter(FilterSet):
"""Filter for RACPropertyMapping"""
managed = extend_schema_field(OpenApiTypes.STR)(AllValuesMultipleFilter(field_name="managed"))
class Meta:
model = RACPropertyMapping
fields = ["name", "managed"]
class RACPropertyMappingViewSet(UsedByMixin, ModelViewSet):
"""RACPropertyMapping Viewset"""
queryset = RACPropertyMapping.objects.all()
serializer_class = RACPropertyMappingSerializer
search_fields = ["name"]
ordering = ["name"]
filterset_class = RACPropertyMappingFilter

View File

@ -1,32 +0,0 @@
"""RAC Provider API Views"""
from rest_framework.fields import CharField, ListField
from rest_framework.viewsets import ModelViewSet
from authentik.core.api.providers import ProviderSerializer
from authentik.core.api.used_by import UsedByMixin
from authentik.enterprise.api import EnterpriseRequiredMixin
from authentik.enterprise.providers.rac.models import RACProvider
class RACProviderSerializer(EnterpriseRequiredMixin, ProviderSerializer):
"""RACProvider Serializer"""
outpost_set = ListField(child=CharField(), read_only=True, source="outpost_set.all")
class Meta:
model = RACProvider
fields = ProviderSerializer.Meta.fields + ["settings", "outpost_set", "connection_expiry"]
extra_kwargs = ProviderSerializer.Meta.extra_kwargs
class RACProviderViewSet(UsedByMixin, ModelViewSet):
"""RACProvider Viewset"""
queryset = RACProvider.objects.all()
serializer_class = RACProviderSerializer
filterset_fields = {
"application": ["isnull"],
"name": ["iexact"],
}
search_fields = ["name"]
ordering = ["name"]

View File

@ -1,17 +0,0 @@
"""RAC app config"""
from authentik.enterprise.apps import EnterpriseConfig
class AuthentikEnterpriseProviderRAC(EnterpriseConfig):
"""authentik enterprise rac app config"""
name = "authentik.enterprise.providers.rac"
label = "authentik_providers_rac"
verbose_name = "authentik Enterprise.Providers.RAC"
default = True
mountpoint = ""
ws_mountpoint = "authentik.enterprise.providers.rac.urls"
def reconcile_global_load_rac_signals(self):
"""Load rac signals"""
self.import_module("authentik.enterprise.providers.rac.signals")

View File

@ -1,163 +0,0 @@
"""RAC Client consumer"""
from asgiref.sync import async_to_sync
from channels.db import database_sync_to_async
from channels.exceptions import ChannelFull, DenyConnection
from channels.generic.websocket import AsyncWebsocketConsumer
from django.http.request import QueryDict
from structlog.stdlib import BoundLogger, get_logger
from authentik.enterprise.providers.rac.models import ConnectionToken, RACProvider
from authentik.outposts.consumer import OUTPOST_GROUP_INSTANCE
from authentik.outposts.models import Outpost, OutpostState, OutpostType
# Global broadcast group, which messages are sent to when the outpost connects back
# to authentik for a specific connection
# The `RACClientConsumer` consumer adds itself to this group on connection,
# and removes itself once it has been assigned a specific outpost channel
RAC_CLIENT_GROUP = "group_enterprise_rac_client"
# A group for all connections in a given authentik session ID
# A disconnect message is sent to this group when the session expires/is deleted
RAC_CLIENT_GROUP_SESSION = "group_enterprise_rac_client_%(session)s"
# A group for all connections with a specific token, which in almost all cases
# is just one connection, however this is used to disconnect the connection
# when the token is deleted
RAC_CLIENT_GROUP_TOKEN = "group_enterprise_rac_token_%(token)s" # nosec
# Step 1: Client connects to this websocket endpoint
# Step 2: We prepare all the connection args for Guac
# Step 3: Send a websocket message to a single outpost that has this provider assigned
# (Currently sending to all of them)
# (Should probably do different load balancing algorithms)
# Step 4: Outpost creates a websocket connection back to authentik
# with /ws/outpost_rac/<our_channel_id>/
# Step 5: This consumer transfers data between the two channels
class RACClientConsumer(AsyncWebsocketConsumer):
"""RAC client consumer the browser connects to"""
dest_channel_id: str = ""
provider: RACProvider
token: ConnectionToken
logger: BoundLogger
async def connect(self):
await self.accept("guacamole")
await self.channel_layer.group_add(RAC_CLIENT_GROUP, self.channel_name)
await self.channel_layer.group_add(
RAC_CLIENT_GROUP_SESSION % {"session": self.scope["session"].session_key},
self.channel_name,
)
await self.init_outpost_connection()
async def disconnect(self, code):
self.logger.debug("Disconnecting")
# Tell the outpost we're disconnecting
await self.channel_layer.send(
self.dest_channel_id,
{
"type": "event.disconnect",
},
)
@database_sync_to_async
def init_outpost_connection(self):
"""Initialize guac connection settings"""
self.token = ConnectionToken.filter_not_expired(
token=self.scope["url_route"]["kwargs"]["token"]
).first()
if not self.token:
raise DenyConnection()
self.provider = self.token.provider
params = self.token.get_settings()
self.logger = get_logger().bind(
endpoint=self.token.endpoint.name, user=self.scope["user"].username
)
msg = {
"type": "event.provider.specific",
"sub_type": "init_connection",
"dest_channel_id": self.channel_name,
"params": params,
"protocol": self.token.endpoint.protocol,
}
query = QueryDict(self.scope["query_string"].decode())
for key in ["screen_width", "screen_height", "screen_dpi", "audio"]:
value = query.get(key, None)
if not value:
continue
msg[key] = str(value)
outposts = Outpost.objects.filter(
type=OutpostType.RAC,
providers__in=[self.provider],
)
if not outposts.exists():
self.logger.warning("Provider has no outpost")
raise DenyConnection()
for outpost in outposts:
# Sort all states for the outpost by connection count
states = sorted(
OutpostState.for_outpost(outpost),
key=lambda state: int(state.args.get("active_connections", 0)),
)
if len(states) < 1:
continue
self.logger.debug("Sending out connection broadcast")
async_to_sync(self.channel_layer.group_send)(
OUTPOST_GROUP_INSTANCE % {"outpost_pk": str(outpost.pk), "instance": states[0].uid},
msg,
)
async def receive(self, text_data=None, bytes_data=None):
"""Mirror data received from client to the dest_channel_id
which is the channel talking to guacd"""
if self.dest_channel_id == "":
return
if self.token.is_expired:
await self.event_disconnect({"reason": "token_expiry"})
return
try:
await self.channel_layer.send(
self.dest_channel_id,
{
"type": "event.send",
"text_data": text_data,
"bytes_data": bytes_data,
},
)
except ChannelFull:
pass
async def event_outpost_connected(self, event: dict):
"""Handle event broadcasted from outpost consumer, and check if they
created a connection for us"""
outpost_channel = event.get("outpost_channel")
if event.get("client_channel") != self.channel_name:
return
if self.dest_channel_id != "":
# We've already selected an outpost channel, so tell the other channel to disconnect
# This should never happen since we remove ourselves from the broadcast group
await self.channel_layer.send(
outpost_channel,
{
"type": "event.disconnect",
},
)
return
self.logger.debug("Connected to a single outpost instance")
self.dest_channel_id = outpost_channel
# Since we have a specific outpost channel now, we can remove
# ourselves from the global broadcast group
await self.channel_layer.group_discard(RAC_CLIENT_GROUP, self.channel_name)
async def event_send(self, event: dict):
"""Handler called by outpost websocket that sends data to this specific
client connection"""
if self.token.is_expired:
await self.event_disconnect({"reason": "token_expiry"})
return
await self.send(text_data=event.get("text_data"), bytes_data=event.get("bytes_data"))
async def event_disconnect(self, event: dict):
"""Disconnect when the session ends"""
self.logger.info("Disconnecting RAC connection", reason=event.get("reason"))
await self.close()

View File

@ -1,48 +0,0 @@
"""RAC consumer"""
from channels.exceptions import ChannelFull
from channels.generic.websocket import AsyncWebsocketConsumer
from authentik.enterprise.providers.rac.consumer_client import RAC_CLIENT_GROUP
class RACOutpostConsumer(AsyncWebsocketConsumer):
"""Consumer the outpost connects to, to send specific data back to a client connection"""
dest_channel_id: str
async def connect(self):
self.dest_channel_id = self.scope["url_route"]["kwargs"]["channel"]
await self.accept()
await self.channel_layer.group_send(
RAC_CLIENT_GROUP,
{
"type": "event.outpost.connected",
"outpost_channel": self.channel_name,
"client_channel": self.dest_channel_id,
},
)
async def receive(self, text_data=None, bytes_data=None):
"""Mirror data received from guacd running in the outpost
to the dest_channel_id which is the channel talking to the browser"""
try:
await self.channel_layer.send(
self.dest_channel_id,
{
"type": "event.send",
"text_data": text_data,
"bytes_data": bytes_data,
},
)
except ChannelFull:
pass
async def event_send(self, event: dict):
"""Handler called by client websocket that sends data to this specific
outpost connection"""
await self.send(text_data=event.get("text_data"), bytes_data=event.get("bytes_data"))
async def event_disconnect(self, event: dict):
"""Tell outpost we're about to disconnect"""
await self.send(text_data="0.authentik.disconnect")
await self.close()

View File

@ -1,11 +0,0 @@
"""RAC Provider Docker Controller"""
from authentik.outposts.controllers.docker import DockerController
from authentik.outposts.models import DockerServiceConnection, Outpost
class RACDockerController(DockerController):
"""RAC Provider Docker Controller"""
def __init__(self, outpost: Outpost, connection: DockerServiceConnection):
super().__init__(outpost, connection)
self.deployment_ports = []

View File

@ -1,13 +0,0 @@
"""RAC Provider Kubernetes Controller"""
from authentik.outposts.controllers.k8s.service import ServiceReconciler
from authentik.outposts.controllers.kubernetes import KubernetesController
from authentik.outposts.models import KubernetesServiceConnection, Outpost
class RACKubernetesController(KubernetesController):
"""RAC Provider Kubernetes Controller"""
def __init__(self, outpost: Outpost, connection: KubernetesServiceConnection):
super().__init__(outpost, connection)
self.deployment_ports = []
del self.reconcilers[ServiceReconciler.reconciler_name()]

View File

@ -1,164 +0,0 @@
# Generated by Django 4.2.8 on 2023-12-29 15:58
import uuid
import django.db.models.deletion
from django.db import migrations, models
import authentik.core.models
import authentik.lib.utils.time
class Migration(migrations.Migration):
initial = True
dependencies = [
("authentik_policies", "0011_policybinding_failure_result_and_more"),
("authentik_core", "0032_group_roles"),
]
operations = [
migrations.CreateModel(
name="RACPropertyMapping",
fields=[
(
"propertymapping_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="authentik_core.propertymapping",
),
),
("static_settings", models.JSONField(default=dict)),
],
options={
"verbose_name": "RAC Property Mapping",
"verbose_name_plural": "RAC Property Mappings",
},
bases=("authentik_core.propertymapping",),
),
migrations.CreateModel(
name="RACProvider",
fields=[
(
"provider_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="authentik_core.provider",
),
),
("settings", models.JSONField(default=dict)),
(
"auth_mode",
models.TextField(
choices=[("static", "Static"), ("prompt", "Prompt")], default="prompt"
),
),
(
"connection_expiry",
models.TextField(
default="hours=8",
help_text="Determines how long a session lasts. Default of 0 means that the sessions lasts until the browser is closed. (Format: hours=-1;minutes=-2;seconds=-3)",
validators=[authentik.lib.utils.time.timedelta_string_validator],
),
),
],
options={
"verbose_name": "RAC Provider",
"verbose_name_plural": "RAC Providers",
},
bases=("authentik_core.provider",),
),
migrations.CreateModel(
name="Endpoint",
fields=[
(
"policybindingmodel_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="authentik_policies.policybindingmodel",
),
),
("name", models.TextField()),
("host", models.TextField()),
(
"protocol",
models.TextField(choices=[("rdp", "Rdp"), ("vnc", "Vnc"), ("ssh", "Ssh")]),
),
("settings", models.JSONField(default=dict)),
(
"auth_mode",
models.TextField(choices=[("static", "Static"), ("prompt", "Prompt")]),
),
(
"property_mappings",
models.ManyToManyField(
blank=True, default=None, to="authentik_core.propertymapping"
),
),
(
"provider",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="authentik_providers_rac.racprovider",
),
),
],
options={
"verbose_name": "RAC Endpoint",
"verbose_name_plural": "RAC Endpoints",
},
bases=("authentik_policies.policybindingmodel", models.Model),
),
migrations.CreateModel(
name="ConnectionToken",
fields=[
(
"expires",
models.DateTimeField(default=authentik.core.models.default_token_duration),
),
("expiring", models.BooleanField(default=True)),
(
"connection_token_uuid",
models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False),
),
("token", models.TextField(default=authentik.core.models.default_token_key)),
("settings", models.JSONField(default=dict)),
(
"endpoint",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="authentik_providers_rac.endpoint",
),
),
(
"provider",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="authentik_providers_rac.racprovider",
),
),
(
"session",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="authentik_core.authenticatedsession",
),
),
],
options={
"abstract": False,
},
),
]

View File

@ -1,17 +0,0 @@
# Generated by Django 5.0 on 2024-01-03 23:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_providers_rac", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="endpoint",
name="maximum_connections",
field=models.IntegerField(default=1),
),
]

View File

@ -1,192 +0,0 @@
"""RAC Models"""
from typing import Optional
from uuid import uuid4
from deepmerge import always_merger
from django.db import models
from django.db.models import QuerySet
from django.utils.translation import gettext as _
from rest_framework.serializers import Serializer
from structlog.stdlib import get_logger
from authentik.core.exceptions import PropertyMappingExpressionException
from authentik.core.models import ExpiringModel, PropertyMapping, Provider, default_token_key
from authentik.events.models import Event, EventAction
from authentik.lib.models import SerializerModel
from authentik.lib.utils.time import timedelta_string_validator
from authentik.policies.models import PolicyBindingModel
LOGGER = get_logger()
class Protocols(models.TextChoices):
"""Supported protocols"""
RDP = "rdp"
VNC = "vnc"
SSH = "ssh"
class AuthenticationMode(models.TextChoices):
"""Authentication modes"""
STATIC = "static"
PROMPT = "prompt"
class RACProvider(Provider):
"""Remotely access computers/servers via RDP/SSH/VNC."""
settings = models.JSONField(default=dict)
auth_mode = models.TextField(
choices=AuthenticationMode.choices, default=AuthenticationMode.PROMPT
)
connection_expiry = models.TextField(
default="hours=8",
validators=[timedelta_string_validator],
help_text=_(
"Determines how long a session lasts. Default of 0 means "
"that the sessions lasts until the browser is closed. "
"(Format: hours=-1;minutes=-2;seconds=-3)"
),
)
@property
def launch_url(self) -> Optional[str]:
"""URL to this provider and initiate authorization for the user.
Can return None for providers that are not URL-based"""
return "goauthentik.io://providers/rac/launch"
@property
def component(self) -> str:
return "ak-provider-rac-form"
@property
def serializer(self) -> type[Serializer]:
from authentik.enterprise.providers.rac.api.providers import RACProviderSerializer
return RACProviderSerializer
class Meta:
verbose_name = _("RAC Provider")
verbose_name_plural = _("RAC Providers")
class Endpoint(SerializerModel, PolicyBindingModel):
"""Remote-accessible endpoint"""
name = models.TextField()
host = models.TextField()
protocol = models.TextField(choices=Protocols.choices)
settings = models.JSONField(default=dict)
auth_mode = models.TextField(choices=AuthenticationMode.choices)
provider = models.ForeignKey("RACProvider", on_delete=models.CASCADE)
maximum_connections = models.IntegerField(default=1)
property_mappings = models.ManyToManyField(
"authentik_core.PropertyMapping", default=None, blank=True
)
@property
def serializer(self) -> type[Serializer]:
from authentik.enterprise.providers.rac.api.endpoints import EndpointSerializer
return EndpointSerializer
def __str__(self):
return f"RAC Endpoint {self.name}"
class Meta:
verbose_name = _("RAC Endpoint")
verbose_name_plural = _("RAC Endpoints")
class RACPropertyMapping(PropertyMapping):
"""Configure settings for remote access endpoints."""
static_settings = models.JSONField(default=dict)
@property
def component(self) -> str:
return "ak-property-mapping-rac-form"
@property
def serializer(self) -> type[Serializer]:
from authentik.enterprise.providers.rac.api.property_mappings import (
RACPropertyMappingSerializer,
)
return RACPropertyMappingSerializer
class Meta:
verbose_name = _("RAC Property Mapping")
verbose_name_plural = _("RAC Property Mappings")
class ConnectionToken(ExpiringModel):
"""Token for a single connection to a specified endpoint"""
connection_token_uuid = models.UUIDField(default=uuid4, primary_key=True)
provider = models.ForeignKey(RACProvider, on_delete=models.CASCADE)
endpoint = models.ForeignKey(Endpoint, on_delete=models.CASCADE)
token = models.TextField(default=default_token_key)
settings = models.JSONField(default=dict)
session = models.ForeignKey("authentik_core.AuthenticatedSession", on_delete=models.CASCADE)
def get_settings(self) -> dict:
"""Get settings"""
default_settings = {}
if ":" in self.endpoint.host:
host, _, port = self.endpoint.host.partition(":")
default_settings["hostname"] = host
default_settings["port"] = str(port)
else:
default_settings["hostname"] = self.endpoint.host
default_settings["client-name"] = "authentik"
# default_settings["enable-drive"] = "true"
# default_settings["drive-name"] = "authentik"
settings = {}
always_merger.merge(settings, default_settings)
always_merger.merge(settings, self.endpoint.provider.settings)
always_merger.merge(settings, self.endpoint.settings)
always_merger.merge(settings, self.settings)
def mapping_evaluator(mappings: QuerySet):
for mapping in mappings:
mapping: RACPropertyMapping
if len(mapping.static_settings) > 0:
always_merger.merge(settings, mapping.static_settings)
continue
try:
mapping_settings = mapping.evaluate(
self.session.user, None, endpoint=self.endpoint, provider=self.provider
)
always_merger.merge(settings, mapping_settings)
except PropertyMappingExpressionException as exc:
Event.new(
EventAction.CONFIGURATION_ERROR,
message=f"Failed to evaluate property-mapping: '{mapping.name}'",
provider=self.provider,
mapping=mapping,
).set_user(self.session.user).save()
LOGGER.warning("Failed to evaluate property mapping", exc=exc)
mapping_evaluator(
RACPropertyMapping.objects.filter(provider__in=[self.provider]).order_by("name")
)
mapping_evaluator(
RACPropertyMapping.objects.filter(endpoint__in=[self.endpoint]).order_by("name")
)
settings["drive-path"] = f"/tmp/connection/{self.token}" # nosec
settings["create-drive-path"] = "true"
# Ensure all values of the settings dict are strings
for key, value in settings.items():
if isinstance(value, str):
continue
# Special case for bools
if isinstance(value, bool):
settings[key] = str(value).lower()
continue
settings[key] = str(value)
return settings

View File

@ -1,54 +0,0 @@
"""RAC Signals"""
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from django.contrib.auth.signals import user_logged_out
from django.core.cache import cache
from django.db.models import Model
from django.db.models.signals import post_save, pre_delete
from django.dispatch import receiver
from django.http import HttpRequest
from authentik.core.models import User
from authentik.enterprise.providers.rac.api.endpoints import user_endpoint_cache_key
from authentik.enterprise.providers.rac.consumer_client import (
RAC_CLIENT_GROUP_SESSION,
RAC_CLIENT_GROUP_TOKEN,
)
from authentik.enterprise.providers.rac.models import ConnectionToken, Endpoint
@receiver(user_logged_out)
def user_logged_out_session(sender, request: HttpRequest, user: User, **_):
"""Disconnect any open RAC connections"""
layer = get_channel_layer()
async_to_sync(layer.group_send)(
RAC_CLIENT_GROUP_SESSION
% {
"session": request.session.session_key,
},
{"type": "event.disconnect", "reason": "session_logout"},
)
@receiver(pre_delete, sender=ConnectionToken)
def pre_delete_connection_token_disconnect(sender, instance: ConnectionToken, **_):
"""Disconnect session when connection token is deleted"""
layer = get_channel_layer()
async_to_sync(layer.group_send)(
RAC_CLIENT_GROUP_TOKEN
% {
"token": instance.token,
},
{"type": "event.disconnect", "reason": "token_delete"},
)
@receiver(post_save, sender=Endpoint)
def post_save_application(sender: type[Model], instance, created: bool, **_):
"""Clear user's application cache upon application creation"""
if not created: # pragma: no cover
return
# Delete user endpoint cache
keys = cache.keys(user_endpoint_cache_key("*"))
cache.delete_many(keys)

View File

@ -1,18 +0,0 @@
{% extends "base/skeleton.html" %}
{% load static %}
{% block head %}
<script src="{% static 'dist/enterprise/rac/index.js' %}?version={{ version }}" type="module"></script>
<meta name="theme-color" content="#18191a" media="(prefers-color-scheme: dark)">
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
<link rel="icon" href="{{ tenant.branding_favicon }}">
<link rel="shortcut icon" href="{{ tenant.branding_favicon }}">
{% include "base/header_js.html" %}
{% endblock %}
{% block body %}
<ak-rac token="{{ url_kwargs.token }}" endpointName="{{ token.endpoint.name }}">
<ak-loading></ak-loading>
</ak-rac>
{% endblock %}

View File

@ -1,171 +0,0 @@
"""Test Endpoints API"""
from django.urls import reverse
from rest_framework.test import APITestCase
from authentik.core.models import Application
from authentik.core.tests.utils import create_test_admin_user
from authentik.enterprise.providers.rac.models import Endpoint, Protocols, RACProvider
from authentik.lib.generators import generate_id
from authentik.policies.dummy.models import DummyPolicy
from authentik.policies.models import PolicyBinding
class TestEndpointsAPI(APITestCase):
"""Test endpoints API"""
def setUp(self) -> None:
self.user = create_test_admin_user()
self.provider = RACProvider.objects.create(
name=generate_id(),
)
self.app = Application.objects.create(
name=generate_id(),
slug=generate_id(),
provider=self.provider,
)
self.allowed = Endpoint.objects.create(
name=f"a-{generate_id()}",
host=generate_id(),
protocol=Protocols.RDP,
provider=self.provider,
)
self.denied = Endpoint.objects.create(
name=f"b-{generate_id()}",
host=generate_id(),
protocol=Protocols.RDP,
provider=self.provider,
)
PolicyBinding.objects.create(
target=self.denied,
policy=DummyPolicy.objects.create(name="deny", result=False, wait_min=1, wait_max=2),
order=0,
)
def test_list(self):
"""Test list operation without superuser_full_list"""
self.client.force_login(self.user)
response = self.client.get(reverse("authentik_api:endpoint-list"))
self.assertJSONEqual(
response.content.decode(),
{
"pagination": {
"next": 0,
"previous": 0,
"count": 2,
"current": 1,
"total_pages": 1,
"start_index": 1,
"end_index": 2,
},
"results": [
{
"pk": str(self.allowed.pk),
"name": self.allowed.name,
"provider": self.provider.pk,
"provider_obj": {
"pk": self.provider.pk,
"name": self.provider.name,
"authentication_flow": None,
"authorization_flow": None,
"property_mappings": [],
"connection_expiry": "hours=8",
"component": "ak-provider-rac-form",
"assigned_application_slug": self.app.slug,
"assigned_application_name": self.app.name,
"verbose_name": "RAC Provider",
"verbose_name_plural": "RAC Providers",
"meta_model_name": "authentik_providers_rac.racprovider",
"settings": {},
"outpost_set": [],
},
"protocol": "rdp",
"host": self.allowed.host,
"maximum_connections": 1,
"settings": {},
"property_mappings": [],
"auth_mode": "",
"launch_url": f"/application/rac/{self.app.slug}/{str(self.allowed.pk)}/",
},
],
},
)
def test_list_superuser_full_list(self):
"""Test list operation with superuser_full_list"""
self.client.force_login(self.user)
response = self.client.get(
reverse("authentik_api:endpoint-list") + "?superuser_full_list=true"
)
self.assertJSONEqual(
response.content.decode(),
{
"pagination": {
"next": 0,
"previous": 0,
"count": 2,
"current": 1,
"total_pages": 1,
"start_index": 1,
"end_index": 2,
},
"results": [
{
"pk": str(self.allowed.pk),
"name": self.allowed.name,
"provider": self.provider.pk,
"provider_obj": {
"pk": self.provider.pk,
"name": self.provider.name,
"authentication_flow": None,
"authorization_flow": None,
"property_mappings": [],
"component": "ak-provider-rac-form",
"assigned_application_slug": self.app.slug,
"assigned_application_name": self.app.name,
"connection_expiry": "hours=8",
"verbose_name": "RAC Provider",
"verbose_name_plural": "RAC Providers",
"meta_model_name": "authentik_providers_rac.racprovider",
"settings": {},
"outpost_set": [],
},
"protocol": "rdp",
"host": self.allowed.host,
"maximum_connections": 1,
"settings": {},
"property_mappings": [],
"auth_mode": "",
"launch_url": f"/application/rac/{self.app.slug}/{str(self.allowed.pk)}/",
},
{
"pk": str(self.denied.pk),
"name": self.denied.name,
"provider": self.provider.pk,
"provider_obj": {
"pk": self.provider.pk,
"name": self.provider.name,
"authentication_flow": None,
"authorization_flow": None,
"property_mappings": [],
"component": "ak-provider-rac-form",
"assigned_application_slug": self.app.slug,
"assigned_application_name": self.app.name,
"connection_expiry": "hours=8",
"verbose_name": "RAC Provider",
"verbose_name_plural": "RAC Providers",
"meta_model_name": "authentik_providers_rac.racprovider",
"settings": {},
"outpost_set": [],
},
"protocol": "rdp",
"host": self.denied.host,
"maximum_connections": 1,
"settings": {},
"property_mappings": [],
"auth_mode": "",
"launch_url": f"/application/rac/{self.app.slug}/{str(self.denied.pk)}/",
},
],
},
)

View File

@ -1,144 +0,0 @@
"""Test RAC Models"""
from django.test import TransactionTestCase
from authentik.core.models import Application, AuthenticatedSession
from authentik.core.tests.utils import create_test_admin_user
from authentik.enterprise.providers.rac.models import (
ConnectionToken,
Endpoint,
Protocols,
RACPropertyMapping,
RACProvider,
)
from authentik.lib.generators import generate_id
class TestModels(TransactionTestCase):
"""Test RAC Models"""
def setUp(self):
self.user = create_test_admin_user()
self.provider = RACProvider.objects.create(
name=generate_id(),
)
self.app = Application.objects.create(
name=generate_id(),
slug=generate_id(),
provider=self.provider,
)
self.endpoint = Endpoint.objects.create(
name=generate_id(),
host=f"{generate_id()}:1324",
protocol=Protocols.RDP,
provider=self.provider,
)
def test_settings_merge(self):
"""Test settings merge"""
token = ConnectionToken.objects.create(
provider=self.provider,
endpoint=self.endpoint,
session=AuthenticatedSession.objects.create(
user=self.user,
session_key=generate_id(),
),
)
path = f"/tmp/connection/{token.token}" # nosec
self.assertEqual(
token.get_settings(),
{
"hostname": self.endpoint.host.split(":")[0],
"port": "1324",
"client-name": "authentik",
"drive-path": path,
"create-drive-path": "true",
},
)
# Set settings in provider
self.provider.settings = {"level": "provider"}
self.provider.save()
self.assertEqual(
token.get_settings(),
{
"hostname": self.endpoint.host.split(":")[0],
"port": "1324",
"client-name": "authentik",
"drive-path": path,
"create-drive-path": "true",
"level": "provider",
},
)
# Set settings in endpoint
self.endpoint.settings = {
"level": "endpoint",
}
self.endpoint.save()
self.assertEqual(
token.get_settings(),
{
"hostname": self.endpoint.host.split(":")[0],
"port": "1324",
"client-name": "authentik",
"drive-path": path,
"create-drive-path": "true",
"level": "endpoint",
},
)
# Set settings in token
token.settings = {
"level": "token",
}
token.save()
self.assertEqual(
token.get_settings(),
{
"hostname": self.endpoint.host.split(":")[0],
"port": "1324",
"client-name": "authentik",
"drive-path": path,
"create-drive-path": "true",
"level": "token",
},
)
# Set settings in property mapping (provider)
mapping = RACPropertyMapping.objects.create(
name=generate_id(),
expression="""return {
"level": "property_mapping_provider"
}""",
)
self.provider.property_mappings.add(mapping)
self.assertEqual(
token.get_settings(),
{
"hostname": self.endpoint.host.split(":")[0],
"port": "1324",
"client-name": "authentik",
"drive-path": path,
"create-drive-path": "true",
"level": "property_mapping_provider",
},
)
# Set settings in property mapping (endpoint)
mapping = RACPropertyMapping.objects.create(
name=generate_id(),
static_settings={
"level": "property_mapping_endpoint",
"foo": True,
"bar": 6,
},
)
self.endpoint.property_mappings.add(mapping)
self.assertEqual(
token.get_settings(),
{
"hostname": self.endpoint.host.split(":")[0],
"port": "1324",
"client-name": "authentik",
"drive-path": path,
"create-drive-path": "true",
"level": "property_mapping_endpoint",
"foo": "true",
"bar": "6",
},
)

View File

@ -1,132 +0,0 @@
"""RAC Views tests"""
from datetime import timedelta
from json import loads
from time import mktime
from unittest.mock import MagicMock, patch
from django.urls import reverse
from django.utils.timezone import now
from rest_framework.test import APITestCase
from authentik.core.models import Application
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
from authentik.enterprise.models import License, LicenseKey
from authentik.enterprise.providers.rac.models import Endpoint, Protocols, RACProvider
from authentik.lib.generators import generate_id
from authentik.policies.denied import AccessDeniedResponse
from authentik.policies.dummy.models import DummyPolicy
from authentik.policies.models import PolicyBinding
class TestRACViews(APITestCase):
"""RAC Views tests"""
def setUp(self):
self.user = create_test_admin_user()
self.flow = create_test_flow()
self.provider = RACProvider.objects.create(name=generate_id(), authorization_flow=self.flow)
self.app = Application.objects.create(
name=generate_id(),
slug=generate_id(),
provider=self.provider,
)
self.endpoint = Endpoint.objects.create(
name=generate_id(),
host=f"{generate_id()}:1324",
protocol=Protocols.RDP,
provider=self.provider,
)
@patch(
"authentik.enterprise.models.LicenseKey.validate",
MagicMock(
return_value=LicenseKey(
aud="",
exp=int(mktime((now() + timedelta(days=3000)).timetuple())),
name=generate_id(),
internal_users=100,
external_users=100,
)
),
)
def test_no_policy(self):
"""Test request"""
License.objects.create(key=generate_id())
self.client.force_login(self.user)
response = self.client.get(
reverse(
"authentik_providers_rac:start",
kwargs={"app": self.app.slug, "endpoint": str(self.endpoint.pk)},
)
)
self.assertEqual(response.status_code, 302)
flow_response = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
)
body = loads(flow_response.content)
next_url = body["to"]
final_response = self.client.get(next_url)
self.assertEqual(final_response.status_code, 200)
@patch(
"authentik.enterprise.models.LicenseKey.validate",
MagicMock(
return_value=LicenseKey(
aud="",
exp=int(mktime((now() + timedelta(days=3000)).timetuple())),
name=generate_id(),
internal_users=100,
external_users=100,
)
),
)
def test_app_deny(self):
"""Test request (deny on app level)"""
PolicyBinding.objects.create(
target=self.app,
policy=DummyPolicy.objects.create(name="deny", result=False, wait_min=1, wait_max=2),
order=0,
)
License.objects.create(key=generate_id())
self.client.force_login(self.user)
response = self.client.get(
reverse(
"authentik_providers_rac:start",
kwargs={"app": self.app.slug, "endpoint": str(self.endpoint.pk)},
)
)
self.assertIsInstance(response, AccessDeniedResponse)
@patch(
"authentik.enterprise.models.LicenseKey.validate",
MagicMock(
return_value=LicenseKey(
aud="",
exp=int(mktime((now() + timedelta(days=3000)).timetuple())),
name=generate_id(),
internal_users=100,
external_users=100,
)
),
)
def test_endpoint_deny(self):
"""Test request (deny on endpoint level)"""
PolicyBinding.objects.create(
target=self.endpoint,
policy=DummyPolicy.objects.create(name="deny", result=False, wait_min=1, wait_max=2),
order=0,
)
License.objects.create(key=generate_id())
self.client.force_login(self.user)
response = self.client.get(
reverse(
"authentik_providers_rac:start",
kwargs={"app": self.app.slug, "endpoint": str(self.endpoint.pk)},
)
)
self.assertEqual(response.status_code, 302)
flow_response = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
)
body = loads(flow_response.content)
self.assertEqual(body["component"], "ak-stage-access-denied")

View File

@ -1,47 +0,0 @@
"""rac urls"""
from channels.auth import AuthMiddleware
from channels.sessions import CookieMiddleware
from django.urls import path
from django.views.decorators.csrf import ensure_csrf_cookie
from authentik.core.channels import TokenOutpostMiddleware
from authentik.enterprise.providers.rac.api.endpoints import EndpointViewSet
from authentik.enterprise.providers.rac.api.property_mappings import RACPropertyMappingViewSet
from authentik.enterprise.providers.rac.api.providers import RACProviderViewSet
from authentik.enterprise.providers.rac.consumer_client import RACClientConsumer
from authentik.enterprise.providers.rac.consumer_outpost import RACOutpostConsumer
from authentik.enterprise.providers.rac.views import RACInterface, RACStartView
from authentik.root.asgi_middleware import SessionMiddleware
from authentik.root.middleware import ChannelsLoggingMiddleware
urlpatterns = [
path(
"application/rac/<slug:app>/<uuid:endpoint>/",
ensure_csrf_cookie(RACStartView.as_view()),
name="start",
),
path(
"if/rac/<str:token>/",
ensure_csrf_cookie(RACInterface.as_view()),
name="if-rac",
),
]
websocket_urlpatterns = [
path(
"ws/rac/<str:token>/",
ChannelsLoggingMiddleware(
CookieMiddleware(SessionMiddleware(AuthMiddleware(RACClientConsumer.as_asgi())))
),
),
path(
"ws/outpost_rac/<str:channel>/",
ChannelsLoggingMiddleware(TokenOutpostMiddleware(RACOutpostConsumer.as_asgi())),
),
]
api_urlpatterns = [
("providers/rac", RACProviderViewSet),
("propertymappings/rac", RACPropertyMappingViewSet),
("rac/endpoints", EndpointViewSet),
]

View File

@ -1,145 +0,0 @@
"""RAC Views"""
from typing import Any
from django.http import Http404, HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.utils.timezone import now
from django.utils.translation import gettext as _
from authentik.core.models import Application, AuthenticatedSession
from authentik.core.views.interface import InterfaceView
from authentik.enterprise.policy import EnterprisePolicyAccessView
from authentik.enterprise.providers.rac.models import ConnectionToken, Endpoint, RACProvider
from authentik.events.models import Event, EventAction
from authentik.flows.challenge import RedirectChallenge
from authentik.flows.exceptions import FlowNonApplicableException
from authentik.flows.models import in_memory_stage
from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, FlowPlanner
from authentik.flows.stage import RedirectStage
from authentik.flows.views.executor import SESSION_KEY_PLAN
from authentik.lib.utils.time import timedelta_from_string
from authentik.lib.utils.urls import redirect_with_qs
from authentik.policies.engine import PolicyEngine
class RACStartView(EnterprisePolicyAccessView):
"""Start a RAC connection by checking access and creating a connection token"""
endpoint: Endpoint
def resolve_provider_application(self):
self.application = get_object_or_404(Application, slug=self.kwargs["app"])
# Endpoint permissions are validated in the RACFinalStage below
self.endpoint = get_object_or_404(Endpoint, pk=self.kwargs["endpoint"])
self.provider = RACProvider.objects.get(application=self.application)
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
"""Start flow planner for RAC provider"""
planner = FlowPlanner(self.provider.authorization_flow)
planner.allow_empty_flows = True
try:
plan = planner.plan(
self.request,
{
PLAN_CONTEXT_APPLICATION: self.application,
},
)
except FlowNonApplicableException:
raise Http404
plan.insert_stage(
in_memory_stage(
RACFinalStage,
application=self.application,
endpoint=self.endpoint,
provider=self.provider,
)
)
request.session[SESSION_KEY_PLAN] = plan
return redirect_with_qs(
"authentik_core:if-flow",
request.GET,
flow_slug=self.provider.authorization_flow.slug,
)
class RACInterface(InterfaceView):
"""Start RAC connection"""
template_name = "if/rac.html"
token: ConnectionToken
def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
# Early sanity check to ensure token still exists
token = ConnectionToken.filter_not_expired(token=self.kwargs["token"]).first()
if not token:
return redirect("authentik_core:if-user")
self.token = token
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
kwargs["token"] = self.token
return super().get_context_data(**kwargs)
class RACFinalStage(RedirectStage):
"""RAC Connection final stage, set the connection token in the stage"""
endpoint: Endpoint
provider: RACProvider
application: Application
def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
self.endpoint = self.executor.current_stage.endpoint
self.provider = self.executor.current_stage.provider
self.application = self.executor.current_stage.application
# Check policies bound to endpoint directly
engine = PolicyEngine(self.endpoint, self.request.user, self.request)
engine.use_cache = False
engine.build()
passing = engine.result
if not passing.passing:
return self.executor.stage_invalid(", ".join(passing.messages))
# Check if we're already at the maximum connection limit
all_tokens = ConnectionToken.filter_not_expired(
endpoint=self.endpoint,
).exclude(endpoint__maximum_connections__lte=-1)
if all_tokens.count() >= self.endpoint.maximum_connections:
msg = [_("Maximum connection limit reached.")]
# Check if any other tokens exist for the current user, and inform them
# they are already connected
if all_tokens.filter(session__user=self.request.user).exists():
msg.append(_("(You are already connected in another tab/window)"))
return self.executor.stage_invalid(" ".join(msg))
return super().dispatch(request, *args, **kwargs)
def get_challenge(self, *args, **kwargs) -> RedirectChallenge:
token = ConnectionToken.objects.create(
provider=self.provider,
endpoint=self.endpoint,
settings=self.executor.plan.context.get("connection_settings", {}),
session=AuthenticatedSession.objects.filter(
session_key=self.request.session.session_key
).first(),
expires=now() + timedelta_from_string(self.provider.connection_expiry),
expiring=True,
)
Event.new(
EventAction.AUTHORIZE_APPLICATION,
authorized_application=self.application,
flow=self.executor.plan.flow_pk,
endpoint=self.endpoint.name,
).from_http(self.request)
setattr(
self.executor.current_stage,
"destination",
self.request.build_absolute_uri(
reverse(
"authentik_providers_rac:if-rac",
kwargs={
"token": str(token.token),
},
)
),
)
return super().get_challenge(*args, **kwargs)

View File

@ -10,7 +10,3 @@ CELERY_BEAT_SCHEDULE = {
"options": {"queue": "authentik_scheduled"}, "options": {"queue": "authentik_scheduled"},
} }
} }
TENANT_APPS = [
"authentik.enterprise.providers.rac",
]

View File

@ -5,8 +5,7 @@ from json import loads
import django_filters import django_filters
from django.db.models.aggregates import Count from django.db.models.aggregates import Count
from django.db.models.fields.json import KeyTextTransform, KeyTransform from django.db.models.fields.json import KeyTextTransform, KeyTransform
from django.db.models.functions import ExtractDay, ExtractHour from django.db.models.functions import ExtractDay
from django.db.models.query_utils import Q
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 guardian.shortcuts import get_objects_for_user
@ -36,7 +35,7 @@ class EventSerializer(ModelSerializer):
"client_ip", "client_ip",
"created", "created",
"expires", "expires",
"brand", "tenant",
] ]
@ -77,10 +76,10 @@ class EventsFilter(django_filters.FilterSet):
field_name="action", field_name="action",
lookup_expr="icontains", lookup_expr="icontains",
) )
brand_name = django_filters.CharFilter( tenant_name = django_filters.CharFilter(
field_name="brand", field_name="tenant",
lookup_expr="name", lookup_expr="name",
label="Brand name", label="Tenant name",
) )
def filter_context_model_pk(self, queryset, name, value): def filter_context_model_pk(self, queryset, name, value):
@ -88,12 +87,7 @@ class EventsFilter(django_filters.FilterSet):
we need to remove the dashes that a client may send. We can't use a we need to remove the dashes that a client may send. We can't use a
UUIDField for this, as some models might not have a UUID PK""" UUIDField for this, as some models might not have a UUID PK"""
value = str(value).replace("-", "") value = str(value).replace("-", "")
query = Q(context__model__pk=value) return queryset.filter(context__model__pk=value)
try:
query |= Q(context__model__pk=int(value))
except ValueError:
pass
return queryset.filter(query)
class Meta: class Meta:
model = Event model = Event
@ -155,15 +149,7 @@ class EventViewSet(ModelViewSet):
return Response(EventTopPerUserSerializer(instance=events, many=True).data) return Response(EventTopPerUserSerializer(instance=events, many=True).data)
@extend_schema( @extend_schema(
responses={200: CoordinateSerializer(many=True)}, methods=["GET"],
)
@action(detail=False, methods=["GET"], pagination_class=None)
def volume(self, request: Request) -> Response:
"""Get event volume for specified filters and timeframe"""
queryset = self.filter_queryset(self.get_queryset())
return Response(queryset.get_events_per(timedelta(days=7), ExtractHour, 7 * 3))
@extend_schema(
responses={200: CoordinateSerializer(many=True)}, responses={200: CoordinateSerializer(many=True)},
filters=[], filters=[],
parameters=[ parameters=[

View File

@ -2,12 +2,11 @@
from prometheus_client import Gauge from prometheus_client import Gauge
from authentik.blueprints.apps import ManagedAppConfig from authentik.blueprints.apps import ManagedAppConfig
from authentik.lib.config import CONFIG, ENV_PREFIX
GAUGE_TASKS = Gauge( GAUGE_TASKS = Gauge(
"authentik_system_tasks", "authentik_system_tasks",
"System tasks and their status", "System tasks and their status",
["tenant", "task_name", "task_uid", "status"], ["task_name", "task_uid", "status"],
) )
@ -19,27 +18,6 @@ class AuthentikEventsConfig(ManagedAppConfig):
verbose_name = "authentik Events" verbose_name = "authentik Events"
default = True default = True
def reconcile_global_load_events_signals(self): def reconcile_load_events_signals(self):
"""Load events signals""" """Load events signals"""
self.import_module("authentik.events.signals") self.import_module("authentik.events.signals")
def reconcile_global_check_deprecations(self):
"""Check for config deprecations"""
from authentik.events.models import Event, EventAction
for key_replace, msg in CONFIG.deprecations.items():
key, replace = key_replace
key_env = f"{ENV_PREFIX}_{key.replace('.', '__')}".upper()
replace_env = f"{ENV_PREFIX}_{replace.replace('.', '__')}".upper()
if Event.objects.filter(
action=EventAction.CONFIGURATION_ERROR, context__deprecated_option=key
).exists():
continue
Event.new(
EventAction.CONFIGURATION_ERROR,
deprecated_option=key,
deprecated_env=key_env,
replacement_option=replace,
replacement_env=replace_env,
message=msg,
).save()

View File

@ -1,81 +0,0 @@
"""ASN Enricher"""
from typing import TYPE_CHECKING, Optional, TypedDict
from django.http import HttpRequest
from geoip2.errors import GeoIP2Error
from geoip2.models import ASN
from sentry_sdk import Hub
from authentik.events.context_processors.mmdb import MMDBContextProcessor
from authentik.lib.config import CONFIG
from authentik.root.middleware import ClientIPMiddleware
if TYPE_CHECKING:
from authentik.api.v3.config import Capabilities
from authentik.events.models import Event
class ASNDict(TypedDict):
"""ASN Details"""
asn: int
as_org: str | None
network: str | None
class ASNContextProcessor(MMDBContextProcessor):
"""ASN Database reader wrapper"""
def capability(self) -> Optional["Capabilities"]:
from authentik.api.v3.config import Capabilities
return Capabilities.CAN_ASN
def path(self) -> str | None:
return CONFIG.get("events.context_processors.asn")
def enrich_event(self, event: "Event"):
asn = self.asn_dict(event.client_ip)
if not asn:
return
event.context["asn"] = asn
def enrich_context(self, request: HttpRequest) -> dict:
return {
"asn": self.asn_dict(ClientIPMiddleware.get_client_ip(request)),
}
def asn(self, ip_address: str) -> Optional[ASN]:
"""Wrapper for Reader.asn"""
with Hub.current.start_span(
op="authentik.events.asn.asn",
description=ip_address,
):
if not self.configured():
return None
self.check_expired()
try:
return self.reader.asn(ip_address)
except (GeoIP2Error, ValueError):
return None
def asn_to_dict(self, asn: ASN | None) -> ASNDict:
"""Convert ASN to dict"""
if not asn:
return {}
asn_dict: ASNDict = {
"asn": asn.autonomous_system_number,
"as_org": asn.autonomous_system_organization,
"network": str(asn.network) if asn.network else None,
}
return asn_dict
def asn_dict(self, ip_address: str) -> Optional[ASNDict]:
"""Wrapper for self.asn that returns a dict"""
asn = self.asn(ip_address)
if not asn:
return None
return self.asn_to_dict(asn)
ASN_CONTEXT_PROCESSOR = ASNContextProcessor()

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