Compare commits

...

65 Commits

Author SHA1 Message Date
Marc 'risson' Schmitt
7ff008d6d6 wip
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2026-04-24 16:47:38 +02:00
Marc 'risson' Schmitt
5ad0150fe4 fix page size
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2026-04-24 15:19:32 +02:00
Marc 'risson' Schmitt
4f52a79c6a application refresh
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2026-04-24 15:18:50 +02:00
Marc 'risson' Schmitt
a8b8a81375 Merge branch 'main' into rust-proxy 2026-04-24 13:54:38 +02:00
dependabot[bot]
0459568a96 core: bump github.com/Azure/go-ntlmssp from 0.1.0 to 0.1.1 in the go_modules group across 1 directory (#21807)
core: bump github.com/Azure/go-ntlmssp

Bumps the go_modules group with 1 update in the / directory: [github.com/Azure/go-ntlmssp](https://github.com/Azure/go-ntlmssp).


Updates `github.com/Azure/go-ntlmssp` from 0.1.0 to 0.1.1
- [Release notes](https://github.com/Azure/go-ntlmssp/releases)
- [Commits](https://github.com/Azure/go-ntlmssp/compare/v0.1.0...v0.1.1)

---
updated-dependencies:
- dependency-name: github.com/Azure/go-ntlmssp
  dependency-version: 0.1.1
  dependency-type: indirect
  dependency-group: go_modules
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-24 11:39:57 +01:00
dependabot[bot]
aa746e7585 lifecycle/aws: bump aws-cdk from 2.1118.3 to 2.1118.4 in /lifecycle/aws (#21808)
Bumps [aws-cdk](https://github.com/aws/aws-cdk-cli/tree/HEAD/packages/aws-cdk) from 2.1118.3 to 2.1118.4.
- [Release notes](https://github.com/aws/aws-cdk-cli/releases)
- [Commits](https://github.com/aws/aws-cdk-cli/commits/aws-cdk@v2.1118.4/packages/aws-cdk)

---
updated-dependencies:
- dependency-name: aws-cdk
  dependency-version: 2.1118.4
  dependency-type: direct:development
  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>
2026-04-24 11:39:53 +01:00
dependabot[bot]
a4dcf097b3 core: bump pydantic from 2.13.2 to 2.13.3 (#21809)
Bumps [pydantic](https://github.com/pydantic/pydantic) from 2.13.2 to 2.13.3.
- [Release notes](https://github.com/pydantic/pydantic/releases)
- [Changelog](https://github.com/pydantic/pydantic/blob/main/HISTORY.md)
- [Commits](https://github.com/pydantic/pydantic/compare/v2.13.2...v2.13.3)

---
updated-dependencies:
- dependency-name: pydantic
  dependency-version: 2.13.3
  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>
2026-04-24 11:39:48 +01:00
dependabot[bot]
c2ecff559c web: bump @sentry/browser from 10.48.0 to 10.49.0 in /web in the sentry group across 1 directory (#21810)
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.48.0 to 10.49.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.48.0...10.49.0)

---
updated-dependencies:
- dependency-name: "@sentry/browser"
  dependency-version: 10.49.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>
2026-04-24 11:39:43 +01:00
dependabot[bot]
c20ecb48f8 core: bump cachetools from 7.0.5 to 7.0.6 (#21811)
Bumps [cachetools](https://github.com/tkem/cachetools) from 7.0.5 to 7.0.6.
- [Changelog](https://github.com/tkem/cachetools/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/tkem/cachetools/compare/v7.0.5...v7.0.6)

---
updated-dependencies:
- dependency-name: cachetools
  dependency-version: 7.0.6
  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>
2026-04-24 11:39:39 +01:00
dependabot[bot]
34a50ad46e ci: bump calibreapp/image-actions from 4f7260f5dbd809ec86d03721c1ad71b8a841d3e0 to e2cc8db5d49c849e00844dfebf01438318e96fa2 (#21812)
ci: bump calibreapp/image-actions

Bumps [calibreapp/image-actions](https://github.com/calibreapp/image-actions) from 4f7260f5dbd809ec86d03721c1ad71b8a841d3e0 to e2cc8db5d49c849e00844dfebf01438318e96fa2.
- [Release notes](https://github.com/calibreapp/image-actions/releases)
- [Commits](4f7260f5db...e2cc8db5d4)

---
updated-dependencies:
- dependency-name: calibreapp/image-actions
  dependency-version: e2cc8db5d49c849e00844dfebf01438318e96fa2
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-24 11:39:34 +01:00
dependabot[bot]
99410f3775 web: bump @patternfly/elements from 4.3.1 to 4.4.0 in /web (#21813)
Bumps [@patternfly/elements](https://github.com/patternfly/patternfly-elements/tree/HEAD/elements) from 4.3.1 to 4.4.0.
- [Release notes](https://github.com/patternfly/patternfly-elements/releases)
- [Changelog](https://github.com/patternfly/patternfly-elements/blob/main/elements/CHANGELOG.md)
- [Commits](https://github.com/patternfly/patternfly-elements/commits/@patternfly/elements@4.4.0/elements)

---
updated-dependencies:
- dependency-name: "@patternfly/elements"
  dependency-version: 4.4.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>
2026-04-24 11:39:30 +01:00
dependabot[bot]
86de4955aa ci: bump taiki-e/install-action from 2.75.18 to 2.75.19 in /.github/actions/setup (#21814)
ci: bump taiki-e/install-action in /.github/actions/setup

Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.75.18 to 2.75.19.
- [Release notes](https://github.com/taiki-e/install-action/releases)
- [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md)
- [Commits](055f5df8c3...5f57d6cb7c)

---
updated-dependencies:
- dependency-name: taiki-e/install-action
  dependency-version: 2.75.19
  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>
2026-04-24 11:39:26 +01:00
dependabot[bot]
bea9b23555 lifecycle/aws: bump aws-cdk from 2.1118.2 to 2.1118.3 in /lifecycle/aws (#21801)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-23 18:09:55 +02:00
dependabot[bot]
9820ee1d67 core: bump rustls from 0.23.38 to 0.23.39 (#21802)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-23 14:18:04 +00:00
Marc 'risson' Schmitt
31e7b1dc4b Merge branch 'rust-worker-2' into rust-proxy
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2026-04-23 15:46:53 +02:00
Marc 'risson' Schmitt
1379637389 ci: add rustls and aws-lc ecosystem crates to delay ignore list (#21800) 2026-04-23 13:42:25 +00:00
Marc 'risson' Schmitt
8bf7efecfd fix lint
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2026-04-23 15:33:30 +02:00
Marc 'risson' Schmitt
b1ceb28f71 Merge branch 'main' into rust-worker-2
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2026-04-23 15:26:14 +02:00
Dominic R
39e6c41566 admin/files: sign custom-domain S3 URLs for the final host (#21704) 2026-04-23 15:23:05 +02:00
Sai Asish Y
92a2d26c86 core: survive the empty-queryset race in chunked_queryset (#21666) 2026-04-23 15:21:57 +02:00
Simonyi Gergő
0f8d8c81d7 core: simplify boolean (#21790) 2026-04-23 14:47:23 +02:00
Sai Asish Y
cce646b132 providers/oauth2: clip device authorization scope against the provider's ScopeMapping set (#21701)
* providers/oauth2: clip device authorization scope against the provider's ScopeMapping set

DeviceView.parse_request stored the raw request scope straight onto the
DeviceToken:

	self.scopes = self.request.POST.get("scope", "").split(" ")
	...
	token = DeviceToken.objects.create(..., _scope=" ".join(self.scopes))

The token-exchange side then reads those scopes back directly:

	if SCOPE_OFFLINE_ACCESS in self.params.device_code.scope:
		refresh_token = RefreshToken(...)
		...

so a caller that adds offline_access to the device authorization
request body gets a refresh_token at the exchange, even when the
provider has no offline_access ScopeMapping configured. Every other
grant type clips scope against ScopeMapping for the provider inside
TokenParams.__check_scopes, but the device authorization endpoint
runs before TokenParams is ever constructed, so the clip never
happens for the device flow.

Combined with #20828 (missing client_secret verification on device
code exchange for confidential clients, now being fixed separately)
and the lack of per-app opt-out for the device flow, this gives any
caller that knows the client_id a path to an offline refresh token
against any OIDC application the deployment exposes.

Intersect the requested scope set with the provider's ScopeMapping
names before we ever persist the DeviceToken. offline_access that is
not configured is silently dropped, matching __check_scopes on the
other grant types. Configured offline_access still flows through
unchanged.

Fixes #20825

Signed-off-by: SAY-5 <SAY-5@users.noreply.github.com>

* rework and add tests

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

---------

Signed-off-by: SAY-5 <SAY-5@users.noreply.github.com>
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: SAY-5 <SAY-5@users.noreply.github.com>
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
2026-04-23 13:44:44 +02:00
dependabot[bot]
6d274d1e3d core: bump library/nginx from 3acc8b9 to 6e23479 in /website (#21794)
Bumps library/nginx from `3acc8b9` to `6e23479`.

---
updated-dependencies:
- dependency-name: library/nginx
  dependency-version: 1.29-trixie
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-23 11:20:09 +02:00
dependabot[bot]
8d5489e441 core: bump library/node from b272ff1 to 74ff139 in /website (#21795)
Bumps library/node from `b272ff1` to `74ff139`.

---
updated-dependencies:
- dependency-name: library/node
  dependency-version: 25.9.0-trixie
  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>
2026-04-23 11:19:56 +02:00
dependabot[bot]
8ea9a48017 core: bump library/golang from cd8540d to 982ae92 in /lifecycle/container (#21793)
core: bump library/golang in /lifecycle/container

Bumps library/golang from `cd8540d` to `982ae92`.

---
updated-dependencies:
- dependency-name: library/golang
  dependency-version: 1.26.2-trixie
  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>
2026-04-23 10:19:37 +01:00
Sai Asish Y
c6b5869b48 stages/user_write: refuse to write id/pk claims onto the user model (#21667)
* stages/user_write: refuse to write id/pk claims onto the user model

When an enrollment or source flow maps IdP-supplied attributes onto the
User model, update_user walks each key and, if the user already has an
attribute by that name, calls setattr(user, key, value) unconditionally.
"id" is always present on the User model (it is the Django PK), so a
SAML assertion that ships an "id" claim, e.g. a hex string from
mocksaml, was written straight into the PK field. Django then rejected
the save:

  ValueError: Field 'id' expected a number but got '<hex>'.

The log surfaced as "Failed to save user" and the enrollment flow
silently failed for every incoming user.

Treat "id" and "pk" the same way the existing "groups" entry is
treated: add them to disallowed_user_attributes so the walker logs and
skips them. IdP attributes can still be stored on user.attributes via
the dotted/underscored forms (e.g. attributes.id), which go through
write_attribute and land in the JSONField safely.

Added a regression test covering both id and pk in the prompt context.

Fixes #21580

Signed-off-by: SAY-5 <SAY-5@users.noreply.github.com>

* fix lint

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

---------

Signed-off-by: SAY-5 <SAY-5@users.noreply.github.com>
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: SAY-5 <SAY-5@users.noreply.github.com>
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
2026-04-23 11:03:12 +02:00
authentik-automation[bot]
e4971f9aa5 core, web: update translations (#21785)
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
2026-04-23 10:39:13 +02:00
Dominic R
028ec05a8b website: Merge branch (#21684)
Co-authored-by: Codex <codex@openai.com>
Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
2026-04-23 01:46:10 +00:00
Ryan Pesek
b4c9ac57e0 core/applications: Optimize list applications when only_with_launch_url=true (#20428)
* Performance optimizations for the application list API endpoint when only_with_launch_url=true

* lint

---------

Signed-off-by: Simonyi Gergő <28359278+gergosimonyi@users.noreply.github.com>
Co-authored-by: Simonyi Gergő <28359278+gergosimonyi@users.noreply.github.com>
2026-04-23 03:15:16 +02:00
Dewi Roberts
80b93e1fbc website/docs: add authorization header info to all proxy configs (#21664)
Add authorization header info to all proxy configs
2026-04-23 02:35:02 +02:00
dependabot[bot]
dff6b48f53 web: bump @xmldom/xmldom from 0.8.12 to 0.8.13 in /web (#21784)
Bumps [@xmldom/xmldom](https://github.com/xmldom/xmldom) from 0.8.12 to 0.8.13.
- [Release notes](https://github.com/xmldom/xmldom/releases)
- [Changelog](https://github.com/xmldom/xmldom/blob/master/CHANGELOG.md)
- [Commits](https://github.com/xmldom/xmldom/compare/0.8.12...0.8.13)

---
updated-dependencies:
- dependency-name: "@xmldom/xmldom"
  dependency-version: 0.8.13
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-23 02:33:20 +02:00
gp-somni-labs
79473341d6 internal/outpost: serialize websocket writes to prevent panic (#21728)
The outpost API controller shares a single *websocket.Conn across
multiple goroutines: the event-handler loop, the 10s health ticker
(SendEventHello), the shutdown path (WriteMessage close), initEvent
writing the hello frame on (re)connect, and RAC session handlers that
also invoke SendEventHello. gorilla/websocket explicitly documents that
concurrent WriteMessage/WriteJSON calls are unsafe and will panic with
"concurrent write to websocket connection", which takes the outpost
(and embedded-outpost authentik-server) pod down.

Fix by adding a sync.Mutex on APIController guarding every write path
on eventConn (initEvent hello, Shutdown close message, SendEventHello).
Reads (ReadJSON in startEventHandler) are left unsynchronized as
gorilla permits a single concurrent reader alongside a writer.

Minimal, localized change: no API changes, no behavior changes, writes
are already infrequent so lock contention is negligible.

Refs #11090

Co-authored-by: curiosity <curiosity@somni.dev>
2026-04-23 02:33:10 +02:00
dependabot[bot]
99f9682d61 core: bump rand from 0.8.5 to 0.8.6 in the cargo group across 1 directory (#21783)
core: bump rand in the cargo group across 1 directory

Bumps the cargo group with 1 update in the / directory: [rand](https://github.com/rust-random/rand).


Updates `rand` from 0.8.5 to 0.8.6
- [Release notes](https://github.com/rust-random/rand/releases)
- [Changelog](https://github.com/rust-random/rand/blob/0.8.6/CHANGELOG.md)
- [Commits](https://github.com/rust-random/rand/compare/0.8.5...0.8.6)

---
updated-dependencies:
- dependency-name: rand
  dependency-version: 0.8.6
  dependency-type: indirect
  dependency-group: cargo
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-23 02:02:24 +02:00
Bapuji Koraganti
987f367d7b web: merge MFA devices and tokens into unified Credentials tab (#21705)
* web: merge MFA devices and tokens into unified Credentials tab

Combines the separate "MFA Devices" and "Tokens and App passwords"
tabs into a single "Credentials" tab on the user settings page,
so users can manage all credentials from one place.

Fixes #21637

Signed-off-by: Bapuji Koraganti <bapuk.2008@gmail.com>

* add card title

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

---------

Signed-off-by: Bapuji Koraganti <bapuk.2008@gmail.com>
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
2026-04-23 02:02:00 +02:00
Jens L.
805ff9f1ab web/admin: fix policy/stage wizard label, fix connector create wizard, cleanup (#21781)
* update labels

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

* remove unused app wizard hint

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

* connector wizard should use grid

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

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2026-04-22 19:32:23 +02:00
dependabot[bot]
42fc9d537e website: bump the build group in /website with 6 updates (#21777)
* website: bump the build group in /website with 6 updates

Bumps the build group in /website with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [@swc/core-darwin-arm64](https://github.com/swc-project/swc) | `1.15.26` | `1.15.30` |
| [@swc/core-linux-arm64-gnu](https://github.com/swc-project/swc) | `1.15.26` | `1.15.30` |
| [@swc/core-linux-x64-gnu](https://github.com/swc-project/swc) | `1.15.26` | `1.15.30` |
| [@swc/html-darwin-arm64](https://github.com/swc-project/swc) | `1.15.26` | `1.15.30` |
| [@swc/html-linux-arm64-gnu](https://github.com/swc-project/swc) | `1.15.26` | `1.15.30` |
| [@swc/html-linux-x64-gnu](https://github.com/swc-project/swc) | `1.15.26` | `1.15.30` |


Updates `@swc/core-darwin-arm64` from 1.15.26 to 1.15.30
- [Release notes](https://github.com/swc-project/swc/releases)
- [Changelog](https://github.com/swc-project/swc/blob/main/CHANGELOG.md)
- [Commits](https://github.com/swc-project/swc/compare/v1.15.26...v1.15.30)

Updates `@swc/core-linux-arm64-gnu` from 1.15.26 to 1.15.30
- [Release notes](https://github.com/swc-project/swc/releases)
- [Changelog](https://github.com/swc-project/swc/blob/main/CHANGELOG.md)
- [Commits](https://github.com/swc-project/swc/compare/v1.15.26...v1.15.30)

Updates `@swc/core-linux-x64-gnu` from 1.15.26 to 1.15.30
- [Release notes](https://github.com/swc-project/swc/releases)
- [Changelog](https://github.com/swc-project/swc/blob/main/CHANGELOG.md)
- [Commits](https://github.com/swc-project/swc/compare/v1.15.26...v1.15.30)

Updates `@swc/html-darwin-arm64` from 1.15.26 to 1.15.30
- [Release notes](https://github.com/swc-project/swc/releases)
- [Changelog](https://github.com/swc-project/swc/blob/main/CHANGELOG.md)
- [Commits](https://github.com/swc-project/swc/compare/v1.15.26...v1.15.30)

Updates `@swc/html-linux-arm64-gnu` from 1.15.26 to 1.15.30
- [Release notes](https://github.com/swc-project/swc/releases)
- [Changelog](https://github.com/swc-project/swc/blob/main/CHANGELOG.md)
- [Commits](https://github.com/swc-project/swc/compare/v1.15.26...v1.15.30)

Updates `@swc/html-linux-x64-gnu` from 1.15.26 to 1.15.30
- [Release notes](https://github.com/swc-project/swc/releases)
- [Changelog](https://github.com/swc-project/swc/blob/main/CHANGELOG.md)
- [Commits](https://github.com/swc-project/swc/compare/v1.15.26...v1.15.30)

---
updated-dependencies:
- dependency-name: "@swc/core-darwin-arm64"
  dependency-version: 1.15.30
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: build
- dependency-name: "@swc/core-linux-arm64-gnu"
  dependency-version: 1.15.30
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: build
- dependency-name: "@swc/core-linux-x64-gnu"
  dependency-version: 1.15.30
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: build
- dependency-name: "@swc/html-darwin-arm64"
  dependency-version: 1.15.30
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: build
- dependency-name: "@swc/html-linux-arm64-gnu"
  dependency-version: 1.15.30
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: build
- dependency-name: "@swc/html-linux-x64-gnu"
  dependency-version: 1.15.30
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: build
...

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

* sigh

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

---------

Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
2026-04-22 17:38:32 +02:00
dependabot[bot]
3f4c0fb35d core: bump library/nginx from 7f0adca to 3acc8b9 in /website (#21775)
Bumps library/nginx from `7f0adca` to `3acc8b9`.

---
updated-dependencies:
- dependency-name: library/nginx
  dependency-version: 1.29-trixie
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-22 17:32:46 +02:00
dependabot[bot]
42d87072cf core: bump library/node from f57f0c7 to b272ff1 in /website (#21776)
core: bump library/node from `f57f0c7` to `7e77811` in /website

Bumps library/node from `f57f0c7` to `7e77811`.

---
updated-dependencies:
- dependency-name: library/node
  dependency-version: 25.9.0-trixie
  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>
2026-04-22 17:32:36 +02:00
Jens L.
075a1f5875 web/admin: Allow binding users/groups in policy binding wizard and existing stage in stage binding wizard (#21697)
* web/admin: allow creating only binding for policies

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

* dont show type selector if only one is allowed

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

* do the same for stage wizard

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

* minor unrelated fix: alignment in table desc

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

* add option to bind existing policy

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

* adjust labels?

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

* Clean up post-type select state. Types.

* Clean up brand form.

* Flesh out parse.

* Tidy textarea.

* Fix table alignment when images are present.

* Simplify radio.

* Fix form group layout, styles.

* Flesh out plural helper.

* Flesh out formatted user display name.

* Allow slotted HTML in page description.

* Clean up transclusion types.

* Allow null.

* Flesh out user activation toggle.

* Clean up activation labeling.

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com>
2026-04-22 16:08:31 +02:00
Bapuji Koraganti
24edee3e78 flows: add warning message for expired password reset links (#21395)
* flows: add warning message for expired password reset links

Fixes #21306

* Replace token expiry check with REQUIRE_TOKEN authentication requirement

Incorporate review comments to move expired/invalid token handling from executor-level check to flow planner authentication requirement. This avoids disclosing whether a token ever existed and handles already-cleaned-up tokens.

* The fix was changing gettext_lazy to gettext

* remove unneeded migration

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

* update form

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

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
2026-04-22 15:09:05 +02:00
Marc 'risson' Schmitt
2cb3df2a60 wip
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2026-04-16 19:00:42 +02:00
Marc 'risson' Schmitt
5426881797 wip
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2026-04-16 19:00:26 +02:00
Marc 'risson' Schmitt
3f703bb21b wip
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2026-04-16 18:23:54 +02:00
Marc 'risson' Schmitt
b3c0a50f91 metrics and logging
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2026-04-16 16:46:54 +02:00
Marc 'risson' Schmitt
1fec16b8e0 run -> start
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2026-04-16 14:04:40 +02:00
Marc 'risson' Schmitt
8657d74dc9 root: init rust worker
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2026-04-16 14:00:52 +02:00
Marc 'risson' Schmitt
347df15f50 wip
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2026-04-16 14:00:28 +02:00
Marc 'risson' Schmitt
cf2ed15ced Merge branch 'rust-worker-2' into rust-proxy
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2026-04-16 13:42:43 +02:00
Marc 'risson' Schmitt
b220e80a0d run -> start
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2026-04-16 13:40:30 +02:00
Marc 'risson' Schmitt
54f6b5c73c root: init rust worker
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2026-04-16 13:40:30 +02:00
Marc 'risson' Schmitt
9fad68bdad packages/ak-common/tracing: get sentry config from API for outposts
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2026-04-16 13:40:29 +02:00
Marc 'risson' Schmitt
dc1d99288f wip
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2026-04-15 17:51:28 +02:00
Marc 'risson' Schmitt
8fb795ec89 wip
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2026-04-15 17:41:40 +02:00
Marc 'risson' Schmitt
f8f84f5f0b fixup
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2026-04-15 17:41:33 +02:00
Marc 'risson' Schmitt
5812558463 wip
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2026-04-15 17:38:06 +02:00
Marc 'risson' Schmitt
513462f78d fixup
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2026-04-15 17:38:02 +02:00
Marc 'risson' Schmitt
833912b712 Merge branch 'rust-worker-2' into rust-proxy
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2026-04-15 17:32:31 +02:00
Marc 'risson' Schmitt
78a4b06ab3 root: init rust worker
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2026-04-15 17:21:35 +02:00
Marc 'risson' Schmitt
c38e3cbbcf packages/ak-common/tracing: get sentry config from API for outposts
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2026-04-15 17:21:35 +02:00
Marc 'risson' Schmitt
9fba928666 Merge branch 'main' into rust-proxy
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2026-04-15 17:16:50 +02:00
Marc 'risson' Schmitt
ce8f33416e ws
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2026-04-15 16:41:26 +02:00
Marc 'risson' Schmitt
6308ec3360 wip
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2026-04-14 15:04:03 +02:00
Marc 'risson' Schmitt
915bf6942e wip
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2026-04-10 17:16:32 +02:00
Marc 'risson' Schmitt
e63d2afb29 wip
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2026-04-10 14:10:05 +02:00
Marc 'risson' Schmitt
d103cea26a root: init rust worker
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2026-04-10 12:56:24 +02:00
151 changed files with 6889 additions and 3136 deletions

View File

@@ -1,5 +1,5 @@
[alias]
t = ["nextest", "run"]
t = ["nextest", "run", "--workspace"]
[build]
rustflags = ["--cfg", "tokio_unstable"]

View File

@@ -1,5 +1,6 @@
[licenses]
allow = [
"Apache-2.0 WITH LLVM-exception",
"Apache-2.0",
"BSD-3-Clause",
"CC0-1.0",

View File

@@ -64,7 +64,7 @@ runs:
rustflags: ""
- name: Setup rust dependencies
if: ${{ contains(inputs.dependencies, 'rust') }}
uses: taiki-e/install-action@055f5df8c3f65ea01cd41e9dc855becd88953486 # v2
uses: taiki-e/install-action@5f57d6cb7cd20b14a8a27f522884c4bc8a187458 # v2
with:
tool: cargo-deny cargo-machete cargo-llvm-cov nextest
- name: Setup node (web)

View File

@@ -67,6 +67,12 @@ updates:
semver-major-days: 14
semver-patch-days: 3
exclude:
- aws-lc-fips-sys
- aws-lc-rs
- aws-lc-sys
- rustls
- rustls-pki-types
- rustls-platform-verifier
- rustls-webpki
- package-ecosystem: rust-toolchain

View File

@@ -38,7 +38,7 @@ jobs:
token: ${{ steps.generate_token.outputs.token }}
- name: Compress images
id: compress
uses: calibreapp/image-actions@4f7260f5dbd809ec86d03721c1ad71b8a841d3e0 # main
uses: calibreapp/image-actions@e2cc8db5d49c849e00844dfebf01438318e96fa2 # main
with:
GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }}
compressOnly: ${{ github.event_name != 'pull_request' }}

366
Cargo.lock generated
View File

@@ -17,6 +17,18 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "ahash"
version = "0.8.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [
"cfg-if",
"once_cell",
"version_check",
"zerocopy",
]
[[package]]
name = "aho-corasick"
version = "1.1.4"
@@ -106,6 +118,37 @@ dependencies = [
"rustversion",
]
[[package]]
name = "argh"
version = "0.1.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "211818e820cda9ca6f167a64a5c808837366a6dfd807157c64c1304c486cd033"
dependencies = [
"argh_derive",
"argh_shared",
]
[[package]]
name = "argh_derive"
version = "0.1.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c442a9d18cef5dde467405d27d461d080d68972d6d0dfd0408265b6749ec427d"
dependencies = [
"argh_shared",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "argh_shared"
version = "0.1.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5ade012bac4db278517a0132c8c10c6427025868dca16c801087c28d5a411f1"
dependencies = [
"serde",
]
[[package]]
name = "arraydeque"
version = "0.5.1"
@@ -138,6 +181,39 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "authentik"
version = "2026.5.0-rc1"
dependencies = [
"arc-swap",
"argh",
"authentik-axum",
"authentik-client",
"authentik-common",
"axum",
"color-eyre",
"eyre",
"futures",
"hyper-unix-socket",
"hyper-util",
"metrics",
"metrics-exporter-prometheus",
"nix 0.31.2",
"pyo3",
"rand 0.10.1",
"serde",
"serde_json",
"serde_repr",
"sqlx",
"time",
"tokio",
"tokio-retry2",
"tokio-tungstenite",
"tracing",
"url",
"uuid",
]
[[package]]
name = "authentik-axum"
version = "2026.5.0-rc1"
@@ -485,6 +561,17 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chacha20"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"rand_core 0.10.1",
]
[[package]]
name = "chrono"
version = "0.4.44"
@@ -567,6 +654,33 @@ dependencies = [
"cc",
]
[[package]]
name = "color-eyre"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5920befb47832a6d61ee3a3a846565cfa39b331331e68a3b1d1116630f2f26d"
dependencies = [
"backtrace",
"color-spantrace",
"eyre",
"indenter",
"once_cell",
"owo-colors",
"tracing-error",
]
[[package]]
name = "color-spantrace"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8b88ea9df13354b55bc7234ebcce36e6ef896aca2e42a15de9e10edce01b427"
dependencies = [
"once_cell",
"owo-colors",
"tracing-core",
"tracing-error",
]
[[package]]
name = "colorchoice"
version = "1.0.5"
@@ -695,6 +809,15 @@ dependencies = [
"libc",
]
[[package]]
name = "cpufeatures"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
dependencies = [
"libc",
]
[[package]]
name = "crc"
version = "3.4.0"
@@ -728,6 +851,15 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-queue"
version = "0.3.12"
@@ -977,6 +1109,12 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "form_urlencoded"
version = "1.2.2"
@@ -1166,6 +1304,7 @@ dependencies = [
"cfg-if",
"libc",
"r-efi 6.0.0",
"rand_core 0.10.1",
"wasip2",
"wasip3",
]
@@ -1209,7 +1348,7 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
"foldhash 0.1.5",
]
[[package]]
@@ -1217,6 +1356,9 @@ name = "hashbrown"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"foldhash 0.2.0",
]
[[package]]
name = "hashlink"
@@ -1343,9 +1485,9 @@ checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424"
[[package]]
name = "hyper"
version = "1.8.1"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11"
checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"
dependencies = [
"atomic-waker",
"bytes",
@@ -1358,7 +1500,6 @@ dependencies = [
"httpdate",
"itoa",
"pin-project-lite",
"pin-utils",
"smallvec",
"tokio",
"want",
@@ -1393,6 +1534,20 @@ dependencies = [
"tower-service",
]
[[package]]
name = "hyper-unix-socket"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c255628da188a9d9ee218bae99da33a4b684ed63abe140a94d0f6e4b5af9a090"
dependencies = [
"bytes",
"hyper",
"hyper-util",
"pin-project-lite",
"tokio",
"tower-service",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
@@ -1850,6 +2005,46 @@ version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "metrics"
version = "0.24.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d5312e9ba3771cfa961b585728215e3d972c950a3eed9252aa093d6301277e8"
dependencies = [
"ahash",
"portable-atomic",
]
[[package]]
name = "metrics-exporter-prometheus"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3589659543c04c7dc5526ec858591015b87cd8746583b51b48ef4353f99dbcda"
dependencies = [
"base64 0.22.1",
"indexmap",
"metrics",
"metrics-util",
"quanta",
"thiserror 2.0.18",
]
[[package]]
name = "metrics-util"
version = "0.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdfb1365fea27e6dd9dc1dbc19f570198bc86914533ad639dae939635f096be4"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
"hashbrown 0.16.1",
"metrics",
"quanta",
"rand 0.9.2",
"rand_xoshiro",
"sketches-ddsketch",
]
[[package]]
name = "mime"
version = "0.3.17"
@@ -1981,7 +2176,7 @@ dependencies = [
"num-integer",
"num-iter",
"num-traits",
"rand 0.8.5",
"rand 0.8.6",
"smallvec",
"zeroize",
]
@@ -2233,6 +2428,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "owo-colors"
version = "4.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d"
[[package]]
name = "parking"
version = "2.2.1"
@@ -2309,12 +2510,6 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pin-utils"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "pkcs1"
version = "0.7.5"
@@ -2348,6 +2543,12 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "portable-atomic"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
[[package]]
name = "potential_utf"
version = "0.1.4"
@@ -2423,6 +2624,79 @@ dependencies = [
"prost",
]
[[package]]
name = "pyo3"
version = "0.28.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91fd8e38a3b50ed1167fb981cd6fd60147e091784c427b8f7183a7ee32c31c12"
dependencies = [
"libc",
"once_cell",
"portable-atomic",
"pyo3-build-config",
"pyo3-ffi",
"pyo3-macros",
]
[[package]]
name = "pyo3-build-config"
version = "0.28.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e368e7ddfdeb98c9bca7f8383be1648fd84ab466bf2bc015e94008db6d35611e"
dependencies = [
"target-lexicon",
]
[[package]]
name = "pyo3-ffi"
version = "0.28.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f29e10af80b1f7ccaf7f69eace800a03ecd13e883acfacc1e5d0988605f651e"
dependencies = [
"libc",
"pyo3-build-config",
]
[[package]]
name = "pyo3-macros"
version = "0.28.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df6e520eff47c45997d2fc7dd8214b25dd1310918bbb2642156ef66a67f29813"
dependencies = [
"proc-macro2",
"pyo3-macros-backend",
"quote",
"syn",
]
[[package]]
name = "pyo3-macros-backend"
version = "0.28.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4cdc218d835738f81c2338f822078af45b4afdf8b2e33cbb5916f108b813acb"
dependencies = [
"heck",
"proc-macro2",
"pyo3-build-config",
"quote",
"syn",
]
[[package]]
name = "quanta"
version = "0.12.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7"
dependencies = [
"crossbeam-utils",
"libc",
"once_cell",
"raw-cpuid",
"wasi",
"web-sys",
"winapi",
]
[[package]]
name = "quinn"
version = "0.11.9"
@@ -2502,9 +2776,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.8.5"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [
"libc",
"rand_chacha 0.3.1",
@@ -2521,6 +2795,17 @@ dependencies = [
"rand_core 0.9.5",
]
[[package]]
name = "rand"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207"
dependencies = [
"chacha20",
"getrandom 0.4.2",
"rand_core 0.10.1",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
@@ -2559,6 +2844,30 @@ dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "rand_core"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rand_xoshiro"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f703f4665700daf5512dcca5f43afa6af89f09db47fb56be587f80636bda2d41"
dependencies = [
"rand_core 0.9.5",
]
[[package]]
name = "raw-cpuid"
version = "11.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186"
dependencies = [
"bitflags 2.11.0",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
@@ -2737,9 +3046,9 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.38"
version = "0.23.39"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21"
checksum = "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e"
dependencies = [
"aws-lc-rs",
"log",
@@ -3095,7 +3404,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures",
"cpufeatures 0.2.17",
"digest",
]
@@ -3106,7 +3415,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"cpufeatures 0.2.17",
"digest",
]
@@ -3151,6 +3460,12 @@ version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]]
name = "sketches-ddsketch"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c6f73aeb92d671e0cc4dca167e59b2deb6387c375391bc99ee743f326994a2b"
[[package]]
name = "slab"
version = "0.4.12"
@@ -3315,7 +3630,7 @@ dependencies = [
"memchr",
"once_cell",
"percent-encoding",
"rand 0.8.5",
"rand 0.8.6",
"rsa",
"serde",
"sha1",
@@ -3356,7 +3671,7 @@ dependencies = [
"md-5",
"memchr",
"once_cell",
"rand 0.8.5",
"rand 0.8.6",
"serde",
"serde_json",
"sha2",
@@ -3476,6 +3791,12 @@ dependencies = [
"libc",
]
[[package]]
name = "target-lexicon"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
[[package]]
name = "tempfile"
version = "3.27.0"
@@ -3664,8 +3985,12 @@ checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
dependencies = [
"futures-util",
"log",
"rustls",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tungstenite",
"webpki-roots 0.26.11",
]
[[package]]
@@ -3879,8 +4204,11 @@ dependencies = [
"httparse",
"log",
"rand 0.9.2",
"rustls",
"rustls-pki-types",
"sha1",
"thiserror 2.0.18",
"url",
]
[[package]]

View File

@@ -20,11 +20,13 @@ publish = false
[workspace.dependencies]
arc-swap = "= 1.9.1"
argh = "= 0.1.19"
axum-server = { version = "= 0.8.0", features = ["tls-rustls-no-provider"] }
aws-lc-rs = { version = "= 1.16.3", features = ["fips"] }
axum = { version = "= 0.8.9", features = ["http2", "macros", "ws"] }
clap = { version = "= 4.6.1", features = ["derive", "env"] }
client-ip = { version = "0.2.1", features = ["forwarded-header"] }
color-eyre = "= 0.6.5"
colored = "= 3.1.1"
config-rs = { package = "config", version = "= 0.15.22", default-features = false, features = [
"json",
@@ -37,11 +39,17 @@ eyre = "= 0.6.12"
forwarded-header-value = "= 0.1.1"
futures = "= 0.3.32"
glob = "= 0.3.3"
hyper-unix-socket = "= 0.3.0"
hyper-util = "= 0.1.20"
ipnet = { version = "= 2.12.0", features = ["serde"] }
json-subscriber = "= 0.2.8"
nix = { version = "= 0.31.2", features = ["signal"] }
metrics = "= 0.24.3"
metrics-exporter-prometheus = { version = "= 0.18.1", default-features = false }
nix = { version = "= 0.31.2", features = ["hostname", "signal"] }
notify = "= 8.2.0"
pin-project-lite = "= 0.2.17"
pyo3 = "= 0.28.3"
rand = "= 0.10.1"
regex = "= 1.12.3"
reqwest = { version = "= 0.13.2", features = [
"form",
@@ -58,7 +66,7 @@ reqwest-middleware = { version = "= 0.5.1", features = [
"query",
"rustls",
] }
rustls = { version = "= 0.23.38", features = ["fips"] }
rustls = { version = "= 0.23.39", features = ["fips"] }
sentry = { version = "= 0.47.0", default-features = false, features = [
"backtrace",
"contexts",
@@ -92,6 +100,10 @@ time = { version = "= 0.3.47", features = ["macros"] }
tokio = { version = "= 1.52.1", features = ["full", "tracing"] }
tokio-retry2 = "= 0.9.1"
tokio-rustls = "= 0.26.4"
tokio-tungstenite = { version = "= 0.29.0", features = [
"rustls-tls-webpki-roots",
"url",
] }
tokio-util = { version = "= 0.7.18", features = ["full"] }
tower = "= 0.5.3"
tower-http = { version = "= 0.6.8", features = ["timeout"] }
@@ -106,16 +118,10 @@ tracing-subscriber = { version = "= 0.3.23", features = [
url = "= 2.5.8"
uuid = { version = "= 1.23.1", features = ["serde", "v4"] }
ak-axum = { package = "authentik-axum", version = "2026.5.0-rc1", path = "./packages/ak-axum" }
ak-client = { package = "authentik-client", version = "2026.5.0-rc1", path = "./packages/client-rust" }
ak-common = { package = "authentik-common", version = "2026.5.0-rc1", path = "./packages/ak-common", default-features = false }
[profile.dev.package.backtrace]
opt-level = 3
[profile.release]
lto = true
debug = 2
[workspace.lints.rust]
ambiguous_negative_literals = "warn"
closure_returning_async_block = "warn"
@@ -229,3 +235,64 @@ unused_trait_names = "warn"
unwrap_in_result = "warn"
unwrap_used = "warn"
verbose_file_reads = "warn"
[profile.dev.package.backtrace]
opt-level = 3
[profile.dev]
panic = "abort"
[profile.release]
debug = 2
lto = "fat"
# Because of the async runtime, we want to die straightaway if we panic.
panic = "abort"
strip = true
[package]
name = "authentik"
version.workspace = true
authors.workspace = true
edition.workspace = true
readme.workspace = true
homepage.workspace = true
repository.workspace = true
license-file.workspace = true
publish.workspace = true
[features]
default = ["core", "proxy"]
core = ["ak-common/core", "dep:pyo3", "dep:sqlx"]
proxy = ["ak-common/proxy", "dep:ak-client"]
[dependencies]
ak-axum.workspace = true
ak-client = { workspace = true, optional = true }
ak-common.workspace = true
arc-swap.workspace = true
argh.workspace = true
axum.workspace = true
color-eyre.workspace = true
eyre.workspace = true
futures.workspace = true
hyper-unix-socket.workspace = true
hyper-util.workspace = true
metrics-exporter-prometheus.workspace = true
metrics.workspace = true
nix.workspace = true
pyo3 = { workspace = true, optional = true }
rand.workspace = true
serde.workspace = true
serde_json.workspace = true
serde_repr.workspace = true
sqlx = { workspace = true, optional = true }
time.workspace = true
tokio-retry2.workspace = true
tokio-tungstenite.workspace = true
tokio.workspace = true
tracing.workspace = true
url.workspace = true
uuid.workspace = true
[lints]
workspace = true

View File

@@ -115,6 +115,9 @@ run-server: ## Run the main authentik server process
run-worker: ## Run the main authentik worker process
$(UV) run ak worker
run-worker-watch: ## Run the authentik worker, with auto reloading
watchexec --on-busy-update=restart --stop-signal=SIGINT --exts py,rs --no-meta --notify -- $(UV) run ak worker
core-i18n-extract:
$(UV) run ak makemessages \
--add-location file \

View File

@@ -1,7 +1,7 @@
from collections.abc import Generator, Iterator
from contextlib import contextmanager
from tempfile import SpooledTemporaryFile
from urllib.parse import urlsplit
from urllib.parse import urlsplit, urlunsplit
import boto3
from botocore.config import Config
@@ -164,16 +164,19 @@ class S3Backend(ManageableBackend):
)
def _file_url(name: str, request: HttpRequest | None) -> str:
client = self.client
params = {
"Bucket": self.bucket_name,
"Key": f"{self.base_path}/{name}",
}
url = self.client.generate_presigned_url(
"get_object",
Params=params,
ExpiresIn=expires_in,
HttpMethod="GET",
operation_name = "GetObject"
operation_model = client.meta.service_model.operation_model(operation_name)
request_dict = client._convert_to_request_dict(
params,
operation_model,
endpoint_url=client.meta.endpoint_url,
context={"is_presign_request": True},
)
# Support custom domain for S3-compatible storage (so not AWS)
@@ -183,9 +186,8 @@ class S3Backend(ManageableBackend):
CONFIG.get(f"storage.{self.name}.custom_domain", None),
)
if custom_domain:
parsed = urlsplit(url)
scheme = "https" if use_https else "http"
path = parsed.path
path = request_dict["url_path"]
# When using path-style addressing, the presigned URL contains the bucket
# name in the path (e.g., /bucket-name/key). Since custom_domain must
@@ -200,9 +202,22 @@ class S3Backend(ManageableBackend):
if not path.startswith("/"):
path = f"/{path}"
url = f"{scheme}://{custom_domain}{path}?{parsed.query}"
custom_base = urlsplit(f"{scheme}://{custom_domain}")
return url
# Sign the final public URL instead of signing the internal S3 endpoint and
# rewriting it afterwards. Presigned SigV4 URLs include the host header in the
# canonical request, so post-sign host changes break strict backends like RustFS.
public_path = f"{custom_base.path.rstrip('/')}{path}" if custom_base.path else path
request_dict["url_path"] = public_path
request_dict["url"] = urlunsplit(
(custom_base.scheme, custom_base.netloc, public_path, "", "")
)
return client._request_signer.generate_presigned_url(
request_dict,
operation_name,
expires_in=expires_in,
)
if use_cache:
return self._cache_get_or_set(name, request, _file_url, expires_in)

View File

@@ -1,4 +1,5 @@
from unittest import skipUnless
from urllib.parse import parse_qs, urlsplit
from botocore.exceptions import UnsupportedSignatureVersionError
from django.test import TestCase
@@ -168,6 +169,44 @@ class TestS3Backend(FileTestS3BackendMixin, TestCase):
f"URL: {url}",
)
@CONFIG.patch("storage.s3.secure_urls", False)
@CONFIG.patch("storage.s3.addressing_style", "path")
def test_file_url_custom_domain_resigns_for_custom_host(self):
"""Test presigned URLs are signed for the custom domain host.
Host-changing custom domains must produce a signature query string for
the public host, not reuse the internal endpoint signature.
"""
bucket_name = self.media_s3_bucket_name
key_name = "application-icons/test.svg"
custom_domain = f"files.example.test:8020/{bucket_name}"
endpoint_signed_url = self.media_s3_backend.client.generate_presigned_url(
"get_object",
Params={
"Bucket": bucket_name,
"Key": f"{self.media_s3_backend.base_path}/{key_name}",
},
ExpiresIn=900,
HttpMethod="GET",
)
with CONFIG.patch("storage.media.s3.custom_domain", custom_domain):
custom_url = self.media_s3_backend.file_url(key_name, use_cache=False)
endpoint_parts = urlsplit(endpoint_signed_url)
custom_parts = urlsplit(custom_url)
self.assertEqual(custom_parts.scheme, "http")
self.assertEqual(custom_parts.netloc, "files.example.test:8020")
self.assertEqual(parse_qs(custom_parts.query)["X-Amz-SignedHeaders"], ["host"])
self.assertNotEqual(
custom_parts.query,
endpoint_parts.query,
"Custom-domain URLs must be signed for the public host, not reuse the endpoint "
"signature query string.",
)
def test_themed_urls_without_theme_variable(self):
"""Test themed_urls returns None when filename has no %(theme)s"""
result = self.media_s3_backend.themed_urls("logo.png")

View File

@@ -1,7 +1,5 @@
"""Apply blueprint from commandline"""
from sys import exit as sys_exit
from django.core.management.base import BaseCommand, no_translations
from structlog.stdlib import get_logger
@@ -28,7 +26,7 @@ class Command(BaseCommand):
self.stderr.write("Blueprint invalid")
for log in logs:
self.stderr.write(f"\t{log.logger}: {log.event}: {log.attributes}")
sys_exit(1)
raise RuntimeError("Blueprint invalid")
importer.apply()
def add_arguments(self, parser):

View File

@@ -4,7 +4,7 @@ from collections.abc import Iterator
from copy import copy
from django.core.cache import cache
from django.db.models import Case, QuerySet
from django.db.models import Case, Q, QuerySet
from django.db.models.expressions import When
from django.shortcuts import get_object_or_404
from django.utils.translation import gettext as _
@@ -36,9 +36,13 @@ from authentik.rbac.filters import ObjectFilter
LOGGER = get_logger()
def user_app_cache_key(user_pk: str, page_number: int | None = None) -> str:
def user_app_cache_key(
user_pk: str, page_number: int | None = None, only_with_launch_url: bool = False
) -> str:
"""Cache key where application list for user is saved"""
key = f"{CACHE_PREFIX}app_access/{user_pk}"
if only_with_launch_url:
key += "/launch"
if page_number:
key += f"/{page_number}"
return key
@@ -274,11 +278,19 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
if superuser_full_list and request.user.is_superuser:
return super().list(request)
only_with_launch_url = str(
request.query_params.get("only_with_launch_url", "false")
).lower()
only_with_launch_url = (
str(request.query_params.get("only_with_launch_url", "false")).lower()
) == "true"
queryset = self._filter_queryset_for_list(self.get_queryset())
if only_with_launch_url:
# Pre-filter at DB level to skip expensive per-app policy evaluation
# for apps that can never appear in the launcher:
# - No meta_launch_url AND no provider: no possible launch URL
# - meta_launch_url="blank://blank": documented convention to hide from launcher
queryset = queryset.exclude(
Q(meta_launch_url="", provider__isnull=True) | Q(meta_launch_url="blank://blank")
)
paginator: Pagination = self.paginator
paginated_apps = paginator.paginate_queryset(queryset, request)
@@ -295,7 +307,6 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
except ValueError as exc:
raise ValidationError from exc
allowed_applications = self._get_allowed_applications(paginated_apps, user=for_user)
allowed_applications = self._expand_applications(allowed_applications)
serializer = self.get_serializer(allowed_applications, many=True)
return self.get_paginated_response(serializer.data)
@@ -305,19 +316,26 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
allowed_applications = self._get_allowed_applications(paginated_apps)
if should_cache:
allowed_applications = cache.get(
user_app_cache_key(self.request.user.pk, paginator.page.number)
user_app_cache_key(
self.request.user.pk, paginator.page.number, only_with_launch_url
)
)
if not allowed_applications:
if allowed_applications:
# Re-fetch cached applications since pickled instances lose prefetched
# relationships, causing N+1 queries during serialization
allowed_applications = self._expand_applications(allowed_applications)
else:
LOGGER.debug("Caching allowed application list", page=paginator.page.number)
allowed_applications = self._get_allowed_applications(paginated_apps)
cache.set(
user_app_cache_key(self.request.user.pk, paginator.page.number),
user_app_cache_key(
self.request.user.pk, paginator.page.number, only_with_launch_url
),
allowed_applications,
timeout=86400,
)
allowed_applications = self._expand_applications(allowed_applications)
if only_with_launch_url == "true":
if only_with_launch_url:
allowed_applications = self._filter_applications_with_launch_url(allowed_applications)
serializer = self.get_serializer(allowed_applications, many=True)

View File

@@ -790,9 +790,13 @@ class Application(SerializerModel, PolicyBindingModel):
def get_provider(self) -> Provider | None:
"""Get casted provider instance. Needs Application queryset with_provider"""
if hasattr(self, "_cached_provider"):
return self._cached_provider
if not self.provider:
self._cached_provider = None
return None
return get_deepest_child(self.provider)
self._cached_provider = get_deepest_child(self.provider)
return self._cached_provider
def backchannel_provider_for[T: Provider](self, provider_type: type[T], **kwargs) -> T | None:
"""Get Backchannel provider for a specific type"""

View File

@@ -11,6 +11,10 @@ class FlowNonApplicableException(SentryIgnoredException):
policy_result: PolicyResult | None = None
def __init__(self, policy_result: PolicyResult | None = None, *args):
super().__init__(*args)
self.policy_result = policy_result
@property
def messages(self) -> str:
"""Get messages from policy result, fallback to generic reason"""

View File

@@ -42,6 +42,7 @@ class Migration(migrations.Migration):
("require_superuser", "Require Superuser"),
("require_redirect", "Require Redirect"),
("require_outpost", "Require Outpost"),
("require_token", "Require Token"),
],
default="none",
help_text="Required level of authentication and authorization to access a flow.",

View File

@@ -40,6 +40,7 @@ class FlowAuthenticationRequirement(models.TextChoices):
REQUIRE_SUPERUSER = "require_superuser"
REQUIRE_REDIRECT = "require_redirect"
REQUIRE_OUTPOST = "require_outpost"
REQUIRE_TOKEN = "require_token"
class NotConfiguredAction(models.TextChoices):

View File

@@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, Any
from django.core.cache import cache
from django.http import HttpRequest, HttpResponse
from django.utils.translation import gettext as _
from sentry_sdk import start_span
from sentry_sdk.tracing import Span
from structlog.stdlib import BoundLogger, get_logger
@@ -26,6 +27,7 @@ from authentik.lib.config import CONFIG
from authentik.lib.utils.urls import redirect_with_qs
from authentik.outposts.models import Outpost
from authentik.policies.engine import PolicyEngine
from authentik.policies.types import PolicyResult
from authentik.root.middleware import ClientIPMiddleware
if TYPE_CHECKING:
@@ -226,6 +228,15 @@ class FlowPlanner:
and context.get(PLAN_CONTEXT_IS_REDIRECTED) is None
):
raise FlowNonApplicableException()
if (
self.flow.authentication == FlowAuthenticationRequirement.REQUIRE_TOKEN
and context.get(PLAN_CONTEXT_IS_RESTORED) is None
):
raise FlowNonApplicableException(
PolicyResult(
False, _("This link is invalid or has expired. Please request a new one.")
)
)
outpost_user = ClientIPMiddleware.get_outpost_user(request)
if self.flow.authentication == FlowAuthenticationRequirement.REQUIRE_OUTPOST:
if not outpost_user:
@@ -273,9 +284,7 @@ class FlowPlanner:
engine.build()
result = engine.result
if not result.passing:
exc = FlowNonApplicableException()
exc.policy_result = result
raise exc
raise FlowNonApplicableException(result)
# User is passing so far, check if we have a cached plan
cached_plan_key = cache_key(self.flow, user)
cached_plan = cache.get(cached_plan_key, None)

View File

@@ -1,5 +1,6 @@
"""flow views tests"""
from datetime import timedelta
from unittest.mock import MagicMock, PropertyMock, patch
from urllib.parse import urlencode
@@ -7,6 +8,7 @@ from django.http import HttpRequest, HttpResponse
from django.test import override_settings
from django.test.client import RequestFactory
from django.urls import reverse
from django.utils.timezone import now
from rest_framework.exceptions import ParseError
from authentik.core.models import Group, User
@@ -17,6 +19,7 @@ from authentik.flows.models import (
FlowDeniedAction,
FlowDesignation,
FlowStageBinding,
FlowToken,
InvalidResponseAction,
)
from authentik.flows.planner import FlowPlan, FlowPlanner
@@ -24,6 +27,7 @@ from authentik.flows.stage import PLAN_CONTEXT_PENDING_USER_IDENTIFIER, StageVie
from authentik.flows.tests import FlowTestCase
from authentik.flows.views.executor import (
NEXT_ARG_NAME,
QS_KEY_TOKEN,
QS_QUERY,
SESSION_KEY_PLAN,
FlowExecutorView,
@@ -740,3 +744,77 @@ class TestFlowExecutor(FlowTestCase):
"title": flow.title,
},
)
@patch(
"authentik.flows.views.executor.to_stage_response",
TO_STAGE_RESPONSE_MOCK,
)
def test_expired_flow_token(self):
"""Test that an expired flow token shows an appropriate error message"""
flow = create_test_flow(
FlowDesignation.RECOVERY,
authentication=FlowAuthenticationRequirement.REQUIRE_TOKEN,
)
user = create_test_user()
plan = FlowPlan(flow_pk=flow.pk.hex, bindings=[], markers=[])
token = FlowToken.objects.create(
user=user,
identifier=generate_id(),
flow=flow,
_plan=FlowToken.pickle(plan),
expires=now() - timedelta(hours=1),
)
url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug})
response = self.client.get(
url + f"?{urlencode({QS_QUERY: urlencode({QS_KEY_TOKEN: token.key})})}"
)
self.assertStageResponse(
response,
flow,
component="ak-stage-access-denied",
error_message="This link is invalid or has expired. Please request a new one.",
)
@patch(
"authentik.flows.views.executor.to_stage_response",
TO_STAGE_RESPONSE_MOCK,
)
def test_invalid_flow_token_require_token(self):
"""Test that an invalid/nonexistent token on a REQUIRE_TOKEN flow shows error"""
flow = create_test_flow(
FlowDesignation.RECOVERY,
authentication=FlowAuthenticationRequirement.REQUIRE_TOKEN,
)
url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug})
response = self.client.get(
url + f"?{urlencode({QS_QUERY: urlencode({QS_KEY_TOKEN: 'invalid-token'})})}"
)
self.assertStageResponse(
response,
flow,
component="ak-stage-access-denied",
error_message="This link is invalid or has expired. Please request a new one.",
)
@patch(
"authentik.flows.views.executor.to_stage_response",
TO_STAGE_RESPONSE_MOCK,
)
def test_no_token_require_token(self):
"""Test that accessing a REQUIRE_TOKEN flow without any token shows error"""
flow = create_test_flow(
FlowDesignation.RECOVERY,
authentication=FlowAuthenticationRequirement.REQUIRE_TOKEN,
)
url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug})
response = self.client.get(url)
self.assertStageResponse(
response,
flow,
component="ak-stage-access-denied",
error_message="This link is invalid or has expired. Please request a new one.",
)

View File

@@ -26,6 +26,7 @@ from authentik.flows.models import (
)
from authentik.flows.planner import (
PLAN_CONTEXT_IS_REDIRECTED,
PLAN_CONTEXT_IS_RESTORED,
PLAN_CONTEXT_PENDING_USER,
FlowPlanner,
cache_key,
@@ -129,6 +130,22 @@ class TestFlowPlanner(TestCase):
planner.allow_empty_flows = True
planner.plan(request)
def test_authentication_require_token(self):
"""Test flow authentication (require_token)"""
flow = create_test_flow()
flow.authentication = FlowAuthenticationRequirement.REQUIRE_TOKEN
request = self.request_factory.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
)
planner = FlowPlanner(flow)
planner.allow_empty_flows = True
with self.assertRaises(FlowNonApplicableException):
planner.plan(request)
context = {PLAN_CONTEXT_IS_RESTORED: True}
planner.plan(request, context)
@patch(
"authentik.policies.engine.PolicyEngine.result",
POLICY_RETURN_FALSE,

View File

@@ -62,6 +62,7 @@ from authentik.policies.engine import PolicyEngine
LOGGER = get_logger()
# Argument used to redirect user after login
NEXT_ARG_NAME = "next"
SESSION_KEY_PLAN = "authentik/flows/plan"
SESSION_KEY_GET = "authentik/flows/get"
SESSION_KEY_POST = "authentik/flows/post"

View File

@@ -14,7 +14,16 @@ def chunked_queryset[T: Model](queryset: QuerySet[T], chunk_size: int = 1_000) -
def get_chunks(qs: QuerySet) -> Generator[QuerySet[T]]:
qs = qs.order_by("pk")
pks = qs.values_list("pk", flat=True)
start_pk = pks[0]
# The outer queryset.exists() guard can race with a concurrent
# transaction that deletes the last matching row (or with a
# different isolation-level snapshot), so by the time this
# generator starts iterating the queryset may be empty and
# pks[0] would raise IndexError and crash the caller. Using
# .first() returns None on an empty queryset, which we bail
# out on cleanly. See goauthentik/authentik#21643.
start_pk = pks.first()
if start_pk is None:
return
while True:
try:
end_pk = pks.filter(pk__gte=start_pk)[chunk_size]

View File

@@ -6,10 +6,11 @@ from urllib.parse import quote
from django.urls import reverse
from authentik.blueprints.tests import apply_blueprint
from authentik.core.models import Application
from authentik.core.tests.utils import create_test_flow
from authentik.lib.generators import generate_id
from authentik.providers.oauth2.models import OAuth2Provider
from authentik.providers.oauth2.models import DeviceToken, OAuth2Provider, ScopeMapping
from authentik.providers.oauth2.tests.utils import OAuthTestCase
@@ -110,3 +111,57 @@ class TesOAuth2DeviceBackchannel(OAuthTestCase):
self.assertEqual(res.status_code, 200)
body = loads(res.content.decode())
self.assertEqual(body["expires_in"], 60)
@apply_blueprint("system/providers-oauth2.yaml")
def test_backchannel_scopes(self):
"""Test backchannel"""
self.provider.property_mappings.set(
ScopeMapping.objects.filter(
managed__in=[
"goauthentik.io/providers/oauth2/scope-openid",
"goauthentik.io/providers/oauth2/scope-email",
"goauthentik.io/providers/oauth2/scope-profile",
]
)
)
creds = b64encode(f"{self.provider.client_id}:".encode()).decode()
res = self.client.post(
reverse("authentik_providers_oauth2:device"),
HTTP_AUTHORIZATION=f"Basic {creds}",
data={"scope": "openid email"},
)
self.assertEqual(res.status_code, 200)
body = loads(res.content.decode())
self.assertEqual(body["expires_in"], 60)
token = DeviceToken.objects.filter(device_code=body["device_code"]).first()
self.assertIsNotNone(token)
self.assertEqual(len(token.scope), 2)
self.assertIn("openid", token.scope)
self.assertIn("email", token.scope)
@apply_blueprint("system/providers-oauth2.yaml")
def test_backchannel_scopes_extra(self):
"""Test backchannel"""
self.provider.property_mappings.set(
ScopeMapping.objects.filter(
managed__in=[
"goauthentik.io/providers/oauth2/scope-openid",
"goauthentik.io/providers/oauth2/scope-email",
"goauthentik.io/providers/oauth2/scope-profile",
]
)
)
creds = b64encode(f"{self.provider.client_id}:".encode()).decode()
res = self.client.post(
reverse("authentik_providers_oauth2:device"),
HTTP_AUTHORIZATION=f"Basic {creds}",
data={"scope": "openid email foo"},
)
self.assertEqual(res.status_code, 200)
body = loads(res.content.decode())
self.assertEqual(body["expires_in"], 60)
token = DeviceToken.objects.filter(device_code=body["device_code"]).first()
self.assertIsNotNone(token)
self.assertEqual(len(token.scope), 2)
self.assertIn("openid", token.scope)
self.assertIn("email", token.scope)

View File

@@ -15,7 +15,7 @@ from authentik.core.models import Application
from authentik.lib.config import CONFIG
from authentik.lib.utils.time import timedelta_from_string
from authentik.providers.oauth2.errors import DeviceCodeError
from authentik.providers.oauth2.models import DeviceToken, OAuth2Provider
from authentik.providers.oauth2.models import DeviceToken, OAuth2Provider, ScopeMapping
from authentik.providers.oauth2.utils import TokenResponse, extract_client_auth
from authentik.providers.oauth2.views.device_init import QS_KEY_CODE
@@ -28,7 +28,7 @@ class DeviceView(View):
client_id: str
provider: OAuth2Provider
scopes: list[str] = []
scopes: set[str] = []
def parse_request(self):
"""Parse incoming request"""
@@ -44,7 +44,21 @@ class DeviceView(View):
raise DeviceCodeError("invalid_client") from None
self.provider = provider
self.client_id = client_id
self.scopes = self.request.POST.get("scope", "").split(" ")
scopes_to_check = set(self.request.POST.get("scope", "").split())
default_scope_names = set(
ScopeMapping.objects.filter(provider__in=[self.provider]).values_list(
"scope_name", flat=True
)
)
self.scopes = scopes_to_check
if not scopes_to_check.issubset(default_scope_names):
LOGGER.info(
"Application requested scopes not configured, setting to overlap",
scope_allowed=default_scope_names,
scope_given=self.scopes,
)
self.scopes = self.scopes.intersection(default_scope_names)
def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
throttle = AnonRateThrottle()

View File

@@ -446,8 +446,6 @@ DRAMATIQ = {
("authentik.tasks.middleware.TaskLogMiddleware", {}),
("authentik.tasks.middleware.LoggingMiddleware", {}),
("authentik.tasks.middleware.DescriptionMiddleware", {}),
("authentik.tasks.middleware.WorkerHealthcheckMiddleware", {}),
("authentik.tasks.middleware.WorkerStatusMiddleware", {}),
(
"authentik.tasks.middleware.MetricsMiddleware",
{

View File

@@ -36,6 +36,14 @@ class UserWriteStageView(StageView):
super().__init__(executor, **kwargs)
self.disallowed_user_attributes = [
"groups",
# Block attribute writes that would otherwise land on the model's
# primary key. An IdP that returns an `id` claim (mocksaml is one
# example) used to crash the enrollment flow with
# ValueError: Field 'id' expected a number but got '<hex>'
# because hasattr(user, "id") is true and setattr(user, "id", ...)
# was taken unchecked. See #21580.
"id",
"pk",
]
@staticmethod

View File

@@ -315,6 +315,34 @@ class TestUserWriteStage(FlowTestCase):
component="ak-stage-access-denied",
)
def test_user_update_ignores_id_from_idp(self):
"""IdP-supplied `id`/`pk` attributes must not land on the model
primary key and crash user save (#21580)."""
existing = User.objects.create(username="unittest", email="test@goauthentik.io")
original_pk = existing.pk
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
plan.context[PLAN_CONTEXT_PENDING_USER] = existing
plan.context[PLAN_CONTEXT_PROMPT] = {
"username": "idp-user",
# Hex string from a SAML IdP; would previously crash with
# ValueError: Field 'id' expected a number but got '<hex>'.
"id": "1dda9fb491dc01bd24d2423ba2f22ae561f56ddf2376b29a11c80281d21201f9",
"pk": "also-not-an-int",
}
session = self.client.session
session[SESSION_KEY_PLAN] = plan
session.save()
response = self.client.post(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
)
self.assertEqual(response.status_code, 200)
self.assertStageRedirects(response, reverse("authentik_core:root-redirect"))
user = User.objects.get(username="idp-user")
self.assertEqual(user.pk, original_pk)
def test_write_attribute(self):
"""Test write_attribute"""
user = create_test_admin_user()

View File

@@ -1,4 +1,3 @@
import pglock
from django.utils.timezone import now, timedelta
from drf_spectacular.utils import extend_schema, inline_serializer
from packaging.version import parse
@@ -31,18 +30,13 @@ class WorkerView(APIView):
def get(self, request: Request) -> Response:
response = []
our_version = parse(authentik_full_version())
for status in WorkerStatus.objects.filter(last_seen__gt=now() - timedelta(minutes=2)):
lock_id = f"goauthentik.io/worker/status/{status.pk}"
with pglock.advisory(lock_id, timeout=0, side_effect=pglock.Return) as acquired:
# The worker doesn't hold the lock, it isn't running
if acquired:
continue
version_matching = parse(status.version) == our_version
response.append(
{
"worker_id": f"{status.pk}@{status.hostname}",
"version": status.version,
"version_matching": version_matching,
}
)
for status in WorkerStatus.objects.filter(last_seen__gt=now() - timedelta(seconds=45)):
version_matching = parse(status.version) == our_version
response.append(
{
"worker_id": f"{status.pk}@{status.hostname}",
"version": status.version,
"version_matching": version_matching,
}
)
return Response(response)

View File

@@ -1,42 +1,25 @@
import socket
from collections.abc import Callable
from http.server import BaseHTTPRequestHandler
from threading import Event as TEvent
from threading import Thread, current_thread
from typing import Any, cast
import pglock
from django.db import OperationalError, connections, transaction
from django.utils.timezone import now
from django.conf import settings
from django.db import OperationalError
from django_dramatiq_postgres.middleware import (
CurrentTask as BaseCurrentTask,
)
from django_dramatiq_postgres.middleware import (
HTTPServer,
HTTPServerThread,
)
from django_dramatiq_postgres.middleware import (
MetricsMiddleware as BaseMetricsMiddleware,
)
from django_dramatiq_postgres.middleware import (
_MetricsHandler as BaseMetricsHandler,
)
from dramatiq import Worker
from dramatiq.broker import Broker
from dramatiq.message import Message
from dramatiq.middleware import Middleware
from psycopg.errors import Error
from setproctitle import setthreadtitle
from structlog.stdlib import get_logger
from authentik import authentik_full_version
from authentik.events.models import Event, EventAction
from authentik.lib.config import CONFIG
from authentik.lib.sentry import should_ignore_exception
from authentik.lib.utils.reflection import class_to_path
from authentik.root.monitoring import monitoring_set
from authentik.root.signals import post_startup, pre_startup, startup
from authentik.tasks.models import Task, TaskLog, TaskStatus, WorkerStatus
from authentik.tasks.models import Task, TaskLog, TaskStatus
from authentik.tenants.models import Tenant
from authentik.tenants.utils import get_current_tenant
@@ -193,154 +176,26 @@ class DescriptionMiddleware(Middleware):
return {"description"}
class _healthcheck_handler(BaseHTTPRequestHandler):
def log_request(self, code="-", size="-"):
HEALTHCHECK_LOGGER.info(
self.path,
method=self.command,
status=code,
)
def log_error(self, format, *args):
HEALTHCHECK_LOGGER.warning(format, *args)
def do_HEAD(self):
try:
for db_conn in connections.all():
# Force connection reload
db_conn.connect()
_ = db_conn.cursor()
self.send_response(200)
except DB_ERRORS: # pragma: no cover
self.send_response(503)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.send_header("Content-Length", "0")
self.end_headers()
do_GET = do_HEAD
class WorkerHealthcheckMiddleware(Middleware):
thread: HTTPServerThread | None
def __init__(self):
listen = CONFIG.get("listen.http", ["[::]:9000"])
if isinstance(listen, str):
listen = listen.split(",")
host, _, port = listen[0].rpartition(":")
try:
port = int(port)
except ValueError:
LOGGER.error(f"Invalid port entered: {port}")
self.host, self.port = host, port
def after_worker_boot(self, broker: Broker, worker: Worker):
self.thread = HTTPServerThread(
target=WorkerHealthcheckMiddleware.run, args=(self.host, self.port)
)
self.thread.start()
def before_worker_shutdown(self, broker: Broker, worker: Worker):
server = self.thread.server
if server:
server.shutdown()
LOGGER.debug("Stopping WorkerHealthcheckMiddleware")
self.thread.join()
@staticmethod
def run(addr: str, port: int):
setthreadtitle("authentik Worker Healthcheck server")
try:
server = HTTPServer((addr, port), _healthcheck_handler)
thread = cast(HTTPServerThread, current_thread())
thread.server = server
server.serve_forever()
except OSError as exc:
get_logger(__name__, type(WorkerHealthcheckMiddleware)).warning(
"Port is already in use, not starting healthcheck server",
exc=exc,
)
class WorkerStatusMiddleware(Middleware):
thread: Thread | None
thread_event: TEvent | None
def after_worker_boot(self, broker: Broker, worker: Worker):
self.thread_event = TEvent()
self.thread = Thread(target=WorkerStatusMiddleware.run, args=(self.thread_event,))
self.thread.start()
def before_worker_shutdown(self, broker: Broker, worker: Worker):
self.thread_event.set()
LOGGER.debug("Stopping WorkerStatusMiddleware")
self.thread.join()
@staticmethod
def run(event: TEvent):
setthreadtitle("authentik Worker status")
with transaction.atomic():
hostname = socket.gethostname()
WorkerStatus.objects.filter(hostname=hostname).delete()
status, _ = WorkerStatus.objects.update_or_create(
hostname=hostname,
version=authentik_full_version(),
)
while not event.is_set():
try:
WorkerStatusMiddleware.keep(event, status)
except DB_ERRORS: # pragma: no cover
event.wait(10)
try:
connections.close_all()
except DB_ERRORS:
pass
@staticmethod
def keep(event: TEvent, status: WorkerStatus):
lock_id = f"goauthentik.io/worker/status/{status.pk}"
with pglock.advisory(lock_id, side_effect=pglock.Raise):
while not event.is_set():
status.refresh_from_db()
old_last_seen = status.last_seen
status.last_seen = now()
if old_last_seen != status.last_seen:
status.save(update_fields=("last_seen",))
event.wait(30)
class _MetricsHandler(BaseMetricsHandler):
def do_GET(self) -> None:
monitoring_set.send_robust(self)
return super().do_GET()
class MetricsMiddleware(BaseMetricsMiddleware):
thread: HTTPServerThread | None
handler_class = _MetricsHandler
@property
def forks(self) -> list[Callable[[], None]]:
return []
def after_worker_boot(self, broker: Broker, worker: Worker):
listen = CONFIG.get("listen.metrics", ["[::]:9300"])
if isinstance(listen, str):
listen = listen.split(",")
addr, _, port = listen[0].rpartition(":")
def before_worker_boot(self, broker: Broker, worker: Any) -> None:
if settings.TEST:
return super().before_worker_boot(broker, worker)
try:
port = int(port)
except ValueError:
LOGGER.error(f"Invalid port entered: {port}")
self.thread = HTTPServerThread(target=MetricsMiddleware.run, args=(addr, port))
self.thread.start()
from prometheus_client import values
from prometheus_client.values import MultiProcessValue
def before_worker_shutdown(self, broker: Broker, worker: Worker):
server = self.thread.server
if server:
server.shutdown()
LOGGER.debug("Stopping MetricsMiddleware")
self.thread.join()
values.ValueClass = MultiProcessValue(lambda: worker.worker_id)
return super().before_worker_boot(broker, worker)
def after_worker_shutdown(self, broker: Broker, worker: Any) -> None:
if settings.TEST:
return
from prometheus_client import multiprocess
multiprocess.mark_process_dead(worker.worker_id)

View File

@@ -2,7 +2,6 @@
from datetime import timedelta
import pglock
from django.db.models import Count
from django.dispatch import receiver
from django.utils.timezone import now
@@ -31,24 +30,15 @@ GAUGE_TASKS_QUEUED = Gauge(
)
_version = parse(authentik_full_version())
@receiver(monitoring_set)
def monitoring_set_workers(sender, **kwargs):
"""Set worker gauge"""
worker_version_count = {}
for status in WorkerStatus.objects.filter(last_seen__gt=now() - timedelta(minutes=2)):
lock_id = f"goauthentik.io/worker/status/{status.pk}"
with pglock.advisory(lock_id, timeout=0, side_effect=pglock.Return) as acquired:
# The worker doesn't hold the lock, it isn't running
if acquired:
continue
version_matching = parse(status.version) == _version
worker_version_count.setdefault(
status.version, {"count": 0, "matching": version_matching}
)
worker_version_count[status.version]["count"] += 1
our_version = parse(authentik_full_version())
for status in WorkerStatus.objects.filter(last_seen__gt=now() - timedelta(seconds=45)):
version_matching = parse(status.version) == our_version
worker_version_count.setdefault(status.version, {"count": 0, "matching": version_matching})
worker_version_count[status.version]["count"] += 1
for version, stats in worker_version_count.items():
OLD_GAUGE_WORKERS.labels(version, stats["matching"]).set(stats["count"])
GAUGE_WORKERS.labels(version, stats["matching"]).set(stats["count"])

View File

@@ -10,7 +10,6 @@ from dramatiq.results.middleware import Results
from dramatiq.worker import Worker, _ConsumerThread, _WorkerThread
from authentik.tasks.broker import PostgresBroker
from authentik.tasks.middleware import WorkerHealthcheckMiddleware
TESTING_QUEUE = "testing"
@@ -18,6 +17,7 @@ TESTING_QUEUE = "testing"
class TestWorker(Worker):
def __init__(self, broker: Broker):
super().__init__(broker=broker)
self.worker_id = 1000
self.work_queue = PriorityQueue()
self.consumers = {
TESTING_QUEUE: _ConsumerThread(
@@ -82,8 +82,6 @@ def use_test_broker():
middleware: Middleware = import_string(middleware_class)(
**middleware_kwargs,
)
if isinstance(middleware, WorkerHealthcheckMiddleware):
middleware.port = 9102
if isinstance(middleware, Retries):
middleware.max_retries = 0
if isinstance(middleware, Results):

View File

@@ -8430,7 +8430,8 @@
"require_unauthenticated",
"require_superuser",
"require_redirect",
"require_outpost"
"require_outpost",
"require_token"
],
"title": "Authentication",
"description": "Required level of authentication and authorization to access a flow."

View File

@@ -7,9 +7,7 @@ import (
"net/http"
"os"
"path"
"strconv"
"strings"
"syscall"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
@@ -18,8 +16,6 @@ import (
"goauthentik.io/internal/web"
)
var workerPidFile = path.Join(os.TempDir(), "authentik-worker.pid")
var healthcheckCmd = &cobra.Command{
Use: "healthcheck",
Run: func(cmd *cobra.Command, args []string) {
@@ -31,9 +27,9 @@ var healthcheckCmd = &cobra.Command{
log.WithField("mode", mode).Debug("checking health")
switch strings.ToLower(mode) {
case "server":
exitCode = checkServer()
exitCode = check(fmt.Sprintf("http://localhost%s-/health/live/", config.Get().Web.Path))
case "worker":
exitCode = checkWorker()
exitCode = check("http://localhost/-/health/live/")
default:
log.Warn("Invalid mode")
}
@@ -45,7 +41,7 @@ func init() {
rootCmd.AddCommand(healthcheckCmd)
}
func checkServer() int {
func check(url string) int {
h := &http.Client{
Transport: utils.NewUserAgentTransport("goauthentik.io/healthcheck",
&http.Transport{
@@ -55,7 +51,6 @@ func checkServer() int {
},
),
}
url := fmt.Sprintf("http://localhost%s-/health/live/", config.Get().Web.Path)
res, err := h.Head(url)
if err != nil {
log.WithError(err).Warning("failed to send healthcheck request")
@@ -68,29 +63,3 @@ func checkServer() int {
log.Debug("successfully checked health")
return 0
}
func checkWorker() int {
pidB, err := os.ReadFile(workerPidFile)
if err != nil {
log.WithError(err).Warning("failed to check worker PID file")
return 1
}
pidS := strings.TrimSpace(string(pidB[:]))
pid, err := strconv.Atoi(pidS)
if err != nil {
log.WithError(err).Warning("failed to find worker process PID")
return 1
}
process, err := os.FindProcess(pid)
if err != nil {
log.WithError(err).Warning("failed to find worker process")
return 1
}
err = process.Signal(syscall.Signal(0))
if err != nil {
log.WithError(err).Warning("failed to signal worker process")
return 1
}
log.Info("successfully checked health")
return 0
}

2
go.mod
View File

@@ -40,7 +40,7 @@ require (
)
require (
github.com/Azure/go-ntlmssp v0.1.0 // indirect
github.com/Azure/go-ntlmssp v0.1.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect

4
go.sum
View File

@@ -2,8 +2,8 @@ beryju.io/ldap v0.2.1 h1:rhTAP2CXqrKZy/UycLC/aPSSBMcgJMzooKqk3TwVFxY=
beryju.io/ldap v0.2.1/go.mod h1:GJSw3pVOON/3+L5att3Eysmj7j0GmjLvA6/WNmPajD4=
beryju.io/radius-eap v0.1.0 h1:5M3HwkzH3nIEBcKDA2z5+sb4nCY3WdKL/SDDKTBvoqw=
beryju.io/radius-eap v0.1.0/go.mod h1:yYtO59iyoLNEepdyp1gZ0i1tGdjPbrR2M/v5yOz7Fkc=
github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A=
github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw=
github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/avast/retry-go/v4 v4.7.0 h1:yjDs35SlGvKwRNSykujfjdMxMhMQQM0TnIjJaHB+Zio=

View File

@@ -11,6 +11,7 @@ import (
"os"
"os/signal"
"runtime"
"sync"
"syscall"
"time"
@@ -45,6 +46,7 @@ type APIController struct {
reloadOffset time.Duration
eventConn *websocket.Conn
eventConnMu sync.Mutex
lastWsReconnect time.Time
wsIsReconnecting bool
eventHandlers []EventHandler

View File

@@ -77,7 +77,12 @@ func (ac *APIController) initEvent(outpostUUID string, attempt int) error {
Instruction: EventKindHello,
Args: ac.getEventPingArgs(),
}
// Serialize this write against concurrent SendEventHello callers (health
// ticker, RAC handlers) sharing the same *websocket.Conn. Gorilla's Conn
// does not permit concurrent writes.
ac.eventConnMu.Lock()
err = ws.WriteJSON(msg)
ac.eventConnMu.Unlock()
if err != nil {
ac.logger.WithField("logger", "authentik.outpost.events").WithError(err).Warning("Failed to hello to authentik")
return err
@@ -91,7 +96,9 @@ func (ac *APIController) initEvent(outpostUUID string, attempt int) error {
func (ac *APIController) Shutdown() {
// Cleanly close the connection by sending a close message and then
// waiting (with timeout) for the server to close the connection.
ac.eventConnMu.Lock()
err := ac.eventConn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
ac.eventConnMu.Unlock()
if err != nil {
ac.logger.WithError(err).Warning("failed to write close message")
return
@@ -252,6 +259,10 @@ func (a *APIController) SendEventHello(args map[string]any) error {
Instruction: EventKindHello,
Args: allArgs,
}
// Gorilla *websocket.Conn does not permit concurrent writes. This method
// is invoked from the health ticker and from RAC session handlers.
a.eventConnMu.Lock()
err := a.eventConn.WriteJSON(aliveMsg)
a.eventConnMu.Unlock()
return err
}

View File

@@ -1,10 +1,8 @@
#!/usr/bin/env -S bash
set -e -o pipefail
MODE_FILE="${TMPDIR}/authentik-mode"
#!/usr/bin/env bash
if [[ -z "${PROMETHEUS_MULTIPROC_DIR}" ]]; then
export PROMETHEUS_MULTIPROC_DIR="${TMPDIR:-/tmp}/authentik_prometheus_tmp"
fi
set -e -o pipefail
MODE_FILE="$TMPDIR/authentik-mode"
function log {
printf '{"event": "%s", "level": "info", "logger": "bootstrap"}\n' "$@" >&2
@@ -15,10 +13,41 @@ function wait_for_db {
log "Bootstrap completed"
}
function check_if_root {
function run_authentik {
case "$1" in
server)
shift 1
echo -n server >"$MODE_FILE"
if [[ -x "$(command -v authentik-server)" ]]; then
echo authentik-server "$@"
else
echo go run ./cmd/server "$@"
fi
;;
healthcheck)
if [[ -x "$(command -v authentik-server)" ]]; then
echo authentik-server "$@"
else
echo go run ./cmd/server "$@"
fi
;;
worker)
if [[ -x "$(command -v authentik)" ]]; then
echo authentik "$@"
else
echo cargo run -- "$@"
fi
;;
*)
echo "$@"
;;
esac
}
function check_if_root_and_run {
if [[ $EUID -ne 0 ]]; then
log "Not running as root, disabling permission fixes"
exec $1
exec $(run_authentik "$@")
return
fi
SOCKET="/var/run/docker.sock"
@@ -26,36 +55,19 @@ function check_if_root {
if [[ -e "$SOCKET" ]]; then
# Get group ID of the docker socket, so we can create a matching group and
# add ourselves to it
DOCKER_GID=$(stat -c '%g' $SOCKET)
DOCKER_GID="$(stat -c "%g" "${SOCKET}")"
# Ensure group for the id exists
getent group $DOCKER_GID || groupadd -f -g $DOCKER_GID docker
usermod -a -G $DOCKER_GID authentik
getent group "${DOCKER_GID}" || groupadd -f -g "${DOCKER_GID}" docker
usermod -a -G "${DOCKER_GID}" authentik
# since the name of the group might not be docker, we need to lookup the group id
GROUP_NAME=$(getent group $DOCKER_GID | sed 's/:/\n/g' | head -1)
GROUP_NAME=$(getent group "${DOCKER_GID}" | sed 's/:/\n/g' | head -1)
GROUP="authentik:${GROUP_NAME}"
fi
# Fix permissions of certs and media
chown -R authentik:authentik /data /certs "${PROMETHEUS_MULTIPROC_DIR}"
chmod ug+rwx /data
chmod ug+rx /certs
exec chpst -u authentik:$GROUP env HOME=/authentik $1
}
function run_authentik {
if [[ -x "$(command -v authentik)" ]]; then
exec authentik $@
else
exec go run -v ./cmd/server/ $@
fi
}
function set_mode {
echo $1 >$MODE_FILE
trap cleanup EXIT
}
function cleanup {
rm -f ${MODE_FILE}
exec chpst -u authentik:"${GROUP}" env HOME=/authentik $(run_authentik "$@")
}
function prepare_debug {
@@ -72,38 +84,33 @@ function prepare_debug {
chown authentik:authentik /unittest.xml
}
if [[ -z "${PROMETHEUS_MULTIPROC_DIR}" ]]; then
export PROMETHEUS_MULTIPROC_DIR="${TMPDIR:-/tmp}/authentik_prometheus_tmp"
fi
mkdir -p "${PROMETHEUS_MULTIPROC_DIR}"
if [[ "$(python -m authentik.lib.config debugger 2>/dev/null)" == "True" ]]; then
prepare_debug
fi
if [[ "$1" == "server" ]]; then
set_mode "server"
run_authentik
elif [[ "$1" == "worker" ]]; then
set_mode "worker"
shift
# If we have bootstrap credentials set, run bootstrap tasks outside of main server
# sync, so that we can sure the first start actually has working bootstrap
# credentials
if [[ -n "${AUTHENTIK_BOOTSTRAP_PASSWORD}" || -n "${AUTHENTIK_BOOTSTRAP_TOKEN}" ]]; then
python -m manage apply_blueprint system/bootstrap.yaml || true
fi
check_if_root "python -m manage worker --pid-file ${TMPDIR}/authentik-worker.pid $@"
elif [[ "$1" == "bash" ]]; then
/bin/bash
elif [[ "$1" == "test-all" ]]; then
prepare_debug
chmod 777 /root
check_if_root "python -m manage test authentik"
elif [[ "$1" == "healthcheck" ]]; then
run_authentik healthcheck $(cat $MODE_FILE)
if [[ "$1" == "bash" ]]; then
exec /usr/bin/env -S bash "$@"
elif [[ "$1" == "dump_config" ]]; then
shift
exec python -m authentik.lib.config $@
shift 1
exec python -m authentik.lib.config "$@"
elif [[ "$1" == "debug" ]]; then
exec sleep infinity
elif [[ "$1" == "test-all" ]]; then
wait_for_db
prepare_debug
chmod 777 /root
check_if_root_and_run manage test authentik
elif [[ "$1" == "server" ]] || [[ "$1" == "worker" ]]; then
wait_for_db
check_if_root_and_run "$@"
elif [[ "$1" == "healthcheck" ]]; then
check_if_root_and_run "$@" "$(cat "$MODE_FILE")"
else
wait_for_db
exec python -m manage "$@"
fi

View File

@@ -9,7 +9,7 @@
"version": "0.0.0",
"license": "MIT",
"devDependencies": {
"aws-cdk": "^2.1118.2",
"aws-cdk": "^2.1118.4",
"cross-env": "^10.1.0"
},
"engines": {
@@ -25,9 +25,9 @@
"license": "MIT"
},
"node_modules/aws-cdk": {
"version": "2.1118.2",
"resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1118.2.tgz",
"integrity": "sha512-jHuShSx0JI14enDz2Hk2Qe0LYTDPzLyF2nBhWCvoXyRCpz31sI3XsCh4KO5ZXKfw9ET0bHvDTVnMZQPBpswg8A==",
"version": "2.1118.4",
"resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1118.4.tgz",
"integrity": "sha512-wJfRQdvb+FJ2cni059mYdmjhfwhMskP+PAB59BL9jhon+jYtjy8X3pbj3uzHgAOJwNhh6jGkP8xq36Cffccbbw==",
"dev": true,
"license": "Apache-2.0",
"bin": {

View File

@@ -7,7 +7,7 @@
"aws-cfn": "cross-env CI=false cdk synth --version-reporting=false > template.yaml"
},
"devDependencies": {
"aws-cdk": "^2.1118.2",
"aws-cdk": "^2.1118.4",
"cross-env": "^10.1.0"
},
"engines": {

View File

@@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1
# Stage 1: Build webui
# Stage: Build webui
FROM --platform=${BUILDPLATFORM} docker.io/library/node:24-trixie-slim@sha256:735dd688da64d22ebd9dd374b3e7e5a874635668fd2a6ec20ca1f99264294086 AS node-builder
ARG GIT_BUILD_HASH
@@ -28,23 +28,14 @@ COPY ./website /work/website/
RUN npm run build && \
npm run build:sfe
# Stage 2: Build go proxy
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.26.2-trixie@sha256:cd8540d626ab35272a8f5ef829c5e5f189ce1dfbf467c123320c191c69c23245 AS go-builder
# Stage: Build go proxy
FROM docker.io/library/golang:1.26.2-trixie@sha256:982ae929f9a74083a242c6e25d19d7d9ed78c6e97fab639a119e90707ba819e2 AS go-builder
ARG TARGETOS
ARG TARGETARCH
ARG TARGETVARIANT
ARG GOOS=$TARGETOS
ARG GOARCH=$TARGETARCH
WORKDIR /go/src/goauthentik.io
RUN --mount=type=cache,id=apt-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/var/cache/apt \
dpkg --add-architecture arm64 && \
apt-get update && \
apt-get install -y --no-install-recommends crossbuild-essential-arm64 gcc-aarch64-linux-gnu
RUN --mount=type=bind,target=/go/src/goauthentik.io/go.mod,src=./go.mod \
--mount=type=bind,target=/go/src/goauthentik.io/go.sum,src=./go.sum \
--mount=type=cache,target=/go/pkg/mod \
@@ -62,11 +53,9 @@ COPY ./packages/client-go /go/src/goauthentik.io/packages/client-go
RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \
--mount=type=cache,id=go-build-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/root/.cache/go-build \
if [ "$TARGETARCH" = "arm64" ]; then export CC=aarch64-linux-gnu-gcc && export CC_FOR_TARGET=gcc-aarch64-linux-gnu; fi && \
CGO_ENABLED=1 GOFIPS140=latest GOARM="${TARGETVARIANT#v}" \
go build -o /go/authentik ./cmd/server
CGO_ENABLED=1 GOFIPS140=latest go build -o /go/authentik-server ./cmd/server
# Stage 3: MaxMind GeoIP
# Stage: MaxMind GeoIP
FROM --platform=${BUILDPLATFORM} ghcr.io/maxmind/geoipupdate:v7.1.1@sha256:faecdca22579730ab0b7dea5aa9af350bb3c93cb9d39845c173639ead30346d2 AS geoip
ENV GEOIPUPDATE_EDITION_IDS="GeoLite2-City GeoLite2-ASN"
@@ -79,9 +68,31 @@ RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \
mkdir -p /usr/share/GeoIP && \
/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
# Stage: download Rust toolchain
FROM ghcr.io/goauthentik/fips-debian:trixie-slim-fips@sha256:7726387c78b5787d2146868c2ccc8948a3591d0a5a6436f7780c8c28acc76341 AS rust-toolchain
ARG TARGETARCH
ARG TARGETVARIANT
ENV PATH="/root/.cargo/bin:$PATH"
SHELL ["/bin/sh", "-o", "pipefail", "-c"]
RUN --mount=type=bind,target=rust-toolchain.toml,src=rust-toolchain.toml \
apt-get update && \
# Required for installing pip packages
apt-get install -y --no-install-recommends \
# Build essentials
build-essential && \
curl https://sh.rustup.rs -sSf | sh -s -- -y --profile minimal --default-toolchain none && \
rustup install && \
rustup default "$(sed -n 's/channel = "\(.*\)"/\1/p' rust-toolchain.toml)" && \
rustc --version && \
cargo --version
RUN cat /root/.rustup/settings.toml
# Stage: Download uv
FROM ghcr.io/astral-sh/uv:0.11.5@sha256:555ac94f9a22e656fc5f2ce5dfee13b04e94d099e46bb8dd3a73ec7263f2e484 AS uv
# Stage 5: Base python image
# Stage: Base python image
FROM ghcr.io/goauthentik/fips-python:3.14.3-slim-trixie-fips@sha256:bf45eb77a010d76fe6abd7ae137d1b0c44b6227cd984945042135fdf05ebf8d9 AS python-base
ENV VENV_PATH="/ak-root/.venv" \
@@ -95,16 +106,53 @@ WORKDIR /ak-root/
COPY --from=uv /uv /uvx /bin/
# Stage 6: Python dependencies
# Stage: build rust binary
FROM python-base AS rust-builder
ARG TARGETARCH
ARG TARGETVARIANT
WORKDIR /build
ENV PATH="/root/.cargo/bin:$PATH"
COPY --from=rust-toolchain /root/.rustup /root/.rustup
COPY --from=rust-toolchain /root/.cargo /root/.cargo
RUN rm -f /etc/apt/apt.conf.d/docker-clean; echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache
RUN --mount=type=cache,id=apt-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/var/cache/apt \
--mount=type=bind,target=rust-toolchain.toml,src=rust-toolchain.toml \
apt-get update && \
apt-get install -y --no-install-recommends \
# common dependencies
build-essential \
# aws-lc deps
cmake clang golang
# See https://github.com/aws/aws-lc-rs/issues/569
ENV AWS_LC_FIPS_SYS_CC=clang
RUN --mount=type=bind,target=rust-toolchain.toml,src=rust-toolchain.toml \
--mount=type=bind,target=Cargo.toml,src=Cargo.toml \
--mount=type=bind,target=Cargo.lock,src=Cargo.lock \
--mount=type=bind,target=.cargo/,src=.cargo/ \
--mount=type=bind,target=src/,src=src/ \
--mount=type=bind,target=packages/,src=packages/ \
--mount=type=bind,target=authentik/lib/default.yml,src=authentik/lib/default.yml \
# Required otherwise workspace discovery fails
--mount=type=bind,target=website/scripts/docsmg/,src=website/scripts/docsmg/ \
--mount=type=cache,id=cargo-git-db-$TARGETARCH$TARGETVARIANT,target=/root/.cargo/git/db/ \
--mount=type=cache,id=cargo-registry-$TARGETARCH$TARGETVARIANT,target=/root/.cargo/registry/ \
--mount=type=cache,id=rust-target-$TARGETARCH$TARGETVARIANT,target=/build/target/ \
cargo build --package authentik --no-default-features --features core --locked --release && \
cp ./target/release/authentik /bin/authentik
# Stage: Python dependencies
FROM python-base AS python-deps
ARG TARGETARCH
ARG TARGETVARIANT
RUN rm -f /etc/apt/apt.conf.d/docker-clean; echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache
ENV PATH="/root/.cargo/bin:$PATH"
RUN --mount=type=cache,id=apt-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/var/cache/apt \
--mount=type=bind,target=rust-toolchain.toml,src=rust-toolchain.toml \
apt-get update && \
@@ -121,28 +169,21 @@ RUN --mount=type=cache,id=apt-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/v
# python-kadmin-rs
krb5-multidev libkrb5-dev heimdal-multidev libclang-dev \
# xmlsec
libltdl-dev && \
export RUST_TOOLCHAIN="$(awk -F'\"' '/^[[:space:]]*channel[[:space:]]*=/{print $2; exit}' rust-toolchain.toml)" && \
curl https://sh.rustup.rs -sSf | sh -s -- -y --profile minimal --default-toolchain "${RUST_TOOLCHAIN}" && \
rustup default "${RUST_TOOLCHAIN}" && \
rustc --version && \
cargo --version
libltdl-dev
ENV UV_NO_BINARY_PACKAGE="cryptography lxml python-kadmin-rs xmlsec" \
# https://github.com/rust-lang/rustup/issues/2949
# Fixes issues where the rust version in the build cache is older than latest
# and rustup tries to update it, which fails
RUSTUP_PERMIT_COPY_RENAME="1"
ENV PATH="/root/.cargo/bin:$PATH"
COPY --from=rust-toolchain /root/.rustup /root/.rustup
COPY --from=rust-toolchain /root/.cargo /root/.cargo
ENV UV_NO_BINARY_PACKAGE="cryptography lxml python-kadmin-rs xmlsec"
RUN --mount=type=bind,target=pyproject.toml,src=pyproject.toml \
--mount=type=bind,target=uv.lock,src=uv.lock \
--mount=type=bind,target=packages,src=packages \
--mount=type=bind,target=rust-toolchain.toml,src=rust-toolchain.toml \
--mount=type=cache,id=uv-python-deps-$TARGETARCH$TARGETVARIANT,target=/root/.cache/uv \
RUSTUP_TOOLCHAIN="$(awk -F'\"' '/^[[:space:]]*channel[[:space:]]*=/{print $2; exit}' rust-toolchain.toml)" \
uv sync --frozen --no-install-project --no-dev
# Stage 7: Run
# Stage: Run
FROM python-base AS final-image
ARG VERSION
@@ -193,7 +234,8 @@ COPY ./manage.py /
COPY ./blueprints /blueprints
COPY ./lifecycle/ /lifecycle
COPY ./authentik/sources/kerberos/krb5.conf /etc/krb5.conf
COPY --from=go-builder /go/authentik /bin/authentik
COPY --from=rust-builder /bin/authentik /bin/authentik
COPY --from=go-builder /go/authentik-server /bin/authentik-server
COPY ./packages/ /ak-root/packages
RUN ln -s /ak-root/packages /packages
COPY --from=python-deps /ak-root/.venv /ak-root/.venv

View File

@@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1
# Stage 1: Build
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.26.2-trixie@sha256:cd8540d626ab35272a8f5ef829c5e5f189ce1dfbf467c123320c191c69c23245 AS builder
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.26.2-trixie@sha256:982ae929f9a74083a242c6e25d19d7d9ed78c6e97fab639a119e90707ba819e2 AS builder
ARG TARGETOS
ARG TARGETARCH

View File

@@ -21,7 +21,7 @@ COPY web .
RUN npm run build-proxy
# Stage 2: Build
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.26.2-trixie@sha256:cd8540d626ab35272a8f5ef829c5e5f189ce1dfbf467c123320c191c69c23245 AS builder
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.26.2-trixie@sha256:982ae929f9a74083a242c6e25d19d7d9ed78c6e97fab639a119e90707ba819e2 AS builder
ARG TARGETOS
ARG TARGETARCH

View File

@@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1
# Stage 1: Build
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.26.2-trixie@sha256:cd8540d626ab35272a8f5ef829c5e5f189ce1dfbf467c123320c191c69c23245 AS builder
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.26.2-trixie@sha256:982ae929f9a74083a242c6e25d19d7d9ed78c6e97fab639a119e90707ba819e2 AS builder
ARG TARGETOS
ARG TARGETARCH

View File

@@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1
# Stage 1: Build
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.26.2-trixie@sha256:cd8540d626ab35272a8f5ef829c5e5f189ce1dfbf467c123320c191c69c23245 AS builder
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.26.2-trixie@sha256:982ae929f9a74083a242c6e25d19d7d9ed78c6e97fab639a119e90707ba819e2 AS builder
ARG TARGETOS
ARG TARGETARCH

147
lifecycle/worker_process.py Executable file
View File

@@ -0,0 +1,147 @@
#!/usr/bin/env python3
import os
import random
import signal
import sys
from http.server import BaseHTTPRequestHandler, HTTPServer
from socket import AF_UNIX
from threading import Event, Thread
from typing import Any
from dramatiq import Worker, get_broker
from structlog.stdlib import get_logger
from authentik.lib.config import CONFIG
LOGGER = get_logger()
INITIAL_WORKER_ID = 1000
class HttpHandler(BaseHTTPRequestHandler):
def check_db(self):
from django.db import connections
for db_conn in connections.all():
# Force connection reload
db_conn.connect()
_ = db_conn.cursor()
def do_GET(self):
if self.path == "/-/metrics/":
from authentik.root.monitoring import monitoring_set
monitoring_set.send_robust(self)
self.send_response(200)
self.end_headers()
elif self.path == "/-/health/ready/":
from django.db.utils import OperationalError
try:
self.check_db()
except OperationalError:
self.send_response(503)
self.send_response(200)
self.end_headers()
else:
self.send_response(200)
self.end_headers()
def log_message(self, format: str, *args: Any) -> None:
pass
class UnixSocketServer(HTTPServer):
address_family = AF_UNIX
def main(worker_id: int, socket_path: str):
shutdown = Event()
try:
os.remove(socket_path)
except OSError, FileNotFoundError:
pass
srv = UnixSocketServer(socket_path, HttpHandler)
def immediate_shutdown(signum, frame):
nonlocal srv
srv.shutdown()
sys.exit(0)
def graceful_shutdown(signum, frame):
nonlocal shutdown
shutdown.set()
signal.signal(signal.SIGHUP, immediate_shutdown)
signal.signal(signal.SIGINT, immediate_shutdown)
signal.signal(signal.SIGQUIT, immediate_shutdown)
signal.signal(signal.SIGTERM, graceful_shutdown)
random.seed()
logger = LOGGER.bind(worker_id=worker_id)
logger.debug("Loading broker...")
broker = get_broker()
broker.emit_after("process_boot")
logger.debug("Starting worker threads...")
queues = None # all queues
worker = Worker(broker, queues=queues, worker_threads=CONFIG.get_int("worker.threads"))
worker.worker_id = worker_id
worker.start()
logger.info("Worker process is ready for action.")
Thread(target=srv.serve_forever).start()
# Notify rust process that we are ready
os.kill(os.getppid(), signal.SIGUSR2)
shutdown.wait()
logger.info("Shutting down worker...")
# 5 secs if debug, 5 mins otherwise
worker.stop(timeout=5_000 if CONFIG.get_bool("debug") else 600_000)
srv.shutdown()
broker.close()
logger.info("Worker shut down.")
if __name__ == "__main__":
if len(sys.argv) != 3: # noqa: PLR2004
print("USAGE: worker_process <worker_id> <socket_path>")
sys.exit(1)
worker_id = int(sys.argv[1])
socket_path = sys.argv[2]
from authentik.root.setup import setup
setup()
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "authentik.root.settings")
import django
django.setup()
from django.core.management import execute_from_command_line
if worker_id == INITIAL_WORKER_ID:
from lifecycle.migrate import run_migrations
run_migrations()
if (
"AUTHENTIK_BOOTSTRAP_PASSWORD" in os.environ
or "AUTHENTIK_BOOTSTRAP_TOKEN" in os.environ
):
try:
execute_from_command_line(["", "apply_blueprint", "system/bootstrap.yaml"])
except Exception as exc: # noqa: BLE001
sys.stderr.write(f"Failed to apply bootstrap blueprint: {exc}")
main(worker_id, socket_path)

View File

@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-04-22 00:20+0000\n"
"POT-Creation-Date: 2026-04-23 00:25+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -1579,6 +1579,10 @@ msgstr ""
msgid "Flow Tokens"
msgstr ""
#: authentik/flows/planner.py
msgid "This link is invalid or has expired. Please request a new one."
msgstr ""
#: authentik/flows/views/executor.py
msgid "Invalid next URL"
msgstr ""

View File

@@ -4,3 +4,4 @@ Yubi
Yubikey
Yubikeys
mycorp
mocksaml

View File

@@ -17,11 +17,13 @@ if __name__ == "__main__":
if (
len(sys.argv) > 1
# Explicitly only run migrate for server and worker
and sys.argv[1] in ["dev_server", "worker"]
and sys.argv[1] in ["dev_server"]
# and don't run if this is the child process of a dev_server
and os.environ.get(DJANGO_AUTORELOAD_ENV, None) is None
):
run_migrations()
if len(sys.argv) > 1 and sys.argv[1] in ["worker"]:
raise RuntimeError(f"{sys.argv[1]} command not allowed.")
try:
from django.core.management import execute_from_command_line
except ImportError as exc:

View File

@@ -1,6 +1,7 @@
//! Utilities for working with the authentik API client.
use ak_client::apis::configuration::Configuration;
use ak_client::models::Pagination;
use eyre::{Result, eyre};
use url::Url;
@@ -60,6 +61,42 @@ pub fn make_config() -> Result<Configuration> {
})
}
/// Fetch all pages from a paginated API endpoint, returning all results combined.
///
/// - `fetch`: a function that takes a page number and returns a future resolving to a paginated
/// response.
/// - `get_pagination`: a function that extracts the [`Pagination`] metadata from a response.
/// - `get_results`: a function that extracts the result items from a response.
pub async fn fetch_all<T, R, E, F, Fut>(
fetch: F,
get_pagination: impl Fn(&R) -> &Pagination,
get_results: impl Fn(R) -> Vec<T>,
) -> std::result::Result<Vec<T>, E>
where
F: Fn(i32) -> Fut,
Fut: Future<Output = std::result::Result<R, E>>,
{
let mut page = 1;
let mut results = Vec::with_capacity(0);
loop {
let response = fetch(page).await?;
let next = get_pagination(&response).next;
if page == 1 {
let count = get_pagination(&response).count as usize;
results.reserve(count);
}
results.extend(get_results(response));
if next > 0.0 {
page += 1;
} else {
break;
}
}
Ok(results)
}
#[cfg(test)]
mod tests {
use serde_json::json;

View File

@@ -263,7 +263,7 @@ async fn watch_config(arbiter: Arbiter) -> Result<()> {
/// Start the configuration watcher.
///
/// [`init`] must be called before this is used.
pub fn run(tasks: &mut Tasks) -> Result<()> {
pub fn start(tasks: &mut Tasks) -> Result<()> {
info!("starting config file watcher");
let arbiter = tasks.arbiter();
tasks
@@ -400,7 +400,7 @@ mod tests {
let arbiter = tasks.arbiter();
let mut events_rx = arbiter.events_subscribe();
super::run(&mut tasks).expect("failed to start watcher");
super::start(&mut tasks).expect("failed to start watcher");
assert_eq!(super::get().secret_key, "my_secret_key");
assert_eq!(super::get().postgresql.password, "my_postgres_pass");

View File

@@ -30,12 +30,12 @@ pub fn install() -> Result<()> {
}
if config.debug {
let console_layer = console_subscriber::ConsoleLayer::builder()
.server_addr(config.listen.debug_tokio)
.spawn();
// let console_layer = console_subscriber::ConsoleLayer::builder()
// .server_addr(config.listen.debug_tokio)
// .spawn();
tracing_subscriber::registry()
.with(ErrorLayer::default())
.with(console_layer)
// .with(console_layer)
.with(
fmt::layer()
.compact()
@@ -180,12 +180,9 @@ pub mod sentry {
sentry_dsn: Some(config.sentry_dsn),
environment: config.environment,
send_pii: config.send_pii,
#[expect(
clippy::cast_possible_truncation,
reason = "This is fine, we'll never get big values here."
)]
#[expect(
clippy::as_conversions,
clippy::cast_possible_truncation,
reason = "This is fine, we'll never get big values here."
)]
sample_rate: config.traces_sample_rate as f32,

View File

@@ -23,6 +23,7 @@ export const AuthenticationEnum = {
RequireSuperuser: "require_superuser",
RequireRedirect: "require_redirect",
RequireOutpost: "require_outpost",
RequireToken: "require_token",
UnknownDefaultOpenApi: "11184809",
} as const;
export type AuthenticationEnum = (typeof AuthenticationEnum)[keyof typeof AuthenticationEnum];

View File

@@ -1,102 +0,0 @@
import platform
import sys
from argparse import Namespace
from multiprocessing import set_start_method
from typing import Any
from django.apps.registry import apps
from django.core.management.base import BaseCommand, CommandParser
from django.db import connections
from django.utils.module_loading import import_string, module_has_submodule
from dramatiq.cli import main
from django_dramatiq_postgres.conf import Conf
class Command(BaseCommand):
"""Run worker"""
def add_arguments(self, parser: CommandParser) -> None:
parser.add_argument(
"--pid-file",
action="store",
default=None,
dest="pid_file",
help="PID file",
)
parser.add_argument(
"--watch",
action="store_true",
default=False,
dest="watch",
help="Watch for file changes",
)
def handle(
self,
pid_file: str,
watch: bool,
verbosity: int,
**options: Any,
) -> None:
worker = Conf().worker
setup, modules = self._discover_tasks_modules()
args = Namespace(
broker=setup,
modules=modules,
path=["."],
queues=None,
log_file=None,
skip_logging=True,
use_spawn=False,
forks=[],
worker_shutdown_timeout=600000,
watch=None,
watch_use_polling=False,
include_patterns=["**.py"],
exclude_patterns=None,
verbose=0,
)
if watch:
args.watch = worker["watch_folder"]
if worker["watch_use_polling"]:
args.watch_use_polling = True
if processes := worker["processes"]:
args.processes = processes
if threads := worker["threads"]:
args.threads = threads
if pid_file is not None:
args.pid_file = pid_file
args.verbose = verbosity - 1
# > On macOS [...] the fork start method should be considered unsafe
# https://docs.python.org/3/library/multiprocessing.html#contexts-and-start-methods
if not platform.system() == "Darwin":
set_start_method("fork")
connections.close_all()
sys.exit(main(args)) # type: ignore[no-untyped-call]
def _discover_tasks_modules(self) -> tuple[str, list[str]]:
# Does not support a tasks directory
autodiscovery = Conf().autodiscovery
modules = []
if autodiscovery["enabled"]:
for app in apps.get_app_configs():
if autodiscovery["apps_prefix"] and not app.name.startswith(
autodiscovery["apps_prefix"]
):
continue
if module_has_submodule(app.module, autodiscovery["actors_module_name"]):
modules.append(f"{app.name}.{autodiscovery['actors_module_name']}")
else:
modules_callback = autodiscovery["modules_callback"]
callback = (
modules_callback
if not isinstance(modules_callback, str)
else import_string(modules_callback)
)
modules.extend(callback())
return autodiscovery["setup_module"], modules

View File

@@ -36,7 +36,7 @@ dependencies = [
"django >=4.2,<6.0",
"django-pglock >=1.7,<2",
"django-pgtrigger >=4,<5",
"dramatiq[watch] >=1.17,<1.18",
"dramatiq >=1.17,<1.18",
"tenacity >=9,<10",
"structlog >=25,<26",
]

View File

@@ -7,7 +7,7 @@ requires-python = "==3.14.*"
dependencies = [
"ak-guardian==3.2.0",
"argon2-cffi==25.1.0",
"cachetools==7.0.5",
"cachetools==7.0.6",
"channels==4.3.2",
"cryptography==46.0.7",
"dacite==1.9.2",
@@ -50,7 +50,7 @@ dependencies = [
"paramiko==4.0.0",
"psycopg[c,pool]==3.3.3",
"pydantic-scim==0.0.8",
"pydantic==2.13.2",
"pydantic==2.13.3",
"pyjwt==2.11.0",
"pyrad==2.5.4",
"python-kadmin-rs==0.7.0",

View File

@@ -34355,6 +34355,7 @@ components:
- require_superuser
- require_redirect
- require_outpost
- require_token
type: string
AuthenticatorAttachmentEnum:
enum:

108
src/main.rs Normal file
View File

@@ -0,0 +1,108 @@
use std::sync::atomic::{AtomicUsize, Ordering};
#[cfg(feature = "core")]
use ak_common::db;
use ak_common::{Mode, Tasks, authentik_full_version, config, tls, tracing as ak_tracing};
use argh::FromArgs;
use eyre::{Result, eyre};
use tracing::{error, info, trace};
mod metrics;
#[cfg(feature = "proxy")]
mod outpost;
#[cfg(feature = "core")]
mod server;
#[cfg(feature = "core")]
mod worker;
#[derive(Debug, FromArgs, PartialEq)]
/// The authentication glue you need.
struct Cli {
#[argh(subcommand)]
command: Command,
}
#[derive(Debug, FromArgs, PartialEq)]
#[argh(subcommand)]
enum Command {
#[cfg(feature = "core")]
Worker(worker::Cli),
#[cfg(feature = "proxy")]
Proxy(outpost::proxy::Cli),
}
fn main() -> Result<()> {
let tracing_crude = ak_tracing::install_crude();
info!(version = authentik_full_version(), "authentik is starting");
let cli: Cli = argh::from_env();
match &cli.command {
#[cfg(feature = "core")]
Command::Worker(_) => Mode::set(Mode::Worker)?,
#[cfg(feature = "proxy")]
Command::Proxy(_) => Mode::set(Mode::Proxy)?,
}
trace!("installing error formatting");
color_eyre::install()?;
#[cfg(feature = "core")]
if Mode::is_core() {
trace!("initializing Python");
pyo3::Python::initialize();
trace!("Python initialized");
}
config::init()?;
tls::init()?;
let _sentry = ak_tracing::sentry::install()?;
ak_tracing::install()?;
drop(tracing_crude);
tokio::runtime::Builder::new_multi_thread()
.thread_name_fn(|| {
static ATOMIC_ID: AtomicUsize = AtomicUsize::new(0);
let id = ATOMIC_ID.fetch_add(1, Ordering::SeqCst);
format!("tokio-{id}")
})
.enable_all()
.build()?
.block_on(async {
let mut tasks = Tasks::new()?;
config::start(&mut tasks)?;
let metrics = metrics::start(&mut tasks)?;
#[cfg(feature = "core")]
if Mode::get() == Mode::AllInOne || Mode::get() == Mode::Worker {
db::init(&mut tasks).await?;
}
match cli.command {
#[cfg(feature = "core")]
Command::Worker(args) => {
let workers = worker::start(args, &mut tasks)?;
metrics.workers.store(Some(workers));
}
#[cfg(feature = "proxy")]
Command::Proxy(args) => {
outpost::start::<outpost::proxy::ProxyOutpost>(args, &mut tasks).await?;
}
}
let errors = tasks.run().await;
Mode::cleanup();
if errors.is_empty() {
info!("authentik exiting");
Ok(())
} else {
error!(err = ?errors, "authentik encountered errors");
Err(eyre!("Errors encountered: {:?}", errors))
}
})
}

73
src/metrics/handlers.rs Normal file
View File

@@ -0,0 +1,73 @@
use std::sync::Arc;
use ak_axum::error::Result;
use ak_common::mode::Mode;
use axum::{body::Body, extract::State, http::StatusCode, response::Response};
use tokio::task::spawn_blocking;
use super::Metrics;
pub(super) async fn metrics_handler(State(state): State<Arc<Metrics>>) -> Result<Response> {
let mut metrics = Vec::new();
state.prometheus.render_to_write(&mut metrics)?;
#[cfg(feature = "core")]
if Mode::is_core() {
if Mode::get() == Mode::Worker
&& let Some(workers) = state.workers.load_full()
{
workers.notify_metrics().await?;
}
metrics.extend(spawn_blocking(python::get_python_metrics).await??);
}
Ok(Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "text/plain; version=1.0.0; charset=utf-8")
.body(Body::from(metrics))?)
}
#[cfg(feature = "core")]
mod python {
use eyre::{Report, Result};
use pyo3::{
IntoPyObjectExt as _,
ffi::c_str,
prelude::*,
types::{PyBytes, PyDict},
};
pub(super) fn get_python_metrics() -> Result<Vec<u8>> {
let metrics = Python::attach(|py| {
let locals = PyDict::new(py);
Python::run(
py,
c_str!(
r#"
from prometheus_client import (
CollectorRegistry,
generate_latest,
multiprocess,
)
registry = CollectorRegistry()
multiprocess.MultiProcessCollector(registry)
output = generate_latest(registry)
"#
),
None,
Some(&locals),
)?;
let metrics = locals
.get_item("output")?
.unwrap_or(PyBytes::new(py, &[]).into_bound_py_any(py)?)
.cast::<PyBytes>()
.map_or_else(|_| PyBytes::new(py, &[]), |v| v.to_owned())
.as_bytes()
.to_owned();
Ok::<_, Report>(metrics)
})?;
Ok::<_, Report>(metrics)
}
}

99
src/metrics/mod.rs Normal file
View File

@@ -0,0 +1,99 @@
use std::{env::temp_dir, os::unix, path::PathBuf, sync::Arc};
use ak_axum::{router::wrap_router, server};
use ak_common::{
arbiter::{Arbiter, Tasks},
config,
};
use arc_swap::ArcSwapOption;
use axum::{Router, routing::any};
use eyre::Result;
use metrics_exporter_prometheus::{PrometheusBuilder, PrometheusHandle};
use tokio::{
task::spawn_blocking,
time::{Duration, interval},
};
use tracing::info;
#[cfg(feature = "core")]
use crate::worker::Workers;
mod handlers;
fn socket_path() -> PathBuf {
temp_dir().join("authentik-metrics.sock")
}
pub(crate) struct Metrics {
prometheus: PrometheusHandle,
#[cfg(feature = "core")]
pub(crate) workers: ArcSwapOption<Workers>,
}
impl Metrics {
fn new() -> Result<Self> {
info!("installing Prometheus recorder");
let prometheus = PrometheusBuilder::new()
.with_recommended_naming(true)
.install_recorder()?;
Ok(Self {
prometheus,
#[cfg(feature = "core")]
workers: ArcSwapOption::empty(),
})
}
}
async fn run_upkeep(arbiter: Arbiter, state: Arc<Metrics>) -> Result<()> {
info!("starting metrics upkeep runner");
let mut upkeep_interval = interval(Duration::from_secs(5));
loop {
tokio::select! {
_ = upkeep_interval.tick() => {
let state_clone = Arc::clone(&state);
spawn_blocking(move || state_clone.prometheus.run_upkeep()).await?;
},
() = arbiter.shutdown() => return Ok(())
}
}
}
fn build_router(state: Arc<Metrics>) -> Router {
wrap_router(
Router::new()
.fallback(any(handlers::metrics_handler))
.with_state(state),
true,
)
}
pub(crate) fn start(tasks: &mut Tasks) -> Result<Arc<Metrics>> {
let arbiter = tasks.arbiter();
let metrics = Arc::new(Metrics::new()?);
let router = build_router(Arc::clone(&metrics));
tasks
.build_task()
.name(&format!("{}::run_upkeep", module_path!()))
.spawn(run_upkeep(arbiter, Arc::clone(&metrics)))?;
for addr in config::get().listen.metrics.iter().copied() {
server::start_plain(
tasks,
"metrics",
router.clone(),
addr,
true, // Allow failure in case the server is running on the same machine, like in dev
)?;
}
server::start_unix(
tasks,
"metrics",
router,
unix::net::SocketAddr::from_pathname(socket_path())?,
true, // Allow failure in case the server is running on the same machine, like in dev
)?;
Ok(metrics)
}

312
src/outpost/event.rs Normal file
View File

@@ -0,0 +1,312 @@
use std::{fmt::Display, sync::Arc};
use ak_common::{Arbiter, Tasks, VERSION, api, arbiter, authentik_build_hash};
use axum::http::{HeaderValue, header::AUTHORIZATION};
use eyre::{Result, eyre};
use futures::{SinkExt as _, StreamExt as _};
use nix::unistd::gethostname;
use serde::{Deserialize, Serialize};
use serde_repr::{Deserialize_repr, Serialize_repr};
use time::UtcDateTime;
use tokio::{
signal::unix::SignalKind,
time::{Duration, interval, sleep},
};
use tokio_tungstenite::tungstenite::{Message, client::IntoClientRequest as _};
use tracing::{debug, info, instrument, trace, warn};
use url::Url;
use crate::outpost::{Outpost, OutpostController};
#[derive(Serialize_repr, Deserialize_repr, PartialEq, Debug, Clone, Copy, Eq)]
#[repr(u8)]
enum EventKind {
/// Code used to acknowledge a previous message.
Ack = 0,
/// Code used to send a healthcheck keepalive.
Hello = 1,
/// Code received to trigger a config update.
TriggerUpdate = 2,
/// Code received to trigger some provider specific function.
ProviderSpecific = 3,
/// Code received to identify the end of a session.
SessionEnd = 4,
}
impl Display for EventKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Ack => write!(f, "Ack"),
Self::Hello => write!(f, "Hello"),
Self::TriggerUpdate => write!(f, "TriggerUpdate"),
Self::ProviderSpecific => write!(f, "ProviderSpecific"),
Self::SessionEnd => write!(f, "SessionEnd"),
}
}
}
#[derive(Serialize, Deserialize)]
struct Event {
instruction: EventKind,
args: serde_json::Value,
}
#[derive(Debug, Deserialize)]
pub(crate) struct EventSessionEnd {
session_id: String,
}
fn build_ws_url(mut url: Url, outpost_pk: &str, instance_uuid: &str, attempt: u32) -> Result<Url> {
let ws_scheme = match url.scheme() {
"https" => "wss",
"http" => "ws",
other => return Err(eyre!("Unsupported scheme for WebSocket URL: {other}")),
};
url.set_scheme(ws_scheme)
.map_err(|()| eyre!("Failed to set URL scheme to {ws_scheme}"))?;
url.set_path(&format!("{}ws/outpost/{outpost_pk}/", url.path()));
url.query_pairs_mut()
.append_pair("instance_uuid", instance_uuid)
.append_pair("attempt", &attempt.to_string());
Ok(url)
}
fn hello_args(instance_uuid: &str) -> serde_json::Value {
let raw_hostname = gethostname().unwrap_or_default();
let hostname = raw_hostname.to_string_lossy();
serde_json::json!({
"version": VERSION,
"buildHash": authentik_build_hash(None),
"uuid": instance_uuid,
// TODO: rust version and AWS-LC versions
"hostname": hostname,
})
}
#[instrument(skip_all)]
async fn handle_event<O: Outpost>(
controller: Arc<OutpostController>,
outpost: Arc<O>,
event: Event,
) -> Result<()> {
match event.instruction {
EventKind::Ack | EventKind::Hello => {}
EventKind::TriggerUpdate => {
info!("received update trigger, refreshing outpost");
sleep(controller.reload_offset).await;
controller.refresh().await?;
debug!("outpost controller has been refreshed");
outpost.refresh().await?;
debug!("outpost has been refreshed");
#[expect(
clippy::as_conversions,
clippy::cast_precision_loss,
reason = "This is fine, we'll never get big values here."
)]
controller
.m_last_update
.set(UtcDateTime::now().unix_timestamp() as f64);
}
EventKind::SessionEnd => {
let event: EventSessionEnd = serde_json::from_value(event.args)?;
outpost.end_session(event).await?;
}
#[expect(
clippy::unimplemented,
reason = "this is only relevant for the RAC provider"
)]
EventKind::ProviderSpecific => unimplemented!(),
}
Ok(())
}
async fn watch_events_inner<O: Outpost>(
arbiter: Arbiter,
controller: Arc<OutpostController>,
outpost: Arc<O>,
attempt: u32,
) -> Result<()> {
let server_config = api::ServerConfig::new()?;
let ws_url = build_ws_url(
server_config.host,
&controller.outpost.load().pk.to_string(),
&controller.instance_uuid.to_string(),
attempt,
)?;
debug!(url = %ws_url, "connecting to websocket");
let mut request = ws_url.into_client_request()?;
let token = controller
.api_config
.bearer_access_token
.as_deref()
.unwrap_or("");
request.headers_mut().insert(
AUTHORIZATION,
HeaderValue::from_str(&format!("Bearer {token}"))?,
);
let (ws_stream, _response) = tokio_tungstenite::connect_async(request).await?;
let (mut ws_write, mut ws_read) = ws_stream.split();
info!(
outpost = %controller.outpost.load().pk,
"connected to websocket"
);
controller.m_connection.set(1_u8);
let get_refresh_interval = || {
let mut interval = controller.outpost.load().refresh_interval_s;
// Ensure timer interval is not negative or 0.
// If it is, we default to 5 minutes.
if interval <= 0_i32 {
interval = 60_i32 * 5_i32;
}
// Clamp interval to be at least 30 seconds.
if interval < 30_i32 {
interval = 30_i32;
}
// infallible because we bound it to be positive above
Duration::from_secs(interval.try_into().expect("infallible"))
};
let mut refresh_interval = interval(get_refresh_interval());
let mut heartbeat_interval = interval(Duration::from_secs(10));
let mut events_rx = arbiter.events_subscribe();
loop {
tokio::select! {
_ = refresh_interval.tick() => {
info!("refreshing outpost on interval");
if let Err(err) = handle_event(
Arc::clone(&controller),
Arc::clone(&outpost),
Event {
instruction: EventKind::TriggerUpdate,
args: serde_json::Value::Null
}
).await {
warn!(?err, "failed to refresh");
}
refresh_interval = interval(get_refresh_interval());
// Since we re-create the interval, we need to make it tick instantly to avoid
// ending up in a never-ending tick-loop.
refresh_interval.tick().await;
},
_ = heartbeat_interval.tick() => {
let ping = Event {
instruction: EventKind::Hello,
args: hello_args(&controller.instance_uuid.to_string()),
};
ws_write.send(Message::text(serde_json::to_string(&ping)?)).await?;
trace!("sent websocket hello (heartbeat)");
},
Ok(arbiter::Event::Signal(signal)) = events_rx.recv() => {
if signal == SignalKind::user_defined1() {
info!("refreshing outpost on signal");
if let Err(err) = handle_event(
Arc::clone(&controller),
Arc::clone(&outpost),
Event {
instruction: EventKind::TriggerUpdate,
args: serde_json::Value::Null
}
).await {
warn!(?err, "failed to refresh");
}
}
},
msg = ws_read.next() => {
let Some(msg) = msg else {
break;
};
let msg = msg?;
match msg {
Message::Text(text) => {
let Ok(event): Result<Event, _> = serde_json::from_str(&text) else {
warn!(data = text.as_str(), "failed to parse event");
continue;
};
trace!(event = %event.instruction, "received websocket event");
if let Err(err) = handle_event(
Arc::clone(&controller),
Arc::clone(&outpost),
event,
).await {
warn!(?err, "failed to handle event");
}
},
Message::Ping(data) => {
ws_write.send(Message::Pong(data)).await?;
},
Message::Close(_) => {
break;
},
_ => {},
}
},
() = arbiter.shutdown() => break,
}
}
Ok(())
}
async fn watch_events<O: Outpost>(
arbiter: Arbiter,
controller: Arc<OutpostController>,
outpost: Arc<O>,
) -> Result<()> {
const MAX_BACKOFF: Duration = Duration::from_mins(5);
let mut backoff = Duration::from_secs(1);
let mut attempt: u32 = 0;
loop {
tokio::select! {
() = arbiter.shutdown() => break,
res = watch_events_inner(
arbiter.clone(),
Arc::clone(&controller),
Arc::clone(&outpost),
attempt
) => {
controller.m_connection.set(0_u8);
match res {
Ok(()) => debug!("websocket disconnected cleanly"),
Err(err) => warn!(?err, attempt, "websocket error"),
}
info!(attempt, delay = backoff.as_secs(), "reconnecting websocket in {}s...", backoff.as_secs());
tokio::select! {
() = arbiter.shutdown() => break,
() = sleep(backoff) => {}
}
backoff = (backoff * 2).min(MAX_BACKOFF);
attempt += 1;
}
}
}
info!("stopping event watcher");
Ok(())
}
pub(crate) fn start<O: Outpost + 'static>(
tasks: &mut Tasks,
controller: Arc<OutpostController>,
outpost: Arc<O>,
) -> Result<()> {
let arbiter = tasks.arbiter();
tasks
.build_task()
.name(&format!("{}::watch_events", module_path!()))
.spawn(watch_events(arbiter, controller, outpost))?;
Ok(())
}

123
src/outpost/mod.rs Normal file
View File

@@ -0,0 +1,123 @@
use std::{sync::Arc, time::Duration};
use ak_client::{
apis::{configuration::Configuration, outposts_api::outposts_instances_list},
models::Outpost as OutpostModel,
};
use ak_common::{Tasks, VERSION, api, authentik_build_hash};
use arc_swap::ArcSwap;
use eyre::{Result, eyre};
use tracing::{debug, info, instrument};
use uuid::Uuid;
pub(crate) mod event;
#[cfg(feature = "proxy")]
pub(crate) mod proxy;
pub(crate) trait Outpost: Send + Sync + Sized {
const OUTPOST_TYPE: &'static str;
type Cli: Send + Sync;
async fn new(controller: Arc<OutpostController>) -> Result<Self>;
fn start(&self, tasks: &mut Tasks) -> Result<()>;
fn refresh(&self) -> impl Future<Output = Result<()>> + Send;
fn end_session(&self, event: event::EventSessionEnd)
-> impl Future<Output = Result<()>> + Send;
}
#[derive(Debug)]
pub(crate) struct OutpostController {
api_config: Configuration,
outpost: ArcSwap<OutpostModel>,
instance_uuid: Uuid,
reload_offset: Duration,
m_info: metrics::Gauge,
m_last_update: metrics::Gauge,
m_connection: metrics::Gauge,
}
impl OutpostController {
#[instrument(skip_all)]
async fn get_outpost(api_config: &Configuration) -> Result<OutpostModel> {
let outposts = outposts_instances_list(
api_config, None, None, None, None, None, None, None, None, None, None, None, None,
)
.await?;
let Some(outpost) = outposts.results.into_iter().next() else {
return Err(eyre!(
"No outposts found with given token, ensure the given token corresponds to an \
authentik Outpost"
));
};
debug!(name = outpost.name, "fetched outpost configuration");
Ok(outpost)
}
#[instrument(skip_all)]
async fn new<O: Outpost>() -> Result<Self> {
let api_config = api::make_config()?;
let outpost = Self::get_outpost(&api_config).await?;
let instance_uuid = Uuid::new_v4();
let m_labels = [
("outpost_name", outpost.name.clone()),
("outpost_type", O::OUTPOST_TYPE.to_owned()),
("uuid", instance_uuid.to_string()),
("version", VERSION.to_owned()),
("build", authentik_build_hash(None)),
];
metrics::describe_gauge!("authentik_outpost_info", "Outpost info");
let m_info = metrics::gauge!("authentik_outpost_info", &m_labels);
metrics::describe_gauge!("authentik_outpost_last_update", "Time of last update");
let m_last_update = metrics::gauge!("authentik_outpost_last_update", &m_labels);
metrics::describe_gauge!("authentik_outpost_connection", "Connection status");
let m_connection = metrics::gauge!("authentik_outpost_connection", &m_labels);
let reload_offset = Duration::from_secs(rand::random_range(0..10));
let controller = Self {
api_config,
outpost: ArcSwap::from_pointee(outpost),
instance_uuid,
reload_offset,
m_info,
m_last_update,
m_connection,
};
info!(embedded = controller.is_embedded(), "outpost mode");
debug!(?reload_offset, "HA Reload offset");
Ok(controller)
}
fn is_embedded(&self) -> bool {
self.outpost
.load()
.managed
.as_ref()
.and_then(|m| m.as_deref())
.is_some_and(|m| m == "goauthentik.io/outposts/embedded")
}
async fn refresh(&self) -> Result<()> {
let outpost = Self::get_outpost(&self.api_config).await?;
self.outpost.swap(Arc::new(outpost));
Ok(())
}
}
#[instrument(skip_all)]
pub(crate) async fn start<O: Outpost + 'static>(_cli: O::Cli, tasks: &mut Tasks) -> Result<()> {
let controller = Arc::new(OutpostController::new::<O>().await?);
let outpost = Arc::new(O::new(Arc::clone(&controller)).await?);
event::start(tasks, Arc::clone(&controller), Arc::clone(&outpost))?;
outpost.start(tasks)?;
controller.m_info.set(1_u8);
Ok(())
}

View File

@@ -0,0 +1,38 @@
use url::Url;
use ak_client::models::ProxyOutpostConfig;
use eyre::{Result, eyre};
use tracing::instrument;
use crate::outpost::proxy::ProxyOutpost;
const REDIRECT_PARAM: &str = "rd";
const CALLBACK_SIGNATURE: &str = "X-authentik-auth-callback";
const LOGOUT_SIGNATURE: &str = "X-authentik-logout";
pub(super) struct Application {
pub(super) host: String,
}
impl Application {
#[instrument(skip_all)]
pub(super) fn new(
_existing_apps: &ProxyOutpost,
provider: &ProxyOutpostConfig,
) -> Result<Self> {
let external_url = Url::parse(&provider.external_host)?;
let external_host = external_url
.host_str()
.ok_or_else(|| eyre!("no host in external host"))?;
let _redirect_url = {
let mut redirect_url = external_url.join("outpost.goauthentik.io/callback")?;
redirect_url.set_query(Some(&format!("{CALLBACK_SIGNATURE}=true")));
redirect_url
};
Ok(Self {
host: external_host.to_owned(),
})
}
}

151
src/outpost/proxy/mod.rs Normal file
View File

@@ -0,0 +1,151 @@
use ak_axum::extract::host::Host;
use axum::extract::State;
use axum::http::Method;
use axum::routing::any;
use metrics::{Histogram, histogram};
use std::{collections::HashMap, sync::Arc};
use tokio::time::Instant;
use ak_axum::router::wrap_router;
use ak_client::apis::outposts_api::outposts_proxy_list;
use ak_common::{Tasks, api::fetch_all};
use arc_swap::ArcSwap;
use argh::FromArgs;
use axum::Router;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use eyre::Result;
use tracing::{debug, error, info, instrument, warn};
use crate::outpost::proxy::application::Application;
use crate::outpost::{Outpost, OutpostController};
mod application;
#[derive(Debug, Default, FromArgs, PartialEq, Eq)]
/// Run the authentik proxy outpost.
#[argh(subcommand, name = "proxy")]
#[expect(
clippy::empty_structs_with_brackets,
reason = "argh doesn't support unit structs"
)]
pub(crate) struct Cli {}
pub(crate) struct ProxyOutpost {
controller: Arc<OutpostController>,
applications: ArcSwap<HashMap<String, Application>>,
}
impl Outpost for ProxyOutpost {
type Cli = Cli;
const OUTPOST_TYPE: &'static str = "proxy";
#[instrument(skip_all)]
async fn new(controller: Arc<OutpostController>) -> Result<Self> {
Ok(Self {
controller,
applications: ArcSwap::from_pointee(HashMap::with_capacity(0)),
})
}
fn start(&self, _tasks: &mut Tasks) -> Result<()> {
Ok(())
}
#[instrument(skip_all)]
async fn refresh(&self) -> Result<()> {
debug!(
outpost_pk = %self.controller.outpost.load().pk,
"requesting providers for outpost"
);
let providers = fetch_all(
|page| {
outposts_proxy_list(
&self.controller.api_config,
None,
None,
Some(page),
Some(100_i32),
None,
)
},
|r| &r.pagination,
|r| r.results,
)
.await
.inspect_err(|err| error!(?err, "failed to fetch providers"))?;
debug!(count = providers.len(), "fetched providers");
if providers.is_empty() && !self.controller.is_embedded() {
warn!(
"no providers assigned to this outpost, check outpost configuration in authentik"
);
}
for (i, provider) in providers.iter().enumerate() {
debug!(
index = i,
name = provider.name,
external_host = provider.external_host,
assigned_to_app = provider.assigned_application_name,
"provider details"
);
}
let mut apps = HashMap::with_capacity(providers.len());
for provider in providers {
let Ok(application) = Application::new(self, &provider)
.inspect_err(|err| warn!(?err, "failed to setup application, skipping provider"))
else {
continue;
};
info!(
name = provider.name,
host = application.host,
"loaded application"
);
apps.insert(application.host.clone(), application);
}
self.applications.store(Arc::new(apps));
Ok(())
}
async fn end_session(&self, _event: super::event::EventSessionEnd) -> Result<()> {
// todo!()
warn!(?_event, "removing session");
Ok(())
}
}
async fn handle_ping(
method: Method,
Host(host): Host,
State(outpost): State<Arc<ProxyOutpost>>,
) -> impl IntoResponse {
let start = Instant::now();
histogram!(
"authentik_outpost_proxy_request_duration_seconds",
"outpost_name" => outpost.controller.outpost.load().name.clone(),
"method" => method.to_string(),
"host" => host,
"type" => "ping",
)
.record(start.elapsed().as_secs_f64());
StatusCode::NO_CONTENT
}
fn build_router(outpost: Arc<ProxyOutpost>) -> Router {
// TODO: static files
wrap_router(
Router::new()
.route("outpost.goauthentik.io/ping", any(handle_ping))
.with_state(outpost),
true,
)
}

5
src/server/mod.rs Normal file
View File

@@ -0,0 +1,5 @@
use std::{env::temp_dir, path::PathBuf};
pub(crate) fn socket_path() -> PathBuf {
temp_dir().join("authentik.sock")
}

42
src/worker/healthcheck.rs Normal file
View File

@@ -0,0 +1,42 @@
use std::sync::Arc;
use ak_axum::{error::Result, router::wrap_router};
use ak_common::db;
use axum::{Router, extract::State, http::StatusCode, response::IntoResponse, routing::any};
use super::Workers;
async fn health_ready(State(workers): State<Arc<Workers>>) -> Result<StatusCode> {
if !workers.are_alive().await || sqlx::query("SELECT 1").execute(db::get()).await.is_err() {
Ok(StatusCode::SERVICE_UNAVAILABLE)
} else if workers.health_ready().await? {
Ok(StatusCode::OK)
} else {
Ok(StatusCode::SERVICE_UNAVAILABLE)
}
}
async fn health_live(State(workers): State<Arc<Workers>>) -> Result<StatusCode> {
if !workers.are_alive().await || sqlx::query("SELECT 1").execute(db::get()).await.is_err() {
Ok(StatusCode::SERVICE_UNAVAILABLE)
} else if workers.health_live().await? {
Ok(StatusCode::OK)
} else {
Ok(StatusCode::SERVICE_UNAVAILABLE)
}
}
async fn fallback() -> impl IntoResponse {
StatusCode::OK
}
pub(super) fn build_router(workers: Arc<Workers>) -> Router {
wrap_router(
Router::new()
.route("/-/heath/ready/", any(health_ready))
.route("/-/heath/live/", any(health_live))
.fallback(fallback)
.with_state(workers),
true,
)
}

338
src/worker/mod.rs Normal file
View File

@@ -0,0 +1,338 @@
use std::{
env::temp_dir,
os::unix,
path::PathBuf,
process::Stdio,
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
};
use ak_common::{
Event,
arbiter::{Arbiter, Tasks},
config,
mode::Mode,
};
use argh::FromArgs;
use axum::{
body::Body,
http::{Request, header::HOST},
};
use eyre::{Result, eyre};
use hyper_unix_socket::UnixSocketConnector;
use hyper_util::{client::legacy::Client, rt::TokioExecutor};
use nix::{
sys::signal::{Signal, kill},
unistd::Pid,
};
use tokio::{
net::UnixStream,
process::{Child, Command},
signal::unix::SignalKind,
sync::Mutex,
time::{Duration, interval},
};
use tracing::{info, trace, warn};
use crate::server::socket_path;
mod healthcheck;
mod worker_status;
#[derive(Debug, Default, FromArgs, PartialEq, Eq)]
/// Run the authentik worker.
#[argh(subcommand, name = "worker")]
#[expect(
clippy::empty_structs_with_brackets,
reason = "argh doesn't support unit structs"
)]
pub(crate) struct Cli {}
const INITIAL_WORKER_ID: usize = 1000;
static INITIAL_WORKER_READY: AtomicBool = AtomicBool::new(false);
pub(crate) struct Worker {
worker: Child,
client: Client<UnixSocketConnector<PathBuf>, Body>,
socket_path: PathBuf,
}
impl Worker {
fn new(worker_id: usize, socket_path: PathBuf) -> Result<Self> {
info!(worker_id, "starting worker");
let mut cmd = Command::new("python");
cmd.arg("-m");
cmd.arg("lifecycle.worker_process");
cmd.arg(worker_id.to_string());
cmd.arg(&socket_path);
let client = Client::builder(TokioExecutor::new())
.pool_idle_timeout(Duration::from_mins(1))
.set_host(false)
.build(UnixSocketConnector::new(socket_path.clone()));
Ok(Self {
worker: cmd
.kill_on_drop(true)
.stdin(Stdio::null())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.spawn()?,
client,
socket_path,
})
}
async fn shutdown(&mut self, signal: Signal) -> Result<()> {
trace!(
signal = signal.as_str(),
"sending shutdown signal to worker"
);
if let Some(id) = self.worker.id() {
kill(Pid::from_raw(id.cast_signed()), signal)?;
}
self.worker.wait().await?;
Ok(())
}
async fn graceful_shutdown(&mut self) -> Result<()> {
info!("gracefully shutting down worker");
self.shutdown(Signal::SIGTERM).await
}
async fn fast_shutdown(&mut self) -> Result<()> {
info!("immediately shutting down worker");
self.shutdown(Signal::SIGINT).await
}
fn is_alive(&mut self) -> bool {
let try_wait = self.worker.try_wait();
match try_wait {
Ok(Some(code)) => {
warn!(?code, "worker has exited");
false
}
Ok(None) => true,
Err(err) => {
warn!(
?err,
"failed to check the status of worker process, ignoring"
);
true
}
}
}
async fn is_socket_ready(&self) -> bool {
let result = UnixStream::connect(&self.socket_path).await;
trace!(?result, "checking if worker socket is ready");
result.is_ok()
}
async fn health_live(&self) -> Result<bool> {
let req = Request::builder()
.method("GET")
.uri("http://localhost:8000/-/health/live/")
.header(HOST, "localhost")
.body(Body::from(""))?;
Ok(self.client.request(req).await?.status().is_success())
}
async fn health_ready(&self) -> Result<bool> {
let req = Request::builder()
.method("GET")
.uri("http://localhost:8000/-/health/ready/")
.header(HOST, "localhost")
.body(Body::from(""))?;
Ok(self.client.request(req).await?.status().is_success())
}
async fn notify_metrics(&self) -> Result<()> {
let req = Request::builder()
.method("GET")
.uri("http://localhost:8000/-/metrics/")
.header(HOST, "localhost")
.body(Body::from(""))?;
self.client.request(req).await?;
Ok(())
}
}
impl Drop for Worker {
fn drop(&mut self) {
if let Err(err) = std::fs::remove_file(&self.socket_path) {
trace!(?err, "failed to remove socket, ignoring");
}
}
}
pub(crate) struct Workers(Mutex<Vec<Worker>>);
impl Workers {
fn new() -> Result<Self> {
let mut workers = Vec::with_capacity(config::get().worker.processes.get());
workers.push(Worker::new(
INITIAL_WORKER_ID,
temp_dir().join(format!("authentik-worker-{INITIAL_WORKER_ID}.sock")),
)?);
Ok(Self(Mutex::new(workers)))
}
async fn start_other_workers(&self) -> Result<()> {
let mut workers = self.0.lock().await;
while workers.len() != config::get().worker.processes.get() {
let worker_id = INITIAL_WORKER_ID + workers.len();
workers.push(Worker::new(
worker_id,
temp_dir().join(format!("authentik-worker-{worker_id}.sock")),
)?);
}
Ok(())
}
async fn graceful_shutdown(&self) -> Result<()> {
let mut results = Vec::with_capacity(self.0.lock().await.capacity());
for worker in self.0.lock().await.iter_mut() {
results.push(worker.graceful_shutdown().await);
}
results.into_iter().find(Result::is_err).unwrap_or(Ok(()))
}
async fn fast_shutdown(&self) -> Result<()> {
let mut results = Vec::with_capacity(self.0.lock().await.capacity());
for worker in self.0.lock().await.iter_mut() {
results.push(worker.fast_shutdown().await);
}
results.into_iter().find(Result::is_err).unwrap_or(Ok(()))
}
async fn are_alive(&self) -> bool {
for worker in self.0.lock().await.iter_mut() {
if !worker.is_alive() {
return false;
}
}
true
}
async fn is_socket_ready(&self) -> bool {
if let Some(initial_worker) = self.0.lock().await.iter_mut().next() {
return initial_worker.is_socket_ready().await;
}
false
}
async fn health_live(&self) -> Result<bool> {
for worker in self.0.lock().await.iter() {
if !worker.health_live().await? {
return Ok(false);
}
}
Ok(true)
}
async fn health_ready(&self) -> Result<bool> {
for worker in self.0.lock().await.iter() {
if !worker.health_ready().await? {
return Ok(false);
}
}
Ok(true)
}
pub(crate) async fn notify_metrics(&self) -> Result<()> {
if let Some(worker) = self.0.lock().await.iter().next() {
worker.notify_metrics().await?;
}
Ok(())
}
}
async fn watch_workers(arbiter: Arbiter, workers: Arc<Workers>) -> Result<()> {
info!("starting worker watcher");
let mut events_rx = arbiter.events_subscribe();
let mut check_interval = interval(Duration::from_secs(5));
let mut start_interval = interval(Duration::from_secs(1));
loop {
tokio::select! {
Ok(Event::Signal(signal)) = events_rx.recv() => {
if signal == SignalKind::user_defined2() && !INITIAL_WORKER_READY.load(Ordering::Relaxed) {
info!("worker notified us ready, marked ready for operation");
INITIAL_WORKER_READY.store(true, Ordering::Relaxed);
workers.start_other_workers().await?;
}
},
_ = start_interval.tick(), if !INITIAL_WORKER_READY.load(Ordering::Relaxed) => {
// On some platforms the SIGUSR1 can be missed.
// Fall back to probing the worker unix socket and mark ready once it accepts connections.
if workers.is_socket_ready().await {
info!("worker socket is accepting connections, marked ready for operation");
INITIAL_WORKER_READY.store(true, Ordering::Relaxed);
workers.start_other_workers().await?;
}
},
_ = check_interval.tick() => {
if !workers.are_alive().await {
return Err(eyre!("one or more workers have exited unexpectedly"));
}
},
() = arbiter.fast_shutdown() => {
workers.fast_shutdown().await?;
return Ok(());
},
() = arbiter.graceful_shutdown() => {
workers.graceful_shutdown().await?;
return Ok(());
},
}
}
}
pub(crate) fn start(_cli: Cli, tasks: &mut Tasks) -> Result<Arc<Workers>> {
let arbiter = tasks.arbiter();
let workers = Arc::new(Workers::new()?);
tasks
.build_task()
.name(&format!("{}::watch_workers", module_path!()))
.spawn(watch_workers(arbiter.clone(), Arc::clone(&workers)))?;
tasks
.build_task()
.name(&format!("{}::worker_status::run", module_path!()))
.spawn(worker_status::run(arbiter))?;
// Only run HTTP server in worker mode, in allinone mode, they're handled by the server.
if Mode::get() == Mode::Worker {
let router = healthcheck::build_router(Arc::clone(&workers));
for addr in config::get().listen.http.iter().copied() {
ak_axum::server::start_plain(
tasks,
"worker",
router.clone(),
addr,
true, /* Allow failure in case the server is running on the same machine, like
* in dev. */
)?;
}
ak_axum::server::start_unix(
tasks,
"worker",
router,
unix::net::SocketAddr::from_pathname(socket_path())?,
true, // Allow failure in case the server is running on the same machine, like in dev.
)?;
}
Ok(workers)
}

View File

@@ -0,0 +1,48 @@
use ak_common::{arbiter::Arbiter, authentik_full_version, db};
use eyre::Result;
use nix::unistd::gethostname;
use tokio::time::{Duration, interval, sleep};
use tracing::warn;
use uuid::Uuid;
async fn keep(arbiter: Arbiter, id: Uuid, hostname: &str, version: &str) -> Result<()> {
let query = "
INSERT INTO authentik_tasks_workerstatus (id, hostname, version, last_seen)
VALUES ($1, $2, $3, NOW())
ON CONFLICT (id) DO UPDATE SET last_seen = NOW()
";
let mut keep_interval = interval(Duration::from_secs(30));
loop {
tokio::select! {
_ = keep_interval.tick() => {
sqlx::query(query)
.bind(id)
.bind(hostname)
.bind(version)
.execute(db::get())
.await?;
},
() = arbiter.shutdown() => return Ok(()),
}
}
}
pub(super) async fn run(arbiter: Arbiter) -> Result<()> {
let id = Uuid::new_v4();
let raw_hostname = gethostname()?;
let hostname = raw_hostname.to_string_lossy();
let version = authentik_full_version();
loop {
if let Err(err) = keep(arbiter.clone(), id, hostname.as_ref(), &version).await {
warn!(?err, "failed to update worker status in database");
}
// `keep` returned. It's either an error in which case we wait 10s before
// retrying.
// Or we actually need to exit, which will happen here.
tokio::select! {
() = sleep(Duration::from_secs(10)) => {},
() = arbiter.shutdown() => return Ok(()),
}
}
}

134
uv.lock generated
View File

@@ -316,7 +316,7 @@ dev = [
requires-dist = [
{ name = "ak-guardian", editable = "packages/ak-guardian" },
{ name = "argon2-cffi", specifier = "==25.1.0" },
{ name = "cachetools", specifier = "==7.0.5" },
{ name = "cachetools", specifier = "==7.0.6" },
{ name = "channels", specifier = "==4.3.2" },
{ name = "cryptography", specifier = "==46.0.7" },
{ name = "dacite", specifier = "==1.9.2" },
@@ -358,7 +358,7 @@ requires-dist = [
{ name = "packaging", specifier = "==26.1" },
{ name = "paramiko", specifier = "==4.0.0" },
{ name = "psycopg", extras = ["c", "pool"], specifier = "==3.3.3" },
{ name = "pydantic", specifier = "==2.13.2" },
{ name = "pydantic", specifier = "==2.13.3" },
{ name = "pydantic-scim", specifier = "==0.0.8" },
{ name = "pyjwt", specifier = "==2.11.0" },
{ name = "pyrad", specifier = "==2.5.4" },
@@ -688,11 +688,11 @@ wheels = [
[[package]]
name = "cachetools"
version = "7.0.5"
version = "7.0.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/af/dd/57fe3fdb6e65b25a5987fd2cdc7e22db0aef508b91634d2e57d22928d41b/cachetools-7.0.5.tar.gz", hash = "sha256:0cd042c24377200c1dcd225f8b7b12b0ca53cc2c961b43757e774ebe190fd990", size = 37367, upload-time = "2026-03-09T20:51:29.451Z" }
sdist = { url = "https://files.pythonhosted.org/packages/76/7b/1755ed2c6bfabd1d98b37ae73152f8dcf94aa40fee119d163c19ed484704/cachetools-7.0.6.tar.gz", hash = "sha256:e5d524d36d65703a87243a26ff08ad84f73352adbeafb1cde81e207b456aaf24", size = 37526, upload-time = "2026-04-20T19:02:23.289Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/06/f3/39cf3367b8107baa44f861dc802cbf16263c945b62d8265d36034fc07bea/cachetools-7.0.5-py3-none-any.whl", hash = "sha256:46bc8ebefbe485407621d0a4264b23c080cedd913921bad7ac3ed2f26c183114", size = 13918, upload-time = "2026-03-09T20:51:27.33Z" },
{ url = "https://files.pythonhosted.org/packages/fe/c4/cf76242a5da1410917107ff14551764aa405a5fd10cd10cf9a5ca8fa77f4/cachetools-7.0.6-py3-none-any.whl", hash = "sha256:4e94956cfdd3086f12042cdd29318f5ced3893014f7d0d059bf3ead3f85b7f8b", size = 13976, upload-time = "2026-04-20T19:02:21.187Z" },
]
[[package]]
@@ -1116,7 +1116,7 @@ dependencies = [
{ name = "django" },
{ name = "django-pglock" },
{ name = "django-pgtrigger" },
{ name = "dramatiq", extra = ["watch"] },
{ name = "dramatiq" },
{ name = "structlog" },
{ name = "tenacity" },
]
@@ -1127,7 +1127,7 @@ requires-dist = [
{ name = "django", specifier = ">=4.2,<6.0" },
{ name = "django-pglock", specifier = ">=1.7,<2" },
{ name = "django-pgtrigger", specifier = ">=4,<5" },
{ name = "dramatiq", extras = ["watch"], specifier = ">=1.17,<1.18" },
{ name = "dramatiq", specifier = ">=1.17,<1.18" },
{ name = "structlog", specifier = ">=25,<26" },
{ name = "tenacity", specifier = ">=9,<10" },
]
@@ -1375,12 +1375,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/36/925c7afd5db4f1a3f00676b9c3c58f31ff7ae29a347282d86c8d429280a5/dramatiq-1.17.1-py3-none-any.whl", hash = "sha256:951cdc334478dff8e5150bb02a6f7a947d215ee24b5aedaf738eff20e17913df", size = 120382, upload-time = "2024-10-26T05:09:26.436Z" },
]
[package.optional-dependencies]
watch = [
{ name = "watchdog" },
{ name = "watchdog-gevent" },
]
[[package]]
name = "drf-jsonschema-serializer"
version = "3.0.0"
@@ -1572,28 +1566,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/15/cf2a69ade4b194aa524ac75112d5caac37414b20a3a03e6865dfe0bd1539/geopy-2.4.1-py3-none-any.whl", hash = "sha256:ae8b4bc5c1131820f4d75fce9d4aaaca0c85189b3aa5d64c3dcaf5e3b7b882a7", size = 125437, upload-time = "2023-11-23T21:49:30.421Z" },
]
[[package]]
name = "gevent"
version = "25.9.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation == 'CPython' and sys_platform == 'win32'" },
{ name = "greenlet", marker = "platform_python_implementation == 'CPython'" },
{ name = "zope-event" },
{ name = "zope-interface" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9e/48/b3ef2673ffb940f980966694e40d6d32560f3ffa284ecaeb5ea3a90a6d3f/gevent-25.9.1.tar.gz", hash = "sha256:adf9cd552de44a4e6754c51ff2e78d9193b7fa6eab123db9578a210e657235dd", size = 5059025, upload-time = "2025-09-17T16:15:34.528Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/15/1a/948f8167b2cdce573cf01cec07afc64d0456dc134b07900b26ac7018b37e/gevent-25.9.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:1a3fe4ea1c312dbf6b375b416925036fe79a40054e6bf6248ee46526ea628be1", size = 2982934, upload-time = "2025-09-17T14:54:11.302Z" },
{ url = "https://files.pythonhosted.org/packages/9b/ec/726b146d1d3aad82e03d2e1e1507048ab6072f906e83f97f40667866e582/gevent-25.9.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0adb937f13e5fb90cca2edf66d8d7e99d62a299687400ce2edee3f3504009356", size = 1813982, upload-time = "2025-09-17T15:41:28.506Z" },
{ url = "https://files.pythonhosted.org/packages/35/5d/5f83f17162301662bd1ce702f8a736a8a8cac7b7a35e1d8b9866938d1f9d/gevent-25.9.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:427f869a2050a4202d93cf7fd6ab5cffb06d3e9113c10c967b6e2a0d45237cb8", size = 1894902, upload-time = "2025-09-17T15:49:03.702Z" },
{ url = "https://files.pythonhosted.org/packages/83/cd/cf5e74e353f60dab357829069ffc300a7bb414c761f52cf8c0c6e9728b8d/gevent-25.9.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c049880175e8c93124188f9d926af0a62826a3b81aa6d3074928345f8238279e", size = 1861792, upload-time = "2025-09-17T15:49:23.279Z" },
{ url = "https://files.pythonhosted.org/packages/dd/65/b9a4526d4a4edce26fe4b3b993914ec9dc64baabad625a3101e51adb17f3/gevent-25.9.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b5a67a0974ad9f24721034d1e008856111e0535f1541499f72a733a73d658d1c", size = 2113215, upload-time = "2025-09-17T15:15:16.34Z" },
{ url = "https://files.pythonhosted.org/packages/e5/be/7d35731dfaf8370795b606e515d964a0967e129db76ea7873f552045dd39/gevent-25.9.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1d0f5d8d73f97e24ea8d24d8be0f51e0cf7c54b8021c1fddb580bf239474690f", size = 1833449, upload-time = "2025-09-17T15:52:43.75Z" },
{ url = "https://files.pythonhosted.org/packages/65/58/7bc52544ea5e63af88c4a26c90776feb42551b7555a1c89c20069c168a3f/gevent-25.9.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ddd3ff26e5c4240d3fbf5516c2d9d5f2a998ef87cfb73e1429cfaeaaec860fa6", size = 2176034, upload-time = "2025-09-17T15:24:15.676Z" },
{ url = "https://files.pythonhosted.org/packages/c2/69/a7c4ba2ffbc7c7dbf6d8b4f5d0f0a421f7815d229f4909854266c445a3d4/gevent-25.9.1-cp314-cp314-win_amd64.whl", hash = "sha256:bb63c0d6cb9950cc94036a4995b9cc4667b8915366613449236970f4394f94d7", size = 1703019, upload-time = "2025-09-17T19:30:55.272Z" },
]
[[package]]
name = "google-api-core"
version = "2.29.0"
@@ -2824,7 +2796,7 @@ wheels = [
[[package]]
name = "pydantic"
version = "2.13.2"
version = "2.13.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
@@ -2832,9 +2804,9 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/09/e5/06d23afac9973109d1e3c8ad38e1547a12e860610e327c05ee686827dc37/pydantic-2.13.2.tar.gz", hash = "sha256:b418196607e61081c3226dcd4f0672f2a194828abb9109e9cfb84026564df2d1", size = 843836, upload-time = "2026-04-17T09:31:59.636Z" }
sdist = { url = "https://files.pythonhosted.org/packages/d9/e4/40d09941a2cebcb20609b86a559817d5b9291c49dd6f8c87e5feffbe703a/pydantic-2.13.3.tar.gz", hash = "sha256:af09e9d1d09f4e7fe37145c1f577e1d61ceb9a41924bf0094a36506285d0a84d", size = 844068, upload-time = "2026-04-20T14:46:43.632Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/77/ca/b45c378e6e8d0b90577288b533e04e95b7afd61bb1d51b6c263176435489/pydantic-2.13.2-py3-none-any.whl", hash = "sha256:a525087f4c03d7e7456a3de89b64cd693d2229933bb1068b9af6befd5563694e", size = 471947, upload-time = "2026-04-17T09:31:57.541Z" },
{ url = "https://files.pythonhosted.org/packages/f3/0a/fd7d723f8f8153418fb40cf9c940e82004fce7e987026b08a68a36dd3fe7/pydantic-2.13.3-py3-none-any.whl", hash = "sha256:6db14ac8dfc9a1e57f87ea2c0de670c251240f43cb0c30a5130e9720dc612927", size = 471981, upload-time = "2026-04-20T14:46:41.402Z" },
]
[package.optional-dependencies]
@@ -2844,43 +2816,43 @@ email = [
[[package]]
name = "pydantic-core"
version = "2.46.2"
version = "2.46.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/43/bb/4742f05b739b2478459bb16fa8470549518c802e06ddcf3f106c5081315e/pydantic_core-2.46.2.tar.gz", hash = "sha256:37bb079f9ee3f1a519392b73fda2a96379b31f2013c6b467fe693e7f2987f596", size = 471269, upload-time = "2026-04-17T09:10:07.017Z" }
sdist = { url = "https://files.pythonhosted.org/packages/2a/ef/f7abb56c49382a246fd2ce9c799691e3c3e7175ec74b14d99e798bcddb1a/pydantic_core-2.46.3.tar.gz", hash = "sha256:41c178f65b8c29807239d47e6050262eb6bf84eb695e41101e62e38df4a5bc2c", size = 471412, upload-time = "2026-04-20T14:40:56.672Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d0/96/a50ccb6b539ae780f73cea74905468777680e30c6c3bdf714b9d4c116ea0/pydantic_core-2.46.2-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:4f59b45f3ef8650c0c736a57f59031d47ed9df4c0a64e83796849d7d14863a2d", size = 2097111, upload-time = "2026-04-17T09:10:49.617Z" },
{ url = "https://files.pythonhosted.org/packages/34/5f/fdead7b3afa822ab6e5a18ee0ecffd54937de1877c01ed13a342e0fb3f07/pydantic_core-2.46.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3a075a29ebef752784a91532a1a85be6b234ccffec0a9d7978a92696387c3da6", size = 1951904, upload-time = "2026-04-17T09:12:32.062Z" },
{ url = "https://files.pythonhosted.org/packages/95/e0/1c5d547e550cdab1bec737492aa08865337af6fe7fc9b96f7f45f17d9519/pydantic_core-2.46.2-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d12d786e30c04a9d307c5d7080bf720d9bac7f1668191d8e37633a9562749e2", size = 1978667, upload-time = "2026-04-17T09:11:35.589Z" },
{ url = "https://files.pythonhosted.org/packages/0e/cb/665ce629e218c8228302cb94beff4f6531082a2c87d3ecc3d5e63a26f392/pydantic_core-2.46.2-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0d5e6d6343b0b5dcacb3503b5de90022968da8ed0ab9ab39d3eda71c20cbf84e", size = 2046721, upload-time = "2026-04-17T09:11:47.725Z" },
{ url = "https://files.pythonhosted.org/packages/77/e9/6cb2cf60f54c1472bbdfce19d957553b43dbba79d1d7b2930a195c594785/pydantic_core-2.46.2-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:233eebac0999b6b9ba76eb56f3ec8fce13164aa16b6d2225a36a79e0f95b5973", size = 2228483, upload-time = "2026-04-17T09:12:08.837Z" },
{ url = "https://files.pythonhosted.org/packages/0d/2a/93e018dd5571f781ebaeda8c0cf65398489d5bee9b1f484df0b6149b43b9/pydantic_core-2.46.2-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9cc0eee720dd2f14f3b7c349469402b99ad81a174ab49d3533974529e9d93992", size = 2294663, upload-time = "2026-04-17T09:12:52.053Z" },
{ url = "https://files.pythonhosted.org/packages/5e/4f/49e57ca55c770c93d9bb046666a54949b42e3c9099a0c5fe94557873fe30/pydantic_core-2.46.2-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83ee76bf2c9910513dbc19e7d82367131fa7508dedd6186a462393071cc11059", size = 2098742, upload-time = "2026-04-17T09:13:45.472Z" },
{ url = "https://files.pythonhosted.org/packages/c6/b0/6e46b5cd3332af665f794b8cdeea206618a8630bd9e7bcc36864518fce81/pydantic_core-2.46.2-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:d61db38eb4ee5192f0c261b7f2d38e420b554df8912245e3546aee5c45e2fd78", size = 2125922, upload-time = "2026-04-17T09:12:54.304Z" },
{ url = "https://files.pythonhosted.org/packages/06/d1/40850c81585be443a2abfdf7f795f8fae831baf8e2f9b2133c8246ac671c/pydantic_core-2.46.2-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8f09a713d17bcd55da8ab02ebd9110c5246a49c44182af213b5212800af8bc83", size = 2183000, upload-time = "2026-04-17T09:10:59.027Z" },
{ url = "https://files.pythonhosted.org/packages/04/af/8493d7dfa03ebb7866909e577c6aa65ea0de7377b86023cc51d0c8e11db3/pydantic_core-2.46.2-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:30cacc5fb696e64b8ef6fd31d9549d394dd7d52760db072eecb98e37e3af1677", size = 2180335, upload-time = "2026-04-17T09:12:57.01Z" },
{ url = "https://files.pythonhosted.org/packages/72/5b/1f6a344c4ffdf284da41c6067b82d5ebcbd11ce1b515ae4b662d4adb6f61/pydantic_core-2.46.2-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:7ccfb105fcfe91a22bbb5563ad3dc124bc1aa75bfd2e53a780ab05f78cdf6108", size = 2330002, upload-time = "2026-04-17T09:12:02.958Z" },
{ url = "https://files.pythonhosted.org/packages/25/ff/9a694126c12d6d2f48a0cafa6f8eef88ef0d8825600e18d03ff2e896c3b2/pydantic_core-2.46.2-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:13ffef637dc8370c249e5b26bd18e9a80a4fca3d809618c44e18ec834a7ca7a8", size = 2359920, upload-time = "2026-04-17T09:10:27.764Z" },
{ url = "https://files.pythonhosted.org/packages/51/c8/3a35c763d68a9cb2675eb10ef242cf66c5d4701b28ae12e688d67d2c180e/pydantic_core-2.46.2-cp314-cp314-win32.whl", hash = "sha256:1b0ab6d756ca2704a938e6c31b53f290c2f9c10d3914235410302a149de1a83e", size = 1953701, upload-time = "2026-04-17T09:13:30.021Z" },
{ url = "https://files.pythonhosted.org/packages/1a/6a/f2726a780365f7dfd89d62036f984f7acb99978c60c5e1fa7c0cb898ed11/pydantic_core-2.46.2-cp314-cp314-win_amd64.whl", hash = "sha256:99ebade8c9ada4df975372d8dd25883daa0e379a05f1cd0c99aa0c04368d01a6", size = 2071867, upload-time = "2026-04-17T09:10:39.205Z" },
{ url = "https://files.pythonhosted.org/packages/e1/79/76baacb9feba3d7c399b245ca1a29c74ea0db04ea693811374827eec2290/pydantic_core-2.46.2-cp314-cp314-win_arm64.whl", hash = "sha256:de87422197cf7f83db91d89c86a21660d749b3cd76cd8a45d115b8e675670f02", size = 2017252, upload-time = "2026-04-17T09:10:26.175Z" },
{ url = "https://files.pythonhosted.org/packages/f1/3b/77c26938f817668d9ad9bab1a905cb23f11d9a3d4bf724d429b3e55a8eaf/pydantic_core-2.46.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:236f22b4a206b5b61db955396b7cf9e2e1ff77f372efe9570128ccfcd6a525eb", size = 2094545, upload-time = "2026-04-17T09:12:19.339Z" },
{ url = "https://files.pythonhosted.org/packages/fe/de/42c13f590e3c260966aa49bcdb1674774f975467c49abd51191e502bea28/pydantic_core-2.46.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c2012f64d2cd7cca50f49f22445aa5a88691ac2b4498ee0a9a977f8ca4f7289f", size = 1933953, upload-time = "2026-04-17T09:09:55.889Z" },
{ url = "https://files.pythonhosted.org/packages/4e/84/ebe3ebb3e2d8db656937cfa6f97f544cb7132f2307a4a7dfdcd0ea102a12/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d07d6c63106d3a9c9a333e2636f9c82c703b1a9e3b079299e58747964e4fdb72", size = 1974435, upload-time = "2026-04-17T09:10:12.371Z" },
{ url = "https://files.pythonhosted.org/packages/b9/15/0bf51ca6709477cd4ef86148b6d7844f3308f029eac361dd0383f1e17b1a/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c326a2b4b85e959d9a1fc3a11f32f84611b6ec07c053e1828a860edf8d068208", size = 2031113, upload-time = "2026-04-17T09:10:00.752Z" },
{ url = "https://files.pythonhosted.org/packages/02/ae/b7b5af9b79db036d9e61a44c481c17a213dc8fc4b8b71fe6875a72fc778b/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac8a65e798f2462552c00d2e013d532c94d646729dda98458beaf51f9ec7b120", size = 2236325, upload-time = "2026-04-17T09:10:33.227Z" },
{ url = "https://files.pythonhosted.org/packages/a6/ae/ecef7477b5a03d4a499708f7e75d2836452ebb70b776c2d64612b334f57a/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5a3c2bc1cc8164bedbc160b7bb1e8cc1e8b9c27f69ae4f9ae2b976cdae02b2dd", size = 2278135, upload-time = "2026-04-17T09:10:23.287Z" },
{ url = "https://files.pythonhosted.org/packages/db/e4/2f9d82faa47af6c39fc3f120145fd915971e1e0cb6b55b494fad9fdf8275/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e69aa5e10b7e8b1bb4a6888650fd12fcbf11d396ca11d4a44de1450875702830", size = 2109071, upload-time = "2026-04-17T09:11:06.149Z" },
{ url = "https://files.pythonhosted.org/packages/f1/9c/677cf10873fbd0b116575ab7b97c90482b21564f8a8040beb18edef7a577/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4e6df5c3301e65fb42bc5338bf9a1027a02b0a31dc7f54c33775229af474daf0", size = 2106028, upload-time = "2026-04-17T09:10:51.525Z" },
{ url = "https://files.pythonhosted.org/packages/d6/53/6a06183544daba51c059123a2064a99039df25f115a06bdb26f2ea177038/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2c2f6e32548ac8d559b47944effcf8ae4d81c161f6b6c885edc53bc08b8f192d", size = 2164816, upload-time = "2026-04-17T09:11:56.187Z" },
{ url = "https://files.pythonhosted.org/packages/57/6f/10fcdd9e3eca66fc828eef0f6f5850f2dd3bca2c59e6e041fb8bc3da39be/pydantic_core-2.46.2-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:b089a81c58e6ea0485562bbbbbca4f65c0549521606d5ef27fba217aac9b665a", size = 2166130, upload-time = "2026-04-17T09:10:03.804Z" },
{ url = "https://files.pythonhosted.org/packages/29/83/92d3fd0e0156cad2e3cb5c26de73794af78ac9fa0c22ab666e566dd67061/pydantic_core-2.46.2-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:7f700a6d6f64112ae9193709b84303bbab84424ad4b47d0253301aabce9dfc70", size = 2316605, upload-time = "2026-04-17T09:12:45.249Z" },
{ url = "https://files.pythonhosted.org/packages/97/f1/facffdb970981068219582e499b8d0871ed163ffcc6b347de5c412669e4c/pydantic_core-2.46.2-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:67db6814beaa5fefe91101ec7eb9efda613795767be96f7cf58b1ca8c9ca9972", size = 2358385, upload-time = "2026-04-17T09:09:54.657Z" },
{ url = "https://files.pythonhosted.org/packages/8b/a1/b8160b2f22b2199467bc68581a4ed380643c16b348a27d6165c6c242d694/pydantic_core-2.46.2-cp314-cp314t-win32.whl", hash = "sha256:32fbc7447be8e3be99bf7869f7066308f16be55b61f9882c2cefc7931f5c7664", size = 1942373, upload-time = "2026-04-17T09:12:59.594Z" },
{ url = "https://files.pythonhosted.org/packages/0d/90/db89acabe5b150e11d1b59fe3d947dda2ef6abbfef5c82f056ff63802f5d/pydantic_core-2.46.2-cp314-cp314t-win_amd64.whl", hash = "sha256:b317a2b97019c0b95ce99f4f901ae383f40132da6706cdf1731066a73394c25c", size = 2052078, upload-time = "2026-04-17T09:10:19.96Z" },
{ url = "https://files.pythonhosted.org/packages/97/32/e19b83ceb07a3f1bb21798407790bbc9a31740158fd132b94139cb84e16c/pydantic_core-2.46.2-cp314-cp314t-win_arm64.whl", hash = "sha256:7dcb9d40930dfad7ab6b20bcc6ca9d2b030b0f347a0cd9909b54bd53ead521b1", size = 2016941, upload-time = "2026-04-17T09:12:34.447Z" },
{ url = "https://files.pythonhosted.org/packages/7f/db/a7bcb4940183fda36022cd18ba8dd12f2dff40740ec7b58ce7457befa416/pydantic_core-2.46.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:afa3aa644f74e290cdede48a7b0bee37d1c35e71b05105f6b340d484af536d9b", size = 2097614, upload-time = "2026-04-20T14:44:38.374Z" },
{ url = "https://files.pythonhosted.org/packages/24/35/e4066358a22e3e99519db370494c7528f5a2aa1367370e80e27e20283543/pydantic_core-2.46.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ced3310e51aa425f7f77da8bbbb5212616655bedbe82c70944320bc1dbe5e018", size = 1951896, upload-time = "2026-04-20T14:40:53.996Z" },
{ url = "https://files.pythonhosted.org/packages/87/92/37cf4049d1636996e4b888c05a501f40a43ff218983a551d57f9d5e14f0d/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e29908922ce9da1a30b4da490bd1d3d82c01dcfdf864d2a74aacee674d0bfa34", size = 1979314, upload-time = "2026-04-20T14:41:49.446Z" },
{ url = "https://files.pythonhosted.org/packages/d8/36/9ff4d676dfbdfb2d591cf43f3d90ded01e15b1404fd101180ed2d62a2fd3/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0c9ff69140423eea8ed2d5477df3ba037f671f5e897d206d921bc9fdc39613e7", size = 2056133, upload-time = "2026-04-20T14:42:23.574Z" },
{ url = "https://files.pythonhosted.org/packages/bc/f0/405b442a4d7ba855b06eec8b2bf9c617d43b8432d099dfdc7bf999293495/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b675ab0a0d5b1c8fdb81195dc5bcefea3f3c240871cdd7ff9a2de8aa50772eb2", size = 2228726, upload-time = "2026-04-20T14:44:22.816Z" },
{ url = "https://files.pythonhosted.org/packages/e7/f8/65cd92dd5a0bd89ba277a98ecbfaf6fc36bbd3300973c7a4b826d6ab1391/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0087084960f209a9a4af50ecd1fb063d9ad3658c07bb81a7a53f452dacbfb2ba", size = 2301214, upload-time = "2026-04-20T14:44:48.792Z" },
{ url = "https://files.pythonhosted.org/packages/fd/86/ef96a4c6e79e7a2d0410826a68fbc0eccc0fd44aa733be199d5fcac3bb87/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed42e6cc8e1b0e2b9b96e2276bad70ae625d10d6d524aed0c93de974ae029f9f", size = 2099927, upload-time = "2026-04-20T14:41:40.196Z" },
{ url = "https://files.pythonhosted.org/packages/6d/53/269caf30e0096e0a8a8f929d1982a27b3879872cca2d917d17c2f9fdf4fe/pydantic_core-2.46.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:f1771ce258afb3e4201e67d154edbbae712a76a6081079fe247c2f53c6322c22", size = 2128789, upload-time = "2026-04-20T14:41:15.868Z" },
{ url = "https://files.pythonhosted.org/packages/00/b0/1a6d9b6a587e118482910c244a1c5acf4d192604174132efd12bf0ac486f/pydantic_core-2.46.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a7610b6a5242a6c736d8ad47fd5fff87fcfe8f833b281b1c409c3d6835d9227f", size = 2173815, upload-time = "2026-04-20T14:44:25.152Z" },
{ url = "https://files.pythonhosted.org/packages/87/56/e7e00d4041a7e62b5a40815590114db3b535bf3ca0bf4dca9f16cef25246/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:ff5e7783bcc5476e1db448bf268f11cb257b1c276d3e89f00b5727be86dd0127", size = 2181608, upload-time = "2026-04-20T14:41:28.933Z" },
{ url = "https://files.pythonhosted.org/packages/e8/22/4bd23c3d41f7c185d60808a1de83c76cf5aeabf792f6c636a55c3b1ec7f9/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:9d2e32edcc143bc01e95300671915d9ca052d4f745aa0a49c48d4803f8a85f2c", size = 2326968, upload-time = "2026-04-20T14:42:03.962Z" },
{ url = "https://files.pythonhosted.org/packages/24/ac/66cd45129e3915e5ade3b292cb3bc7fd537f58f8f8dbdaba6170f7cabb74/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6e42d83d1c6b87fa56b521479cff237e626a292f3b31b6345c15a99121b454c1", size = 2369842, upload-time = "2026-04-20T14:41:35.52Z" },
{ url = "https://files.pythonhosted.org/packages/a2/51/dd4248abb84113615473aa20d5545b7c4cd73c8644003b5259686f93996c/pydantic_core-2.46.3-cp314-cp314-win32.whl", hash = "sha256:07bc6d2a28c3adb4f7c6ae46aa4f2d2929af127f587ed44057af50bf1ce0f505", size = 1959661, upload-time = "2026-04-20T14:41:00.042Z" },
{ url = "https://files.pythonhosted.org/packages/20/eb/59980e5f1ae54a3b86372bd9f0fa373ea2d402e8cdcd3459334430f91e91/pydantic_core-2.46.3-cp314-cp314-win_amd64.whl", hash = "sha256:8940562319bc621da30714617e6a7eaa6b98c84e8c685bcdc02d7ed5e7c7c44e", size = 2071686, upload-time = "2026-04-20T14:43:16.471Z" },
{ url = "https://files.pythonhosted.org/packages/8c/db/1cf77e5247047dfee34bc01fa9bca134854f528c8eb053e144298893d370/pydantic_core-2.46.3-cp314-cp314-win_arm64.whl", hash = "sha256:5dcbbcf4d22210ced8f837c96db941bdb078f419543472aca5d9a0bb7cddc7df", size = 2026907, upload-time = "2026-04-20T14:43:31.732Z" },
{ url = "https://files.pythonhosted.org/packages/57/c0/b3df9f6a543276eadba0a48487b082ca1f201745329d97dbfa287034a230/pydantic_core-2.46.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d0fe3dce1e836e418f912c1ad91c73357d03e556a4d286f441bf34fed2dbeecf", size = 2095047, upload-time = "2026-04-20T14:42:37.982Z" },
{ url = "https://files.pythonhosted.org/packages/66/57/886a938073b97556c168fd99e1a7305bb363cd30a6d2c76086bf0587b32a/pydantic_core-2.46.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9ce92e58abc722dac1bf835a6798a60b294e48eb0e625ec9fd994b932ac5feee", size = 1934329, upload-time = "2026-04-20T14:43:49.655Z" },
{ url = "https://files.pythonhosted.org/packages/0b/7c/b42eaa5c34b13b07ecb51da21761297a9b8eb43044c864a035999998f328/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a03e6467f0f5ab796a486146d1b887b2dc5e5f9b3288898c1b1c3ad974e53e4a", size = 1974847, upload-time = "2026-04-20T14:42:10.737Z" },
{ url = "https://files.pythonhosted.org/packages/e6/9b/92b42db6543e7de4f99ae977101a2967b63122d4b6cf7773812da2d7d5b5/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2798b6ba041b9d70acfb9071a2ea13c8456dd1e6a5555798e41ba7b0790e329c", size = 2041742, upload-time = "2026-04-20T14:40:44.262Z" },
{ url = "https://files.pythonhosted.org/packages/0f/19/46fbe1efabb5aa2834b43b9454e70f9a83ad9c338c1291e48bdc4fecf167/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9be3e221bdc6d69abf294dcf7aff6af19c31a5cdcc8f0aa3b14be29df4bd03b1", size = 2236235, upload-time = "2026-04-20T14:41:27.307Z" },
{ url = "https://files.pythonhosted.org/packages/77/da/b3f95bc009ad60ec53120f5d16c6faa8cabdbe8a20d83849a1f2b8728148/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f13936129ce841f2a5ddf6f126fea3c43cd128807b5a59588c37cf10178c2e64", size = 2282633, upload-time = "2026-04-20T14:44:33.271Z" },
{ url = "https://files.pythonhosted.org/packages/cc/6e/401336117722e28f32fb8220df676769d28ebdf08f2f4469646d404c43a3/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28b5f2ef03416facccb1c6ef744c69793175fd27e44ef15669201601cf423acb", size = 2109679, upload-time = "2026-04-20T14:44:41.065Z" },
{ url = "https://files.pythonhosted.org/packages/fc/53/b289f9bc8756a32fe718c46f55afaeaf8d489ee18d1a1e7be1db73f42cc4/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:830d1247d77ad23852314f069e9d7ddafeec5f684baf9d7e7065ed46a049c4e6", size = 2108342, upload-time = "2026-04-20T14:42:50.144Z" },
{ url = "https://files.pythonhosted.org/packages/10/5b/8292fc7c1f9111f1b2b7c1b0dcf1179edcd014fc3ea4517499f50b829d71/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0793c90c1a3c74966e7975eaef3ed30ebdff3260a0f815a62a22adc17e4c01c", size = 2157208, upload-time = "2026-04-20T14:42:08.133Z" },
{ url = "https://files.pythonhosted.org/packages/2b/9e/f80044e9ec07580f057a89fc131f78dda7a58751ddf52bbe05eaf31db50f/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d2d0aead851b66f5245ec0c4fb2612ef457f8bbafefdf65a2bf9d6bac6140f47", size = 2167237, upload-time = "2026-04-20T14:42:25.412Z" },
{ url = "https://files.pythonhosted.org/packages/f8/84/6781a1b037f3b96be9227edbd1101f6d3946746056231bf4ac48cdff1a8d/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:2f40e4246676beb31c5ce77c38a55ca4e465c6b38d11ea1bd935420568e0b1ab", size = 2312540, upload-time = "2026-04-20T14:40:40.313Z" },
{ url = "https://files.pythonhosted.org/packages/3e/db/19c0839feeb728e7df03255581f198dfdf1c2aeb1e174a8420b63c5252e5/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:cf489cf8986c543939aeee17a09c04d6ffb43bfef8ca16fcbcc5cfdcbed24dba", size = 2369556, upload-time = "2026-04-20T14:41:09.427Z" },
{ url = "https://files.pythonhosted.org/packages/e0/15/3228774cb7cd45f5f721ddf1b2242747f4eb834d0c491f0c02d606f09fed/pydantic_core-2.46.3-cp314-cp314t-win32.whl", hash = "sha256:ffe0883b56cfc05798bf994164d2b2ff03efe2d22022a2bb080f3b626176dd56", size = 1949756, upload-time = "2026-04-20T14:41:25.717Z" },
{ url = "https://files.pythonhosted.org/packages/b8/2a/c79cf53fd91e5a87e30d481809f52f9a60dd221e39de66455cf04deaad37/pydantic_core-2.46.3-cp314-cp314t-win_amd64.whl", hash = "sha256:706d9d0ce9cf4593d07270d8e9f53b161f90c57d315aeec4fb4fd7a8b10240d8", size = 2051305, upload-time = "2026-04-20T14:43:18.627Z" },
{ url = "https://files.pythonhosted.org/packages/0b/db/d8182a7f1d9343a032265aae186eb063fe26ca4c40f256b21e8da4498e89/pydantic_core-2.46.3-cp314-cp314t-win_arm64.whl", hash = "sha256:77706aeb41df6a76568434701e0917da10692da28cb69d5fb6919ce5fdb07374", size = 2026310, upload-time = "2026-04-20T14:41:01.778Z" },
]
[[package]]
@@ -3888,19 +3860,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" },
]
[[package]]
name = "watchdog-gevent"
version = "0.2.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "gevent" },
{ name = "watchdog" },
]
sdist = { url = "https://files.pythonhosted.org/packages/97/69/91cfca7c21c382e3a8aca4251dcd7d4315228d9346381feb2dde36d14061/watchdog_gevent-0.2.1.tar.gz", hash = "sha256:ae6b94d0f8c8ce1c5956cd865f612b61f456cf19801744bba25a349fe8e8c337", size = 4296, upload-time = "2024-10-19T05:29:12.987Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/00/a9/54b88e150b77791958957e2188312477d09fc84820fc03f8b3a7569d10b0/watchdog_gevent-0.2.1-py3-none-any.whl", hash = "sha256:e8114658104a018f626ee54052335407c1438369febc776c4b4c4308ed002350", size = 3462, upload-time = "2024-10-19T05:29:11.421Z" },
]
[[package]]
name = "watchfiles"
version = "1.1.1"
@@ -4082,15 +4041,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" },
]
[[package]]
name = "zope-event"
version = "6.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/46/33/d3eeac228fc14de76615612ee208be2d8a5b5b0fada36bf9b62d6b40600c/zope_event-6.1.tar.gz", hash = "sha256:6052a3e0cb8565d3d4ef1a3a7809336ac519bc4fe38398cb8d466db09adef4f0", size = 18739, upload-time = "2025-11-07T08:05:49.934Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c2/b0/956902e5e1302f8c5d124e219c6bf214e2649f92ad5fce85b05c039a04c9/zope_event-6.1-py3-none-any.whl", hash = "sha256:0ca78b6391b694272b23ec1335c0294cc471065ed10f7f606858fc54566c25a0", size = 6414, upload-time = "2025-11-07T08:05:48.874Z" },
]
[[package]]
name = "zope-interface"
version = "8.2"

82
web/package-lock.json generated
View File

@@ -40,10 +40,10 @@
"@open-wc/lit-helpers": "^0.7.0",
"@openlayers-elements/core": "^0.4.0",
"@openlayers-elements/maps": "^0.4.0",
"@patternfly/elements": "^4.3.1",
"@patternfly/elements": "^4.4.0",
"@patternfly/patternfly": "^4.224.2",
"@playwright/test": "^1.59.1",
"@sentry/browser": "^10.48.0",
"@sentry/browser": "^10.49.0",
"@storybook/addon-docs": "^10.3.5",
"@storybook/addon-links": "^10.3.5",
"@storybook/web-components": "^10.3.5",
@@ -2825,14 +2825,14 @@
}
},
"node_modules/@patternfly/elements": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/@patternfly/elements/-/elements-4.3.1.tgz",
"integrity": "sha512-MRVwxcam+ACyy+0Xy5igPr+LcSVRbX422NGPE4I7WRuwAEhRBA3BayyLi8mNVKXpLLZbk8EtJ17kM30PcMziMw==",
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/@patternfly/elements/-/elements-4.4.0.tgz",
"integrity": "sha512-ShLDYMYEWdhmYDd1XUVj41IfwEmWEXXvHEscVTuga1M9KWMXRJQgf+9jio/2Od5dNh4PAshyH0f19fHFU9EAsA==",
"license": "MIT",
"dependencies": {
"@lit/context": "^1.1.6",
"@patternfly/icons": "^1.0.3",
"@patternfly/pfe-core": "^5.0.6",
"@patternfly/pfe-core": "^5.0.8",
"lit": "^3.3.2",
"tslib": "^2.8.1"
}
@@ -2850,9 +2850,9 @@
"license": "MIT"
},
"node_modules/@patternfly/pfe-core": {
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/@patternfly/pfe-core/-/pfe-core-5.0.7.tgz",
"integrity": "sha512-cOIyW2k+l/H2592BQ00Bc0kfJClBCRiDDmeEYvhumHAKzgJiQIsVQ81GpNpOgtlibV5KTn3FxrSMadGEpEl/fg==",
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/@patternfly/pfe-core/-/pfe-core-5.0.8.tgz",
"integrity": "sha512-gH+gC8+lwLQ5OxcQsmJOSHNHqQgoa+VboM4LlI63N+jnDPmB7E9EZ7VzJc8C4qTPbCIfQp+o1ObjmKyNw/b9TA==",
"license": "MIT",
"dependencies": {
"@lit/context": "^1.1.6",
@@ -3591,75 +3591,75 @@
"license": "MIT"
},
"node_modules/@sentry-internal/browser-utils": {
"version": "10.48.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.48.0.tgz",
"integrity": "sha512-SCiTLBXzugFKxev6NoKYBIhQoDk0gUh0AVVVepCBqfCJiWBG01Zvv0R5tCVohr4cWRllkQ8mlBdNQd/I7s9tdA==",
"version": "10.49.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.49.0.tgz",
"integrity": "sha512-n0QRx0Ysx6mPfIydTkz7VP0FmwM+/EqMZiRqdsU3aTYsngE9GmEDV0OL1bAy6a8N/C1xf9vntkuAtj6N/8Z51w==",
"license": "MIT",
"dependencies": {
"@sentry/core": "10.48.0"
"@sentry/core": "10.49.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry-internal/feedback": {
"version": "10.48.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.48.0.tgz",
"integrity": "sha512-tGkEyOM1HDS9qebDphUMEnyk3qq/50AnuTBiFmMJyjNzowylVGmRRk0sr3xkmbVHCDXQCiYnDmSVlJ2x4SDMrQ==",
"version": "10.49.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.49.0.tgz",
"integrity": "sha512-JNsUBGv0faCFE7MeZUH99Y9lU9qq3LBALbLxpE1x7ngNrQnVYRlcFgdqaD/btNBKr8awjYL8gmcSkHBWskGqLQ==",
"license": "MIT",
"dependencies": {
"@sentry/core": "10.48.0"
"@sentry/core": "10.49.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry-internal/replay": {
"version": "10.48.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.48.0.tgz",
"integrity": "sha512-sevRTePfuk4PNuz9KAKpmTZEomAU0aLXyIhOwA0OnUDdxPhkY8kq5lwDbuxTHv6DQUjUX3YgFbY45VH1JEqHKA==",
"version": "10.49.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.49.0.tgz",
"integrity": "sha512-IEy4lwHVMiRE3JAcn+kFKjsTgalDOCSTf20SoFd+nkt6rN/k1RDyr4xpdfF//Kj3UdeTmbuibYjK5H/FLhhnGg==",
"license": "MIT",
"dependencies": {
"@sentry-internal/browser-utils": "10.48.0",
"@sentry/core": "10.48.0"
"@sentry-internal/browser-utils": "10.49.0",
"@sentry/core": "10.49.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry-internal/replay-canvas": {
"version": "10.48.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.48.0.tgz",
"integrity": "sha512-9nWuN2z4O+iwbTfuYV5ZmngBgJU/ZxfOo47A5RJP3Nu/kl59aJ1lUhILYOKyeNOIC/JyeERmpIcTxnlPXQzZ3Q==",
"version": "10.49.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.49.0.tgz",
"integrity": "sha512-7D/NrgH1Qwx5trDYaaTSSJmCb1yVQQLqFG4G/S9x2ltzl9876lSGJL8UeW8ReNQgF3CDAcwbmm/9aXaVSBUNZA==",
"license": "MIT",
"dependencies": {
"@sentry-internal/replay": "10.48.0",
"@sentry/core": "10.48.0"
"@sentry-internal/replay": "10.49.0",
"@sentry/core": "10.49.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/browser": {
"version": "10.48.0",
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.48.0.tgz",
"integrity": "sha512-4jt2zX2ExgFcNe2x+W+/k81fmDUsOrquGtt028CiGuDuma6kEsWBI4JbooT1jhj2T+eeUxe3YGbM23Zhh7Ghhw==",
"version": "10.49.0",
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.49.0.tgz",
"integrity": "sha512-bGCHc+wK2Dx67YoSbmtlt04alqWfQ+dasD/GVipVOq50gvw/BBIDHTEWRJEjACl+LrvszeY54V+24p8z4IgysA==",
"license": "MIT",
"dependencies": {
"@sentry-internal/browser-utils": "10.48.0",
"@sentry-internal/feedback": "10.48.0",
"@sentry-internal/replay": "10.48.0",
"@sentry-internal/replay-canvas": "10.48.0",
"@sentry/core": "10.48.0"
"@sentry-internal/browser-utils": "10.49.0",
"@sentry-internal/feedback": "10.49.0",
"@sentry-internal/replay": "10.49.0",
"@sentry-internal/replay-canvas": "10.49.0",
"@sentry/core": "10.49.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/core": {
"version": "10.48.0",
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.48.0.tgz",
"integrity": "sha512-h8F+fXVwYC9ro5ZaO8V+v3vqc0awlXHGblEAuVxSGgh4IV/oFX+QVzXeDTTrFOFS6v/Vn5vAyu240eJrJAS6/g==",
"version": "10.49.0",
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.49.0.tgz",
"integrity": "sha512-UaFeum3LUM1mB0d67jvKnqId1yWQjyqmaDV6kWngG03x+jqXb08tJdGpSoxjXZe13jFBbiBL/wKDDYIK7rCK4g==",
"license": "MIT",
"engines": {
"node": ">=18"
@@ -6151,9 +6151,9 @@
}
},
"node_modules/@xmldom/xmldom": {
"version": "0.8.12",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz",
"integrity": "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==",
"version": "0.8.13",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz",
"integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"

View File

@@ -116,10 +116,10 @@
"@open-wc/lit-helpers": "^0.7.0",
"@openlayers-elements/core": "^0.4.0",
"@openlayers-elements/maps": "^0.4.0",
"@patternfly/elements": "^4.3.1",
"@patternfly/elements": "^4.4.0",
"@patternfly/patternfly": "^4.224.2",
"@playwright/test": "^1.59.1",
"@sentry/browser": "^10.48.0",
"@sentry/browser": "^10.49.0",
"@storybook/addon-docs": "^10.3.5",
"@storybook/addon-links": "^10.3.5",
"@storybook/web-components": "^10.3.5",

View File

@@ -8,7 +8,7 @@ import { AKModal } from "#elements/dialogs/ak-modal";
import { WithBrandConfig } from "#elements/mixins/branding";
import { WithLicenseSummary } from "#elements/mixins/license";
import { SlottedTemplateResult } from "#elements/types";
import { ThemedImage } from "#elements/utils/images";
import { DefaultFlowBackground, ThemedImage } from "#elements/utils/images";
import {
AdminApi,
@@ -27,8 +27,6 @@ import { customElement, state } from "lit/decorators.js";
import PFAbout from "@patternfly/patternfly/components/AboutModalBox/about-modal-box.css";
const DEFAULT_BRAND_IMAGE = "/static/dist/assets/images/flow_background.jpg";
type AboutEntry = [label: string, content?: SlottedTemplateResult];
function renderEntry([label, content = null]: AboutEntry): SlottedTemplateResult {
@@ -191,7 +189,7 @@ export class AboutModal extends WithLicenseSummary(WithBrandConfig(AKModal)) {
${ref(this.scrollContainerRef)}
class="pf-c-about-modal-box"
style=${styleMap({
"--pf-c-about-modal-box__hero--sm--BackgroundImage": `url(${DEFAULT_BRAND_IMAGE})`,
"--pf-c-about-modal-box__hero--sm--BackgroundImage": `url(${DefaultFlowBackground})`,
})}
part="box"
>

View File

@@ -1,8 +1,3 @@
/* Fix alignment issues with images in tables */
.pf-c-table tbody > tr > * {
vertical-align: middle;
}
tr td:first-child {
width: auto;
min-width: 0px;

View File

@@ -7,7 +7,6 @@ import "#elements/forms/DeleteBulkForm";
import "#elements/forms/ModalForm";
import "#elements/dialogs/ak-modal";
import "#admin/applications/ApplicationForm";
import "#admin/applications/ApplicationWizardHint";
import { DEFAULT_CONFIG } from "#common/api/config";
@@ -127,6 +126,7 @@ export class ApplicationListPage extends WithBrandConfig(TablePage<Application>)
return [
html`<ak-app-icon
aria-label=${msg(str`Application icon for "${item.name}"`)}
role="img"
name=${item.name}
icon=${ifPresent(item.metaIconUrl)}
.iconThemedUrls=${item.metaIconThemedUrls}

View File

@@ -1,134 +0,0 @@
import "#admin/applications/wizard/ak-application-wizard";
import "#components/ak-hint/ak-hint";
import "#components/ak-hint/ak-hint-body";
import "#elements/Label";
import "#elements/buttons/ActionButton/ak-action-button";
import { AKElement } from "#elements/Base";
import { getURLParam } from "#elements/router/RouteMatch";
import { ShowHintController, ShowHintControllerHost } from "#components/ak-hint/ShowHintController";
import { msg } from "@lit/localize";
import { css, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { styleMap } from "lit/directives/style-map.js";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFLabel from "@patternfly/patternfly/components/Label/label.css";
import PFPage from "@patternfly/patternfly/components/Page/page.css";
const closeButtonIcon = html`<svg
fill="currentColor"
height="1em"
width="1em"
viewBox="0 0 352 512"
aria-hidden="true"
role="img"
style="vertical-align: -0.125em;"
>
<path
d="M242.72 256l100.07-100.07c12.28-12.28 12.28-32.19 0-44.48l-22.24-22.24c-12.28-12.28-32.19-12.28-44.48 0L176 189.28 75.93 89.21c-12.28-12.28-32.19-12.28-44.48 0L9.21 111.45c-12.28 12.28-12.28 32.19 0 44.48L109.28 256 9.21 356.07c-12.28 12.28-12.28 32.19 0 44.48l22.24 22.24c12.28 12.28 32.2 12.28 44.48 0L176 322.72l100.07 100.07c12.28 12.28 32.2 12.28 44.48 0l22.24-22.24c12.28-12.28 12.28-32.19 0-44.48L242.72 256z"
></path>
</svg>`;
@customElement("ak-application-wizard-hint")
export class AkApplicationWizardHint extends AKElement implements ShowHintControllerHost {
static styles = [
PFButton,
PFPage,
PFLabel,
css`
.pf-c-page__main-section {
padding-bottom: 0;
}
.ak-hint-text {
padding-bottom: var(--pf-global--spacer--md);
}
`,
];
@property({ type: Boolean, attribute: "show-hint" })
forceHint: boolean = false;
@state()
showHint: boolean = true;
showHintController: ShowHintController;
constructor() {
super();
this.showHintController = new ShowHintController(
this,
"202310-application-wizard-announcement",
);
}
renderReminder() {
const sectionStyles = {
paddingBottom: "0",
marginBottom: "-0.5rem",
marginRight: "0.0625rem",
textAlign: "right",
};
const textStyle = { maxWidth: "60ch" };
return html`<section
class="pf-c-page__main-section pf-m-no-padding-mobile"
style="${styleMap(sectionStyles)}"
>
<span class="pf-c-label">
<a class="pf-c-label__content" @click=${this.showHintController.show}>
<span class="pf-c-label__text" style="${styleMap(textStyle)}">
${msg("One hint, 'New Application Wizard', is currently hidden")}
</span>
<button
aria-disabled="false"
aria-label=${msg("Restore Application Wizard Hint")}
class="pf-c-button pf-m-plain"
type="button"
data-ouia-safe="true"
>
${closeButtonIcon}
</button>
</a>
</span>
</section>`;
}
renderHint() {
return html` <section class="pf-c-page__main-section pf-m-no-padding-mobile">
<ak-hint>
<ak-hint-body>
<p class="ak-hint-text">
You can now configure both an application and its authentication provider at
the same time with our new Application Wizard.
<!-- <a href="(link to docs)">Learn more about the wizard here.</a> -->
</p>
<ak-application-wizard .open=${getURLParam("createWizard", false)}>
<button
slot="trigger"
class="pf-c-button pf-m-primary"
data-ouia-component-id="start-application-wizard"
>
${msg("Create with wizard")}
</button>
</ak-application-wizard>
</ak-hint-body>
${this.showHintController.render()}
</ak-hint>
</section>`;
}
render() {
return this.showHint || this.forceHint ? this.renderHint() : this.renderReminder();
}
}
export default AkApplicationWizardHint;
declare global {
interface HTMLElementTagNameMap {
"ak-application-wizard-hint": AkApplicationWizardHint;
}
}

View File

@@ -14,6 +14,7 @@ import { DEFAULT_CONFIG } from "#common/api/config";
import { DefaultBrand } from "#common/ui/config";
import { ModelForm } from "#elements/forms/ModelForm";
import { DefaultFlowBackground } from "#elements/utils/images";
import { AKLabel } from "#components/ak-label";
@@ -65,7 +66,17 @@ export class BrandForm extends ModelForm<Brand, string> {
}
protected override renderForm(): TemplateResult {
return html` <ak-text-input
const {
brandingTitle = "",
brandingLogo = "",
brandingFavicon = "",
brandingCustomCss = "",
} = this.instance ?? DefaultBrand;
const defaultFlowBackground =
this.instance?.brandingDefaultFlowBackground ?? DefaultFlowBackground;
return html`<ak-text-input
required
name="domain"
input-hint="code"
@@ -75,6 +86,7 @@ export class BrandForm extends ModelForm<Brand, string> {
help=${msg(
"Matching is done based on domain suffix, so if you enter domain.tld, foo.domain.tld will still match.",
)}
?autofocus=${!this.instance}
></ak-text-input>
<ak-switch-input
@@ -91,7 +103,7 @@ export class BrandForm extends ModelForm<Brand, string> {
required
name="brandingTitle"
placeholder="authentik"
value="${this.instance?.brandingTitle ?? DefaultBrand.brandingTitle}"
value=${brandingTitle}
label=${msg("Title")}
autocomplete="off"
spellcheck="false"
@@ -102,7 +114,7 @@ export class BrandForm extends ModelForm<Brand, string> {
required
name="brandingLogo"
label=${msg("Logo")}
value="${this.instance?.brandingLogo ?? DefaultBrand.brandingLogo}"
value=${brandingLogo}
.usage=${UsageEnum.Media}
help=${msg("Logo shown in sidebar/header and flow executor.")}
></ak-file-search-input>
@@ -111,7 +123,7 @@ export class BrandForm extends ModelForm<Brand, string> {
required
name="brandingFavicon"
label=${msg("Favicon")}
value="${this.instance?.brandingFavicon ?? DefaultBrand.brandingFavicon}"
value=${brandingFavicon}
.usage=${UsageEnum.Media}
help=${msg("Icon shown in the browser tab.")}
></ak-file-search-input>
@@ -120,8 +132,7 @@ export class BrandForm extends ModelForm<Brand, string> {
required
name="brandingDefaultFlowBackground"
label=${msg("Default flow background")}
value="${this.instance?.brandingDefaultFlowBackground ??
"/static/dist/assets/images/flow_background.jpg"}"
value=${defaultFlowBackground}
.usage=${UsageEnum.Media}
help=${msg(
"Default background used during flow execution. Can be overridden per flow.",
@@ -141,8 +152,7 @@ export class BrandForm extends ModelForm<Brand, string> {
<ak-codemirror
id="branding-custom-css"
mode="css"
value="${this.instance?.brandingCustomCss ??
DefaultBrand.brandingCustomCss}"
value=${brandingCustomCss}
>
</ak-codemirror>
<p class="pf-c-form__helper-text">

View File

@@ -10,6 +10,7 @@ import { DEFAULT_CONFIG } from "#common/api/config";
import { SlottedTemplateResult } from "#elements/types";
import { CreateWizard } from "#elements/wizard/CreateWizard";
import { TypeCreateWizardPageLayouts } from "#elements/wizard/TypeCreateWizardPage";
import { EndpointsApi, TypeCreate } from "@goauthentik/api";
@@ -23,6 +24,8 @@ export class AKEndpointConnectorWizard extends CreateWizard {
public static override verboseName = msg("Endpoint Connector");
public static override verboseNamePlural = msg("Endpoint Connectors");
public override layout = TypeCreateWizardPageLayouts.grid;
protected apiEndpoint = (requestInit?: RequestInit): Promise<TypeCreate[]> => {
return this.#api.endpointsConnectorsTypesList(requestInit);
};

View File

@@ -20,22 +20,24 @@ import { AKStageWizard } from "#admin/stages/ak-stage-wizard";
import { FlowsApi, FlowStageBinding, ModelEnum } from "@goauthentik/api";
import { msg } from "@lit/localize";
import { html, TemplateResult } from "lit";
import { html } from "lit";
import { customElement, property } from "lit/decorators.js";
@customElement("ak-bound-stages-list")
export class BoundStagesList extends Table<FlowStageBinding> {
expandable = true;
checkbox = true;
clearOnRefresh = true;
protected flowsAPI = new FlowsApi(DEFAULT_CONFIG);
order = "order";
public override expandable = true;
public override checkbox = true;
public override clearOnRefresh = true;
@property()
target?: string;
public override order = "order";
async apiEndpoint(): Promise<PaginatedResponse<FlowStageBinding>> {
return new FlowsApi(DEFAULT_CONFIG).flowsBindingsList({
@property({ type: String, useDefault: true })
public target: string | null = null;
protected override async apiEndpoint(): Promise<PaginatedResponse<FlowStageBinding>> {
return this.flowsAPI.flowsBindingsList({
...(await this.defaultEndpointConfig()),
target: this.target || "",
});
@@ -52,7 +54,7 @@ export class BoundStagesList extends Table<FlowStageBinding> {
[msg("Actions"), null, msg("Row Actions")],
];
renderToolbarSelected(): TemplateResult {
renderToolbarSelected(): SlottedTemplateResult {
const disabled = this.selectedElements.length < 1;
return html`<ak-forms-delete-bulk
object-label=${msg("Stage binding(s)")}
@@ -64,12 +66,12 @@ export class BoundStagesList extends Table<FlowStageBinding> {
];
}}
.usedBy=${(item: FlowStageBinding) => {
return new FlowsApi(DEFAULT_CONFIG).flowsBindingsUsedByList({
return this.flowsAPI.flowsBindingsUsedByList({
fsbUuid: item.pk,
});
}}
.delete=${(item: FlowStageBinding) => {
return new FlowsApi(DEFAULT_CONFIG).flowsBindingsDestroy({
return this.flowsAPI.flowsBindingsDestroy({
fsbUuid: item.pk,
});
}}
@@ -80,7 +82,7 @@ export class BoundStagesList extends Table<FlowStageBinding> {
</ak-forms-delete-bulk>`;
}
row(item: FlowStageBinding): SlottedTemplateResult[] {
protected override row(item: FlowStageBinding): SlottedTemplateResult[] {
return [
html`<pre>${item.order}</pre>`,
item.stageObj?.name,
@@ -115,30 +117,27 @@ export class BoundStagesList extends Table<FlowStageBinding> {
protected renderActions(): SlottedTemplateResult {
return html`<button
class="pf-c-button pf-m-primary"
${modalInvoker(AKStageWizard, {
showBindingPage: true,
bindingTarget: this.target,
})}
>
${msg("New Stage")}
</button>
<button
slot="trigger"
class="pf-c-button pf-m-primary"
${modalInvoker(StageBindingForm, { targetPk: this.target })}
>
${msg("Bind Existing Stage")}
</button>`;
class="pf-c-button pf-m-primary"
${modalInvoker(AKStageWizard, {
showBindingPage: true,
bindingTarget: this.target,
})}
>
${msg("Create or bind...")}
</button>`;
}
protected override renderExpanded(item: FlowStageBinding): TemplateResult {
protected override renderExpanded(item: FlowStageBinding): SlottedTemplateResult {
return html`<div class="pf-c-content">
<p>${msg("These bindings control if this stage will be applied to the flow.")}</p>
<ak-bound-policies-list
.target=${item.policybindingmodelPtrId}
.policyEngineMode=${item.policyEngineMode}
>
<span slot="description"
>${msg(
"These bindings control if this stage will be applied to the flow.",
)}</span
>
</ak-bound-policies-list>
</div>`;
}

View File

@@ -218,6 +218,15 @@ export class FlowForm extends WithCapabilitiesConfig(ModelForm<Flow, string>) {
>
${msg("Require Outpost (flow can only be executed from an outpost)")}
</option>
<option
value=${AuthenticationEnum.RequireToken}
?selected=${this.instance?.authentication ===
AuthenticationEnum.RequireToken}
>
${msg(
"Require Flow token (flow can only be executed from a generated recovery link)",
)}
</option>
</select>
<p class="pf-c-form__helper-text">
${msg("Required authentication level for this flow.")}

View File

@@ -14,6 +14,7 @@ import "#elements/forms/ModalForm";
import "@patternfly/elements/pf-tooltip/pf-tooltip.js";
import { DEFAULT_CONFIG } from "#common/api/config";
import { formatDisambiguatedUserDisplayName } from "#common/users";
import { IconEditButton, renderModal } from "#elements/dialogs";
import { AKFormSubmitEvent, Form } from "#elements/forms/Form";
@@ -22,11 +23,11 @@ import { WithCapabilitiesConfig } from "#elements/mixins/capabilities";
import { getURLParam, updateURLParams } from "#elements/router/RouteMatch";
import { PaginatedResponse, Table, TableColumn, Timestamp } from "#elements/table/Table";
import { SlottedTemplateResult } from "#elements/types";
import { UserOption } from "#elements/user/utils";
import { AKLabel } from "#components/ak-label";
import { RecoveryButtons } from "#admin/users/recovery";
import { ToggleUserActivationButton } from "#admin/users/UserActiveForm";
import { UserForm } from "#admin/users/UserForm";
import { UserImpersonateForm } from "#admin/users/UserImpersonateForm";
@@ -153,7 +154,7 @@ export class AddRelatedUserForm extends Form<{ users: number[] }> {
this.requestUpdate();
}}
>
${UserOption(user)}
${formatDisambiguatedUserDisplayName(user)}
</ak-chip>`;
})}</ak-chip-group
>
@@ -317,22 +318,7 @@ export class RelatedUserList extends WithBrandConfig(WithCapabilitiesConfig(Tabl
</dt>
<dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text">
<ak-user-active-form
.obj=${item}
object-label=${msg("User")}
.delete=${() => {
return new CoreApi(DEFAULT_CONFIG).coreUsersPartialUpdate({
id: item.pk || 0,
patchedUserRequest: {
isActive: !item.isActive,
},
});
}}
>
<button slot="trigger" class="pf-c-button pf-m-warning">
${item.isActive ? msg("Deactivate") : msg("Activate")}
</button>
</ak-user-active-form>
${ToggleUserActivationButton(item)}
</div>
</dd>
</div>

View File

@@ -184,7 +184,7 @@ export class BoundPoliciesList<T extends PolicyBinding = PolicyBinding> extends
bindingTarget: this.target,
})}
>
${msg("Create and bind Policy")}
${msg("Create or bind...")}
</button>`;
}
@@ -223,44 +223,16 @@ export class BoundPoliciesList<T extends PolicyBinding = PolicyBinding> extends
html`<ak-empty-state icon="pf-icon-module"
><span>${msg("No Policies bound.")}</span>
<div slot="body">${msg("No policies are currently bound to this object.")}</div>
<fieldset class="pf-c-form__group pf-m-action" slot="primary">
<div class="pf-c-form__group pf-m-action" slot="primary">
<legend class="sr-only">${msg("Policy actions")}</legend>
${this.renderNewPolicyButton()}
<button
type="button"
class="pf-c-button pf-m-secondary"
${modalInvoker(() => {
return StrictUnsafe<PolicyBindingForm>(this.bindingEditForm, {
allowedTypes: this.allowedTypes,
typeNotices: this.typeNotices,
targetPk: this.target || "",
});
})}
>
${msg("Bind existing policy/group/user")}
</button>
</fieldset>
</div>
</ak-empty-state>`,
);
}
renderToolbar(): SlottedTemplateResult {
return html`${this.allowedTypes.includes(PolicyBindingCheckTarget.Policy)
? this.renderNewPolicyButton()
: null}
<button
type="button"
class="pf-c-button pf-m-secondary"
${modalInvoker(() => {
return StrictUnsafe<PolicyBindingForm>(this.bindingEditForm, {
allowedTypes: this.allowedTypes,
typeNotices: this.typeNotices,
targetPk: this.target || "",
});
})}
>
${msg(str`Bind existing ${this.allowedTypesLabel}`)}
</button>`;
return this.renderNewPolicyButton();
}
renderPolicyEngineMode() {
@@ -270,10 +242,15 @@ export class BoundPoliciesList<T extends PolicyBinding = PolicyBinding> extends
if (policyEngineMode === undefined) {
return nothing;
}
return html`<p class="policy-desc">
${msg(str`The currently selected policy engine mode is ${policyEngineMode.label}:`)}
${policyEngineMode.description}
</p>`;
return html`${this.findSlotted("description")
? html`<p class="policy-desc">
<slot name="description"></slot>
</p>`
: nothing}
<p class="policy-desc">
${msg(str`The currently selected policy engine mode is ${policyEngineMode.label}:`)}
${policyEngineMode.description}
</p>`;
}
renderToolbarContainer(): SlottedTemplateResult {

View File

@@ -63,7 +63,7 @@ export class PolicyBindingForm<T extends PolicyBinding = PolicyBinding> extends
public targetPk = "";
@state()
protected policyGroupUser: PolicyBindingCheckTarget = PolicyBindingCheckTarget.Policy;
public policyGroupUser: PolicyBindingCheckTarget = PolicyBindingCheckTarget.Policy;
@property({ type: Array })
public allowedTypes: PolicyBindingCheckTarget[] = [
@@ -161,107 +161,109 @@ export class PolicyBindingForm<T extends PolicyBinding = PolicyBinding> extends
</ak-toggle-group>`;
}
protected renderTarget() {
return html`<ak-form-element-horizontal
label=${msg("Policy")}
name="policy"
?hidden=${this.policyGroupUser !== PolicyBindingCheckTarget.Policy}
>
<ak-search-select
.groupBy=${(items: Policy[]) => {
return groupBy(items, (policy) => policy.verboseNamePlural);
}}
.fetchObjects=${async (query?: string): Promise<Policy[]> => {
const args: PoliciesAllListRequest = {
ordering: "name",
};
if (query !== undefined) {
args.search = query;
}
const policies = await new PoliciesApi(DEFAULT_CONFIG).policiesAllList(
args,
);
return policies.results;
}}
.renderElement=${(policy: Policy) => policy.name}
.value=${(policy: Policy | null) => policy?.pk}
.selected=${(policy: Policy) => policy.pk === this.instance?.policy}
blankable
>
</ak-search-select>
${this.typeNotices
.filter(({ type }) => type === PolicyBindingCheckTarget.Policy)
.map((msg) => {
return html`<p class="pf-c-form__helper-text">${msg.notice}</p>`;
})}
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Group")}
name="group"
?hidden=${this.policyGroupUser !== PolicyBindingCheckTarget.Group}
>
<ak-search-select
.fetchObjects=${async (query?: string): Promise<Group[]> => {
const args: CoreGroupsListRequest = {
ordering: "name",
includeUsers: false,
};
if (query !== undefined) {
args.search = query;
}
const groups = await new CoreApi(DEFAULT_CONFIG).coreGroupsList(args);
return groups.results;
}}
.renderElement=${(group: Group): string => {
return group.name;
}}
.value=${(group: Group | null) => String(group?.pk ?? "")}
.selected=${(group: Group) => group.pk === this.instance?.group}
blankable
>
</ak-search-select>
${this.typeNotices
.filter(({ type }) => type === PolicyBindingCheckTarget.Group)
.map((msg) => {
return html`<p class="pf-c-form__helper-text">${msg.notice}</p>`;
})}
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("User")}
name="user"
?hidden=${this.policyGroupUser !== PolicyBindingCheckTarget.User}
>
<ak-search-select
.fetchObjects=${async (query?: string): Promise<User[]> => {
const args: CoreUsersListRequest = {
ordering: "username",
};
if (query !== undefined) {
args.search = query;
}
const users = await new CoreApi(DEFAULT_CONFIG).coreUsersList(args);
return users.results;
}}
.renderElement=${(user: User) => user.username}
.renderDescription=${(user: User) => html`${user.name}`}
.value=${(user: User | null) => user?.pk}
.selected=${(user: User) => user.pk === this.instance?.user}
blankable
>
</ak-search-select>
${this.typeNotices
.filter(({ type }) => type === PolicyBindingCheckTarget.User)
.map((msg) => {
return html`<p class="pf-c-form__helper-text">${msg.notice}</p>`;
})}
</ak-form-element-horizontal>`;
}
protected override renderForm(): TemplateResult {
return html` <div class="pf-c-card pf-m-selectable pf-m-selected">
<div class="pf-c-card__body">${this.renderModeSelector()}</div>
<div class="pf-c-card__footer">
<ak-form-element-horizontal
label=${msg("Policy")}
name="policy"
?hidden=${this.policyGroupUser !== PolicyBindingCheckTarget.Policy}
>
<ak-search-select
.groupBy=${(items: Policy[]) => {
return groupBy(items, (policy) => policy.verboseNamePlural);
}}
.fetchObjects=${async (query?: string): Promise<Policy[]> => {
const args: PoliciesAllListRequest = {
ordering: "name",
};
if (query !== undefined) {
args.search = query;
}
const policies = await new PoliciesApi(
DEFAULT_CONFIG,
).policiesAllList(args);
return policies.results;
}}
.renderElement=${(policy: Policy) => policy.name}
.value=${(policy: Policy | null) => policy?.pk}
.selected=${(policy: Policy) => policy.pk === this.instance?.policy}
blankable
>
</ak-search-select>
${this.typeNotices
.filter(({ type }) => type === PolicyBindingCheckTarget.Policy)
.map((msg) => {
return html`<p class="pf-c-form__helper-text">${msg.notice}</p>`;
})}
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Group")}
name="group"
?hidden=${this.policyGroupUser !== PolicyBindingCheckTarget.Group}
>
<ak-search-select
.fetchObjects=${async (query?: string): Promise<Group[]> => {
const args: CoreGroupsListRequest = {
ordering: "name",
includeUsers: false,
};
if (query !== undefined) {
args.search = query;
}
const groups = await new CoreApi(DEFAULT_CONFIG).coreGroupsList(
args,
);
return groups.results;
}}
.renderElement=${(group: Group): string => {
return group.name;
}}
.value=${(group: Group | null) => String(group?.pk ?? "")}
.selected=${(group: Group) => group.pk === this.instance?.group}
blankable
>
</ak-search-select>
${this.typeNotices
.filter(({ type }) => type === PolicyBindingCheckTarget.Group)
.map((msg) => {
return html`<p class="pf-c-form__helper-text">${msg.notice}</p>`;
})}
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("User")}
name="user"
?hidden=${this.policyGroupUser !== PolicyBindingCheckTarget.User}
>
<ak-search-select
.fetchObjects=${async (query?: string): Promise<User[]> => {
const args: CoreUsersListRequest = {
ordering: "username",
};
if (query !== undefined) {
args.search = query;
}
const users = await new CoreApi(DEFAULT_CONFIG).coreUsersList(args);
return users.results;
}}
.renderElement=${(user: User) => user.username}
.renderDescription=${(user: User) => html`${user.name}`}
.value=${(user: User | null) => user?.pk}
.selected=${(user: User) => user.pk === this.instance?.user}
blankable
>
</ak-search-select>
${this.typeNotices
.filter(({ type }) => type === PolicyBindingCheckTarget.User)
.map((msg) => {
return html`<p class="pf-c-form__helper-text">${msg.notice}</p>`;
})}
</ak-form-element-horizontal>
</div>
</div>
return html`${this.allowedTypes.length > 1
? html`<div class="pf-c-card pf-m-selectable pf-m-selected">
<div class="pf-c-card__body">${this.renderModeSelector()}</div>
<div class="pf-c-card__footer">${this.renderTarget()}</div>
</div>`
: this.renderTarget()}
<ak-switch-input
name="enabled"
label=${msg("Enabled")}

View File

@@ -9,26 +9,36 @@ import "#admin/policies/unique_password/UniquePasswordPolicyForm";
import "#elements/wizard/FormWizardPage";
import "#elements/wizard/TypeCreateWizardPage";
import "#elements/wizard/Wizard";
import "#elements/forms/FormGroup";
import "#admin/policies/PolicyBindingForm";
import { DEFAULT_CONFIG } from "#common/api/config";
import { PolicyBindingCheckTarget } from "#common/policies/utils";
import { RadioChangeEventDetail, RadioOption } from "#elements/forms/Radio";
import { SlottedTemplateResult } from "#elements/types";
import { CreateWizard } from "#elements/wizard/CreateWizard";
import { FormWizardPage } from "#elements/wizard/FormWizardPage";
import { TypeCreateWizardPageLayouts } from "#elements/wizard/TypeCreateWizardPage";
import { PolicyBindingForm } from "#admin/policies/PolicyBindingForm";
import { PoliciesApi, Policy, PolicyBinding, TypeCreate } from "@goauthentik/api";
import {
PoliciesApi,
Policy,
PolicyBinding,
PolicyBindingRequest,
TypeCreate,
} from "@goauthentik/api";
import { msg } from "@lit/localize";
import { customElement } from "@lit/reactive-element/decorators/custom-element.js";
import { html, PropertyValues } from "lit";
import { property } from "lit/decorators.js";
const initialStep = "initial";
@customElement("ak-policy-wizard")
export class PolicyWizard extends CreateWizard {
#api = new PoliciesApi(DEFAULT_CONFIG);
protected policiesAPI = new PoliciesApi(DEFAULT_CONFIG);
@property({ type: Boolean })
public showBindingPage = false;
@@ -36,6 +46,9 @@ export class PolicyWizard extends CreateWizard {
@property()
public bindingTarget: string | null = null;
public override groupLabel = msg("Bind New Policy");
public override groupDescription = msg("Select the type of policy you want to create.");
public override initialSteps = this.showBindingPage
? ["initial", "create-binding"]
: ["initial"];
@@ -45,11 +58,11 @@ export class PolicyWizard extends CreateWizard {
public override layout = TypeCreateWizardPageLayouts.list;
protected apiEndpoint = async (requestInit?: RequestInit): Promise<TypeCreate[]> => {
return this.#api.policiesAllTypesList(requestInit);
protected override apiEndpoint = async (requestInit?: RequestInit): Promise<TypeCreate[]> => {
return this.policiesAPI.policiesAllTypesList(requestInit);
};
protected updated(changedProperties: PropertyValues<this>): void {
protected override updated(changedProperties: PropertyValues<this>): void {
super.updated(changedProperties);
if (changedProperties.has("showBindingPage")) {
@@ -57,25 +70,81 @@ export class PolicyWizard extends CreateWizard {
}
}
protected createBindingActivate = async (page: FormWizardPage) => {
const createSlot = page.host.steps[1];
const bindingForm = page.querySelector<PolicyBindingForm>("ak-policy-binding-form");
protected createBindingActivate = async (
page: FormWizardPage<{ "initial": PolicyBindingCheckTarget; "create-binding": Policy }>,
) => {
const createSlot = page.host.steps[1] as "create-binding";
const bindingForm = page.querySelector("ak-policy-binding-form");
if (!bindingForm) return;
bindingForm.instance = {
policy: (page.host.state[createSlot] as Policy).pk,
} as PolicyBinding;
if (page.host.state[createSlot]) {
bindingForm.allowedTypes = [PolicyBindingCheckTarget.Policy];
bindingForm.policyGroupUser = PolicyBindingCheckTarget.Policy;
const policyBindingRequest: Partial<PolicyBindingRequest> = {
policy: (page.host.state[createSlot] as Policy).pk,
};
bindingForm.instance = policyBindingRequest as unknown as PolicyBinding;
}
if (page.host.state[initialStep]) {
bindingForm.allowedTypes = [page.host.state[initialStep]];
bindingForm.policyGroupUser = page.host.state[initialStep];
}
};
protected override renderCreateBefore(): SlottedTemplateResult {
if (!this.showBindingPage) {
return null;
}
return html`<ak-form-group
slot="pre-items"
label=${msg("Bind Existing...")}
description=${msg(
"Select a type to bind an existing object instead of creating a new one.",
)}
open
>
<ak-radio
.options=${[
{
label: msg("Bind a user"),
description: html`${msg("Statically bind an existing user.")}`,
value: PolicyBindingCheckTarget.User,
},
{
label: msg("Bind a group"),
description: html`${msg("Statically bind an existing group.")}`,
value: PolicyBindingCheckTarget.Group,
},
{
label: msg("Bind an existing policy"),
description: html`${msg("Bind an existing policy.")}`,
value: PolicyBindingCheckTarget.Policy,
},
] satisfies RadioOption<PolicyBindingCheckTarget>[]}
@change=${(ev: CustomEvent<RadioChangeEventDetail<PolicyBindingCheckTarget>>) => {
if (!this.wizard) {
return;
}
this.wizard.state[initialStep] = ev.detail.value;
this.wizard.navigateNext();
}}
>
</ak-radio>
</ak-form-group>`;
}
protected renderForms(): SlottedTemplateResult {
const bindingPage = this.showBindingPage
? html`<ak-wizard-page-form
slot="create-binding"
headline=${msg("Create Binding")}
.activePageCallback=${this.createBindingActivate}
>
<ak-policy-binding-form .targetPk=${this.bindingTarget}></ak-policy-binding-form>
><ak-policy-binding-form .targetPk=${this.bindingTarget}></ak-policy-binding-form>
</ak-wizard-page-form>`
: null;

View File

@@ -1,4 +1,3 @@
import "#admin/applications/ApplicationWizardHint";
import "#admin/providers/ak-provider-wizard";
import "#admin/providers/google_workspace/GoogleWorkspaceProviderForm";
import "#admin/providers/ldap/LDAPProviderForm";

View File

@@ -3,16 +3,17 @@ import "#elements/LicenseNotice";
import "#elements/wizard/FormWizardPage";
import "#elements/wizard/TypeCreateWizardPage";
import "#elements/wizard/Wizard";
import "#elements/forms/FormGroup";
import "#admin/flows/StageBindingForm";
import { DEFAULT_CONFIG } from "#common/api/config";
import { RadioOption } from "#elements/forms/Radio";
import { SlottedTemplateResult } from "#elements/types";
import { CreateWizard } from "#elements/wizard/CreateWizard";
import { FormWizardPage } from "#elements/wizard/FormWizardPage";
import { TypeCreateWizardPageLayouts } from "#elements/wizard/TypeCreateWizardPage";
import { StageBindingForm } from "#admin/flows/StageBindingForm";
import { FlowStageBinding, Stage, StagesApi, TypeCreate } from "@goauthentik/api";
import { msg } from "@lit/localize";
@@ -27,8 +28,8 @@ export class AKStageWizard extends CreateWizard {
@property({ type: Boolean })
public showBindingPage = false;
@property()
public bindingTarget?: string;
@property({ type: String, useDefault: true })
public bindingTarget: string | null = null;
public override initialSteps = this.showBindingPage
? ["initial", "create-binding"]
@@ -39,11 +40,14 @@ export class AKStageWizard extends CreateWizard {
public override layout = TypeCreateWizardPageLayouts.list;
public override groupLabel = msg("Bind New Stage");
public override groupDescription = msg("Select the type of stage you want to create.");
protected apiEndpoint = async (requestInit?: RequestInit): Promise<TypeCreate[]> => {
return this.#api.stagesAllTypesList(requestInit);
};
protected updated(changedProperties: PropertyValues<this>): void {
protected override updated(changedProperties: PropertyValues<this>): void {
super.updated(changedProperties);
if (changedProperties.has("showBindingPage")) {
@@ -51,17 +55,52 @@ export class AKStageWizard extends CreateWizard {
}
}
protected createBindingActivate = async (context: FormWizardPage) => {
const createSlot = context.host.steps[1];
const bindingForm = context.querySelector<StageBindingForm>("ak-stage-binding-form");
protected createBindingActivate = async (
context: FormWizardPage<{ "create-binding": Stage }>,
) => {
const createSlot = context.host.steps[1] as "create-binding";
const bindingForm = context.querySelector("ak-stage-binding-form");
if (!bindingForm) return;
bindingForm.instance = {
stage: (context.host.state[createSlot] as Stage).pk,
} as FlowStageBinding;
if (context.host.state[createSlot]) {
bindingForm.instance = {
stage: (context.host.state[createSlot] as Stage).pk,
} as FlowStageBinding;
}
};
protected override renderCreateBefore(): SlottedTemplateResult {
if (!this.showBindingPage) {
return null;
}
return html`<ak-form-group
slot="pre-items"
label=${msg("Existing Stage")}
description=${msg("Bind an existing stage to this flow.")}
open
>
<ak-radio
.options=${[
{
label: "Bind existing stage",
description: msg("Bind an existing stage to this flow."),
value: true,
},
] satisfies RadioOption<boolean>[]}
@change=${() => {
if (!this.wizard) {
return;
}
this.wizard.navigateNext();
}}
>
</ak-radio>
</ak-form-group>`;
}
protected renderForms(): SlottedTemplateResult {
const bindingPage = this.showBindingPage
? html`<ak-wizard-page-form

View File

@@ -1,73 +1,145 @@
import "#elements/buttons/SpinnerButton/index";
import "#elements/forms/FormGroup";
import { parseAPIResponseError, pluckErrorDetail } from "#common/errors/network";
import { MessageLevel } from "#common/messages";
import { DEFAULT_CONFIG } from "#common/api/config";
import { formatDisambiguatedUserDisplayName } from "#common/users";
import { showMessage } from "#elements/messages/MessageContainer";
import { UserDeleteForm } from "#elements/user/utils";
import { RawContent } from "#elements/ak-table/ak-simple-table";
import { modalInvoker } from "#elements/dialogs";
import { pluckEntityName } from "#elements/entities/names";
import { DestructiveModelForm } from "#elements/forms/DestructiveModelForm";
import { WithLocale } from "#elements/mixins/locale";
import { SlottedTemplateResult } from "#elements/types";
import { msg, str } from "@lit/localize";
import { html, TemplateResult } from "lit";
import { CoreApi, UsedBy, User } from "@goauthentik/api";
import { str } from "@lit/localize";
import { msg } from "@lit/localize/init/install";
import { html } from "lit-html";
import { customElement } from "lit/decorators.js";
@customElement("ak-user-active-form")
export class UserActiveForm extends UserDeleteForm {
onSuccess(): void {
showMessage({
message: msg(
str`Successfully updated ${this.objectLabel} ${this.getObjectDisplayName()}`,
),
level: MessageLevel.success,
/**
* A form for activating/deactivating a user.
*/
@customElement("ak-user-activation-toggle-form")
export class UserActivationToggleForm extends WithLocale(DestructiveModelForm<User>) {
public static override verboseName = msg("User");
public static override verboseNamePlural = msg("Users");
protected coreAPI = new CoreApi(DEFAULT_CONFIG);
protected override send(): Promise<unknown> {
if (!this.instance) {
return Promise.reject(new Error("No user instance provided"));
}
const nextActiveState = !this.instance.isActive;
return this.coreAPI.coreUsersPartialUpdate({
id: this.instance.pk,
patchedUserRequest: {
isActive: nextActiveState,
},
});
}
onError(error: unknown): Promise<void> {
return parseAPIResponseError(error).then((parsedError) => {
showMessage({
message: msg(
str`Failed to update ${this.objectLabel}: ${pluckErrorDetail(parsedError)}`,
),
level: MessageLevel.error,
});
});
public override formatSubmitLabel(): string {
return super.formatSubmitLabel(
this.instance?.isActive ? msg("Deactivate") : msg("Activate"),
);
}
override renderModalInner(): TemplateResult {
const objName = this.getFormattedObjectName();
return html`<section class="pf-c-modal-box__header pf-c-page__main-section pf-m-light">
<div class="pf-c-content">
<h1 class="pf-c-title pf-m-2xl">${msg(str`Update ${this.objectLabel}`)}</h1>
</div>
</section>
<section class="pf-c-modal-box__body pf-m-light">
<form class="pf-c-form pf-m-horizontal">
<p>
${msg(str`Are you sure you want to update ${this.objectLabel}${objName}?`)}
</p>
</form>
</section>
<fieldset class="pf-c-modal-box__footer">
<legend class="sr-only">${msg("Form actions")}</legend>
<ak-spinner-button
.callAction=${async () => {
this.open = false;
}}
class="pf-m-secondary"
>${msg("Cancel")}</ak-spinner-button
>
<ak-spinner-button
.callAction=${() => {
return this.confirm();
}}
class="pf-m-warning"
>${msg("Save Changes")}</ak-spinner-button
>
</fieldset>`;
public override formatSubmittingLabel(): string {
return super.formatSubmittingLabel(
this.instance?.isActive ? msg("Deactivating...") : msg("Activating..."),
);
}
protected override formatDisplayName(): string {
if (!this.instance) {
return msg("Unknown user");
}
return formatDisambiguatedUserDisplayName(this.instance, this.activeLanguageTag);
}
protected override formatHeadline(): string {
return this.instance?.isActive
? msg(str`Review ${this.verboseName} Deactivation`, {
id: "form.headline.deactivation",
})
: msg(str`Review ${this.verboseName} Activation`, { id: "form.headline.activation" });
}
public override usedBy = (): Promise<UsedBy[]> => {
if (!this.instance) {
return Promise.resolve([]);
}
return this.coreAPI.coreUsersUsedByList({ id: this.instance.pk });
};
protected override renderUsedBySection(): SlottedTemplateResult {
if (this.instance?.isActive) {
return super.renderUsedBySection();
}
const displayName = this.formatDisplayName();
const { usedByList, verboseName } = this;
return html`<ak-form-group
open
label=${msg("Objects associated with this user", {
id: "usedBy.associated-objects.label",
})}
>
<div
class="pf-m-monospace"
aria-description=${msg(
str`List of objects that are associated with this ${verboseName}.`,
{
id: "usedBy.description",
},
)}
slot="description"
>
${displayName}
</div>
<ak-simple-table
.columns=${[msg("Object Name"), msg("ID")]}
.content=${usedByList.map((ub): RawContent[] => {
return [pluckEntityName(ub) || msg("Unnamed"), html`<code>${ub.pk}</code>`];
})}
></ak-simple-table>
</ak-form-group>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-user-active-form": UserActiveForm;
"ak-user-activation-toggle-form": UserActivationToggleForm;
}
}
export interface ToggleUserActivationButtonProps {
className?: string;
}
export function ToggleUserActivationButton(
user: User,
{ className = "" }: ToggleUserActivationButtonProps = {},
): SlottedTemplateResult {
const label = user.isActive ? msg("Deactivate") : msg("Activate");
const tooltip = user.isActive
? msg("Lock the user out of this system")
: msg("Allow the user to log in and use this system");
return html`<button
class="pf-c-button pf-m-warning ${className}"
type="button"
${modalInvoker(UserActivationToggleForm, {
instance: user,
})}
>
<pf-tooltip position="top" content=${tooltip}>${label}</pf-tooltip>
</button>`;
}

View File

@@ -30,6 +30,7 @@ import { SlottedTemplateResult } from "#elements/types";
import { AKUserWizard } from "#admin/users/ak-user-wizard";
import { RecoveryButtons } from "#admin/users/recovery";
import { ToggleUserActivationButton } from "#admin/users/UserActiveForm";
import { UserForm } from "#admin/users/UserForm";
import { UserImpersonateForm } from "#admin/users/UserImpersonateForm";
@@ -69,7 +70,7 @@ export class UserListPage extends WithBrandConfig(
.pf-c-avatar {
max-height: var(--pf-c-avatar--Height);
max-width: var(--pf-c-avatar--Width);
margin-bottom: calc(var(--pf-c-avatar--Width) * -0.6);
vertical-align: middle;
}
`,
];
@@ -309,22 +310,7 @@ export class UserListPage extends WithBrandConfig(
</dt>
<dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text">
<ak-user-active-form
object-label=${msg("User")}
.obj=${item}
.delete=${() => {
return this.#api.coreUsersPartialUpdate({
id: item.pk,
patchedUserRequest: {
isActive: !item.isActive,
},
});
}}
>
<button slot="trigger" class="pf-c-button pf-m-warning">
${item.isActive ? msg("Deactivate") : msg("Activate")}
</button>
</ak-user-active-form>
${ToggleUserActivationButton(item)}
</div>
</dd>
</div>

View File

@@ -28,27 +28,34 @@ import "./UserDevicesTable.js";
import "#elements/ak-mdx/ak-mdx";
import { DEFAULT_CONFIG } from "#common/api/config";
import { AKRefreshEvent } from "#common/events";
import { userTypeToLabel } from "#common/labels";
import { formatUserDisplayName } from "#common/users";
import { formatDisambiguatedUserDisplayName, formatUserDisplayName } from "#common/users";
import { AKElement } from "#elements/Base";
import { listen } from "#elements/decorators/listen";
import { showAPIErrorMessage } from "#elements/messages/MessageContainer";
import { WithBrandConfig } from "#elements/mixins/branding";
import { WithCapabilitiesConfig } from "#elements/mixins/capabilities";
import { WithLicenseSummary } from "#elements/mixins/license";
import { WithLocale } from "#elements/mixins/locale";
import { WithSession } from "#elements/mixins/session";
import { Timestamp } from "#elements/table/shared";
import { SlottedTemplateResult } from "#elements/types";
import { setPageDetails } from "#components/ak-page-navbar";
import { type DescriptionPair, renderDescriptionList } from "#components/DescriptionList";
import { RecoveryButtons } from "#admin/users/recovery";
import { ToggleUserActivationButton } from "#admin/users/UserActiveForm";
import { UserForm } from "#admin/users/UserForm";
import { UserImpersonateForm } from "#admin/users/UserImpersonateForm";
import { CapabilitiesEnum, CoreApi, ModelEnum, User } from "@goauthentik/api";
import { msg, str } from "@lit/localize";
import { css, html, nothing, PropertyValues, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { css, html, PropertyValues, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import PFBanner from "@patternfly/patternfly/components/Banner/banner.css";
@@ -62,20 +69,16 @@ import PFDisplay from "@patternfly/patternfly/utilities/Display/display.css";
import PFSizing from "@patternfly/patternfly/utilities/Sizing/sizing.css";
@customElement("ak-user-view")
export class UserViewPage extends WithBrandConfig(WithCapabilitiesConfig(WithSession(AKElement))) {
@property({ type: Number })
set userId(id: number) {
new CoreApi(DEFAULT_CONFIG)
.coreUsersRetrieve({
id: id,
})
.then((user) => {
this.user = user;
});
}
export class UserViewPage extends WithLicenseSummary(
WithLocale(WithBrandConfig(WithCapabilitiesConfig(WithSession(AKElement)))),
) {
#api = new CoreApi(DEFAULT_CONFIG);
@state()
protected user: User | null = null;
@property({ type: Number, useDefault: true })
public userId: number | null = null;
@property({ attribute: false, useDefault: true })
public user: User | null = null;
static styles = [
PFPage,
@@ -103,26 +106,64 @@ export class UserViewPage extends WithBrandConfig(WithCapabilitiesConfig(WithSes
`,
];
renderUserCard() {
@listen(AKRefreshEvent)
public refresh = () => {
if (!this.userId) {
return;
}
return this.#api
.coreUsersRetrieve({
id: this.userId!,
})
.then((user) => {
this.user = user;
})
.catch(showAPIErrorMessage);
};
protected override updated(changed: PropertyValues<this>) {
super.updated(changed);
if (changed.has("userId") && this.userId !== null) {
this.refresh();
}
if (changed.has("user") && this.user) {
const { username, avatar, name, email } = this.user;
const icon = avatar ?? "pf-icon pf-icon-user";
setPageDetails({
icon,
iconImage: !!avatar,
header: username ? msg(str`User ${username}`) : msg("User"),
description: this.user
? formatDisambiguatedUserDisplayName({ name, email }, this.activeLanguageTag)
: null,
});
}
}
protected renderUserCard() {
if (!this.user) {
return nothing;
return null;
}
const user = this.user;
// prettier-ignore
const userInfo: DescriptionPair[] = [
[msg("Username"), user.username],
[msg("Name"), user.name],
[msg("Email"), user.email || "-"],
[msg("Last login"), Timestamp(user.lastLogin)],
[msg("Last password change"), Timestamp(user.passwordChangeDate)],
[msg("Active"), html`<ak-status-label ?good=${user.isActive}></ak-status-label>`],
[msg("Type"), userTypeToLabel(user.type)],
[msg("Superuser"), html`<ak-status-label type="warning" ?good=${user.isSuperuser}></ak-status-label>`],
[msg("Actions"), this.renderActionButtons(user)],
[msg("Recovery"), this.renderRecoveryButtons(user)],
];
[ msg("Username"), user.username ],
[ msg("Name"), user.name ],
[ msg("Email"), user.email || "-" ],
[ msg("Last login"), Timestamp(user.lastLogin) ],
[ msg("Last password change"), Timestamp(user.passwordChangeDate) ],
[ msg("Active"), html`<ak-status-label ?good=${user.isActive}></ak-status-label>` ],
[ msg("Type"), userTypeToLabel(user.type) ],
[ msg("Superuser"), html`<ak-status-label type="warning" ?good=${user.isSuperuser}></ak-status-label>` ],
[ msg("Actions"), this.renderActionButtons(user) ],
[ msg("Recovery"), this.renderRecoveryButtons(user) ],
]
return html`
<div class="pf-c-card__title">${msg("User Info")}</div>
@@ -132,7 +173,7 @@ export class UserViewPage extends WithBrandConfig(WithCapabilitiesConfig(WithSes
`;
}
renderActionButtons(user: User) {
protected renderActionButtons(user: User): SlottedTemplateResult {
const showImpersonate =
this.can(CapabilitiesEnum.CanImpersonate) && user.pk !== this.currentUser?.pk;
@@ -145,29 +186,8 @@ export class UserViewPage extends WithBrandConfig(WithCapabilitiesConfig(WithSes
>
${msg("Edit User")}
</button>
<ak-user-active-form
.obj=${user}
object-label=${msg("User")}
.delete=${() => {
return new CoreApi(DEFAULT_CONFIG).coreUsersPartialUpdate({
id: user.pk,
patchedUserRequest: {
isActive: !user.isActive,
},
});
}}
>
<button slot="trigger" class="pf-c-button pf-m-warning pf-m-block">
<pf-tooltip
position="top"
content=${user.isActive
? msg("Lock the user out of this system")
: msg("Allow the user to log in and use this system")}
>
${user.isActive ? msg("Deactivate") : msg("Activate")}
</pf-tooltip>
</button>
</ak-user-active-form>
${ToggleUserActivationButton(user, { className: "pf-m-block" })}
${showImpersonate
? html`<button
class="pf-c-button pf-m-tertiary pf-m-block"
@@ -185,7 +205,7 @@ export class UserViewPage extends WithBrandConfig(WithCapabilitiesConfig(WithSes
</div> `;
}
renderRecoveryButtons(user: User) {
protected renderRecoveryButtons(user: User) {
return html`<div class="ak-button-collection">
${RecoveryButtons({
user,
@@ -195,7 +215,7 @@ export class UserViewPage extends WithBrandConfig(WithCapabilitiesConfig(WithSes
</div>`;
}
renderTabCredentialsToken(user: User): TemplateResult {
protected renderTabCredentialsToken(user: User): TemplateResult {
return html`
<ak-tabs pageIdentifier="userCredentialsTokens" vertical>
<div
@@ -308,7 +328,7 @@ export class UserViewPage extends WithBrandConfig(WithCapabilitiesConfig(WithSes
`;
}
renderTabApplications(user: User): TemplateResult {
protected renderTabApplications(user: User): TemplateResult {
return html`<div class="pf-c-card">
<ak-user-application-table .user=${user}></ak-user-application-table>
</div>`;
@@ -348,10 +368,11 @@ export class UserViewPage extends WithBrandConfig(WithCapabilitiesConfig(WithSes
`;
}
render() {
protected override render() {
if (!this.user) {
return nothing;
return null;
}
return html`<main>
<ak-tabs>
<div
@@ -476,16 +497,6 @@ export class UserViewPage extends WithBrandConfig(WithCapabilitiesConfig(WithSes
</ak-tabs>
</main>`;
}
updated(changed: PropertyValues<this>) {
super.updated(changed);
setPageDetails({
icon: this.user?.avatar ?? "pf-icon pf-icon-user",
iconImage: !!this.user?.avatar,
header: this.user?.username ? msg(str`User ${this.user.username}`) : msg("User"),
description: this.user?.name || "",
});
}
}
declare global {

View File

@@ -0,0 +1,68 @@
/**
* Defines the plural forms for a given locale, and provides a function to select the appropriate form based on a count.
*
* @see {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/PluralRules MDN} for more information on plural categories and rules.
*/
export interface PluralForms {
/**
* The "other" form is required as a fallback for categories that may not be provided.
* For example, if only "one" and "other" are provided,
* then "other" will be used for all counts that don't fall into the "one" category.
*/
other: () => string;
/**
* Used for counts that fall into the "one" category for the given locale.
*/
one?: () => string;
/**
* Used for counts that fall into the "two" category for the given locale.
*/
two?: () => string;
/**
* Used for counts that fall into the "few" category for the given locale.
*/
few?: () => string;
/**
* Used for counts that fall into the "many" category for the given locale.
*/
many?: () => string;
/**
* Used for counts that fall into the "zero" category for the given locale.
*/
zero?: () => string;
}
/**
* Cache of {@linkcode Intl.PluralRules} instances, keyed by locale argument. The empty string key is used for the default locale.
*/
const PluralRulesCache = new Map<Intl.LocalesArgument, Intl.PluralRules>();
/**
* Get an {@linkcode Intl.PluralRules} instance for the given locale, using a cache to avoid unnecessary allocations.
*
* @param locale The locale to get plural rules for, or undefined to use the default locale.
* @returns An {@linkcode Intl.PluralRules} instance for the given locale.
*/
function getPluralRules(locale?: Intl.LocalesArgument): Intl.PluralRules {
const key = locale ?? "";
let pr = PluralRulesCache.get(key);
if (!pr) {
pr = new Intl.PluralRules(locale);
PluralRulesCache.set(key, pr);
}
return pr;
}
/**
* Get the appropriate plural form for a given count and set of forms.
*
* @param count The count to get the plural form for.
* @param forms The forms to use for each plural category.
* @param locale The locale to use for determining the plural category, or undefined to use the default locale.
*/
export function plural(count: number, forms: PluralForms, locale?: Intl.LocalesArgument): string {
const category = getPluralRules(locale).select(count);
return (forms[category] ?? forms.other)();
}

View File

@@ -6,12 +6,14 @@ import { CoreApi, SessionUser, UserSelf } from "@goauthentik/api";
import { match } from "ts-pattern";
import { msg, str } from "@lit/localize";
export interface ClientSessionPermissions {
editApplications: boolean;
accessAdmin: boolean;
}
export type UserLike = Pick<UserSelf, "username" | "name" | "email">;
export type UserLike = Partial<Pick<UserSelf, "username" | "name" | "email">>;
/**
* The display name of the current user, according to their UI config settings.
@@ -29,6 +31,72 @@ export function formatUserDisplayName(user: UserLike | null, uiConfig?: UIConfig
return label || "";
}
const formatUnknownUserLabel = () =>
msg("Unknown user", {
id: "user.display.unknownUser",
desc: "Placeholder for an unknown user, in the format 'Unknown user'.",
});
/**
* Format a user's display name with disambiguation, such as when multiple users have the same name appearing in a list.
*/
export function formatDisambiguatedUserDisplayName(
user?: UserLike | null,
formatter?: Intl.ListFormat,
): string;
export function formatDisambiguatedUserDisplayName(
user?: UserLike | null,
locale?: Intl.LocalesArgument,
): string;
export function formatDisambiguatedUserDisplayName(
user?: UserLike | null,
localeOrFormatter?: Intl.ListFormat | Intl.LocalesArgument,
): string {
if (!user) {
return formatUnknownUserLabel();
}
const formatter =
localeOrFormatter instanceof Intl.ListFormat
? localeOrFormatter
: new Intl.ListFormat(localeOrFormatter, { style: "narrow", type: "unit" });
const { username, name, email } = user;
const segments: string[] = [];
if (username) {
segments.push(username);
}
if (name && name !== username) {
if (segments.length === 0) {
segments.push(name);
} else {
segments.push(
msg(str`(${name})`, {
id: "user.display.nameInParens",
desc: "The user's name in parentheses, used when the name is different from the username",
}),
);
}
}
if (email && email !== username) {
segments.push(
msg(str`<${email}>`, {
id: "user.display.emailInAngleBrackets",
desc: "The user's email in angle brackets, used when the email is different from the username",
}),
);
}
if (!segments.length) {
return formatUnknownUserLabel();
}
return formatter.format(segments);
}
/**
* Whether the current session is an unauthenticated guest session.
*/

View File

@@ -7,13 +7,14 @@ import { AKElement } from "#elements/Base";
import { WithBrandConfig } from "#elements/mixins/branding";
import { WithSession } from "#elements/mixins/session";
import { isAdminRoute } from "#elements/router/utils";
import { SlottedTemplateResult } from "#elements/types";
import { ThemedImage } from "#elements/utils/images";
import Styles from "#components/ak-page-navbar.css";
import { msg } from "@lit/localize";
import { CSSResult, html, nothing, TemplateResult } from "lit";
import { customElement, state } from "lit/decorators.js";
import { customElement, property } from "lit/decorators.js";
import { guard } from "lit/directives/guard.js";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
@@ -38,7 +39,7 @@ export function setPageDetails(header: PageHeaderInit) {
export interface PageHeaderInit {
header?: string | null;
description?: string | null;
description?: SlottedTemplateResult;
icon?: string | null;
iconImage?: boolean;
}
@@ -73,20 +74,20 @@ export class AKPageNavbar
//#region Properties
@state()
icon?: string | null = null;
@property({ attribute: false })
public icon?: string | null = null;
@state()
iconImage = false;
@property({ attribute: false })
public iconImage = false;
@state()
header?: string | null = null;
@property({ attribute: false })
public header?: string | null = null;
@state()
description?: string | null = null;
@property({ attribute: false })
public description?: SlottedTemplateResult = null;
@state()
hasIcon = true;
@property({ attribute: false })
public hasIcon = true;
//#endregion

View File

@@ -2,17 +2,37 @@ import { HorizontalLightComponent } from "./HorizontalLightComponent.js";
import { ifPresent } from "#elements/utils/attributes";
import { html } from "lit";
import { html, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
@customElement("ak-textarea-input")
export class AkTextareaInput extends HorizontalLightComponent<string> {
@property({ type: String, reflect: true })
public value = "";
@property({ type: Number })
public rows?: number;
@property({ type: Number })
public maxLength: number = -1;
@property({ type: String })
public placeholder: string | null = null;
public placeholder: string = "";
public override connectedCallback(): void {
super.connectedCallback();
// Listen for form reset events to clear the value
this.closest("form")?.addEventListener("reset", this.handleReset);
}
disconnectedCallback(): void {
super.disconnectedCallback();
this.closest("form")?.removeEventListener("reset", this.handleReset);
}
private handleReset = (): void => {
this.value = "";
};
public override renderControl() {
const code = this.inputHint === "code";
@@ -22,11 +42,13 @@ export class AkTextareaInput extends HorizontalLightComponent<string> {
// Prevent the leading spaces added by Prettier's whitespace algo
// prettier-ignore
return html`<textarea
id=${ifDefined(this.fieldID)}
id=${ifPresent(this.fieldID)}
@input=${setValue}
class="pf-c-form-control"
?required=${this.required}
name=${this.name}
rows=${ifPresent(this.rows)}
maxlength=${(this.maxLength >= 0) ? this.maxLength : nothing}
placeholder=${ifPresent(this.placeholder)}
autocomplete=${ifPresent(code, "off")}
spellcheck=${ifPresent(code, "false")}

View File

@@ -1,11 +1,21 @@
import "#elements/EmptyState";
import { TableColumn } from "./TableColumn.js";
import type { Column, TableFlat, TableGroup, TableGrouped, TableRow } from "./types.js";
import { convertContent } from "./utils.js";
import { AKElement } from "#elements/Base";
import { bound } from "#elements/decorators/bound";
import {
EntityDescriptorElement,
isTransclusionParentElement,
TransclusionChildElement,
TransclusionChildSymbol,
} from "#elements/dialogs/shared";
import { WithLocale } from "#elements/mixins/locale";
import { SlottedTemplateResult } from "#elements/types";
import { randomId } from "#elements/utils/randomId";
import { msg, str } from "@lit/localize";
import { css, html, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import { map } from "lit/directives/map.js";
@@ -70,43 +80,90 @@ export interface ISimpleTable {
* which is zero-indexed
*
*/
@customElement("ak-simple-table")
export class SimpleTable extends AKElement implements ISimpleTable {
static styles = [
export class SimpleTable
extends WithLocale(AKElement)
implements ISimpleTable, TransclusionChildElement
{
declare ["constructor"]: Required<EntityDescriptorElement>;
public static verboseName: string = msg("Object");
public static verboseNamePlural: string = msg("Objects");
public static styles = [
PFTable,
css`
.pf-c-table thead .pf-c-table__check {
min-width: 3rem;
}
.pf-c-table tbody .pf-c-table__check input {
margin-top: calc(var(--pf-c-table__check--input--MarginTop) + 1px);
}
.pf-c-toolbar__content {
row-gap: var(--pf-global--spacer--sm);
}
.pf-c-toolbar__item .pf-c-input-group {
padding: 0 var(--pf-global--spacer--sm);
}
tr:last-child {
--pf-c-table--BorderColor: transparent;
}
`,
];
public [TransclusionChildSymbol] = true;
#verboseName: string | null = null;
/**
* Optional singular label for the type of entity this form creates/edits.
*
* Overrides the static `verboseName` property for this instance.
*/
@property({ type: String, attribute: "entity-singular" })
public set verboseName(value: string | null) {
this.#verboseName = value;
if (isTransclusionParentElement(this.parentElement)) {
this.parentElement.slottedElementUpdatedAt = new Date();
}
}
public get verboseName(): string | null {
return this.#verboseName || this.constructor.verboseName || null;
}
#verboseNamePlural: string | null = null;
/**
* Optional plural label for the type of entity this form creates/edits.
*
* Overrides the static `verboseNamePlural` property for this instance.
*/
@property({ type: String, attribute: "entity-plural" })
public set verboseNamePlural(value: string | null) {
this.#verboseNamePlural = value;
if (isTransclusionParentElement(this.parentElement)) {
this.parentElement.slottedElementUpdatedAt = new Date();
}
}
public get verboseNamePlural(): string | null {
return this.#verboseNamePlural || this.constructor.verboseNamePlural || null;
}
@property({ type: String, attribute: true, reflect: true })
order?: string;
public order?: string;
@property({ type: Array, attribute: false })
columns: Column[] = [];
public columns: Column[] = [];
@property({ type: Object, attribute: false })
set content(content: ContentType) {
this._content = convertContent(content);
public set content(content: ContentType) {
this.#content = convertContent(content);
}
get content(): TableGrouped | TableFlat {
return this._content;
public get content(): TableGrouped | TableFlat {
return this.#content;
}
private _content: TableGrouped | TableFlat = {
#content: TableGrouped | TableFlat = {
kind: "flat",
content: [],
};
@@ -141,62 +198,81 @@ export class SimpleTable extends AKElement implements ISimpleTable {
super.performUpdate();
}
public renderRow(row: TableRow, _rownum: number) {
return html` <tr part="row">
protected renderEmpty(): SlottedTemplateResult {
const columnCount = this.columns.length || 1;
const verboseNamePlural = this.constructor.verboseNamePlural || msg("Objects");
const message = msg(
str`No ${verboseNamePlural.toLocaleLowerCase(this.activeLanguageTag)} found.`,
{
id: "table.empty",
desc: "The message to show when a table has no content. The placeholder {0} is replaced with the pluralized name of the type of entity being shown in the table.",
},
);
return html`<tr role="presentation">
<td role="presentation" colspan=${columnCount}>
<div class="pf-l-bullseye">
<ak-empty-state><span>${message}</span></ak-empty-state>
</div>
</td>
</tr>`;
}
protected renderRow(row: TableRow, _rownum: number): SlottedTemplateResult {
return html`<tr part="row">
${map(row.content, (col, idx) => html`<td part="cell cell-${idx}">${col}</td>`)}
</tr>`;
}
public renderRows(rows: TableRow[]) {
protected renderRows(rows: TableRow[]): SlottedTemplateResult {
return html`<tbody part="body">
${repeat(rows, (row) => row.key, this.renderRow)}
${rows.length ? repeat(rows, (row) => row.key, this.renderRow) : this.renderEmpty()}
</tbody>`;
}
@bound
public renderRowGroup({ group, content }: TableGroup) {
protected renderRowGroup = ({ group, content }: TableGroup): SlottedTemplateResult => {
return html`<thead part="group-header">
<tr part="group-row">
<td colspan="200" part="group-head">${group}</td>
</tr>
</thead>
${this.renderRows(content)}`;
}
};
@bound
public renderRowGroups(rowGroups: TableGroup[]) {
return html`${map(rowGroups, this.renderRowGroup)}`;
}
protected renderRowGroups = (rowGroups: TableGroup[]): SlottedTemplateResult => {
return map(rowGroups, this.renderRowGroup);
};
public renderBody() {
// prettier-ignore
return this.content.kind === 'flat'
protected renderBody(): SlottedTemplateResult {
return this.content.kind === "flat"
? this.renderRows(this.content.content)
: this.renderRowGroups(this.content.content);
}
public renderColumnHeaders() {
protected renderColumnHeaders(): SlottedTemplateResult {
return html`<tr part="column-row" role="row">
${map(this.icolumns, (col) => col.render(this.order))}
</tr>`;
}
public renderTable() {
return html`
<table part="table" class="pf-c-table pf-m-compact pf-m-grid-md pf-m-expandable">
<thead part="column-header">
${this.renderColumnHeaders()}
</thead>
${this.renderBody()}
</table>
`;
protected renderTable(): SlottedTemplateResult {
return html`<table
part="table"
class="pf-c-table pf-m-compact pf-m-grid-md pf-m-expandable"
>
<thead part="column-header">
${this.renderColumnHeaders()}
</thead>
${this.renderBody()}
</table> `;
}
public render() {
protected render(): SlottedTemplateResult {
return this.renderTable();
}
public override updated() {
public override updated(): void {
this.setAttribute("data-ouia-component-safe", "true");
}
}

View File

@@ -50,9 +50,21 @@ export interface DialogInit {
onDispose?: (event?: Event) => void;
}
export interface TransclusionElementConstructor extends CustomElementConstructor {
export interface EntityDescriptor {
/**
* Singular label for the type of entity this form creates/edits.
*/
verboseName?: string | null;
/**
* Plural label for the type of entity this form creates/edits.
*/
verboseNamePlural?: string | null;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
export interface EntityDescriptorElement extends Function, EntityDescriptor {}
export interface TransclusionElementConstructor extends EntityDescriptor, CustomElementConstructor {
createLabel?: string | null;
}

View File

@@ -0,0 +1,99 @@
import { PFSize } from "#common/enums";
import { UsedByListItem } from "#elements/entities/used-by";
import { StaticTable } from "#elements/table/StaticTable";
import { TableColumn } from "#elements/table/TableColumn";
import { SlottedTemplateResult } from "#elements/types";
import { type UsedBy } from "@goauthentik/api";
import { msg } from "@lit/localize";
import { CSSResult, PropertyValues } from "lit";
import { html } from "lit-html";
import { until } from "lit-html/directives/until.js";
import { customElement, property, state } from "lit/decorators.js";
import PFList from "@patternfly/patternfly/components/List/list.css";
export interface BulkDeleteMetadata {
key: string;
value: string;
}
@customElement("ak-used-by-table")
export class UsedByTable<T extends object> extends StaticTable<T> {
static styles: CSSResult[] = [...super.styles, PFList];
@property({ attribute: false })
public metadata: (item: T) => BulkDeleteMetadata[] = (item: T) => {
const metadata: BulkDeleteMetadata[] = [];
if ("name" in item) {
metadata.push({ key: msg("Name"), value: item.name as string });
}
return metadata;
};
@property({ attribute: false })
public usedBy: null | ((item: T) => Promise<UsedBy[]>) = null;
@state()
protected usedByData: Map<T, UsedBy[]> = new Map();
protected override rowLabel(item: T): string | null {
const name = "name" in item && typeof item.name === "string" ? item.name.trim() : null;
return name || null;
}
@state()
protected get columns(): TableColumn[] {
const [first] = this.items || [];
if (!first) {
return [];
}
return this.metadata(first).map((element) => [element.key]);
}
protected override row(item: T): SlottedTemplateResult[] {
return this.metadata(item).map((element) => element.value);
}
protected override renderToolbarContainer(): SlottedTemplateResult {
return null;
}
protected override firstUpdated(changedProperties: PropertyValues<this>): void {
this.expandable = !!this.usedBy;
super.firstUpdated(changedProperties);
}
protected override renderExpanded(item: T): SlottedTemplateResult {
const handler = async () => {
if (!this.usedByData.has(item) && this.usedBy) {
this.usedByData.set(item, await this.usedBy(item));
}
return this.renderUsedBy(this.usedByData.get(item) || []);
};
return html`${this.usedBy
? until(handler(), html`<ak-spinner size=${PFSize.Large}></ak-spinner>`)
: null}`;
}
protected renderUsedBy(usedBy: UsedBy[]): SlottedTemplateResult {
if (usedBy.length < 1) {
return html`<span>${msg("Not used by any other object.")}</span>`;
}
return html`<ul class="pf-c-list">
${usedBy.map((ub) => UsedByListItem({ ub }))}
</ul>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-used-by-table": UsedByTable<object>;
}
}

View File

@@ -0,0 +1,21 @@
/**
* Given an object and a key, returns the trimmed string value of the key if it exists, otherwise returns null.
*
* @param item The object to pluck the name from.
* @param key The key to look for in the object, defaults to "name".
* @returns The trimmed string value of the key if it exists, otherwise null.
*/
export function pluckEntityName<T extends object, K extends Extract<keyof T, string>>(
item?: T | null,
key: K = "name" as K,
): string | null {
if (typeof item !== "object" || item === null) {
return null;
}
if (!(key in item)) {
return null;
}
return typeof item[key] === "string" ? item[key].trim() : null;
}

View File

@@ -0,0 +1,79 @@
import { pluckEntityName } from "#elements/entities/names";
import { LitFC } from "#elements/types";
import { UsedBy, UsedByActionEnum } from "@goauthentik/api";
import { match } from "ts-pattern";
import { msg, str } from "@lit/localize";
import { html } from "lit-html";
export function formatUsedByConsequence(usedBy: UsedBy, verboseName?: string): string {
verboseName ||= msg("Object");
return match(usedBy.action)
.with(UsedByActionEnum.Cascade, () => {
const relationName = usedBy.modelName || msg("Related object");
return msg(str`${relationName} will be deleted`, {
id: "used-by.consequence.cascade",
desc: "Consequence of deletion, when the related object will also be deleted. The name of the related object will be included, in the format 'Related object will be deleted'.",
});
})
.with(UsedByActionEnum.CascadeMany, () =>
msg(str`Connection will be deleted`, {
id: "used-by.consequence.cascade-many",
}),
)
.with(UsedByActionEnum.SetDefault, () =>
msg(str`Reference will be reset to default value`, {
id: "used-by.consequence.set-default",
}),
)
.with(UsedByActionEnum.SetNull, () =>
msg(str`Reference will be set to an empty value`, {
id: "used-by.consequence.set-null",
}),
)
.with(UsedByActionEnum.LeftDangling, () =>
msg(str`${verboseName} will be left dangling (may cause errors)`, {
id: "used-by.consequence.left-dangling",
}),
)
.with(UsedByActionEnum.UnknownDefaultOpenApi, () =>
msg(str`${verboseName} has an unknown relationship (check logs)`, {
id: "used-by.consequence.unknown-default-open-api",
}),
)
.otherwise(() =>
msg(str`${verboseName} has an unrecognized relationship (check logs)`, {
id: "used-by.consequence.unrecognized",
}),
);
}
export interface UsedByListItemProps {
ub: UsedBy;
formattedName?: string;
verboseName?: string | null;
}
export function formatUsedByMessage({
ub,
verboseName,
formattedName,
}: UsedByListItemProps): string {
verboseName ||= msg("Object");
formattedName ||= pluckEntityName(ub) || msg("Unnamed");
const consequence = formatUsedByConsequence(ub, verboseName);
return msg(str`${formattedName} (${consequence})`, {
id: "used-by-list-item",
desc: "Used in list item, showing the name of the object and the consequence of deletion.",
});
}
export const UsedByListItem: LitFC<UsedByListItemProps> = (props) => {
return html`<li>${formatUsedByMessage(props)}</li>`;
};

View File

@@ -1,111 +1,19 @@
import "#elements/buttons/SpinnerButton/index";
import "#elements/entities/UsedByTable";
import { EVENT_REFRESH } from "#common/constants";
import { PFSize } from "#common/enums";
import { parseAPIResponseError, pluckErrorDetail } from "#common/errors/network";
import { MessageLevel } from "#common/messages";
import { ModalButton } from "#elements/buttons/ModalButton";
import { BulkDeleteMetadata } from "#elements/entities/UsedByTable";
import { showMessage } from "#elements/messages/MessageContainer";
import { StaticTable } from "#elements/table/StaticTable";
import { TableColumn } from "#elements/table/Table";
import { SlottedTemplateResult } from "#elements/types";
import { UsedBy, UsedByActionEnum } from "@goauthentik/api";
import { UsedBy } from "@goauthentik/api";
import { msg, str } from "@lit/localize";
import { CSSResult, html, nothing, PropertyValues, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { until } from "lit/directives/until.js";
import PFList from "@patternfly/patternfly/components/List/list.css";
type BulkDeleteMetadata = { key: string; value: string }[];
@customElement("ak-delete-objects-table")
export class DeleteObjectsTable<T extends object> extends StaticTable<T> {
static styles: CSSResult[] = [...super.styles, PFList];
@property({ attribute: false })
public metadata: (item: T) => BulkDeleteMetadata = (item: T) => {
const metadata: BulkDeleteMetadata = [];
if ("name" in item) {
metadata.push({ key: msg("Name"), value: item.name as string });
}
return metadata;
};
@property({ attribute: false })
public usedBy?: (item: T) => Promise<UsedBy[]>;
@state()
protected usedByData: Map<T, UsedBy[]> = new Map();
protected override rowLabel(item: T): string | null {
const name = "name" in item && typeof item.name === "string" ? item.name.trim() : null;
return name || null;
}
@state()
protected get columns(): TableColumn[] {
return this.metadata(this.items![0]).map((element) => [element.key]);
}
protected row(item: T): SlottedTemplateResult[] {
return this.metadata(item).map((element) => {
return html`${element.value}`;
});
}
protected override renderToolbarContainer(): SlottedTemplateResult {
return nothing;
}
protected override firstUpdated(changedProperties: PropertyValues<this>): void {
this.expandable = !!this.usedBy;
super.firstUpdated(changedProperties);
}
protected override renderExpanded(item: T): TemplateResult {
const handler = async () => {
if (!this.usedByData.has(item) && this.usedBy) {
this.usedByData.set(item, await this.usedBy(item));
}
return this.renderUsedBy(this.usedByData.get(item) || []);
};
return html`${this.usedBy
? until(handler(), html`<ak-spinner size=${PFSize.Large}></ak-spinner>`)
: nothing}`;
}
protected renderUsedBy(usedBy: UsedBy[]): TemplateResult {
if (usedBy.length < 1) {
return html`<span>${msg("Not used by any other object.")}</span>`;
}
return html`<ul class="pf-c-list">
${usedBy.map((ub) => {
let consequence = "";
switch (ub.action) {
case UsedByActionEnum.Cascade:
consequence = msg("object will be DELETED");
break;
case UsedByActionEnum.CascadeMany:
consequence = msg("connection will be deleted");
break;
case UsedByActionEnum.SetDefault:
consequence = msg("reference will be reset to default value");
break;
case UsedByActionEnum.SetNull:
consequence = msg("reference will be set to an empty value");
break;
case UsedByActionEnum.LeftDangling:
consequence = msg("reference will be left dangling");
break;
}
return html`<li>${msg(str`${ub.name} (${consequence})`)}</li>`;
})}
</ul>`;
}
}
import { html, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
@customElement("ak-forms-delete-bulk")
export class DeleteBulkForm<T> extends ModalButton {
@@ -127,61 +35,58 @@ export class DeleteBulkForm<T> extends ModalButton {
/**
* Action shown in messages, for example `deleted` or `removed`
*/
@property()
action = msg("deleted");
@property({ type: String })
public action = msg("deleted");
@property({ attribute: false })
metadata: (item: T) => BulkDeleteMetadata = (item: T) => {
public metadata: (item: T) => BulkDeleteMetadata[] = (item: T) => {
const rec = item as Record<string, unknown>;
const meta = [];
if (Object.prototype.hasOwnProperty.call(rec, "name")) {
const meta: BulkDeleteMetadata[] = [];
if (Object.hasOwn(rec, "name")) {
meta.push({ key: msg("Name"), value: rec.name as string });
}
if (Object.prototype.hasOwnProperty.call(rec, "pk")) {
if (Object.hasOwn(rec, "pk")) {
meta.push({ key: msg("ID"), value: rec.pk as string });
}
return meta;
};
@property({ attribute: false })
usedBy?: (item: T) => Promise<UsedBy[]>;
public usedBy?: (item: T) => Promise<UsedBy[]>;
@property({ attribute: false })
delete!: (item: T) => Promise<unknown>;
public delete!: (item: T) => Promise<unknown>;
async confirm(): Promise<void> {
try {
await Promise.all(
this.objects.map((item) => {
return this.delete(item);
}),
);
this.onSuccess();
this.dispatchEvent(
new CustomEvent(EVENT_REFRESH, {
bubbles: true,
composed: true,
}),
);
this.open = false;
} catch (e) {
this.onError(e as Error);
throw e;
}
}
protected async confirm(): Promise<void> {
return Promise.all(this.objects.map((item) => this.delete(item)))
.then(() => {
showMessage({
message: msg(
str`Successfully deleted ${this.objects.length} ${this.objectLabel}`,
),
level: MessageLevel.success,
});
onSuccess(): void {
showMessage({
message: msg(str`Successfully deleted ${this.objects.length} ${this.objectLabel}`),
level: MessageLevel.success,
});
}
onError(e: Error): void {
showMessage({
message: msg(str`Failed to delete ${this.objectLabel}: ${e.toString()}`),
level: MessageLevel.error,
});
this.dispatchEvent(
new CustomEvent(EVENT_REFRESH, {
bubbles: true,
composed: true,
}),
);
this.open = false;
})
.catch((parsedError: unknown) => {
return parseAPIResponseError(parsedError).then(() => {
showMessage({
message: msg(str`Failed to delete ${this.objectLabel}`),
description: pluckErrorDetail(parsedError),
level: MessageLevel.error,
});
});
});
}
renderModalInner(): TemplateResult {
@@ -207,12 +112,12 @@ export class DeleteBulkForm<T> extends ModalButton {
</form>
</section>
<section class="pf-c-modal-box__body pf-m-light">
<ak-delete-objects-table
<ak-used-by-table
.items=${this.objects}
.usedBy=${this.usedBy}
.metadata=${this.metadata}
>
</ak-delete-objects-table>
</ak-used-by-table>
</section>
<fieldset class="pf-c-modal-box__footer">
<legend class="sr-only">${msg("Form actions")}</legend>
@@ -234,7 +139,6 @@ export class DeleteBulkForm<T> extends ModalButton {
declare global {
interface HTMLElementTagNameMap {
"ak-delete-objects-table": DeleteObjectsTable<object>;
"ak-forms-delete-bulk": DeleteBulkForm<object>;
}
}

View File

@@ -1,176 +0,0 @@
import "#elements/buttons/SpinnerButton/index";
import { EVENT_REFRESH } from "#common/constants";
import { parseAPIResponseError, pluckErrorDetail } from "#common/errors/network";
import { MessageLevel } from "#common/messages";
import { ModalButton } from "#elements/buttons/ModalButton";
import { showMessage } from "#elements/messages/MessageContainer";
import { UsedBy, UsedByActionEnum } from "@goauthentik/api";
import { msg, str } from "@lit/localize";
import { CSSResult, html, nothing, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import { until } from "lit/directives/until.js";
import PFList from "@patternfly/patternfly/components/List/list.css";
@customElement("ak-forms-delete")
export class DeleteForm extends ModalButton {
static styles: CSSResult[] = [...super.styles, PFList];
@property({ attribute: false })
public obj?: Record<string, unknown>;
@property({ type: String, attribute: "object-label" })
public objectLabel?: string;
@property({ attribute: false })
public usedBy?: () => Promise<UsedBy[]>;
@property({ attribute: false })
public delete!: () => Promise<unknown>;
/**
* Get the display name for the object being deleted/updated.
*/
protected getObjectDisplayName(): string | undefined {
return this.obj?.name as string | undefined;
}
/**
* Get the formatted object name for display in messages.
* Returns ` "displayName"` with quotes if display name exists, empty string otherwise.
*/
protected getFormattedObjectName(): string {
const displayName = this.getObjectDisplayName();
return displayName ? ` "${displayName}"` : "";
}
confirm(): Promise<void> {
return this.delete()
.then(() => {
this.onSuccess();
this.open = false;
this.dispatchEvent(
new CustomEvent(EVENT_REFRESH, {
bubbles: true,
composed: true,
}),
);
})
.catch(async (error: unknown) => {
await this.onError(error);
throw error;
});
}
onSuccess(): void {
showMessage({
message: msg(
str`Successfully deleted ${this.objectLabel} ${this.getObjectDisplayName()}`,
),
level: MessageLevel.success,
});
}
onError(error: unknown): Promise<void> {
return parseAPIResponseError(error).then((parsedError) => {
showMessage({
message: msg(
str`Failed to delete ${this.objectLabel}: ${pluckErrorDetail(parsedError)}`,
),
level: MessageLevel.error,
});
});
}
renderModalInner(): TemplateResult {
const objName = this.getFormattedObjectName();
return html`<section class="pf-c-modal-box__header pf-c-page__main-section pf-m-light">
<div class="pf-c-content">
<h1 class="pf-c-title pf-m-2xl">${msg(str`Delete ${this.objectLabel}`)}</h1>
</div>
</section>
<section class="pf-c-modal-box__body pf-m-light">
<form class="pf-c-form pf-m-horizontal">
<p>
${msg(str`Are you sure you want to delete ${this.objectLabel}${objName}?`)}
</p>
</form>
</section>
${this.usedBy
? until(
this.usedBy().then((usedBy) => {
if (usedBy.length < 1) {
return nothing;
}
return html`
<section class="pf-c-modal-box__body pf-m-light">
<form class="pf-c-form pf-m-horizontal">
<p>${msg(str`The following objects use ${objName}`)}</p>
<ul class="pf-c-list">
${usedBy.map((ub) => {
let consequence = "";
switch (ub.action) {
case UsedByActionEnum.Cascade:
consequence = msg("object will be DELETED");
break;
case UsedByActionEnum.CascadeMany:
consequence = msg(
"connecting object will be deleted",
);
break;
case UsedByActionEnum.SetDefault:
consequence = msg(
"reference will be reset to default value",
);
break;
case UsedByActionEnum.SetNull:
consequence = msg(
"reference will be set to an empty value",
);
break;
}
return html`<li>
${msg(str`${ub.name} (${consequence})`)}
</li>`;
})}
</ul>
</form>
</section>
`;
}),
)
: nothing}
<fieldset class="pf-c-modal-box__footer">
<legend class="sr-only">${msg("Form actions")}</legend>
<ak-spinner-button
.callAction=${async () => {
this.open = false;
}}
class="pf-m-plain"
>
${msg("Cancel")}
</ak-spinner-button>
<ak-spinner-button
.callAction=${() => {
return this.confirm();
}}
class="pf-m-danger"
>
${msg("Delete")}
</ak-spinner-button>
</fieldset>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-forms-delete": DeleteForm;
}
}

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