Compare commits

..

88 Commits

Author SHA1 Message Date
Teffen Ellis
d1d1d1597f Remove stub. 2025-12-08 19:48:57 +01:00
Teffen Ellis
c9427cd6c1 web: Remove PFBase. 2025-12-08 19:48:57 +01:00
authentik-automation[bot]
08551f1b46 *: Auto compress images (#18673)
* *: compress images

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

* ci trigger

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>

---------

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Co-authored-by: gergosimonyi <28359278+gergosimonyi@users.noreply.github.com>
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2025-12-08 14:18:31 +00:00
Dewi Roberts
6663cacfb4 website/integrations: update kimai doc (#18629)
* Update doc

* NameID
2025-12-08 14:06:02 +00:00
Jens L.
ff91edd70d root: skip current tab when refreshing others (#18674)
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-12-08 14:57:36 +01:00
Simonyi Gergő
f7e23295ed core: add digraph group hierarchy (#17050)
* move imports

* core: add digraph group hierarchy

* move to permissions from Group or User to Role

* set group parents on frontend

* do not serialize `GroupParentageNode` directly

* core: enforce unique group name on database level

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

* use group parents in LDAP provider

* add user-role relationship control to frontend

* move materialized view to be more discoverable

* add guardian to mypy exceptions

* make `Role` a `ManagedModel`

* fixup! make `Role` a `ManagedModel`

* simplify `get_objects_for_user`

* fix flaky unit test

* rename `django-guardian` fork to `ak-guardian`

* add tests around users/groups/roles

* remove unused guardian config variable

* simplify guardian file structure

* clean up frontend

* initial docs

* remove `mode` from `InitialPermissions`

This is no longer needed, since users no longer directly have permissions.

* fixup! Merge branch 'main' into core/add-digraph-group-hierarchy

* clean up docs for managing permissions

* addendums from docs review

* fixup! Merge branch 'main' into core/add-digraph-group-hierarchy

* tweaks

* dewi and tana edits to docs

* tweak

* truly final tweaks, for now

* relabel Role Permissions table

* clarify button label

* fixup! Merge branch 'main' into core/add-digraph-group-hierarchy

* fixup! Merge branch 'main' into core/add-digraph-group-hierarchy

* merge migrations

* fixup! Merge branch 'main' into core/add-digraph-group-hierarchy

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Tana M Berry <tana@goauthentik.io>
2025-12-08 12:04:04 +01:00
dependabot[bot]
d54409c5dd core: bump astral-sh/uv from 0.9.15 to 0.9.16 (#18668)
Bumps [astral-sh/uv](https://github.com/astral-sh/uv) from 0.9.15 to 0.9.16.
- [Release notes](https://github.com/astral-sh/uv/releases)
- [Changelog](https://github.com/astral-sh/uv/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/uv/compare/0.9.15...0.9.16)

---
updated-dependencies:
- dependency-name: astral-sh/uv
  dependency-version: 0.9.16
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-08 11:13:40 +01:00
dependabot[bot]
bebd725d25 core: bump goauthentik.io/api/v3 from 3.2025120.16 to 3.2025120.18 (#18661)
Bumps [goauthentik.io/api/v3](https://github.com/goauthentik/client-go) from 3.2025120.16 to 3.2025120.18.
- [Release notes](https://github.com/goauthentik/client-go/releases)
- [Commits](https://github.com/goauthentik/client-go/compare/v3.2025120.16...v3.2025120.18)

---
updated-dependencies:
- dependency-name: goauthentik.io/api/v3
  dependency-version: 3.2025120.18
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-08 11:13:16 +01:00
dependabot[bot]
a1ded8a837 web: bump type-fest from 5.3.0 to 5.3.1 in /web (#18663)
Bumps [type-fest](https://github.com/sindresorhus/type-fest) from 5.3.0 to 5.3.1.
- [Release notes](https://github.com/sindresorhus/type-fest/releases)
- [Commits](https://github.com/sindresorhus/type-fest/compare/v5.3.0...v5.3.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-08 11:11:39 +01:00
dependabot[bot]
7ea083f16c ci: bump peter-evans/create-pull-request from 7.0.9 to 7.0.11 (#18666)
Bumps [peter-evans/create-pull-request](https://github.com/peter-evans/create-pull-request) from 7.0.9 to 7.0.11.
- [Release notes](https://github.com/peter-evans/create-pull-request/releases)
- [Commits](84ae59a2cd...22a9089034)

---
updated-dependencies:
- dependency-name: peter-evans/create-pull-request
  dependency-version: 7.0.11
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-08 11:11:24 +01:00
dependabot[bot]
306921ac8a web: bump vite from 7.2.6 to 7.2.7 in /web (#18662)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 7.2.6 to 7.2.7.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v7.2.7/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.2.7/packages/vite)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-08 11:11:09 +01:00
dependabot[bot]
c255b086da core: bump goauthentik/fips-debian from a80dbbd to 10c8086 (#18665)
Bumps goauthentik/fips-debian from `a80dbbd` to `10c8086`.

---
updated-dependencies:
- dependency-name: goauthentik/fips-debian
  dependency-version: trixie-slim-fips
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-08 11:10:48 +01:00
dependabot[bot]
35f6c9204c ci: bump actions/create-github-app-token from 2.2.0 to 2.2.1 (#18664)
Bumps [actions/create-github-app-token](https://github.com/actions/create-github-app-token) from 2.2.0 to 2.2.1.
- [Release notes](https://github.com/actions/create-github-app-token/releases)
- [Commits](7e473efe3c...29824e69f5)

---
updated-dependencies:
- dependency-name: actions/create-github-app-token
  dependency-version: 2.2.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-08 11:10:32 +01:00
dependabot[bot]
a627396dcb ci: bump astral-sh/setup-uv from 7.1.4 to 7.1.5 in /.github/actions/setup (#18667)
ci: bump astral-sh/setup-uv in /.github/actions/setup

Bumps [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) from 7.1.4 to 7.1.5.
- [Release notes](https://github.com/astral-sh/setup-uv/releases)
- [Commits](1e862dfacb...ed21f2f24f)

---
updated-dependencies:
- dependency-name: astral-sh/setup-uv
  dependency-version: 7.1.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-08 11:09:40 +01:00
Dominic R
888733a32c website/docs: background tasks: add more detail about "next run" (#18660) 2025-12-08 09:07:32 +00:00
Dominic R
fa579c2ba5 website/docs: install-config: fix dump_config command (#18659) 2025-12-08 09:06:28 +00:00
Dominic R
8a200fd715 website/integrations: wordpress: fix redirect uri (#18658) 2025-12-08 09:06:10 +00:00
Jens L.
37ca47312d stages/mtls: always include cert in flow plan (#18657)
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-12-08 01:58:21 +01:00
Jens L.
475ab76a5e endpoints: fix UI bugs, add user binding, etc (#18609)
* fix serializer for device user binding

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

* fix missing import

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

* don't expire enrollment tokens by default

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

* slightly better config modal error handling

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

* add ability to bind to device

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

* add text when authenticating to device

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

* prevent error when no authz flow is set

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

* add device to token log

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

* address comments

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

* rework config

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

* fix expiring default

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

* don't require page refresh for enrollment token to show up

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

* make config

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

* fix tests

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

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-12-08 01:13:29 +01:00
Jens L.
a0fe677efd sources/ldap: make server info optional (#18648)
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-12-07 16:57:49 +01:00
Jens L.
3548d5e30d web/admin: fix event volume chart not updating with query (#18649)
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-12-07 14:34:40 +01:00
dependabot[bot]
8e87585fce web: Bump types, fix ESLint errors (#17546)
* Fix config.

* Fix linter.

* Fix ts ignore comments.

* Fix empty functions

* Fix unnamed functions.

* Fix unused parameters.

* Fix define before use.

* Remove unused.

* Replace esbuild-copy-plugin with `fs` module.

---------

Co-authored-by: Teffen Ellis <teffen@goauthentik.io>
2025-12-06 20:21:29 +00:00
Teffen Ellis
31b0e73329 web: Fix row expansion on modal trigger buttons. (#18412)
web: Fix row expansion on modal triggers.
2025-12-06 12:10:17 -05:00
Connor Peshek
859a753e24 docs/integrations: add salesforce oauth source and SCIM steps (#18627) 2025-12-06 04:11:52 -06:00
Jens L.
dbbfb3cf19 root: fix missing authentik_device cookie causing error (#18642)
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-12-06 03:00:56 +01:00
Jens L.
6d7249ea56 enterprise/stages/mtls: fix traefik certificate parsing (#18607)
* enterprise/stages/mtls: fix traefik certificate parsing

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

* fix tests

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

* add links for relevant docs

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

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-12-05 19:06:20 +01:00
Dewi Roberts
a07e820bce wed/admin: change s to S in "Stage" (#18632)
change s to S in "Stage"
2025-12-05 16:11:52 +00:00
Jens L.
31186baf25 flows: refresh unauthenticated tabs (#18621)
* flows: implement signaling

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

* add flag

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

* better flag configuration

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

* format

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

* Update web/src/flow/FlowExecutor.ts

Co-authored-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com>
Signed-off-by: Jens L. <jens@beryju.org>

* format

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

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Signed-off-by: Jens L. <jens@beryju.org>
Co-authored-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com>
2025-12-05 16:03:16 +01:00
Jens L.
024e6c1961 flows: keep ?next url when using cancel (#18619)
keep ?next url when using cancel

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-12-05 15:35:15 +01:00
authentik-automation[bot]
1244a40ffb core, web: update translations (#18620)
Co-authored-by: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
2025-12-05 15:18:42 +01:00
dependabot[bot]
dcfe722f5c ci: bump actions/setup-node from 6.0.0 to 6.1.0 (#18552)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6.0.0 to 6.1.0.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](2028fbc5c2...395ad32622)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: 6.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-05 14:37:31 +01:00
dependabot[bot]
6b1171aac8 core: bump goauthentik/fips-debian from cf233be to a80dbbd (#18594)
Bumps goauthentik/fips-debian from `cf233be` to `a80dbbd`.

---
updated-dependencies:
- dependency-name: goauthentik/fips-debian
  dependency-version: trixie-slim-fips
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-05 14:37:12 +01:00
dependabot[bot]
b2d5519611 web: bump @sentry/browser from 10.28.0 to 10.29.0 in /web in the sentry group across 1 directory (#18623)
web: bump @sentry/browser in /web in the sentry group across 1 directory

Bumps the sentry group with 1 update in the /web directory: [@sentry/browser](https://github.com/getsentry/sentry-javascript).


Updates `@sentry/browser` from 10.28.0 to 10.29.0
- [Release notes](https://github.com/getsentry/sentry-javascript/releases)
- [Changelog](https://github.com/getsentry/sentry-javascript/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-javascript/compare/10.28.0...10.29.0)

---
updated-dependencies:
- dependency-name: "@sentry/browser"
  dependency-version: 10.29.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: sentry
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-05 14:36:55 +01:00
Dewi Roberts
1620a96cd4 website/docs: adds note about ak_create_jwt function (#18614)
* Adds note

* Apply suggestion from @tanberry

Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
Signed-off-by: Dominic R <dominic@sdko.org>

---------

Signed-off-by: Dominic R <dominic@sdko.org>
Co-authored-by: Dominic R <dominic@sdko.org>
Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
2025-12-05 09:35:53 +00:00
Jens L.
a42fc4b741 api: fix IPC auth (#18612)
* api: fix IPC auth

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

* add tests

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

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-12-04 22:50:50 +01:00
dependabot[bot]
9b822ce0fd web: bump mermaid from 11.12.1 to 11.12.2 in /web (#18602)
Bumps [mermaid](https://github.com/mermaid-js/mermaid) from 11.12.1 to 11.12.2.
- [Release notes](https://github.com/mermaid-js/mermaid/releases)
- [Commits](https://github.com/mermaid-js/mermaid/compare/mermaid@11.12.1...mermaid@11.12.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-04 14:27:27 -05:00
Teffen Ellis
05c30af790 web: Codemirror fixes (#18610)
* web: Dynamic Loading of Codemirror

* Clarify error.

* Fix labels, links

* Fix key maps, tabbing

* Remove dupe.

* Update web/src/elements/codemirror/editor.ts

Co-authored-by: Ken Sternberg <133134217+kensternberg-authentik@users.noreply.github.com>
Signed-off-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com>

* Fix inversion of opacity.

* Format.

* Fix import.

* Fix imports.

* Fix static styles using getters.

- Seems to be a merge conflict from long ago.

* Fix typo.

* Fix capitalization.

---------

Signed-off-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com>
Co-authored-by: Ken Sternberg <133134217+kensternberg-authentik@users.noreply.github.com>
2025-12-04 19:15:43 +00:00
dependabot[bot]
6683d9943c web: bump packages in /web (#18604)
* web: bump playwright from 1.56.1 to 1.57.0 in /web

Bumps [playwright](https://github.com/microsoft/playwright) from 1.56.1 to 1.57.0.
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.56.1...v1.57.0)

---
updated-dependencies:
- dependency-name: playwright
  dependency-version: 1.57.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* Bump Playwright related.

* Fix package upgrade log jam.

* Format.

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Teffen Ellis <teffen@goauthentik.io>
2025-12-04 19:15:14 +00:00
Dominic R
17ef75c19f website/docs: expressions: fix markdown (#18613) 2025-12-04 18:19:42 +00:00
Dewi Roberts
d8428bf59a website/docs: add missing API sidebar entry (#18586)
Adds missing sidebar entry
2025-12-04 11:53:51 -05:00
dependabot[bot]
3ef06094b5 web: bump yaml from 2.8.1 to 2.8.2 in /web (#18605)
Bumps [yaml](https://github.com/eemeli/yaml) from 2.8.1 to 2.8.2.
- [Release notes](https://github.com/eemeli/yaml/releases)
- [Commits](https://github.com/eemeli/yaml/compare/v2.8.1...v2.8.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-04 11:33:44 -05:00
Marc 'risson' Schmitt
6b22487406 web/elements: update AppIcon story with files change (#18608)
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2025-12-04 16:28:57 +00:00
Jens L.
0fa412e782 api: test action decorator (#18583)
* api: validate usage of action decorator

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

* rework auth

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

# Conflicts:
#	authentik/enterprise/endpoints/connectors/agent/auth.py

* refactor auth

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

* fixup things

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

* fix

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

* fix outpost token

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

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-12-04 16:44:04 +01:00
Jens L.
334c0175f9 crypto: separate permissions for certificate and private keydownload (#18588)
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-12-04 16:31:52 +01:00
dependabot[bot]
3c2f39559f core: bump github.com/spf13/cobra from 1.10.1 to 1.10.2 (#18592)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-04 15:31:18 +00:00
dependabot[bot]
d05ad4403b core: bump goauthentik.io/api/v3 from 3.2025120.15 to 3.2025120.16 (#18591)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-04 15:55:19 +01:00
authentik-automation[bot]
10866f9dfc core, web: update translations (#18587)
Co-authored-by: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
2025-12-04 15:54:09 +01:00
Marcelo Elizeche Landó
97f0c6475d core: bump boto3 from 1.40.75 to v1.42.1 (#18571) 2025-12-04 15:47:59 +01:00
Marcelo Elizeche Landó
0f6cb9183e core: bump asgiref from 3.10.0 to v3.11.0 (#18568) 2025-12-04 15:47:40 +01:00
Marcelo Elizeche Landó
499c1b6fab core: bump autobahn from 25.10.2 to v25.11.1 (#18569) 2025-12-04 15:47:21 +01:00
Marcelo Elizeche Landó
362d67ca6e core: bump blessed from 1.24.0 to v1.25.0 (#18570) 2025-12-04 15:47:09 +01:00
Marcelo Elizeche Landó
abe944b8c9 core: bump cron-converter from 1.2.2 to v1.3.1 (#18572) 2025-12-04 15:47:05 +01:00
Marcelo Elizeche Landó
bba9643864 core: bump django-stubs-ext from 5.2.7 to v5.2.8 (#18574) 2025-12-04 15:46:30 +01:00
Marcelo Elizeche Landó
467af902f1 core: bump django-pgactivity from 1.7.1 to v1.8.0 (#18573) 2025-12-04 15:45:59 +01:00
Marcelo Elizeche Landó
e28a8aacc7 core: bump rpds-py from 0.29.0 to v0.30.0 (#18579) 2025-12-04 15:45:43 +01:00
Marcelo Elizeche Landó
af0444b0dd core: bump opentelemetry-api from 1.38.0 to v1.39.0 (#18577) 2025-12-04 15:45:33 +01:00
Marcelo Elizeche Landó
8fcf60ecce core: bump incremental from 24.7.2 to v24.11.0 (#18575) 2025-12-04 15:45:21 +01:00
Marcelo Elizeche Landó
10ebbcfd61 core: bump jsii from 1.119.0 to v1.120.0 (#18576) 2025-12-04 15:45:07 +01:00
Marcelo Elizeche Landó
6a1bde1fd8 core: bump psycopg-pool from 3.2.7 to v3.3.0 (#18578) 2025-12-04 15:45:03 +01:00
Marcelo Elizeche Landó
6d5092a394 core: bump sqlparse from 0.5.3 to v0.5.4 (#18580) 2025-12-04 15:44:52 +01:00
Marcelo Elizeche Landó
0a3763b82b core: bump stevedore from 5.5.0 to v5.6.0 (#18581) 2025-12-04 15:44:47 +01:00
Marcelo Elizeche Landó
6a80490fdb core: bump django from v5.2.8 to 5.2.9 (#18582)
bump django from v5.2.8 to 5.2.9
2025-12-04 15:44:34 +01:00
Teffen Ellis
74266a1e3d web: bump base package (#18509)
* web: bump base package.

* Fix dependabot groups.

* Add root package files to code owners.

* Format.

* Update packages.

* Add dev engines.
2025-12-04 14:30:32 +01:00
Dominic R
29a9e31143 stages/captcha: Make stage more managed with provider-specific defaults (#16129) 2025-12-03 23:18:45 +00:00
Jens L.
e2df658d88 endpoints/stage: v2.1, fix asymmetric token exchange and missing form input (#18547)
* fix oauth federated providers not configurable

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

* fix federated auth not working with asymmetric keys

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

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-12-04 00:09:07 +01:00
dependabot[bot]
302898a00a build(deps): bump django from 5.2.8 to 5.2.9 (#18566)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2025-12-03 16:31:16 +00:00
Teffen Ellis
18663bffa5 web: Adjust colors (#18427)
* Fix contrast in dark mode.

* Fix hover color.

* web: Fix danger button hover background color.

* web: Adjust colors, padding.

* web: Fix sidebar colors, padding.

* Normalize colors.
2025-12-03 16:27:56 +00:00
Marc 'risson' Schmitt
f46159bb3a admin/files: delete applications cache on migration (#18565) 2025-12-03 16:22:44 +00:00
dependabot[bot]
ea19094c46 core: bump astral-sh/uv from 0.9.14 to 0.9.15 (#18555)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-03 13:59:30 +00:00
dependabot[bot]
cc3ebb29ad core: bump goauthentik.io/api/v3 from 3.2025120.11 to 3.2025120.15 (#18551)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-03 14:42:52 +01:00
dependabot[bot]
bbc9943bb3 core: bump goauthentik/fips-debian from c718f60 to cf233be (#18553)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-03 14:42:04 +01:00
dependabot[bot]
f9d3e91106 ci: bump actions/checkout from 6.0.0 to 6.0.1 (#18554)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-03 14:41:48 +01:00
dependabot[bot]
7a6f1f3165 ci: bump actions/stale from 10.1.0 to 10.1.1 (#18556)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-03 14:40:56 +01:00
dependabot[bot]
c78b5c36bb ci: bump golangci/golangci-lint-action from 9.1.0 to 9.2.0 (#18557)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-03 14:40:48 +01:00
dependabot[bot]
4ac6825e9f ci: bump actions/setup-node from 6.0.0 to 6.1.0 in /.github/actions/setup (#18559)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-03 14:40:41 +01:00
dependabot[bot]
41162d3ad2 core: bump library/golang from 1.25.4-trixie to 1.25.5-trixie (#18558)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-03 14:40:33 +01:00
Dominic R
c1cfeaf4b5 providers/scim: cache ServiceProviderConfig (#18047) 2025-12-03 08:07:00 -05:00
Teffen Ellis
fe7a8894d3 web/i18n: Locale Context Merge Branch (#18426)
* web: Update fonts to Patternfly 5 variants.

* Fix order of heading override.

* web: Flesh out locale context.

* Fix Han pattern.

* Remove comment.

* Add additional regional codes.

* Clarify comment.

* Fix typos.

* web/i18n: Add locale-specific font overrides.

* Fix stale session in locale lifecycle.

* core, web: Fix Han language codes.

* Fix warnings about invalid BCP language code.

* Build translations.

* Add locale relative labels.

* Add locale translations for Finnish and Portuguese.

* Fix XLIFF errors.

* Clean up labels.

* Tidy regions.

* Match region comment.

* Update extracted values.

* Fix locale switch not triggering on source language.

* Split labels.

* Clean up labels.
2025-12-03 06:30:07 +00:00
Dominic R
96eb8dda0f website: Glossary (#16007)
* website: Glossary

fix minor issues

wip

Apply suggestion from @dominic-r

Signed-off-by: Dominic R <dominic@sdko.org>

anchor to param

wip

wip

at least the lockfile changes now

sure

a-z first as tana asked

idk why i switched in the first place

wip

wip

lock

lockfiles are hard

wip

please work

no have?

Revert "no have?"

This reverts commit 743dbc1bc2900eedcc2c93af248e6afdec3688a3.

* changed to sentence-case capitalization

---------

Co-authored-by: Tana M Berry <tana@goauthentik.io>
2025-12-02 21:36:51 -05:00
Jens L.
d0ef8a8b8e endpoints/stage: v2, better error handling, more settings (#18545)
* add options, idle fallback

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

* delete other device tokens during enroll

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

* better error handling

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

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-12-02 22:25:47 +01:00
Teffen Ellis
1474c65e11 website: Docusaurus 3.9.2 (#18506) 2025-12-02 19:17:52 +00:00
shcherbak
324a6de47c website/integrations: add hoop.dev (#17868)
Co-authored-by: iops <iops@syneforge.com>
Co-authored-by: Dominic R <dominic@sdko.org>
2025-12-02 18:59:28 +00:00
Jens L.
bee733b484 web/flows: update default background image (#18540)
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-12-02 19:25:15 +01:00
Jens L.
5ccd66ddca endpoints: implement endpoint stage (#18468)
* endpoints: implement endpoint stage

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

* format

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

* fix mismatched label

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

* fix url in mdm config

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

* rephrase

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

* and API & UI

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

* add deprecated support and deprecate gdtc

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

* add stage mode

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

* fixup

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

* rework stage slightly, add frontend

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

* include jwks, add iat and exp

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

* fix tests

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

* set kid

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

* include device details in event list

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

* format

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

* implement device summary

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

* add remaining tables

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

* revert sanitize

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

* fix uuid format issues

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

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-12-02 19:19:14 +01:00
Connor Peshek
b8e15ad0d0 website/integrations: add salesforce (#18516)
Co-authored-by: connor peshek <connorpeshek@connors-MacBook-Pro.local>
Co-authored-by: dewi-tik <dewi@goauthentik.io>
Co-authored-by: Dominic R <dominic@sdko.org>
2025-12-02 18:02:25 +00:00
Konrad Mösch
39f8969f51 core: custom avatar url improvements (#10525)
Co-authored-by: Dominic R <dominic@sdko.org>
2025-12-02 12:49:54 -05:00
Connor Peshek
45ee4af451 sources/oauth: save returned oauth refresh tokens and add slack provider (#18501)
* sources/oauth: save returned oauth refresh tokens

* Update authentik/sources/oauth/models.py

Co-authored-by: Jens L. <jens@goauthentik.io>
Signed-off-by: Connor Peshek <connor@connorpeshek.me>

* lint

* add tests

* fix proper id setting

* update id test

---------

Signed-off-by: Connor Peshek <connor@connorpeshek.me>
Co-authored-by: connor peshek <connorpeshek@unknown1641287c8f5d.attlocal.net>
Co-authored-by: Jens L. <jens@goauthentik.io>
Co-authored-by: connor peshek <connorpeshek@connors-MacBook-Pro.local>
2025-12-02 11:49:40 -06:00
Marc 'risson' Schmitt
c30d1a478d files: rework (#17535)
Co-authored-by: Dominic R <dominic@sdko.org>
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Tana M Berry <tana@goauthentik.io>
2025-12-02 18:01:51 +01:00
754 changed files with 24685 additions and 13223 deletions

View File

@@ -21,7 +21,7 @@ runs:
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
- name: Install uv
if: ${{ contains(inputs.dependencies, 'python') }}
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v5
uses: astral-sh/setup-uv@ed21f2f24f8dd64503750218de024bcf64c7250a # v5
with:
enable-cache: true
- name: Setup python
@@ -35,7 +35,7 @@ runs:
run: uv sync --all-extras --dev --frozen
- name: Setup node
if: ${{ contains(inputs.dependencies, 'node') }}
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v4
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v4
with:
node-version-file: web/package.json
cache: "npm"

View File

@@ -16,7 +16,24 @@ services:
ports:
- 6379:6379
restart: always
s3:
container_name: s3
image: docker.io/zenko/cloudserver
environment:
REMOTE_MANAGEMENT_DISABLE: "1"
SCALITY_ACCESS_KEY_ID: accessKey1
SCALITY_SECRET_ACCESS_KEY: secretKey1
ports:
- 8020:8000
volumes:
- s3-data:/usr/src/app/localData
- s3-metadata:/usr/scr/app/localMetadata
restart: always
volumes:
db-data:
driver: local
s3-data:
driver: local
s3-metadata:
driver: local

119
.github/dependabot.yml vendored
View File

@@ -1,5 +1,7 @@
version: 2
updates:
#region Github Actions
- package-ecosystem: "github-actions"
directories:
- /
@@ -18,6 +20,11 @@ updates:
prefix: "ci:"
labels:
- dependencies
#endregion
#region Golang
- package-ecosystem: gomod
directory: "/"
schedule:
@@ -28,16 +35,16 @@ updates:
prefix: "core:"
labels:
- dependencies
#endregion
#region Web
- package-ecosystem: npm
directories:
- "/"
- "/web"
- "/web/packages/sfe"
- "/web/packages/core"
- "/packages/esbuild-plugin-live-reload"
- "/packages/prettier-config"
- "/packages/tsconfig"
- "/packages/docusaurus-config"
- "/packages/eslint-config"
- "/web/packages/*"
schedule:
interval: daily
time: "04:00"
@@ -50,7 +57,6 @@ updates:
sentry:
patterns:
- "@sentry/*"
- "@spotlightjs/*"
babel:
patterns:
- "@babel/*"
@@ -66,10 +72,12 @@ updates:
patterns:
- "@storybook/*"
- "*storybook*"
esbuild:
bundler:
patterns:
- "@esbuild/*"
- "esbuild*"
- "@vitest/*"
- "vitest"
rollup:
patterns:
- "@rollup/*"
@@ -79,9 +87,6 @@ updates:
patterns:
- "@swc/*"
- "swc-*"
wdio:
patterns:
- "@wdio/*"
goauthentik:
patterns:
- "@goauthentik/*"
@@ -91,6 +96,74 @@ updates:
- "react-dom"
- "@types/react"
- "@types/react-dom"
#endregion
#region NPM Packages
- package-ecosystem: npm
directories:
- "/packages/esbuild-plugin-live-reload"
- "/packages/prettier-config"
- "/packages/tsconfig"
- "/packages/docusaurus-config"
- "/packages/eslint-config"
schedule:
interval: daily
time: "04:00"
labels:
- dependencies
open-pull-requests-limit: 10
commit-message:
prefix: "core, web:"
groups:
sentry:
patterns:
- "@sentry/*"
babel:
patterns:
- "@babel/*"
- "babel-*"
eslint:
patterns:
- "@eslint/*"
- "@typescript-eslint/*"
- "eslint-*"
- "eslint"
- "typescript-eslint"
storybook:
patterns:
- "@storybook/*"
- "*storybook*"
bundler:
patterns:
- "@esbuild/*"
- "esbuild*"
- "@vitest/*"
- "vitest"
rollup:
patterns:
- "@rollup/*"
- "rollup-*"
- "rollup*"
swc:
patterns:
- "@swc/*"
- "swc-*"
goauthentik:
patterns:
- "@goauthentik/*"
react:
patterns:
- "react"
- "react-dom"
- "@types/react"
- "@types/react-dom"
#endregion
# #region Documentation
- package-ecosystem: npm
directory: "/website"
schedule:
@@ -105,6 +178,7 @@ updates:
docusaurus:
patterns:
- "@docusaurus/*"
- "@goauthentik/docusaurus-config"
build:
patterns:
- "@swc/*"
@@ -113,7 +187,9 @@ updates:
- "@rspack/binding*"
goauthentik:
patterns:
- "@goauthentik/*"
- "@goauthentik/eslint-config"
- "@goauthentik/prettier-config"
- "@goauthentik/tsconfig"
eslint:
patterns:
- "@eslint/*"
@@ -121,6 +197,11 @@ updates:
- "eslint-*"
- "eslint"
- "typescript-eslint"
#endregion
# AWS Lifecycle
- package-ecosystem: npm
directory: "/lifecycle/aws"
schedule:
@@ -131,6 +212,11 @@ updates:
prefix: "lifecycle/aws:"
labels:
- dependencies
#endregion
#region Python
- package-ecosystem: uv
directory: "/"
schedule:
@@ -141,6 +227,11 @@ updates:
prefix: "core:"
labels:
- dependencies
#endregion
#region Docker
- package-ecosystem: docker
directories:
- /
@@ -166,3 +257,5 @@ updates:
prefix: "core:"
labels:
- dependencies
#endregion

View File

@@ -42,7 +42,7 @@ jobs:
# Needed for checkout
contents: read
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3
- name: prepare variables
@@ -74,7 +74,7 @@ jobs:
mkdir -p ./gen-go-api
- name: Setup node
if: ${{ !inputs.release }}
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v5
with:
node-version-file: web/package.json
cache: "npm"

View File

@@ -49,7 +49,7 @@ jobs:
tags: ${{ steps.ev.outputs.imageTagsJSON }}
shouldPush: ${{ steps.ev.outputs.shouldPush }}
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- name: prepare variables
uses: ./.github/actions/docker-push-variables
id: ev
@@ -69,7 +69,7 @@ jobs:
matrix:
tag: ${{ fromJson(needs.get-tags.outputs.tags) }}
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- name: prepare variables
uses: ./.github/actions/docker-push-variables
id: ev

View File

@@ -18,14 +18,14 @@ jobs:
runs-on: ubuntu-latest
steps:
- id: generate_token
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
with:
token: ${{ steps.generate_token.outputs.token }}
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v5
with:
node-version-file: web/package.json
registry-url: "https://registry.npmjs.org"
@@ -46,7 +46,7 @@ jobs:
run: |
export VERSION=`node -e 'console.log(require("../gen-ts-api/package.json").version)'`
npm i @goauthentik/api@$VERSION
- uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7
- uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7
id: cpr
with:
token: ${{ steps.generate_token.outputs.token }}

View File

@@ -21,7 +21,7 @@ jobs:
command:
- prettier-check
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- name: Install Dependencies
working-directory: website/
run: npm ci
@@ -32,8 +32,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v5
with:
node-version-file: website/package.json
cache: "npm"
@@ -66,12 +66,12 @@ jobs:
- lint
- build
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v5
with:
name: api-docs
path: website/api/build
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v5
with:
node-version-file: website/package.json
cache: "npm"

View File

@@ -21,10 +21,10 @@ jobs:
check-changes-applied:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- name: Setup authentik env
uses: ./.github/actions/setup
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v5
with:
node-version-file: lifecycle/aws/package.json
cache: "npm"

View File

@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 120
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- name: Setup authentik env
uses: ./.github/actions/setup
- name: generate docs

View File

@@ -21,7 +21,7 @@ jobs:
command:
- prettier-check
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- name: Install dependencies
working-directory: website/
run: npm ci
@@ -32,8 +32,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v5
with:
node-version-file: website/package.json
cache: "npm"
@@ -48,8 +48,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v5
with:
node-version-file: website/package.json
cache: "npm"
@@ -69,7 +69,7 @@ jobs:
id-token: write
attestations: write
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up QEMU

View File

@@ -18,7 +18,7 @@ jobs:
- version-2025-4
- version-2025-2
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- run: |
current="$(pwd)"
dir="/tmp/authentik/${{ matrix.version }}"

View File

@@ -37,7 +37,7 @@ jobs:
- mypy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- name: Setup authentik env
uses: ./.github/actions/setup
- name: run job
@@ -45,7 +45,7 @@ jobs:
test-migrations:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- name: Setup authentik env
uses: ./.github/actions/setup
- name: run migrations
@@ -71,7 +71,7 @@ jobs:
- 18-alpine
run_id: [1, 2, 3, 4, 5]
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
with:
fetch-depth: 0
- name: checkout stable
@@ -136,7 +136,7 @@ jobs:
- 18-alpine
run_id: [1, 2, 3, 4, 5]
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- name: Setup authentik env
uses: ./.github/actions/setup
with:
@@ -156,7 +156,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- name: Setup authentik env
uses: ./.github/actions/setup
- name: Create k8s Kind Cluster
@@ -194,7 +194,7 @@ jobs:
- name: flows
glob: tests/e2e/test_flows*
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- name: Setup authentik env
uses: ./.github/actions/setup
- name: Setup e2e env (chrome, etc)
@@ -260,7 +260,7 @@ jobs:
pull-requests: write
timeout-minutes: 120
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: prepare variables

View File

@@ -21,7 +21,7 @@ jobs:
lint-golint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6
with:
go-version-file: "go.mod"
@@ -34,7 +34,7 @@ jobs:
- name: Generate API
run: make gen-client-go
- name: golangci-lint
uses: golangci/golangci-lint-action@e7fa5ac41e1cf5b7d48e45e42232ce7ada589601 # v8
uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v8
with:
version: latest
args: --timeout 5000s --verbose
@@ -42,7 +42,7 @@ jobs:
test-unittest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6
with:
go-version-file: "go.mod"
@@ -86,7 +86,7 @@ jobs:
id-token: write
attestations: write
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up QEMU
@@ -145,13 +145,13 @@ jobs:
goos: [linux]
goarch: [amd64, arm64]
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
with:
ref: ${{ github.event.pull_request.head.sha }}
- uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6
with:
go-version-file: "go.mod"
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v5
with:
node-version-file: web/package.json
cache: "npm"

View File

@@ -31,8 +31,8 @@ jobs:
- command: lit-analyse
project: web
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v5
with:
node-version-file: ${{ matrix.project }}/package.json
cache: "npm"
@@ -48,8 +48,8 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v5
with:
node-version-file: web/package.json
cache: "npm"
@@ -76,8 +76,8 @@ jobs:
- ci-web-mark
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v5
with:
node-version-file: web/package.json
cache: "npm"

View File

@@ -29,11 +29,11 @@ jobs:
github.event.pull_request.head.repo.full_name == github.repository)
steps:
- id: generate_token
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
with:
token: ${{ steps.generate_token.outputs.token }}
- name: Compress images
@@ -42,7 +42,7 @@ jobs:
with:
GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }}
compressOnly: ${{ github.event_name != 'pull_request' }}
- uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7
- uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7
if: "${{ github.event_name != 'pull_request' && steps.compress.outputs.markdown != '' }}"
id: cpr
with:

View File

@@ -16,17 +16,17 @@ jobs:
runs-on: ubuntu-latest
steps:
- id: generate_token
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
with:
token: ${{ steps.generate_token.outputs.token }}
- name: Setup authentik env
uses: ./.github/actions/setup
- run: uv run ak update_webauthn_mds
- uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7
- uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7
id: cpr
with:
token: ${{ steps.generate_token.outputs.token }}

View File

@@ -10,14 +10,14 @@ jobs:
steps:
- id: app-token
name: Generate app token
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
if: ${{ env.GH_APP_ID != '' }}
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
env:
GH_APP_ID: ${{ secrets.GH_APP_ID }}
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
if: ${{ steps.app-token.outcome != 'skipped' }}
with:
fetch-depth: 0

View File

@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- name: Cleanup
run: |

View File

@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- id: generate_token
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}

View File

@@ -31,10 +31,10 @@ jobs:
- packages/docusaurus-config
- packages/esbuild-plugin-live-reload
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
with:
fetch-depth: 2
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v5
with:
node-version-file: ${{ matrix.package }}/package.json
registry-url: "https://registry.npmjs.org"

View File

@@ -24,7 +24,7 @@ jobs:
language: ["go", "javascript", "python"]
steps:
- name: Checkout repository
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- name: Setup authentik env
uses: ./.github/actions/setup
- name: Initialize CodeQL

View File

@@ -26,5 +26,5 @@ jobs:
image: semgrep/semgrep
if: (github.actor != 'dependabot[bot]')
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- run: semgrep ci

View File

@@ -29,12 +29,12 @@ jobs:
steps:
- id: app-token
name: Generate app token
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Checkout main
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
with:
ref: main
token: "${{ steps.app-token.outputs.token }}"
@@ -57,12 +57,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- id: generate_token
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Checkout main
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
with:
ref: main
token: ${{ steps.generate_token.outputs.token }}
@@ -73,7 +73,7 @@ jobs:
- name: Bump version
run: "make bump version=${{ inputs.next_version }}.0-rc1"
- name: Create pull request
uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7
uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7
with:
token: ${{ steps.generate_token.outputs.token }}
branch: release-bump-${{ inputs.next_version }}

View File

@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
environment: internal-production
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
with:
ref: main
- run: |

View File

@@ -31,7 +31,7 @@ jobs:
id-token: write
attestations: write
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Set up Docker Buildx
@@ -83,7 +83,7 @@ jobs:
- radius
- rac
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6
with:
go-version-file: "go.mod"
@@ -146,11 +146,11 @@ jobs:
goos: [linux, darwin]
goarch: [amd64, arm64]
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6
with:
go-version-file: "go.mod"
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v5
with:
node-version-file: web/package.json
cache: "npm"
@@ -186,7 +186,7 @@ jobs:
AWS_REGION: eu-central-1
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708 # v5
with:
role-to-assume: "arn:aws:iam::016170277896:role/github_goauthentik_authentik"
@@ -202,7 +202,7 @@ jobs:
- build-outpost-binary
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- name: Run test suite in final docker images
run: |
echo "PG_PASS=$(openssl rand 32 | base64 -w 0)" >> .env
@@ -218,7 +218,7 @@ jobs:
- build-outpost-binary
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- name: prepare variables
uses: ./.github/actions/docker-push-variables
id: ev

View File

@@ -50,7 +50,7 @@ jobs:
name: Pre-release test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- run: make test-docker
bump-authentik:
name: Bump authentik version
@@ -61,7 +61,7 @@ jobs:
steps:
- id: app-token
name: Generate app token
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
@@ -70,7 +70,7 @@ jobs:
run: echo "user-id=$(gh api "/users/${{ steps.app-token.outputs.app-slug }}[bot]" --jq .id)" >> "$GITHUB_OUTPUT"
env:
GH_TOKEN: "${{ steps.app-token.outputs.token }}"
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
with:
ref: "version-${{ needs.check-inputs.outputs.major_version }}"
token: "${{ steps.app-token.outputs.token }}"
@@ -108,7 +108,7 @@ jobs:
steps:
- id: app-token
name: Generate app token
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
@@ -118,7 +118,7 @@ jobs:
run: echo "user-id=$(gh api "/users/${{ steps.app-token.outputs.app-slug }}[bot]" --jq .id)" >> "$GITHUB_OUTPUT"
env:
GH_TOKEN: "${{ steps.app-token.outputs.token }}"
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
with:
repository: "${{ github.repository_owner }}/helm"
token: "${{ steps.app-token.outputs.token }}"
@@ -130,7 +130,7 @@ jobs:
sed -E -i 's/[0-9]{4}\.[0-9]{1,2}\.[0-9]+$/${{ inputs.version }}/' charts/authentik/Chart.yaml
./scripts/helm-docs.sh
- name: Create pull request
uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7
uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7
with:
token: "${{ steps.app-token.outputs.token }}"
branch: bump-${{ inputs.version }}
@@ -150,7 +150,7 @@ jobs:
steps:
- id: app-token
name: Generate app token
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
@@ -160,7 +160,7 @@ jobs:
run: echo "user-id=$(gh api "/users/${{ steps.app-token.outputs.app-slug }}[bot]" --jq .id)" >> "$GITHUB_OUTPUT"
env:
GH_TOKEN: "${{ steps.app-token.outputs.token }}"
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
with:
repository: "${{ github.repository_owner }}/version"
token: "${{ steps.app-token.outputs.token }}"
@@ -185,7 +185,7 @@ jobs:
'.stable.version = $version | .stable.changelog = $changelog | .stable.changelog_url = $changelog_url' version.json > version.new.json
mv version.new.json version.json
- name: Create pull request
uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7
uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7
with:
token: "${{ steps.app-token.outputs.token }}"
branch: bump-${{ inputs.version }}

View File

@@ -15,11 +15,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- id: generate_token
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10
- uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10
with:
repo-token: ${{ steps.generate_token.outputs.token }}
days-before-stale: 60

View File

@@ -21,15 +21,15 @@ jobs:
steps:
- id: generate_token
if: ${{ github.event_name != 'pull_request' }}
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
if: ${{ github.event_name != 'pull_request' }}
with:
token: ${{ steps.generate_token.outputs.token }}
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v5
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
if: ${{ github.event_name == 'pull_request' }}
- name: Setup authentik env
uses: ./.github/actions/setup
@@ -44,7 +44,7 @@ jobs:
make web-check-compile
- name: Create Pull Request
if: ${{ github.event_name != 'pull_request' }}
uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7
uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7
with:
token: ${{ steps.generate_token.outputs.token }}
branch: extract-compile-backend-translation

View File

@@ -11,6 +11,9 @@
"[jsonc]": {
"editor.minimap.markSectionHeaderRegex": "#\\bregion\\s*(?<separator>-?)\\s*(?<label>.*)$"
},
"[xml]": {
"editor.minimap.markSectionHeaderRegex": "<!--\\s*#\\bregion\\s*(?<separator>-?)\\s*(?<label>.*)\\s*-->"
},
"todo-tree.tree.showCountsInTree": true,
"todo-tree.tree.showBadges": true,
"yaml.customTags": [

View File

@@ -28,6 +28,8 @@ packages/django-channels-postgres @goauthentik/backend
packages/django-postgres-cache @goauthentik/backend
packages/django-dramatiq-postgres @goauthentik/backend
# Web packages
packages/package.json @goauthentik/backend @goauthentik/frontend
packages/package-lock.json @goauthentik/backend @goauthentik/frontend
packages/docusaurus-config @goauthentik/frontend
packages/esbuild-plugin-live-reload @goauthentik/frontend
packages/eslint-config @goauthentik/frontend

View File

@@ -26,7 +26,7 @@ RUN npm run build && \
npm run build:sfe
# Stage 2: Build go proxy
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.25.4-trixie@sha256:a02d35efc036053fdf0da8c15919276bf777a80cbfda6a35c5e9f087e652adfc AS go-builder
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.25.5-trixie@sha256:4f9d98ebaa759f776496d850e0439c48948d587b191fc3949b5f5e4667abef90 AS go-builder
ARG TARGETOS
ARG TARGETARCH
@@ -76,7 +76,7 @@ RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \
/bin/sh -c "GEOIPUPDATE_LICENSE_KEY_FILE=/run/secrets/GEOIPUPDATE_LICENSE_KEY /usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0"
# Stage 4: Download uv
FROM ghcr.io/astral-sh/uv:0.9.14@sha256:fef8e5fb8809f4b57069e919ffcd1529c92b432a2c8d8ad1768087b0b018d840 AS uv
FROM ghcr.io/astral-sh/uv:0.9.16@sha256:ae9ff79d095a61faf534a882ad6378e8159d2ce322691153d68d2afac7422840 AS uv
# Stage 5: Base python image
FROM ghcr.io/goauthentik/fips-python:3.13.9-slim-trixie-fips@sha256:700fc8c1e290bd14e5eaca50b1d8e8c748c820010559cbfb4c4f8dfbe2c4c9ff AS python-base
@@ -163,10 +163,11 @@ RUN apt-get update && \
apt-get clean && \
rm -rf /tmp/* /var/lib/apt/lists/* /var/tmp/ && \
adduser --system --no-create-home --uid 1000 --group --home /authentik authentik && \
mkdir -p /certs /media /blueprints && \
mkdir -p /certs /data /media /blueprints && \
ln -s /media /data/media && \
mkdir -p /authentik/.ssh && \
mkdir -p /ak-root && \
chown authentik:authentik /certs /media /authentik/.ssh /ak-root
chown authentik:authentik /certs /data /data/media /media /authentik/.ssh /ak-root
COPY ./authentik/ /authentik
COPY ./pyproject.toml /

View File

@@ -0,0 +1,258 @@
import mimetypes
from django.db.models import Q
from django.utils.translation import gettext as _
from drf_spectacular.utils import extend_schema
from guardian.shortcuts import get_objects_for_user
from rest_framework.exceptions import ValidationError
from rest_framework.fields import BooleanField, CharField, ChoiceField, FileField
from rest_framework.parsers import MultiPartParser
from rest_framework.permissions import SAFE_METHODS
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from authentik.admin.files.fields import FileField as AkFileField
from authentik.admin.files.manager import get_file_manager
from authentik.admin.files.usage import FileApiUsage
from authentik.admin.files.validation import validate_upload_file_name
from authentik.api.validation import validate
from authentik.core.api.used_by import DeleteAction, UsedBySerializer
from authentik.core.api.utils import PassiveSerializer
from authentik.events.models import Event, EventAction
from authentik.lib.utils.reflection import get_apps
from authentik.rbac.permissions import HasPermission
MAX_FILE_SIZE_BYTES = 25 * 1024 * 1024 # 25MB
def get_mime_from_filename(filename: str) -> str:
mime_type, _ = mimetypes.guess_type(filename)
return mime_type or "application/octet-stream"
class FileView(APIView):
pagination_class = None
parser_classes = [MultiPartParser]
def get_permissions(self):
return [
HasPermission(
"authentik_rbac.view_media_files"
if self.request.method in SAFE_METHODS
else "authentik_rbac.manage_media_files"
)()
]
class FileListParameters(PassiveSerializer):
usage = ChoiceField(choices=list(FileApiUsage), default=FileApiUsage.MEDIA.value)
search = CharField(required=False)
manageable_only = BooleanField(required=False, default=False)
class FileListSerializer(PassiveSerializer):
name = CharField()
mime_type = CharField()
url = CharField()
@extend_schema(
parameters=[FileListParameters],
responses={200: FileListSerializer(many=True)},
)
@validate(FileListParameters, location="query")
def get(self, request: Request, query: FileListParameters) -> Response:
"""List files from storage backend."""
params = query.validated_data
try:
usage = FileApiUsage(params.get("usage", FileApiUsage.MEDIA.value))
except ValueError as exc:
raise ValidationError(
f"Invalid usage parameter provided: {params.get('usage')}"
) from exc
# Backend is source of truth - list all files from storage
manager = get_file_manager(usage)
files = manager.list_files(manageable_only=params.get("manageable_only", False))
search_query = params.get("search", "")
if search_query:
files = filter(lambda file: search_query in file.lower(), files)
files = [
FileView.FileListSerializer(
data={
"name": file,
"url": manager.file_url(file),
"mime_type": get_mime_from_filename(file),
}
)
for file in files
]
for file in files:
file.is_valid(raise_exception=True)
return Response([file.data for file in files])
class FileUploadSerializer(PassiveSerializer):
file = FileField(required=True)
name = CharField(required=False, allow_blank=True)
usage = CharField(required=False, default=FileApiUsage.MEDIA.value)
@extend_schema(
request=FileUploadSerializer,
responses={200: None},
)
@validate(FileUploadSerializer)
def post(self, request: Request, body: FileUploadSerializer) -> Response:
"""Upload file to storage backend."""
file = body.validated_data["file"]
name = body.validated_data.get("name", "").strip()
usage_value = body.validated_data.get("usage", FileApiUsage.MEDIA.value)
# Validate file size and type
if file.size > MAX_FILE_SIZE_BYTES:
raise ValidationError(
{
"file": [
_(
f"File size ({file.size}B) exceeds maximum allowed "
f"size ({MAX_FILE_SIZE_BYTES}B)."
)
]
}
)
try:
usage = FileApiUsage(usage_value)
except ValueError as exc:
raise ValidationError(f"Invalid usage parameter provided: {usage_value}") from exc
# Use original filename
if not name:
name = file.name
# Sanitize path to prevent directory traversal
validate_upload_file_name(name, ValidationError)
manager = get_file_manager(usage)
# Check if file already exists
if manager.file_exists(name):
raise ValidationError({"name": ["A file with this name already exists."]})
# Save to backend
with manager.save_file_stream(name) as f:
f.write(file.read())
Event.new(
EventAction.MODEL_CREATED,
model={
"app": "authentik_admin_files",
"model_name": "File",
"pk": name,
"name": name,
"usage": usage.value,
"mime_type": get_mime_from_filename(name),
},
).from_http(request)
return Response()
class FileDeleteParameters(PassiveSerializer):
name = CharField()
usage = ChoiceField(choices=list(FileApiUsage), default=FileApiUsage.MEDIA.value)
@extend_schema(
parameters=[FileDeleteParameters],
responses={200: None},
)
@validate(FileDeleteParameters, location="query")
def delete(self, request: Request, query: FileDeleteParameters) -> Response:
"""Delete file from storage backend."""
params = query.validated_data
validate_upload_file_name(params.get("name", ""), ValidationError)
try:
usage = FileApiUsage(params.get("usage", FileApiUsage.MEDIA.value))
except ValueError as exc:
raise ValidationError(
f"Invalid usage parameter provided: {params.get('usage')}"
) from exc
manager = get_file_manager(usage)
# Delete from backend
manager.delete_file(params.get("name"))
# Audit log for file deletion
Event.new(
EventAction.MODEL_DELETED,
model={
"app": "authentik_admin_files",
"model_name": "File",
"pk": params.get("name"),
"name": params.get("name"),
"usage": usage.value,
},
).from_http(request)
return Response()
class FileUsedByView(APIView):
pagination_class = None
def get_permissions(self):
return [
HasPermission(
"authentik_rbac.view_media_files"
if self.request.method in SAFE_METHODS
else "authentik_rbac.manage_media_files"
)()
]
class FileUsedByParameters(PassiveSerializer):
name = CharField()
@extend_schema(
parameters=[FileUsedByParameters],
responses={200: UsedBySerializer(many=True)},
)
@validate(FileUsedByParameters, location="query")
def get(self, request: Request, query: FileUsedByParameters) -> Response:
params = query.validated_data
models_and_fields = {}
for app in get_apps():
for model in app.get_models():
if model._meta.abstract:
continue
for field in model._meta.get_fields():
if isinstance(field, AkFileField):
models_and_fields.setdefault(model, []).append(field.name)
used_by = []
for model, fields in models_and_fields.items():
app = model._meta.app_label
model_name = model._meta.model_name
q = Q()
for field in fields:
q |= Q(**{field: params.get("name")})
objs = get_objects_for_user(request.user, f"{app}.view_{model_name}", model)
objs = objs.filter(q)
for obj in objs:
serializer = UsedBySerializer(
data={
"app": model._meta.app_label,
"model_name": model._meta.model_name,
"pk": str(obj.pk),
"name": str(obj),
"action": DeleteAction.LEFT_DANGLING,
}
)
serializer.is_valid()
used_by.append(serializer.data)
return Response(used_by)

View File

@@ -0,0 +1,8 @@
from authentik.blueprints.apps import ManagedAppConfig
class AuthentikFilesConfig(ManagedAppConfig):
name = "authentik.admin.files"
label = "authentik_admin_files"
verbose_name = "authentik Files"
default = True

View File

@@ -0,0 +1,134 @@
from collections.abc import Generator, Iterator
from django.http.request import HttpRequest
from structlog.stdlib import get_logger
from authentik.admin.files.usage import FileUsage
LOGGER = get_logger()
class Backend:
"""
Base class for file storage backends.
Class attributes:
allowed_usages: List of usages that can be used with this backend
"""
allowed_usages: list[FileUsage]
def __init__(self, usage: FileUsage):
"""
Initialize backend for the given usage type.
Args:
usage: FileUsage type enum value
"""
self.usage = usage
LOGGER.debug(
"Initializing storage backend",
backend=self.__class__.__name__,
usage=usage.value,
)
def supports_file(self, name: str) -> bool:
"""
Check if this backend can handle the given file path.
Args:
name: File path to check
Returns:
True if this backend supports this file path
"""
raise NotImplementedError
def list_files(self) -> Generator[str]:
"""
List all files stored in this backend.
Yields:
Relative file paths
"""
raise NotImplementedError
def file_url(self, name: str, request: HttpRequest | None = None) -> str:
"""
Get URL for accessing the file.
Args:
file_path: Relative file path
request: Optional Django HttpRequest for fully qualifed URL building
Returns:
URL to access the file (may be relative or absolute depending on backend)
"""
raise NotImplementedError
class ManageableBackend(Backend):
"""
Base class for manageable file storage backends.
Class attributes:
name: Canonical name of the storage backend, for use in configuration.
"""
name: str
@property
def manageable(self) -> bool:
"""
Whether this backend can actually be used for management.
Used only for management check, not for created the backend
"""
raise NotImplementedError
def save_file(self, name: str, content: bytes) -> None:
"""
Save file content to storage.
Args:
file_path: Relative file path
content: File content as bytes
"""
raise NotImplementedError
def save_file_stream(self, name: str) -> Iterator:
"""
Context manager for streaming file writes.
Args:
file_path: Relative file path
Returns:
Context manager that yields a writable file-like object
FileUsage:
with backend.save_file_stream("output.csv") as f:
f.write(b"data...")
"""
raise NotImplementedError
def delete_file(self, name: str) -> None:
"""
Delete file from storage.
Args:
file_path: Relative file path
"""
raise NotImplementedError
def file_exists(self, name: str) -> bool:
"""
Check if a file exists.
Args:
file_path: Relative file path
Returns:
True if file exists, False otherwise
"""
raise NotImplementedError

View File

@@ -0,0 +1,114 @@
import os
from collections.abc import Generator, Iterator
from contextlib import contextmanager
from datetime import timedelta
from hashlib import sha256
from pathlib import Path
import jwt
from django.conf import settings
from django.db import connection
from django.http.request import HttpRequest
from django.utils.timezone import now
from authentik.admin.files.backends.base import ManageableBackend
from authentik.admin.files.usage import FileUsage
from authentik.lib.config import CONFIG
from authentik.lib.utils.time import timedelta_from_string
class FileBackend(ManageableBackend):
"""Local filesystem backend for file storage.
Stores files in a local directory structure:
- Path: {base_dir}/{usage}/{schema}/{filename}
- Supports full file management (upload, delete, list)
- Used when storage.backend=file (default)
"""
name = "file"
allowed_usages = list(FileUsage) # All usages
@property
def _base_dir(self) -> Path:
return Path(
CONFIG.get(
f"storage.{self.usage.value}.{self.name}.path",
CONFIG.get(f"storage.{self.name}.path", "./data"),
)
)
@property
def base_path(self) -> Path:
"""Path structure: {base_dir}/{usage}/{schema}"""
return self._base_dir / self.usage.value / connection.schema_name
@property
def manageable(self) -> bool:
return (
self.base_path.exists()
and (self._base_dir.is_mount() or (self._base_dir / self.usage.value).is_mount())
or (settings.DEBUG or settings.TEST)
)
def supports_file(self, name: str) -> bool:
"""We support all files"""
return True
def list_files(self) -> Generator[str]:
"""List all files returning relative paths from base_path."""
for root, _, files in os.walk(self.base_path):
for file in files:
full_path = Path(root) / file
rel_path = full_path.relative_to(self.base_path)
yield str(rel_path)
def file_url(self, name: str, request: HttpRequest | None = None) -> str:
"""Get URL for accessing the file."""
expires_in = timedelta_from_string(
CONFIG.get(
f"storage.{self.usage.value}.{self.name}.url_expiry",
CONFIG.get(f"storage.{self.name}.url_expiry", "minutes=15"),
)
)
prefix = CONFIG.get("web.path", "/")[:-1]
path = f"{self.usage.value}/{connection.schema_name}/{name}"
token = jwt.encode(
payload={
"path": path,
"exp": now() + expires_in,
"nbf": now() - timedelta(seconds=15),
},
key=sha256(f"{settings.SECRET_KEY}:{self.usage}".encode()).hexdigest(),
algorithm="HS256",
)
url = f"{prefix}/files/{path}?token={token}"
if request is None:
return url
return request.build_absolute_uri(url)
def save_file(self, name: str, content: bytes) -> None:
"""Save file to local filesystem."""
path = self.base_path / Path(name)
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w+b") as f:
f.write(content)
@contextmanager
def save_file_stream(self, name: str) -> Iterator:
"""Context manager for streaming file writes to local filesystem."""
path = self.base_path / Path(name)
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "wb") as f:
yield f
def delete_file(self, name: str) -> None:
"""Delete file from local filesystem."""
path = self.base_path / Path(name)
path.unlink(missing_ok=True)
def file_exists(self, name: str) -> bool:
"""Check if a file exists."""
path = self.base_path / Path(name)
return path.exists()

View File

@@ -0,0 +1,43 @@
from collections.abc import Generator
from django.http.request import HttpRequest
from authentik.admin.files.backends.base import Backend
from authentik.admin.files.usage import FileUsage
EXTERNAL_URL_SCHEMES = ["http:", "https://"]
FONT_AWESOME_SCHEME = "fa://"
class PassthroughBackend(Backend):
"""Passthrough backend for external URLs and special schemes.
Handles external resources that aren't stored in authentik:
- Font Awesome icons (fa://...)
- HTTP/HTTPS URLs (http://..., https://...)
Files that are "managed" by this backend are just passed through as-is.
No upload, delete, or listing operations are supported.
Only accessible through resolve_file_url when an external URL is detected.
"""
allowed_usages = [FileUsage.MEDIA]
def supports_file(self, name: str) -> bool:
"""Check if file path is an external URL or Font Awesome icon."""
if name.startswith(FONT_AWESOME_SCHEME):
return True
for scheme in EXTERNAL_URL_SCHEMES:
if name.startswith(scheme):
return True
return False
def list_files(self) -> Generator[str]:
"""External files cannot be listed."""
yield from []
def file_url(self, name: str, request: HttpRequest | None = None) -> str:
"""Return the URL as-is for passthrough files."""
return name

View File

@@ -0,0 +1,213 @@
from collections.abc import Generator, Iterator
from contextlib import contextmanager
from tempfile import SpooledTemporaryFile
from urllib.parse import urlsplit
import boto3
from botocore.config import Config
from botocore.exceptions import ClientError
from django.db import connection
from django.http.request import HttpRequest
from authentik.admin.files.backends.base import ManageableBackend
from authentik.admin.files.usage import FileUsage
from authentik.lib.config import CONFIG
from authentik.lib.utils.time import timedelta_from_string
class S3Backend(ManageableBackend):
"""S3-compatible object storage backend.
Stores files in s3-compatible storage:
- Key prefix: {usage}/{schema}/{filename}
- Supports full file management (upload, delete, list)
- Generates presigned URLs for file access
- Used when storage.backend=s3
"""
allowed_usages = list(FileUsage) # All usages
name = "s3"
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._config = {}
self._session = None
def _get_config(self, key: str, default: str | None) -> tuple[str | None, bool]:
unset = object()
current = self._config.get(key, unset)
refreshed = CONFIG.refresh(
f"storage.{self.usage.value}.{self.name}.{key}",
CONFIG.refresh(f"storage.{self.name}.{key}", default),
)
if current is unset:
current = refreshed
self._config[key] = refreshed
return (refreshed, current != refreshed)
@property
def base_path(self) -> str:
"""S3 key prefix: {usage}/{schema}/"""
return f"{self.usage.value}/{connection.schema_name}"
@property
def bucket_name(self) -> str:
return CONFIG.get(
f"storage.{self.usage.value}.{self.name}.bucket_name",
CONFIG.get(f"storage.{self.name}.bucket_name"),
)
@property
def session(self) -> boto3.Session:
"""Create boto3 session with configured credentials."""
session_profile, session_profile_r = self._get_config("session_profile", None)
if session_profile is not None:
if session_profile_r or self._session is None:
self._session = boto3.Session(profile_name=session_profile)
return self._session
else:
return self._session
else:
access_key, access_key_r = self._get_config("access_key", None)
secret_key, secret_key_r = self._get_config("secret_key", None)
session_token, session_token_r = self._get_config("session_token", None)
if access_key_r or secret_key_r or session_token_r or self._session is None:
self._session = boto3.Session(
aws_access_key_id=access_key,
aws_secret_access_key=secret_key,
aws_session_token=session_token,
)
return self._session
else:
return self._session
@property
def client(self):
"""Create S3 client with configured endpoint and region."""
endpoint_url = CONFIG.get(
f"storage.{self.usage.value}.{self.name}.endpoint",
CONFIG.get(f"storage.{self.name}.endpoint", None),
)
use_ssl = CONFIG.get(
f"storage.{self.usage.value}.{self.name}.use_ssl",
CONFIG.get(f"storage.{self.name}.use_ssl", True),
)
region_name = CONFIG.get(
f"storage.{self.usage.value}.{self.name}.region",
CONFIG.get(f"storage.{self.name}.region", None),
)
addressing_style = CONFIG.get(
f"storage.{self.usage.value}.{self.name}.addressing_style",
CONFIG.get(f"storage.{self.name}.addressing_style", "auto"),
)
return self.session.client(
"s3",
endpoint_url=endpoint_url,
use_ssl=use_ssl,
region_name=region_name,
config=Config(signature_version="s3v4", s3={"addressing_style": addressing_style}),
)
@property
def manageable(self) -> bool:
return True
def supports_file(self, name: str) -> bool:
"""We support all files"""
return True
def list_files(self) -> Generator[str]:
"""List all files returning relative paths from base_path."""
paginator = self.client.get_paginator("list_objects_v2")
pages = paginator.paginate(Bucket=self.bucket_name, Prefix=f"{self.base_path}/")
for page in pages:
for obj in page.get("Contents", []):
key = obj["Key"]
# Remove base path prefix to get relative path
rel_path = key.removeprefix(f"{self.base_path}/")
if rel_path: # Skip if it's just the directory itself
yield rel_path
def file_url(self, name: str, request: HttpRequest | None = None) -> str:
"""Generate presigned URL for file access."""
use_https = CONFIG.get_bool(
f"storage.{self.usage.value}.{self.name}.secure_urls",
CONFIG.get_bool(f"storage.{self.name}.secure_urls", True),
)
params = {
"Bucket": self.bucket_name,
"Key": f"{self.base_path}/{name}",
}
expires_in = timedelta_from_string(
CONFIG.get(
f"storage.{self.usage.value}.{self.name}.url_expiry",
CONFIG.get(f"storage.{self.name}.url_expiry", "minutes=15"),
)
)
url = self.client.generate_presigned_url(
"get_object",
Params=params,
ExpiresIn=expires_in.total_seconds(),
HttpMethod="GET",
)
# Support custom domain for S3-compatible storage (so not AWS)
# Well, can't you do custom domains on AWS as well?
custom_domain = CONFIG.get(
f"storage.{self.usage.value}.{self.name}.custom_domain",
CONFIG.get(f"storage.{self.name}.custom_domain", None),
)
if custom_domain:
parsed = urlsplit(url)
scheme = "https" if use_https else "http"
url = f"{scheme}://{custom_domain}{parsed.path}?{parsed.query}"
return url
def save_file(self, name: str, content: bytes) -> None:
"""Save file to S3."""
self.client.put_object(
Bucket=self.bucket_name,
Key=f"{self.base_path}/{name}",
Body=content,
ACL="private",
)
@contextmanager
def save_file_stream(self, name: str) -> Iterator:
"""Context manager for streaming file writes to S3."""
# Keep files in memory up to 5 MB
with SpooledTemporaryFile(max_size=5 * 1024 * 1024, suffix=".S3File") as file:
yield file
file.seek(0)
self.client.upload_fileobj(
Fileobj=file,
Bucket=self.bucket_name,
Key=f"{self.base_path}/{name}",
ExtraArgs={
"ACL": "private",
},
)
def delete_file(self, name: str) -> None:
"""Delete file from S3."""
self.client.delete_object(
Bucket=self.bucket_name,
Key=f"{self.base_path}/{name}",
)
def file_exists(self, name: str) -> bool:
"""Check if a file exists in S3."""
try:
self.client.head_object(
Bucket=self.bucket_name,
Key=f"{self.base_path}/{name}",
)
return True
except ClientError:
return False

View File

@@ -0,0 +1,53 @@
from collections.abc import Generator
from pathlib import Path
from django.http.request import HttpRequest
from authentik.admin.files.backends.base import Backend
from authentik.admin.files.usage import FileUsage
from authentik.lib.config import CONFIG
STATIC_ASSETS_BASE_DIR = Path("web/dist")
STATIC_ASSETS_DIRS = [Path(p) for p in ("assets/icons", "assets/images")]
STATIC_ASSETS_SOURCES_DIR = Path("web/authentik/sources")
STATIC_FILE_EXTENSIONS = [".svg", ".png", ".jpg", ".jpeg"]
STATIC_PATH_PREFIX = "/static"
class StaticBackend(Backend):
"""Read-only backend for static files from web/dist/assets.
- Used for serving built-in static assets like icons and images.
- Files cannot be uploaded or deleted through this backend.
- Only accessible through resolve_file_url when a static path is detected.
"""
allowed_usages = [FileUsage.MEDIA]
def supports_file(self, name: str) -> bool:
"""Check if file path is a static path."""
return name.startswith(STATIC_PATH_PREFIX)
def list_files(self) -> Generator[str]:
"""List all static files."""
# List built-in source icons
if STATIC_ASSETS_SOURCES_DIR.exists():
for file_path in STATIC_ASSETS_SOURCES_DIR.iterdir():
if file_path.is_file() and (file_path.suffix in STATIC_FILE_EXTENSIONS):
yield f"{STATIC_PATH_PREFIX}/authentik/sources/{file_path.name}"
# List other static assets
for dir in STATIC_ASSETS_DIRS:
dist_dir = STATIC_ASSETS_BASE_DIR / dir
if dist_dir.exists():
for file_path in dist_dir.rglob("*"):
if file_path.is_file() and (file_path.suffix in STATIC_FILE_EXTENSIONS):
yield f"{STATIC_PATH_PREFIX}/dist/{dir}/{file_path.name}"
def file_url(self, name: str, request: HttpRequest | None = None) -> str:
"""Get URL for static file."""
prefix = CONFIG.get("web.path", "/")[:-1]
url = f"{prefix}{name}"
if request is None:
return url
return request.build_absolute_uri(url)

View File

@@ -0,0 +1,167 @@
from pathlib import Path
from django.test import TestCase
from authentik.admin.files.backends.file import FileBackend
from authentik.admin.files.tests.utils import FileTestFileBackendMixin
from authentik.admin.files.usage import FileUsage
from authentik.lib.config import CONFIG
class TestFileBackend(FileTestFileBackendMixin, TestCase):
"""Test FileBackend class"""
def setUp(self):
"""Set up test fixtures"""
super().setUp()
self.backend = FileBackend(FileUsage.MEDIA)
def test_allowed_usages(self):
"""Test that FileBackend supports all usage types"""
self.assertEqual(self.backend.allowed_usages, list(FileUsage))
def test_base_path(self):
"""Test base_path property constructs correct path"""
base_path = self.backend.base_path
expected = Path(self.media_backend_path) / "media" / "public"
self.assertEqual(base_path, expected)
def test_base_path_reports_usage(self):
"""Test base_path with reports usage"""
backend = FileBackend(FileUsage.REPORTS)
base_path = backend.base_path
expected = Path(self.reports_backend_path) / "reports" / "public"
self.assertEqual(base_path, expected)
def test_list_files_empty_directory(self):
"""Test list_files returns empty when directory is empty"""
# Create the directory but keep it empty
self.backend.base_path.mkdir(parents=True, exist_ok=True)
files = list(self.backend.list_files())
self.assertEqual(files, [])
def test_list_files_with_files(self):
"""Test list_files returns all files in directory"""
base_path = self.backend.base_path
base_path.mkdir(parents=True, exist_ok=True)
# Create some test files
(base_path / "file1.txt").write_text("content1")
(base_path / "file2.png").write_text("content2")
(base_path / "subdir").mkdir()
(base_path / "subdir" / "file3.csv").write_text("content3")
files = sorted(list(self.backend.list_files()))
expected = sorted(["file1.txt", "file2.png", "subdir/file3.csv"])
self.assertEqual(files, expected)
def test_list_files_nonexistent_directory(self):
"""Test list_files returns empty when directory doesn't exist"""
files = list(self.backend.list_files())
self.assertEqual(files, [])
def test_save_file(self):
content = b"test file content"
file_name = "test.txt"
self.backend.save_file(file_name, content)
# Verify file was created
file_path = self.backend.base_path / file_name
self.assertTrue(file_path.exists())
self.assertEqual(file_path.read_bytes(), content)
def test_save_file_creates_subdirectories(self):
"""Test save_file creates parent directories as needed"""
content = b"nested file content"
file_name = "subdir1/subdir2/nested.txt"
self.backend.save_file(file_name, content)
# Verify file and directories were created
file_path = self.backend.base_path / file_name
self.assertTrue(file_path.exists())
self.assertEqual(file_path.read_bytes(), content)
def test_save_file_stream(self):
"""Test save_file_stream context manager writes file correctly"""
content = b"streamed content"
file_name = "stream_test.txt"
with self.backend.save_file_stream(file_name) as f:
f.write(content)
# Verify file was created
file_path = self.backend.base_path / file_name
self.assertTrue(file_path.exists())
self.assertEqual(file_path.read_bytes(), content)
def test_save_file_stream_creates_subdirectories(self):
"""Test save_file_stream creates parent directories as needed"""
content = b"nested stream content"
file_name = "dir1/dir2/stream.bin"
with self.backend.save_file_stream(file_name) as f:
f.write(content)
# Verify file and directories were created
file_path = self.backend.base_path / file_name
self.assertTrue(file_path.exists())
self.assertEqual(file_path.read_bytes(), content)
def test_delete_file(self):
"""Test delete_file removes existing file"""
file_name = "to_delete.txt"
# Create file first
self.backend.save_file(file_name, b"content")
file_path = self.backend.base_path / file_name
self.assertTrue(file_path.exists())
# Delete it
self.backend.delete_file(file_name)
self.assertFalse(file_path.exists())
def test_delete_file_nonexistent(self):
"""Test delete_file handles nonexistent file gracefully"""
file_name = "does_not_exist.txt"
self.backend.delete_file(file_name)
def test_file_url(self):
"""Test file_url generates correct URL"""
file_name = "icon.png"
url = self.backend.file_url(file_name).split("?")[0]
expected = "/files/media/public/icon.png"
self.assertEqual(url, expected)
@CONFIG.patch("web.path", "/authentik/")
def test_file_url_with_prefix(self):
"""Test file_url with web path prefix"""
file_name = "logo.svg"
url = self.backend.file_url(file_name).split("?")[0]
expected = "/authentik/files/media/public/logo.svg"
self.assertEqual(url, expected)
def test_file_url_nested_path(self):
"""Test file_url with nested file path"""
file_name = "path/to/file.png"
url = self.backend.file_url(file_name).split("?")[0]
expected = "/files/media/public/path/to/file.png"
self.assertEqual(url, expected)
def test_file_exists_true(self):
"""Test file_exists returns True for existing file"""
file_name = "exists.txt"
self.backend.base_path.mkdir(parents=True, exist_ok=True)
(self.backend.base_path / file_name).touch()
self.assertTrue(self.backend.file_exists(file_name))
def test_file_exists_false(self):
"""Test file_exists returns False for nonexistent file"""
self.assertFalse(self.backend.file_exists("does_not_exist.txt"))

View File

@@ -0,0 +1,67 @@
"""Test passthrough backend"""
from django.test import TestCase
from authentik.admin.files.backends.passthrough import PassthroughBackend
from authentik.admin.files.usage import FileUsage
class TestPassthroughBackend(TestCase):
"""Test PassthroughBackend class"""
def setUp(self):
"""Set up test fixtures"""
self.backend = PassthroughBackend(FileUsage.MEDIA)
def test_allowed_usages(self):
"""Test that PassthroughBackend only supports MEDIA usage"""
self.assertEqual(self.backend.allowed_usages, [FileUsage.MEDIA])
def test_supports_file_path_font_awesome(self):
"""Test supports_file_path returns True for Font Awesome icons"""
self.assertTrue(self.backend.supports_file("fa://user"))
self.assertTrue(self.backend.supports_file("fa://home"))
self.assertTrue(self.backend.supports_file("fa://shield"))
def test_supports_file_path_http(self):
"""Test supports_file_path returns True for HTTP URLs"""
self.assertTrue(self.backend.supports_file("http://example.com/icon.png"))
self.assertTrue(self.backend.supports_file("http://cdn.example.com/logo.svg"))
def test_supports_file_path_https(self):
"""Test supports_file_path returns True for HTTPS URLs"""
self.assertTrue(self.backend.supports_file("https://example.com/icon.png"))
self.assertTrue(self.backend.supports_file("https://cdn.example.com/logo.svg"))
def test_supports_file_path_false(self):
"""Test supports_file_path returns False for regular paths"""
self.assertFalse(self.backend.supports_file("icon.png"))
self.assertFalse(self.backend.supports_file("/static/icon.png"))
self.assertFalse(self.backend.supports_file("media/logo.svg"))
self.assertFalse(self.backend.supports_file(""))
def test_supports_file_path_invalid_scheme(self):
"""Test supports_file_path returns False for invalid schemes"""
self.assertFalse(self.backend.supports_file("ftp://example.com/file.png"))
self.assertFalse(self.backend.supports_file("file:///path/to/file.png"))
self.assertFalse(self.backend.supports_file("data:image/png;base64,abc123"))
def test_list_files(self):
"""Test list_files returns empty generator"""
files = list(self.backend.list_files())
self.assertEqual(files, [])
def test_file_url(self):
"""Test file_url returns the URL as-is"""
url = "https://example.com/icon.png"
self.assertEqual(self.backend.file_url(url), url)
def test_file_url_font_awesome(self):
"""Test file_url returns Font Awesome URL as-is"""
url = "fa://user"
self.assertEqual(self.backend.file_url(url), url)
def test_file_url_http(self):
"""Test file_url returns HTTP URL as-is"""
url = "http://cdn.example.com/logo.svg"
self.assertEqual(self.backend.file_url(url), url)

View File

@@ -0,0 +1,109 @@
from django.test import TestCase
from authentik.admin.files.tests.utils import FileTestS3BackendMixin
from authentik.admin.files.usage import FileUsage
from authentik.lib.config import CONFIG
class TestS3Backend(FileTestS3BackendMixin, TestCase):
"""Test S3 backend functionality"""
def setUp(self):
super().setUp()
def test_base_path(self):
"""Test base_path property generates correct S3 key prefix"""
expected = "media/public"
self.assertEqual(self.media_s3_backend.base_path, expected)
def test_supports_file_path_s3(self):
"""Test supports_file_path returns True for s3 backend"""
self.assertTrue(self.media_s3_backend.supports_file("path/to/any-file.png"))
self.assertTrue(self.media_s3_backend.supports_file("any-file.png"))
def test_list_files(self):
"""Test list_files returns relative paths"""
self.media_s3_backend.client.put_object(
Bucket=self.media_s3_bucket_name,
Key="media/public/file1.png",
Body=b"test content",
ACL="private",
)
self.media_s3_backend.client.put_object(
Bucket=self.media_s3_bucket_name,
Key="media/other/file1.png",
Body=b"test content",
ACL="private",
)
files = list(self.media_s3_backend.list_files())
self.assertEqual(len(files), 1)
self.assertIn("file1.png", files)
def test_list_files_empty(self):
"""Test list_files with no files"""
files = list(self.media_s3_backend.list_files())
self.assertEqual(len(files), 0)
def test_save_file(self):
"""Test save_file uploads to S3"""
content = b"test file content"
self.media_s3_backend.save_file("test.png", content)
def test_save_file_stream(self):
"""Test save_file_stream uploads to S3 using context manager"""
with self.media_s3_backend.save_file_stream("test.csv") as f:
f.write(b"header1,header2\n")
f.write(b"value1,value2\n")
def test_delete_file(self):
"""Test delete_file removes from S3"""
self.media_s3_backend.client.put_object(
Bucket=self.media_s3_bucket_name,
Key="media/public/test.png",
Body=b"test content",
ACL="private",
)
self.media_s3_backend.delete_file("test.png")
@CONFIG.patch("storage.s3.secure_urls", True)
@CONFIG.patch("storage.s3.custom_domain", None)
def test_file_url_basic(self):
"""Test file_url generates presigned URL with AWS signature format"""
url = self.media_s3_backend.file_url("test.png")
self.assertIn("X-Amz-Algorithm=AWS4-HMAC-SHA256", url)
self.assertIn("X-Amz-Signature=", url)
self.assertIn("test.png", url)
@CONFIG.patch("storage.s3.bucket_name", "test-bucket")
def test_file_exists_true(self):
"""Test file_exists returns True for existing file"""
self.media_s3_backend.client.put_object(
Bucket=self.media_s3_bucket_name,
Key="media/public/test.png",
Body=b"test content",
ACL="private",
)
exists = self.media_s3_backend.file_exists("test.png")
self.assertTrue(exists)
@CONFIG.patch("storage.s3.bucket_name", "test-bucket")
def test_file_exists_false(self):
"""Test file_exists returns False for non-existent file"""
exists = self.media_s3_backend.file_exists("nonexistent.png")
self.assertFalse(exists)
def test_allowed_usages(self):
"""Test that S3Backend supports all usage types"""
self.assertEqual(self.media_s3_backend.allowed_usages, list(FileUsage))
def test_reports_usage(self):
"""Test S3Backend with REPORTS usage"""
self.assertEqual(self.reports_s3_backend.usage, FileUsage.REPORTS)
self.assertEqual(self.reports_s3_backend.base_path, "reports/public")

View File

@@ -0,0 +1,42 @@
from django.test import TestCase
from authentik.admin.files.backends.static import StaticBackend
from authentik.admin.files.usage import FileUsage
class TestStaticBackend(TestCase):
"""Test Static backend functionality"""
def setUp(self):
"""Set up test fixtures"""
self.usage = FileUsage.MEDIA
self.backend = StaticBackend(self.usage)
def test_init(self):
"""Test StaticBackend initialization"""
self.assertEqual(self.backend.usage, self.usage)
def test_allowed_usages(self):
"""Test that StaticBackend only supports MEDIA usage"""
self.assertEqual(self.backend.allowed_usages, [FileUsage.MEDIA])
def test_supports_file_path_static_prefix(self):
"""Test supports_file_path returns True for /static prefix"""
self.assertTrue(self.backend.supports_file("/static/assets/icons/test.svg"))
self.assertTrue(self.backend.supports_file("/static/authentik/sources/icon.png"))
def test_supports_file_path_not_static(self):
"""Test supports_file_path returns False for non-static paths"""
self.assertFalse(self.backend.supports_file("web/dist/assets/icons/test.svg"))
self.assertFalse(self.backend.supports_file("web/dist/assets/images/logo.png"))
self.assertFalse(self.backend.supports_file("media/public/test.png"))
self.assertFalse(self.backend.supports_file("/media/test.svg"))
self.assertFalse(self.backend.supports_file("test.jpg"))
def test_list_files(self):
"""Test list_files includes expected files"""
files = list(self.backend.list_files())
self.assertIn("/static/authentik/sources/ldap.png", files)
self.assertIn("/static/authentik/sources/openidconnect.svg", files)
self.assertIn("/static/authentik/sources/saml.png", files)

View File

@@ -0,0 +1,7 @@
from django.db import models
from authentik.admin.files.validation import validate_file_name
class FileField(models.TextField):
default_validators = [validate_file_name]

View File

@@ -0,0 +1,141 @@
from collections.abc import Generator, Iterator
from django.core.exceptions import ImproperlyConfigured
from django.http.request import HttpRequest
from rest_framework.request import Request
from structlog.stdlib import get_logger
from authentik.admin.files.backends.base import ManageableBackend
from authentik.admin.files.backends.file import FileBackend
from authentik.admin.files.backends.passthrough import PassthroughBackend
from authentik.admin.files.backends.s3 import S3Backend
from authentik.admin.files.backends.static import StaticBackend
from authentik.admin.files.usage import FileUsage
from authentik.lib.config import CONFIG
LOGGER = get_logger()
_FILE_BACKENDS = [
StaticBackend,
PassthroughBackend,
FileBackend,
S3Backend,
]
class FileManager:
def __init__(self, usage: FileUsage) -> None:
management_backend_name = CONFIG.get(
f"storage.{usage.value}.backend",
CONFIG.get("storage.backend", "file"),
)
self.management_backend = None
for backend in _FILE_BACKENDS:
if issubclass(backend, ManageableBackend) and backend.name == management_backend_name:
self.management_backend = backend(usage)
if self.management_backend is None:
LOGGER.warning(
f"Storage backend configuration for {usage.value} is "
f"invalid: {management_backend_name}"
)
self.backends = []
for backend in _FILE_BACKENDS:
if usage not in backend.allowed_usages:
continue
if isinstance(self.management_backend, backend):
self.backends.append(self.management_backend)
elif not issubclass(backend, ManageableBackend):
self.backends.append(backend(usage))
@property
def manageable(self) -> bool:
"""
Whether this file manager is able to manage files.
"""
return self.management_backend is not None and self.management_backend.manageable
def list_files(self, manageable_only: bool = False) -> Generator[str]:
"""
List available files.
"""
for backend in self.backends:
if manageable_only and not isinstance(backend, ManageableBackend):
continue
yield from backend.list_files()
def file_url(
self,
name: str | None,
request: HttpRequest | Request | None = None,
) -> str:
"""
Get URL for accessing the file.
"""
if not name:
return ""
if isinstance(request, Request):
request = request._request
for backend in self.backends:
if backend.supports_file(name):
return backend.file_url(name, request)
LOGGER.warning(f"Could not find file backend for file: {name}")
return ""
def _check_manageable(self) -> None:
if not self.manageable:
raise ImproperlyConfigured("No file management backend configured.")
def save_file(self, file_path: str, content: bytes) -> None:
"""
Save file contents to storage.
"""
self._check_manageable()
assert self.management_backend is not None # nosec
return self.management_backend.save_file(file_path, content)
def save_file_stream(self, file_path: str) -> Iterator:
"""
Context manager for streaming file writes.
Args:
file_path: Relative file path
Returns:
Context manager that yields a writable file-like object
Usage:
with manager.save_file_stream("output.csv") as f:
f.write(b"data...")
"""
self._check_manageable()
assert self.management_backend is not None # nosec
return self.management_backend.save_file_stream(file_path)
def delete_file(self, file_path: str) -> None:
"""
Delete file from storage.
"""
self._check_manageable()
assert self.management_backend is not None # nosec
return self.management_backend.delete_file(file_path)
def file_exists(self, file_path: str) -> bool:
"""
Check if a file exists.
"""
self._check_manageable()
assert self.management_backend is not None # nosec
return self.management_backend.file_exists(file_path)
MANAGERS = {usage: FileManager(usage) for usage in list(FileUsage)}
def get_file_manager(usage: FileUsage) -> FileManager:
return MANAGERS[usage]

View File

@@ -0,0 +1 @@
"""authentik files tests"""

View File

@@ -0,0 +1,229 @@
"""test file api"""
from io import BytesIO
from django.test import TestCase
from django.urls import reverse
from authentik.admin.files.api import get_mime_from_filename
from authentik.admin.files.manager import FileManager
from authentik.admin.files.tests.utils import FileTestFileBackendMixin
from authentik.admin.files.usage import FileUsage
from authentik.core.tests.utils import create_test_admin_user
from authentik.events.models import Event, EventAction
class TestFileAPI(FileTestFileBackendMixin, TestCase):
"""test file api"""
def setUp(self) -> None:
super().setUp()
self.user = create_test_admin_user()
self.client.force_login(self.user)
def test_upload_creates_event(self):
"""Test that uploading a file creates a FILE_UPLOADED event"""
manager = FileManager(FileUsage.MEDIA)
file_content = b"test file content"
file_name = "test-upload.png"
# Upload file
response = self.client.post(
reverse("authentik_api:files"),
{
"file": BytesIO(file_content),
"name": file_name,
"usage": FileUsage.MEDIA.value,
},
format="multipart",
)
self.assertEqual(response.status_code, 200)
# Verify event was created
event = Event.objects.filter(action=EventAction.MODEL_CREATED).first()
self.assertIsNotNone(event)
assert event is not None # nosec
self.assertEqual(event.context["model"]["name"], file_name)
self.assertEqual(event.context["model"]["usage"], FileUsage.MEDIA.value)
self.assertEqual(event.context["model"]["mime_type"], "image/png")
# Verify user is captured
self.assertEqual(event.user["username"], self.user.username)
self.assertEqual(event.user["pk"], self.user.pk)
manager.delete_file(file_name)
def test_delete_creates_event(self):
"""Test that deleting a file creates an event"""
manager = FileManager(FileUsage.MEDIA)
file_name = "test-delete.png"
manager.save_file(file_name, b"test content")
# Delete file
response = self.client.delete(
reverse(
"authentik_api:files",
query={
"name": file_name,
"usage": FileUsage.MEDIA.value,
},
)
)
self.assertEqual(response.status_code, 200)
# Verify event was created
event = Event.objects.filter(action=EventAction.MODEL_DELETED).first()
self.assertIsNotNone(event)
assert event is not None # nosec
self.assertEqual(event.context["model"]["name"], file_name)
self.assertEqual(event.context["model"]["usage"], FileUsage.MEDIA.value)
# Verify user is captured
self.assertEqual(event.user["username"], self.user.username)
self.assertEqual(event.user["pk"], self.user.pk)
def test_list_files_basic(self):
"""Test listing files with default parameters"""
response = self.client.get(reverse("authentik_api:files"))
self.assertEqual(response.status_code, 200)
self.assertIn(
{
"name": "/static/authentik/sources/ldap.png",
"url": "/static/authentik/sources/ldap.png",
"mime_type": "image/png",
},
response.data,
)
def test_list_files_invalid_usage(self):
"""Test listing files with invalid usage parameter"""
response = self.client.get(
reverse(
"authentik_api:files",
query={
"usage": "invalid",
},
)
)
self.assertEqual(response.status_code, 400)
self.assertIn("not a valid choice", str(response.data))
def test_list_files_with_search(self):
"""Test listing files with search query"""
response = self.client.get(
reverse(
"authentik_api:files",
query={
"search": "ldap.png",
},
)
)
self.assertEqual(response.status_code, 200)
self.assertIn(
{
"name": "/static/authentik/sources/ldap.png",
"url": "/static/authentik/sources/ldap.png",
"mime_type": "image/png",
},
response.data,
)
def test_list_files_with_manageable_only(self):
"""Test listing files with omit parameter"""
response = self.client.get(
reverse(
"authentik_api:files",
query={
"manageableOnly": "true",
},
)
)
self.assertEqual(response.status_code, 200)
self.assertNotIn(
{
"name": "/static/dist/assets/images/flow_background.jpg",
"mime_type": "image/jpeg",
},
response.data,
)
def test_upload_file_with_custom_path(self):
"""Test uploading file with custom path"""
manager = FileManager(FileUsage.MEDIA)
file_name = "custom/test"
file_content = b"test content"
response = self.client.post(
reverse("authentik_api:files"),
{
"file": BytesIO(file_content),
"name": file_name,
"usage": FileUsage.MEDIA.value,
},
format="multipart",
)
self.assertEqual(response.status_code, 200)
self.assertTrue(manager.file_exists(file_name))
manager.delete_file(file_name)
def test_upload_file_duplicate(self):
"""Test uploading file that already exists"""
manager = FileManager(FileUsage.MEDIA)
file_name = "test-file.png"
file_content = b"test content"
manager.save_file(file_name, file_content)
response = self.client.post(
reverse("authentik_api:files"),
{
"file": BytesIO(file_content),
"name": file_name,
},
format="multipart",
)
self.assertEqual(response.status_code, 400)
self.assertIn("already exists", str(response.data))
manager.delete_file(file_name)
def test_delete_without_name_parameter(self):
"""Test delete without name parameter"""
response = self.client.delete(reverse("authentik_api:files"))
self.assertEqual(response.status_code, 400)
self.assertIn("field is required", str(response.data))
class TestGetMimeFromFilename(TestCase):
"""Test get_mime_from_filename function"""
def test_image_png(self):
"""Test PNG image MIME type"""
self.assertEqual(get_mime_from_filename("test.png"), "image/png")
def test_image_jpeg(self):
"""Test JPEG image MIME type"""
self.assertEqual(get_mime_from_filename("test.jpg"), "image/jpeg")
def test_image_svg(self):
"""Test SVG image MIME type"""
self.assertEqual(get_mime_from_filename("test.svg"), "image/svg+xml")
def test_text_plain(self):
"""Test text file MIME type"""
self.assertEqual(get_mime_from_filename("test.txt"), "text/plain")
def test_unknown_extension(self):
"""Test unknown extension returns octet-stream"""
self.assertEqual(get_mime_from_filename("test.unknown"), "application/octet-stream")
def test_no_extension(self):
"""Test no extension returns octet-stream"""
self.assertEqual(get_mime_from_filename("test"), "application/octet-stream")

View File

@@ -0,0 +1,99 @@
"""Test file service layer"""
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.usage import FileUsage
from authentik.lib.config import CONFIG
class TestResolveFileUrlBasic(TestCase):
def test_resolve_empty_path(self):
"""Test resolving empty file path"""
manager = FileManager(FileUsage.MEDIA)
result = manager.file_url("")
self.assertEqual(result, "")
def test_resolve_none_path(self):
"""Test resolving None file path"""
manager = FileManager(FileUsage.MEDIA)
result = manager.file_url(None)
self.assertEqual(result, "")
def test_resolve_font_awesome(self):
"""Test resolving Font Awesome icon"""
manager = FileManager(FileUsage.MEDIA)
result = manager.file_url("fa://fa-check")
self.assertEqual(result, "fa://fa-check")
def test_resolve_http_url(self):
"""Test resolving HTTP URL"""
manager = FileManager(FileUsage.MEDIA)
result = manager.file_url("http://example.com/icon.png")
self.assertEqual(result, "http://example.com/icon.png")
def test_resolve_https_url(self):
"""Test resolving HTTPS URL"""
manager = FileManager(FileUsage.MEDIA)
result = manager.file_url("https://example.com/icon.png")
self.assertEqual(result, "https://example.com/icon.png")
def test_resolve_static_path(self):
"""Test resolving static file path"""
manager = FileManager(FileUsage.MEDIA)
result = manager.file_url("/static/authentik/sources/icon.svg")
self.assertEqual(result, "/static/authentik/sources/icon.svg")
class TestResolveFileUrlFileBackend(FileTestFileBackendMixin, TestCase):
def test_resolve_storage_file(self):
"""Test resolving uploaded storage file"""
manager = FileManager(FileUsage.MEDIA)
result = manager.file_url("test.png").split("?")[0]
self.assertEqual(result, "/files/media/public/test.png")
def test_resolve_full_static_with_request(self):
"""Test resolving static file with request builds absolute URI"""
mock_request = HttpRequest()
mock_request.META = {
"HTTP_HOST": "example.com",
"SERVER_NAME": "example.com",
}
manager = FileManager(FileUsage.MEDIA)
result = manager.file_url("/static/icon.svg", mock_request)
self.assertEqual(result, "http://example.com/static/icon.svg")
def test_resolve_full_file_backend_with_request(self):
"""Test resolving FileBackend file with request"""
mock_request = HttpRequest()
mock_request.META = {
"HTTP_HOST": "example.com",
"SERVER_NAME": "example.com",
}
manager = FileManager(FileUsage.MEDIA)
result = manager.file_url("test.png", mock_request).split("?")[0]
self.assertEqual(result, "http://example.com/files/media/public/test.png")
class TestResolveFileUrlS3Backend(FileTestS3BackendMixin, TestCase):
@CONFIG.patch("storage.media.s3.custom_domain", "s3.test:8080/test")
@CONFIG.patch("storage.media.s3.secure_urls", False)
def test_resolve_full_s3_backend(self):
"""Test resolving S3Backend returns presigned URL as-is"""
mock_request = HttpRequest()
mock_request.META = {
"HTTP_HOST": "example.com",
"SERVER_NAME": "example.com",
}
manager = FileManager(FileUsage.MEDIA)
result = manager.file_url("test.png", mock_request)
# S3 URLs should be returned as-is (already absolute)
self.assertTrue(result.startswith("http://s3.test:8080/test"))

View File

@@ -0,0 +1,110 @@
from django.core.exceptions import ValidationError
from django.test import TestCase
from authentik.admin.files.validation import (
MAX_FILE_NAME_LENGTH,
MAX_PATH_COMPONENT_LENGTH,
validate_file_name,
)
class TestSanitizeFilePath(TestCase):
"""Test validate_file_name function"""
def test_sanitize_valid_filename(self):
"""Test sanitizing valid filename"""
validate_file_name("test.png")
def test_sanitize_valid_path_with_directory(self):
"""Test sanitizing valid path with directory"""
validate_file_name("images/test.png")
def test_sanitize_valid_path_with_nested_dirs(self):
"""Test sanitizing valid path with nested directories"""
validate_file_name("dir1/dir2/dir3/test.png")
def test_sanitize_with_hyphens(self):
"""Test sanitizing filename with hyphens"""
validate_file_name("test-file-name.png")
def test_sanitize_with_underscores(self):
"""Test sanitizing filename with underscores"""
validate_file_name("test_file_name.png")
def test_sanitize_with_dots(self):
"""Test sanitizing filename with multiple dots"""
validate_file_name("test.file.name.png")
def test_sanitize_strips_whitespace(self):
"""Test sanitizing filename strips whitespace"""
with self.assertRaises(ValidationError):
validate_file_name(" test.png ")
def test_sanitize_removes_duplicate_slashes(self):
"""Test sanitizing path removes duplicate slashes"""
with self.assertRaises(ValidationError):
validate_file_name("dir1//dir2///test.png")
def test_sanitize_empty_path_raises(self):
"""Test sanitizing empty path raises ValidationError"""
with self.assertRaises(ValidationError):
validate_file_name("")
def test_sanitize_whitespace_only_raises(self):
"""Test sanitizing whitespace-only path raises ValidationError"""
with self.assertRaises(ValidationError):
validate_file_name(" ")
def test_sanitize_invalid_characters_raises(self):
"""Test sanitizing path with invalid characters raises ValidationError"""
invalid_paths = [
"test file.png", # space
"test@file.png", # @
"test#file.png", # #
"test$file.png", # $
"test%file.png", # %
"test&file.png", # &
"test*file.png", # *
"test(file).png", # parentheses
"test[file].png", # brackets
"test{file}.png", # braces
]
for path in invalid_paths:
with self.assertRaises(ValidationError):
validate_file_name(path)
def test_sanitize_absolute_path_raises(self):
"""Test sanitizing absolute path raises ValidationError"""
with self.assertRaises(ValidationError):
validate_file_name("/absolute/path/test.png")
def test_sanitize_parent_directory_raises(self):
"""Test sanitizing path with parent directory reference raises ValidationError"""
with self.assertRaises(ValidationError):
validate_file_name("../test.png")
def test_sanitize_nested_parent_directory_raises(self):
"""Test sanitizing path with nested parent directory reference raises ValidationError"""
with self.assertRaises(ValidationError):
validate_file_name("dir1/../test.png")
def test_sanitize_starts_with_dot_raises(self):
"""Test sanitizing path starting with dot raises ValidationError"""
with self.assertRaises(ValidationError):
validate_file_name(".hidden")
def test_sanitize_too_long_path_raises(self):
"""Test sanitizing too long path raises ValidationError"""
long_path = "a" * (MAX_FILE_NAME_LENGTH + 1) + ".png"
with self.assertRaises(ValidationError):
validate_file_name(long_path)
def test_sanitize_too_long_component_raises(self):
"""Test sanitizing path with too long component raises ValidationError"""
long_component = "a" * (MAX_PATH_COMPONENT_LENGTH + 1)
path = f"dir/{long_component}.png"
with self.assertRaises(ValidationError):
validate_file_name(path)

View File

@@ -0,0 +1,114 @@
import shutil
from tempfile import mkdtemp
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
class FileTestFileBackendMixin:
def setUp(self):
self.original_media_backend = CONFIG.get("storage.media.backend", UNSET)
self.original_media_backend_path = CONFIG.get("storage.media.file.path", UNSET)
self.media_backend_path = mkdtemp()
CONFIG.set("storage.media.backend", "file")
CONFIG.set("storage.media.file.path", str(self.media_backend_path))
self.original_reports_backend = CONFIG.get("storage.reports.backend", UNSET)
self.original_reports_backend_path = CONFIG.get("storage.reports.file.path", UNSET)
self.reports_backend_path = mkdtemp()
CONFIG.set("storage.reports.backend", "file")
CONFIG.set("storage.reports.file.path", str(self.reports_backend_path))
def tearDown(self):
if self.original_media_backend is not UNSET:
CONFIG.set("storage.media.backend", self.original_media_backend)
else:
CONFIG.delete("storage.media.backend")
if self.original_media_backend_path is not UNSET:
CONFIG.set("storage.media.file.path", self.original_media_backend_path)
else:
CONFIG.delete("storage.media.file.path")
shutil.rmtree(self.media_backend_path)
if self.original_reports_backend is not UNSET:
CONFIG.set("storage.reports.backend", self.original_reports_backend)
else:
CONFIG.delete("storage.reports.backend")
if self.original_reports_backend_path is not UNSET:
CONFIG.set("storage.reports.file.path", self.original_reports_backend_path)
else:
CONFIG.delete("storage.reports.file.path")
shutil.rmtree(self.reports_backend_path)
class FileTestS3BackendMixin:
def setUp(self):
s3_config_keys = {
"endpoint",
"access_key",
"secret_key",
"bucket_name",
}
self.original_media_backend = CONFIG.get("storage.media.backend", UNSET)
CONFIG.set("storage.media.backend", "s3")
self.original_media_s3_settings = {}
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.access_key", "accessKey1")
CONFIG.set("storage.media.s3.secret_key", "secretKey1")
CONFIG.set("storage.media.s3.bucket_name", self.media_s3_bucket_name)
self.media_s3_backend = S3Backend(FileUsage.MEDIA)
self.media_s3_backend.client.create_bucket(Bucket=self.media_s3_bucket_name, ACL="private")
self.original_reports_backend = CONFIG.get("storage.reports.backend", UNSET)
CONFIG.set("storage.reports.backend", "s3")
self.original_reports_s3_settings = {}
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.access_key", "accessKey1")
CONFIG.set("storage.reports.s3.secret_key", "secretKey1")
CONFIG.set("storage.reports.s3.bucket_name", self.reports_s3_bucket_name)
self.reports_s3_backend = S3Backend(FileUsage.REPORTS)
self.reports_s3_backend.client.create_bucket(
Bucket=self.reports_s3_bucket_name, ACL="private"
)
def tearDown(self):
def delete_objects_in_bucket(client, bucket_name):
paginator = client.get_paginator("list_objects_v2")
pages = paginator.paginate(Bucket=bucket_name)
for page in pages:
if "Contents" not in page:
continue
for obj in page["Contents"]:
client.delete_object(Bucket=bucket_name, Key=obj["Key"])
delete_objects_in_bucket(self.media_s3_backend.client, self.media_s3_bucket_name)
self.media_s3_backend.client.delete_bucket(Bucket=self.media_s3_bucket_name)
if self.original_media_backend is not UNSET:
CONFIG.set("storage.media.backend", self.original_media_backend)
else:
CONFIG.delete("storage.media.backend")
for k, v in self.original_media_s3_settings.items():
if v is not UNSET:
CONFIG.set(f"storage.media.s3.{k}", v)
else:
CONFIG.delete(f"storage.media.s3.{k}")
delete_objects_in_bucket(self.reports_s3_backend.client, self.reports_s3_bucket_name)
self.reports_s3_backend.client.delete_bucket(Bucket=self.reports_s3_bucket_name)
if self.original_reports_backend is not UNSET:
CONFIG.set("storage.reports.backend", self.original_reports_backend)
else:
CONFIG.delete("storage.reports.backend")
for k, v in self.original_reports_s3_settings.items():
if v is not UNSET:
CONFIG.set(f"storage.reports.s3.{k}", v)
else:
CONFIG.delete(f"storage.reports.s3.{k}")

View File

@@ -0,0 +1,8 @@
from django.urls import path
from authentik.admin.files.api import FileUsedByView, FileView
api_urlpatterns = [
path("admin/file/", FileView.as_view(), name="files"),
path("admin/file/used_by/", FileUsedByView.as_view(), name="files-used-by"),
]

View File

@@ -0,0 +1,17 @@
from enum import StrEnum
from itertools import chain
class FileApiUsage(StrEnum):
"""Usage types for file API"""
MEDIA = "media"
class FileManagedUsage(StrEnum):
"""Usage types for managed files"""
REPORTS = "reports"
FileUsage = StrEnum("FileUsage", [(v.name, v.value) for v in chain(FileApiUsage, FileManagedUsage)])

View File

@@ -0,0 +1,79 @@
import re
from pathlib import PurePosixPath
from django.core.exceptions import ValidationError
from django.utils.translation import gettext as _
from authentik.admin.files.backends.passthrough import PassthroughBackend
from authentik.admin.files.backends.static import StaticBackend
from authentik.admin.files.usage import FileUsage
# File upload limits
MAX_FILE_NAME_LENGTH = 1024
MAX_PATH_COMPONENT_LENGTH = 255
def validate_file_name(name: str) -> None:
if PassthroughBackend(FileUsage.MEDIA).supports_file(name) or StaticBackend(
FileUsage.MEDIA
).supports_file(name):
return
validate_upload_file_name(name)
def validate_upload_file_name(
name: str,
ValidationError: type[Exception] = ValidationError,
) -> None:
"""Sanitize file path.
Args:
file_path: The file path to sanitize
Returns:
Sanitized file path
Raises:
ValidationError: If file path is invalid
"""
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):
raise ValidationError(
_(
"File name can only contain letters (a-z, A-Z), numbers (0-9), "
"dots (.), hyphens (-), underscores (_), and forward slashes (/)"
)
)
if "//" in name:
raise ValidationError(_("File name cannot contain duplicate /"))
# Convert to posix path
path = PurePosixPath(name)
# Check for absolute paths
# Needs the / at the start. If it doesn't have it, it might still be unsafe, so see L53+
if path.is_absolute():
raise ValidationError(_("Absolute paths are not allowed"))
# Check for parent directory references
if ".." in path.parts:
raise ValidationError(_("Parent directory references ('..') are not allowed"))
# Disallow paths starting with dot (hidden files at root level)
if str(path).startswith("."):
raise ValidationError(_("Paths cannot start with '.'"))
# Check path length limits
normalized = str(path)
if len(normalized) > MAX_FILE_NAME_LENGTH:
raise ValidationError(_(f"File name too long (max {MAX_FILE_NAME_LENGTH} characters)"))
for part in path.parts:
if len(part) > MAX_PATH_COMPONENT_LENGTH:
raise ValidationError(
_(f"Path component too long (max {MAX_PATH_COMPONENT_LENGTH} characters)")
)

View File

@@ -42,68 +42,6 @@ def validate_auth(header: bytes, format="bearer") -> str | None:
return auth_credentials
def bearer_auth(raw_header: bytes) -> User | None:
"""raw_header in the Format of `Bearer ....`"""
user = auth_user_lookup(raw_header)
if not user:
return None
if not user.is_active:
raise AuthenticationFailed("Token invalid/expired")
return user
def auth_user_lookup(raw_header: bytes) -> User | None:
"""raw_header in the Format of `Bearer ....`"""
from authentik.providers.oauth2.models import AccessToken
auth_credentials = validate_auth(raw_header)
if not auth_credentials:
return None
# first, check traditional tokens
key_token = Token.filter_not_expired(
key=auth_credentials, intent=TokenIntents.INTENT_API
).first()
if key_token:
CTX_AUTH_VIA.set("api_token")
return key_token.user
# then try to auth via JWT
jwt_token = AccessToken.filter_not_expired(
token=auth_credentials, _scope__icontains=SCOPE_AUTHENTIK_API
).first()
if jwt_token:
# Double-check scopes, since they are saved in a single string
# we want to check the parsed version too
if SCOPE_AUTHENTIK_API not in jwt_token.scope:
raise AuthenticationFailed("Token invalid/expired")
CTX_AUTH_VIA.set("jwt")
return jwt_token.user
# then try to auth via secret key (for embedded outpost/etc)
user = token_secret_key(auth_credentials)
if user:
CTX_AUTH_VIA.set("secret_key")
return user
# then try to auth via secret key (for embedded outpost/etc)
user = token_ipc(auth_credentials)
if user:
CTX_AUTH_VIA.set("ipc")
return user
raise AuthenticationFailed("Token invalid/expired")
def token_secret_key(value: str) -> User | None:
"""Check if the token is the secret key
and return the service account for the managed outpost"""
from authentik.outposts.apps import MANAGED_OUTPOST
if not compare_digest(value, settings.SECRET_KEY):
return None
outposts = Outpost.objects.filter(managed=MANAGED_OUTPOST)
if not outposts:
return None
outpost = outposts.first()
return outpost.user
class IPCUser(AnonymousUser):
"""'Virtual' user for IPC communication between authentik core and the authentik router"""
@@ -132,13 +70,8 @@ class IPCUser(AnonymousUser):
def is_authenticated(self):
return True
def token_ipc(value: str) -> User | None:
"""Check if the token is the secret key
and return the service account for the managed outpost"""
if not ipc_key or not compare_digest(value, ipc_key):
return None
return IPCUser()
def all_roles(self):
return []
class TokenAuthentication(BaseAuthentication):
@@ -148,12 +81,79 @@ class TokenAuthentication(BaseAuthentication):
"""Token-based authentication using HTTP Bearer authentication"""
auth = get_authorization_header(request)
user = bearer_auth(auth)
user_ctx = self.bearer_auth(auth)
# None is only returned when the header isn't set.
if not user:
if not user_ctx:
return None
return (user, None) # pragma: no cover
return user_ctx
def bearer_auth(self, raw_header: bytes) -> tuple[User, Any] | None:
"""raw_header in the Format of `Bearer ....`"""
user_ctx = self.auth_user_lookup(raw_header)
if not user_ctx:
return None
user, ctx = user_ctx
if not user.is_active:
raise AuthenticationFailed("Token invalid/expired")
return user, ctx
def auth_user_lookup(self, raw_header: bytes) -> tuple[User, Any] | None:
"""raw_header in the Format of `Bearer ....`"""
from authentik.providers.oauth2.models import AccessToken
auth_credentials = validate_auth(raw_header)
if not auth_credentials:
return None
# first, check traditional tokens
key_token = Token.filter_not_expired(
key=auth_credentials, intent=TokenIntents.INTENT_API
).first()
if key_token:
CTX_AUTH_VIA.set("api_token")
return key_token.user, key_token
# then try to auth via JWT
jwt_token = AccessToken.filter_not_expired(
token=auth_credentials, _scope__icontains=SCOPE_AUTHENTIK_API
).first()
if jwt_token:
# Double-check scopes, since they are saved in a single string
# we want to check the parsed version too
if SCOPE_AUTHENTIK_API not in jwt_token.scope:
raise AuthenticationFailed("Token invalid/expired")
CTX_AUTH_VIA.set("jwt")
return jwt_token.user, jwt_token
# then try to auth via secret key (for embedded outpost/etc)
user_outpost = self.token_secret_key(auth_credentials)
if user_outpost:
CTX_AUTH_VIA.set("secret_key")
return user_outpost
# then try to auth via secret key (for embedded outpost/etc)
user = self.token_ipc(auth_credentials)
if user:
CTX_AUTH_VIA.set("ipc")
return user
raise AuthenticationFailed("Token invalid/expired")
def token_ipc(self, value: str) -> tuple[User, None] | None:
"""Check if the token is the secret key
and return the service account for the managed outpost"""
if not ipc_key or not compare_digest(value, ipc_key):
return None
return IPCUser(), None
def token_secret_key(self, value: str) -> tuple[User, Outpost] | None:
"""Check if the token is the secret key
and return the service account for the managed outpost"""
from authentik.outposts.apps import MANAGED_OUTPOST
if not compare_digest(value, settings.SECRET_KEY):
return None
outposts = Outpost.objects.filter(managed=MANAGED_OUTPOST)
if not outposts:
return None
outpost = outposts.first()
return outpost.user, outpost
class TokenSchema(OpenApiAuthenticationExtension):

View File

@@ -2,15 +2,16 @@
import json
from base64 import b64encode
from unittest.mock import patch
from django.conf import settings
from django.test import TestCase
from django.utils import timezone
from rest_framework.exceptions import AuthenticationFailed
from authentik.api.authentication import bearer_auth
from authentik.api.authentication import IPCUser, TokenAuthentication
from authentik.blueprints.tests import reconcile_app
from authentik.core.models import Token, TokenIntents, User, UserTypes
from authentik.core.models import Token, TokenIntents, UserTypes
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
from authentik.lib.generators import generate_id
from authentik.outposts.apps import MANAGED_OUTPOST
@@ -24,22 +25,24 @@ class TestAPIAuth(TestCase):
def test_invalid_type(self):
"""Test invalid type"""
self.assertIsNone(bearer_auth(b"foo bar"))
self.assertIsNone(TokenAuthentication().bearer_auth(b"foo bar"))
def test_invalid_empty(self):
"""Test invalid type"""
self.assertIsNone(bearer_auth(b"Bearer "))
self.assertIsNone(bearer_auth(b""))
self.assertIsNone(TokenAuthentication().bearer_auth(b"Bearer "))
self.assertIsNone(TokenAuthentication().bearer_auth(b""))
def test_invalid_no_token(self):
"""Test invalid with no token"""
auth = b64encode(b":abc").decode()
self.assertIsNone(bearer_auth(f"Basic :{auth}".encode()))
self.assertIsNone(TokenAuthentication().bearer_auth(f"Basic :{auth}".encode()))
def test_bearer_valid(self):
"""Test valid token"""
token = Token.objects.create(intent=TokenIntents.INTENT_API, user=create_test_admin_user())
self.assertEqual(bearer_auth(f"Bearer {token.key}".encode()), token.user)
user, tk = TokenAuthentication().bearer_auth(f"Bearer {token.key}".encode())
self.assertEqual(user, token.user)
self.assertEqual(token, token)
def test_bearer_valid_deactivated(self):
"""Test valid token"""
@@ -48,7 +51,7 @@ class TestAPIAuth(TestCase):
user.save()
token = Token.objects.create(intent=TokenIntents.INTENT_API, user=user)
with self.assertRaises(AuthenticationFailed):
bearer_auth(f"Bearer {token.key}".encode())
TokenAuthentication().bearer_auth(f"Bearer {token.key}".encode())
@reconcile_app("authentik_outposts")
def test_managed_outpost_fail(self):
@@ -57,20 +60,21 @@ class TestAPIAuth(TestCase):
outpost.user.delete()
outpost.delete()
with self.assertRaises(AuthenticationFailed):
bearer_auth(f"Bearer {settings.SECRET_KEY}".encode())
TokenAuthentication().bearer_auth(f"Bearer {settings.SECRET_KEY}".encode())
@reconcile_app("authentik_outposts")
def test_managed_outpost_success(self):
"""Test managed outpost"""
user: User = bearer_auth(f"Bearer {settings.SECRET_KEY}".encode())
user, outpost = TokenAuthentication().bearer_auth(f"Bearer {settings.SECRET_KEY}".encode())
self.assertEqual(user.type, UserTypes.INTERNAL_SERVICE_ACCOUNT)
self.assertEqual(outpost, Outpost.objects.filter(managed=MANAGED_OUTPOST).first())
def test_jwt_valid(self):
"""Test valid JWT"""
provider = OAuth2Provider.objects.create(
name=generate_id(), client_id=generate_id(), authorization_flow=create_test_flow()
)
refresh = AccessToken.objects.create(
access = AccessToken.objects.create(
user=create_test_admin_user(),
provider=provider,
token=generate_id(),
@@ -78,14 +82,16 @@ class TestAPIAuth(TestCase):
_scope=SCOPE_AUTHENTIK_API,
_id_token=json.dumps({}),
)
self.assertEqual(bearer_auth(f"Bearer {refresh.token}".encode()), refresh.user)
user, token = TokenAuthentication().bearer_auth(f"Bearer {access.token}".encode())
self.assertEqual(user, access.user)
self.assertEqual(token, access)
def test_jwt_missing_scope(self):
"""Test valid JWT"""
provider = OAuth2Provider.objects.create(
name=generate_id(), client_id=generate_id(), authorization_flow=create_test_flow()
)
refresh = AccessToken.objects.create(
access = AccessToken.objects.create(
user=create_test_admin_user(),
provider=provider,
token=generate_id(),
@@ -94,4 +100,12 @@ class TestAPIAuth(TestCase):
_id_token=json.dumps({}),
)
with self.assertRaises(AuthenticationFailed):
self.assertEqual(bearer_auth(f"Bearer {refresh.token}".encode()), refresh.user)
TokenAuthentication().bearer_auth(f"Bearer {access.token}".encode())
def test_ipc(self):
"""Test IPC auth (mock key)"""
key = generate_id()
with patch("authentik.api.authentication.ipc_key", key):
user, ctx = TokenAuthentication().bearer_auth(f"Bearer {key}".encode())
self.assertEqual(user, IPCUser())
self.assertEqual(ctx, None)

View File

@@ -0,0 +1,62 @@
from collections.abc import Callable
from inspect import getmembers
from django.urls import reverse
from rest_framework.test import APITestCase
from rest_framework.views import APIView
from rest_framework.viewsets import GenericViewSet
from authentik.lib.utils.reflection import all_subclasses
class TestAPIViewAuthnAuthz(APITestCase): ...
def api_viewset_action(viewset: GenericViewSet, member: Callable) -> Callable:
"""Test API Viewset action"""
def tester(self: TestAPIViewAuthnAuthz):
if "permission_classes" in member.kwargs:
self.assertNotEqual(
member.kwargs["permission_classes"], [], "permission_classes should not be empty"
)
if "authentication_classes" in member.kwargs:
self.assertNotEqual(
member.kwargs["authentication_classes"],
[],
"authentication_classes should not be empty",
)
return tester
def api_view(view: APIView) -> Callable:
def tester(self: TestAPIViewAuthnAuthz):
self.assertNotEqual(view.permission_classes, [], "permission_classes should not be empty")
self.assertNotEqual(
view.authentication_classes,
[],
"authentication_classes should not be empty",
)
return tester
# Tell django to load all URLs
reverse("authentik_core:root-redirect")
for viewset in all_subclasses(GenericViewSet):
for act_name, member in getmembers(viewset(), lambda x: isinstance(x, Callable)):
if not hasattr(member, "kwargs") or not hasattr(member, "mapping"):
continue
setattr(
TestAPIViewAuthnAuthz,
f"test_viewset_{viewset.__name__}_action_{act_name}",
api_viewset_action(viewset, member),
)
for view in all_subclasses(APIView):
setattr(
TestAPIViewAuthnAuthz,
f"test_view_{view.__name__}",
api_view(view),
)

View File

@@ -1,7 +1,5 @@
"""core Configs API"""
from pathlib import Path
from django.conf import settings
from django.db import models
from django.dispatch import Signal
@@ -20,6 +18,8 @@ from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from authentik.admin.files.manager import get_file_manager
from authentik.admin.files.usage import FileUsage
from authentik.core.api.utils import PassiveSerializer
from authentik.events.context_processors.base import get_context_processors
from authentik.lib.config import CONFIG
@@ -68,12 +68,7 @@ class ConfigView(APIView):
def get_capabilities(request: HttpRequest) -> list[Capabilities]:
"""Get all capabilities this server instance supports"""
caps = []
deb_test = settings.DEBUG or settings.TEST
if (
CONFIG.get("storage.media.backend", "file") == "s3"
or Path(settings.STORAGES["default"]["OPTIONS"]["location"]).is_mount()
or deb_test
):
if get_file_manager(FileUsage.MEDIA).manageable:
caps.append(Capabilities.CAN_SAVE_MEDIA)
for processor in get_context_processors():
if cap := processor.capability():

View File

@@ -3,12 +3,10 @@
from django.test import TransactionTestCase
from authentik.blueprints.v1.importer import Importer
from authentik.core.models import Application, Token, User
from authentik.core.models import Token, User
from authentik.core.tests.utils import create_test_admin_user
from authentik.flows.models import Flow
from authentik.lib.generators import generate_id
from authentik.lib.tests.utils import load_fixture
from authentik.sources.oauth.models import OAuthSource
class TestBlueprintsV1ConditionalFields(TransactionTestCase):
@@ -29,24 +27,6 @@ class TestBlueprintsV1ConditionalFields(TransactionTestCase):
self.assertIsNotNone(token)
self.assertEqual(token.key, self.uid)
def test_application(self):
"""Test application"""
app = Application.objects.filter(slug=f"{self.uid}-app").first()
self.assertIsNotNone(app)
self.assertEqual(app.meta_icon, "https://goauthentik.io/img/icon.png")
def test_source(self):
"""Test source"""
source = OAuthSource.objects.filter(slug=f"{self.uid}-source").first()
self.assertIsNotNone(source)
self.assertEqual(source.icon, "https://goauthentik.io/img/icon.png")
def test_flow(self):
"""Test flow"""
flow = Flow.objects.filter(slug=f"{self.uid}-flow").first()
self.assertIsNotNone(flow)
self.assertEqual(flow.background, "https://goauthentik.io/img/icon.png")
def test_user(self):
"""Test user"""
user: User = User.objects.filter(username=self.uid).first()

View File

@@ -36,10 +36,7 @@ class TestBlueprintsV1RBAC(TransactionTestCase):
self.assertTrue(importer.apply())
role = Role.objects.filter(name=uid).first()
self.assertIsNotNone(role)
self.assertEqual(
list(role.group.permissions.all().values_list("codename", flat=True)),
["view_blueprintinstance"],
)
self.assertEqual(get_perms(role), {"authentik_blueprints.view_blueprintinstance"})
def test_object_permission(self):
"""Test permissions"""
@@ -53,5 +50,5 @@ class TestBlueprintsV1RBAC(TransactionTestCase):
user = User.objects.filter(username=uid).first()
role = Role.objects.filter(name=uid).first()
self.assertIsNotNone(flow)
self.assertEqual(get_perms(user, flow), ["view_flow"])
self.assertEqual(get_perms(role.group, flow), ["view_flow"])
self.assertEqual(get_perms(user, flow), {"authentik_flows.view_flow"})
self.assertEqual(get_perms(role, flow), {"authentik_flows.view_flow"})

View File

@@ -16,8 +16,7 @@ from django.db.models.query_utils import Q
from django.db.transaction import atomic
from django.db.utils import IntegrityError
from django_channels_postgres.models import GroupChannel, Message
from guardian.models import UserObjectPermission
from guardian.shortcuts import assign_perm
from guardian.models import RoleObjectPermission, UserObjectPermission
from rest_framework.exceptions import ValidationError
from rest_framework.serializers import BaseSerializer, Serializer
from structlog.stdlib import BoundLogger, get_logger
@@ -110,6 +109,7 @@ def excluded_models() -> list[type[Model]]:
DjangoGroup,
ContentType,
Permission,
RoleObjectPermission,
UserObjectPermission,
# Base classes
Provider,
@@ -394,10 +394,12 @@ class Importer:
"""Apply object-level permissions for an entry"""
for perm in entry.get_permissions(self._import):
if perm.user is not None:
assign_perm(perm.permission, User.objects.get(pk=perm.user), instance)
User.objects.get(pk=perm.user).assign_perms_to_managed_role(
perm.permission, instance
)
if perm.role is not None:
role = Role.objects.get(pk=perm.role)
role.assign_permission(perm.permission, obj=instance)
role.assign_perms(perm.permission, obj=instance)
def apply(self) -> bool:
"""Apply (create/update) models yaml, in database transaction"""

View File

@@ -163,4 +163,4 @@ class BrandViewSet(UsedByMixin, ModelViewSet):
def current(self, request: Request) -> Response:
"""Get current brand"""
brand: Brand = request._request.brand
return Response(CurrentBrandSerializer(brand).data)
return Response(CurrentBrandSerializer(brand, context={"request": request}).data)

View File

@@ -0,0 +1,35 @@
# Generated by Django 5.2.8 on 2025-11-27 16:22
import authentik.admin.files.fields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("authentik_brands", "0010_brand_client_certificates_and_more"),
]
operations = [
migrations.AlterField(
model_name="brand",
name="branding_default_flow_background",
field=authentik.admin.files.fields.FileField(
default="/static/dist/assets/images/flow_background.jpg"
),
),
migrations.AlterField(
model_name="brand",
name="branding_favicon",
field=authentik.admin.files.fields.FileField(
default="/static/dist/assets/icons/icon.png"
),
),
migrations.AlterField(
model_name="brand",
name="branding_logo",
field=authentik.admin.files.fields.FileField(
default="/static/dist/assets/icons/icon_left_brand.svg"
),
),
]

View File

@@ -8,9 +8,11 @@ from django.utils.translation import gettext_lazy as _
from rest_framework.serializers import Serializer
from structlog.stdlib import get_logger
from authentik.admin.files.fields import FileField
from authentik.admin.files.manager import get_file_manager
from authentik.admin.files.usage import FileUsage
from authentik.crypto.models import CertificateKeyPair
from authentik.flows.models import Flow
from authentik.lib.config import CONFIG
from authentik.lib.models import SerializerModel
LOGGER = get_logger()
@@ -31,11 +33,11 @@ class Brand(SerializerModel):
branding_title = models.TextField(default="authentik")
branding_logo = models.TextField(default="/static/dist/assets/icons/icon_left_brand.svg")
branding_favicon = models.TextField(default="/static/dist/assets/icons/icon.png")
branding_logo = FileField(default="/static/dist/assets/icons/icon_left_brand.svg")
branding_favicon = FileField(default="/static/dist/assets/icons/icon.png")
branding_custom_css = models.TextField(default="", blank=True)
branding_default_flow_background = models.TextField(
default="/static/dist/assets/images/flow_background.jpg"
branding_default_flow_background = FileField(
default="/static/dist/assets/images/flow_background.jpg",
)
flow_authentication = models.ForeignKey(
@@ -84,25 +86,19 @@ class Brand(SerializerModel):
attributes = models.JSONField(default=dict, blank=True)
def branding_logo_url(self) -> str:
"""Get branding_logo with the correct prefix"""
if self.branding_logo.startswith("/static"):
return CONFIG.get("web.path", "/")[:-1] + self.branding_logo
return self.branding_logo
"""Get branding_logo URL"""
return get_file_manager(FileUsage.MEDIA).file_url(self.branding_logo)
def branding_favicon_url(self) -> str:
"""Get branding_favicon with the correct prefix"""
if self.branding_favicon.startswith("/static"):
return CONFIG.get("web.path", "/")[:-1] + self.branding_favicon
return self.branding_favicon
"""Get branding_favicon URL"""
return get_file_manager(FileUsage.MEDIA).file_url(self.branding_favicon)
def branding_default_flow_background_url(self) -> str:
"""Get branding_default_flow_background with the correct prefix"""
if self.branding_default_flow_background.startswith("/static"):
return CONFIG.get("web.path", "/")[:-1] + self.branding_default_flow_background
return self.branding_default_flow_background
"""Get branding_default_flow_background URL"""
return get_file_manager(FileUsage.MEDIA).file_url(self.branding_default_flow_background)
@property
def serializer(self) -> Serializer:
def serializer(self) -> type[Serializer]:
from authentik.brands.api import BrandSerializer
return BrandSerializer

View File

@@ -8,12 +8,11 @@ from django.db.models import QuerySet
from django.shortcuts import get_object_or_404
from django.utils.translation import gettext as _
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
from drf_spectacular.utils import OpenApiParameter, extend_schema
from guardian.shortcuts import get_objects_for_user
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.fields import CharField, ReadOnlyField, SerializerMethodField
from rest_framework.parsers import MultiPartParser
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet
@@ -26,16 +25,9 @@ from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import ModelSerializer
from authentik.core.models import Application, User
from authentik.events.logs import LogEventSerializer, capture_logs
from authentik.lib.utils.file import (
FilePathSerializer,
FileUploadSerializer,
set_file,
set_file_url,
)
from authentik.policies.api.exec import PolicyTestResultSerializer
from authentik.policies.engine import PolicyEngine
from authentik.policies.types import CACHE_PREFIX, PolicyResult
from authentik.rbac.decorators import permission_required
from authentik.rbac.filters import ObjectFilter
LOGGER = get_logger()
@@ -58,7 +50,7 @@ class ApplicationSerializer(ModelSerializer):
source="backchannel_providers", required=False, read_only=True, many=True
)
meta_icon = ReadOnlyField(source="get_meta_icon")
meta_icon_url = ReadOnlyField(source="get_meta_icon")
def get_launch_url(self, app: Application) -> str | None:
"""Allow formatting of launch URL"""
@@ -95,13 +87,13 @@ class ApplicationSerializer(ModelSerializer):
"open_in_new_tab",
"meta_launch_url",
"meta_icon",
"meta_icon_url",
"meta_description",
"meta_publisher",
"policy_engine_mode",
"group",
]
extra_kwargs = {
"meta_icon": {"read_only": True},
"backchannel_providers": {"required": False},
}
@@ -286,44 +278,3 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
serializer = self.get_serializer(allowed_applications, many=True)
return self.get_paginated_response(serializer.data)
@permission_required("authentik_core.change_application")
@extend_schema(
request={
"multipart/form-data": FileUploadSerializer,
},
responses={
200: OpenApiResponse(description="Success"),
400: OpenApiResponse(description="Bad request"),
},
)
@action(
detail=True,
pagination_class=None,
filter_backends=[],
methods=["POST"],
parser_classes=(MultiPartParser,),
)
def set_icon(self, request: Request, slug: str):
"""Set application icon"""
app: Application = self.get_object()
return set_file(request, app, "meta_icon")
@permission_required("authentik_core.change_application")
@extend_schema(
request=FilePathSerializer,
responses={
200: OpenApiResponse(description="Success"),
400: OpenApiResponse(description="Bad request"),
},
)
@action(
detail=True,
pagination_class=None,
filter_backends=[],
methods=["POST"],
)
def set_icon_url(self, request: Request, slug: str):
"""Set application icon (as URL)"""
app: Application = self.get_object()
return set_file_url(request, app, "meta_icon")

View File

@@ -72,13 +72,13 @@ class AdminDeviceViewSet(ViewSet):
"""Viewset for authenticator devices"""
serializer_class = DeviceSerializer
permission_classes = []
permission_classes = [IsAuthenticated]
def get_devices(self, **kwargs):
"""Get all devices in all child classes"""
for model in device_classes():
device_set = get_objects_for_user(
self.request.user, f"{model._meta.app_label}.view_{model._meta.model_name}", model
self.request.user, f"{model._meta.app_label}.view_{model._meta.model_name}"
).filter(**kwargs)
yield from device_set

View File

@@ -17,10 +17,11 @@ from guardian.shortcuts import get_objects_for_user
from rest_framework.authentication import SessionAuthentication
from rest_framework.decorators import action
from rest_framework.fields import CharField, IntegerField, SerializerMethodField
from rest_framework.permissions import IsAuthenticated
from rest_framework.relations import PrimaryKeyRelatedField
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import ListSerializer, ValidationError
from rest_framework.validators import UniqueValidator
from rest_framework.viewsets import ModelViewSet
from authentik.api.authentication import TokenAuthentication
@@ -53,8 +54,8 @@ class PartialUserSerializer(ModelSerializer):
]
class GroupChildSerializer(ModelSerializer):
"""Stripped down group serializer to show relevant children for groups"""
class RelatedGroupSerializer(ModelSerializer):
"""Stripped down group serializer to show relevant children/parents for groups"""
attributes = JSONDictField(required=False)
@@ -73,15 +74,16 @@ class GroupSerializer(ModelSerializer):
"""Group Serializer"""
attributes = JSONDictField(required=False)
users_obj = SerializerMethodField(allow_null=True)
parents = PrimaryKeyRelatedField(queryset=Group.objects.all(), many=True, required=False)
parents_obj = SerializerMethodField(allow_null=True)
children_obj = SerializerMethodField(allow_null=True)
users_obj = SerializerMethodField(allow_null=True)
roles_obj = ListSerializer(
child=RoleSerializer(),
read_only=True,
source="roles",
required=False,
)
parent_name = CharField(source="parent.name", read_only=True, allow_null=True)
num_pk = IntegerField(read_only=True)
@property
@@ -98,25 +100,30 @@ class GroupSerializer(ModelSerializer):
return True
return str(request.query_params.get("include_children", "false")).lower() == "true"
@property
def _should_include_parents(self) -> bool:
request: Request = self.context.get("request", None)
if not request:
return True
return str(request.query_params.get("include_parents", "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:
return None
return PartialUserSerializer(instance.users, many=True).data
@extend_schema_field(GroupChildSerializer(many=True))
def get_children_obj(self, instance: Group) -> list[GroupChildSerializer] | None:
@extend_schema_field(RelatedGroupSerializer(many=True))
def get_children_obj(self, instance: Group) -> list[RelatedGroupSerializer] | None:
if not self._should_include_children:
return None
return GroupChildSerializer(instance.children, many=True).data
return RelatedGroupSerializer(instance.children, many=True).data
def validate_parent(self, parent: Group | None):
"""Validate group parent (if set), ensuring the parent isn't itself"""
if not self.instance or not parent:
return parent
if str(parent.group_uuid) == str(self.instance.group_uuid):
raise ValidationError(_("Cannot set group as parent of itself."))
return parent
@extend_schema_field(RelatedGroupSerializer(many=True))
def get_parents_obj(self, instance: Group) -> list[RelatedGroupSerializer] | None:
if not self._should_include_parents:
return None
return RelatedGroupSerializer(instance.parents, many=True).data
def validate_is_superuser(self, superuser: bool):
"""Ensure that the user creating this group has permissions to set the superuser flag"""
@@ -152,8 +159,8 @@ class GroupSerializer(ModelSerializer):
"num_pk",
"name",
"is_superuser",
"parent",
"parent_name",
"parents",
"parents_obj",
"users",
"users_obj",
"attributes",
@@ -170,9 +177,10 @@ class GroupSerializer(ModelSerializer):
"required": False,
"default": list,
},
# TODO: This field isn't unique on the database which is hard to backport
# hence we just validate the uniqueness here
"name": {"validators": [UniqueValidator(Group.objects.all())]},
"parents": {
"required": False,
"default": list,
},
}
@@ -251,7 +259,7 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
]
def get_queryset(self):
base_qs = Group.objects.all().select_related("parent").prefetch_related("roles")
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")
@@ -263,12 +271,16 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
if self.serializer_class(context={"request": self.request})._should_include_children:
base_qs = base_qs.prefetch_related("children")
if self.serializer_class(context={"request": self.request})._should_include_parents:
base_qs = base_qs.prefetch_related("parents")
return base_qs
@extend_schema(
parameters=[
OpenApiParameter("include_users", bool, default=True),
OpenApiParameter("include_children", bool, default=False),
OpenApiParameter("include_parents", bool, default=False),
]
)
def list(self, request, *args, **kwargs):
@@ -278,6 +290,7 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
parameters=[
OpenApiParameter("include_users", bool, default=True),
OpenApiParameter("include_children", bool, default=False),
OpenApiParameter("include_parents", bool, default=False),
]
)
def retrieve(self, request, *args, **kwargs):
@@ -296,7 +309,7 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
methods=["POST"],
pagination_class=None,
filter_backends=[],
permission_classes=[],
permission_classes=[IsAuthenticated],
)
@validate(UserAccountSerializer)
def add_user(self, request: Request, body: UserAccountSerializer, pk: str) -> Response:
@@ -327,7 +340,7 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
methods=["POST"],
pagination_class=None,
filter_backends=[],
permission_classes=[],
permission_classes=[IsAuthenticated],
)
@validate(UserAccountSerializer)
def remove_user(self, request: Request, body: UserAccountSerializer, pk: str) -> Response:

View File

@@ -11,6 +11,7 @@ from rest_framework.response import Response
from authentik.core.api.utils import PassiveSerializer
from authentik.enterprise.apps import EnterpriseConfig
from authentik.lib.models import DeprecatedMixin
from authentik.lib.utils.reflection import all_subclasses
@@ -24,6 +25,7 @@ class TypeCreateSerializer(PassiveSerializer):
icon_url = CharField(required=False)
requires_enterprise = BooleanField(default=False)
deprecated = BooleanField(default=False)
class CreatableType:
@@ -69,6 +71,7 @@ class TypesMixin:
"requires_enterprise": isinstance(
subclass._meta.app_config, EnterpriseConfig
),
"deprecated": isinstance(instance, DeprecatedMixin),
}
)
except NotImplementedError:

View File

@@ -2,31 +2,22 @@
from collections.abc import Iterable
from drf_spectacular.utils import OpenApiResponse, extend_schema
from drf_spectacular.utils import extend_schema
from rest_framework import mixins
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.fields import CharField, ReadOnlyField, SerializerMethodField
from rest_framework.parsers import MultiPartParser
from rest_framework.fields import ReadOnlyField, SerializerMethodField
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.viewsets import GenericViewSet
from structlog.stdlib import get_logger
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
from authentik.core.api.object_types import TypesMixin
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import MetaNameSerializer, ModelSerializer
from authentik.core.models import GroupSourceConnection, Source, UserSourceConnection
from authentik.core.types import UserSettingSerializer
from authentik.lib.utils.file import (
FilePathSerializer,
FileUploadSerializer,
set_file,
set_file_url,
)
from authentik.policies.engine import PolicyEngine
from authentik.rbac.decorators import permission_required
LOGGER = get_logger()
@@ -36,7 +27,7 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer):
managed = ReadOnlyField()
component = SerializerMethodField()
icon = ReadOnlyField(source="icon_url")
icon_url = ReadOnlyField()
def get_component(self, obj: Source) -> str:
"""Get object component so that we know how to edit the object"""
@@ -44,11 +35,6 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer):
return ""
return obj.component
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
if SERIALIZER_CONTEXT_BLUEPRINT in self.context:
self.fields["icon"] = CharField(required=False)
class Meta:
model = Source
fields = [
@@ -70,6 +56,7 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer):
"managed",
"user_path_template",
"icon",
"icon_url",
]
@@ -92,47 +79,6 @@ class SourceViewSet(
def get_queryset(self): # pragma: no cover
return Source.objects.select_subclasses()
@permission_required("authentik_core.change_source")
@extend_schema(
request={
"multipart/form-data": FileUploadSerializer,
},
responses={
200: OpenApiResponse(description="Success"),
400: OpenApiResponse(description="Bad request"),
},
)
@action(
detail=True,
pagination_class=None,
filter_backends=[],
methods=["POST"],
parser_classes=(MultiPartParser,),
)
def set_icon(self, request: Request, slug: str):
"""Set source icon"""
source: Source = self.get_object()
return set_file(request, source, "icon")
@permission_required("authentik_core.change_source")
@extend_schema(
request=FilePathSerializer,
responses={
200: OpenApiResponse(description="Success"),
400: OpenApiResponse(description="Bad request"),
},
)
@action(
detail=True,
pagination_class=None,
filter_backends=[],
methods=["POST"],
)
def set_icon_url(self, request: Request, slug: str):
"""Set source icon (as URL)"""
source: Source = self.get_object()
return set_file_url(request, source, "icon")
@extend_schema(responses={200: UserSettingSerializer(many=True)})
@action(detail=False, pagination_class=None, filter_backends=[])
def user_settings(self, request: Request) -> Response:

View File

@@ -4,7 +4,7 @@ from typing import Any
from django.utils.timezone import now
from drf_spectacular.utils import OpenApiResponse, extend_schema
from guardian.shortcuts import assign_perm, get_anonymous_user
from guardian.shortcuts import get_anonymous_user
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.fields import CharField
@@ -157,7 +157,9 @@ class TokenViewSet(UsedByMixin, ModelViewSet):
user=self.request.user,
expiring=self.request.user.attributes.get(USER_ATTRIBUTE_TOKEN_EXPIRING, True),
)
assign_perm("authentik_core.view_token_key", self.request.user, instance)
self.request.user.assign_perms_to_managed_role(
"authentik_core.view_token_key", instance
)
return instance
return super().perform_create(serializer)

View File

@@ -24,6 +24,7 @@ class DeleteAction(Enum):
CASCADE_MANY = "cascade_many"
SET_NULL = "set_null"
SET_DEFAULT = "set_default"
LEFT_DANGLING = "left_dangling"
class UsedBySerializer(PassiveSerializer):
@@ -80,7 +81,7 @@ class UsedByMixin:
# query and check if there is a difference between modes the user can see
# and can't see and add a warning
for obj in get_objects_for_user(
request.user, f"{app}.view_{model_name}", manager
request.user, f"{app}.view_{model_name}", manager.all()
).all():
# Only merge shadows on first object
if first_object:

View File

@@ -43,6 +43,7 @@ from rest_framework.fields import (
ListField,
SerializerMethodField,
)
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import (
@@ -85,8 +86,9 @@ from authentik.flows.models import FlowToken
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner
from authentik.flows.views.executor import QS_KEY_TOKEN
from authentik.lib.avatars import get_avatar
from authentik.rbac.api.roles import RoleSerializer
from authentik.rbac.decorators import permission_required
from authentik.rbac.models import get_permission_choices
from authentik.rbac.models import Role, get_permission_choices
from authentik.stages.email.flow import pickle_flow_token_for_email
from authentik.stages.email.models import EmailStage
from authentik.stages.email.tasks import send_mails
@@ -105,7 +107,6 @@ class PartialGroupSerializer(ModelSerializer):
"""Partial Group Serializer, does not include child relations."""
attributes = JSONDictField(required=False)
parent_name = CharField(source="parent.name", read_only=True, allow_null=True)
class Meta:
model = Group
@@ -114,8 +115,6 @@ class PartialGroupSerializer(ModelSerializer):
"num_pk",
"name",
"is_superuser",
"parent",
"parent_name",
"attributes",
]
@@ -134,6 +133,13 @@ class UserSerializer(ModelSerializer):
default=list,
)
groups_obj = SerializerMethodField(allow_null=True)
roles = PrimaryKeyRelatedField(
allow_empty=True,
many=True,
queryset=Role.objects.all().order_by("name"),
default=list,
)
roles_obj = SerializerMethodField(allow_null=True)
uid = CharField(read_only=True)
username = CharField(
max_length=150,
@@ -147,12 +153,25 @@ class UserSerializer(ModelSerializer):
return True
return str(request.query_params.get("include_groups", "true")).lower() == "true"
@property
def _should_include_roles(self) -> bool:
request: Request = self.context.get("request", None)
if not request:
return True
return str(request.query_params.get("include_roles", "true")).lower() == "true"
@extend_schema_field(PartialGroupSerializer(many=True))
def get_groups_obj(self, instance: User) -> list[PartialGroupSerializer] | None:
if not self._should_include_groups:
return None
return PartialGroupSerializer(instance.ak_groups, many=True).data
@extend_schema_field(RoleSerializer(many=True))
def get_roles_obj(self, instance: User) -> list[RoleSerializer] | None:
if not self._should_include_roles:
return None
return RoleSerializer(instance.roles, many=True).data
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if SERIALIZER_CONTEXT_BLUEPRINT in self.context:
@@ -167,24 +186,26 @@ class UserSerializer(ModelSerializer):
directly setting a password. However should be done via the `set_password`
method instead of directly setting it like rest_framework."""
password = validated_data.pop("password", None)
permissions = Permission.objects.filter(
perms_qs = Permission.objects.filter(
codename__in=[x.split(".")[1] for x in validated_data.pop("permissions", [])]
)
validated_data["user_permissions"] = permissions
).values_list("content_type__app_label", "codename")
perms_list = [f"{ct}.{name}" for ct, name in list(perms_qs)]
instance: User = super().create(validated_data)
self._set_password(instance, password)
instance.assign_perms_to_managed_role(perms_list)
return instance
def update(self, instance: User, validated_data: dict) -> User:
"""Same as `create` above, set the password directly if we're in a blueprint
context"""
password = validated_data.pop("password", None)
permissions = Permission.objects.filter(
perms_qs = Permission.objects.filter(
codename__in=[x.split(".")[1] for x in validated_data.pop("permissions", [])]
)
validated_data["user_permissions"] = permissions
).values_list("content_type__app_label", "codename")
perms_list = [f"{ct}.{name}" for ct, name in list(perms_qs)]
instance = super().update(instance, validated_data)
self._set_password(instance, password)
instance.assign_perms_to_managed_role(perms_list)
return instance
def _set_password(self, instance: User, password: str | None):
@@ -239,6 +260,8 @@ class UserSerializer(ModelSerializer):
"is_superuser",
"groups",
"groups_obj",
"roles",
"roles_obj",
"email",
"avatar",
"attributes",
@@ -262,6 +285,7 @@ class UserSelfSerializer(ModelSerializer):
is_superuser = BooleanField(read_only=True)
avatar = SerializerMethodField()
groups = SerializerMethodField()
roles = SerializerMethodField()
uid = CharField(read_only=True)
settings = SerializerMethodField()
system_permissions = SerializerMethodField()
@@ -289,6 +313,25 @@ class UserSelfSerializer(ModelSerializer):
"pk": group.pk,
}
@extend_schema_field(
ListSerializer(
child=inline_serializer(
"UserSelfRoles",
{
"name": CharField(read_only=True),
"pk": CharField(read_only=True),
},
)
)
)
def get_roles(self, _: User):
"""Return only the roles a user is member of"""
for role in self.instance.all_roles().order_by("name"):
yield {
"name": role.name,
"pk": role.pk,
}
def get_settings(self, user: User) -> dict[str, Any]:
"""Get user settings with brand and group settings applied"""
return user.group_attributes(self._context["request"]).get("settings", {})
@@ -310,6 +353,7 @@ class UserSelfSerializer(ModelSerializer):
"is_active",
"is_superuser",
"groups",
"roles",
"email",
"avatar",
"uid",
@@ -389,6 +433,16 @@ class UsersFilter(FilterSet):
queryset=Group.objects.all().order_by("name"),
)
roles_by_name = ModelMultipleChoiceFilter(
field_name="roles__name",
to_field_name="name",
queryset=Role.objects.all().order_by("name"),
)
roles_by_pk = ModelMultipleChoiceFilter(
field_name="roles",
queryset=Role.objects.all().order_by("name"),
)
def filter_is_superuser(self, queryset, name, value):
if value:
return queryset.filter(ak_groups__is_superuser=True).distinct()
@@ -424,6 +478,8 @@ class UsersFilter(FilterSet):
"attributes",
"groups_by_name",
"groups_by_pk",
"roles_by_name",
"roles_by_pk",
"type",
]
@@ -464,11 +520,14 @@ class UserViewSet(UsedByMixin, ModelViewSet):
base_qs = User.objects.all().exclude_anonymous()
if self.serializer_class(context={"request": self.request})._should_include_groups:
base_qs = base_qs.prefetch_related("ak_groups")
if self.serializer_class(context={"request": self.request})._should_include_roles:
base_qs = base_qs.prefetch_related("roles")
return base_qs
@extend_schema(
parameters=[
OpenApiParameter("include_groups", bool, default=True),
OpenApiParameter("include_roles", bool, default=True),
]
)
def list(self, request, *args, **kwargs):
@@ -632,7 +691,11 @@ class UserViewSet(UsedByMixin, ModelViewSet):
400: OpenApiResponse(description="Bad request"),
},
)
@action(detail=True, methods=["POST"], permission_classes=[])
@action(
detail=True,
methods=["POST"],
permission_classes=[IsAuthenticated],
)
@validate(UserPasswordSetSerializer)
def set_password(self, request: Request, pk: int, body: UserPasswordSetSerializer) -> Response:
"""Set password for user"""
@@ -718,7 +781,7 @@ class UserViewSet(UsedByMixin, ModelViewSet):
204: OpenApiResponse(description="Successfully started impersonation"),
},
)
@action(detail=True, methods=["POST"], permission_classes=[])
@action(detail=True, methods=["POST"], permission_classes=[IsAuthenticated])
def impersonate(self, request: Request, pk: int) -> Response:
"""Impersonate a user"""
if not request.tenant.impersonation:

View File

@@ -12,7 +12,27 @@ from authentik.flows.views.executor import SESSION_KEY_PLAN
from authentik.stages.password.stage import PLAN_CONTEXT_METHOD, PLAN_CONTEXT_METHOD_ARGS
class InbuiltBackend(ModelBackend):
class ModelBackendNoAuthz(ModelBackend):
def get_user_permissions(self, user_obj, obj=None):
return set()
def get_group_permissions(self, user_obj, obj=None):
return set()
def get_all_permissions(self, user_obj, obj=None):
return set()
def has_perm(self, user_obj, perm, obj=None):
return False
def has_module_perms(self, user_obj, app_label):
return False
def with_perm(self, perm, is_active=True, include_superusers=True, obj=None):
return User.objects.none()
class InbuiltBackend(ModelBackendNoAuthz):
"""Inbuilt backend"""
def authenticate(

View File

@@ -6,7 +6,6 @@ import django.contrib.auth.models
import django.contrib.auth.validators
import django.db.models.deletion
import django.utils.timezone
import guardian.mixins
from django.conf import settings
from django.db import migrations, models
@@ -111,7 +110,7 @@ class Migration(migrations.Migration):
options={
"permissions": (("reset_user_password", "Reset Password"),),
},
bases=(guardian.mixins.GuardianUserMixin, models.Model),
bases=(models.Model,),
managers=[
("objects", django.contrib.auth.models.UserManager()),
],

View File

@@ -0,0 +1,33 @@
# Generated by Django 5.2.8 on 2025-11-27 16:22
import authentik.admin.files.fields
from django.db import migrations
def clear_cache(apps, schema_editor):
CacheEntry = apps.get_model("django_postgres_cache", "CacheEntry")
db_alias = schema_editor.connection.alias
CacheEntry.objects.using(db_alias).all().delete()
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0053_alter_application_slug_alter_source_slug"),
("django_postgres_cache", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="application",
name="meta_icon",
field=authentik.admin.files.fields.FileField(blank=True, default=""),
),
migrations.AlterField(
model_name="source",
name="icon",
field=authentik.admin.files.fields.FileField(blank=True, default=""),
),
migrations.RunPython(code=clear_cache),
]

View File

@@ -0,0 +1,155 @@
# Generated by Django 5.1.12 on 2025-09-12 08:38
import django.db.models.deletion
import pgtrigger.compiler
import pgtrigger.migrations
import psqlextra.backend.migrations.operations.apply_state
import psqlextra.backend.migrations.operations.create_materialized_view_model
import psqlextra.indexes.unique_index
import psqlextra.manager.manager
import psqlextra.models.view
import uuid
from django.apps.registry import Apps
from django.db import migrations, models
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
def migrate_parents(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
Group = apps.get_model("authentik_core", "Group")
db_alias = schema_editor.connection.alias
for group in Group.objects.using(db_alias).all():
if not group.parent:
continue
group.parents.add(group.parent)
group.save()
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0054_alter_application_meta_icon_alter_source_icon"),
]
operations = [
migrations.CreateModel(
name="GroupParentageNode",
fields=[
(
"uuid",
models.UUIDField(
default=uuid.uuid4, editable=False, primary_key=True, serialize=False
),
),
],
options={
"verbose_name": "Group Parentage Node",
"verbose_name_plural": "Group Parentage Nodes",
"db_table": "authentik_core_groupparentage",
},
),
migrations.AddField(
model_name="groupparentagenode",
name="child",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="parent_nodes",
to="authentik_core.group",
),
),
migrations.AddField(
model_name="groupparentagenode",
name="parent",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="child_nodes",
to="authentik_core.group",
),
),
psqlextra.backend.migrations.operations.create_materialized_view_model.PostgresCreateMaterializedViewModel(
name="GroupAncestryNode",
fields=[
(
"id",
models.AutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
],
options={
"db_table": "authentik_core_groupancestry",
},
view_options={
"query": (
"\n WITH RECURSIVE accumulator AS (\n SELECT\n child_id::text || '-' || parent_id::text as id,\n child_id AS descendant_id,\n parent_id AS ancestor_id\n FROM authentik_core_groupparentage\n\n UNION\n\n SELECT\n accumulator.descendant_id::text || '-' || current.parent_id::text as id,\n accumulator.descendant_id,\n current.parent_id AS ancestor_id\n FROM accumulator\n JOIN authentik_core_groupparentage current\n ON accumulator.ancestor_id = current.child_id\n )\n SELECT * FROM accumulator\n ",
(),
),
},
bases=(psqlextra.models.view.PostgresMaterializedViewModel,),
managers=[
("objects", psqlextra.manager.manager.PostgresManager()),
],
),
psqlextra.backend.migrations.operations.apply_state.ApplyState(
state_operation=migrations.AddField(
model_name="groupancestrynode",
name="ancestor",
field=models.ForeignKey(
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="descendant_nodes",
to="authentik_core.group",
),
),
),
psqlextra.backend.migrations.operations.apply_state.ApplyState(
state_operation=migrations.AddField(
model_name="groupancestrynode",
name="descendant",
field=models.ForeignKey(
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="ancestor_nodes",
to="authentik_core.group",
),
),
),
migrations.AddIndex(
model_name="groupancestrynode",
index=models.Index(fields=["descendant"], name="authentik_c_descend_f83a71_idx"),
),
migrations.AddIndex(
model_name="groupancestrynode",
index=models.Index(fields=["ancestor"], name="authentik_c_ancesto_974845_idx"),
),
migrations.AddIndex(
model_name="groupancestrynode",
index=psqlextra.indexes.unique_index.UniqueIndex(
fields=["id"], name="authentik_c_id_5d0bb4_idx"
),
),
pgtrigger.migrations.AddTrigger(
model_name="groupparentagenode",
trigger=pgtrigger.compiler.Trigger(
name="refresh_groupancestry",
sql=pgtrigger.compiler.UpsertTriggerSql(
func="\n REFRESH MATERIALIZED VIEW CONCURRENTLY authentik_core_groupancestry;\n RETURN NULL;\n ",
hash="a987621714359aa0389e03fd2d52f86b118e7d24",
operation="INSERT OR UPDATE OR DELETE",
pgid="pgtrigger_refresh_groupancestry_62450",
table="authentik_core_groupparentage",
when="AFTER",
),
),
),
migrations.AddField(
model_name="group",
name="parents",
field=models.ManyToManyField(
blank=True,
related_name="children",
through="authentik_core.GroupParentageNode",
to="authentik_core.group",
),
),
migrations.RunPython(migrate_parents, migrations.RunPython.noop),
]

View File

@@ -0,0 +1,180 @@
# Generated by Django 5.1.12 on 2025-09-30 12:29
from django.db import migrations, models
from django.apps.registry import Apps
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
def migrate_object_permissions(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
db_alias = schema_editor.connection.alias
User = apps.get_model("authentik_core", "User")
Group = apps.get_model("auth", "Group")
Role = apps.get_model("authentik_rbac", "Role")
UserObjectPermission = apps.get_model("guardian", "UserObjectPermission")
GroupObjectPermission = apps.get_model("guardian", "GroupObjectPermission")
RoleObjectPermission = apps.get_model("guardian", "RoleObjectPermission")
RoleModelPermission = apps.get_model("guardian", "RoleModelPermission")
def get_role_for_user_id(user_id: int) -> Role:
name = f"ak-managed-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)
return role
def get_role_for_group_id(group_id: int) -> Role:
role = Role.objects.using(db_alias).filter(group_id=group_id).first()
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}"
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
role.save()
return role
# Below are 4 very similar pieces of code, for (user, group) x (model, object).
# Since this is a one-off migration, I won't attempt DRYing them.
# User model permissions
user_ids_with_model_permissions = (
User.user_permissions.through.objects.using(db_alias)
.values_list("user", flat=True)
.distinct()
)
for user_id in user_ids_with_model_permissions:
role = get_role_for_user_id(user_id)
user_model_permissions = User.user_permissions.through.objects.using(db_alias).filter(
user_id=user_id
)
role_model_permissions = []
for user_model_permission in user_model_permissions:
role_model_permissions.append(
RoleModelPermission(
permission=user_model_permission.permission,
content_type=user_model_permission.permission.content_type,
role=role,
)
)
RoleModelPermission.objects.using(db_alias).bulk_create(role_model_permissions)
# Group model permissions
group_ids_with_model_permissions = (
Group.permissions.through.objects.using(db_alias).values_list("group", flat=True).distinct()
)
for group_id in group_ids_with_model_permissions:
role = get_role_for_group_id(group_id)
group_model_permissions = Group.permissions.through.objects.using(db_alias).filter(
group_id=group_id
)
role_model_permissions = []
for group_model_permission in group_model_permissions:
role_model_permissions.append(
RoleModelPermission(
permission=group_model_permission.permission,
content_type=group_model_permission.permission.content_type,
role=role,
)
)
RoleModelPermission.objects.using(db_alias).bulk_create(role_model_permissions)
# User object permissions
user_ids_with_object_permissions = (
UserObjectPermission.objects.using(db_alias).values_list("user", flat=True).distinct()
)
for user_id in user_ids_with_object_permissions:
role = get_role_for_user_id(user_id)
user_object_permissions = UserObjectPermission.objects.using(db_alias).filter(user=user_id)
role_object_permissions = []
for user_object_permission in user_object_permissions:
role_object_permissions.append(
RoleObjectPermission(
permission=user_object_permission.permission,
content_type=user_object_permission.content_type,
object_pk=user_object_permission.object_pk,
role=role,
)
)
RoleObjectPermission.objects.using(db_alias).bulk_create(role_object_permissions)
# Group object permissions
group_ids_with_object_permissions = (
GroupObjectPermission.objects.using(db_alias).values_list("group", flat=True).distinct()
)
for group_id in group_ids_with_object_permissions:
role = get_role_for_group_id(group_id)
group_object_permissions = GroupObjectPermission.objects.using(db_alias).filter(
group=group_id
)
role_object_permissions = []
for group_object_permission in group_object_permissions:
role_object_permissions.append(
RoleObjectPermission(
permission=group_object_permission.permission,
content_type=group_object_permission.content_type,
object_pk=group_object_permission.object_pk,
role=role,
)
)
RoleObjectPermission.objects.using(db_alias).bulk_create(role_object_permissions)
class Migration(migrations.Migration):
dependencies = [
("guardian", "0004_role_permissions"),
("authentik_core", "0055_groupancestor_groupparentagenode_group_parents"),
("authentik_rbac", "0008_alter_role_group"),
]
operations = [
migrations.AddField(
model_name="user",
name="roles",
field=models.ManyToManyField(
blank=True, related_name="users", to="authentik_rbac.role"
),
),
migrations.RunPython(migrate_object_permissions),
migrations.AlterUniqueTogether(
name="group",
unique_together=set(),
),
migrations.AlterField(
model_name="group",
name="parents",
field=models.ManyToManyField(
blank=True,
related_name="children",
through="authentik_core.GroupParentageNode",
to="authentik_core.group",
),
),
migrations.RemoveField(
model_name="group",
name="parent",
),
migrations.AlterField(
model_name="group",
name="name",
field=models.TextField(unique=True, verbose_name="name"),
),
]

View File

@@ -6,9 +6,10 @@ from hashlib import sha256
from typing import Any, Optional, Self
from uuid import uuid4
import pgtrigger
from deepmerge import always_merger
from django.contrib.auth.hashers import check_password
from django.contrib.auth.models import AbstractUser
from django.contrib.auth.models import AbstractUser, Permission
from django.contrib.auth.models import UserManager as DjangoUserManager
from django.contrib.sessions.base_session import AbstractBaseSession
from django.core.validators import validate_slug
@@ -19,18 +20,21 @@ from django.http import HttpRequest
from django.utils.functional import cached_property
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from django_cte import CTE, with_cte
from guardian.conf import settings
from guardian.mixins import GuardianUserMixin
from guardian.models import RoleModelPermission, RoleObjectPermission
from model_utils.managers import InheritanceManager
from psqlextra.indexes import UniqueIndex
from psqlextra.models import PostgresMaterializedViewModel
from rest_framework.serializers import Serializer
from structlog.stdlib import get_logger
from authentik.admin.files.fields import FileField
from authentik.admin.files.manager import get_file_manager
from authentik.admin.files.usage import FileUsage
from authentik.blueprints.models import ManagedModel
from authentik.core.expression.exceptions import PropertyMappingExpressionException
from authentik.core.types import UILoginButton, UserSettingSerializer
from authentik.lib.avatars import get_avatar
from authentik.lib.config import CONFIG
from authentik.lib.expression.exceptions import ControlFlowException
from authentik.lib.generators import generate_id
from authentik.lib.merge import MERGE_LIST_UNIQUE
@@ -41,6 +45,7 @@ from authentik.lib.models import (
)
from authentik.lib.utils.time import timedelta_from_string
from authentik.policies.models import PolicyBindingModel
from authentik.rbac.models import Role
from authentik.tenants.models import DEFAULT_TOKEN_DURATION, DEFAULT_TOKEN_LENGTH
from authentik.tenants.utils import get_current_tenant, get_unique_identifier
@@ -67,6 +72,17 @@ options.DEFAULT_NAMES = options.DEFAULT_NAMES + (
GROUP_RECURSION_LIMIT = 20
MANAGED_ROLE_PREFIX_USER = "ak-managed-role--user"
MANAGED_ROLE_PREFIX_GROUP = "ak-managed-role--group"
def managed_role_name(user_or_group: models.Model):
if isinstance(user_or_group, User):
return f"{MANAGED_ROLE_PREFIX_USER}-{user_or_group.pk}"
if isinstance(user_or_group, Group):
return f"{MANAGED_ROLE_PREFIX_GROUP}-{user_or_group.pk}"
raise TypeError("Managed roles are only available for User or Group.")
def default_token_duration() -> datetime:
"""Default duration a Token is valid"""
@@ -146,69 +162,40 @@ class AttributesMixin(models.Model):
class GroupQuerySet(QuerySet):
def with_children_recursive(self):
"""Recursively get all groups that have the current queryset as parents
or are indirectly related."""
def with_descendants(self):
pks = self.values_list("pk", flat=True)
return Group.objects.filter(Q(pk__in=pks) | Q(ancestor_nodes__ancestor__in=pks)).distinct()
def make_cte(cte):
"""Build the query that ends up in WITH RECURSIVE"""
# Start from self, aka the current query
# Add a depth attribute to limit the recursion
return self.annotate(
relative_depth=models.Value(0, output_field=models.IntegerField())
).union(
# Here is the recursive part of the query. cte refers to the previous iteration
# Only select groups for which the parent is part of the previous iteration
# and increase the depth
# Finally, limit the depth
cte.join(Group, group_uuid=cte.col.parent_id)
.annotate(
relative_depth=models.ExpressionWrapper(
cte.col.relative_depth
+ models.Value(1, output_field=models.IntegerField()),
output_field=models.IntegerField(),
)
)
.filter(relative_depth__lt=GROUP_RECURSION_LIMIT),
all=True,
)
# Build the recursive query, see above
cte = CTE.recursive(make_cte)
# Return the result, as a usable queryset for Group.
return with_cte(cte, select=cte.join(Group, group_uuid=cte.col.group_uuid))
def with_ancestors(self):
pks = self.values_list("pk", flat=True)
return Group.objects.filter(
Q(pk__in=pks) | Q(descendant_nodes__descendant__in=pks)
).distinct()
class Group(SerializerModel, AttributesMixin):
"""Group model which supports a basic hierarchy and has attributes"""
"""Group model which supports a hierarchy and has attributes"""
group_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
name = models.TextField(_("name"))
name = models.TextField(verbose_name=_("name"), unique=True)
is_superuser = models.BooleanField(
default=False, help_text=_("Users added to this group will be superusers.")
)
roles = models.ManyToManyField("authentik_rbac.Role", related_name="ak_groups", blank=True)
parent = models.ForeignKey(
parents = models.ManyToManyField(
"Group",
blank=True,
null=True,
default=None,
on_delete=models.SET_NULL,
symmetrical=False,
through="GroupParentageNode",
related_name="children",
)
objects = GroupQuerySet.as_manager()
class Meta:
unique_together = (
(
"name",
"parent",
),
)
indexes = (
models.Index(fields=["name"]),
models.Index(fields=["is_superuser"]),
@@ -242,12 +229,103 @@ class Group(SerializerModel, AttributesMixin):
"""Recursively check if `user` is member of us, or any parent."""
return user.all_groups().filter(group_uuid=self.group_uuid).exists()
def children_recursive(self: Self | QuerySet["Group"]) -> QuerySet["Group"]:
"""Compatibility layer for Group.objects.with_children_recursive()"""
qs = self
if not isinstance(self, QuerySet):
qs = Group.objects.filter(group_uuid=self.group_uuid)
return qs.with_children_recursive()
def all_roles(self) -> QuerySet[Role]:
"""Get all roles of this group and all of its ancestors."""
return Role.objects.filter(
ak_groups__in=Group.objects.filter(pk=self.pk).with_ancestors()
).distinct()
def get_managed_role(self, create=False):
if create:
name = managed_role_name(self)
role, created = Role.objects.get_or_create(name=name, managed=name)
if created:
role.ak_groups.add(self)
return role
else:
return Role.objects.filter(name=managed_role_name(self)).first()
def assign_perms_to_managed_role(
self,
perms: str | list[str] | Permission | list[Permission],
obj: models.Model | None = None,
):
if not perms:
return
role = self.get_managed_role(create=True)
role.assign_perms(perms, obj)
class GroupParentageNode(models.Model):
uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
child = models.ForeignKey(Group, related_name="parent_nodes", on_delete=models.CASCADE)
parent = models.ForeignKey(Group, related_name="child_nodes", on_delete=models.CASCADE)
class Meta:
verbose_name = _("Group Parentage Node")
verbose_name_plural = _("Group Parentage Nodes")
db_table = "authentik_core_groupparentage"
triggers = [
pgtrigger.Trigger(
name="refresh_groupancestry",
operation=pgtrigger.Insert | pgtrigger.Update | pgtrigger.Delete,
when=pgtrigger.After,
func="""
REFRESH MATERIALIZED VIEW CONCURRENTLY authentik_core_groupancestry;
RETURN NULL;
""",
),
]
def __str__(self) -> str:
return f"Group Parentage Node from #{self.child_id} to {self.parent_id}"
class GroupAncestryNode(PostgresMaterializedViewModel):
descendant = models.ForeignKey(
Group, related_name="ancestor_nodes", on_delete=models.DO_NOTHING
)
ancestor = models.ForeignKey(
Group, related_name="descendant_nodes", on_delete=models.DO_NOTHING
)
class Meta:
# This is a transitive closure of authentik_core_groupparentage
# See https://en.wikipedia.org/wiki/Transitive_closure#In_graph_theory
db_table = "authentik_core_groupancestry"
indexes = [
models.Index(fields=["descendant"]),
models.Index(fields=["ancestor"]),
UniqueIndex(fields=["id"]),
]
class ViewMeta:
query = """
WITH RECURSIVE accumulator AS (
SELECT
child_id::text || '-' || parent_id::text as id,
child_id AS descendant_id,
parent_id AS ancestor_id
FROM authentik_core_groupparentage
UNION
SELECT
accumulator.descendant_id::text || '-' || current.parent_id::text as id,
accumulator.descendant_id,
current.parent_id AS ancestor_id
FROM accumulator
JOIN authentik_core_groupparentage current
ON accumulator.ancestor_id = current.child_id
)
SELECT * FROM accumulator
"""
def __str__(self) -> str:
return f"Group Ancestry Node from {self.descendant_id} to {self.ancestor_id}"
class UserQuerySet(models.QuerySet):
@@ -274,7 +352,7 @@ class UserManager(DjangoUserManager):
return self.get_queryset().exclude_anonymous()
class User(SerializerModel, GuardianUserMixin, AttributesMixin, AbstractUser):
class User(SerializerModel, AttributesMixin, AbstractUser):
"""authentik User model, based on django's contrib auth user model."""
uuid = models.UUIDField(default=uuid4, editable=False, unique=True)
@@ -284,6 +362,7 @@ class User(SerializerModel, GuardianUserMixin, AttributesMixin, AbstractUser):
sources = models.ManyToManyField("Source", through="UserSourceConnection")
ak_groups = models.ManyToManyField("Group", related_name="users")
roles = models.ManyToManyField("authentik_rbac.Role", related_name="users", blank=True)
password_change_date = models.DateTimeField(auto_now_add=True)
last_updated = models.DateTimeField(auto_now=True)
@@ -321,7 +400,60 @@ class User(SerializerModel, GuardianUserMixin, AttributesMixin, AbstractUser):
def all_groups(self) -> QuerySet[Group]:
"""Recursively get all groups this user is a member of."""
return self.ak_groups.all().with_children_recursive()
return self.ak_groups.all().with_ancestors()
def all_roles(self) -> QuerySet[Role]:
"""Get all roles of this user and all of its groups (recursively)."""
return Role.objects.filter(Q(users=self) | Q(ak_groups__in=self.all_groups())).distinct()
def get_managed_role(self, create=False):
if create:
name = managed_role_name(self)
role, created = Role.objects.get_or_create(name=name, managed=name)
if created:
role.users.add(self)
return role
else:
return Role.objects.filter(name=managed_role_name(self)).first()
def get_all_model_perms_on_managed_role(self) -> QuerySet[RoleModelPermission]:
role = self.get_managed_role()
if not role:
return RoleModelPermission.objects.none()
return RoleModelPermission.objects.filter(role=role)
def get_all_obj_perms_on_managed_role(self) -> QuerySet[RoleObjectPermission]:
role = self.get_managed_role()
if not role:
return RoleObjectPermission.objects.none()
return RoleObjectPermission.objects.filter(role=role)
def assign_perms_to_managed_role(
self,
perms: str | list[str] | Permission | list[Permission],
obj: models.Model | None = None,
):
if not perms:
return
role = self.get_managed_role(create=True)
role.assign_perms(perms, obj)
def remove_perms_from_managed_role(
self,
perms: str | list[str] | Permission | list[Permission],
obj: models.Model | None = None,
):
role = self.get_managed_role()
if not role:
return None
role.remove_perms(perms, obj)
def remove_all_perms_from_managed_role(self):
role = self.get_managed_role()
if not role:
return None
RoleModelPermission.objects.filter(role=role).delete()
RoleObjectPermission.objects.filter(role=role).delete()
def group_attributes(self, request: HttpRequest | None = None) -> dict[str, Any]:
"""Get a dictionary containing the attributes from all groups the user belongs to,
@@ -554,13 +686,7 @@ class Application(SerializerModel, PolicyBindingModel):
default=False, help_text=_("Open launch URL in a new browser tab or window.")
)
# For template applications, this can be set to /static/authentik/applications/*
meta_icon = models.FileField(
upload_to="application-icons/",
default=None,
null=True,
max_length=500,
)
meta_icon = FileField(default="", blank=True)
meta_description = models.TextField(default="", blank=True)
meta_publisher = models.TextField(default="", blank=True)
@@ -577,17 +703,11 @@ class Application(SerializerModel, PolicyBindingModel):
@property
def get_meta_icon(self) -> str | None:
"""Get the URL to the App Icon image. If the name is /static or starts with http
it is returned as-is"""
"""Get the URL to the App Icon image"""
if not self.meta_icon:
return None
if self.meta_icon.name.startswith("http"):
return self.meta_icon.name
if self.meta_icon.name.startswith("fa://"):
return self.meta_icon.name
if self.meta_icon.name.startswith("/"):
return CONFIG.get("web.path", "/")[:-1] + self.meta_icon.name
return self.meta_icon.url
return get_file_manager(FileUsage.MEDIA).file_url(self.meta_icon)
def get_launch_url(self, user: Optional["User"] = None) -> str | None:
"""Get launch URL if set, otherwise attempt to get launch URL based on provider."""
@@ -747,12 +867,8 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
group_property_mappings = models.ManyToManyField(
"PropertyMapping", default=None, blank=True, related_name="source_grouppropertymappings_set"
)
icon = models.FileField(
upload_to="source-icons/",
default=None,
null=True,
max_length=500,
)
icon = FileField(blank=True, default="")
authentication_flow = models.ForeignKey(
"authentik_flows.Flow",
@@ -793,17 +909,11 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
@property
def icon_url(self) -> str | None:
"""Get the URL to the Icon. If the name is /static or
starts with http it is returned as-is"""
"""Get the URL to the source icon"""
if not self.icon:
return None
if self.icon.name.startswith("http"):
return self.icon.name
if self.icon.name.startswith("fa://"):
return self.icon.name
if self.icon.name.startswith("/"):
return CONFIG.get("web.path", "/")[:-1] + self.icon.name
return self.icon.url
return get_file_manager(FileUsage.MEDIA).file_url(self.icon)
def get_user_path(self) -> str:
"""Get user path, fallback to default for formatting errors"""

View File

@@ -41,7 +41,6 @@ class SessionStore(SessionBase):
)
.prefetch_related(
"authenticatedsession__user__groups",
"authenticatedsession__user__user_permissions",
)
.get(
session_key=self.session_key,
@@ -62,7 +61,6 @@ class SessionStore(SessionBase):
)
.prefetch_related(
"authenticatedsession__user__groups",
"authenticatedsession__user__user_permissions",
)
.aget(
session_key=self.session_key,

View File

@@ -1,5 +1,7 @@
"""authentik core signals"""
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from django.contrib.auth.signals import user_logged_in
from django.core.cache import cache
from django.db.models import Model
@@ -17,6 +19,8 @@ from authentik.core.models import (
User,
default_token_duration,
)
from authentik.flows.apps import RefreshOtherFlowsAfterAuthentication
from authentik.root.ws.consumer import build_device_group
# Arguments: user: User, password: str
password_changed = Signal()
@@ -47,6 +51,16 @@ def user_logged_in_session(sender, request: HttpRequest, user: User, **_):
if session:
session.save()
if not RefreshOtherFlowsAfterAuthentication().get():
return
layer = get_channel_layer()
device_cookie = request.COOKIES.get("authentik_device")
if device_cookie:
async_to_sync(layer.group_send)(
build_device_group(device_cookie),
{"type": "event.session.authenticated"},
)
@receiver(post_delete, sender=AuthenticatedSession)
def authenticated_session_delete(sender: type[Model], instance: "AuthenticatedSession", **_):

View File

@@ -1,9 +1,11 @@
{% load static %}
{% load i18n %}
{% load authentik_core %}
{% get_current_language as LANGUAGE_CODE %}
<!DOCTYPE html>
<html
lang="{{ LANGUAGE_CODE }}"
data-theme="{% if ui_theme == "dark" %}dark{% else %}light{% endif %}"
data-theme-choice="{% if ui_theme == "dark" %}dark{% elif ui_theme == "light" %}light{% else %}auto{% endif %}"
>
@@ -15,6 +17,7 @@
<title>{% block title %}{% trans title|default:brand.branding_title %}{% endblock %}</title>
<link rel="icon" href="{{ brand.branding_favicon_url }}">
<link rel="shortcut icon" href="{{ brand.branding_favicon_url }}">
{% block head_before %}
{% endblock %}

View File

@@ -1,7 +1,6 @@
"""Test Application Entitlements API"""
from django.urls import reverse
from guardian.shortcuts import assign_perm
from rest_framework.test import APITestCase
from authentik.core.models import Application, ApplicationEntitlement, Group
@@ -49,7 +48,8 @@ class TestApplicationEntitlements(APITestCase):
def test_group_indirect(self):
"""Test indirect group"""
parent = Group.objects.create(name=generate_id())
group = Group.objects.create(name=generate_id(), parent=parent)
group = Group.objects.create(name=generate_id())
group.parents.add(parent)
self.user.ak_groups.add(group)
ent = ApplicationEntitlement.objects.create(app=self.app, name=generate_id())
PolicyBinding.objects.create(target=ent, group=parent, order=0)
@@ -76,8 +76,8 @@ class TestApplicationEntitlements(APITestCase):
def test_api_perms_global(self):
"""Test API creation with global permissions"""
assign_perm("authentik_core.add_applicationentitlement", self.user)
assign_perm("authentik_core.view_application", self.user)
self.user.assign_perms_to_managed_role("authentik_core.add_applicationentitlement")
self.user.assign_perms_to_managed_role("authentik_core.view_application")
self.client.force_login(self.user)
res = self.client.post(
reverse("authentik_api:applicationentitlement-list"),
@@ -90,8 +90,8 @@ class TestApplicationEntitlements(APITestCase):
def test_api_perms_scoped(self):
"""Test API creation with scoped permissions"""
assign_perm("authentik_core.add_applicationentitlement", self.user)
assign_perm("authentik_core.view_application", self.user, self.app)
self.user.assign_perms_to_managed_role("authentik_core.add_applicationentitlement")
self.user.assign_perms_to_managed_role("authentik_core.view_application", self.app)
self.client.force_login(self.user)
res = self.client.post(
reverse("authentik_api:applicationentitlement-list"),
@@ -104,7 +104,7 @@ class TestApplicationEntitlements(APITestCase):
def test_api_perms_missing(self):
"""Test API creation with no permissions"""
assign_perm("authentik_core.add_applicationentitlement", self.user)
self.user.assign_perms_to_managed_role("authentik_core.add_applicationentitlement")
self.client.force_login(self.user)
res = self.client.post(
reverse("authentik_api:applicationentitlement-list"),

View File

@@ -2,8 +2,6 @@
from json import loads
from django.core.files.base import ContentFile
from django.test.client import BOUNDARY, MULTIPART_CONTENT, encode_multipart
from django.urls import reverse
from rest_framework.test import APITestCase
@@ -57,91 +55,6 @@ class TestApplicationsAPI(APITestCase):
f"https://{self.user.username}-test.test.goauthentik.io/{self.user.username}",
)
def test_set_icon(self):
"""Test set_icon"""
file = ContentFile(b"text", "name")
self.client.force_login(self.user)
response = self.client.post(
reverse(
"authentik_api:application-set-icon",
kwargs={"slug": self.allowed.slug},
),
data=encode_multipart(data={"file": file}, boundary=BOUNDARY),
content_type=MULTIPART_CONTENT,
)
self.assertEqual(response.status_code, 200)
app_raw = self.client.get(
reverse(
"authentik_api:application-detail",
kwargs={"slug": self.allowed.slug},
),
)
app = loads(app_raw.content)
self.allowed.refresh_from_db()
self.assertEqual(self.allowed.get_meta_icon, app["meta_icon"])
self.assertEqual(self.allowed.meta_icon.read(), b"text")
def test_set_icon_relative(self):
"""Test set_icon (relative path)"""
self.client.force_login(self.user)
response = self.client.post(
reverse(
"authentik_api:application-set-icon-url",
kwargs={"slug": self.allowed.slug},
),
data={"url": "relative/path"},
)
self.assertEqual(response.status_code, 200)
self.allowed.refresh_from_db()
self.assertEqual(self.allowed.get_meta_icon, "/media/public/relative/path")
def test_set_icon_absolute(self):
"""Test set_icon (absolute path)"""
self.client.force_login(self.user)
response = self.client.post(
reverse(
"authentik_api:application-set-icon-url",
kwargs={"slug": self.allowed.slug},
),
data={"url": "/relative/path"},
)
self.assertEqual(response.status_code, 200)
self.allowed.refresh_from_db()
self.assertEqual(self.allowed.get_meta_icon, "/relative/path")
def test_set_icon_url(self):
"""Test set_icon (url)"""
self.client.force_login(self.user)
response = self.client.post(
reverse(
"authentik_api:application-set-icon-url",
kwargs={"slug": self.allowed.slug},
),
data={"url": "https://authentik.company/img.png"},
)
self.assertEqual(response.status_code, 200)
self.allowed.refresh_from_db()
self.assertEqual(self.allowed.get_meta_icon, "https://authentik.company/img.png")
def test_set_icon_fa(self):
"""Test set_icon (url)"""
self.client.force_login(self.user)
response = self.client.post(
reverse(
"authentik_api:application-set-icon-url",
kwargs={"slug": self.allowed.slug},
),
data={"url": "fa://fa-check-circle"},
)
self.assertEqual(response.status_code, 200)
self.allowed.refresh_from_db()
self.assertEqual(self.allowed.get_meta_icon, "fa://fa-check-circle")
def test_check_access(self):
"""Test check_access operation"""
self.client.force_login(self.user)
@@ -210,7 +123,8 @@ class TestApplicationsAPI(APITestCase):
"launch_url": f"https://goauthentik.io/{self.user.username}",
"meta_launch_url": "https://goauthentik.io/%(username)s",
"open_in_new_tab": True,
"meta_icon": None,
"meta_icon": "",
"meta_icon_url": None,
"meta_description": "",
"meta_publisher": "",
"policy_engine_mode": "any",
@@ -264,7 +178,8 @@ class TestApplicationsAPI(APITestCase):
"launch_url": f"https://goauthentik.io/{self.user.username}",
"meta_launch_url": "https://goauthentik.io/%(username)s",
"open_in_new_tab": True,
"meta_icon": None,
"meta_icon": "",
"meta_icon_url": None,
"meta_description": "",
"meta_publisher": "",
"policy_engine_mode": "any",
@@ -272,7 +187,8 @@ class TestApplicationsAPI(APITestCase):
{
"launch_url": None,
"meta_description": "",
"meta_icon": None,
"meta_icon": "",
"meta_icon_url": None,
"meta_launch_url": "",
"open_in_new_tab": False,
"meta_publisher": "",

View File

@@ -25,7 +25,8 @@ class TestGroups(TestCase):
user = User.objects.create(username=generate_id())
user2 = User.objects.create(username=generate_id())
parent = Group.objects.create(name=generate_id())
child = Group.objects.create(name=generate_id(), parent=parent)
child = Group.objects.create(name=generate_id())
child.parents.add(parent)
child.users.add(user)
self.assertTrue(child.is_member(user))
self.assertTrue(parent.is_member(user))
@@ -37,8 +38,10 @@ class TestGroups(TestCase):
user = User.objects.create(username=generate_id())
user2 = User.objects.create(username=generate_id())
parent = Group.objects.create(name=generate_id())
second = Group.objects.create(name=generate_id(), parent=parent)
third = Group.objects.create(name=generate_id(), parent=second)
second = Group.objects.create(name=generate_id())
second.parents.add(parent)
third = Group.objects.create(name=generate_id())
third.parents.add(second)
second.users.add(user)
self.assertTrue(parent.is_member(user))
self.assertFalse(parent.is_member(user2))
@@ -51,9 +54,21 @@ class TestGroups(TestCase):
"""Test group membership (recursive)"""
user = User.objects.create(username=generate_id())
group = Group.objects.create(name=generate_id())
group2 = Group.objects.create(name=generate_id(), parent=group)
group2 = Group.objects.create(name=generate_id())
group.parents.add(group2)
group2.parents.add(group)
group.users.add(user)
group.parent = group2
group.save()
self.assertTrue(group.is_member(user))
self.assertTrue(group2.is_member(user))
def test_group_managed_role(self):
"""Test group managed role"""
perm = "authentik_core.view_user"
user = User.objects.create(username=generate_id())
group = Group.objects.create(name=generate_id())
group.users.add(user)
group.assign_perms_to_managed_role(perm)
self.assertEqual(group.roles.count(), 1)
self.assertEqual(user.roles.count(), 0)
self.assertTrue(user.has_perm(perm))

View File

@@ -1,7 +1,6 @@
"""Test Groups API"""
from django.urls.base import reverse
from guardian.shortcuts import assign_perm
from rest_framework.test import APITestCase
from authentik.core.models import Group
@@ -37,8 +36,8 @@ class TestGroupsAPI(APITestCase):
def test_add_user(self):
"""Test add_user"""
group = Group.objects.create(name=generate_id())
assign_perm("authentik_core.add_user_to_group", self.login_user, group)
assign_perm("authentik_core.view_user", self.login_user)
self.login_user.assign_perms_to_managed_role("authentik_core.add_user_to_group", group)
self.login_user.assign_perms_to_managed_role("authentik_core.view_user")
self.client.force_login(self.login_user)
res = self.client.post(
reverse("authentik_api:group-add-user", kwargs={"pk": group.pk}),
@@ -53,8 +52,8 @@ class TestGroupsAPI(APITestCase):
def test_add_user_404(self):
"""Test add_user"""
group = Group.objects.create(name=generate_id())
assign_perm("authentik_core.add_user_to_group", self.login_user, group)
assign_perm("authentik_core.view_user", self.login_user)
self.login_user.assign_perms_to_managed_role("authentik_core.add_user_to_group", group)
self.login_user.assign_perms_to_managed_role("authentik_core.view_user")
self.client.force_login(self.login_user)
res = self.client.post(
reverse("authentik_api:group-add-user", kwargs={"pk": group.pk}),
@@ -67,8 +66,8 @@ class TestGroupsAPI(APITestCase):
def test_remove_user(self):
"""Test remove_user"""
group = Group.objects.create(name=generate_id())
assign_perm("authentik_core.remove_user_from_group", self.login_user, group)
assign_perm("authentik_core.view_user", self.login_user)
self.login_user.assign_perms_to_managed_role("authentik_core.remove_user_from_group", group)
self.login_user.assign_perms_to_managed_role("authentik_core.view_user")
group.users.add(self.user)
self.client.force_login(self.login_user)
res = self.client.post(
@@ -84,8 +83,8 @@ class TestGroupsAPI(APITestCase):
def test_remove_user_404(self):
"""Test remove_user"""
group = Group.objects.create(name=generate_id())
assign_perm("authentik_core.remove_user_from_group", self.login_user, group)
assign_perm("authentik_core.view_user", self.login_user)
self.login_user.assign_perms_to_managed_role("authentik_core.remove_user_from_group", group)
self.login_user.assign_perms_to_managed_role("authentik_core.view_user")
group.users.add(self.user)
self.client.force_login(self.login_user)
res = self.client.post(
@@ -96,23 +95,9 @@ class TestGroupsAPI(APITestCase):
)
self.assertEqual(res.status_code, 404)
def test_parent_self(self):
"""Test parent"""
group = Group.objects.create(name=generate_id())
assign_perm("view_group", self.login_user, group)
assign_perm("change_group", self.login_user, group)
self.client.force_login(self.login_user)
res = self.client.patch(
reverse("authentik_api:group-detail", kwargs={"pk": group.pk}),
data={
"parent": group.pk,
},
)
self.assertEqual(res.status_code, 400)
def test_superuser_no_perm(self):
"""Test creating a superuser group without permission"""
assign_perm("authentik_core.add_group", self.login_user)
self.login_user.assign_perms_to_managed_role("authentik_core.add_group")
self.client.force_login(self.login_user)
res = self.client.post(
reverse("authentik_api:group-list"),
@@ -126,7 +111,7 @@ class TestGroupsAPI(APITestCase):
def test_superuser_no_perm_no_superuser(self):
"""Test creating a group without permission and without superuser flag"""
assign_perm("authentik_core.add_group", self.login_user)
self.login_user.assign_perms_to_managed_role("authentik_core.add_group")
self.client.force_login(self.login_user)
res = self.client.post(
reverse("authentik_api:group-list"),
@@ -137,8 +122,8 @@ class TestGroupsAPI(APITestCase):
def test_superuser_update_no_perm(self):
"""Test updating a superuser group without permission"""
group = Group.objects.create(name=generate_id(), is_superuser=True)
assign_perm("view_group", self.login_user, group)
assign_perm("change_group", self.login_user, group)
self.login_user.assign_perms_to_managed_role("view_group", group)
self.login_user.assign_perms_to_managed_role("change_group", group)
self.client.force_login(self.login_user)
res = self.client.patch(
reverse("authentik_api:group-detail", kwargs={"pk": group.pk}),
@@ -154,8 +139,8 @@ class TestGroupsAPI(APITestCase):
"""Test updating a superuser group without permission
and without changing the superuser status"""
group = Group.objects.create(name=generate_id(), is_superuser=True)
assign_perm("view_group", self.login_user, group)
assign_perm("change_group", self.login_user, group)
self.login_user.assign_perms_to_managed_role("view_group", group)
self.login_user.assign_perms_to_managed_role("change_group", group)
self.client.force_login(self.login_user)
res = self.client.patch(
reverse("authentik_api:group-detail", kwargs={"pk": group.pk}),
@@ -165,8 +150,8 @@ class TestGroupsAPI(APITestCase):
def test_superuser_create(self):
"""Test creating a superuser group with permission"""
assign_perm("authentik_core.add_group", self.login_user)
assign_perm("authentik_core.enable_group_superuser", self.login_user)
self.login_user.assign_perms_to_managed_role("authentik_core.add_group")
self.login_user.assign_perms_to_managed_role("authentik_core.enable_group_superuser")
self.client.force_login(self.login_user)
res = self.client.post(
reverse("authentik_api:group-list"),

View File

@@ -3,7 +3,6 @@
from json import loads
from django.urls import reverse
from guardian.shortcuts import assign_perm
from rest_framework.test import APITestCase
from authentik.core.tests.utils import create_test_admin_user, create_test_user
@@ -48,8 +47,8 @@ class TestImpersonation(APITestCase):
def test_impersonate_global(self):
"""Test impersonation with global permissions"""
new_user = create_test_user()
assign_perm("authentik_core.impersonate", new_user)
assign_perm("authentik_core.view_user", new_user)
new_user.assign_perms_to_managed_role("authentik_core.impersonate")
new_user.assign_perms_to_managed_role("authentik_core.view_user")
self.client.force_login(new_user)
response = self.client.post(
@@ -69,8 +68,8 @@ class TestImpersonation(APITestCase):
def test_impersonate_scoped(self):
"""Test impersonation with scoped permissions"""
new_user = create_test_user()
assign_perm("authentik_core.impersonate", new_user, self.other_user)
assign_perm("authentik_core.view_user", new_user, self.other_user)
new_user.assign_perms_to_managed_role("authentik_core.impersonate", self.other_user)
new_user.assign_perms_to_managed_role("authentik_core.view_user", self.other_user)
self.client.force_login(new_user)
response = self.client.post(

View File

@@ -3,7 +3,7 @@
from django.contrib.auth.models import AnonymousUser
from django.test import TestCase
from django.urls import reverse
from guardian.utils import get_anonymous_user
from guardian.shortcuts import get_anonymous_user
from authentik.core.models import SourceUserMatchingModes, User
from authentik.core.sources.flow_manager import Action

View File

@@ -1,7 +1,6 @@
"""Test Transactional API"""
from django.urls import reverse
from guardian.shortcuts import assign_perm
from rest_framework.test import APITestCase
from authentik.core.models import Application, Group
@@ -16,8 +15,8 @@ class TestTransactionalApplicationsAPI(APITestCase):
def setUp(self) -> None:
self.user = create_test_user()
assign_perm("authentik_core.add_application", self.user)
assign_perm("authentik_providers_oauth2.add_oauth2provider", self.user)
self.user.assign_perms_to_managed_role("authentik_core.add_application")
self.user.assign_perms_to_managed_role("authentik_providers_oauth2.add_oauth2provider")
def test_create_transactional(self):
"""Test transactional Application + provider creation"""
@@ -73,7 +72,7 @@ class TestTransactionalApplicationsAPI(APITestCase):
def test_create_transactional_bindings(self):
"""Test transactional Application + provider creation"""
assign_perm("authentik_policies.add_policybinding", self.user)
self.user.assign_perms_to_managed_role("authentik_policies.add_policybinding")
self.client.force_login(self.user)
uid = generate_id()
group = Group.objects.create(name=generate_id())

View File

@@ -0,0 +1,20 @@
"""user tests"""
from django.test.testcases import TestCase
from authentik.core.models import User
from authentik.lib.generators import generate_id
class TestUsers(TestCase):
"""Test user"""
def test_user_managed_role(self):
"""Test user managed role"""
perm = "authentik_core.view_user"
user = User.objects.create(username=generate_id())
user.assign_perms_to_managed_role(perm)
self.assertEqual(user.roles.count(), 1)
self.assertTrue(user.has_perm(perm))
user.remove_perms_from_managed_role(perm)
self.assertFalse(user.has_perm(perm))

View File

@@ -2,6 +2,7 @@
from json import loads
from django.core.cache import cache
from django.urls.base import reverse
from requests_mock import Mocker
from rest_framework.test import APITestCase
@@ -46,6 +47,7 @@ class TestUsersAvatars(APITestCase):
"6cf71fb567ae36025a9d4ea86b?size=158&rating=g&default=404"
),
text="foo",
headers={"Content-Type": "image/png"},
)
response = self.client.get(reverse("authentik_api:user-me"))
self.assertEqual(response.status_code, 200)
@@ -89,3 +91,170 @@ class TestUsersAvatars(APITestCase):
self.assertEqual(response.status_code, 200)
body = loads(response.content.decode())
self.assertIn("data:image/svg+xml;base64,", body["user"]["avatar"])
def test_avatars_custom_content_type_valid(self):
"""Test custom avatar URL with valid image Content-Type"""
cache.clear()
self.set_avatar_mode("https://example.com/avatar/%(username)s")
self.client.force_login(self.admin)
with Mocker() as mocker:
mocker.head(
f"https://example.com/avatar/{self.admin.username}",
headers={"Content-Type": "image/png"},
)
response = self.client.get(reverse("authentik_api:user-me"))
self.assertEqual(response.status_code, 200)
body = loads(response.content.decode())
self.assertEqual(
body["user"]["avatar"], f"https://example.com/avatar/{self.admin.username}"
)
def test_avatars_custom_content_type_invalid(self):
"""Test custom avatar URL with invalid Content-Type falls back"""
cache.clear()
self.set_avatar_mode("https://example.com/avatar/%(username)s,initials")
self.client.force_login(self.admin)
with Mocker() as mocker:
mocker.head(
f"https://example.com/avatar/{self.admin.username}",
headers={"Content-Type": "text/html"},
)
response = self.client.get(reverse("authentik_api:user-me"))
self.assertEqual(response.status_code, 200)
body = loads(response.content.decode())
# Should fallback to initials since Content-Type is not image/*
self.assertIn("data:image/svg+xml;base64,", body["user"]["avatar"])
def test_avatars_custom_content_type_missing(self):
"""Test custom avatar URL with missing Content-Type header falls back"""
cache.clear()
self.set_avatar_mode("https://example.com/avatar/%(username)s,initials")
self.client.force_login(self.admin)
with Mocker() as mocker:
mocker.head(
f"https://example.com/avatar/{self.admin.username}",
headers={},
)
response = self.client.get(reverse("authentik_api:user-me"))
self.assertEqual(response.status_code, 200)
body = loads(response.content.decode())
# Should fallback to initials since Content-Type header is missing
self.assertIn("data:image/svg+xml;base64,", body["user"]["avatar"])
def test_avatars_custom_404_cached(self):
"""Test that 404 responses are cached with TTL"""
cache.clear()
self.set_avatar_mode("https://example.com/avatar/%(username)s")
self.client.force_login(self.admin)
with Mocker() as mocker:
mocker.head(
f"https://example.com/avatar/{self.admin.username}",
status_code=404,
)
response = self.client.get(reverse("authentik_api:user-me"))
self.assertEqual(response.status_code, 200)
body = loads(response.content.decode())
# Should fallback to default avatar
self.assertEqual(body["user"]["avatar"], "/static/dist/assets/images/user_default.png")
# Verify cache was set with the expected structure
from hashlib import md5
mail_hash = md5(self.admin.email.lower().encode("utf-8"), usedforsecurity=False).hexdigest()
cache_key = f"goauthentik.io/lib/avatars/example.com/{mail_hash}"
self.assertIsNone(cache.get(cache_key))
# Verify TTL was set (cache entry exists)
self.assertTrue(cache.has_key(cache_key))
def test_avatars_custom_redirect(self):
"""Test custom avatar URL follows redirects"""
cache.clear()
self.set_avatar_mode("https://example.com/avatar/%(username)s")
self.client.force_login(self.admin)
with Mocker() as mocker:
# Mock a redirect
mocker.head(
f"https://example.com/avatar/{self.admin.username}",
status_code=302,
headers={"Location": "https://cdn.example.com/final-avatar.png"},
)
mocker.head(
"https://cdn.example.com/final-avatar.png",
headers={"Content-Type": "image/png"},
)
response = self.client.get(reverse("authentik_api:user-me"))
self.assertEqual(response.status_code, 200)
body = loads(response.content.decode())
# Should return the original URL (not the redirect destination)
self.assertEqual(
body["user"]["avatar"], f"https://example.com/avatar/{self.admin.username}"
)
def test_avatars_hostname_availability_cache(self):
"""Test that hostname availability is cached when domain fails"""
from requests.exceptions import Timeout
cache.clear()
self.set_avatar_mode("https://failing.example.com/avatar/%(username)s,initials")
self.client.force_login(self.admin)
with Mocker() as mocker:
# First request times out
mocker.head(
f"https://failing.example.com/avatar/{self.admin.username}",
exc=Timeout("Connection timeout"),
)
response = self.client.get(reverse("authentik_api:user-me"))
self.assertEqual(response.status_code, 200)
body = loads(response.content.decode())
# Should fallback to initials
self.assertIn("data:image/svg+xml;base64,", body["user"]["avatar"])
# Verify hostname is marked as unavailable
cache_key_hostname = "goauthentik.io/lib/avatars/failing.example.com/available"
self.assertFalse(cache.get(cache_key_hostname, True))
# Second request should not even try to fetch (hostname cached as unavailable)
with Mocker() as mocker:
# This should NOT be called due to hostname cache
mocker.head(
f"https://failing.example.com/avatar/{self.admin.username}",
headers={"Content-Type": "image/png"},
)
response = self.client.get(reverse("authentik_api:user-me"))
self.assertEqual(response.status_code, 200)
body = loads(response.content.decode())
# Should still fallback to initials without making a request
self.assertIn("data:image/svg+xml;base64,", body["user"]["avatar"])
# Verify no request was made (request_history should be empty)
self.assertEqual(len(mocker.request_history), 0)
def test_avatars_gravatar_uses_url_validation(self):
"""Test that Gravatar now uses avatar_mode_url validation (regression test)"""
cache.clear()
self.set_avatar_mode("gravatar")
self.admin.email = "test@example.com"
self.admin.save()
self.client.force_login(self.admin)
with Mocker() as mocker:
# Mock Gravatar to return non-image content
from hashlib import sha256
mail_hash = sha256(self.admin.email.lower().encode("utf-8")).hexdigest()
gravatar_url = (
f"https://www.gravatar.com/avatar/{mail_hash}?size=158&rating=g&default=404"
)
mocker.head(
gravatar_url,
headers={"Content-Type": "text/html"},
)
response = self.client.get(reverse("authentik_api:user-me"))
self.assertEqual(response.status_code, 200)
body = loads(response.content.decode())
# Should fallback to default avatar since Content-Type is not image/*
self.assertEqual(body["user"]["avatar"], "/static/dist/assets/images/user_default.png")

View File

@@ -28,8 +28,8 @@ from authentik.core.views.interface import (
)
from authentik.flows.views.interface import FlowInterfaceView
from authentik.root.asgi_middleware import AuthMiddlewareStack
from authentik.root.messages.consumer import MessageConsumer
from authentik.root.middleware import ChannelsLoggingMiddleware
from authentik.root.ws.consumer import MessageConsumer
from authentik.tenants.channels import TenantsAwareMiddleware
urlpatterns = [

View File

@@ -27,6 +27,7 @@ from rest_framework.fields import (
SerializerMethodField,
)
from rest_framework.filters import OrderingFilter, SearchFilter
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.validators import UniqueValidator
@@ -42,7 +43,7 @@ from authentik.crypto.builder import CertificateBuilder, PrivateKeyAlg
from authentik.crypto.models import CertificateKeyPair, KeyType
from authentik.events.models import Event, EventAction
from authentik.rbac.decorators import permission_required
from authentik.rbac.filters import ObjectFilter, SecretKeyFilter
from authentik.rbac.filters import SecretKeyFilter
LOGGER = get_logger()
@@ -292,6 +293,7 @@ class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet):
serializer = self.get_serializer(instance)
return Response(serializer.data)
@permission_required("view_certificatekeypair_certificate")
@extend_schema(
parameters=[
OpenApiParameter(
@@ -302,7 +304,7 @@ class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet):
],
responses={200: CertificateDataSerializer(many=False)},
)
@action(detail=True, pagination_class=None, filter_backends=[ObjectFilter])
@action(detail=True, pagination_class=None, permission_classes=[IsAuthenticated])
def view_certificate(self, request: Request, pk: str) -> Response:
"""Return certificate-key pairs certificate and log access"""
certificate: CertificateKeyPair = self.get_object()
@@ -323,6 +325,7 @@ class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet):
return response
return Response(CertificateDataSerializer({"data": certificate.certificate_data}).data)
@permission_required("view_certificatekeypair_key")
@extend_schema(
parameters=[
OpenApiParameter(
@@ -333,7 +336,7 @@ class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet):
],
responses={200: CertificateDataSerializer(many=False)},
)
@action(detail=True, pagination_class=None, filter_backends=[ObjectFilter])
@action(detail=True, pagination_class=None, permission_classes=[IsAuthenticated])
def view_private_key(self, request: Request, pk: str) -> Response:
"""Return certificate-key pairs private key and log access"""
certificate: CertificateKeyPair = self.get_object()

View File

@@ -0,0 +1,27 @@
# Generated by Django 5.2.8 on 2025-11-20 14:50
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("authentik_crypto", "0004_alter_certificatekeypair_name"),
]
operations = [
migrations.AlterModelOptions(
name="certificatekeypair",
options={
"permissions": [
(
"view_certificatekeypair_certificate",
"View Certificate-Key pair's certificate",
),
("view_certificatekeypair_key", "View Certificate-Key pair's private key"),
],
"verbose_name": "Certificate-Key Pair",
"verbose_name_plural": "Certificate-Key Pairs",
},
),
]

View File

@@ -2,6 +2,8 @@
from binascii import hexlify
from hashlib import md5
from ssl import PEM_FOOTER, PEM_HEADER
from textwrap import wrap
from uuid import uuid4
from cryptography.hazmat.backends import default_backend
@@ -25,6 +27,11 @@ from authentik.lib.models import CreatedUpdatedModel, SerializerModel
LOGGER = get_logger()
def format_cert(raw_pam: str) -> str:
"""Format a PEM certificate that is either missing its header/footer or is in a single line"""
return "\n".join([PEM_HEADER, *wrap(raw_pam.replace("\n", ""), 64), PEM_FOOTER])
class KeyType(models.TextChoices):
"""Cryptographic key algorithm types"""
@@ -140,3 +147,7 @@ class CertificateKeyPair(SerializerModel, ManagedModel, CreatedUpdatedModel):
class Meta:
verbose_name = _("Certificate-Key Pair")
verbose_name_plural = _("Certificate-Key Pairs")
permissions = [
("view_certificatekeypair_certificate", _("View Certificate-Key pair's certificate")),
("view_certificatekeypair_key", _("View Certificate-Key pair's private key")),
]

View File

@@ -12,7 +12,12 @@ from django.utils.timezone import now
from rest_framework.test import APITestCase
from authentik.core.api.used_by import DeleteAction
from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow
from authentik.core.tests.utils import (
create_test_admin_user,
create_test_cert,
create_test_flow,
create_test_user,
)
from authentik.crypto.api import CertificateKeyPairSerializer
from authentik.crypto.builder import CertificateBuilder
from authentik.crypto.models import CertificateKeyPair
@@ -144,7 +149,7 @@ class TestCrypto(APITestCase):
),
data={"name": cert.name},
)
self.assertEqual(200, response.status_code)
self.assertEqual(response.status_code, 200)
body = loads(response.content.decode())
api_cert = [x for x in body["results"] if x["name"] == cert.name][0]
self.assertEqual(api_cert["fingerprint_sha1"], cert.fingerprint_sha1)
@@ -162,7 +167,7 @@ class TestCrypto(APITestCase):
),
data={"name": cert.name, "has_key": False},
)
self.assertEqual(200, response.status_code)
self.assertEqual(response.status_code, 200)
body = loads(response.content.decode())
api_cert = [x for x in body["results"] if x["name"] == cert.name][0]
self.assertEqual(api_cert["fingerprint_sha1"], cert.fingerprint_sha1)
@@ -178,7 +183,7 @@ class TestCrypto(APITestCase):
),
data={"name": cert.name, "include_details": False},
)
self.assertEqual(200, response.status_code)
self.assertEqual(response.status_code, 200)
body = loads(response.content.decode())
api_cert = [x for x in body["results"] if x["name"] == cert.name][0]
self.assertEqual(api_cert["fingerprint_sha1"], None)
@@ -186,15 +191,18 @@ class TestCrypto(APITestCase):
def test_certificate_download(self):
"""Test certificate export (download)"""
self.client.force_login(create_test_admin_user())
keypair = create_test_cert()
user = create_test_user()
user.assign_perms_to_managed_role("view_certificatekeypair", keypair)
user.assign_perms_to_managed_role("view_certificatekeypair_certificate", keypair)
self.client.force_login(user)
response = self.client.get(
reverse(
"authentik_api:certificatekeypair-view-certificate",
kwargs={"pk": keypair.pk},
)
)
self.assertEqual(200, response.status_code)
self.assertEqual(response.status_code, 200)
response = self.client.get(
reverse(
"authentik_api:certificatekeypair-view-certificate",
@@ -202,20 +210,23 @@ class TestCrypto(APITestCase):
),
data={"download": True},
)
self.assertEqual(200, response.status_code)
self.assertEqual(response.status_code, 200)
self.assertIn("Content-Disposition", response)
def test_private_key_download(self):
"""Test private_key export (download)"""
self.client.force_login(create_test_admin_user())
keypair = create_test_cert()
user = create_test_user()
user.assign_perms_to_managed_role("view_certificatekeypair", keypair)
user.assign_perms_to_managed_role("view_certificatekeypair_key", keypair)
self.client.force_login(user)
response = self.client.get(
reverse(
"authentik_api:certificatekeypair-view-private-key",
kwargs={"pk": keypair.pk},
)
)
self.assertEqual(200, response.status_code)
self.assertEqual(response.status_code, 200)
response = self.client.get(
reverse(
"authentik_api:certificatekeypair-view-private-key",
@@ -223,12 +234,12 @@ class TestCrypto(APITestCase):
),
data={"download": True},
)
self.assertEqual(200, response.status_code)
self.assertEqual(response.status_code, 200)
self.assertIn("Content-Disposition", response)
def test_certificate_download_denied(self):
"""Test certificate export (download)"""
self.client.logout()
self.client.force_login(create_test_user())
keypair = create_test_cert()
response = self.client.get(
reverse(
@@ -248,7 +259,7 @@ class TestCrypto(APITestCase):
def test_private_key_download_denied(self):
"""Test private_key export (download)"""
self.client.logout()
self.client.force_login(create_test_user())
keypair = create_test_cert()
response = self.client.get(
reverse(
@@ -284,7 +295,7 @@ class TestCrypto(APITestCase):
kwargs={"pk": keypair.pk},
)
)
self.assertEqual(200, response.status_code)
self.assertEqual(response.status_code, 200)
self.assertJSONEqual(
response.content.decode(),
[

View File

@@ -20,6 +20,7 @@ class DeviceUserBindingSerializer(PolicyBindingSerializer):
class DeviceUserBindingViewSet(PolicyBindingViewSet):
"""PolicyBinding Viewset"""
serializer_class = DeviceUserBindingSerializer
queryset = (
DeviceUserBinding.objects.all()
.select_related("target", "group", "user")

View File

@@ -1,13 +1,21 @@
from datetime import timedelta
from django.db.models import OuterRef, Subquery
from django.utils.timezone import now
from drf_spectacular.utils import extend_schema
from rest_framework import mixins
from rest_framework.fields import SerializerMethodField
from rest_framework.decorators import action
from rest_framework.fields import IntegerField, SerializerMethodField
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.viewsets import GenericViewSet
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import ModelSerializer
from authentik.core.api.utils import ModelSerializer, PassiveSerializer
from authentik.endpoints.api.device_access_group import DeviceAccessGroupSerializer
from authentik.endpoints.api.device_connections import DeviceConnectionSerializer
from authentik.endpoints.api.device_fact_snapshots import DeviceFactSnapshotSerializer
from authentik.endpoints.models import Device
from authentik.endpoints.models import Device, DeviceFactSnapshot
class EndpointDeviceSerializer(ModelSerializer):
@@ -67,6 +75,13 @@ class DeviceViewSet(
ordering = ["identifier"]
filterset_fields = ["name", "identifier"]
class DeviceSummarySerializer(PassiveSerializer):
"""Summary of registered devices"""
total_count = IntegerField()
unreachable_count = IntegerField()
outdated_agent_count = IntegerField()
def get_serializer_class(self):
if self.action == "retrieve":
return EndpointDeviceDetailsSerializer
@@ -76,3 +91,28 @@ class DeviceViewSet(
if self.action == "retrieve":
return super().get_queryset().prefetch_related("connections")
return super().get_queryset()
@extend_schema(responses={200: DeviceSummarySerializer()})
@action(methods=["GET"], detail=False)
def summary(self, request: Request) -> Response:
delta = now() - timedelta(hours=24)
unreachable = (
Device.filter_not_expired()
.annotate(
latest_snapshot=Subquery(
DeviceFactSnapshot.objects.filter(connection__device=OuterRef("pk"))
.order_by("-created")
.values("created")[:1]
)
)
.filter(latest_snapshot__lte=delta)
.distinct()
.count()
)
data = {
"total_count": Device.filter_not_expired().count(),
"unreachable_count": unreachable,
# Currently not supported
"outdated_agent_count": 0,
}
return Response(data)

View File

@@ -17,6 +17,7 @@ class EndpointStageSerializer(EnterpriseRequiredMixin, StageSerializer):
fields = StageSerializer.Meta.fields + [
"connector",
"connector_obj",
"mode",
]

View File

@@ -21,7 +21,8 @@ class AgentConfigSerializer(PassiveSerializer):
refresh_interval = SerializerMethodField()
authorization_flow = SerializerMethodField()
jwks = SerializerMethodField()
jwks_auth = SerializerMethodField()
jwks_challenge = SerializerMethodField()
nss_uid_offset = IntegerField()
nss_gid_offset = IntegerField()
@@ -41,10 +42,15 @@ class AgentConfigSerializer(PassiveSerializer):
return None
return instance.authorization_flow.slug
def get_jwks(self, instance: AgentConnector) -> dict:
def get_jwks_auth(self, instance: AgentConnector) -> dict:
kp = CertificateKeyPair.objects.filter(managed=MANAGED_KEY).first()
return {"keys": [JWKSView.get_jwk_for_key(kp, "sig")]}
def get_jwks_challenge(self, instance: AgentConnector) -> dict | None:
if not instance.challenge_key:
return None
return {"keys": [JWKSView.get_jwk_for_key(instance.challenge_key, "sig")]}
def get_system_config(self, instance: AgentConnector) -> ConfigSerializer:
return ConfigView.get_config(self.context["request"]).data

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