Compare commits

...

93 Commits

Author SHA1 Message Date
authentik-automation[bot]
f83d3a19d0 release: 2025.12.0-rc3 2026-01-06 19:51:06 +00:00
authentik-automation[bot]
ef59ff1856 web: Fix user library colors, modal z-indexes, table progress bars (cherry-pick #19152 to version-2025.12) (#19174)
Cherry-pick #19152 to version-2025.12 (with conflicts)
This cherry-pick has conflicts that need manual resolution.

Original PR: #19152
Original commit: 3838150

Co-authored-by: Teffen Ellis <teffen@goauthentik.io>
2026-01-06 19:15:39 +00:00
authentik-automation[bot]
4966225282 outpost/proxyv2: reduce max number of postgres connections (cherry-pick #19211 to version-2025.12) (#19214)
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2026-01-06 19:20:39 +01:00
authentik-automation[bot]
2b8765d0aa web: fix promoted source button hover losing blue color (cherry-pick #19048 to version-2025.12) (#19100)
web: fix promoted source button hover losing blue color (#19048)

Co-authored-by: Dominic R <dominic@sdko.org>
2026-01-06 18:10:50 +00:00
authentik-automation[bot]
d60d06f958 core: handle deserialization errors from FileField migration (cherry-pick #19067 to version-2025.12) (#19168)
Co-authored-by: Dominic R <dominic@sdko.org>
2026-01-06 18:48:35 +01:00
authentik-automation[bot]
1a3f268476 admin/files: support %(theme)s variable in media file paths (cherry-pick #19108 to version-2025.12) (#19213)
Co-authored-by: Dominic R <dominic@sdko.org>
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2026-01-06 14:32:12 +01:00
authentik-automation[bot]
515a855c40 web/admin: adjust sync threshold, add tooltip (cherry-pick #19131 to version-2025.12) (#19175)
Co-authored-by: Jens L. <jens@goauthentik.io>
2026-01-06 13:48:37 +01:00
authentik-automation[bot]
16d65b8d12 rbac: Add show all to roles tab, add role tab to groups (cherry-pick #19097 to version-2025.12) (#19199)
rbac: Add show all to roles tab, add role tab to groups (#19097)

* improve sort order and inherit visual

* Update web/src/admin/groups/GroupViewPage.ts




* Update web/src/admin/users/UserViewPage.ts




* Update web/src/admin/roles/RelatedRoleList.ts




* Update web/src/admin/roles/RelatedRoleList.ts




* Update web/src/admin/roles/RelatedRoleList.ts




* Update web/src/admin/roles/RelatedRoleList.ts




* setup include inherited roles and fix returning nothing

* update api calls

* fix rendering error

* do not use set

* change from exception handling

* go off query param

* fix wording

* fix linting error for new group api structure

---------

Signed-off-by: Connor Peshek <connor@connorpeshek.me>
Co-authored-by: Connor Peshek <connor@connorpeshek.me>
Co-authored-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com>
Co-authored-by: Marcelo Elizeche Landó <marcelo@goauthentik.io>
2026-01-06 04:01:06 +00:00
authentik-automation[bot]
bfe928df18 web: Defer table refresh, visibility checks. (cherry-pick #19194 to version-2025.12) (#19198)
* web: Fix user library colors, modal z-indexes, table progress bars (#19152)

* Fix progress bar fade out, positioning, labels.

* Export parts. Fix z-index, colors.

* Fix clickable area.

* Ignore clickable icons.

* web: Defer table refresh, visibility checks. (#19194)

Fix types, args.

---------

Co-authored-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com>
2026-01-05 23:38:47 +00:00
authentik-automation[bot]
c447bbe6c8 web: Merge branch -- Stale notifications, synchronized context objects, rendering fixes (cherry-pick #19141 to version-2025.12) (#19197)
Cherry-pick #19141 to version-2025.12 (with conflicts)

This cherry-pick has conflicts that need manual resolution.

Original PR: #19141
Original commit: 2c813cbe03

Co-authored-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com>
2026-01-05 23:13:08 +00:00
authentik-automation[bot]
1c0a3f95df core: add prettier failure on duplicate group names (cherry-pick #18941 to version-2025.12) (#19193)
core: add prettier failure on duplicate group names (#18941)

* core: add prettier failure on duplicate group names

* add db_alias




* lint

* migrate to system migration



* fix error on empty database



* returning a count of 0 still takes 1 row :P

---------

Signed-off-by: Simonyi Gergő <28359278+gergosimonyi@users.noreply.github.com>
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Simonyi Gergő <28359278+gergosimonyi@users.noreply.github.com>
Co-authored-by: Jens L. <jens@goauthentik.io>
2026-01-05 18:44:03 +01:00
authentik-automation[bot]
8a6116ab79 lifecycle: fix migration conn_options for psycopg connection (cherry-pick #19134 to version-2025.12) (#19186)
Co-authored-by: Duncan Tasker <72562945+D-Tasker207@users.noreply.github.com>
fix migration conn_options for psycopg connection (#19134)
2026-01-05 15:21:49 +01:00
authentik-automation[bot]
430010fbea website/docs: remove duplicates in slo docs (cherry-pick #19170 to version-2025.12) (#19177)
website/docs: remove duplicates in slo docs (#19170)

remove duplicated points in the iframe mode points in slo docs

Co-authored-by: Adithya S Narasinghe <adithyasnarasinghe@gmail.com>
2026-01-04 21:41:54 +00:00
authentik-automation[bot]
079b575a45 web: fix slug auto-updating when editing existing applications (cherry-pick #19169 to version-2025.12) (#19173)
web: fix slug auto-updating when editing existing applications (#19169)

Co-authored-by: Dominic R <dominic@sdko.org>
2026-01-04 04:23:21 +00:00
authentik-automation[bot]
b2ca887d59 website/docs: endpoint agent release notes (cherry-pick #19042 to version-2025.12) (#19146)
website/docs: endpoint agent release notes (#19042)

* website/docs: endpoint agent release notes



* Apply suggestion from @dominic-r



* rename, update



---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Signed-off-by: Dominic R <dominic@sdko.org>
Co-authored-by: Jens L. <jens@goauthentik.io>
Co-authored-by: Dominic R <dominic@sdko.org>
2026-01-03 21:35:39 +01:00
authentik-automation[bot]
d7b30ad0d7 web: Token Form Fixes (cherry-pick #19121 to version-2025.12) (#19153)
web: Token Form Fixes (#19121)

* Fix autofocus attribute.

* web: Fix label alignment, focus handlers, edit states.

* Tidy date functions.

* Use Dates over strings.

Co-authored-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com>
2026-01-03 02:20:12 +00:00
authentik-automation[bot]
b084ace1dd web/user: fix consent delete form missing details (cherry-pick #19147 to version-2025.12) (#19156)
web/user: fix consent delete form missing details (#19147)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2026-01-02 12:27:56 +01:00
authentik-automation[bot]
b3e45cdf1a website/docs: fix build (cherry-pick #19148 to version-2025.12) (#19151)
website/docs: fix build (#19148)

* ensure we never throw errors in the browser



* cleaner



* rework



* fix misleading variable



* Tidy behavior.

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
Co-authored-by: Teffen Ellis <teffen@goauthentik.io>
2026-01-01 18:53:33 +00:00
authentik-automation[bot]
8132e1f7d9 web: Capitalize language display names, code owner fix (cherry-pick #19119 to version-2025.12) (#19122)
web: Capitalize language display names, code owner fix (#19119)

* web: Capitalize locale display names.

* Fix broad code owner.

Co-authored-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com>
2025-12-30 20:50:42 -05:00
authentik-automation[bot]
149dccf244 web: fix file search input not resetting results properly (cherry-pick #19034 to version-2025.12) (#19075)
web: fix file search input not resetting results properly (#19034)

Co-authored-by: Dominic R <dominic@sdko.org>
Co-authored-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com>
Co-authored-by: Marcelo Elizeche Landó <marcelo@goauthentik.io>
2025-12-30 20:18:23 +00:00
authentik-automation[bot]
b5e4797761 web: Fix stale flow background (cherry-pick #19015 to version-2025.12) (#19101)
web: Fix stale flow background (#19015)

Co-authored-by: Dominic R <dominic@sdko.org>
2025-12-30 19:31:58 +00:00
authentik-automation[bot]
be670d6253 web: Fix Impersonation, Lit Reactive Controller Contexts (cherry-pick #19114 to version-2025.12) (#19117)
web: Fix Impersonation, Lit Reactive Controller Contexts (#19114)

* web: Fix issue where impersonation does not trigger updates.

* web: Fix issues surrounding abort controller types, lifecycle.

Co-authored-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com>
2025-12-30 17:06:25 +00:00
authentik-automation[bot]
71060ea4e7 website/docs: release notes: Add more integrations (cherry-pick #19109 to version-2025.12) (#19115)
website/docs: release notes: Add more integrations (#19109)

Co-authored-by: Dominic R <dominic@sdko.org>
2025-12-30 08:50:05 +00:00
authentik-automation[bot]
f60f38280c website/docs: endpoints: mention connector key required for stage to work (cherry-pick #19084 to version-2025.12) (#19095)
website/docs: endpoints: mention connector key required for stage to work (#19084)

keypair = CertificateKeyPair.objects.filter(pk=stage.connector.challenge_key_id).first()
  if not keypair:
      return self.executor.stage_ok()  # < --- skips the stage

took me a bit of time to find this and yea

Co-authored-by: Dominic R <dominic@sdko.org>
2025-12-29 20:59:38 +01:00
authentik-automation[bot]
418deeb332 website/docs: endpoint devices: add path to macos setup (cherry-pick #19093 to version-2025.12) (#19099)
website/docs: endpoint devices: add path to macos setup (#19093)

* Add path

* Update macos.md



---------

Signed-off-by: Dewi Roberts <dewi@goauthentik.io>
Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
2025-12-29 20:59:18 +01:00
authentik-automation[bot]
619c77c27e web/admin: use consistent icon for inactive user status (cherry-pick #19032 to version-2025.12) (#19035)
Co-authored-by: Dominic R <dominic@sdko.org>
2025-12-29 08:26:45 -08:00
authentik-automation[bot]
ddfddb49da website/docs: endpoint devices: update features table (cherry-pick #19094 to version-2025.12) (#19098)
website/docs: endpoint devices: update features table (#19094)

* Update table

* Remove wording

Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
2025-12-29 13:35:29 +00:00
authentik-automation[bot]
dbbb1870b7 website/docs: rel notes .12: add wallos (cherry-pick #19063 to version-2025.12) (#19096)
website/docs: rel notes .12: add wallos (#19063)

Co-authored-by: Dominic R <dominic@sdko.org>
2025-12-29 12:43:03 +00:00
authentik-automation[bot]
5b43301206 docs/release notes: update 2025.12 release notes (cherry-pick #19043 to version-2025.12) (#19046)
docs/release notes: update 2025.12 release notes (#19043)

* Add links and tags

* Update website/docs/releases/2025/v2025.12.md




---------

Signed-off-by: Marcelo Elizeche Landó <marce@melizeche.com>
Co-authored-by: Marcelo Elizeche Landó <marcelo@goauthentik.io>
Co-authored-by: Dominic R <dominic@sdko.org>
2025-12-29 05:09:34 +00:00
authentik-automation[bot]
d915d1a94a web/admin: fix button alignment on user view page (cherry-pick #19079 to version-2025.12) (#19081)
web/admin: fix button alignment on user view page (#19079)

* web/admin: fix button alignment on user view page



* fix width



---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2025-12-28 19:58:29 +01:00
authentik-automation[bot]
786497790a internal: update TLS Suite (cherry-pick #19076 to version-2025.12) (#19078)
internal: update TLS Suite (#19076)

* internal: update TLS Suite



* disable chacha20 due to fips



---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2025-12-28 15:19:46 +01:00
authentik-automation[bot]
56c899cf21 blueprints: set enrollment token key (cherry-pick #19061 to version-2025.12) (#19062)
blueprints: set enrollment token key (#19061)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2025-12-26 23:42:27 +01:00
authentik-automation[bot]
943f22e5a9 blueprints: fix deadlock and task context error in MetaApplyBlueprint (cherry-pick #19033 to version-2025.12) (#19068)
blueprints: fix deadlock and task context error in MetaApplyBlueprint (#19033)

Co-authored-by: Dominic R <dominic@sdko.org>
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
2025-12-26 23:41:22 +01:00
authentik-automation[bot]
11b45689f4 blueprints: fix flaky tests (cherry-pick #19002 to version-2025.12) (#19059)
blueprints: fix flaky tests (#19002)

* blueprints: attempt to fix tests



* fix postgres debug logging



---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2025-12-26 16:11:21 +01:00
authentik-automation[bot]
87f443532f endpoints/devices: cleanup (cherry-pick #19047 to version-2025.12) (#19057)
* endpoints/devices: cleanup  (#19047)

* endpoints: make device token internally managed

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

* fix text and defaults for agent

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

* re-org some code

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

---------

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

* fix

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

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2025-12-26 15:53:23 +01:00
authentik-automation[bot]
0c672a0c37 lib/sync: fix sync_dispatch (cherry-pick #19053 to version-2025.12) (#19056)
lib/sync: fix sync_dispatch (#19053)

* fix: add missing call to all on self.schedules

Fixes #19051

* fix: change the name of syncOutgoingTriggerMode ak-radio-input

Fixes #19052

Co-authored-by: Amélie Lilith Krejčí <krejcar25@blep.cz>
2025-12-26 14:20:34 +01:00
authentik-automation[bot]
dfd11ceb57 events: notifications live update (cherry-pick #18980 to version-2025.12) (#18990)
events: notifications live update (#18980)

* this has been broken for a while but no one noticed...? cc @rissson



* send WS broadcast for new notifications



* add tests



* better layout



* fix e2e tests



---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2025-12-24 14:29:14 +01:00
authentik-automation[bot]
d865b7fd87 core: use chunked_queryset for expired message deletion (cherry-pick #19028 to version-2025.12) (#19031)
core: use chunked_queryset for expired message deletion (#19028)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2025-12-24 01:56:23 +01:00
authentik-automation[bot]
aa8a6b9c43 web: Locale selector UI fixes (cherry-pick #18972 to version-2025.12) (#19027)
web: Locale selector UI fixes (#18972)

* Fix alignment, focus.

* Clean up.

* Tidy click area.

* Fix compatibility mode.

* Fix alignment.

* Fix issues surrounding labels, alignment, consistency.

* Update web/src/common/ui/locale/format.ts



* Tidy hover states.

* Tidy.

* Clean up parsing.

* Tidy comments, usage.

* Always use script naming over region.

* Remove unused.

* Spacing.

---------

Signed-off-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com>
Co-authored-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com>
2025-12-23 16:22:32 -05:00
Jens L.
fe5313f42e ci: ensure disk space is available (#19025)
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-12-23 17:57:03 +01:00
authentik-automation[bot]
499f739e2b website/docs: Prioritize "Release Candidate" over "Current Release" (cherry-pick #18975 to version-2025.12) (#19022)
website/docs: Prioritize "Release Candidate" over "Current Release" (#18975)

Normalize labels.

Co-authored-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com>
2025-12-23 16:28:23 +00:00
authentik-automation[bot]
4e0e738823 web/admin: prevent file upload attempt when backend not managed (cherry-pick #18646 to version-2025.12) (#19021)
web/admin: prevent file upload attempt when backend not managed (#18646)

* web/admin: prevent file upload attempt when backend not managed



* wip

* fixup

* rework



* format



* add check for reports



* fix delete table for data exports missing details



---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
Co-authored-by: Dominic R <dominic@sdko.org>
2025-12-23 14:47:24 +01:00
authentik-automation[bot]
24360bf306 web: fix Open button selecting row instead of navigating (cherry-pick #18992 to version-2025.12) (#19003)
web: fix Open button selecting row instead of navigating (#18992)

the `isEventTargetingListener()` function only checked the click target and the immediate parent for interactive elements (like links, buttons and more). when clicking the icon inside the Open button, the DOM structure is:

<a href=...>  <--- 2 levels up, never checked
<pf-tooltip>  <--- immediate parent, not interactive
<i> <---- click target, not interactive

Because <i> and <pf-tooltip> did not match the interactive elements query, the function returned false which caused the table rowClickListener to continue with row selection isntead of allowing the click.

The fix is to update the function to to traverse (up) the entire dom tree from the click target to the listener element (the table cell) and check for each ancestor for the interactive elements.

Co-authored-by: Dominic R <dominic@sdko.org>
2025-12-22 23:48:07 +01:00
authentik-automation[bot]
6fad3c2bbd enterprise/search: add static autocomplete structure (cherry-pick #19008 to version-2025.12) (#19011)
enterprise/search: add static autocomplete structure (#19008)

* enterprise/search: add static autocomplete structure



* add recursive structured for context



---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2025-12-22 23:47:44 +01:00
authentik-automation[bot]
2cf20de7ec website/docs: improve endpoint devices docs (cherry-pick #19007 to version-2025.12) (#19012)
website/docs: improve endpoint devices docs (#19007)

* Remove sudo auth sections

* Add firefox extension link

* Add chrome extension

* Update release notes

* Remove link

* Fix link

* Fix release note wording

Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
2025-12-22 20:50:05 +00:00
authentik-automation[bot]
3d8d3bb8ce enterprise/reports: improve export list, confirmation (cherry-pick #18981 to version-2025.12) (#19010)
enterprise/reports: improve export list, confirmation (#18981)

* enterprise/reports: use verbose name for model label



* add confirmation for export



* update docs



* remove duplicated api



* fix duplicate



* fix search query not updated



* exclude page & page size



* improve query display



* fix user display



* exclude unset params



* Apply suggestions from code review




* more code style



* format



* fix types



---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Signed-off-by: Jens L. <jens@beryju.org>
Co-authored-by: Jens L. <jens@goauthentik.io>
Co-authored-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com>
2025-12-22 20:53:51 +01:00
authentik-automation[bot]
80bcbe4885 web/admin: Fix haveibeenpwned link in PasswordPolicyForm (cherry-pick #18984 to version-2025.12) (#18989)
web/admin: Fix haveibeenpwned link in PasswordPolicyForm (#18984)

web: Fix haveibeenpwned link in PasswordPolicyForm

Co-authored-by: Henry Skrtich <1214484+hskrtich@users.noreply.github.com>
2025-12-21 15:46:52 +01:00
authentik-automation[bot]
32e4782ed8 web/admin: fix dark theme on map (cherry-pick #18985 to version-2025.12) (#18987)
web/admin: fix dark theme on map (#18985)

web/admin: fix dark theme on map broken

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2025-12-21 15:44:39 +01:00
authentik-automation[bot]
613a51bdbb web/admin: fix endpoints user binding (cherry-pick #18935 to version-2025.12) (#18952)
web/admin: fix endpoints user binding (#18935)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2025-12-19 17:53:22 +01:00
Teffen Ellis
1c6de43701 website/docs: Backport version picker updates. (#18964)
Fix import path.

Show unlisted entries if release.

Fix sidebar rendering.

Fix positioning of pre-release note. Tidy phrasing.

Clarify pre-release vs draft.

website/docs: Fix version parsing.
2025-12-19 17:07:56 +01:00
authentik-automation[bot]
6771530025 web/admin: add UI copy to RBAC modal (cherry-pick #18917 to version-2025.12) (#18962)
Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
2025-12-19 16:49:32 +01:00
authentik-automation[bot]
5876f367bc website/docs: add note to active directory source doc (cherry-pick #18787 to version-2025.12) (#18966)
Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
Co-authored-by: Dominic R <dominic@sdko.org>
2025-12-19 16:48:20 +01:00
authentik-automation[bot]
e263af2dd9 web/elements: progress-bar and table loading header (cherry-pick #18934 to version-2025.12) (#18939)
web/elements: progress-bar and table loading header (#18934)

* add ak-progress-bar



* make intermediate smaller



* add table



* hide table overflow



---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2025-12-19 00:06:02 +01:00
authentik-automation[bot]
3a59911a2b website/docs: release notes: add endpoint device links to 2025.12 notes (cherry-pick #18940 to version-2025.12) (#18947)
website/docs: release notes: add endpoint device links to 2025.12 notes (#18940)

Add links to release notes

Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
2025-12-18 21:59:53 +00:00
authentik-automation[bot]
bbf31e99c3 website/docs: endpoint devices (cherry-pick #18634 to version-2025.12) (#18946)
website/docs: endpoint devices (#18634)

* Initial

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* Apply suggestions from code review




* Apply suggestions from code review




* Apply suggestions

* Apply suggestions

* Apply suggestions from code review




* Apply suggestions from code review




* WIP

* Apply suggestions from code review




* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* fixes



* WIP

* Optimised images with calibre/image-actions

* Optimised images with calibre/image-actions

* Optimised images with calibre/image-actions

* Optimised images with calibre/image-actions

* Optimised images with calibre/image-actions

* Optimised images with calibre/image-actions

* Optimised images with calibre/image-actions

* Fix anchor

* Update website/docs/endpoint-devices/index.mdx




* WIP

---------

Signed-off-by: Dewi Roberts <dewi@goauthentik.io>
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
Co-authored-by: Dominic R <dominic@sdko.org>
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
2025-12-18 19:40:56 +00:00
authentik-automation[bot]
9d5bd42f3e stages/identification: replace sleep with make_password (cherry-pick #18883 to version-2025.12) (#18943)
Co-authored-by: Jens L. <jens@goauthentik.io>
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2025-12-18 19:10:01 +01:00
authentik-automation[bot]
e721dae6da web/flow: Fix spurious double submit on ak-stage-autosubmit (cherry-pick #18727 to version-2025.12) (#18933)
web/flow: Fix spurious double submit  on ak-stage-autosubmit (#18727)

* Fix double submission on ak-stage-autosubmit

* use updated correctly



---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Victor Nawothnig <dminuoso@icloud.com>
Co-authored-by: Victor Nawothnig <Victor.Nawothnig+git@icloud.com>
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
2025-12-18 16:25:19 +01:00
authentik-automation[bot]
af3106b144 packages/ak-guardian: cast safely (cherry-pick #18929 to version-2025.12) (#18931)
Co-authored-by: Simonyi Gergő <28359278+gergosimonyi@users.noreply.github.com>
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2025-12-18 16:24:59 +01:00
authentik-automation[bot]
5b55103575 tests/e2e: handle StaleElementReferenceException in parse_json_content (cherry-pick #18842 to version-2025.12) (#18919)
Co-authored-by: Marcelo Elizeche Landó <marcelo@goauthentik.io>
2025-12-18 14:08:49 +01:00
authentik-automation[bot]
ee4ecf929f release: 2025.12.0-rc2 2025-12-17 22:03:04 +00:00
authentik-automation[bot]
8336556a6f root: fix docker-compose data mount (cherry-pick #18903 to version-2025.12) (#18918)
root: fix docker-compose data mount (#18903)

Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2025-12-17 19:53:44 +00:00
authentik-automation[bot]
709aad1d3b core/groups: optimize prefetch queries to fetch only required fields (cherry-pick #18448 to version-2025.12) (#18914)
core/groups: optimize prefetch queries to fetch only required fields (#18448)

Co-authored-by: João C. Fernandes <joaocfernandes@gmail.com>
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2025-12-17 17:50:44 +00:00
authentik-automation[bot]
fb7ab4937c web/admin: reword some things on the device view page (cherry-pick #18785 to version-2025.12) (#18913)
Co-authored-by: Jens L. <jens@goauthentik.io>
2025-12-17 17:55:14 +01:00
authentik-automation[bot]
5df1726d80 website/docs: 2025.12: remove superfluous changes (cherry-pick #18910 to version-2025.12) (#18912)
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2025-12-17 17:25:13 +01:00
authentik-automation[bot]
9fdb568843 ci/release-tag: checkout correct branch for make test-docker (cherry-pick #18880 to version-2025.12) (#18911)
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2025-12-17 16:24:56 +01:00
authentik-automation[bot]
8e76f56f89 api: fix latest version for public schema (cherry-pick #18902 to version-2025.12) (#18909)
Co-authored-by: Jens L. <jens@goauthentik.io>
fix latest version for public schema (#18902)
2025-12-17 16:14:16 +01:00
authentik-automation[bot]
05d3791577 website/docs: added list of Int Guide contributors (also edited frontmatter) (cherry-pick #18888 to version-2025.12) (#18907)
Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
Co-authored-by: Dominic R <dominic@sdko.org>
2025-12-17 16:10:50 +01:00
authentik-automation[bot]
d00dd7eb90 api: fix page_size with invalid query param (cherry-pick #18879 to version-2025.12) (#18908)
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
fix page_size with invalid query param (#18879)
2025-12-17 16:10:07 +01:00
authentik-automation[bot]
8d2e404017 stages/authenticator_*: fix code input field not string (cherry-pick #18875 to version-2025.12) (#18906)
Co-authored-by: Jens L. <jens@goauthentik.io>
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
fix code input field not string (#18875)
2025-12-17 16:04:32 +01:00
authentik-automation[bot]
95eb2af25e tasks/middleware: close connections on worker status update database error (cherry-pick #18881 to version-2025.12) (#18905)
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
close connections on worker status update database error (#18881)
2025-12-17 15:46:53 +01:00
authentik-automation[bot]
cbc00a501b web: fix file upload form (cherry-pick #18808 to version-2025.12) (#18884)
Co-authored-by: Dominic R <dominic@sdko.org>
fix file upload form (#18808)
2025-12-17 14:02:31 +01:00
authentik-automation[bot]
480645d897 website/docs: add icon info to style guide (cherry-pick #18832 to version-2025.12) (#18837)
Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
Co-authored-by: Dominic R <dominic@sdko.org>
2025-12-17 14:02:12 +01:00
authentik-automation[bot]
997c767c95 web/admin: endpoint: change wording and add helper text (cherry-pick #18871 to version-2025.12) (#18890)
Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
Co-authored-by: Dominic R <dominic@sdko.org>
Co-authored-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com>
Co-authored-by: Teffen Ellis <teffen@sister.software>
2025-12-17 14:00:02 +01:00
authentik-automation[bot]
5a54e1dc9a web: fix notification counter (cherry-pick #18781 to version-2025.12) (#18882)
Co-authored-by: Alexander Tereshkin <96586+atereshkin@users.noreply.github.com>
fix notification counter (#18781)
2025-12-16 18:44:22 +01:00
authentik-automation[bot]
49b1952566 website/docs: Add docs for passkey autofill (WebauthN Conditional UI) (cherry-pick #18805 to version-2025.12) (#18870)
Co-authored-by: Marcelo Elizeche Landó <marcelo@goauthentik.io>
Co-authored-by: Dominic R <dominic@sdko.org>
2025-12-16 18:11:02 +01:00
authentik-automation[bot]
e73edc2fce web/admin: fix read-only provider selection for application form (cherry-pick #18768 to version-2025.12) (#18803)
Co-authored-by: Dominic R <dominic@sdko.org>
fix read-only provider selection for application form (#18768)
2025-12-16 18:10:49 +01:00
authentik-automation[bot]
409652e874 web: add custom message with links for empty data export list (cherry-pick #18830 to version-2025.12) (#18876)
Co-authored-by: Alexander Tereshkin <96586+atereshkin@users.noreply.github.com>
2025-12-16 18:09:52 +01:00
authentik-automation[bot]
1d3fb6431f website/docs: 2025.10.3 release notes (cherry-pick #18868 to version-2025.12) (#18873)
website/docs: 2025.10.3 release notes (#18868)

* website/docs: 2025.10.3 release notes



* format



---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2025-12-16 17:06:16 +01:00
authentik-automation[bot]
76cfada60f website/docs: adjust RBAC-related details in 2025.12 release notes (cherry-pick #18863 to version-2025.12) (#18869)
website/docs: adjust RBAC-related details in 2025.12 release notes (#18863)

* website/docs: adjust RBAC-related details in 2025.12 release notes

* adjust wording




---------

Signed-off-by: Simonyi Gergő <28359278+gergosimonyi@users.noreply.github.com>
Co-authored-by: Simonyi Gergő <28359278+gergosimonyi@users.noreply.github.com>
Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
2025-12-16 10:39:50 -05:00
authentik-automation[bot]
ac45f80551 outposts: fix permission errors for related certificates (cherry-pick #18861 to version-2025.12) (#18866)
Co-authored-by: Jens L. <jens@goauthentik.io>
fix permission errors for related certificates (#18861)
2025-12-16 15:23:49 +01:00
authentik-automation[bot]
5ea85f086a web/admin/rbac: misc object permission fixes (cherry-pick #18859 to version-2025.12) (#18865)
Co-authored-by: Simonyi Gergő <28359278+gergosimonyi@users.noreply.github.com>
fixes (#18859)
2025-12-16 14:54:31 +01:00
authentik-automation[bot]
e3f657746c rbac: alter migrated direct permission roles (cherry-pick #18860 to version-2025.12) (#18864)
Co-authored-by: Simonyi Gergő <28359278+gergosimonyi@users.noreply.github.com>
2025-12-16 13:56:35 +01:00
authentik-automation[bot]
001b56e2cc release: 2025.12.0-rc1 2025-12-16 04:59:24 +00:00
Marcelo Elizeche Landó
ecbfd2f0de add skip s3_test_server_available to TestResolveFileUrlS3Backend 2025-12-16 01:29:40 -03:00
authentik-automation[bot]
45753397e1 admin/files: fix get_objects_for_user queryset argument in FileUsedByView (cherry-pick #18845 to version-2025.12) (#18847)
admin/files: fix get_objects_for_user queryset argument in FileUsedByView (#18845)

Co-authored-by: Dominic R <dominic@sdko.org>
Co-authored-by: Marcelo Elizeche Landó <marcelo@goauthentik.io>
2025-12-16 01:15:30 +00:00
Marcelo Elizeche Landó
dc6fe1dafe core: skip s3 tests if endpoint isn't available (#18841)
skip s3 tests if endpoint isn't available
2025-12-15 20:38:01 -03:00
authentik-automation[bot]
d5e8f2f416 admin/files: revert add check for /media existence (#18636) (cherry-pick #18829 to version-2025.12) (#18838)
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2025-12-15 16:45:43 +01:00
authentik-automation[bot]
d73af5a2b4 packages/django-dramatiq-postgres: broker: close django connections on consumer close (cherry-pick #18833 to version-2025.12) (#18836)
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Co-authored-by: Norman Ziebal <norman.ziebal@mail.schwarz>
close django connections on consumer close (#18833)
2025-12-15 15:01:08 +01:00
authentik-automation[bot]
7042f2bba8 core: list applications fix (cherry-pick #18798 to version-2025.12) (#18828)
Co-authored-by: Ryan Pesek <44002516+ryanpesek@users.noreply.github.com>
fix (#18798)
2025-12-15 12:47:20 +00:00
authentik-automation[bot]
efeb260fa8 tests/e2e: retry detached shadow roots (cherry-pick #18796 to version-2025.12) (#18799)
tests/e2e: retry detached shadow roots (#18796)

tests(e2e): retry detached shadow roots

Co-authored-by: Marcelo Elizeche Landó <marcelo@goauthentik.io>
2025-12-14 00:50:23 +01:00
authentik-automation[bot]
29e90092ea website/release notes: Update v2025.12 release notes (cherry-pick #18797 to version-2025.12) (#18800)
website/release notes: Update v2025.12 release notes (#18797)

* website/release notes: Update v2025.12 release notes



* fix linting

---------

Signed-off-by: Marcelo Elizeche Landó <marcelo@goauthentik.io>
Co-authored-by: Marcelo Elizeche Landó <marcelo@goauthentik.io>
2025-12-13 00:03:26 +00:00
Marcelo Elizeche Landó
0abe865023 Revert "Update docker compose command to start postgresql with s3"
This reverts commit 220c65a41a.
2025-12-12 20:59:44 -03:00
Marcelo Elizeche Landó
220c65a41a Update docker compose command to start postgresql with s3
Signed-off-by: Marcelo Elizeche Landó <marcelo@goauthentik.io>
2025-12-12 19:47:24 -03:00
280 changed files with 21308 additions and 2590 deletions

View File

@@ -12,13 +12,14 @@ inputs:
runs:
using: "composite"
steps:
- name: Install apt deps
- name: Install apt deps & cleanup
if: ${{ contains(inputs.dependencies, 'system') || contains(inputs.dependencies, 'python') }}
shell: bash
run: |
sudo apt-get remove --purge man-db
sudo apt-get update
sudo apt-get install --no-install-recommends -y libpq-dev openssl libxmlsec1-dev pkg-config gettext libkrb5-dev krb5-kdc krb5-user krb5-admin-server
sudo rm -rf /usr/local/lib/android
- name: Install uv
if: ${{ contains(inputs.dependencies, 'python') }}
uses: astral-sh/setup-uv@ed21f2f24f8dd64503750218de024bcf64c7250a # v5

View File

@@ -20,7 +20,7 @@ runs:
- name: PostgreSQL Logs
shell: bash
run: |
if [[ $ACTIONS_RUNNER_DEBUG == 'true' || $ACTIONS_STEP_DEBUG == 'true' ]]; then
if [[ $RUNNER_DEBUG == '1' ]]; then
docker stop setup-postgresql-1
echo "::group::PostgreSQL Logs"
docker logs setup-postgresql-1

View File

@@ -49,8 +49,12 @@ jobs:
test:
name: Pre-release test
runs-on: ubuntu-latest
needs:
- check-inputs
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
with:
ref: "version-${{ needs.check-inputs.outputs.major_version }}"
- run: make test-docker
bump-authentik:
name: Bump authentik version

View File

@@ -40,7 +40,7 @@ packages/tsconfig @goauthentik/frontend
# Web
web/ @goauthentik/frontend
# Locale
locale/ @goauthentik/backend @goauthentik/frontend
/locale/ @goauthentik/backend @goauthentik/frontend
web/xliff/ @goauthentik/backend @goauthentik/frontend
# Docs
website/ @goauthentik/docs

View File

@@ -327,6 +327,6 @@ ci-pending-migrations: ci--meta-debug
uv run ak makemigrations --check
ci-test: ci--meta-debug
uv run coverage run manage.py test --keepdb --randomly-seed ${CI_TEST_SEED} authentik
uv run coverage run manage.py test --keepdb authentik
uv run coverage report
uv run coverage xml

View File

@@ -3,7 +3,7 @@
from functools import lru_cache
from os import environ
VERSION = "2025.12.0-rc1"
VERSION = "2025.12.0-rc3"
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"

View File

@@ -37,7 +37,7 @@ class VersionSerializer(PassiveSerializer):
def get_version_latest(self, _) -> str:
"""Get latest version from cache"""
if get_current_tenant().schema_name == get_public_schema_name():
if get_current_tenant().schema_name != get_public_schema_name():
return authentik_version()
version_in_cache = cache.get(VERSION_CACHE_KEY)
if not version_in_cache: # pragma: no cover

View File

@@ -240,7 +240,9 @@ class FileUsedByView(APIView):
for field in fields:
q |= Q(**{field: params.get("name")})
objs = get_objects_for_user(request.user, f"{app}.view_{model_name}", model)
objs = get_objects_for_user(
request.user, f"{app}.view_{model_name}", model.objects.all()
)
objs = objs.filter(q)
for obj in objs:
serializer = UsedBySerializer(

View File

@@ -1,9 +1,4 @@
from pathlib import Path
from django.conf import settings
from authentik.blueprints.apps import ManagedAppConfig
from authentik.lib.config import CONFIG
class AuthentikFilesConfig(ManagedAppConfig):
@@ -11,20 +6,3 @@ class AuthentikFilesConfig(ManagedAppConfig):
label = "authentik_admin_files"
verbose_name = "authentik Files"
default = True
@ManagedAppConfig.reconcile_global
def check_for_media_mount(self):
if settings.TEST:
return
from authentik.events.models import Event, EventAction
if (
CONFIG.get("storage.media.backend", CONFIG.get("storage.backend", "file")) == "file"
and Path("/media").exists()
):
Event.new(
EventAction.CONFIGURATION_ERROR,
message="/media has been moved to /data/media. "
"Check the release notes for migration steps.",
).save()

View File

@@ -1,10 +1,13 @@
from unittest import skipUnless
from django.test import TestCase
from authentik.admin.files.tests.utils import FileTestS3BackendMixin
from authentik.admin.files.tests.utils import FileTestS3BackendMixin, s3_test_server_available
from authentik.admin.files.usage import FileUsage
from authentik.lib.config import CONFIG
@skipUnless(s3_test_server_available(), "S3 test server not available")
class TestS3Backend(FileTestS3BackendMixin, TestCase):
"""Test S3 backend functionality"""

View File

@@ -1,10 +1,16 @@
"""Test file service layer"""
from unittest import skipUnless
from django.http import HttpRequest
from django.test import TestCase
from authentik.admin.files.manager import FileManager
from authentik.admin.files.tests.utils import FileTestFileBackendMixin, FileTestS3BackendMixin
from authentik.admin.files.tests.utils import (
FileTestFileBackendMixin,
FileTestS3BackendMixin,
s3_test_server_available,
)
from authentik.admin.files.usage import FileUsage
from authentik.lib.config import CONFIG
@@ -81,6 +87,7 @@ class TestResolveFileUrlFileBackend(FileTestFileBackendMixin, TestCase):
self.assertEqual(result, "http://example.com/files/media/public/test.png")
@skipUnless(s3_test_server_available(), "S3 test server not available")
class TestResolveFileUrlS3Backend(FileTestS3BackendMixin, TestCase):
@CONFIG.patch("storage.media.s3.custom_domain", "s3.test:8080/test")
@CONFIG.patch("storage.media.s3.secure_urls", False)

View File

@@ -62,10 +62,10 @@ class TestSanitizeFilePath(TestCase):
"test@file.png", # @
"test#file.png", # #
"test$file.png", # $
"test%file.png", # %
"test%file.png", # % (but %(theme)s is allowed)
"test&file.png", # &
"test*file.png", # *
"test(file).png", # parentheses
"test(file).png", # parentheses (but %(theme)s is allowed)
"test[file].png", # brackets
"test{file}.png", # braces
]
@@ -108,3 +108,30 @@ class TestSanitizeFilePath(TestCase):
with self.assertRaises(ValidationError):
validate_file_name(path)
def test_sanitize_theme_variable_valid(self):
"""Test sanitizing filename with %(theme)s variable"""
# These should all be valid
validate_file_name("logo-%(theme)s.png")
validate_file_name("brand/logo-%(theme)s.svg")
validate_file_name("images/icon-%(theme)s.png")
validate_file_name("%(theme)s/logo.png")
validate_file_name("brand/%(theme)s/logo.png")
def test_sanitize_theme_variable_multiple(self):
"""Test sanitizing filename with multiple %(theme)s variables"""
validate_file_name("%(theme)s/logo-%(theme)s.png")
def test_sanitize_theme_variable_invalid_format(self):
"""Test that partial or malformed theme variables are rejected"""
invalid_paths = [
"test%(theme.png", # missing )s
"test%theme)s.png", # missing (
"test%(themes).png", # wrong variable name
"test%(THEME)s.png", # wrong case
"test%()s.png", # empty variable name
]
for path in invalid_paths:
with self.assertRaises(ValidationError):
validate_file_name(path)

View File

@@ -1,11 +1,26 @@
import shutil
import socket
from tempfile import mkdtemp
from urllib.parse import urlparse
from authentik.admin.files.backends.s3 import S3Backend
from authentik.admin.files.usage import FileUsage
from authentik.lib.config import CONFIG, UNSET
from authentik.lib.generators import generate_id
S3_TEST_ENDPOINT = "http://localhost:8020"
def s3_test_server_available() -> bool:
"""Check if the S3 test server is reachable."""
parsed = urlparse(S3_TEST_ENDPOINT)
try:
with socket.create_connection((parsed.hostname, parsed.port), timeout=2):
return True
except OSError:
return False
class FileTestFileBackendMixin:
def setUp(self):
@@ -57,7 +72,7 @@ class FileTestS3BackendMixin:
for key in s3_config_keys:
self.original_media_s3_settings[key] = CONFIG.get(f"storage.media.s3.{key}", UNSET)
self.media_s3_bucket_name = f"authentik-test-{generate_id(10)}".lower()
CONFIG.set("storage.media.s3.endpoint", "http://localhost:8020")
CONFIG.set("storage.media.s3.endpoint", S3_TEST_ENDPOINT)
CONFIG.set("storage.media.s3.access_key", "accessKey1")
CONFIG.set("storage.media.s3.secret_key", "secretKey1")
CONFIG.set("storage.media.s3.bucket_name", self.media_s3_bucket_name)
@@ -70,7 +85,7 @@ class FileTestS3BackendMixin:
for key in s3_config_keys:
self.original_reports_s3_settings[key] = CONFIG.get(f"storage.reports.s3.{key}", UNSET)
self.reports_s3_bucket_name = f"authentik-test-{generate_id(10)}".lower()
CONFIG.set("storage.reports.s3.endpoint", "http://localhost:8020")
CONFIG.set("storage.reports.s3.endpoint", S3_TEST_ENDPOINT)
CONFIG.set("storage.reports.s3.access_key", "accessKey1")
CONFIG.set("storage.reports.s3.secret_key", "secretKey1")
CONFIG.set("storage.reports.s3.bucket_name", self.reports_s3_bucket_name)

View File

@@ -12,6 +12,10 @@ from authentik.admin.files.usage import FileUsage
MAX_FILE_NAME_LENGTH = 1024
MAX_PATH_COMPONENT_LENGTH = 255
# Theme variable placeholder that can be used in file paths
# This allows for theme-specific files like logo-%(theme)s.png
THEME_VARIABLE = "%(theme)s"
def validate_file_name(name: str) -> None:
if PassthroughBackend(FileUsage.MEDIA).supports_file(name) or StaticBackend(
@@ -39,12 +43,17 @@ def validate_upload_file_name(
if not name:
raise ValidationError(_("File name cannot be empty"))
# Same regex is used in the frontend as well
if not re.match(r"^[a-zA-Z0-9._/-]+$", name):
# Allow %(theme)s placeholder for theme-specific files
# We temporarily replace it for validation, then check the result
name_for_validation = name.replace(THEME_VARIABLE, "theme")
# Same regex is used in the frontend as well (without %(theme)s handling there)
if not re.match(r"^[a-zA-Z0-9._/-]+$", name_for_validation):
raise ValidationError(
_(
"File name can only contain letters (a-z, A-Z), numbers (0-9), "
"dots (.), hyphens (-), underscores (_), and forward slashes (/)"
"dots (.), hyphens (-), underscores (_), forward slashes (/), "
"and the special placeholder %(theme)s for theme-specific files"
)
)

View File

@@ -15,7 +15,9 @@ class Pagination(pagination.PageNumberPagination):
def get_page_size(self, request):
if self.page_size_query_param in request.query_params:
return min(super().get_page_size(request), request.tenant.pagination_max_page_size)
page_size = super().get_page_size(request)
if page_size is not None:
return min(super().get_page_size(request), request.tenant.pagination_max_page_size)
return request.tenant.pagination_default_page_size
def get_paginated_response(self, data):

View File

@@ -31,6 +31,7 @@ class Capabilities(models.TextChoices):
"""Define capabilities which influence which APIs can/should be used"""
CAN_SAVE_MEDIA = "can_save_media"
CAN_SAVE_REPORTS = "can_save_reports"
CAN_GEO_IP = "can_geo_ip"
CAN_ASN = "can_asn"
CAN_IMPERSONATE = "can_impersonate"
@@ -70,6 +71,8 @@ class ConfigView(APIView):
caps = []
if get_file_manager(FileUsage.MEDIA).manageable:
caps.append(Capabilities.CAN_SAVE_MEDIA)
if get_file_manager(FileUsage.REPORTS).manageable:
caps.append(Capabilities.CAN_SAVE_REPORTS)
for processor in get_context_processors():
if cap := processor.capability():
caps.append(cap)

View File

@@ -8,45 +8,62 @@ metadata:
- Application (icon)
- Source (icon)
- Flow (background)
- Endpoint Enrollment token (key)
entries:
- model: authentik_core.token
identifiers:
identifier: "%(uid)s-token"
attrs:
key: "%(uid)s"
user: "%(user)s"
intent: api
- model: authentik_core.application
identifiers:
slug: "%(uid)s-app"
attrs:
name: "%(uid)s-app"
icon: https://goauthentik.io/img/icon.png
- model: authentik_sources_oauth.oauthsource
identifiers:
slug: "%(uid)s-source"
attrs:
name: "%(uid)s-source"
provider_type: azuread
consumer_key: "%(uid)s"
consumer_secret: "%(uid)s"
icon: https://goauthentik.io/img/icon.png
- model: authentik_flows.flow
identifiers:
slug: "%(uid)s-flow"
attrs:
name: "%(uid)s-flow"
title: "%(uid)s-flow"
designation: authentication
background: https://goauthentik.io/img/icon.png
- model: authentik_core.user
identifiers:
username: "%(uid)s"
attrs:
name: "%(uid)s"
password: "%(uid)s"
- model: authentik_core.user
identifiers:
username: "%(uid)s-no-password"
attrs:
name: "%(uid)s"
token:
- model: authentik_core.token
identifiers:
identifier: "%(uid)s-token"
attrs:
key: "%(uid)s"
user: "%(user)s"
intent: api
app:
- model: authentik_core.application
identifiers:
slug: "%(uid)s-app"
attrs:
name: "%(uid)s-app"
icon: https://goauthentik.io/img/icon.png
source:
- model: authentik_sources_oauth.oauthsource
identifiers:
slug: "%(uid)s-source"
attrs:
name: "%(uid)s-source"
provider_type: azuread
consumer_key: "%(uid)s"
consumer_secret: "%(uid)s"
icon: https://goauthentik.io/img/icon.png
flow:
- model: authentik_flows.flow
identifiers:
slug: "%(uid)s-flow"
attrs:
name: "%(uid)s-flow"
title: "%(uid)s-flow"
designation: authentication
background: https://goauthentik.io/img/icon.png
user:
- model: authentik_core.user
identifiers:
username: "%(uid)s"
attrs:
name: "%(uid)s"
password: "%(uid)s"
- model: authentik_core.user
identifiers:
username: "%(uid)s-no-password"
attrs:
name: "%(uid)s"
endpoint:
- model: authentik_endpoints_connectors_agent.agentconnector
id: connector
identifiers:
name: "%(uid)s"
- model: authentik_endpoints_connectors_agent.enrollmenttoken
identifiers:
name: "%(uid)s"
attrs:
key: "%(uid)s"
connector: !KeyOf connector

View File

@@ -5,6 +5,7 @@ from django.test import TransactionTestCase
from authentik.blueprints.v1.importer import Importer
from authentik.core.models import Token, User
from authentik.core.tests.utils import create_test_admin_user
from authentik.endpoints.connectors.agent.models import EnrollmentToken
from authentik.lib.generators import generate_id
from authentik.lib.tests.utils import load_fixture
@@ -29,12 +30,18 @@ class TestBlueprintsV1ConditionalFields(TransactionTestCase):
def test_user(self):
"""Test user"""
user: User = User.objects.filter(username=self.uid).first()
user = User.objects.filter(username=self.uid).first()
self.assertIsNotNone(user)
self.assertTrue(user.check_password(self.uid))
def test_user_null(self):
"""Test user"""
user: User = User.objects.filter(username=f"{self.uid}-no-password").first()
user = User.objects.filter(username=f"{self.uid}-no-password").first()
self.assertIsNotNone(user)
self.assertFalse(user.has_usable_password())
def test_enrollment_token(self):
"""Test endpoint enrollment token"""
token = EnrollmentToken.objects.filter(name=self.uid).first()
self.assertIsNotNone(token)
self.assertEqual(token.key, self.uid)

View File

@@ -149,7 +149,7 @@ class TestBlueprintsV1Tasks(TransactionTestCase):
instance.status,
BlueprintInstanceStatus.UNKNOWN,
)
apply_blueprint(instance.pk)
apply_blueprint.send(instance.pk).get_result(block=True)
instance.refresh_from_db()
self.assertEqual(instance.last_applied_hash, "")
self.assertEqual(

View File

@@ -37,14 +37,21 @@ class ApplyBlueprintMetaSerializer(PassiveSerializer):
return super().validate(attrs)
def create(self, validated_data: dict) -> MetaResult:
from authentik.blueprints.v1.tasks import apply_blueprint
from authentik.blueprints.v1.importer import Importer
if not self.blueprint_instance:
LOGGER.info("Blueprint does not exist, but not required")
return MetaResult()
LOGGER.debug("Applying blueprint from meta model", blueprint=self.blueprint_instance)
apply_blueprint(self.blueprint_instance.pk)
# Apply blueprint directly using Importer to avoid task context requirements
# and prevent deadlocks when called from within another blueprint task
blueprint_content = self.blueprint_instance.retrieve()
importer = Importer.from_string(blueprint_content, self.blueprint_instance.context)
valid, logs = importer.validate()
[log.log() for log in logs]
if valid:
importer.apply()
return MetaResult()

View File

@@ -12,7 +12,6 @@ from django.db import DatabaseError, InternalError, ProgrammingError
from django.utils.text import slugify
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from django_dramatiq_postgres.middleware import CurrentTaskNotFound
from dramatiq.actor import actor
from dramatiq.middleware import Middleware
from structlog.stdlib import get_logger
@@ -40,7 +39,6 @@ from authentik.events.utils import sanitize_dict
from authentik.lib.config import CONFIG
from authentik.tasks.apps import PRIORITY_HIGH
from authentik.tasks.middleware import CurrentTask
from authentik.tasks.models import Task
from authentik.tasks.schedules.models import Schedule
from authentik.tenants.models import Tenant
@@ -191,10 +189,7 @@ def check_blueprint_v1_file(blueprint: BlueprintFile):
@actor(description=_("Apply single blueprint."))
def apply_blueprint(instance_pk: UUID):
try:
self = CurrentTask.get_task()
except CurrentTaskNotFound:
self = Task()
self = CurrentTask.get_task()
self.set_uid(str(instance_pk))
instance: BlueprintInstance | None = None
try:

View File

@@ -180,10 +180,10 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
)
def _filter_applications_with_launch_url(
self, applications: QuerySet[Application]
self, paginated_apps: QuerySet[Application]
) -> list[Application]:
applications = []
for app in applications:
for app in paginated_apps:
if app.get_launch_url():
applications.append(app)
return applications

View File

@@ -33,6 +33,16 @@ from authentik.endpoints.connectors.agent.auth import AgentAuth
from authentik.rbac.api.roles import RoleSerializer
from authentik.rbac.decorators import permission_required
PARTIAL_USER_SERIALIZER_MODEL_FIELDS = [
"pk",
"username",
"name",
"is_active",
"last_login",
"email",
"attributes",
]
class PartialUserSerializer(ModelSerializer):
"""Partial User Serializer, does not include child relations."""
@@ -42,16 +52,7 @@ class PartialUserSerializer(ModelSerializer):
class Meta:
model = User
fields = [
"pk",
"username",
"name",
"is_active",
"last_login",
"email",
"attributes",
"uid",
]
fields = PARTIAL_USER_SERIALIZER_MODEL_FIELDS + ["uid"]
class RelatedGroupSerializer(ModelSerializer):
@@ -84,6 +85,7 @@ class GroupSerializer(ModelSerializer):
source="roles",
required=False,
)
inherited_roles_obj = SerializerMethodField(allow_null=True)
num_pk = IntegerField(read_only=True)
@property
@@ -107,6 +109,13 @@ class GroupSerializer(ModelSerializer):
return True
return str(request.query_params.get("include_parents", "false")).lower() == "true"
@property
def _should_include_inherited_roles(self) -> bool:
request: Request = self.context.get("request", None)
if not request:
return True
return str(request.query_params.get("include_inherited_roles", "false")).lower() == "true"
@extend_schema_field(PartialUserSerializer(many=True))
def get_users_obj(self, instance: Group) -> list[PartialUserSerializer] | None:
if not self._should_include_users:
@@ -125,6 +134,15 @@ class GroupSerializer(ModelSerializer):
return None
return RelatedGroupSerializer(instance.parents, many=True).data
@extend_schema_field(RoleSerializer(many=True))
def get_inherited_roles_obj(self, instance: Group) -> list | None:
"""Return only inherited roles from ancestor groups (excludes direct roles)"""
if not self._should_include_inherited_roles:
return None
direct_role_pks = instance.roles.values_list("pk", flat=True)
inherited_roles = instance.all_roles().exclude(pk__in=direct_role_pks)
return RoleSerializer(inherited_roles, many=True).data
def validate_is_superuser(self, superuser: bool):
"""Ensure that the user creating this group has permissions to set the superuser flag"""
request: Request = self.context.get("request", None)
@@ -166,6 +184,7 @@ class GroupSerializer(ModelSerializer):
"attributes",
"roles",
"roles_obj",
"inherited_roles_obj",
"children",
"children_obj",
]
@@ -255,14 +274,21 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
return [
StrField(Group, "name"),
BoolField(Group, "is_superuser", nullable=True),
JSONSearchField(Group, "attributes", suggest_nested=False),
JSONSearchField(Group, "attributes"),
]
def get_queryset(self):
base_qs = Group.objects.all().prefetch_related("roles")
if self.serializer_class(context={"request": self.request})._should_include_users:
base_qs = base_qs.prefetch_related("users")
# Only fetch fields needed by PartialUserSerializer to reduce DB load and instantiation
# time
base_qs = base_qs.prefetch_related(
Prefetch(
"users",
queryset=User.objects.all().only(*PARTIAL_USER_SERIALIZER_MODEL_FIELDS),
)
)
else:
base_qs = base_qs.prefetch_related(
Prefetch("users", queryset=User.objects.all().only("id"))
@@ -281,6 +307,7 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
OpenApiParameter("include_users", bool, default=True),
OpenApiParameter("include_children", bool, default=False),
OpenApiParameter("include_parents", bool, default=False),
OpenApiParameter("include_inherited_roles", bool, default=False),
]
)
def list(self, request, *args, **kwargs):
@@ -291,6 +318,7 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
OpenApiParameter("include_users", bool, default=True),
OpenApiParameter("include_children", bool, default=False),
OpenApiParameter("include_parents", bool, default=False),
OpenApiParameter("include_inherited_roles", bool, default=False),
]
)
def retrieve(self, request, *args, **kwargs):

View File

@@ -518,7 +518,7 @@ class UserViewSet(
StrField(User, "path"),
BoolField(User, "is_active", nullable=True),
ChoiceSearchField(User, "type"),
JSONSearchField(User, "attributes", suggest_nested=False),
JSONSearchField(User, "attributes"),
]
def get_queryset(self):

View File

@@ -18,10 +18,9 @@ def migrate_object_permissions(apps: Apps, schema_editor: BaseDatabaseSchemaEdit
RoleModelPermission = apps.get_model("guardian", "RoleModelPermission")
def get_role_for_user_id(user_id: int) -> Role:
name = f"ak-managed-role--user-{user_id}"
name = f"ak-migrated-role--user-{user_id}"
role, created = Role.objects.using(db_alias).get_or_create(
name=name,
managed=name,
)
if created:
role.users.add(user_id)
@@ -32,11 +31,10 @@ def migrate_object_permissions(apps: Apps, schema_editor: BaseDatabaseSchemaEdit
if not role:
# Every django group should already have a role, so this should never happen.
# But let's be nice.
name = f"ak-managed-role--group-{group_id}"
name = f"ak-migrated-role--group-{group_id}"
role, created = Role.objects.using(db_alias).get_or_create(
group_id=group_id,
name=name,
managed=name,
)
if created:
role.group_id = group_id

View File

@@ -66,9 +66,12 @@ class SessionStore(SessionBase):
def decode(self, session_data):
try:
return pickle.loads(session_data) # nosec
except pickle.PickleError:
# ValueError, unpickling exceptions. If any of these happen, just return an empty
# dictionary (an empty session)
except (pickle.PickleError, AttributeError, TypeError):
# PickleError, ValueError - unpickling exceptions
# AttributeError - can happen when Django model fields (e.g., FileField) are unpickled
# and their descriptors fail to initialize (e.g., missing storage)
# TypeError - can happen with incompatible pickled objects
# If any of these happen, just return an empty dictionary (an empty session)
pass
return {}

View File

@@ -35,8 +35,13 @@ def clean_expired_models():
LOGGER.debug("Expired models", model=cls, amount=amount)
self.info(f"Expired {amount} {cls._meta.verbose_name_plural}")
clear_expired_cache()
Message.delete_expired()
GroupChannel.delete_expired()
for cls in [Message, GroupChannel]:
objects = cls.objects.all().filter(expires__lt=now())
amount = objects.count()
for obj in chunked_queryset(objects):
obj.delete()
LOGGER.debug("Expired models", model=cls, amount=amount)
self.info(f"Expired {amount} {cls._meta.verbose_name_plural}")
@actor(description=_("Remove temporary users created by SAML Sources."))

View File

@@ -1,11 +1,13 @@
from typing import cast
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiResponse, extend_schema
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied, ValidationError
from rest_framework.fields import ChoiceField
from rest_framework.permissions import IsAuthenticated
from rest_framework.relations import PrimaryKeyRelatedField
from rest_framework.request import Request
from rest_framework.response import Response
@@ -22,6 +24,9 @@ from authentik.endpoints.connectors.agent.api.agent import (
from authentik.endpoints.connectors.agent.auth import (
AgentAuth,
AgentEnrollmentAuth,
DeviceAuthFedAuthentication,
agent_auth_issue_token,
check_device_policies,
)
from authentik.endpoints.connectors.agent.controller import MDMConfigResponseSerializer
from authentik.endpoints.connectors.agent.models import (
@@ -32,7 +37,10 @@ from authentik.endpoints.connectors.agent.models import (
)
from authentik.endpoints.facts import DeviceFacts, OSFamily
from authentik.endpoints.models import Device
from authentik.events.models import Event, EventAction
from authentik.flows.planner import PLAN_CONTEXT_DEVICE
from authentik.lib.utils.reflection import ConditionalInheritance
from authentik.stages.password.stage import PLAN_CONTEXT_METHOD, PLAN_CONTEXT_METHOD_ARGS
class AgentConnectorSerializer(ConnectorSerializer):
@@ -163,3 +171,43 @@ class AgentConnectorViewSet(
connection: AgentDeviceConnection = token.device
connection.create_snapshot(data.validated_data)
return Response(status=204)
@extend_schema(
request=OpenApiTypes.NONE,
parameters=[OpenApiParameter("device", OpenApiTypes.STR, location="query", required=True)],
responses={
200: AgentTokenResponseSerializer(),
404: OpenApiResponse(description="Device not found"),
},
)
@action(
methods=["POST"],
detail=False,
pagination_class=None,
filter_backends=[],
permission_classes=[IsAuthenticated],
authentication_classes=[DeviceAuthFedAuthentication],
)
def auth_fed(self, request: Request) -> Response:
federated_token, device, connector = request.auth
policy_result = check_device_policies(device, federated_token.user, request._request)
if not policy_result.passing:
raise ValidationError(
{"policy_result": "Policy denied access", "policy_messages": policy_result.messages}
)
token, exp = agent_auth_issue_token(device, connector, federated_token.user)
rel_exp = int((exp - now()).total_seconds())
Event.new(
EventAction.LOGIN,
**{
PLAN_CONTEXT_METHOD: "jwt",
PLAN_CONTEXT_METHOD_ARGS: {
"jwt": federated_token,
"provider": federated_token.provider,
},
PLAN_CONTEXT_DEVICE: device,
},
).from_http(request, user=federated_token.user)
return Response({"token": token, "expires_in": rel_exp})

View File

@@ -1,9 +1,11 @@
from drf_spectacular.utils import OpenApiResponse, extend_schema
from rest_framework.decorators import action
from rest_framework.fields import CharField
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
from authentik.core.api.tokens import TokenViewSerializer
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import ModelSerializer
@@ -19,6 +21,11 @@ class EnrollmentTokenSerializer(ModelSerializer):
source="device_group", read_only=True, required=False
)
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
if SERIALIZER_CONTEXT_BLUEPRINT in self.context:
self.fields["key"] = CharField(required=False)
class Meta:
model = EnrollmentToken
fields = [

View File

@@ -1,13 +1,28 @@
from typing import Any
from django.http import HttpRequest
from django.utils.timezone import now
from drf_spectacular.extensions import OpenApiAuthenticationExtension
from jwt import PyJWTError, decode, encode
from rest_framework.authentication import BaseAuthentication, get_authorization_header
from rest_framework.exceptions import PermissionDenied
from rest_framework.request import Request
from structlog.stdlib import get_logger
from authentik.api.authentication import IPCUser, validate_auth
from authentik.core.middleware import CTX_AUTH_VIA
from authentik.core.models import User
from authentik.endpoints.connectors.agent.models import DeviceToken, EnrollmentToken
from authentik.crypto.apps import MANAGED_KEY
from authentik.crypto.models import CertificateKeyPair
from authentik.endpoints.connectors.agent.models import AgentConnector, DeviceToken, EnrollmentToken
from authentik.endpoints.models import Device
from authentik.lib.utils.time import timedelta_from_string
from authentik.policies.engine import PolicyEngine
from authentik.policies.models import PolicyBindingModel
from authentik.providers.oauth2.models import AccessToken, JWTAlgorithms, OAuth2Provider
LOGGER = get_logger()
PLATFORM_ISSUER = "goauthentik.io/platform"
class DeviceUser(IPCUser):
@@ -40,3 +55,96 @@ class AgentAuth(BaseAuthentication):
raise PermissionDenied()
CTX_AUTH_VIA.set("endpoint_token")
return (DeviceUser(), device_token)
def agent_auth_issue_token(device: Device, connector: AgentConnector, user: User, **kwargs):
kp = CertificateKeyPair.objects.filter(managed=MANAGED_KEY).first()
if not kp:
return None, None
exp = now() + timedelta_from_string(connector.auth_session_duration)
token = encode(
{
"iss": PLATFORM_ISSUER,
"aud": str(device.pk),
"iat": int(now().timestamp()),
"exp": int(exp.timestamp()),
"preferred_username": user.username,
**kwargs,
},
kp.private_key,
headers={
"kid": kp.kid,
},
algorithm=JWTAlgorithms.from_private_key(kp.private_key),
)
return token, exp
class DeviceAuthFedAuthentication(BaseAuthentication):
def authenticate(self, request):
raw_token = validate_auth(get_authorization_header(request))
if not raw_token:
LOGGER.warning("Missing token")
return None
device = Device.filter_not_expired(name=request.query_params.get("device")).first()
if not device:
LOGGER.warning("Couldn't find device")
return None
connectors_for_device = AgentConnector.objects.filter(device__in=[device])
connector = connectors_for_device.first()
providers = OAuth2Provider.objects.filter(agentconnector__in=connectors_for_device)
federated_token = AccessToken.objects.filter(
token=raw_token, provider__in=providers
).first()
if not federated_token:
LOGGER.warning("Couldn't lookup provider")
return None
_key, _alg = federated_token.provider.jwt_key
try:
decode(
raw_token,
_key.public_key(),
algorithms=[_alg],
options={
"verify_aud": False,
},
)
LOGGER.info(
"successfully verified JWT with provider", provider=federated_token.provider.name
)
return (federated_token.user, (federated_token, device, connector))
except (PyJWTError, ValueError, TypeError, AttributeError) as exc:
LOGGER.warning("failed to verify JWT", exc=exc, provider=federated_token.provider.name)
return None
class DeviceFederationAuthSchema(OpenApiAuthenticationExtension):
"""Auth schema"""
target_class = DeviceAuthFedAuthentication
name = "device_federation"
def get_security_definition(self, auto_schema):
"""Auth schema"""
return {"type": "http", "scheme": "bearer"}
def check_device_policies(device: Device, user: User, request: HttpRequest):
"""Check policies bound to device group and device"""
if device.access_group:
result = check_pbm_policies(device.access_group, user, request)
if result.passing:
return result
return check_pbm_policies(device, user, request)
def check_pbm_policies(pbm: PolicyBindingModel, user: User, request: HttpRequest):
policy_engine = PolicyEngine(pbm, user, request)
policy_engine.use_cache = False
policy_engine.empty_result = False
policy_engine.mode = pbm.policy_engine_mode
policy_engine.build()
result = policy_engine.result
LOGGER.debug("PolicyAccessView user_has_access", user=user.username, result=result, pbm=pbm.pk)
return result

View File

@@ -1,6 +1,8 @@
"""Enterprise API Views"""
from collections.abc import Callable
from datetime import timedelta
from functools import wraps
from django.utils.timezone import now
from django.utils.translation import gettext as _
@@ -35,6 +37,18 @@ class EnterpriseRequiredMixin:
return super().validate(attrs)
def enterprise_action(func: Callable):
"""Check permissions for a single custom action"""
@wraps(func)
def wrapper(*args, **kwargs) -> Response:
if not LicenseKey.cached_summary().status.is_valid:
raise ValidationError(_("Enterprise is required to use this endpoint."))
return func(*args, **kwargs)
return wrapper
class LicenseSerializer(ModelSerializer):
"""License Serializer"""

View File

@@ -1,31 +1,20 @@
from django.urls import reverse
from django.utils.timezone import now
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
from drf_spectacular.utils import extend_schema
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from structlog.stdlib import get_logger
from authentik.endpoints.connectors.agent.api.agent import (
AgentAuthenticationResponse,
AgentTokenResponseSerializer,
)
from authentik.endpoints.connectors.agent.auth import AgentAuth
from authentik.endpoints.connectors.agent.models import (
DeviceAuthenticationToken,
DeviceToken,
)
from authentik.enterprise.endpoints.connectors.agent.auth import (
DeviceAuthFedAuthentication,
agent_auth_issue_token,
check_device_policies,
)
from authentik.events.models import Event, EventAction
from authentik.flows.planner import PLAN_CONTEXT_DEVICE
from authentik.stages.password.stage import PLAN_CONTEXT_METHOD, PLAN_CONTEXT_METHOD_ARGS
from authentik.enterprise.api import enterprise_action
LOGGER = get_logger()
@@ -37,6 +26,7 @@ class AgentConnectorViewSetMixin:
responses=AgentAuthenticationResponse(),
)
@action(methods=["POST"], detail=False, authentication_classes=[AgentAuth])
@enterprise_action
def auth_ia(self, request: Request) -> Response:
token: DeviceToken = request.auth
auth_token = DeviceAuthenticationToken.objects.create(
@@ -54,43 +44,3 @@ class AgentConnectorViewSetMixin:
),
}
)
@extend_schema(
request=OpenApiTypes.NONE,
parameters=[OpenApiParameter("device", OpenApiTypes.STR, location="query", required=True)],
responses={
200: AgentTokenResponseSerializer(),
404: OpenApiResponse(description="Device not found"),
},
)
@action(
methods=["POST"],
detail=False,
pagination_class=None,
filter_backends=[],
permission_classes=[IsAuthenticated],
authentication_classes=[DeviceAuthFedAuthentication],
)
def auth_fed(self, request: Request) -> Response:
federated_token, device, connector = request.auth
policy_result = check_device_policies(device, federated_token.user, request._request)
if not policy_result.passing:
raise ValidationError(
{"policy_result": "Policy denied access", "policy_messages": policy_result.messages}
)
token, exp = agent_auth_issue_token(device, connector, federated_token.user)
rel_exp = int((exp - now()).total_seconds())
Event.new(
EventAction.LOGIN,
**{
PLAN_CONTEXT_METHOD: "jwt",
PLAN_CONTEXT_METHOD_ARGS: {
"jwt": federated_token,
"provider": federated_token.provider,
},
PLAN_CONTEXT_DEVICE: device,
},
).from_http(request, user=federated_token.user)
return Response({"token": token, "expires_in": rel_exp})

View File

@@ -1,113 +0,0 @@
from django.http import HttpRequest
from django.utils.timezone import now
from drf_spectacular.extensions import OpenApiAuthenticationExtension
from jwt import PyJWTError, decode, encode
from rest_framework.authentication import BaseAuthentication
from structlog.stdlib import get_logger
from authentik.api.authentication import get_authorization_header, validate_auth
from authentik.core.models import User
from authentik.crypto.apps import MANAGED_KEY
from authentik.crypto.models import CertificateKeyPair
from authentik.endpoints.connectors.agent.models import AgentConnector
from authentik.endpoints.models import Device
from authentik.lib.utils.time import timedelta_from_string
from authentik.policies.engine import PolicyEngine
from authentik.policies.models import PolicyBindingModel
from authentik.providers.oauth2.models import AccessToken, JWTAlgorithms, OAuth2Provider
LOGGER = get_logger()
PLATFORM_ISSUER = "goauthentik.io/platform"
def agent_auth_issue_token(device: Device, connector: AgentConnector, user: User, **kwargs):
kp = CertificateKeyPair.objects.filter(managed=MANAGED_KEY).first()
if not kp:
return None, None
exp = now() + timedelta_from_string(connector.auth_session_duration)
token = encode(
{
"iss": PLATFORM_ISSUER,
"aud": str(device.pk),
"iat": int(now().timestamp()),
"exp": int(exp.timestamp()),
"preferred_username": user.username,
**kwargs,
},
kp.private_key,
headers={
"kid": kp.kid,
},
algorithm=JWTAlgorithms.from_private_key(kp.private_key),
)
return token, exp
class DeviceAuthFedAuthentication(BaseAuthentication):
def authenticate(self, request):
raw_token = validate_auth(get_authorization_header(request))
if not raw_token:
LOGGER.warning("Missing token")
return None
device = Device.filter_not_expired(name=request.query_params.get("device")).first()
if not device:
LOGGER.warning("Couldn't find device")
return None
connectors_for_device = AgentConnector.objects.filter(device__in=[device])
connector = connectors_for_device.first()
providers = OAuth2Provider.objects.filter(agentconnector__in=connectors_for_device)
federated_token = AccessToken.objects.filter(
token=raw_token, provider__in=providers
).first()
if not federated_token:
LOGGER.warning("Couldn't lookup provider")
return None
_key, _alg = federated_token.provider.jwt_key
try:
decode(
raw_token,
_key.public_key(),
algorithms=[_alg],
options={
"verify_aud": False,
},
)
LOGGER.info(
"successfully verified JWT with provider", provider=federated_token.provider.name
)
return (federated_token.user, (federated_token, device, connector))
except (PyJWTError, ValueError, TypeError, AttributeError) as exc:
LOGGER.warning("failed to verify JWT", exc=exc, provider=federated_token.provider.name)
return None
class DeviceFederationAuthSchema(OpenApiAuthenticationExtension):
"""Auth schema"""
target_class = DeviceAuthFedAuthentication
name = "device_federation"
def get_security_definition(self, auto_schema):
"""Auth schema"""
return {"type": "http", "scheme": "bearer"}
def check_device_policies(device: Device, user: User, request: HttpRequest):
"""Check policies bound to device group and device"""
if device.access_group:
result = check_pbm_policies(device.access_group, user, request)
if result.passing:
return result
return check_pbm_policies(device, user, request)
def check_pbm_policies(pbm: PolicyBindingModel, user: User, request: HttpRequest):
policy_engine = PolicyEngine(pbm, user, request)
policy_engine.use_cache = False
policy_engine.empty_result = False
policy_engine.mode = pbm.policy_engine_mode
policy_engine.build()
result = policy_engine.result
LOGGER.debug("PolicyAccessView user_has_access", user=user.username, result=result, pbm=pbm.pk)
return result

View File

@@ -63,8 +63,21 @@ class TestConnectorAuthIA(FlowTestCase):
)
self.assertEqual(response.status_code, 200)
@patch(
"authentik.enterprise.license.LicenseKey.validate",
MagicMock(
return_value=LicenseKey(
aud="",
exp=expiry_valid,
name=generate_id(),
internal_users=100,
external_users=100,
)
),
)
@reconcile_app("authentik_crypto")
def test_auth_ia_fulfill(self):
License.objects.create(key=generate_id())
self.client.force_login(self.user)
response = self.client.post(
reverse("authentik_api:agentconnector-auth-ia"),

View File

@@ -3,12 +3,12 @@ from hmac import compare_digest
from django.http import Http404, HttpRequest, HttpResponse, HttpResponseBadRequest, QueryDict
from authentik.endpoints.connectors.agent.models import AgentConnector, DeviceAuthenticationToken
from authentik.endpoints.models import Device
from authentik.enterprise.endpoints.connectors.agent.auth import (
from authentik.endpoints.connectors.agent.auth import (
agent_auth_issue_token,
check_device_policies,
)
from authentik.endpoints.connectors.agent.models import AgentConnector, DeviceAuthenticationToken
from authentik.endpoints.models import Device
from authentik.enterprise.policy import EnterprisePolicyAccessView
from authentik.flows.exceptions import FlowNonApplicableException
from authentik.flows.models import in_memory_stage

View File

@@ -4,37 +4,35 @@ from django.urls import reverse
from drf_spectacular.utils import extend_schema
from rest_framework import mixins
from rest_framework.decorators import action
from rest_framework.fields import CharField
from rest_framework.fields import CharField, SerializerMethodField
from rest_framework.permissions import BasePermission
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.viewsets import GenericViewSet
from authentik.core.api.groups import PartialUserSerializer
from authentik.core.api.utils import ModelSerializer
from authentik.core.models import User
from authentik.enterprise.api import EnterpriseRequiredMixin
from authentik.enterprise.reports.models import DataExport
from authentik.enterprise.reports.tasks import generate_export
from authentik.rbac.permissions import HasPermission
class RequestedBySerializer(ModelSerializer):
class Meta:
model = User
fields = ("pk", "username")
class ContentTypeSerializer(ModelSerializer):
app_label = CharField(read_only=True)
model = CharField(read_only=True)
verbose_name_plural = SerializerMethodField()
def get_verbose_name_plural(self, ct: ContentType) -> str:
return ct.model_class()._meta.verbose_name_plural
class Meta:
model = ContentType
fields = ("id", "app_label", "model")
fields = ("id", "app_label", "model", "verbose_name_plural")
class DataExportSerializer(EnterpriseRequiredMixin, ModelSerializer):
requested_by = RequestedBySerializer(read_only=True)
requested_by = PartialUserSerializer(read_only=True)
content_type = ContentTypeSerializer(read_only=True)
class Meta:

View File

@@ -7,6 +7,7 @@ from django.db import connection
from django.db.models import Model, Q
from djangoql.compat import text_type
from djangoql.schema import StrField
from djangoql.serializers import DjangoQLSchemaSerializer
class JSONSearchField(StrField):
@@ -14,10 +15,18 @@ class JSONSearchField(StrField):
model: Model
def __init__(self, model=None, name=None, nullable=None, suggest_nested=True):
def __init__(
self,
model=None,
name=None,
nullable=None,
suggest_nested=False,
fixed_structure: OrderedDict | None = None,
):
# Set this in the constructor to not clobber the type variable
self.type = "relation"
self.suggest_nested = suggest_nested
self.fixed_structure = fixed_structure
super().__init__(model, name, nullable)
def get_lookup(self, path, operator, value):
@@ -57,11 +66,23 @@ class JSONSearchField(StrField):
)
return (x[0] for x in cursor.fetchall())
def get_nested_options(self) -> OrderedDict:
def get_fixed_structure(self, serializer: DjangoQLSchemaSerializer) -> OrderedDict:
new_dict = OrderedDict()
if not self.fixed_structure:
return new_dict
new_dict.setdefault(self.relation(), {})
for key, value in self.fixed_structure.items():
new_dict[self.relation()][key] = serializer.serialize_field(value)
if isinstance(value, JSONSearchField):
new_dict.update(value.get_nested_options(serializer))
return new_dict
def get_nested_options(self, serializer: DjangoQLSchemaSerializer) -> OrderedDict:
"""Get keys of all nested objects to show autocomplete"""
if not self.suggest_nested:
if self.fixed_structure:
return self.get_fixed_structure(serializer)
return OrderedDict()
base_model_name = f"{self.model._meta.app_label}.{self.model._meta.model_name}_{self.name}"
def recursive_function(parts: list[str], parent_parts: list[str] | None = None):
if not parent_parts:
@@ -87,7 +108,7 @@ class JSONSearchField(StrField):
relation_structure = defaultdict(dict)
for relations in self.json_field_keys():
result = recursive_function([base_model_name] + relations)
result = recursive_function([self.relation()] + relations)
for relation_key, value in result.items():
for sub_relation_key, sub_value in value.items():
if not relation_structure[relation_key].get(sub_relation_key, None):

View File

@@ -12,7 +12,7 @@ class AKQLSchemaSerializer(DjangoQLSchemaSerializer):
for _, field in fields.items():
if not isinstance(field, JSONSearchField):
continue
serialization["models"].update(field.get_nested_options())
serialization["models"].update(field.get_nested_options(self))
return serialization
def serialize_field(self, field):

View File

@@ -1,5 +1,6 @@
"""Events API Views"""
from collections import OrderedDict
from datetime import timedelta
import django_filters
@@ -136,7 +137,7 @@ class EventViewSet(
filterset_class = EventsFilter
def get_ql_fields(self):
from djangoql.schema import DateTimeField, StrField
from djangoql.schema import DateTimeField, IntField, StrField
from authentik.enterprise.search.fields import ChoiceSearchField, JSONSearchField
@@ -145,9 +146,42 @@ class EventViewSet(
StrField(Event, "event_uuid"),
StrField(Event, "app", suggest_options=True),
StrField(Event, "client_ip"),
JSONSearchField(Event, "user", suggest_nested=False),
JSONSearchField(Event, "brand", suggest_nested=False),
JSONSearchField(Event, "context", suggest_nested=False),
JSONSearchField(
Event,
"user",
fixed_structure=OrderedDict(
pk=IntField(),
username=StrField(),
email=StrField(),
),
),
JSONSearchField(
Event,
"brand",
fixed_structure=OrderedDict(
pk=StrField(),
app=StrField(),
name=StrField(),
model_name=StrField(),
),
),
JSONSearchField(
Event,
"context",
fixed_structure=OrderedDict(
http_request=JSONSearchField(
Event,
"context_http_request",
fixed_structure=OrderedDict(
args=JSONSearchField(Event, "context_http_request_args"),
path=StrField(),
method=StrField(),
request_id=StrField(),
user_agent=StrField(),
),
),
),
),
DateTimeField(Event, "created", suggest_options=True),
]

View File

@@ -7,7 +7,7 @@ from typing import Any
from django.utils.timezone import now
from rest_framework.fields import CharField, ChoiceField, DateTimeField, DictField
from structlog import configure, get_config
from structlog.stdlib import NAME_TO_LEVEL, ProcessorFormatter
from structlog.stdlib import NAME_TO_LEVEL, ProcessorFormatter, get_logger
from structlog.testing import LogCapture
from structlog.types import EventDict
@@ -36,6 +36,9 @@ class LogEvent:
event, log_level, item.pop("logger"), timestamp, attributes=sanitize_dict(item)
)
def log(self):
get_logger(self.logger).log(NAME_TO_LEVEL[self.log_level], self.event, **self.attributes)
class LogEventSerializer(PassiveSerializer):
"""Single log message with all context logged."""

View File

@@ -8,6 +8,8 @@ from inspect import currentframe
from typing import Any
from uuid import uuid4
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from django.apps import apps
from django.db import models
from django.http import HttpRequest
@@ -41,6 +43,7 @@ from authentik.lib.utils.http import get_http_session
from authentik.lib.utils.time import timedelta_from_string
from authentik.policies.models import PolicyBindingModel
from authentik.root.middleware import ClientIPMiddleware
from authentik.root.ws.consumer import build_user_group
from authentik.stages.email.models import EmailTemplates
from authentik.stages.email.utils import TemplateEmailMessage
from authentik.tasks.models import TasksModel
@@ -361,6 +364,15 @@ class NotificationTransport(TasksModel, SerializerModel):
notification=notification,
)
notification.save()
layer = get_channel_layer()
async_to_sync(layer.group_send)(
build_user_group(notification.user),
{
"type": "event.notification",
"id": str(notification.pk),
"data": notification.serializer(notification).data,
},
)
return []
def send_webhook(self, notification: "Notification") -> list[str]:

View File

@@ -84,7 +84,7 @@ class OutgoingSyncProvider(ScheduledModel, Model):
raise NotImplementedError
def sync_dispatch(self) -> None:
for schedule in self.schedules:
for schedule in self.schedules.all():
schedule.send()
@property

View File

@@ -1,16 +1,17 @@
"""authentik database utilities"""
import gc
from collections.abc import Generator
from django.db import reset_queries
from django.db.models import QuerySet
from django.db.models import Model, QuerySet
def chunked_queryset(queryset: QuerySet, chunk_size: int = 1_000):
def chunked_queryset[T: Model](queryset: QuerySet[T], chunk_size: int = 1_000) -> Generator[T]:
if not queryset.exists():
return []
def get_chunks(qs: QuerySet):
def get_chunks(qs: QuerySet) -> Generator[QuerySet[T]]:
qs = qs.order_by("pk")
pks = qs.values_list("pk", flat=True)
start_pk = pks[0]

View File

@@ -86,7 +86,7 @@ class OutpostConfig:
class OutpostModel(Model):
"""Base model for providers that need more objects than just themselves"""
def get_required_objects(self) -> Iterable[models.Model | str]:
def get_required_objects(self) -> Iterable[models.Model | str | tuple[str, models.Model]]:
"""Return a list of all required objects"""
return [self]
@@ -332,41 +332,35 @@ class Outpost(ScheduledModel, SerializerModel, ManagedModel):
"""Create per-object and global permissions for outpost service-account"""
# To ensure the user only has the correct permissions, we delete all of them and re-add
# the ones the user needs
with transaction.atomic():
user.remove_all_perms_from_managed_role()
for model_or_perm in self.get_required_objects():
if isinstance(model_or_perm, models.Model):
model_or_perm: models.Model
code_name = (
f"{model_or_perm._meta.app_label}.view_{model_or_perm._meta.model_name}"
)
try:
user.assign_perms_to_managed_role(code_name, model_or_perm)
except (Permission.DoesNotExist, AttributeError) as exc:
LOGGER.warning(
"permission doesn't exist",
code_name=code_name,
user=user,
model=model_or_perm,
try:
with transaction.atomic():
user.remove_all_perms_from_managed_role()
for model_or_perm in self.get_required_objects():
if isinstance(model_or_perm, models.Model):
code_name = (
f"{model_or_perm._meta.app_label}.view_{model_or_perm._meta.model_name}"
)
Event.new(
action=EventAction.SYSTEM_EXCEPTION,
message=(
"While setting the permissions for the service-account, a "
"permission was not found: Check "
"https://docs.goauthentik.io/troubleshooting/missing_permission"
),
).with_exception(exc).set_user(user).save()
else:
app_label, perm = model_or_perm.split(".")
permission = Permission.objects.filter(
codename=perm,
content_type__app_label=app_label,
)
if not permission.exists():
LOGGER.warning("permission doesn't exist", perm=model_or_perm)
continue
user.assign_perms_to_managed_role(permission.first())
user.assign_perms_to_managed_role(code_name, model_or_perm)
elif isinstance(model_or_perm, tuple):
perm, obj = model_or_perm
user.assign_perms_to_managed_role(perm, obj)
else:
user.assign_perms_to_managed_role(model_or_perm)
except (Permission.DoesNotExist, AttributeError) as exc:
LOGGER.warning(
"permission doesn't exist",
code_name=code_name,
user=user,
model=model_or_perm,
)
Event.new(
action=EventAction.SYSTEM_EXCEPTION,
message=(
"While setting the permissions for the service-account, a "
"permission was not found: Check "
"https://docs.goauthentik.io/troubleshooting/missing_permission"
),
).with_exception(exc).set_user(user).save()
LOGGER.debug(
"Updated service account's permissions",
obj_perms=user.get_all_obj_perms_on_managed_role(),
@@ -431,7 +425,7 @@ class Outpost(ScheduledModel, SerializerModel, ManagedModel):
Token.objects.filter(identifier=self.token_identifier).delete()
return self.token
def get_required_objects(self) -> Iterable[models.Model | str]:
def get_required_objects(self) -> Iterable[models.Model | str | tuple[str, models.Model]]:
"""Get an iterator of all objects the user needs read access to"""
objects: list[models.Model | str] = [
self,
@@ -445,7 +439,9 @@ class Outpost(ScheduledModel, SerializerModel, ManagedModel):
if self.managed:
for brand in Brand.objects.filter(web_certificate__isnull=False):
objects.append(brand)
objects.append(brand.web_certificate)
objects.append(("view_certificatekeypair", brand.web_certificate))
objects.append(("view_certificatekeypair_certificate", brand.web_certificate))
objects.append(("view_certificatekeypair_key", brand.web_certificate))
return objects
def __str__(self) -> str:

View File

@@ -51,10 +51,12 @@ class OutpostTests(TestCase):
permissions = outpost.user.get_all_obj_perms_on_managed_role().order_by(
"content_type__model"
)
self.assertEqual(len(permissions), 3)
self.assertEqual(len(permissions), 5)
self.assertEqual(permissions[0].object_pk, str(keypair.pk))
self.assertEqual(permissions[1].object_pk, str(outpost.pk))
self.assertEqual(permissions[2].object_pk, str(provider.pk))
self.assertEqual(permissions[1].object_pk, str(keypair.pk))
self.assertEqual(permissions[2].object_pk, str(keypair.pk))
self.assertEqual(permissions[3].object_pk, str(outpost.pk))
self.assertEqual(permissions[4].object_pk, str(provider.pk))
# Remove provider from outpost, user should only have access to outpost
outpost.providers.remove(provider)

View File

@@ -93,11 +93,13 @@ class LDAPProvider(OutpostModel, BackchannelProvider):
def __str__(self):
return f"LDAP Provider {self.name}"
def get_required_objects(self) -> Iterable[models.Model | str]:
required_models = [self, "authentik_core.view_user", "authentik_core.view_group"]
def get_required_objects(self) -> Iterable[models.Model | str | tuple[str, models.Model]]:
required = [self, "authentik_core.view_user", "authentik_core.view_group"]
if self.certificate is not None:
required_models.append(self.certificate)
return required_models
required.append(("view_certificatekeypair", self.certificate))
required.append(("view_certificatekeypair_certificate", self.certificate))
required.append(("view_certificatekeypair_key", self.certificate))
return required
class Meta:
verbose_name = _("LDAP Provider")

View File

@@ -179,11 +179,13 @@ class ProxyProvider(OutpostModel, OAuth2Provider):
def __str__(self):
return f"Proxy Provider {self.name}"
def get_required_objects(self) -> Iterable[models.Model | str]:
required_models = [self]
def get_required_objects(self) -> Iterable[models.Model | str | tuple[str, models.Model]]:
required = [self]
if self.certificate is not None:
required_models.append(self.certificate)
return required_models
required.append(("view_certificatekeypair", self.certificate))
required.append(("view_certificatekeypair_certificate", self.certificate))
required.append(("view_certificatekeypair_key", self.certificate))
return required
class Meta:
verbose_name = _("Proxy Provider")

View File

@@ -1,10 +1,14 @@
"""proxy provider tests"""
from json import loads
from django.urls import reverse
from rest_framework.test import APITestCase
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
from authentik.core.models import Application
from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow
from authentik.lib.generators import generate_id
from authentik.outposts.models import Outpost, OutpostType
from authentik.providers.oauth2.models import ClientTypes
from authentik.providers.proxy.models import ProxyMode, ProxyProvider
@@ -127,3 +131,55 @@ class ProxyProviderTests(APITestCase):
self.assertEqual(response.status_code, 200)
provider: ProxyProvider = ProxyProvider.objects.get(name=name)
self.assertEqual(provider.client_type, ClientTypes.CONFIDENTIAL)
def test_sa_fetch(self):
"""Test fetching the outpost config as the service account"""
outpost = Outpost.objects.create(name=generate_id(), type=OutpostType.PROXY)
provider = ProxyProvider.objects.create(name=generate_id())
Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider)
outpost.providers.add(provider)
res = self.client.get(
reverse("authentik_api:proxyprovideroutpost-list"),
HTTP_AUTHORIZATION=f"Bearer {outpost.token.key}",
)
body = loads(res.content)
self.assertEqual(body["pagination"]["count"], 1)
def test_sa_perms_cert(self):
"""Test permissions to access a configured certificate"""
cert = create_test_cert()
outpost = Outpost.objects.create(name=generate_id(), type=OutpostType.PROXY)
provider = ProxyProvider.objects.create(name=generate_id(), certificate=cert)
Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider)
outpost.providers.add(provider)
res = self.client.get(
reverse("authentik_api:proxyprovideroutpost-list"),
HTTP_AUTHORIZATION=f"Bearer {outpost.token.key}",
)
body = loads(res.content)
self.assertEqual(body["pagination"]["count"], 1)
cert_id = body["results"][0]["certificate"]
self.assertEqual(cert_id, str(cert.pk))
res = self.client.get(
reverse(
"authentik_api:certificatekeypair-view-certificate",
kwargs={
"pk": cert_id,
},
),
HTTP_AUTHORIZATION=f"Bearer {outpost.token.key}",
)
self.assertEqual(res.status_code, 200)
# res = self.client.get(
# reverse(
# "authentik_api:certificatekeypair-view-private-key",
# kwargs={
# "pk": cert_id,
# },
# ),
# HTTP_AUTHORIZATION=f"Bearer {outpost.token.key}",
# )
# self.assertEqual(res.status_code, 200)

View File

@@ -64,10 +64,12 @@ class RadiusProvider(OutpostModel, Provider):
return RadiusProviderSerializer
def get_required_objects(self) -> Iterable[models.Model | str]:
def get_required_objects(self) -> Iterable[models.Model | str | tuple[str, models.Model]]:
required = [self, "authentik_stages_mtls.pass_outpost_certificate"]
if self.certificate is not None:
required.append(self.certificate)
required.append(("view_certificatekeypair", self.certificate))
required.append(("view_certificatekeypair_certificate", self.certificate))
required.append(("view_certificatekeypair_key", self.certificate))
return required
def __str__(self):

View File

@@ -2,7 +2,7 @@
from django.contrib.auth.models import Permission
from django.http import Http404
from django_filters.filters import AllValuesMultipleFilter, BooleanFilter
from django_filters.filters import AllValuesMultipleFilter, BooleanFilter, CharFilter, NumberFilter
from django_filters.filterset import FilterSet
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiResponse, extend_schema, extend_schema_field
@@ -22,7 +22,7 @@ from authentik.blueprints.api import ManagedSerializer
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import ModelSerializer, PassiveSerializer
from authentik.core.models import User
from authentik.core.models import Group, User
from authentik.rbac.decorators import permission_required
from authentik.rbac.models import Role, get_permission_choices
@@ -65,15 +65,63 @@ class RoleSerializer(ManagedSerializer, ModelSerializer):
class RoleFilterSet(FilterSet):
"""Filter for PropertyMapping"""
"""Filter for Role"""
managed = extend_schema_field(OpenApiTypes.STR)(AllValuesMultipleFilter(field_name="managed"))
managed__isnull = BooleanFilter(field_name="managed", lookup_expr="isnull")
inherited = BooleanFilter(
method="filter_inherited",
label="Include inherited roles (requires users or ak_groups filter)",
)
users = extend_schema_field(OpenApiTypes.INT)(
NumberFilter(
method="filter_users",
label="Filter by user (use with inherited=true for all roles)",
)
)
ak_groups = extend_schema_field(OpenApiTypes.UUID)(
CharFilter(
method="filter_ak_groups",
label="Filter by group (use with inherited=true for all roles)",
)
)
def filter_inherited(self, queryset, name, value):
"""This filter is handled by filter_users and filter_ak_groups"""
return queryset
def filter_users(self, queryset, name, value):
"""Filter roles by user, optionally including inherited roles"""
user = User.objects.filter(pk=value).first()
if not user:
return queryset.none()
include_inherited = self.data.get("inherited", "").lower() == "true"
if include_inherited:
return user.all_roles()
return queryset.filter(users=user)
def filter_ak_groups(self, queryset, name, value):
"""Filter roles by group, optionally including inherited roles"""
group = Group.objects.filter(pk=value).first()
if not group:
return queryset.none()
include_inherited = self.data.get("inherited", "").lower() == "true"
if include_inherited:
return group.all_roles()
return queryset.filter(ak_groups=group)
class Meta:
model = Role
fields = ["name", "users", "managed"]
fields = [
"name",
"managed",
]
class RoleViewSet(UsedByMixin, ModelViewSet):

View File

@@ -50,7 +50,7 @@ def get_user(scope):
"Cannot find session in scope. You should wrap your consumer in SessionMiddleware."
)
user = None
if (authenticated_session := scope["session"].get("authenticated_session", None)) is not None:
if (authenticated_session := scope["session"].get("authenticatedsession", None)) is not None:
user = authenticated_session.user
return user or AnonymousUser()

View File

@@ -96,6 +96,9 @@ class PytestTestRunner(DiscoverRunner): # pragma: no cover
def add_arguments(cls, parser: ArgumentParser):
"""Add more pytest-specific arguments"""
DiscoverRunner.add_arguments(parser)
default_seed = None
if seed := os.getenv("CI_TEST_SEED"):
default_seed = int(seed)
parser.add_argument(
"--randomly-seed",
type=int,
@@ -103,6 +106,7 @@ class PytestTestRunner(DiscoverRunner): # pragma: no cover
"to reuse the seed from the previous run."
"Default behaviour: use random.Random().getrandbits(32), so the seed is"
"different on each run.",
default=default_seed,
)
parser.add_argument(
"--no-capture",

View File

View File

@@ -0,0 +1,115 @@
from unittest.mock import patch
from asgiref.sync import sync_to_async
from channels.routing import URLRouter
from channels.testing import WebsocketCommunicator
from django.http import HttpRequest
from django.test import TransactionTestCase
from authentik.core.tests.utils import create_test_user
from authentik.events.models import (
Event,
EventAction,
Notification,
NotificationTransport,
TransportMode,
)
from authentik.flows.apps import RefreshOtherFlowsAfterAuthentication
from authentik.lib.generators import generate_id
from authentik.root import websocket
from authentik.stages.password import BACKEND_INBUILT
from authentik.stages.user_login.stage import COOKIE_NAME_KNOWN_DEVICE
from authentik.tenants.utils import get_current_tenant
class TestClientWS(TransactionTestCase):
def setUp(self):
tenant = get_current_tenant()
tenant.flags[RefreshOtherFlowsAfterAuthentication().key] = True
tenant.save()
self.user = create_test_user()
async def _alogin_cookie(self, user, **kwargs):
"""Similar to `client.aforce_login` but allow setting of cookies"""
from django.contrib.auth import alogin
# Create a fake request to store login details.
request = HttpRequest()
session = await self.client.asession()
request.session = session
request.COOKIES.update(kwargs)
await alogin(request, user, BACKEND_INBUILT)
# Save the session values.
await request.session.asave()
self.client._set_login_cookies(request)
async def test_auth_blank(self):
dev_id = generate_id()
communicator = WebsocketCommunicator(
URLRouter(websocket.websocket_urlpatterns),
"/ws/client/",
headers=[(b"cookie", f"{COOKIE_NAME_KNOWN_DEVICE}={dev_id}".encode())],
)
connected, _ = await communicator.connect()
self.assertTrue(connected)
await self._alogin_cookie(self.user, **{COOKIE_NAME_KNOWN_DEVICE: dev_id})
await communicator.receive_nothing()
await communicator.receive_json_from()
await communicator.disconnect()
async def test_tab_refresh(self):
dev_id = generate_id()
communicator = WebsocketCommunicator(
URLRouter(websocket.websocket_urlpatterns),
"/ws/client/",
headers=[(b"cookie", f"{COOKIE_NAME_KNOWN_DEVICE}={dev_id}".encode())],
)
connected, _ = await communicator.connect()
self.assertTrue(connected)
with patch("authentik.flows.apps.RefreshOtherFlowsAfterAuthentication.get") as flag:
flag.return_value = True
await self._alogin_cookie(self.user, **{COOKIE_NAME_KNOWN_DEVICE: dev_id})
evt = await communicator.receive_json_from()
self.assertEqual(
evt, {"message_type": "session.authenticated", "type": "event.session.authenticated"}
)
await communicator.disconnect()
async def test_notification(self):
communicator = WebsocketCommunicator(
URLRouter(websocket.websocket_urlpatterns), "/ws/client/"
)
communicator.scope["user"] = self.user
connected, _ = await communicator.connect()
self.assertTrue(connected)
transport = await NotificationTransport.objects.acreate(
name=generate_id(), mode=TransportMode.LOCAL
)
event = await sync_to_async(Event.new)(EventAction.LOGIN)
event.set_user(self.user)
await event.asave()
notification = Notification(
user=self.user,
body="foo",
event=event,
hyperlink="goauthentik.io",
hyperlink_label="a link",
)
await sync_to_async(transport.send_local)(notification)
evt = await communicator.receive_json_from(timeout=5)
self.assertEqual(evt["message_type"], "notification.new")
self.assertEqual(evt["id"], str(notification.pk))
self.assertEqual(evt["data"]["pk"], str(notification.pk))
self.assertEqual(evt["data"]["body"], "foo")
self.assertEqual(evt["data"]["event"]["pk"], str(event.pk))
await communicator.disconnect()

View File

@@ -7,6 +7,7 @@ from channels.generic.websocket import JsonWebsocketConsumer
from django.core.cache import cache
from django.db import connection
from authentik.core.models import User
from authentik.root.ws.storage import CACHE_PREFIX
@@ -16,24 +17,34 @@ def build_session_group(session_key: str):
).hexdigest()
def build_device_group(session_key: str):
def build_device_group(device_id: str):
return sha256(
f"{connection.schema_name}/group_client_device_{str(session_key)}".encode()
f"{connection.schema_name}/group_client_device_{str(device_id)}".encode()
).hexdigest()
def build_user_group(user: User):
return sha256(f"{connection.schema_name}/group_client_user_{user.uuid}".encode()).hexdigest()
class MessageConsumer(JsonWebsocketConsumer):
"""Consumer which sends django.contrib.messages Messages over WS.
channel_name is saved into cache with user_id, and when a add_message is called"""
session_key: str
device_cookie: str | None = None
user: User | None = None
def connect(self):
self.accept()
self.session_key = self.scope["session"].session_key
if self.session_key:
cache.set(f"{CACHE_PREFIX}{self.session_key}_messages_{self.channel_name}", True, None)
if user := self.scope.get("user"):
if user.is_authenticated:
async_to_sync(self.channel_layer.group_add)(
build_user_group(user), self.channel_name
)
if device_cookie := self.scope["cookies"].get("authentik_device", None):
self.device_cookie = device_cookie
async_to_sync(self.channel_layer.group_add)(
@@ -47,6 +58,10 @@ class MessageConsumer(JsonWebsocketConsumer):
async_to_sync(self.channel_layer.group_discard)(
build_device_group(self.device_cookie), self.channel_name
)
if self.user:
async_to_sync(self.channel_layer.group_discard)(
build_user_group(self.user), self.channel_name
)
def event_message(self, event: dict):
"""Event handler which is called by Messages Storage backend"""
@@ -54,4 +69,8 @@ class MessageConsumer(JsonWebsocketConsumer):
def event_session_authenticated(self, event: dict):
"""Event handler post user authentication"""
self.send_json({"message_type": "session.authenticated"})
self.send_json({"message_type": "session.authenticated", **event})
def event_notification(self, event: dict):
"""Event handler for new notifications"""
self.send_json({"message_type": "notification.new", **event})

View File

@@ -6,7 +6,7 @@ from django.http.request import QueryDict
from django.template.exceptions import TemplateSyntaxError
from django.utils.translation import gettext_lazy as _
from rest_framework.exceptions import ValidationError
from rest_framework.fields import BooleanField, CharField, IntegerField
from rest_framework.fields import BooleanField, CharField
from authentik.events.models import Event, EventAction
from authentik.flows.challenge import (
@@ -47,7 +47,7 @@ class AuthenticatorEmailChallengeResponse(ChallengeResponse):
device: EmailDevice
code = IntegerField(required=False)
code = CharField(required=False)
email = CharField(required=False)
component = CharField(default="ak-stage-authenticator-email")

View File

@@ -5,7 +5,7 @@ from django.http import HttpRequest, HttpResponse
from django.http.request import QueryDict
from django.utils.translation import gettext_lazy as _
from rest_framework.exceptions import ValidationError
from rest_framework.fields import BooleanField, CharField, IntegerField
from rest_framework.fields import BooleanField, CharField
from authentik.flows.challenge import (
Challenge,
@@ -38,7 +38,7 @@ class AuthenticatorSMSChallengeResponse(ChallengeResponse):
device: SMSDevice
code = IntegerField(required=False)
code = CharField(required=False)
phone_number = CharField(required=False)
component = CharField(default="ak-stage-authenticator-sms")

View File

@@ -5,7 +5,7 @@ from urllib.parse import quote
from django.http import HttpRequest, HttpResponse
from django.http.request import QueryDict
from django.utils.translation import gettext_lazy as _
from rest_framework.fields import CharField, IntegerField
from rest_framework.fields import CharField
from rest_framework.serializers import ValidationError
from authentik.flows.challenge import (
@@ -32,10 +32,10 @@ class AuthenticatorTOTPChallengeResponse(ChallengeResponse):
device: TOTPDevice
code = IntegerField()
code = CharField()
component = CharField(default="ak-stage-authenticator-totp")
def validate_code(self, code: int) -> int:
def validate_code(self, code: str) -> str:
"""Validate totp code"""
if not self.device:
raise ValidationError(_("Code does not match"))

View File

@@ -1,10 +1,9 @@
"""Identification stage logic"""
from dataclasses import asdict
from random import SystemRandom
from time import sleep
from typing import Any
from django.contrib.auth.hashers import make_password
from django.core.exceptions import PermissionDenied
from django.db.models import Q
from django.http import HttpResponse
@@ -161,8 +160,8 @@ class IdentificationChallengeResponse(ChallengeResponse):
op="authentik.stages.identification.validate_invalid_wait",
name="Sleep random time on invalid user identifier",
):
# Sleep a random time (between 90 and 210ms) to "prevent" user enumeration attacks
sleep(0.030 * SystemRandom().randint(3, 7))
# hash a random password on invalid identifier, same as with a valid identifier
make_password(make_password(None))
# Log in a similar format to Event.new(), but we don't want to create an event here
# as this stage is mostly used by unauthenticated users with very high rate limits
self.stage.logger.info(

View File

@@ -245,7 +245,10 @@ class WorkerStatusMiddleware(Middleware):
WorkerStatusMiddleware.keep(status)
except DB_ERRORS: # pragma: no cover
sleep(10)
pass
try:
connections.close_all()
except DB_ERRORS:
pass
@staticmethod
def keep(status: WorkerStatus):

View File

@@ -2,7 +2,7 @@
"$schema": "http://json-schema.org/draft-07/schema",
"$id": "https://goauthentik.io/blueprints/schema.json",
"type": "object",
"title": "authentik 2025.12.0-rc1 Blueprint schema",
"title": "authentik 2025.12.0-rc3 Blueprint schema",
"required": [
"version",
"entries"
@@ -6276,6 +6276,11 @@
],
"format": "date-time",
"title": "Expires"
},
"key": {
"type": "string",
"minLength": 1,
"title": "Key"
}
},
"required": []
@@ -14510,7 +14515,8 @@
"description": "Show the user the 'Remember me on this device' toggle, allowing repeat users to skip straight to entering their password."
},
"webauthn_stage": {
"type": "integer",
"type": "string",
"format": "uuid",
"title": "Webauthn stage",
"description": "When set, and conditional WebAuthn is available, allow the user to use their passkey as a first factor."
}

View File

@@ -31,13 +31,13 @@ services:
AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.12.0-rc1}
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.12.0-rc3}
ports:
- ${COMPOSE_PORT_HTTP:-9000}:9000
- ${COMPOSE_PORT_HTTPS:-9443}:9443
restart: unless-stopped
volumes:
- ./media:/data/media
- ./data:/data
- ./custom-templates:/templates
worker:
command: worker
@@ -52,12 +52,12 @@ services:
AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.12.0-rc1}
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.12.0-rc3}
restart: unless-stopped
user: root
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./media:/data/media
- ./data:/data
- ./certs:/certs
- ./custom-templates:/templates
volumes:

4
go.mod
View File

@@ -1,8 +1,6 @@
module goauthentik.io
go 1.24.3
toolchain go1.24.6
go 1.25.5
require (
beryju.io/ldap v0.1.0

View File

@@ -1 +1 @@
2025.12.0-rc1
2025.12.0-rc3

View File

@@ -165,7 +165,7 @@ func (ms *MemorySearcher) Search(req *search.Request) (ldap.ServerSearchResult,
for _, u := range g.UsersObj {
if flag.UserPk == u.Pk {
// TODO: Is there a better way to clone this object?
fg := api.NewGroup(g.Pk, g.NumPk, g.Name, []api.RelatedGroup{}, []api.PartialUser{u}, []api.Role{}, []string{}, []api.RelatedGroup{})
fg := api.NewGroup(g.Pk, g.NumPk, g.Name, []api.RelatedGroup{}, []api.PartialUser{u}, []api.Role{}, nil, []string{}, []api.RelatedGroup{})
fg.SetUsers([]int32{flag.UserPk})
fg.SetAttributes(g.Attributes)
fg.SetIsSuperuser(*g.IsSuperuser)

View File

@@ -234,8 +234,8 @@ func NewPostgresStore(log *log.Entry) (*PostgresStore, error) {
}
// Determine connection pool settings
maxIdleConns := 10
maxOpenConns := 100
maxIdleConns := 4
maxOpenConns := 4
var connMaxLifetime time.Duration
if cfg.ConnMaxAge > 0 {
connMaxLifetime = time.Duration(cfg.ConnMaxAge) * time.Second

View File

@@ -1,25 +1,39 @@
package utils
import "crypto/tls"
import (
"crypto/tls"
"slices"
)
func GetTLSConfig() *tls.Config {
// Based on
// https://ssl-config.mozilla.org/#server=go&version=1.25&config=intermediate&guideline=5.7
tlsConfig := &tls.Config{
MinVersion: tls.VersionTLS12,
MaxVersion: tls.VersionTLS12,
CurvePreferences: []tls.CurveID{
tls.X25519,
tls.CurveP256,
tls.CurveP384,
},
PreferServerCipherSuites: true,
CipherSuites: []uint16{},
}
// Insecure SWEET32 attack ciphers, TLS config uses a fallback
insecureCiphersIds := []uint16{
excludedCiphers := []uint16{
// ChaCha20 is not FIPS validated
tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
// Insecure SWEET32 attack ciphers, TLS config uses a fallback
tls.TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA,
tls.TLS_RSA_WITH_3DES_EDE_CBC_SHA,
}
defaultSecureCiphers := []uint16{}
for _, cs := range tls.CipherSuites() {
for _, icsId := range insecureCiphersIds {
if cs.ID != icsId {
defaultSecureCiphers = append(defaultSecureCiphers, cs.ID)
}
if slices.Contains(excludedCiphers, cs.ID) {
continue
}
defaultSecureCiphers = append(defaultSecureCiphers, cs.ID)
}
tlsConfig.CipherSuites = defaultSecureCiphers
return tlsConfig

View File

@@ -5,6 +5,7 @@ import (
"encoding/hex"
"fmt"
"net/http"
"strings"
"time"
"github.com/go-http-utils/etag"
@@ -17,11 +18,44 @@ import (
staticWeb "goauthentik.io/web"
)
// Theme variable placeholder that can be used in file paths
// This allows for theme-specific files like logo-%(theme)s.png
const themeVariable = "%(theme)s"
// Valid themes that can be substituted for %(theme)s
var validThemes = []string{"light", "dark"}
type StorageClaims struct {
jwt.RegisteredClaims
Path string `json:"path,omitempty"`
}
// pathMatchesWithTheme checks if the requested path matches the JWT path,
// accounting for theme variable substitution.
// If the JWT path contains %(theme)s, it will match the requested path
// if substituting %(theme)s with any valid theme produces the requested path.
func pathMatchesWithTheme(jwtPath, requestedPath string) bool {
// Direct match (no theme variable)
if jwtPath == requestedPath {
return true
}
// Check if JWT path contains theme variable
if !strings.Contains(jwtPath, themeVariable) {
return false
}
// Try substituting each valid theme and check for a match
for _, theme := range validThemes {
substituted := strings.ReplaceAll(jwtPath, themeVariable, theme)
if substituted == requestedPath {
return true
}
}
return false
}
func storageTokenIsValid(usage string, r *http.Request) bool {
tokenString := r.URL.Query().Get("token")
if tokenString == "" {
@@ -51,11 +85,8 @@ func storageTokenIsValid(usage string, r *http.Request) bool {
return false
}
if claims.Path != fmt.Sprintf("%s/%s", usage, r.URL.Path) {
return false
}
return true
requestedPath := fmt.Sprintf("%s/%s", usage, r.URL.Path)
return pathMatchesWithTheme(claims.Path, requestedPath)
}
func (ws *WebServer) configureStatic() {

View File

@@ -0,0 +1,95 @@
package web
import "testing"
func TestPathMatchesWithTheme(t *testing.T) {
tests := []struct {
name string
jwtPath string
requestedPath string
want bool
}{
{
name: "exact match without theme variable",
jwtPath: "media/public/logo.png",
requestedPath: "media/public/logo.png",
want: true,
},
{
name: "no match without theme variable",
jwtPath: "media/public/logo.png",
requestedPath: "media/public/other.png",
want: false,
},
{
name: "theme variable matches light theme",
jwtPath: "media/public/logo-%(theme)s.png",
requestedPath: "media/public/logo-light.png",
want: true,
},
{
name: "theme variable matches dark theme",
jwtPath: "media/public/logo-%(theme)s.png",
requestedPath: "media/public/logo-dark.png",
want: true,
},
{
name: "theme variable does not match invalid theme",
jwtPath: "media/public/logo-%(theme)s.png",
requestedPath: "media/public/logo-blue.png",
want: false,
},
{
name: "theme variable in directory path",
jwtPath: "media/%(theme)s/logo.png",
requestedPath: "media/light/logo.png",
want: true,
},
{
name: "multiple theme variables",
jwtPath: "media/%(theme)s/logo-%(theme)s.png",
requestedPath: "media/light/logo-light.png",
want: true,
},
{
name: "multiple theme variables with dark",
jwtPath: "media/%(theme)s/logo-%(theme)s.png",
requestedPath: "media/dark/logo-dark.png",
want: true,
},
{
name: "multiple theme variables mixed themes should not match",
jwtPath: "media/%(theme)s/logo-%(theme)s.png",
requestedPath: "media/light/logo-dark.png",
want: false,
},
{
name: "theme variable with nested path",
jwtPath: "media/public/brand/logo-%(theme)s.svg",
requestedPath: "media/public/brand/logo-dark.svg",
want: true,
},
{
name: "empty paths",
jwtPath: "",
requestedPath: "",
want: true,
},
{
name: "theme variable only",
jwtPath: "%(theme)s",
requestedPath: "light",
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := pathMatchesWithTheme(tt.jwtPath, tt.requestedPath)
if got != tt.want {
t.Errorf("pathMatchesWithTheme(%q, %q) = %v, want %v",
tt.jwtPath, tt.requestedPath, got, tt.want)
}
})
}
}

View File

@@ -18,7 +18,7 @@ Parameters:
Description: authentik Docker image
AuthentikVersion:
Type: String
Default: 2025.12.0-rc1
Default: 2025.12.0-rc3
Description: authentik Docker image tag
AuthentikServerCPU:
Type: Number

View File

@@ -30,10 +30,11 @@ class BaseMigration:
def __init__(self, cur: Any, con: Any):
self.cur = cur
self.con = con
self.log = get_logger().bind()
def system_crit(self, command: str):
"""Run system command"""
LOGGER.debug("Running system_crit command", command=command)
self.log.debug("Running system_crit command", command=command)
retval = system(command) # nosec
if retval != 0:
raise CommandError("Migration error")
@@ -73,6 +74,7 @@ def release_lock(conn: Connection, cursor: Cursor):
def run_migrations():
conn_opts = CONFIG.get_dict_from_b64_json("postgresql.conn_options", default={})
conn = connect(
dbname=CONFIG.get("postgresql.name"),
user=CONFIG.get("postgresql.user"),
@@ -83,6 +85,7 @@ def run_migrations():
sslrootcert=CONFIG.get("postgresql.sslrootcert"),
sslcert=CONFIG.get("postgresql.sslcert"),
sslkey=CONFIG.get("postgresql.sslkey"),
**conn_opts,
)
curr = conn.cursor()
try:

View File

@@ -0,0 +1,41 @@
# flake8: noqa
from lifecycle.migrate import BaseMigration
SQL_STATEMENT = """
SELECT "authentik_core_group"."name" AS "name",
Count("authentik_core_group"."name") AS "name__count"
FROM "authentik_core_group" GROUP BY 1
HAVING Count("authentik_core_group"."name") > 1
ORDER BY 2 DESC,
1 ASC
"""
class DuplicateNameError(RuntimeError):
pass
class Migration(BaseMigration):
def needs_migration(self) -> bool:
self.cur.execute(
"select 1 from information_schema.tables where table_name = 'django_migrations';"
)
if not bool(self.cur.rowcount):
# No django_migrations table, no data to check
return False
# migration that introduces the uniqueness
self.cur.execute(
"select 1 from django_migrations where app = 'authentik_core' and name = '0056_user_roles';"
)
return not bool(self.cur.rowcount)
def run(self):
rows = self.cur.execute(SQL_STATEMENT).fetchall()
if len(rows):
for row in rows:
self.log.error(
"Group with duplicate name detected", group_name=row[0], count=row[1]
)
raise DuplicateNameError(
f"authentik 2025.12 forbids duplicate group names. For a list of duplicate groups, see logging output above. Please rename the offending groups and re-run the migration. For more information, see: https://version-2025-12.goauthentik.io/releases/2025.12/#group-name-uniqueness"
)

View File

@@ -18,6 +18,7 @@ def check_postgres():
if attempt >= CHECK_THRESHOLD:
sysexit(1)
try:
conn_opts = CONFIG.get_dict_from_b64_json("postgresql.conn_options", default={})
conn = connect(
dbname=CONFIG.refresh("postgresql.name"),
user=CONFIG.refresh("postgresql.user"),
@@ -28,6 +29,7 @@ def check_postgres():
sslrootcert=CONFIG.get("postgresql.sslrootcert"),
sslcert=CONFIG.get("postgresql.sslcert"),
sslkey=CONFIG.get("postgresql.sslkey"),
**conn_opts,
)
conn.cursor()
break

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@goauthentik/authentik",
"version": "2025.12.0-rc1",
"version": "2025.12.0-rc3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@goauthentik/authentik",
"version": "2025.12.0-rc1",
"version": "2025.12.0-rc3",
"dependencies": {
"@eslint/js": "^9.39.1",
"@goauthentik/eslint-config": "./packages/eslint-config",

View File

@@ -1,6 +1,6 @@
{
"name": "@goauthentik/authentik",
"version": "2025.12.0-rc1",
"version": "2025.12.0-rc3",
"private": true,
"type": "module",
"dependencies": {

View File

@@ -1,27 +1,18 @@
"""Convenient shortcuts to manage or check object permissions."""
from functools import lru_cache, partial
from functools import lru_cache
from typing import Any, TypeVar
from django.contrib.auth.models import Permission
from django.contrib.contenttypes.models import ContentType
from django.db import connection
from django.db.models import (
AutoField,
BigIntegerField,
CharField,
Count,
ForeignKey,
IntegerField,
Model,
PositiveIntegerField,
PositiveSmallIntegerField,
QuerySet,
SmallIntegerField,
UUIDField,
)
from django.db.models.expressions import Value
from django.db.models.functions import Cast, Replace
from django.db.models.expressions import RawSQL
from guardian.core import ObjectPermissionChecker
from guardian.ctypes import get_content_type
@@ -295,42 +286,33 @@ def get_objects_for_user( # noqa: PLR0912 PLR0915
.filter(object_pk_count__gte=len(codenames))
)
# object_pk is a varchar, while the queryset's pk is probably an integer or a uuid, so we cast
handle_pk_field = _handle_pk_field(queryset)
if handle_pk_field is not None:
perms_queryset = perms_queryset.annotate(obj_pk=handle_pk_field(expression=pk_field))
pk_field = "obj_pk"
return queryset.filter(pk__in=perms_queryset.values_list(pk_field, flat=True))
def _handle_pk_field(queryset):
# pk is either UUID or an integer type, while object_pk is a varchar
pk = queryset.model._meta.pk
if isinstance(pk, ForeignKey):
return _handle_pk_field(pk.target_field)
def _cast_type(pk):
if isinstance(pk, ForeignKey):
return _cast_type(pk.target_field)
if isinstance(pk, UUIDField):
return "uuid"
return "bigint"
if isinstance( # noqa: UP038
pk,
(
IntegerField,
AutoField,
BigIntegerField,
PositiveIntegerField,
PositiveSmallIntegerField,
SmallIntegerField,
),
):
return partial(Cast, output_field=BigIntegerField())
cast_type = _cast_type(pk)
if isinstance(pk, UUIDField):
if connection.features.has_native_uuid_field:
return partial(Cast, output_field=UUIDField())
return partial(
Replace,
text=Value("-"),
replacement=Value(""),
output_field=CharField(),
)
return None
perms_queryset = perms_queryset.values_list(pk_field, flat=True)
# The raw subquery is done to ensure that casting only takes place after the WHERE clause of
# `perms_queryset` is ran. Otherwise, the query planner may decide to cast every `object_pk`,
# which breaks (for example) if it tries to cast an integer to a UUID. In such a case, the WHERE
# of `perms_queryset` will remove any integer.
# However, the subquery might get optimized out by the query planner, which would cause the same
# cast issue as before. To prevent the subquery from being collapsed in the query below, we add
# OFFSET 0.
perms_subquery_sql, perms_subquery_params = perms_queryset.query.sql_with_params()
subquery = RawSQL(
f"""
SELECT ("permission_subquery"."{pk_field}")::{cast_type} as "object_pk"
FROM ({perms_subquery_sql}) "permission_subquery"
OFFSET 0
""", # nosec
perms_subquery_params,
)
return queryset.filter(pk__in=subquery)

View File

@@ -529,3 +529,7 @@ class _PostgresConsumer(Consumer):
conn.close()
except DATABASE_ERRORS:
pass
try:
connections.close_all()
except DATABASE_ERRORS:
pass

View File

@@ -1,6 +1,6 @@
[project]
name = "authentik"
version = "2025.12.0-rc1"
version = "2025.12.0-rc3"
description = ""
authors = [{ name = "authentik Team", email = "hello@goauthentik.io" }]
requires-python = "==3.13.*"

View File

@@ -1,7 +1,7 @@
openapi: 3.0.3
info:
title: authentik
version: 2025.12.0-rc1
version: 2025.12.0-rc3
description: Making authentication simple.
contact:
email: hello@goauthentik.io
@@ -3385,6 +3385,11 @@ paths:
schema:
type: boolean
default: false
- in: query
name: include_inherited_roles
schema:
type: boolean
default: false
- in: query
name: include_parents
schema:
@@ -3478,6 +3483,11 @@ paths:
schema:
type: boolean
default: false
- in: query
name: include_inherited_roles
schema:
type: boolean
default: false
- in: query
name: include_parents
schema:
@@ -20052,6 +20062,16 @@ paths:
operationId: rbac_roles_list
description: Role viewset
parameters:
- in: query
name: ak_groups
schema:
type: string
format: uuid
- in: query
name: inherited
schema:
type: boolean
description: Include inherited roles (requires users or ak_groups filter)
- in: query
name: managed
schema:
@@ -20072,11 +20092,7 @@ paths:
- in: query
name: users
schema:
type: array
items:
type: integer
explode: true
style: form
type: integer
tags:
- rbac
security:
@@ -33586,7 +33602,8 @@ components:
minLength: 1
default: ak-stage-authenticator-email
code:
type: integer
type: string
minLength: 1
email:
type: string
minLength: 1
@@ -33833,7 +33850,8 @@ components:
minLength: 1
default: ak-stage-authenticator-sms
code:
type: integer
type: string
minLength: 1
phone_number:
type: string
minLength: 1
@@ -34107,7 +34125,8 @@ components:
minLength: 1
default: ak-stage-authenticator-totp
code:
type: integer
type: string
minLength: 1
required:
- code
AuthenticatorTOTPStage:
@@ -34794,6 +34813,7 @@ components:
CapabilitiesEnum:
enum:
- can_save_media
- can_save_reports
- can_geo_ip
- can_asn
- can_impersonate
@@ -35388,10 +35408,14 @@ components:
model:
type: string
readOnly: true
verbose_name_plural:
type: string
readOnly: true
required:
- app_label
- id
- model
- verbose_name_plural
ContextualFlowInfo:
type: object
description: Contextual flow information for a challenge
@@ -35739,7 +35763,7 @@ components:
readOnly: true
requested_by:
allOf:
- $ref: '#/components/schemas/RequestedBy'
- $ref: '#/components/schemas/PartialUser'
readOnly: true
requested_on:
type: string
@@ -38766,6 +38790,12 @@ components:
items:
$ref: '#/components/schemas/Role'
readOnly: true
inherited_roles_obj:
type: array
items:
$ref: '#/components/schemas/Role'
readOnly: true
nullable: true
children:
type: array
items:
@@ -38781,6 +38811,7 @@ components:
required:
- children
- children_obj
- inherited_roles_obj
- name
- num_pk
- parents_obj
@@ -51253,22 +51284,6 @@ components:
minimum: -2147483648
required:
- name
RequestedBy:
type: object
properties:
pk:
type: integer
readOnly: true
title: ID
username:
type: string
description: Required. 150 characters or fewer. Letters, digits and @/./+/-/_
only.
pattern: ^[\w.@+-]+$
maxLength: 150
required:
- pk
- username
ResidentKeyRequirementEnum:
enum:
- discouraged

6
scripts/generate_docker_compose.py Normal file → Executable file
View File

@@ -1,3 +1,5 @@
#!/usr/bin/env python3
from yaml import safe_dump
from authentik import authentik_version
@@ -42,7 +44,7 @@ base = {
"image": authentik_image,
"ports": ["${COMPOSE_PORT_HTTP:-9000}:9000", "${COMPOSE_PORT_HTTPS:-9443}:9443"],
"restart": "unless-stopped",
"volumes": ["./media:/data/media", "./custom-templates:/templates"],
"volumes": ["./data:/data", "./custom-templates:/templates"],
},
"worker": {
"command": "worker",
@@ -62,7 +64,7 @@ base = {
"user": "root",
"volumes": [
"/var/run/docker.sock:/var/run/docker.sock",
"./media:/data/media",
"./data:/data",
"./certs:/certs",
"./custom-templates:/templates",
],

View File

@@ -89,26 +89,7 @@ class TestSourceOAuth2(SeleniumTestCase):
interface = self.driver.find_element(By.CSS_SELECTOR, "ak-interface-user").shadow_root
interface_wait = WebDriverWait(interface, INTERFACE_TIMEOUT)
try:
interface_wait.until(
ec.presence_of_element_located((By.CSS_SELECTOR, "ak-interface-user-presentation"))
)
except TimeoutException:
snippet = context.text.strip()[:1000].replace("\n", " ")
self.fail(
f"Timed out waiting for element text to appear at {self.driver.current_url}. "
f"Current content: {snippet or '<empty>'}"
)
interface_presentation = interface.find_element(
By.CSS_SELECTOR, "ak-interface-user-presentation"
).shadow_root
user_settings = interface_presentation.find_element(
By.CSS_SELECTOR, "ak-user-settings"
).shadow_root
user_settings = interface.find_element(By.CSS_SELECTOR, "ak-user-settings").shadow_root
tab_panel = user_settings.find_element(By.CSS_SELECTOR, panel_content_selector).shadow_root

View File

@@ -23,8 +23,10 @@ from docker.models.containers import Container
from docker.models.networks import Network
from selenium import webdriver
from selenium.common.exceptions import (
DetachedShadowRootException,
NoSuchElementException,
NoSuchShadowRootException,
StaleElementReferenceException,
TimeoutException,
WebDriverException,
)
@@ -247,36 +249,60 @@ class SeleniumTestCase(DockerTestCase, StaticLiveServerTestCase):
Raises a clear test failure if the element isn't found, the text doesn't appear
within `timeout` seconds, or the text is not valid JSON.
"""
use_body = context is None
wait_timeout = timeout or self.wait_timeout
def get_context() -> WebElement:
"""Get or refresh the context element."""
if use_body:
return self.driver.find_element(By.TAG_NAME, "body")
return context
def get_text_safely() -> str:
"""Get element text, re-finding element if stale."""
for _ in range(5):
try:
return get_context().text.strip()
except StaleElementReferenceException:
sleep(0.5)
return get_context().text.strip()
def get_inner_html_safely() -> str:
"""Get innerHTML, re-finding element if stale."""
for _ in range(5):
try:
return get_context().get_attribute("innerHTML") or ""
except StaleElementReferenceException:
sleep(0.5)
return get_context().get_attribute("innerHTML") or ""
try:
if context is None:
context = self.driver.find_element(By.TAG_NAME, "body")
get_context()
except NoSuchElementException:
self.fail(
f"No element found (defaulted to <body>). Current URL: {self.driver.current_url}"
)
wait_timeout = timeout or self.wait_timeout
wait = WebDriverWait(context, wait_timeout)
wait = WebDriverWait(self.driver, wait_timeout)
try:
wait.until(lambda d: len(d.text.strip()) != 0)
wait.until(lambda d: len(get_text_safely()) != 0)
except TimeoutException:
snippet = context.text.strip()[:500].replace("\n", " ")
snippet = get_text_safely()[:500].replace("\n", " ")
self.fail(
f"Timed out waiting for element text to appear at {self.driver.current_url}. "
f"Current content: {snippet or '<empty>'}"
)
body_text = context.text.strip()
inner_html = context.get_attribute("innerHTML") or ""
body_text = get_text_safely()
inner_html = get_inner_html_safely()
if "redirecting" in inner_html.lower():
try:
wait.until(lambda d: "redirecting" not in d.get_attribute("innerHTML").lower())
wait.until(lambda d: "redirecting" not in get_inner_html_safely().lower())
except TimeoutException:
snippet = context.text.strip()[:500].replace("\n", " ")
inner_html = context.get_attribute("innerHTML") or ""
snippet = get_text_safely()[:500].replace("\n", " ")
inner_html = get_inner_html_safely()
self.fail(
f"Timed out waiting for redirect to finish at {self.driver.current_url}. "
@@ -284,8 +310,8 @@ class SeleniumTestCase(DockerTestCase, StaticLiveServerTestCase):
f"{inner_html or '<empty>'}"
)
inner_html = context.get_attribute("innerHTML") or ""
body_text = context.text.strip()
inner_html = get_inner_html_safely()
body_text = get_text_safely()
snippet = body_text[:500].replace("\n", " ")
@@ -326,18 +352,23 @@ class SeleniumTestCase(DockerTestCase, StaticLiveServerTestCase):
while attempts < SHADOW_ROOT_RETRIES:
try:
host = container.find_element(By.CSS_SELECTOR, selector)
return host.shadow_root
except NoSuchShadowRootException:
except (
NoSuchElementException,
NoSuchShadowRootException,
DetachedShadowRootException,
StaleElementReferenceException,
):
attempts += 1
sleep(0.2)
# re-find host in case it was re-attached
try:
host = container.find_element(By.CSS_SELECTOR, selector)
except NoSuchElementException:
# loop and retry finding host
pass
inner_html = host.get_attribute("innerHTML") or "<no host>"
inner_html = "<no host>"
if host is not None:
try:
inner_html = host.get_attribute("innerHTML") or "<no host>"
except (DetachedShadowRootException, StaleElementReferenceException):
inner_html = "<stale host>"
raise RuntimeError(
f"Failed to obtain shadow root for {selector} after {attempts} attempts. "

2
uv.lock generated
View File

@@ -185,7 +185,7 @@ wheels = [
[[package]]
name = "authentik"
version = "2025.12.0rc1"
version = "2025.12.0rc3"
source = { editable = "." }
dependencies = [
{ name = "ak-guardian" },

1
web/.gitignore vendored
View File

@@ -6,6 +6,7 @@
src/locales/*.ts
xliff/pseudo[_-]LOCALE.xlf
xliff/en[_-]XA.xlf
### Node ###
# Logs

View File

@@ -5,6 +5,7 @@
"cs-CZ",
"de-DE",
"en",
"en-XA",
"es-ES",
"fi-FI",
"fr-FR",
@@ -17,8 +18,7 @@
"ru-RU",
"tr-TR",
"zh-Hans",
"zh-Hant",
"pseudo-LOCALE"
"zh-Hant"
],
"tsConfig": "./tsconfig.json",
"output": {

139
web/logger/browser.js Normal file
View File

@@ -0,0 +1,139 @@
/**
* @file Console logger for browser environments.
*
* @remarks
* The repetition of log levels, typedefs, and method signatures is intentional
* to give IDEs and type checkers a mapping of log methods to the TypeScript
* provided JSDoc comments.
*
* Additionally, no wrapper functions are used to avoid the browser's console
* reported call site being the wrapper instead of the actual caller.
*/
/* eslint-disable no-console */
//#region Functions
/**
* @typedef {object} Logger
* @property {typeof console.info} info;
* @property {typeof console.warn} warn;
* @property {typeof console.error} error;
* @property {typeof console.debug} debug;
* @property {typeof console.trace} trace;
*/
/**
* Labels log levels in the browser console.
*/
const LogLevelLabel = /** @type {const} */ ({
info: "[INFO]",
warn: "[WARN]",
error: "[ERROR]",
debug: "[DEBUG]",
trace: "[TRACE]",
});
/**
* @typedef {keyof typeof LogLevelLabel} LogLevel
*/
/**
* Predefined log levels.
*/
const LogLevels = /** @type {LogLevel[]} */ (Object.keys(LogLevelLabel));
/**
* Colors for log levels in the browser console.
*
* @remarks
*
* The colors are derived from Carbon Design System's palette to ensure
* sufficient contrast and accessibility across light and dark themes.
*/
const LogLevelColors = /** @type {const} */ ({
info: `light-dark(#0043CE, #4589FF)`,
warn: `light-dark(#F1C21B, #F1C21B)`,
error: `light-dark(#DA1E28, #FA4D56)`,
debug: `light-dark(#8A3FFC, #A56EFF)`,
trace: `light-dark(#8A3FFC, #A56EFF)`,
});
/**
* Creates a logger with the given prefix.
*
* @param {string} [prefix]
* @param {...string} args
* @returns {Logger}
*
*/
export function createLogger(prefix, ...args) {
const suffix = prefix ? `(${prefix}):` : ":";
/**
* @type {Partial<Logger>}
*/
const logger = {};
for (const level of LogLevels) {
const label = LogLevelLabel[level];
const color = LogLevelColors[level];
logger[level] = console[level].bind(
console,
`%c${label}%c ${suffix}%c`,
`font-weight: 700; color: ${color};`,
`font-weight: 600; color: CanvasText;`,
"",
...args,
);
}
return /** @type {Logger} */ (logger);
}
//#endregion
//#region Console Logger
/**
* @typedef {Logger & {prefix: (logPrefix: string) => Logger}} IConsoleLogger
*/
/**
* A singleton logger instance for the browser.
*
* ```js
* import { ConsoleLogger } from "#logger/browser";
*
* ConsoleLogger.info("Hello, world!");
* ```
*
* @implements {IConsoleLogger}
* @runtime browser
*/
// @ts-expect-error Logging properties are dynamically assigned.
export class ConsoleLogger {
/** @type {typeof console.info} */
static info;
/** @type {typeof console.warn} */
static warn;
/** @type {typeof console.error} */
static error;
/** @type {typeof console.debug} */
static debug;
/** @type {typeof console.trace} */
static trace;
/**
* Creates a logger with the given prefix.
* @param {string} logPrefix
*/
static prefix(logPrefix) {
return createLogger(logPrefix);
}
}
Object.assign(ConsoleLogger, createLogger());
//#endregion

4
web/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@goauthentik/web",
"version": "2025.12.0-rc1",
"version": "2025.12.0-rc3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@goauthentik/web",
"version": "2025.12.0-rc1",
"version": "2025.12.0-rc3",
"license": "MIT",
"workspaces": [
"./packages/*"

View File

@@ -1,6 +1,6 @@
{
"name": "@goauthentik/web",
"version": "2025.12.0-rc1",
"version": "2025.12.0-rc3",
"license": "MIT",
"private": true,
"scripts": {

View File

@@ -52,7 +52,7 @@ const EmittedLocalesDirectory = resolve(
);
const targetLocales = localizeRules.targetLocales.filter((localeCode) => {
return localeCode !== "pseudo-LOCALE";
return localeCode !== "en-XA";
});
//#endregion

View File

@@ -23,7 +23,7 @@ import { makeFormatter } from "@lit/localize-tools/lib/formatters/index.js";
import { sortProgramMessages } from "@lit/localize-tools/lib/messages.js";
import { TransformLitLocalizer } from "@lit/localize-tools/lib/modes/transform.js";
const pseudoLocale = /** @type {Locale} */ ("pseudo-LOCALE");
const pseudoLocale = /** @type {Locale} */ ("en-XA");
const targetLocales = [pseudoLocale];
const __dirname = fileURLToPath(new URL(".", import.meta.url));

View File

@@ -14,17 +14,6 @@
}
}
.pf-c-page__main,
.pf-c-drawer__content,
.pf-c-page__drawer {
z-index: auto !important;
background-color: transparent;
}
.display-none {
display: none;
}
ak-page-navbar {
grid-area: header;
}
@@ -43,5 +32,5 @@ ak-sidebar-item:active ak-sidebar-item::part(list-item) {
}
.pf-c-drawer__panel {
z-index: var(--pf-global--ZIndex--xl);
background-color: transparent !important;
}

View File

@@ -2,8 +2,6 @@ import "#admin/AdminInterface/AboutModal";
import "#elements/banner/EnterpriseStatusBanner";
import "#elements/banner/VersionBanner";
import "#elements/messages/MessageContainer";
import "#elements/notifications/APIDrawer";
import "#elements/notifications/NotificationDrawer";
import "#elements/router/RouterOutlet";
import "#elements/sidebar/Sidebar";
import "#elements/sidebar/SidebarItem";
@@ -15,15 +13,22 @@ import {
} from "./AdminSidebar.js";
import { isAPIResultReady } from "#common/api/responses";
import { EVENT_API_DRAWER_TOGGLE, EVENT_NOTIFICATION_DRAWER_TOGGLE } from "#common/constants";
import { configureSentry } from "#common/sentry/index";
import { isGuest } from "#common/users";
import { WebsocketClient } from "#common/ws";
import { WebsocketClient } from "#common/ws/WebSocketClient";
import { AuthenticatedInterface } from "#elements/AuthenticatedInterface";
import { listen } from "#elements/decorators/listen";
import { WithCapabilitiesConfig } from "#elements/mixins/capabilities";
import { WithNotifications } from "#elements/mixins/notifications";
import { canAccessAdmin, WithSession } from "#elements/mixins/session";
import { getURLParam, updateURLParams } from "#elements/router/RouteMatch";
import { AKDrawerChangeEvent } from "#elements/notifications/events";
import {
DrawerState,
persistDrawerParams,
readDrawerParams,
renderNotificationDrawerPanel,
} from "#elements/notifications/utils";
import { PageNavMenuToggle } from "#components/ak-page-navbar";
@@ -34,29 +39,37 @@ import { ROUTES } from "#admin/Routes";
import { CapabilitiesEnum } from "@goauthentik/api";
import { CSSResult, html, nothing, PropertyValues, TemplateResult } from "lit";
import { customElement, property, query } from "lit/decorators.js";
import { customElement, property, query, state } from "lit/decorators.js";
import { classMap } from "lit/directives/class-map.js";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFDrawer from "@patternfly/patternfly/components/Drawer/drawer.css";
import PFNav from "@patternfly/patternfly/components/Nav/nav.css";
import PFPage from "@patternfly/patternfly/components/Page/page.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
if (process.env.NODE_ENV === "development") {
await import("@goauthentik/esbuild-plugin-live-reload/client");
}
@customElement("ak-interface-admin")
export class AdminInterface extends WithCapabilitiesConfig(WithSession(AuthenticatedInterface)) {
export class AdminInterface extends WithCapabilitiesConfig(
WithNotifications(WithSession(AuthenticatedInterface)),
) {
//#region Styles
public static readonly styles: CSSResult[] = [
// ---
PFPage,
PFButton,
PFDrawer,
PFNav,
Styles,
];
//#endregion
//#region Properties
@property({ type: Boolean })
public notificationDrawerOpen = getURLParam("notificationDrawerOpen", false);
@property({ type: Boolean })
public apiDrawerOpen = getURLParam("apiDrawerOpen", false);
@query("ak-about-modal")
public aboutModal?: AboutModal;
@@ -74,19 +87,14 @@ export class AdminInterface extends WithCapabilitiesConfig(WithSession(Authentic
//#endregion
//#region Styles
@state()
protected drawer: DrawerState = readDrawerParams();
static styles: CSSResult[] = [
// ---
PFBase,
PFPage,
PFButton,
PFDrawer,
PFNav,
Styles,
];
//#endregion
@listen(AKDrawerChangeEvent)
protected drawerListener = (event: AKDrawerChangeEvent) => {
this.drawer = event.drawer;
persistDrawerParams(event.drawer);
};
//#region Lifecycle
@@ -99,6 +107,7 @@ export class AdminInterface extends WithCapabilitiesConfig(WithSession(Authentic
this.#sidebarMatcher = window.matchMedia("(min-width: 1200px)");
this.sidebarOpen = this.#sidebarMatcher.matches;
this.addEventListener(PageNavMenuToggle.eventName, this.#onPageNavMenuEvent, {
passive: true,
});
@@ -107,20 +116,6 @@ export class AdminInterface extends WithCapabilitiesConfig(WithSession(Authentic
public connectedCallback() {
super.connectedCallback();
window.addEventListener(EVENT_NOTIFICATION_DRAWER_TOGGLE, () => {
this.notificationDrawerOpen = !this.notificationDrawerOpen;
updateURLParams({
notificationDrawerOpen: this.notificationDrawerOpen,
});
});
window.addEventListener(EVENT_API_DRAWER_TOGGLE, () => {
this.apiDrawerOpen = !this.apiDrawerOpen;
updateURLParams({
apiDrawerOpen: this.apiDrawerOpen,
});
});
this.#sidebarMatcher.addEventListener("change", this.#sidebarMediaQueryListener, {
passive: true,
});
@@ -128,6 +123,7 @@ export class AdminInterface extends WithCapabilitiesConfig(WithSession(Authentic
public disconnectedCallback(): void {
super.disconnectedCallback();
this.#sidebarMatcher.removeEventListener("change", this.#sidebarMediaQueryListener);
WebsocketClient.close();
@@ -154,11 +150,10 @@ export class AdminInterface extends WithCapabilitiesConfig(WithSession(Authentic
"pf-m-collapsed": !this.sidebarOpen,
};
const drawerOpen = this.notificationDrawerOpen || this.apiDrawerOpen;
const openDrawerCount = (this.drawer.notifications ? 1 : 0) + (this.drawer.api ? 1 : 0);
const drawerClasses = {
"pf-m-expanded": drawerOpen,
"pf-m-collapsed": !drawerOpen,
"pf-m-expanded": openDrawerCount !== 0,
"pf-m-collapsed": openDrawerCount === 0,
};
return html`<div class="pf-c-page">
@@ -190,18 +185,7 @@ export class AdminInterface extends WithCapabilitiesConfig(WithSession(Authentic
</ak-router-outlet>
</div>
</div>
<ak-notification-drawer
class="pf-c-drawer__panel pf-m-width-33 ${this.notificationDrawerOpen
? ""
: "display-none"}"
?hidden=${!this.notificationDrawerOpen}
></ak-notification-drawer>
<ak-api-drawer
class="pf-c-drawer__panel pf-m-width-33 ${this.apiDrawerOpen
? ""
: "display-none"}"
?hidden=${!this.apiDrawerOpen}
></ak-api-drawer>
${renderNotificationDrawerPanel(this.drawer)}
<ak-about-modal></ak-about-modal>
</div>
</div>

View File

@@ -91,7 +91,11 @@ export class AdminOverviewPage extends AdminOverviewBase {
</ak-aggregate-card>
</div>
<div class="pf-l-grid__item pf-m-12-col pf-m-12-col-on-xl pf-m-4-col-on-2xl">
<ak-aggregate-card icon="fa fa-sync-alt" label=${msg("Sync status")}>
<ak-aggregate-card
icon="fa fa-sync-alt"
label=${msg("Sync status")}
tooltip=${msg("Integrations synced in the last 12 hours.")}
>
<ak-admin-status-chart-sync></ak-admin-status-chart-sync>
</ak-aggregate-card>
</div>

View File

@@ -65,7 +65,7 @@ export class SyncStatusChart extends AKChart<SummarizedSyncStatus[]> {
const status = await fetchSyncStatus(element);
const now = new Date().getTime();
const maxDelta = 3600000; // 1 hour
const maxDelta = 12 * 60 * 60 * 1000; // 12 hours
if (
status.lastSyncStatus === TaskAggregatedStatusEnum.Error ||

View File

@@ -99,6 +99,9 @@ export class ApplicationForm extends WithCapabilitiesConfig(ModelForm<Applicatio
const alertMsg = msg(
"Using this form will only create an Application. In order to authenticate with the application, you will have to manually pair it with a Provider.",
);
const providerFromInstance = this.instance?.provider;
const providerValue = providerFromInstance ?? this.provider;
const providerPrefilled = !this.instance && this.provider !== undefined;
return html`
${this.instance ? nothing : html`<ak-alert level="pf-m-info">${alertMsg}</ak-alert>`}
@@ -134,9 +137,10 @@ export class ApplicationForm extends WithCapabilitiesConfig(ModelForm<Applicatio
<ak-provider-search-input
name="provider"
label=${msg("Provider")}
value=${ifPresent(this.instance?.provider)}
.value=${providerValue}
.readOnly=${providerPrefilled}
?blankable=${!providerPrefilled}
help=${msg("Select a provider that this application should use.")}
blankable
></ak-provider-search-input>
<ak-backchannel-providers-input
name="backchannelProviders"

View File

@@ -13,6 +13,7 @@ import { Provider, ProvidersAllListRequest, ProvidersApi } from "@goauthentik/ap
import { html, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
const renderElement = (item: Provider) => item.name;
const renderValue = (item: Provider | undefined) => item?.pk;
@@ -53,6 +54,9 @@ export class AkProviderInput extends AKElement {
@property({ type: Number })
value?: number;
@property({ type: Boolean, attribute: "readonly" })
readOnly = false;
@property({ type: Boolean })
required = false;
@@ -76,6 +80,8 @@ export class AkProviderInput extends AKElement {
};
render() {
const readOnlyValue = this.readOnly && typeof this.value === "number";
return html` <ak-form-element-horizontal name=${this.name}>
${AKLabel(
{
@@ -86,7 +92,9 @@ export class AkProviderInput extends AKElement {
},
this.label,
)}
${readOnlyValue
? html`<input type="hidden" name=${this.name} value=${this.value ?? ""} />`
: nothing}
<ak-search-select
.fieldID=${this.fieldID}
.selected=${this.#selected}
@@ -94,7 +102,9 @@ export class AkProviderInput extends AKElement {
.renderElement=${renderElement}
.value=${renderValue}
.groupBy=${doGroupBy}
?blankable=${!!this.blankable}
?blankable=${readOnlyValue ? false : !!this.blankable}
?readonly=${this.readOnly}
name=${ifDefined(readOnlyValue ? undefined : this.name)}
>
</ak-search-select>
${this.help ? html`<p class="pf-c-form__helper-text">${this.help}</p>` : nothing}

View File

@@ -2,6 +2,7 @@ import "#elements/forms/DeleteBulkForm";
import "#elements/forms/ModalForm";
import "#admin/endpoints/DeviceAccessGroupForm";
import "#admin/policies/BoundPoliciesList";
import "@patternfly/elements/pf-tooltip/pf-tooltip.js";
import { DEFAULT_CONFIG } from "#common/api/config";
@@ -39,17 +40,19 @@ export class DeviceAccessGroupsListPage extends TablePage<DeviceAccessGroup> {
row(item: DeviceAccessGroup): SlottedTemplateResult[] {
return [
html`${item.name}`,
html`<ak-forms-modal>
<span slot="submit">${msg("Update")}</span>
<span slot="header">${msg("Update Group")}</span>
<ak-endpoints-device-access-groups-form slot="form" pk=${item.pbmUuid}>
</ak-endpoints-device-access-groups-form>
<button slot="trigger" class="pf-c-button pf-m-plain">
<pf-tooltip position="top" content=${msg("Edit")}>
<i class="fas fa-edit" aria-hidden="true"></i>
</pf-tooltip>
</button>
</ak-forms-modal>`,
html`<div>
<ak-forms-modal>
<span slot="submit">${msg("Update")}</span>
<span slot="header">${msg("Update Group")}</span>
<ak-endpoints-device-access-groups-form slot="form" .instancePk=${item.pbmUuid}>
</ak-endpoints-device-access-groups-form>
<button slot="trigger" class="pf-c-button pf-m-plain">
<pf-tooltip position="top" content=${msg("Edit")}>
<i class="fas fa-edit" aria-hidden="true"></i>
</pf-tooltip>
</button>
</ak-forms-modal>
</div>`,
];
}

View File

@@ -44,23 +44,25 @@ export class ConnectorsListPage extends TablePage<Connector> {
return [
html`<a href="#/endpoints/connectors/${item.connectorUuid}">${item.name}</a>`,
html`${item.verboseName}`,
html`<ak-forms-modal>
<span slot="submit">${msg("Update")}</span>
<span slot="header">${msg("Update Connector")}</span>
<ak-proxy-form
slot="form"
.args=${{
instancePk: item.connectorUuid,
}}
type=${ifDefined(item.component)}
>
</ak-proxy-form>
<button slot="trigger" class="pf-c-button pf-m-plain">
<pf-tooltip position="top" content=${msg("Edit")}>
<i class="fas fa-edit" aria-hidden="true"></i>
</pf-tooltip>
</button>
</ak-forms-modal>`,
html`<div>
<ak-forms-modal>
<span slot="submit">${msg("Update")}</span>
<span slot="header">${msg("Update Connector")}</span>
<ak-proxy-form
slot="form"
.args=${{
instancePk: item.connectorUuid,
}}
type=${ifDefined(item.component)}
>
</ak-proxy-form>
<button slot="trigger" class="pf-c-button pf-m-plain">
<pf-tooltip position="top" content=${msg("Edit")}>
<i class="fas fa-edit" aria-hidden="true"></i>
</pf-tooltip>
</button>
</ak-forms-modal>
</div>`,
];
}

View File

@@ -15,7 +15,6 @@ import { ModelForm } from "#elements/forms/ModelForm";
import { WithBrandConfig } from "#elements/mixins/branding";
import { ifPresent } from "#elements/utils/attributes";
import { gidStartNumberHelp, uidStartNumberHelp } from "#admin/providers/ldap/LDAPOptionsAndHelp";
import {
oauth2ProvidersProvider,
oauth2ProvidersSelector,
@@ -62,9 +61,11 @@ export class AgentConnectorForm extends WithBrandConfig(ModelForm<AgentConnector
renderForm() {
return html`<ak-text-input
name="name"
placeholder=${msg("Connector name...")}
placeholder=${msg("Type a connector name...")}
label=${msg("Connector name")}
value=${ifDefined(this.instance?.name)}
input-hint="code"
autofocus
required
></ak-text-input>
<ak-text-input
@@ -183,15 +184,19 @@ export class AgentConnectorForm extends WithBrandConfig(ModelForm<AgentConnector
label=${msg("NSS User ID offset")}
required
name="nssUidOffset"
value="${this.instance?.nssUidOffset ?? 1000}"
help=${uidStartNumberHelp}
value="${this.instance?.nssUidOffset ?? 2000}"
help=${msg(
"The start for user ID numbers, this number is added to the user ID to make sure that the numbers aren't too low for POSIX users. Default is 2000 to prevent collisions with local users.",
)}
></ak-number-input>
<ak-number-input
label=${msg("NSS Group ID offset")}
required
name="nssGidOffset"
value="${this.instance?.nssGidOffset ?? 1000}"
help=${gidStartNumberHelp}
value="${this.instance?.nssGidOffset ?? 4000}"
help=${msg(
"The start for group ID numbers, this number is added to a number generated from the groups' ID to make sure that the numbers aren't too low for POSIX groups. Default is 4000 to prevent collisions with local groups.",
)}
></ak-number-input>
</div>
</ak-form-group>`;

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