Compare commits

...

41 Commits

Author SHA1 Message Date
dependabot[bot]
ea4848c7c6 web: bump postcss from 8.5.8 to 8.5.10 in /web (#21819)
Bumps [postcss](https://github.com/postcss/postcss) from 8.5.8 to 8.5.10.
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.8...8.5.10)

---
updated-dependencies:
- dependency-name: postcss
  dependency-version: 8.5.10
  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-25 11:28:06 +02:00
dependabot[bot]
2fd9a09055 web: bump brace-expansion from 1.1.13 to 1.1.14 (#21820)
Bumps [brace-expansion](https://github.com/juliangruber/brace-expansion) from 1.1.13 to 1.1.14.
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v1.1.13...v1.1.14)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 1.1.14
  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-25 11:27:27 +02:00
dependabot[bot]
b07b71f528 web: bump postcss from 8.5.8 to 8.5.10 (#21821)
Bumps [postcss](https://github.com/postcss/postcss) from 8.5.8 to 8.5.10.
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.8...8.5.10)

---
updated-dependencies:
- dependency-name: postcss
  dependency-version: 8.5.10
  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-25 11:27:09 +02:00
Jens L.
c058363180 website/docs: improve social login docs titles (#21816)
* website/docs: improve social login docs titles

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

* sigh twitter

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

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
2026-04-24 17:40:27 +02:00
Sai Asish Y
b5a92b783f providers/oauth2: require client_secret on device_code exchange for confidential clients (#21700)
* providers/oauth2: require client_secret on device_code exchange for confidential clients

TokenParams.__post_init__ only ran the client_secret check for the
authorization_code and refresh_token grant types:

	if self.grant_type in [GRANT_TYPE_AUTHORIZATION_CODE, GRANT_TYPE_REFRESH_TOKEN]:
		if self.provider.client_type == ClientTypes.CONFIDENTIAL and not compare_digest(
			self.provider.client_secret, self.client_secret,
		):
			raise TokenError("invalid_client")

The device_code path (__post_init_device_code) then looked up the
DeviceToken solely by device_code and issued an access token if one
matched. A caller that knows the client_id and has stolen a
device_code (e.g. via the standard phishing flow: attacker starts
device authorization, sends user_code to a victim, victim completes
authorization, attacker redeems the device_code) did not have to
prove ownership of the confidential client.

RFC 6749 Section 2.3.1 requires confidential clients to authenticate
to the token endpoint, and RFC 8628 Section 3.4 inherits that: the
device_code is bearer-shaped but not a substitute for client
credentials. Keycloak and Okta both enforce client_secret on the
device token exchange for confidential clients; we didn't.

Add GRANT_TYPE_DEVICE_CODE to the list so the existing compare_digest
check runs for it too. Public clients are unaffected (the guard is
gated on ClientTypes.CONFIDENTIAL). client_credentials/password keep
their own client-auth path in __post_init_client_credentials, which
also enforces the secret (and supports client assertion).

Fixes #20828

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

* Apply suggestion from @BeryJu

Signed-off-by: Jens L. <jens@beryju.org>

* update tests

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

---------

Signed-off-by: SAY-5 <SAY-5@users.noreply.github.com>
Signed-off-by: Jens L. <jens@beryju.org>
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: SAY-5 <SAY-5@users.noreply.github.com>
Co-authored-by: Jens L. <jens@beryju.org>
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
2026-04-24 17:23:36 +02:00
Marc 'risson' Schmitt
a4c60ece8b lifecycle/container: allow cross-compilation from arm64 to amd64 (#21817)
Co-authored-by: João C. Fernandes <jfernandes@cloudflare.com>
2026-04-24 17:00:46 +02:00
Jens L.
d1d38edb50 enterprise/endpoints/connectors: Fleet conditional access stage (#20978)
* rework mtls stage to be more modular

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

* sync fleet conditional access CA to authentik

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

* save host uuid

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

* initial stage impl

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

* add fixtures & tests

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

* add lookup

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

* migrate to parsing mobileconfig

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

* directly use stage_invalid

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

* add tests

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

* add more test

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

* test team mapping

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

* fix endpoint test

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

* cleanup

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

* Add document for this. Update sidebar.

* Doc improvement

* Add note about Fleet licensing

Signed-off-by: Dewi Roberts <dewi@goauthentik.io>

* re-fix tests after mtls traefik encoding change

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

* Add info about fleet and device config. Add link from fleet connector doc.

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Signed-off-by: Dewi Roberts <dewi@goauthentik.io>
Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
2026-04-24 16:17:00 +02:00
Jens L.
c6ee7b6881 core: complete rework to oobe and setup experience (#21753)
* initial

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

* use same startup template

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

* fix check not working

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

* unrelated: fix inspector auth

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

* add tests

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

* update docs

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

* ensure oobe flow can only accessed via correct url

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

* set setup flag when applying bootstrap blueprint when env is set

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

* add system visibility to flags to make them non-editable

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

* set setup flag for e2e tests

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

* fix tests and linting

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

* fix tests

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

* make github lint happy

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

* make tests have less assumptions

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

* Update docs

* include more heuristics in migration

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

* add management command to set any flag

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

* migrate worker command to signal

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

* improved api for setting flags

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

* short circuit

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

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
2026-04-24 14:47:05 +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
1379637389 ci: add rustls and aws-lc ecosystem crates to delay ignore list (#21800) 2026-04-23 13:42:25 +00: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
173 changed files with 5742 additions and 2787 deletions

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' }}

14
Cargo.lock generated
View File

@@ -1981,7 +1981,7 @@ dependencies = [
"num-integer",
"num-iter",
"num-traits",
"rand 0.8.5",
"rand 0.8.6",
"smallvec",
"zeroize",
]
@@ -2502,9 +2502,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",
@@ -2737,9 +2737,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",
@@ -3315,7 +3315,7 @@ dependencies = [
"memchr",
"once_cell",
"percent-encoding",
"rand 0.8.5",
"rand 0.8.6",
"rsa",
"serde",
"sha1",
@@ -3356,7 +3356,7 @@ dependencies = [
"md-5",
"memchr",
"once_cell",
"rand 0.8.5",
"rand 0.8.6",
"serde",
"serde_json",
"sha2",

View File

@@ -58,7 +58,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",

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,5 +1,6 @@
"""Apply blueprint from commandline"""
from argparse import ArgumentParser
from sys import exit as sys_exit
from django.core.management.base import BaseCommand, no_translations
@@ -31,5 +32,5 @@ class Command(BaseCommand):
sys_exit(1)
importer.apply()
def add_arguments(self, parser):
def add_arguments(self, parser: ArgumentParser):
parser.add_argument("blueprints", nargs="+", type=str)

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

@@ -7,6 +7,12 @@ from authentik.tasks.schedules.common import ScheduleSpec
from authentik.tenants.flags import Flag
class Setup(Flag[bool], key="setup"):
default = False
visibility = "system"
class AppAccessWithoutBindings(Flag[bool], key="core_default_app_access"):
default = True
@@ -26,6 +32,10 @@ class AuthentikCoreConfig(ManagedAppConfig):
mountpoint = ""
default = True
def import_related(self):
super().import_related()
self.import_module("authentik.core.setup.signals")
@ManagedAppConfig.reconcile_tenant
def source_inbuilt(self):
"""Reconcile inbuilt source"""

View File

@@ -0,0 +1,61 @@
# Generated by Django 5.2.13 on 2026-04-21 18:49
from django.apps.registry import Apps
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
from django.db import migrations
def check_is_already_setup(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
from django.conf import settings
from authentik.flows.models import FlowAuthenticationRequirement
VersionHistory = apps.get_model("authentik_admin", "VersionHistory")
Flow = apps.get_model("authentik_flows", "Flow")
User = apps.get_model("authentik_core", "User")
db_alias = schema_editor.connection.alias
# Upgrading from a previous version
if not settings.TEST and VersionHistory.objects.using(db_alias).count() > 1:
return True
# OOBE flow sets itself to this authentication requirement once finished
if (
Flow.objects.using(db_alias)
.filter(
slug="initial-setup", authentication=FlowAuthenticationRequirement.REQUIRE_SUPERUSER
)
.exists()
):
return True
# non-akadmin and non-guardian anonymous user exist
if (
User.objects.using(db_alias)
.exclude(username="akadmin")
.exclude(username="AnonymousUser")
.exists()
):
return True
return False
def update_setup_flag(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
from authentik.core.apps import Setup
from authentik.tenants.utils import get_current_tenant
is_already_setup = check_is_already_setup(apps, schema_editor)
if is_already_setup:
tenant = get_current_tenant()
tenant.flags[Setup().key] = True
tenant.save()
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0057_remove_user_groups_remove_user_user_permissions_and_more"),
# 0024_flow_authentication adds the `authentication` field.
("authentik_flows", "0024_flow_authentication"),
]
operations = [migrations.RunPython(update_setup_flag, migrations.RunPython.noop)]

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

View File

@@ -0,0 +1,38 @@
from os import getenv
from django.dispatch import receiver
from structlog.stdlib import get_logger
from authentik.blueprints.models import BlueprintInstance
from authentik.blueprints.v1.importer import Importer
from authentik.core.apps import Setup
from authentik.root.signals import post_startup
from authentik.tenants.models import Tenant
BOOTSTRAP_BLUEPRINT = "system/bootstrap.yaml"
LOGGER = get_logger()
@receiver(post_startup)
def post_startup_setup_bootstrap(sender, **_):
if not getenv("AUTHENTIK_BOOTSTRAP_PASSWORD") and not getenv("AUTHENTIK_BOOTSTRAP_TOKEN"):
return
LOGGER.info("Configuring authentik through bootstrap environment variables")
content = BlueprintInstance(path=BOOTSTRAP_BLUEPRINT).retrieve()
# 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
for tenant in Tenant.objects.filter(ready=True):
if Setup.get(tenant=tenant):
LOGGER.info("Tenant is already setup, skipping", tenant=tenant.schema_name)
continue
with tenant:
importer = Importer.from_string(content)
valid, logs = importer.validate()
if not valid:
LOGGER.warning("Blueprint invalid", tenant=tenant.schema_name)
for log in logs:
log.log()
importer.apply()
Setup.set(True, tenant=tenant)

View File

@@ -0,0 +1,80 @@
from functools import lru_cache
from http import HTTPMethod, HTTPStatus
from django.contrib.staticfiles import finders
from django.db import transaction
from django.http import HttpRequest, HttpResponse
from django.shortcuts import redirect
from django.urls import reverse
from django.views import View
from structlog.stdlib import get_logger
from authentik.blueprints.models import BlueprintInstance
from authentik.core.apps import Setup
from authentik.flows.models import Flow, FlowAuthenticationRequirement, in_memory_stage
from authentik.flows.planner import FlowPlanner
from authentik.flows.stage import StageView
LOGGER = get_logger()
FLOW_CONTEXT_START_BY = "goauthentik.io/core/setup/started-by"
@lru_cache
def read_static(path: str) -> str | None:
result = finders.find(path)
if not result:
return None
with open(result, encoding="utf8") as _file:
return _file.read()
class SetupView(View):
setup_flow_slug = "initial-setup"
def dispatch(self, request: HttpRequest, *args, **kwargs):
if request.method != HTTPMethod.HEAD and Setup.get():
return redirect(reverse("authentik_core:root-redirect"))
return super().dispatch(request, *args, **kwargs)
def head(self, request: HttpRequest, *args, **kwargs):
if Setup.get():
return HttpResponse(status=HTTPStatus.SERVICE_UNAVAILABLE)
if not Flow.objects.filter(slug=self.setup_flow_slug).exists():
return HttpResponse(status=HTTPStatus.SERVICE_UNAVAILABLE)
return HttpResponse(status=HTTPStatus.OK)
def get(self, request: HttpRequest):
flow = Flow.objects.filter(slug=self.setup_flow_slug).first()
if not flow:
LOGGER.info("Setup flow does not exist yet, waiting for worker to finish")
return HttpResponse(
read_static("dist/standalone/loading/startup.html"),
status=HTTPStatus.SERVICE_UNAVAILABLE,
)
planner = FlowPlanner(flow)
plan = planner.plan(request, {FLOW_CONTEXT_START_BY: "setup"})
plan.append_stage(in_memory_stage(PostSetupStageView))
return plan.to_redirect(request, flow)
class PostSetupStageView(StageView):
"""Run post-setup tasks"""
def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
"""Wrapper when this stage gets hit with a post request"""
return self.get(request, *args, **kwargs)
def get(self, requeset: HttpRequest, *args, **kwargs):
with transaction.atomic():
# Remember we're setup
Setup.set(True)
# Disable OOBE Blueprints
BlueprintInstance.objects.filter(
**{"metadata__labels__blueprints.goauthentik.io/system-oobe": "true"}
).update(enabled=False)
# Make flow inaccessible
Flow.objects.filter(slug="initial-setup").update(
authentication=FlowAuthenticationRequirement.REQUIRE_SUPERUSER
)
return self.executor.stage_ok()

View File

@@ -4,6 +4,7 @@ from django.test import TestCase
from django.urls import reverse
from authentik.brands.models import Brand
from authentik.core.apps import Setup
from authentik.core.models import Application, UserTypes
from authentik.core.tests.utils import create_test_brand, create_test_user
@@ -12,6 +13,7 @@ class TestInterfaceRedirects(TestCase):
"""Test RootRedirectView and BrandDefaultRedirectView redirect logic by user type"""
def setUp(self):
Setup.set(True)
self.app = Application.objects.create(name="test-app", slug="test-app")
self.brand: Brand = create_test_brand(default_application=self.app)

View File

@@ -0,0 +1,156 @@
from http import HTTPStatus
from os import environ
from django.urls import reverse
from authentik.blueprints.tests import apply_blueprint
from authentik.core.apps import Setup
from authentik.core.models import Token, TokenIntents, User
from authentik.flows.models import Flow
from authentik.flows.tests import FlowTestCase
from authentik.lib.generators import generate_id
from authentik.root.signals import post_startup, pre_startup
from authentik.tenants.flags import patch_flag
class TestSetup(FlowTestCase):
def tearDown(self):
environ.pop("AUTHENTIK_BOOTSTRAP_PASSWORD", None)
environ.pop("AUTHENTIK_BOOTSTRAP_TOKEN", None)
@patch_flag(Setup, True)
def test_setup(self):
"""Test existing instance"""
res = self.client.get(reverse("authentik_core:root-redirect"))
self.assertEqual(res.status_code, HTTPStatus.FOUND)
self.assertRedirects(
res,
reverse("authentik_flows:default-authentication") + "?next=/",
fetch_redirect_response=False,
)
res = self.client.head(reverse("authentik_core:setup"))
self.assertEqual(res.status_code, HTTPStatus.SERVICE_UNAVAILABLE)
res = self.client.get(reverse("authentik_core:setup"))
self.assertEqual(res.status_code, HTTPStatus.FOUND)
self.assertRedirects(
res,
reverse("authentik_core:root-redirect"),
fetch_redirect_response=False,
)
@patch_flag(Setup, False)
def test_not_setup_no_flow(self):
"""Test case on initial startup; setup flag is not set and oobe flow does
not exist yet"""
Flow.objects.filter(slug="initial-setup").delete()
res = self.client.get(reverse("authentik_core:root-redirect"))
self.assertEqual(res.status_code, HTTPStatus.FOUND)
self.assertRedirects(res, reverse("authentik_core:setup"), fetch_redirect_response=False)
# Flow does not exist, hence 503
res = self.client.get(reverse("authentik_core:setup"))
self.assertEqual(res.status_code, HTTPStatus.SERVICE_UNAVAILABLE)
res = self.client.head(reverse("authentik_core:setup"))
self.assertEqual(res.status_code, HTTPStatus.SERVICE_UNAVAILABLE)
@patch_flag(Setup, False)
@apply_blueprint("default/flow-oobe.yaml")
def test_not_setup(self):
"""Test case for when worker comes up, and has created flow"""
res = self.client.get(reverse("authentik_core:root-redirect"))
self.assertEqual(res.status_code, HTTPStatus.FOUND)
self.assertRedirects(res, reverse("authentik_core:setup"), fetch_redirect_response=False)
# Flow does not exist, hence 503
res = self.client.head(reverse("authentik_core:setup"))
self.assertEqual(res.status_code, HTTPStatus.OK)
res = self.client.get(reverse("authentik_core:setup"))
self.assertEqual(res.status_code, HTTPStatus.FOUND)
self.assertRedirects(
res,
reverse("authentik_core:if-flow", kwargs={"flow_slug": "initial-setup"}),
fetch_redirect_response=False,
)
@apply_blueprint("default/flow-oobe.yaml")
@apply_blueprint("system/bootstrap.yaml")
def test_setup_flow_full(self):
"""Test full setup flow"""
Setup.set(False)
res = self.client.get(reverse("authentik_core:setup"))
self.assertEqual(res.status_code, HTTPStatus.FOUND)
self.assertRedirects(
res,
reverse("authentik_core:if-flow", kwargs={"flow_slug": "initial-setup"}),
fetch_redirect_response=False,
)
res = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": "initial-setup"}),
)
self.assertEqual(res.status_code, HTTPStatus.OK)
self.assertStageResponse(res, component="ak-stage-prompt")
pw = generate_id()
res = self.client.post(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": "initial-setup"}),
{
"email": f"{generate_id()}@t.goauthentik.io",
"password": pw,
"password_repeat": pw,
"component": "ak-stage-prompt",
},
)
self.assertEqual(res.status_code, HTTPStatus.FOUND)
res = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": "initial-setup"}),
)
self.assertEqual(res.status_code, HTTPStatus.FOUND)
res = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": "initial-setup"}),
)
self.assertEqual(res.status_code, HTTPStatus.FOUND)
res = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": "initial-setup"}),
)
self.assertEqual(res.status_code, HTTPStatus.OK)
self.assertTrue(Setup.get())
user = User.objects.get(username="akadmin")
self.assertTrue(user.check_password(pw))
@patch_flag(Setup, False)
@apply_blueprint("default/flow-oobe.yaml")
@apply_blueprint("system/bootstrap.yaml")
def test_setup_flow_direct(self):
"""Test setup flow, directly accessing the flow"""
res = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": "initial-setup"})
)
self.assertStageResponse(
res,
component="ak-stage-access-denied",
error_message="Access the authentik setup by navigating to http://testserver/",
)
def test_setup_bootstrap_env(self):
"""Test setup with env vars"""
User.objects.filter(username="akadmin").delete()
Setup.set(False)
environ["AUTHENTIK_BOOTSTRAP_PASSWORD"] = generate_id()
environ["AUTHENTIK_BOOTSTRAP_TOKEN"] = generate_id()
pre_startup.send(sender=self)
post_startup.send(sender=self)
self.assertTrue(Setup.get())
user = User.objects.get(username="akadmin")
self.assertTrue(user.check_password(environ["AUTHENTIK_BOOTSTRAP_PASSWORD"]))
token = Token.objects.filter(identifier="authentik-bootstrap-token").first()
self.assertEqual(token.intent, TokenIntents.INTENT_API)
self.assertEqual(token.key, environ["AUTHENTIK_BOOTSTRAP_TOKEN"])

View File

@@ -1,7 +1,6 @@
"""authentik URL Configuration"""
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.urls import path
from authentik.core.api.application_entitlements import ApplicationEntitlementViewSet
@@ -19,6 +18,7 @@ from authentik.core.api.sources import (
from authentik.core.api.tokens import TokenViewSet
from authentik.core.api.transactional_applications import TransactionalApplicationView
from authentik.core.api.users import UserViewSet
from authentik.core.setup.views import SetupView
from authentik.core.views.apps import RedirectToAppLaunch
from authentik.core.views.debug import AccessDeniedView
from authentik.core.views.interface import (
@@ -35,7 +35,7 @@ from authentik.tenants.channels import TenantsAwareMiddleware
urlpatterns = [
path(
"",
login_required(RootRedirectView.as_view()),
RootRedirectView.as_view(),
name="root-redirect",
),
path(
@@ -62,6 +62,11 @@ urlpatterns = [
FlowInterfaceView.as_view(),
name="if-flow",
),
path(
"setup",
SetupView.as_view(),
name="setup",
),
# Fallback for WS
path("ws/outpost/<uuid:pk>/", InterfaceView.as_view(template_name="if/admin.html")),
path(

View File

@@ -3,6 +3,7 @@
from json import dumps
from typing import Any
from django.contrib.auth.mixins import AccessMixin
from django.http import HttpRequest
from django.http.response import HttpResponse
from django.shortcuts import redirect
@@ -14,12 +15,13 @@ from authentik.admin.tasks import LOCAL_VERSION
from authentik.api.v3.config import ConfigView
from authentik.brands.api import CurrentBrandSerializer
from authentik.brands.models import Brand
from authentik.core.apps import Setup
from authentik.core.models import UserTypes
from authentik.lib.config import CONFIG
from authentik.policies.denied import AccessDeniedResponse
class RootRedirectView(RedirectView):
class RootRedirectView(AccessMixin, RedirectView):
"""Root redirect view, redirect to brand's default application if set"""
pattern_name = "authentik_core:if-user"
@@ -40,6 +42,10 @@ class RootRedirectView(RedirectView):
return None
def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
if not Setup.get():
return redirect("authentik_core:setup")
if not request.user.is_authenticated:
return self.handle_no_permission()
if redirect_response := RootRedirectView().redirect_to_app(request):
return redirect_response
return super().dispatch(request, *args, **kwargs)

View File

@@ -1,10 +1,12 @@
from unittest.mock import PropertyMock, patch
from django.urls import reverse
from rest_framework.test import APITestCase
from authentik.core.tests.utils import create_test_admin_user
from authentik.endpoints.connectors.agent.models import AgentConnector
from authentik.endpoints.controller import BaseController
from authentik.endpoints.models import StageMode
from authentik.enterprise.endpoints.connectors.fleet.models import FleetConnector
from authentik.lib.generators import generate_id
@@ -25,16 +27,22 @@ class TestAPI(APITestCase):
)
self.assertEqual(res.status_code, 201)
def test_endpoint_stage_fleet(self):
connector = FleetConnector.objects.create(name=generate_id())
res = self.client.post(
reverse("authentik_api:stages-endpoint-list"),
data={
"name": generate_id(),
"connector": str(connector.pk),
"mode": StageMode.REQUIRED,
},
)
def test_endpoint_stage_agent_no_stage(self):
connector = AgentConnector.objects.create(name=generate_id())
class controller(BaseController):
def capabilities(self):
return []
with patch.object(AgentConnector, "controller", PropertyMock(return_value=controller)):
res = self.client.post(
reverse("authentik_api:stages-endpoint-list"),
data={
"name": generate_id(),
"connector": str(connector.pk),
"mode": StageMode.REQUIRED,
},
)
self.assertEqual(res.status_code, 400)
self.assertJSONEqual(
res.content, {"connector": ["Selected connector is not compatible with this stage."]}

View File

@@ -1,11 +1,15 @@
import re
from plistlib import loads
from typing import Any
from cryptography.hazmat.primitives import serialization
from cryptography.x509 import load_der_x509_certificate
from django.db import transaction
from requests import RequestException
from rest_framework.exceptions import ValidationError
from authentik.core.models import User
from authentik.crypto.models import CertificateKeyPair
from authentik.endpoints.controller import BaseController, Capabilities, ConnectorSyncException
from authentik.endpoints.facts import (
DeviceFacts,
@@ -44,7 +48,7 @@ class FleetController(BaseController[DBC]):
return "fleetdm.com"
def capabilities(self) -> list[Capabilities]:
return [Capabilities.ENROLL_AUTOMATIC_API]
return [Capabilities.STAGE_ENDPOINTS, Capabilities.ENROLL_AUTOMATIC_API]
def _url(self, path: str) -> str:
return f"{self.connector.url}{path}"
@@ -76,8 +80,44 @@ class FleetController(BaseController[DBC]):
except RequestException as exc:
raise ConnectorSyncException(exc) from exc
@property
def mtls_ca_managed(self) -> str:
return f"goauthentik.io/endpoints/connectors/fleet/{self.connector.pk}"
def _sync_mtls_ca(self):
"""Sync conditional access Root CA for mTLS"""
try:
# Fleet doesn't have an API to just get the Conditional Access Root CA Cert (yet),
# hence we fetch the apple config profile and extract it
res = self._session.get(self._url("/api/v1/fleet/conditional_access/idp/apple/profile"))
res.raise_for_status()
profile = loads(res.text).get("PayloadContent", [])
raw_cert = None
for payload in profile:
if payload.get("PayloadIdentifier", "") != "com.fleetdm.conditional-access-ca":
continue
raw_cert = payload.get("PayloadContent")
if not raw_cert:
raise ConnectorSyncException("Failed to get conditional acccess CA")
except RequestException as exc:
raise ConnectorSyncException(exc) from exc
cert = load_der_x509_certificate(raw_cert)
CertificateKeyPair.objects.update_or_create(
managed=self.mtls_ca_managed,
defaults={
"name": f"Fleet Endpoint connector {self.connector.name}",
"certificate_data": cert.public_bytes(
encoding=serialization.Encoding.PEM,
).decode("utf-8"),
},
)
@transaction.atomic
def sync_endpoints(self) -> None:
try:
self._sync_mtls_ca()
except ConnectorSyncException as exc:
self.logger.warning("Failed to sync conditional access CA", exc=exc)
for host in self._paginate_hosts():
serial = host["hardware_serial"]
device, _ = Device.objects.get_or_create(
@@ -198,6 +238,8 @@ class FleetController(BaseController[DBC]):
for policy in host.get("policies", [])
],
"agent_version": fleet_version,
# Host UUID is required for conditional access matching
"uuid": host.get("uuid", "").lower(),
},
},
}

View File

@@ -51,6 +51,12 @@ class FleetConnector(Connector):
def component(self) -> str:
return "ak-endpoints-connector-fleet-form"
@property
def stage(self):
from authentik.enterprise.endpoints.connectors.fleet.stage import FleetStageView
return FleetStageView
class Meta:
verbose_name = _("Fleet Connector")
verbose_name_plural = _("Fleet Connectors")

View File

@@ -0,0 +1,51 @@
from cryptography.x509 import (
Certificate,
Extension,
SubjectAlternativeName,
UniformResourceIdentifier,
)
from rest_framework.exceptions import PermissionDenied
from authentik.crypto.models import CertificateKeyPair, fingerprint_sha256
from authentik.endpoints.models import Device, EndpointStage, StageMode
from authentik.enterprise.endpoints.connectors.fleet.models import FleetConnector
from authentik.enterprise.stages.mtls.stage import PLAN_CONTEXT_CERTIFICATE, MTLSStageView
from authentik.flows.planner import PLAN_CONTEXT_DEVICE
FLEET_CONDITIONAL_ACCESS_URI_PREFIX = "urn:device:apple:uuid:"
class FleetStageView(MTLSStageView):
def get_authorities(self):
stage: EndpointStage = self.executor.current_stage
connector = FleetConnector.objects.filter(pk=stage.connector_id).first()
controller = connector.controller(connector)
kp = CertificateKeyPair.objects.filter(managed=controller.mtls_ca_managed).first()
return [kp] if kp else None
def lookup_device(self, cert: Certificate, mode: StageMode):
san_ext: Extension[SubjectAlternativeName] = cert.extensions.get_extension_for_oid(
SubjectAlternativeName.oid
)
raw_values = san_ext.value.get_values_for_type(UniformResourceIdentifier)
values = [x.removeprefix(FLEET_CONDITIONAL_ACCESS_URI_PREFIX).lower() for x in raw_values]
self.logger.debug("Looking for devices with uuid", fleet_device_uuid=values)
device = Device.objects.filter(
**{"deviceconnection__devicefactsnapshot__data__vendor__fleetdm.com__uuid__in": values}
).first()
if not device and mode == StageMode.REQUIRED:
raise PermissionDenied("Failed to find device")
self.executor.plan.context[PLAN_CONTEXT_DEVICE] = device
self.executor.plan.context[PLAN_CONTEXT_CERTIFICATE] = self._cert_to_dict(cert)
return self.executor.stage_ok()
def dispatch(self, request, *args, **kwargs):
stage: EndpointStage = self.executor.current_stage
try:
cert = self.get_cert(stage.mode)
if not cert:
return self.executor.stage_ok()
self.logger.debug("Received certificate", cert=fingerprint_sha256(cert))
return self.lookup_device(cert, stage.mode)
except PermissionDenied as exc:
return self.executor.stage_invalid(error_message=exc.detail)

View File

@@ -0,0 +1,23 @@
-----BEGIN CERTIFICATE-----
MIIDwDCCAqigAwIBAgIBBDANBgkqhkiG9w0BAQsFADBpMQkwBwYDVQQGEwAxJDAi
BgNVBAoTG0xvY2FsIGNlcnRpZmljYXRlIGF1dGhvcml0eTEQMA4GA1UECxMHU0NF
UCBDQTEkMCIGA1UEAxMbRmxlZXQgY29uZGl0aW9uYWwgYWNjZXNzIENBMB4XDTI2
MDMxODExMTc1NFoXDTI3MDQyMDExMjc1NFowLDEqMCgGA1UEAxMhRmxlZXQgY29u
ZGl0aW9uYWwgYWNjZXNzIGZvciBPa3RhMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A
MIIBCgKCAQEA3xuKxQQ8JSA4qCJ6RfOB7tbQurhwXiaJSLUDG7R5ncdRcd9LH/9y
5ZyI5kQACOwfICHmv02zR4/CrurfzXabo3CCpvcMdS7JI/FzP1GIIZ5RsR7oPFC6
JJg3m5BHuoHsUtCD7w0D52WiE7XVfbw47h2ChKmGMhkSrBvQnp3dHFEt8ntbl1/q
zCSuQaLeR2sQFurBDVBdinEgsvb1YHaYHi4tdFx5joG64Q/nJXyA2OM4hO9uBF+G
c4UVTzubx5sxwONcPhC9H+eLMpF1VHeU9gAGBlruVusUEYDmlqYQuA+bW5fTr4Zd
ZmJ5e+CzzUBYHduAML9a5S+1jbxSPZFBSwIDAQABo4GvMIGsMA4GA1UdDwEB/wQE
AwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAjAdBgNVHQ4EFgQUPrc1+LvbR9WoJIWZ
7YQa/3IX2w8wHwYDVR0jBBgwFoAUfl92kU2qcH4e+hypez4kEnqMbk4wRQYDVR0R
BD4wPIY6dXJuOmRldmljZTphcHBsZTp1dWlkOjVCRjQyMkQ2LTZFQUItNTE1Ni1B
QzVBLTlFQURDOTUyNDcxMzANBgkqhkiG9w0BAQsFAAOCAQEAGfxJ/u4271tnUUTB
J39YU6z2Ciav+9G3BtbvxBXI57Po7zCE6Z1sVkvYq6Xd0CcItPWRjbSPEy78ZzS0
By+gPy5fkKc8HHJ5I1wK890xbLBUS1P4EbdVBzI9ggouEa3B2asE10asnzLoKE4C
0FYWQwrzCsso8yxsJj1S8RKtd6MMbCis/9OQSC8om2tu6cLO+OftVn5DHtNWFidw
tAl/oHn2cZPUfZGpJGrHNZlp5w1c1dYfQeiPayoQIbsF+8eMV424G76z/8UPhMBs
R23LByv4TlUOPAGn2TRa2WtLIXs7FgqXRIFW4CjsPsEpXSVNlkYcn/VHY7Jl13zz
CRQ1Pg==
-----END CERTIFICATE-----

View File

@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadContent</key>
<array>
<!-- Trusted CA certificate -->
<dict>
<key>PayloadCertificateFileName</key>
<string>conditional_access_ca.der</string>
<key>PayloadContent</key>
<data>MIIDjzCCAnegAwIBAgIBATANBgkqhkiG9w0BAQsFADBpMQkwBwYDVQQGEwAxJDAiBgNVBAoTG0xvY2FsIGNlcnRpZmljYXRlIGF1dGhvcml0eTEQMA4GA1UECxMHU0NFUCBDQTEkMCIGA1UEAxMbRmxlZXQgY29uZGl0aW9uYWwgYWNjZXNzIENBMB4XDTI1MTIwOTEyMjI1MVoXDTM1MTIwOTEyMjI1MVowaTEJMAcGA1UEBhMAMSQwIgYDVQQKExtMb2NhbCBjZXJ0aWZpY2F0ZSBhdXRob3JpdHkxEDAOBgNVBAsTB1NDRVAgQ0ExJDAiBgNVBAMTG0ZsZWV0IGNvbmRpdGlvbmFsIGFjY2VzcyBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANrgCcpzQci2UhH+Dn0eHopnnbx3HbMabMCHXm6xteMVFLrdQJDTFrZCQzcexUgbpPJ0az6mn4szo+E3stn0y2PPWsiAiVhFwp5M9HwNg18rPgDmITv2pM3l/hlEsfggjq6TEVO2gRcq4NujEGagcYX6kp6nWxh6bbRngQ/hlK6mXItWV3x0G9eTcbFObwZhbuC2dNbccytdqbVEIpBjp6fftQnQwAaUVjoyZBFlf1C1cDV4+1jpaVsIj11U1olA33GJCHcZQ4CJEsgh8yiSsvkH5RNf94CGINB5ixsMfppjSXV/vNkWDKEfmUXW2q4ft7KK/L/SRq8QSB4VqTAp2GsCAwEAAaNCMEAwDgYDVR0PAQH/BAQDAgGGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFH5fdpFNqnB+HvocqXs+JBJ6jG5OMA0GCSqGSIb3DQEBCwUAA4IBAQAJr4bTGlrANoHStu4Y+OXjGbEQjZOe546Bcln4eWrEB16eaVzfKuZgjJYdcOmp36/v34QY/OCXEIsixrBU5aW/Sr53IK6UQSZV3O3xbBc4Aert7AbeJ4NVGZyelfVQo/5G0qM6k9p0+zpIZqNAzFbhcSPIzuE7ig2OGsFoQU+bXhzk09bsZ+u4BXibzVNfMuMG+DHNv0PRjll272nEPI3bGwHF5tdrnfJG6e9t+qK9j9UqmSlBknHQJNeU5o8IDcmWYjWtOuBzecYsg8pZzXabJqlHTBIz/h7waRe7jtrK+XopK3jghRf9JTL+i0Y8NbVjoNkIoS3xMeRhnNbR9lw1</data>
<key>PayloadDescription</key>
<string>Fleet conditional access CA certificate</string>
<key>PayloadDisplayName</key>
<string>Fleet conditional access CA</string>
<key>PayloadIdentifier</key>
<string>com.fleetdm.conditional-access-ca</string>
<key>PayloadType</key>
<string>com.apple.security.root</string>
<key>PayloadUUID</key>
<string>ef1b2231-ad80-5511-9893-1f9838295147</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
</array>
<key>PayloadDescription</key>
<string>Configures SCEP enrollment for Okta conditional access</string>
<key>PayloadDisplayName</key>
<string>Fleet conditional access for Okta</string>
<key>PayloadIdentifier</key>
<string>com.fleetdm.conditional-access-okta</string>
<key>PayloadOrganization</key>
<string>Fleet Device Management</string>
<key>PayloadRemovalDisallowed</key>
<false/>
<key>PayloadScope</key>
<string>User</string>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadUUID</key>
<string>6fa509a3-feca-56f7-a283-d6a81c733ed2</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
</plist>

View File

@@ -1,27 +1,27 @@
{
"created_at": "2025-06-25T22:21:35Z",
"updated_at": "2025-12-20T11:42:09Z",
"created_at": "2026-02-18T16:31:34Z",
"updated_at": "2026-03-18T11:29:18Z",
"software": null,
"software_updated_at": "2025-10-22T02:24:25Z",
"id": 1,
"detail_updated_at": "2025-10-23T23:30:31Z",
"label_updated_at": "2025-10-23T23:30:31Z",
"policy_updated_at": "2025-10-23T23:02:11Z",
"last_enrolled_at": "2025-06-25T22:21:37Z",
"seen_time": "2025-10-23T23:59:08Z",
"software_updated_at": "2026-03-18T11:29:17Z",
"id": 19,
"detail_updated_at": "2026-03-18T11:29:18Z",
"label_updated_at": "2026-03-18T11:29:18Z",
"policy_updated_at": "2026-03-18T11:29:18Z",
"last_enrolled_at": "2026-02-18T16:31:45Z",
"seen_time": "2026-03-18T11:31:34Z",
"refetch_requested": false,
"hostname": "jens-mac-vm.local",
"uuid": "C8B98348-A0A6-5838-A321-57B59D788269",
"uuid": "5BF422D6-6EAB-5156-AC5A-9EADC9524713",
"platform": "darwin",
"osquery_version": "5.19.0",
"osquery_version": "5.21.0",
"orbit_version": null,
"fleet_desktop_version": null,
"scripts_enabled": null,
"os_version": "macOS 26.0.1",
"build": "25A362",
"os_version": "macOS 26.3",
"build": "25D125",
"platform_like": "darwin",
"code_name": "",
"uptime": 256356000000000,
"uptime": 653014000000000,
"memory": 4294967296,
"cpu_type": "arm64e",
"cpu_subtype": "ARM64E",
@@ -31,38 +31,41 @@
"hardware_vendor": "Apple Inc.",
"hardware_model": "VirtualMac2,1",
"hardware_version": "",
"hardware_serial": "Z5DDF07GK6",
"hardware_serial": "ZV35VFDD50",
"computer_name": "jens-mac-vm",
"timezone": null,
"public_ip": "92.116.179.252",
"primary_ip": "192.168.85.3",
"primary_mac": "e6:9d:21:c2:2f:19",
"primary_ip": "192.168.64.7",
"primary_mac": "5e:72:1c:89:98:29",
"distributed_interval": 10,
"config_tls_refresh": 60,
"logger_tls_period": 10,
"team_id": 2,
"team_id": 5,
"pack_stats": null,
"team_name": "prod",
"gigs_disk_space_available": 23.82,
"percent_disk_space_available": 37,
"team_name": "dev",
"gigs_disk_space_available": 16.52,
"percent_disk_space_available": 26,
"gigs_total_disk_space": 62.83,
"gigs_all_disk_space": null,
"issues": {
"failing_policies_count": 1,
"critical_vulnerabilities_count": 2,
"total_issues_count": 3
"critical_vulnerabilities_count": 0,
"total_issues_count": 1
},
"device_mapping": null,
"mdm": {
"enrollment_status": "On (manual)",
"dep_profile_error": false,
"server_url": "https://fleet.beryjuio-home.k8s.beryju.io/mdm/apple/mdm",
"server_url": "https://fleet.beryjuio-prod.k8s.beryju.io/mdm/apple/mdm",
"name": "Fleet",
"encryption_key_available": false,
"connected_to_fleet": true
},
"refetch_critical_queries_until": null,
"last_restarted_at": "2025-10-21T00:17:55Z",
"status": "offline",
"last_restarted_at": "2026-03-10T22:05:44.00887Z",
"status": "online",
"display_text": "jens-mac-vm.local",
"display_name": "jens-mac-vm"
"display_name": "jens-mac-vm",
"fleet_id": 5,
"fleet_name": "dev"
}

View File

@@ -21,12 +21,19 @@ TEST_HOST = {"hosts": [TEST_HOST_UBUNTU, TEST_HOST_MACOS, TEST_HOST_WINDOWS, TES
class TestFleetConnector(APITestCase):
def setUp(self):
self.connector = FleetConnector.objects.create(
name=generate_id(), url="http://localhost", token=generate_id()
name=generate_id(),
url="http://localhost",
token=generate_id(),
map_teams_access_group=True,
)
def test_sync(self):
controller = self.connector.controller(self.connector)
with Mocker() as mock:
mock.get(
"http://localhost/api/v1/fleet/conditional_access/idp/apple/profile",
text=load_fixture("fixtures/cond_acc_profile.mobileconfig"),
)
mock.get(
"http://localhost/api/v1/fleet/hosts?order_key=hardware_serial&page=0&per_page=50&device_mapping=true&populate_software=true&populate_users=true",
json=TEST_HOST,
@@ -40,6 +47,9 @@ class TestFleetConnector(APITestCase):
identifier="VMware-56 4d 4a 5a b0 22 7b d7-9b a5 0b dc 8f f2 3b 60"
).first()
self.assertIsNotNone(device)
group = device.access_group
self.assertIsNotNone(group)
self.assertEqual(group.name, "prod")
self.assertEqual(
device.cached_facts.data,
{
@@ -50,7 +60,13 @@ class TestFleetConnector(APITestCase):
"version": "24.04.3 LTS",
},
"disks": [],
"vendor": {"fleetdm.com": {"policies": [], "agent_version": ""}},
"vendor": {
"fleetdm.com": {
"policies": [],
"agent_version": "",
"uuid": "5a4a4d56-22b0-d77b-9ba5-0bdc8ff23b60",
}
},
"network": {"hostname": "ubuntu-desktop", "interfaces": []},
"hardware": {
"model": "VMware20,1",
@@ -72,6 +88,10 @@ class TestFleetConnector(APITestCase):
self.connector.save()
controller = self.connector.controller(self.connector)
with Mocker() as mock:
mock.get(
"http://localhost/api/v1/fleet/conditional_access/idp/apple/profile",
text=load_fixture("fixtures/cond_acc_profile.mobileconfig"),
)
mock.get(
"http://localhost/api/v1/fleet/hosts?order_key=hardware_serial&page=0&per_page=50&device_mapping=true&populate_software=true&populate_users=true",
json=TEST_HOST,
@@ -81,11 +101,13 @@ class TestFleetConnector(APITestCase):
json={"hosts": []},
)
controller.sync_endpoints()
self.assertEqual(mock.call_count, 2)
self.assertEqual(mock.call_count, 3)
self.assertEqual(mock.request_history[0].method, "GET")
self.assertEqual(mock.request_history[0].headers["foo"], "bar")
self.assertEqual(mock.request_history[1].method, "GET")
self.assertEqual(mock.request_history[1].headers["foo"], "bar")
self.assertEqual(mock.request_history[2].method, "GET")
self.assertEqual(mock.request_history[2].headers["foo"], "bar")
def test_map_host_linux(self):
controller = self.connector.controller(self.connector)
@@ -128,6 +150,6 @@ class TestFleetConnector(APITestCase):
"arch": "arm64e",
"family": OSFamily.macOS,
"name": "macOS",
"version": "26.0.1",
"version": "26.3",
},
)

View File

@@ -0,0 +1,84 @@
from json import loads
from ssl import PEM_FOOTER, PEM_HEADER
from django.urls import reverse
from requests_mock import Mocker
from authentik.core.tests.utils import (
create_test_flow,
)
from authentik.endpoints.models import Device, EndpointStage, StageMode
from authentik.enterprise.endpoints.connectors.fleet.models import FleetConnector
from authentik.enterprise.stages.mtls.stage import PLAN_CONTEXT_CERTIFICATE
from authentik.flows.models import FlowDesignation, FlowStageBinding
from authentik.flows.planner import PLAN_CONTEXT_DEVICE
from authentik.flows.tests import FlowTestCase
from authentik.lib.generators import generate_id
from authentik.lib.tests.utils import load_fixture
class FleetConnectorStageTests(FlowTestCase):
def setUp(self):
super().setUp()
self.connector = FleetConnector.objects.create(
name=generate_id(), url="http://localhost", token=generate_id()
)
controller = self.connector.controller(self.connector)
with Mocker() as mock:
mock.get(
"http://localhost/api/v1/fleet/conditional_access/idp/apple/profile",
text=load_fixture("fixtures/cond_acc_profile.mobileconfig"),
)
mock.get(
"http://localhost/api/v1/fleet/hosts?order_key=hardware_serial&page=0&per_page=50&device_mapping=true&populate_software=true&populate_users=true",
json={"hosts": [loads(load_fixture("fixtures/host_macos.json"))]},
)
mock.get(
"http://localhost/api/v1/fleet/hosts?order_key=hardware_serial&page=1&per_page=50&device_mapping=true&populate_software=true&populate_users=true",
json={"hosts": []},
)
controller.sync_endpoints()
self.flow = create_test_flow(FlowDesignation.AUTHENTICATION)
self.stage = EndpointStage.objects.create(
name=generate_id(),
mode=StageMode.REQUIRED,
connector=self.connector,
)
self.binding = FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=0)
self.host_cert = load_fixture("fixtures/cond_acc_host.pem")
def _format_traefik(self, cert: str | None = None):
cert = cert if cert else self.host_cert
return cert.replace(PEM_HEADER, "").replace(PEM_FOOTER, "").replace("\n", "")
def test_assoc(self):
dev = Device.objects.get(identifier="ZV35VFDD50")
with self.assertFlowFinishes() as plan:
res = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
headers={"X-Forwarded-TLS-Client-Cert": self._format_traefik()},
)
self.assertEqual(res.status_code, 200)
plan = plan()
self.assertEqual(plan.context[PLAN_CONTEXT_DEVICE], dev)
self.assertEqual(
plan.context[PLAN_CONTEXT_CERTIFICATE]["subject"],
"CN=Fleet conditional access for Okta",
)
def test_assoc_not_found(self):
dev = Device.objects.get(identifier="ZV35VFDD50")
dev.delete()
with self.assertFlowFinishes() as plan:
res = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
headers={"X-Forwarded-TLS-Client-Cert": self._format_traefik()},
)
self.assertEqual(res.status_code, 200)
self.assertStageResponse(res, self.flow, component="ak-stage-access-denied")
plan = plan()
self.assertNotIn(PLAN_CONTEXT_DEVICE, plan.context)

View File

@@ -15,6 +15,7 @@ from cryptography.x509 import (
)
from cryptography.x509.verification import PolicyBuilder, Store, VerificationError
from django.utils.translation import gettext_lazy as _
from rest_framework.exceptions import PermissionDenied
from authentik.brands.models import Brand
from authentik.core.models import User
@@ -25,7 +26,6 @@ from authentik.enterprise.stages.mtls.models import (
MutualTLSStage,
UserAttributes,
)
from authentik.flows.challenge import AccessDeniedChallenge
from authentik.flows.models import FlowDesignation
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
from authentik.flows.stage import ChallengeStageView
@@ -217,8 +217,7 @@ class MTLSStageView(ChallengeStageView):
return None
return str(_cert_attr[0])
def dispatch(self, request, *args, **kwargs):
stage: MutualTLSStage = self.executor.current_stage
def get_cert(self, mode: StageMode):
certs = [
*self._parse_cert_xfcc(),
*self._parse_cert_nginx(),
@@ -228,21 +227,26 @@ class MTLSStageView(ChallengeStageView):
authorities = self.get_authorities()
if not authorities:
self.logger.warning("No Certificate authority found")
if stage.mode == StageMode.OPTIONAL:
return self.executor.stage_ok()
if stage.mode == StageMode.REQUIRED:
return super().dispatch(request, *args, **kwargs)
if mode == StageMode.OPTIONAL:
return None
if mode == StageMode.REQUIRED:
raise PermissionDenied("Unknown error")
cert = self.validate_cert(authorities, certs)
if not cert and stage.mode == StageMode.REQUIRED:
if not cert and mode == StageMode.REQUIRED:
self.logger.warning("Client certificate required but no certificates given")
return super().dispatch(
request,
*args,
error_message=_("Certificate required but no certificate was given."),
**kwargs,
)
if not cert and stage.mode == StageMode.OPTIONAL:
raise PermissionDenied(str(_("Certificate required but no certificate was given.")))
if not cert and mode == StageMode.OPTIONAL:
self.logger.info("No certificate given, continuing")
return None
return cert
def dispatch(self, request, *args, **kwargs):
stage: MutualTLSStage = self.executor.current_stage
try:
cert = self.get_cert(stage.mode)
except PermissionDenied as exc:
return self.executor.stage_invalid(error_message=exc.detail)
if not cert:
return self.executor.stage_ok()
self.logger.debug("Received certificate", cert=fingerprint_sha256(cert))
existing_user = self.check_if_user(cert)
@@ -251,15 +255,5 @@ class MTLSStageView(ChallengeStageView):
elif existing_user:
self.auth_user(existing_user, cert)
else:
return super().dispatch(
request, *args, error_message=_("No user found for certificate."), **kwargs
)
return self.executor.stage_invalid(_("No user found for certificate."))
return self.executor.stage_ok()
def get_challenge(self, *args, error_message: str | None = None, **kwargs):
return AccessDeniedChallenge(
data={
"component": "ak-stage-access-denied",
"error_message": str(error_message or "Unknown error"),
}
)

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

@@ -71,7 +71,11 @@ class FlowInspectorView(APIView):
flow: Flow
_logger: BoundLogger
permission_classes = [IsAuthenticated]
def get_permissions(self):
if settings.DEBUG:
return []
return [IsAuthenticated()]
def setup(self, request: HttpRequest, flow_slug: str):
super().setup(request, flow_slug=flow_slug)

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

@@ -48,6 +48,7 @@ class TestTokenDeviceCode(OAuthTestCase):
reverse("authentik_providers_oauth2:token"),
data={
"client_id": self.provider.client_id,
"client_secret": self.provider.client_secret,
"grant_type": GRANT_TYPE_DEVICE_CODE,
},
)
@@ -66,6 +67,7 @@ class TestTokenDeviceCode(OAuthTestCase):
reverse("authentik_providers_oauth2:token"),
data={
"client_id": self.provider.client_id,
"client_secret": self.provider.client_secret,
"grant_type": GRANT_TYPE_DEVICE_CODE,
"device_code": device_token.device_code,
},
@@ -74,6 +76,26 @@ class TestTokenDeviceCode(OAuthTestCase):
body = loads(res.content.decode())
self.assertEqual(body["error"], "authorization_pending")
def test_code_no_auth(self):
"""Test code with user"""
device_token = DeviceToken.objects.create(
provider=self.provider,
user_code=generate_code_fixed_length(),
device_code=generate_id(),
user=self.user,
)
res = self.client.post(
reverse("authentik_providers_oauth2:token"),
data={
"client_id": self.provider.client_id,
"grant_type": GRANT_TYPE_DEVICE_CODE,
"device_code": device_token.device_code,
},
)
self.assertEqual(res.status_code, 400)
body = loads(res.content.decode())
self.assertEqual(body["error"], "invalid_client")
def test_code(self):
"""Test code with user"""
device_token = DeviceToken.objects.create(
@@ -86,6 +108,7 @@ class TestTokenDeviceCode(OAuthTestCase):
reverse("authentik_providers_oauth2:token"),
data={
"client_id": self.provider.client_id,
"client_secret": self.provider.client_secret,
"grant_type": GRANT_TYPE_DEVICE_CODE,
"device_code": device_token.device_code,
},
@@ -105,6 +128,7 @@ class TestTokenDeviceCode(OAuthTestCase):
reverse("authentik_providers_oauth2:token"),
data={
"client_id": self.provider.client_id,
"client_secret": self.provider.client_secret,
"grant_type": GRANT_TYPE_DEVICE_CODE,
"device_code": device_token.device_code,
"scope": f"{SCOPE_OPENID} {SCOPE_OPENID_EMAIL} invalid",

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

@@ -165,7 +165,15 @@ class TokenParams:
raise TokenError("invalid_grant")
def __post_init__(self, raw_code: str, raw_token: str, request: HttpRequest):
if self.grant_type in [GRANT_TYPE_AUTHORIZATION_CODE, GRANT_TYPE_REFRESH_TOKEN]:
# Confidential clients MUST authenticate to the token endpoint per
# RFC 6749 §2.3.1. The device code grant (RFC 8628 §3.4) inherits
# that requirement - the device_code alone is not a substitute for
# client credentials.
if self.grant_type in [
GRANT_TYPE_AUTHORIZATION_CODE,
GRANT_TYPE_REFRESH_TOKEN,
GRANT_TYPE_DEVICE_CODE,
]:
if self.provider.client_type == ClientTypes.CONFIDENTIAL and not compare_digest(
self.provider.client_secret, self.client_secret
):

View File

@@ -1,5 +1,7 @@
"""authentik recovery create_admin_group"""
from argparse import ArgumentParser
from django.utils.translation import gettext as _
from authentik.core.models import User
@@ -12,7 +14,7 @@ class Command(TenantCommand):
help = _("Create admin group if the default group gets deleted.")
def add_arguments(self, parser):
def add_arguments(self, parser: ArgumentParser):
parser.add_argument("user", action="store", help="User to add to the admin group.")
def handle_per_tenant(self, *args, **options):

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

@@ -19,19 +19,32 @@ from authentik.tenants.models import Tenant
class FlagJSONField(JSONDictField):
def to_representation(self, value: dict) -> dict:
"""Exclude any system flags that aren't modifiable"""
new_value = value.copy()
for flag in Flag.available(exclude_system=False):
_flag = flag()
if _flag.visibility == "system":
new_value.pop(_flag.key, None)
return super().to_representation(new_value)
def run_validators(self, value: dict):
super().run_validators(value)
for flag in Flag.available():
for flag in Flag.available(exclude_system=False):
_flag = flag()
if _flag.key in value:
flag_value = value.get(_flag.key)
flag_type = get_args(_flag.__orig_bases__[0])[0]
if flag_value and not isinstance(flag_value, flag_type):
raise ValidationError(
_("Value for flag {flag_key} needs to be of type {type}.").format(
flag_key=_flag.key, type=flag_type.__name__
)
if _flag.key not in value:
continue
if _flag.visibility == "system":
value.pop(_flag.key, None)
continue
flag_value = value.get(_flag.key)
flag_type = get_args(_flag.__orig_bases__[0])[0]
if flag_value and not isinstance(flag_value, flag_type):
raise ValidationError(
_("Value for flag {flag_key} needs to be of type {type}.").format(
flag_key=_flag.key, type=flag_type.__name__
)
)
class FlagsJSONExtension(OpenApiSerializerFieldExtension):

View File

@@ -4,6 +4,7 @@ from functools import wraps
from typing import TYPE_CHECKING, Any, Literal
from django.db import DatabaseError, InternalError, ProgrammingError
from django.db.models import F, Func, JSONField, Value
from authentik.lib.utils.reflection import all_subclasses
@@ -13,7 +14,9 @@ if TYPE_CHECKING:
class Flag[T]:
default: T | None = None
visibility: Literal["none"] | Literal["public"] | Literal["authenticated"] = "none"
visibility: (
Literal["none"] | Literal["public"] | Literal["authenticated"] | Literal["system"]
) = "none"
description: str | None = None
def __init_subclass__(cls, key: str, **kwargs):
@@ -24,12 +27,15 @@ class Flag[T]:
return self.__key
@classmethod
def get(cls) -> T | None:
def get(cls, tenant: Tenant | None = None) -> T | None:
from authentik.tenants.utils import get_current_tenant
if not tenant:
tenant = get_current_tenant(["flags"])
flags = {}
try:
flags: dict[str, Any] = get_current_tenant(["flags"]).flags
flags: dict[str, Any] = tenant.flags
except DatabaseError, ProgrammingError, InternalError:
pass
value = flags.get(cls.__key, None)
@@ -37,20 +43,38 @@ class Flag[T]:
return cls().get_default()
return value
@classmethod
def set(cls, value: T, tenant: Tenant | None = None) -> T | None:
from authentik.tenants.models import Tenant
from authentik.tenants.utils import get_current_tenant
if not tenant:
tenant = get_current_tenant()
Tenant.objects.filter(pk=tenant.pk).update(
flags=Func(
F("flags"),
Value([cls.__key]),
Value(value, JSONField()),
function="jsonb_set",
)
)
def get_default(self) -> T | None:
return self.default
@staticmethod
def available(
visibility: Literal["none"] | Literal["public"] | Literal["authenticated"] | None = None,
exclude_system=True,
):
flags = all_subclasses(Flag)
if visibility:
for flag in flags:
if flag.visibility == visibility:
yield flag
else:
yield from flags
for flag in flags:
if visibility and flag.visibility != visibility:
continue
if exclude_system and flag.visibility == "system":
continue
yield flag
def patch_flag[T](flag: Flag[T], value: T):

View File

@@ -0,0 +1,19 @@
from argparse import ArgumentParser
from typing import Any
from authentik.tenants.management import TenantCommand
from authentik.tenants.utils import get_current_tenant
class Command(TenantCommand):
def add_arguments(self, parser: ArgumentParser):
parser.add_argument("flag_key", type=str)
parser.add_argument("flag_value", type=str)
def handle(self, *, flag_key: str, flag_value: Any, **options):
tenant = get_current_tenant()
val = flag_value.lower() == "true"
tenant.flags[flag_key] = val
tenant.save()
self.stdout.write(f"Set flag '{flag_key}' to {val}.")

View File

@@ -1,10 +1,12 @@
"""Test Settings API"""
from django.core.management import call_command
from django.urls import reverse
from rest_framework.test import APITestCase
from authentik.core.tests.utils import create_test_admin_user
from authentik.tenants.flags import Flag
from authentik.tenants.utils import get_current_tenant
class TestLocalSettingsAPI(APITestCase):
@@ -13,11 +15,19 @@ class TestLocalSettingsAPI(APITestCase):
def setUp(self):
super().setUp()
self.local_admin = create_test_admin_user()
self.tenant = get_current_tenant()
def tearDown(self):
super().tearDown()
self.tenant.flags = {}
self.tenant.save()
def test_settings_flags(self):
"""Test settings API"""
self.tenant.flags = {}
self.tenant.save()
class TestFlag(Flag[bool], key="tenants_test_flag"):
class _TestFlag(Flag[bool], key="tenants_test_flag_bool"):
default = False
visibility = "public"
@@ -26,15 +36,19 @@ class TestLocalSettingsAPI(APITestCase):
response = self.client.patch(
reverse("authentik_api:tenant_settings"),
data={
"flags": {"tenants_test_flag": True},
"flags": {"tenants_test_flag_bool": True},
},
)
self.assertEqual(response.status_code, 200)
self.tenant.refresh_from_db()
self.assertEqual(self.tenant.flags["tenants_test_flag_bool"], True)
def test_settings_flags_incorrect(self):
"""Test settings API"""
self.tenant.flags = {}
self.tenant.save()
class TestFlag(Flag[bool], key="tenants_test_flag"):
class _TestFlag(Flag[bool], key="tenants_test_flag_incorrect"):
default = False
visibility = "public"
@@ -43,11 +57,44 @@ class TestLocalSettingsAPI(APITestCase):
response = self.client.patch(
reverse("authentik_api:tenant_settings"),
data={
"flags": {"tenants_test_flag": 123},
"flags": {"tenants_test_flag_incorrect": 123},
},
)
self.assertEqual(response.status_code, 400)
self.assertJSONEqual(
response.content,
{"flags": ["Value for flag tenants_test_flag needs to be of type bool."]},
{"flags": ["Value for flag tenants_test_flag_incorrect needs to be of type bool."]},
)
self.tenant.refresh_from_db()
self.assertEqual(self.tenant.flags, {})
def test_settings_flags_system(self):
"""Test settings API"""
self.tenant.flags = {}
self.tenant.save()
class _TestFlag(Flag[bool], key="tenants_test_flag_sys"):
default = False
visibility = "system"
self.client.force_login(self.local_admin)
response = self.client.patch(
reverse("authentik_api:tenant_settings"),
data={
"flags": {"tenants_test_flag_sys": 123},
},
)
print(response.content)
self.assertEqual(response.status_code, 200)
self.tenant.refresh_from_db()
self.assertEqual(self.tenant.flags, {})
def test_command(self):
self.tenant.flags = {}
self.tenant.save()
call_command("set_flag", "foo", "true")
self.tenant.refresh_from_db()
self.assertTrue(self.tenant.flags["foo"])

View File

@@ -1,4 +1,7 @@
metadata:
labels:
blueprints.goauthentik.io/system-oobe: "true"
blueprints.goauthentik.io/system: "true"
name: Default - Out-of-box-experience flow
version: 1
entries:
@@ -75,23 +78,20 @@ entries:
- attrs:
expression: |
# This policy ensures that the setup flow can only be
# executed when the admin user doesn''t have a password set
# executed when the admin user doesn't have a password set
akadmin = ak_user_by(username="akadmin")
return not akadmin.has_usable_password()
# Ensure flow was started correctly
started_by = context.get("goauthentik.io/core/setup/started-by")
if started_by != "setup":
setup_url = request.http_request.build_absolute_uri("/")
ak_message(f"Access the authentik setup by navigating to {setup_url}")
return False
return akadmin is None or not akadmin.has_usable_password()
id: policy-default-oobe-password-usable
identifiers:
name: default-oobe-password-usable
model: authentik_policies_expression.expressionpolicy
- attrs:
expression: |
# This policy ensures that the setup flow can only be
# used one time
from authentik.flows.models import Flow, FlowAuthenticationRequirement
Flow.objects.filter(slug="initial-setup").update(
authentication=FlowAuthenticationRequirement.REQUIRE_SUPERUSER,
)
return True
id: policy-default-oobe-flow-set-authentication
- state: absent
identifiers:
name: default-oobe-flow-set-authentication
model: authentik_policies_expression.expressionpolicy
@@ -154,8 +154,3 @@ entries:
policy: !KeyOf policy-default-oobe-prefill-user
target: !KeyOf binding-password-write
model: authentik_policies.policybinding
- identifiers:
order: 0
policy: !KeyOf policy-default-oobe-flow-set-authentication
target: !KeyOf binding-login
model: authentik_policies.policybinding

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."

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

@@ -84,12 +84,6 @@ if [[ "$1" == "server" ]]; then
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

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

@@ -29,7 +29,7 @@ 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
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.26.2-trixie@sha256:982ae929f9a74083a242c6e25d19d7d9ed78c6e97fab639a119e90707ba819e2 AS go-builder
ARG TARGETOS
ARG TARGETARCH
@@ -42,8 +42,9 @@ WORKDIR /go/src/goauthentik.io
RUN --mount=type=cache,id=apt-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/var/cache/apt \
dpkg --add-architecture arm64 && \
dpkg --add-architecture amd64 && \
apt-get update && \
apt-get install -y --no-install-recommends crossbuild-essential-arm64 gcc-aarch64-linux-gnu
apt-get install -y --no-install-recommends crossbuild-essential-arm64 gcc-aarch64-linux-gnu crossbuild-essential-amd64 gcc-x86-64-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 \
@@ -62,7 +63,8 @@ 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 && \
if [ "$TARGETARCH" = "arm64" ] && [ "$(uname -m)" != "aarch64" ]; then export CC=aarch64-linux-gnu-gcc && export CC_FOR_TARGET=gcc-aarch64-linux-gnu; fi && \
if [ "$TARGETARCH" = "amd64" ] && [ "$(uname -m)" != "x86_64" ]; then export CC=x86_64-linux-gnu-gcc; fi && \
CGO_ENABLED=1 GOFIPS140=latest GOARM="${TARGETVARIANT#v}" \
go build -o /go/authentik ./cmd/server

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

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

12
package-lock.json generated
View File

@@ -1843,9 +1843,9 @@
}
},
"node_modules/brace-expansion": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
@@ -5373,9 +5373,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.8",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
"version": "8.5.10",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
"integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
"funding": [
{
"type": "opencollective",

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

@@ -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:

View File

@@ -6,6 +6,7 @@ from django.contrib.staticfiles.testing import StaticLiveServerTestCase
from dramatiq import get_broker
from structlog.stdlib import get_logger
from authentik.core.apps import Setup
from authentik.core.models import User
from authentik.core.tests.utils import create_test_admin_user
from authentik.tasks.test import use_test_broker
@@ -27,6 +28,7 @@ class E2ETestMixin(DockerTestCase):
self.wait_timeout = 60
self.logger = get_logger()
self.user = create_test_admin_user()
Setup.set(True)
super().setUp()
@classmethod

80
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]]
@@ -2824,7 +2824,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 +2832,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 +2844,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]]

88
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"
@@ -14810,9 +14810,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.8",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
"version": "8.5.10",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
"integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
"funding": [
{
"type": "opencollective",

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>;
}
}

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