mirror of
https://github.com/goauthentik/authentik
synced 2026-04-26 01:25:02 +02:00
Compare commits
65 Commits
hide_apps
...
rust-proxy
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ff008d6d6 | ||
|
|
5ad0150fe4 | ||
|
|
4f52a79c6a | ||
|
|
a8b8a81375 | ||
|
|
0459568a96 | ||
|
|
aa746e7585 | ||
|
|
a4dcf097b3 | ||
|
|
c2ecff559c | ||
|
|
c20ecb48f8 | ||
|
|
34a50ad46e | ||
|
|
99410f3775 | ||
|
|
86de4955aa | ||
|
|
bea9b23555 | ||
|
|
9820ee1d67 | ||
|
|
31e7b1dc4b | ||
|
|
1379637389 | ||
|
|
8bf7efecfd | ||
|
|
b1ceb28f71 | ||
|
|
39e6c41566 | ||
|
|
92a2d26c86 | ||
|
|
0f8d8c81d7 | ||
|
|
cce646b132 | ||
|
|
6d274d1e3d | ||
|
|
8d5489e441 | ||
|
|
8ea9a48017 | ||
|
|
c6b5869b48 | ||
|
|
e4971f9aa5 | ||
|
|
028ec05a8b | ||
|
|
b4c9ac57e0 | ||
|
|
80b93e1fbc | ||
|
|
dff6b48f53 | ||
|
|
79473341d6 | ||
|
|
99f9682d61 | ||
|
|
987f367d7b | ||
|
|
805ff9f1ab | ||
|
|
42fc9d537e | ||
|
|
3f4c0fb35d | ||
|
|
42d87072cf | ||
|
|
075a1f5875 | ||
|
|
24edee3e78 | ||
|
|
2cb3df2a60 | ||
|
|
5426881797 | ||
|
|
3f703bb21b | ||
|
|
b3c0a50f91 | ||
|
|
1fec16b8e0 | ||
|
|
8657d74dc9 | ||
|
|
347df15f50 | ||
|
|
cf2ed15ced | ||
|
|
b220e80a0d | ||
|
|
54f6b5c73c | ||
|
|
9fad68bdad | ||
|
|
dc1d99288f | ||
|
|
8fb795ec89 | ||
|
|
f8f84f5f0b | ||
|
|
5812558463 | ||
|
|
513462f78d | ||
|
|
833912b712 | ||
|
|
78a4b06ab3 | ||
|
|
c38e3cbbcf | ||
|
|
9fba928666 | ||
|
|
ce8f33416e | ||
|
|
6308ec3360 | ||
|
|
915bf6942e | ||
|
|
e63d2afb29 | ||
|
|
d103cea26a |
@@ -1,5 +1,5 @@
|
||||
[alias]
|
||||
t = ["nextest", "run"]
|
||||
t = ["nextest", "run", "--workspace"]
|
||||
|
||||
[build]
|
||||
rustflags = ["--cfg", "tokio_unstable"]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
[licenses]
|
||||
allow = [
|
||||
"Apache-2.0 WITH LLVM-exception",
|
||||
"Apache-2.0",
|
||||
"BSD-3-Clause",
|
||||
"CC0-1.0",
|
||||
|
||||
2
.github/actions/setup/action.yml
vendored
2
.github/actions/setup/action.yml
vendored
@@ -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)
|
||||
|
||||
6
.github/dependabot.yml
vendored
6
.github/dependabot.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/gen-image-compress.yml
vendored
2
.github/workflows/gen-image-compress.yml
vendored
@@ -38,7 +38,7 @@ jobs:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
- name: Compress images
|
||||
id: compress
|
||||
uses: calibreapp/image-actions@4f7260f5dbd809ec86d03721c1ad71b8a841d3e0 # main
|
||||
uses: calibreapp/image-actions@e2cc8db5d49c849e00844dfebf01438318e96fa2 # main
|
||||
with:
|
||||
GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }}
|
||||
compressOnly: ${{ github.event_name != 'pull_request' }}
|
||||
|
||||
366
Cargo.lock
generated
366
Cargo.lock
generated
@@ -17,6 +17,18 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"version_check",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.4"
|
||||
@@ -106,6 +118,37 @@ dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "argh"
|
||||
version = "0.1.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "211818e820cda9ca6f167a64a5c808837366a6dfd807157c64c1304c486cd033"
|
||||
dependencies = [
|
||||
"argh_derive",
|
||||
"argh_shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "argh_derive"
|
||||
version = "0.1.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c442a9d18cef5dde467405d27d461d080d68972d6d0dfd0408265b6749ec427d"
|
||||
dependencies = [
|
||||
"argh_shared",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "argh_shared"
|
||||
version = "0.1.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5ade012bac4db278517a0132c8c10c6427025868dca16c801087c28d5a411f1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arraydeque"
|
||||
version = "0.5.1"
|
||||
@@ -138,6 +181,39 @@ version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||
|
||||
[[package]]
|
||||
name = "authentik"
|
||||
version = "2026.5.0-rc1"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"argh",
|
||||
"authentik-axum",
|
||||
"authentik-client",
|
||||
"authentik-common",
|
||||
"axum",
|
||||
"color-eyre",
|
||||
"eyre",
|
||||
"futures",
|
||||
"hyper-unix-socket",
|
||||
"hyper-util",
|
||||
"metrics",
|
||||
"metrics-exporter-prometheus",
|
||||
"nix 0.31.2",
|
||||
"pyo3",
|
||||
"rand 0.10.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
"sqlx",
|
||||
"time",
|
||||
"tokio",
|
||||
"tokio-retry2",
|
||||
"tokio-tungstenite",
|
||||
"tracing",
|
||||
"url",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "authentik-axum"
|
||||
version = "2026.5.0-rc1"
|
||||
@@ -485,6 +561,17 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.0",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.44"
|
||||
@@ -567,6 +654,33 @@ dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "color-eyre"
|
||||
version = "0.6.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5920befb47832a6d61ee3a3a846565cfa39b331331e68a3b1d1116630f2f26d"
|
||||
dependencies = [
|
||||
"backtrace",
|
||||
"color-spantrace",
|
||||
"eyre",
|
||||
"indenter",
|
||||
"once_cell",
|
||||
"owo-colors",
|
||||
"tracing-error",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "color-spantrace"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8b88ea9df13354b55bc7234ebcce36e6ef896aca2e42a15de9e10edce01b427"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"owo-colors",
|
||||
"tracing-core",
|
||||
"tracing-error",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.5"
|
||||
@@ -695,6 +809,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc"
|
||||
version = "3.4.0"
|
||||
@@ -728,6 +851,15 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-epoch"
|
||||
version = "0.9.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-queue"
|
||||
version = "0.3.12"
|
||||
@@ -977,6 +1109,12 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
@@ -1166,6 +1304,7 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi 6.0.0",
|
||||
"rand_core 0.10.1",
|
||||
"wasip2",
|
||||
"wasip3",
|
||||
]
|
||||
@@ -1209,7 +1348,7 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash",
|
||||
"foldhash 0.1.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1217,6 +1356,9 @@ name = "hashbrown"
|
||||
version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
dependencies = [
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashlink"
|
||||
@@ -1343,9 +1485,9 @@ checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424"
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.8.1"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11"
|
||||
checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
@@ -1358,7 +1500,6 @@ dependencies = [
|
||||
"httpdate",
|
||||
"itoa",
|
||||
"pin-project-lite",
|
||||
"pin-utils",
|
||||
"smallvec",
|
||||
"tokio",
|
||||
"want",
|
||||
@@ -1393,6 +1534,20 @@ dependencies = [
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-unix-socket"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c255628da188a9d9ee218bae99da33a4b684ed63abe140a94d0f6e4b5af9a090"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.20"
|
||||
@@ -1850,6 +2005,46 @@ version = "2.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
|
||||
[[package]]
|
||||
name = "metrics"
|
||||
version = "0.24.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d5312e9ba3771cfa961b585728215e3d972c950a3eed9252aa093d6301277e8"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"portable-atomic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "metrics-exporter-prometheus"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3589659543c04c7dc5526ec858591015b87cd8746583b51b48ef4353f99dbcda"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"indexmap",
|
||||
"metrics",
|
||||
"metrics-util",
|
||||
"quanta",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "metrics-util"
|
||||
version = "0.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cdfb1365fea27e6dd9dc1dbc19f570198bc86914533ad639dae939635f096be4"
|
||||
dependencies = [
|
||||
"crossbeam-epoch",
|
||||
"crossbeam-utils",
|
||||
"hashbrown 0.16.1",
|
||||
"metrics",
|
||||
"quanta",
|
||||
"rand 0.9.2",
|
||||
"rand_xoshiro",
|
||||
"sketches-ddsketch",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
version = "0.3.17"
|
||||
@@ -1981,7 +2176,7 @@ dependencies = [
|
||||
"num-integer",
|
||||
"num-iter",
|
||||
"num-traits",
|
||||
"rand 0.8.5",
|
||||
"rand 0.8.6",
|
||||
"smallvec",
|
||||
"zeroize",
|
||||
]
|
||||
@@ -2233,6 +2428,12 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "owo-colors"
|
||||
version = "4.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d"
|
||||
|
||||
[[package]]
|
||||
name = "parking"
|
||||
version = "2.2.1"
|
||||
@@ -2309,12 +2510,6 @@ version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "pin-utils"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
|
||||
|
||||
[[package]]
|
||||
name = "pkcs1"
|
||||
version = "0.7.5"
|
||||
@@ -2348,6 +2543,12 @@ version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.4"
|
||||
@@ -2423,6 +2624,79 @@ dependencies = [
|
||||
"prost",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyo3"
|
||||
version = "0.28.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91fd8e38a3b50ed1167fb981cd6fd60147e091784c427b8f7183a7ee32c31c12"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"once_cell",
|
||||
"portable-atomic",
|
||||
"pyo3-build-config",
|
||||
"pyo3-ffi",
|
||||
"pyo3-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyo3-build-config"
|
||||
version = "0.28.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e368e7ddfdeb98c9bca7f8383be1648fd84ab466bf2bc015e94008db6d35611e"
|
||||
dependencies = [
|
||||
"target-lexicon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyo3-ffi"
|
||||
version = "0.28.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f29e10af80b1f7ccaf7f69eace800a03ecd13e883acfacc1e5d0988605f651e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"pyo3-build-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyo3-macros"
|
||||
version = "0.28.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df6e520eff47c45997d2fc7dd8214b25dd1310918bbb2642156ef66a67f29813"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"pyo3-macros-backend",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyo3-macros-backend"
|
||||
version = "0.28.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4cdc218d835738f81c2338f822078af45b4afdf8b2e33cbb5916f108b813acb"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"pyo3-build-config",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quanta"
|
||||
version = "0.12.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"raw-cpuid",
|
||||
"wasi",
|
||||
"web-sys",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.9"
|
||||
@@ -2502,9 +2776,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.5"
|
||||
version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
|
||||
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha 0.3.1",
|
||||
@@ -2521,6 +2795,17 @@ dependencies = [
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207"
|
||||
dependencies = [
|
||||
"chacha20",
|
||||
"getrandom 0.4.2",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.3.1"
|
||||
@@ -2559,6 +2844,30 @@ dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "rand_xoshiro"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f703f4665700daf5512dcca5f43afa6af89f09db47fb56be587f80636bda2d41"
|
||||
dependencies = [
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "raw-cpuid"
|
||||
version = "11.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
@@ -2737,9 +3046,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.38"
|
||||
version = "0.23.39"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21"
|
||||
checksum = "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"log",
|
||||
@@ -3095,7 +3404,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest",
|
||||
]
|
||||
|
||||
@@ -3106,7 +3415,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest",
|
||||
]
|
||||
|
||||
@@ -3151,6 +3460,12 @@ version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
|
||||
|
||||
[[package]]
|
||||
name = "sketches-ddsketch"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c6f73aeb92d671e0cc4dca167e59b2deb6387c375391bc99ee743f326994a2b"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
@@ -3315,7 +3630,7 @@ dependencies = [
|
||||
"memchr",
|
||||
"once_cell",
|
||||
"percent-encoding",
|
||||
"rand 0.8.5",
|
||||
"rand 0.8.6",
|
||||
"rsa",
|
||||
"serde",
|
||||
"sha1",
|
||||
@@ -3356,7 +3671,7 @@ dependencies = [
|
||||
"md-5",
|
||||
"memchr",
|
||||
"once_cell",
|
||||
"rand 0.8.5",
|
||||
"rand 0.8.6",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
@@ -3476,6 +3791,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "target-lexicon"
|
||||
version = "0.13.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
@@ -3664,8 +3985,12 @@ checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tungstenite",
|
||||
"webpki-roots 0.26.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3879,8 +4204,11 @@ dependencies = [
|
||||
"httparse",
|
||||
"log",
|
||||
"rand 0.9.2",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"sha1",
|
||||
"thiserror 2.0.18",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
85
Cargo.toml
85
Cargo.toml
@@ -20,11 +20,13 @@ publish = false
|
||||
|
||||
[workspace.dependencies]
|
||||
arc-swap = "= 1.9.1"
|
||||
argh = "= 0.1.19"
|
||||
axum-server = { version = "= 0.8.0", features = ["tls-rustls-no-provider"] }
|
||||
aws-lc-rs = { version = "= 1.16.3", features = ["fips"] }
|
||||
axum = { version = "= 0.8.9", features = ["http2", "macros", "ws"] }
|
||||
clap = { version = "= 4.6.1", features = ["derive", "env"] }
|
||||
client-ip = { version = "0.2.1", features = ["forwarded-header"] }
|
||||
color-eyre = "= 0.6.5"
|
||||
colored = "= 3.1.1"
|
||||
config-rs = { package = "config", version = "= 0.15.22", default-features = false, features = [
|
||||
"json",
|
||||
@@ -37,11 +39,17 @@ eyre = "= 0.6.12"
|
||||
forwarded-header-value = "= 0.1.1"
|
||||
futures = "= 0.3.32"
|
||||
glob = "= 0.3.3"
|
||||
hyper-unix-socket = "= 0.3.0"
|
||||
hyper-util = "= 0.1.20"
|
||||
ipnet = { version = "= 2.12.0", features = ["serde"] }
|
||||
json-subscriber = "= 0.2.8"
|
||||
nix = { version = "= 0.31.2", features = ["signal"] }
|
||||
metrics = "= 0.24.3"
|
||||
metrics-exporter-prometheus = { version = "= 0.18.1", default-features = false }
|
||||
nix = { version = "= 0.31.2", features = ["hostname", "signal"] }
|
||||
notify = "= 8.2.0"
|
||||
pin-project-lite = "= 0.2.17"
|
||||
pyo3 = "= 0.28.3"
|
||||
rand = "= 0.10.1"
|
||||
regex = "= 1.12.3"
|
||||
reqwest = { version = "= 0.13.2", features = [
|
||||
"form",
|
||||
@@ -58,7 +66,7 @@ reqwest-middleware = { version = "= 0.5.1", features = [
|
||||
"query",
|
||||
"rustls",
|
||||
] }
|
||||
rustls = { version = "= 0.23.38", features = ["fips"] }
|
||||
rustls = { version = "= 0.23.39", features = ["fips"] }
|
||||
sentry = { version = "= 0.47.0", default-features = false, features = [
|
||||
"backtrace",
|
||||
"contexts",
|
||||
@@ -92,6 +100,10 @@ time = { version = "= 0.3.47", features = ["macros"] }
|
||||
tokio = { version = "= 1.52.1", features = ["full", "tracing"] }
|
||||
tokio-retry2 = "= 0.9.1"
|
||||
tokio-rustls = "= 0.26.4"
|
||||
tokio-tungstenite = { version = "= 0.29.0", features = [
|
||||
"rustls-tls-webpki-roots",
|
||||
"url",
|
||||
] }
|
||||
tokio-util = { version = "= 0.7.18", features = ["full"] }
|
||||
tower = "= 0.5.3"
|
||||
tower-http = { version = "= 0.6.8", features = ["timeout"] }
|
||||
@@ -106,16 +118,10 @@ tracing-subscriber = { version = "= 0.3.23", features = [
|
||||
url = "= 2.5.8"
|
||||
uuid = { version = "= 1.23.1", features = ["serde", "v4"] }
|
||||
|
||||
ak-axum = { package = "authentik-axum", version = "2026.5.0-rc1", path = "./packages/ak-axum" }
|
||||
ak-client = { package = "authentik-client", version = "2026.5.0-rc1", path = "./packages/client-rust" }
|
||||
ak-common = { package = "authentik-common", version = "2026.5.0-rc1", path = "./packages/ak-common", default-features = false }
|
||||
|
||||
[profile.dev.package.backtrace]
|
||||
opt-level = 3
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
debug = 2
|
||||
|
||||
[workspace.lints.rust]
|
||||
ambiguous_negative_literals = "warn"
|
||||
closure_returning_async_block = "warn"
|
||||
@@ -229,3 +235,64 @@ unused_trait_names = "warn"
|
||||
unwrap_in_result = "warn"
|
||||
unwrap_used = "warn"
|
||||
verbose_file_reads = "warn"
|
||||
|
||||
[profile.dev.package.backtrace]
|
||||
opt-level = 3
|
||||
|
||||
[profile.dev]
|
||||
panic = "abort"
|
||||
|
||||
[profile.release]
|
||||
debug = 2
|
||||
lto = "fat"
|
||||
# Because of the async runtime, we want to die straightaway if we panic.
|
||||
panic = "abort"
|
||||
strip = true
|
||||
|
||||
[package]
|
||||
name = "authentik"
|
||||
version.workspace = true
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
readme.workspace = true
|
||||
homepage.workspace = true
|
||||
repository.workspace = true
|
||||
license-file.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[features]
|
||||
default = ["core", "proxy"]
|
||||
core = ["ak-common/core", "dep:pyo3", "dep:sqlx"]
|
||||
proxy = ["ak-common/proxy", "dep:ak-client"]
|
||||
|
||||
[dependencies]
|
||||
ak-axum.workspace = true
|
||||
ak-client = { workspace = true, optional = true }
|
||||
ak-common.workspace = true
|
||||
arc-swap.workspace = true
|
||||
argh.workspace = true
|
||||
axum.workspace = true
|
||||
color-eyre.workspace = true
|
||||
eyre.workspace = true
|
||||
futures.workspace = true
|
||||
hyper-unix-socket.workspace = true
|
||||
hyper-util.workspace = true
|
||||
metrics-exporter-prometheus.workspace = true
|
||||
metrics.workspace = true
|
||||
nix.workspace = true
|
||||
pyo3 = { workspace = true, optional = true }
|
||||
rand.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
serde_repr.workspace = true
|
||||
sqlx = { workspace = true, optional = true }
|
||||
time.workspace = true
|
||||
tokio-retry2.workspace = true
|
||||
tokio-tungstenite.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
url.workspace = true
|
||||
uuid.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
3
Makefile
3
Makefile
@@ -115,6 +115,9 @@ run-server: ## Run the main authentik server process
|
||||
run-worker: ## Run the main authentik worker process
|
||||
$(UV) run ak worker
|
||||
|
||||
run-worker-watch: ## Run the authentik worker, with auto reloading
|
||||
watchexec --on-busy-update=restart --stop-signal=SIGINT --exts py,rs --no-meta --notify -- $(UV) run ak worker
|
||||
|
||||
core-i18n-extract:
|
||||
$(UV) run ak makemessages \
|
||||
--add-location file \
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""Apply blueprint from commandline"""
|
||||
|
||||
from sys import exit as sys_exit
|
||||
|
||||
from django.core.management.base import BaseCommand, no_translations
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
@@ -28,7 +26,7 @@ class Command(BaseCommand):
|
||||
self.stderr.write("Blueprint invalid")
|
||||
for log in logs:
|
||||
self.stderr.write(f"\t{log.logger}: {log.event}: {log.attributes}")
|
||||
sys_exit(1)
|
||||
raise RuntimeError("Blueprint invalid")
|
||||
importer.apply()
|
||||
|
||||
def add_arguments(self, parser):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.",
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -446,8 +446,6 @@ DRAMATIQ = {
|
||||
("authentik.tasks.middleware.TaskLogMiddleware", {}),
|
||||
("authentik.tasks.middleware.LoggingMiddleware", {}),
|
||||
("authentik.tasks.middleware.DescriptionMiddleware", {}),
|
||||
("authentik.tasks.middleware.WorkerHealthcheckMiddleware", {}),
|
||||
("authentik.tasks.middleware.WorkerStatusMiddleware", {}),
|
||||
(
|
||||
"authentik.tasks.middleware.MetricsMiddleware",
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import pglock
|
||||
from django.utils.timezone import now, timedelta
|
||||
from drf_spectacular.utils import extend_schema, inline_serializer
|
||||
from packaging.version import parse
|
||||
@@ -31,18 +30,13 @@ class WorkerView(APIView):
|
||||
def get(self, request: Request) -> Response:
|
||||
response = []
|
||||
our_version = parse(authentik_full_version())
|
||||
for status in WorkerStatus.objects.filter(last_seen__gt=now() - timedelta(minutes=2)):
|
||||
lock_id = f"goauthentik.io/worker/status/{status.pk}"
|
||||
with pglock.advisory(lock_id, timeout=0, side_effect=pglock.Return) as acquired:
|
||||
# The worker doesn't hold the lock, it isn't running
|
||||
if acquired:
|
||||
continue
|
||||
version_matching = parse(status.version) == our_version
|
||||
response.append(
|
||||
{
|
||||
"worker_id": f"{status.pk}@{status.hostname}",
|
||||
"version": status.version,
|
||||
"version_matching": version_matching,
|
||||
}
|
||||
)
|
||||
for status in WorkerStatus.objects.filter(last_seen__gt=now() - timedelta(seconds=45)):
|
||||
version_matching = parse(status.version) == our_version
|
||||
response.append(
|
||||
{
|
||||
"worker_id": f"{status.pk}@{status.hostname}",
|
||||
"version": status.version,
|
||||
"version_matching": version_matching,
|
||||
}
|
||||
)
|
||||
return Response(response)
|
||||
|
||||
@@ -1,42 +1,25 @@
|
||||
import socket
|
||||
from collections.abc import Callable
|
||||
from http.server import BaseHTTPRequestHandler
|
||||
from threading import Event as TEvent
|
||||
from threading import Thread, current_thread
|
||||
from typing import Any, cast
|
||||
|
||||
import pglock
|
||||
from django.db import OperationalError, connections, transaction
|
||||
from django.utils.timezone import now
|
||||
from django.conf import settings
|
||||
from django.db import OperationalError
|
||||
from django_dramatiq_postgres.middleware import (
|
||||
CurrentTask as BaseCurrentTask,
|
||||
)
|
||||
from django_dramatiq_postgres.middleware import (
|
||||
HTTPServer,
|
||||
HTTPServerThread,
|
||||
)
|
||||
from django_dramatiq_postgres.middleware import (
|
||||
MetricsMiddleware as BaseMetricsMiddleware,
|
||||
)
|
||||
from django_dramatiq_postgres.middleware import (
|
||||
_MetricsHandler as BaseMetricsHandler,
|
||||
)
|
||||
from dramatiq import Worker
|
||||
from dramatiq.broker import Broker
|
||||
from dramatiq.message import Message
|
||||
from dramatiq.middleware import Middleware
|
||||
from psycopg.errors import Error
|
||||
from setproctitle import setthreadtitle
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik import authentik_full_version
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.lib.sentry import should_ignore_exception
|
||||
from authentik.lib.utils.reflection import class_to_path
|
||||
from authentik.root.monitoring import monitoring_set
|
||||
from authentik.root.signals import post_startup, pre_startup, startup
|
||||
from authentik.tasks.models import Task, TaskLog, TaskStatus, WorkerStatus
|
||||
from authentik.tasks.models import Task, TaskLog, TaskStatus
|
||||
from authentik.tenants.models import Tenant
|
||||
from authentik.tenants.utils import get_current_tenant
|
||||
|
||||
@@ -193,154 +176,26 @@ class DescriptionMiddleware(Middleware):
|
||||
return {"description"}
|
||||
|
||||
|
||||
class _healthcheck_handler(BaseHTTPRequestHandler):
|
||||
def log_request(self, code="-", size="-"):
|
||||
HEALTHCHECK_LOGGER.info(
|
||||
self.path,
|
||||
method=self.command,
|
||||
status=code,
|
||||
)
|
||||
|
||||
def log_error(self, format, *args):
|
||||
HEALTHCHECK_LOGGER.warning(format, *args)
|
||||
|
||||
def do_HEAD(self):
|
||||
try:
|
||||
for db_conn in connections.all():
|
||||
# Force connection reload
|
||||
db_conn.connect()
|
||||
_ = db_conn.cursor()
|
||||
self.send_response(200)
|
||||
except DB_ERRORS: # pragma: no cover
|
||||
self.send_response(503)
|
||||
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
|
||||
do_GET = do_HEAD
|
||||
|
||||
|
||||
class WorkerHealthcheckMiddleware(Middleware):
|
||||
thread: HTTPServerThread | None
|
||||
|
||||
def __init__(self):
|
||||
listen = CONFIG.get("listen.http", ["[::]:9000"])
|
||||
if isinstance(listen, str):
|
||||
listen = listen.split(",")
|
||||
host, _, port = listen[0].rpartition(":")
|
||||
|
||||
try:
|
||||
port = int(port)
|
||||
except ValueError:
|
||||
LOGGER.error(f"Invalid port entered: {port}")
|
||||
|
||||
self.host, self.port = host, port
|
||||
|
||||
def after_worker_boot(self, broker: Broker, worker: Worker):
|
||||
self.thread = HTTPServerThread(
|
||||
target=WorkerHealthcheckMiddleware.run, args=(self.host, self.port)
|
||||
)
|
||||
self.thread.start()
|
||||
|
||||
def before_worker_shutdown(self, broker: Broker, worker: Worker):
|
||||
server = self.thread.server
|
||||
if server:
|
||||
server.shutdown()
|
||||
LOGGER.debug("Stopping WorkerHealthcheckMiddleware")
|
||||
self.thread.join()
|
||||
|
||||
@staticmethod
|
||||
def run(addr: str, port: int):
|
||||
setthreadtitle("authentik Worker Healthcheck server")
|
||||
try:
|
||||
server = HTTPServer((addr, port), _healthcheck_handler)
|
||||
thread = cast(HTTPServerThread, current_thread())
|
||||
thread.server = server
|
||||
server.serve_forever()
|
||||
except OSError as exc:
|
||||
get_logger(__name__, type(WorkerHealthcheckMiddleware)).warning(
|
||||
"Port is already in use, not starting healthcheck server",
|
||||
exc=exc,
|
||||
)
|
||||
|
||||
|
||||
class WorkerStatusMiddleware(Middleware):
|
||||
thread: Thread | None
|
||||
thread_event: TEvent | None
|
||||
|
||||
def after_worker_boot(self, broker: Broker, worker: Worker):
|
||||
self.thread_event = TEvent()
|
||||
self.thread = Thread(target=WorkerStatusMiddleware.run, args=(self.thread_event,))
|
||||
self.thread.start()
|
||||
|
||||
def before_worker_shutdown(self, broker: Broker, worker: Worker):
|
||||
self.thread_event.set()
|
||||
LOGGER.debug("Stopping WorkerStatusMiddleware")
|
||||
self.thread.join()
|
||||
|
||||
@staticmethod
|
||||
def run(event: TEvent):
|
||||
setthreadtitle("authentik Worker status")
|
||||
with transaction.atomic():
|
||||
hostname = socket.gethostname()
|
||||
WorkerStatus.objects.filter(hostname=hostname).delete()
|
||||
status, _ = WorkerStatus.objects.update_or_create(
|
||||
hostname=hostname,
|
||||
version=authentik_full_version(),
|
||||
)
|
||||
while not event.is_set():
|
||||
try:
|
||||
WorkerStatusMiddleware.keep(event, status)
|
||||
except DB_ERRORS: # pragma: no cover
|
||||
event.wait(10)
|
||||
try:
|
||||
connections.close_all()
|
||||
except DB_ERRORS:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def keep(event: TEvent, status: WorkerStatus):
|
||||
lock_id = f"goauthentik.io/worker/status/{status.pk}"
|
||||
with pglock.advisory(lock_id, side_effect=pglock.Raise):
|
||||
while not event.is_set():
|
||||
status.refresh_from_db()
|
||||
old_last_seen = status.last_seen
|
||||
status.last_seen = now()
|
||||
if old_last_seen != status.last_seen:
|
||||
status.save(update_fields=("last_seen",))
|
||||
event.wait(30)
|
||||
|
||||
|
||||
class _MetricsHandler(BaseMetricsHandler):
|
||||
def do_GET(self) -> None:
|
||||
monitoring_set.send_robust(self)
|
||||
return super().do_GET()
|
||||
|
||||
|
||||
class MetricsMiddleware(BaseMetricsMiddleware):
|
||||
thread: HTTPServerThread | None
|
||||
handler_class = _MetricsHandler
|
||||
|
||||
@property
|
||||
def forks(self) -> list[Callable[[], None]]:
|
||||
return []
|
||||
|
||||
def after_worker_boot(self, broker: Broker, worker: Worker):
|
||||
listen = CONFIG.get("listen.metrics", ["[::]:9300"])
|
||||
if isinstance(listen, str):
|
||||
listen = listen.split(",")
|
||||
addr, _, port = listen[0].rpartition(":")
|
||||
def before_worker_boot(self, broker: Broker, worker: Any) -> None:
|
||||
if settings.TEST:
|
||||
return super().before_worker_boot(broker, worker)
|
||||
|
||||
try:
|
||||
port = int(port)
|
||||
except ValueError:
|
||||
LOGGER.error(f"Invalid port entered: {port}")
|
||||
self.thread = HTTPServerThread(target=MetricsMiddleware.run, args=(addr, port))
|
||||
self.thread.start()
|
||||
from prometheus_client import values
|
||||
from prometheus_client.values import MultiProcessValue
|
||||
|
||||
def before_worker_shutdown(self, broker: Broker, worker: Worker):
|
||||
server = self.thread.server
|
||||
if server:
|
||||
server.shutdown()
|
||||
LOGGER.debug("Stopping MetricsMiddleware")
|
||||
self.thread.join()
|
||||
values.ValueClass = MultiProcessValue(lambda: worker.worker_id)
|
||||
|
||||
return super().before_worker_boot(broker, worker)
|
||||
|
||||
def after_worker_shutdown(self, broker: Broker, worker: Any) -> None:
|
||||
if settings.TEST:
|
||||
return
|
||||
|
||||
from prometheus_client import multiprocess
|
||||
|
||||
multiprocess.mark_process_dead(worker.worker_id)
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
import pglock
|
||||
from django.db.models import Count
|
||||
from django.dispatch import receiver
|
||||
from django.utils.timezone import now
|
||||
@@ -31,24 +30,15 @@ GAUGE_TASKS_QUEUED = Gauge(
|
||||
)
|
||||
|
||||
|
||||
_version = parse(authentik_full_version())
|
||||
|
||||
|
||||
@receiver(monitoring_set)
|
||||
def monitoring_set_workers(sender, **kwargs):
|
||||
"""Set worker gauge"""
|
||||
worker_version_count = {}
|
||||
for status in WorkerStatus.objects.filter(last_seen__gt=now() - timedelta(minutes=2)):
|
||||
lock_id = f"goauthentik.io/worker/status/{status.pk}"
|
||||
with pglock.advisory(lock_id, timeout=0, side_effect=pglock.Return) as acquired:
|
||||
# The worker doesn't hold the lock, it isn't running
|
||||
if acquired:
|
||||
continue
|
||||
version_matching = parse(status.version) == _version
|
||||
worker_version_count.setdefault(
|
||||
status.version, {"count": 0, "matching": version_matching}
|
||||
)
|
||||
worker_version_count[status.version]["count"] += 1
|
||||
our_version = parse(authentik_full_version())
|
||||
for status in WorkerStatus.objects.filter(last_seen__gt=now() - timedelta(seconds=45)):
|
||||
version_matching = parse(status.version) == our_version
|
||||
worker_version_count.setdefault(status.version, {"count": 0, "matching": version_matching})
|
||||
worker_version_count[status.version]["count"] += 1
|
||||
for version, stats in worker_version_count.items():
|
||||
OLD_GAUGE_WORKERS.labels(version, stats["matching"]).set(stats["count"])
|
||||
GAUGE_WORKERS.labels(version, stats["matching"]).set(stats["count"])
|
||||
|
||||
@@ -10,7 +10,6 @@ from dramatiq.results.middleware import Results
|
||||
from dramatiq.worker import Worker, _ConsumerThread, _WorkerThread
|
||||
|
||||
from authentik.tasks.broker import PostgresBroker
|
||||
from authentik.tasks.middleware import WorkerHealthcheckMiddleware
|
||||
|
||||
TESTING_QUEUE = "testing"
|
||||
|
||||
@@ -18,6 +17,7 @@ TESTING_QUEUE = "testing"
|
||||
class TestWorker(Worker):
|
||||
def __init__(self, broker: Broker):
|
||||
super().__init__(broker=broker)
|
||||
self.worker_id = 1000
|
||||
self.work_queue = PriorityQueue()
|
||||
self.consumers = {
|
||||
TESTING_QUEUE: _ConsumerThread(
|
||||
@@ -82,8 +82,6 @@ def use_test_broker():
|
||||
middleware: Middleware = import_string(middleware_class)(
|
||||
**middleware_kwargs,
|
||||
)
|
||||
if isinstance(middleware, WorkerHealthcheckMiddleware):
|
||||
middleware.port = 9102
|
||||
if isinstance(middleware, Retries):
|
||||
middleware.max_retries = 0
|
||||
if isinstance(middleware, Results):
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -7,9 +7,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -18,8 +16,6 @@ import (
|
||||
"goauthentik.io/internal/web"
|
||||
)
|
||||
|
||||
var workerPidFile = path.Join(os.TempDir(), "authentik-worker.pid")
|
||||
|
||||
var healthcheckCmd = &cobra.Command{
|
||||
Use: "healthcheck",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
@@ -31,9 +27,9 @@ var healthcheckCmd = &cobra.Command{
|
||||
log.WithField("mode", mode).Debug("checking health")
|
||||
switch strings.ToLower(mode) {
|
||||
case "server":
|
||||
exitCode = checkServer()
|
||||
exitCode = check(fmt.Sprintf("http://localhost%s-/health/live/", config.Get().Web.Path))
|
||||
case "worker":
|
||||
exitCode = checkWorker()
|
||||
exitCode = check("http://localhost/-/health/live/")
|
||||
default:
|
||||
log.Warn("Invalid mode")
|
||||
}
|
||||
@@ -45,7 +41,7 @@ func init() {
|
||||
rootCmd.AddCommand(healthcheckCmd)
|
||||
}
|
||||
|
||||
func checkServer() int {
|
||||
func check(url string) int {
|
||||
h := &http.Client{
|
||||
Transport: utils.NewUserAgentTransport("goauthentik.io/healthcheck",
|
||||
&http.Transport{
|
||||
@@ -55,7 +51,6 @@ func checkServer() int {
|
||||
},
|
||||
),
|
||||
}
|
||||
url := fmt.Sprintf("http://localhost%s-/health/live/", config.Get().Web.Path)
|
||||
res, err := h.Head(url)
|
||||
if err != nil {
|
||||
log.WithError(err).Warning("failed to send healthcheck request")
|
||||
@@ -68,29 +63,3 @@ func checkServer() int {
|
||||
log.Debug("successfully checked health")
|
||||
return 0
|
||||
}
|
||||
|
||||
func checkWorker() int {
|
||||
pidB, err := os.ReadFile(workerPidFile)
|
||||
if err != nil {
|
||||
log.WithError(err).Warning("failed to check worker PID file")
|
||||
return 1
|
||||
}
|
||||
pidS := strings.TrimSpace(string(pidB[:]))
|
||||
pid, err := strconv.Atoi(pidS)
|
||||
if err != nil {
|
||||
log.WithError(err).Warning("failed to find worker process PID")
|
||||
return 1
|
||||
}
|
||||
process, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
log.WithError(err).Warning("failed to find worker process")
|
||||
return 1
|
||||
}
|
||||
err = process.Signal(syscall.Signal(0))
|
||||
if err != nil {
|
||||
log.WithError(err).Warning("failed to signal worker process")
|
||||
return 1
|
||||
}
|
||||
log.Info("successfully checked health")
|
||||
return 0
|
||||
}
|
||||
|
||||
2
go.mod
2
go.mod
@@ -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
4
go.sum
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
113
lifecycle/ak
113
lifecycle/ak
@@ -1,10 +1,8 @@
|
||||
#!/usr/bin/env -S bash
|
||||
set -e -o pipefail
|
||||
MODE_FILE="${TMPDIR}/authentik-mode"
|
||||
#!/usr/bin/env bash
|
||||
|
||||
if [[ -z "${PROMETHEUS_MULTIPROC_DIR}" ]]; then
|
||||
export PROMETHEUS_MULTIPROC_DIR="${TMPDIR:-/tmp}/authentik_prometheus_tmp"
|
||||
fi
|
||||
set -e -o pipefail
|
||||
|
||||
MODE_FILE="$TMPDIR/authentik-mode"
|
||||
|
||||
function log {
|
||||
printf '{"event": "%s", "level": "info", "logger": "bootstrap"}\n' "$@" >&2
|
||||
@@ -15,10 +13,41 @@ function wait_for_db {
|
||||
log "Bootstrap completed"
|
||||
}
|
||||
|
||||
function check_if_root {
|
||||
function run_authentik {
|
||||
case "$1" in
|
||||
server)
|
||||
shift 1
|
||||
echo -n server >"$MODE_FILE"
|
||||
if [[ -x "$(command -v authentik-server)" ]]; then
|
||||
echo authentik-server "$@"
|
||||
else
|
||||
echo go run ./cmd/server "$@"
|
||||
fi
|
||||
;;
|
||||
healthcheck)
|
||||
if [[ -x "$(command -v authentik-server)" ]]; then
|
||||
echo authentik-server "$@"
|
||||
else
|
||||
echo go run ./cmd/server "$@"
|
||||
fi
|
||||
;;
|
||||
worker)
|
||||
if [[ -x "$(command -v authentik)" ]]; then
|
||||
echo authentik "$@"
|
||||
else
|
||||
echo cargo run -- "$@"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "$@"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
function check_if_root_and_run {
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
log "Not running as root, disabling permission fixes"
|
||||
exec $1
|
||||
exec $(run_authentik "$@")
|
||||
return
|
||||
fi
|
||||
SOCKET="/var/run/docker.sock"
|
||||
@@ -26,36 +55,19 @@ function check_if_root {
|
||||
if [[ -e "$SOCKET" ]]; then
|
||||
# Get group ID of the docker socket, so we can create a matching group and
|
||||
# add ourselves to it
|
||||
DOCKER_GID=$(stat -c '%g' $SOCKET)
|
||||
DOCKER_GID="$(stat -c "%g" "${SOCKET}")"
|
||||
# Ensure group for the id exists
|
||||
getent group $DOCKER_GID || groupadd -f -g $DOCKER_GID docker
|
||||
usermod -a -G $DOCKER_GID authentik
|
||||
getent group "${DOCKER_GID}" || groupadd -f -g "${DOCKER_GID}" docker
|
||||
usermod -a -G "${DOCKER_GID}" authentik
|
||||
# since the name of the group might not be docker, we need to lookup the group id
|
||||
GROUP_NAME=$(getent group $DOCKER_GID | sed 's/:/\n/g' | head -1)
|
||||
GROUP_NAME=$(getent group "${DOCKER_GID}" | sed 's/:/\n/g' | head -1)
|
||||
GROUP="authentik:${GROUP_NAME}"
|
||||
fi
|
||||
# Fix permissions of certs and media
|
||||
chown -R authentik:authentik /data /certs "${PROMETHEUS_MULTIPROC_DIR}"
|
||||
chmod ug+rwx /data
|
||||
chmod ug+rx /certs
|
||||
exec chpst -u authentik:$GROUP env HOME=/authentik $1
|
||||
}
|
||||
|
||||
function run_authentik {
|
||||
if [[ -x "$(command -v authentik)" ]]; then
|
||||
exec authentik $@
|
||||
else
|
||||
exec go run -v ./cmd/server/ $@
|
||||
fi
|
||||
}
|
||||
|
||||
function set_mode {
|
||||
echo $1 >$MODE_FILE
|
||||
trap cleanup EXIT
|
||||
}
|
||||
|
||||
function cleanup {
|
||||
rm -f ${MODE_FILE}
|
||||
exec chpst -u authentik:"${GROUP}" env HOME=/authentik $(run_authentik "$@")
|
||||
}
|
||||
|
||||
function prepare_debug {
|
||||
@@ -72,38 +84,33 @@ function prepare_debug {
|
||||
chown authentik:authentik /unittest.xml
|
||||
}
|
||||
|
||||
if [[ -z "${PROMETHEUS_MULTIPROC_DIR}" ]]; then
|
||||
export PROMETHEUS_MULTIPROC_DIR="${TMPDIR:-/tmp}/authentik_prometheus_tmp"
|
||||
fi
|
||||
mkdir -p "${PROMETHEUS_MULTIPROC_DIR}"
|
||||
|
||||
if [[ "$(python -m authentik.lib.config debugger 2>/dev/null)" == "True" ]]; then
|
||||
prepare_debug
|
||||
fi
|
||||
|
||||
if [[ "$1" == "server" ]]; then
|
||||
set_mode "server"
|
||||
run_authentik
|
||||
elif [[ "$1" == "worker" ]]; then
|
||||
set_mode "worker"
|
||||
shift
|
||||
# If we have bootstrap credentials set, run bootstrap tasks outside of main server
|
||||
# sync, so that we can sure the first start actually has working bootstrap
|
||||
# credentials
|
||||
if [[ -n "${AUTHENTIK_BOOTSTRAP_PASSWORD}" || -n "${AUTHENTIK_BOOTSTRAP_TOKEN}" ]]; then
|
||||
python -m manage apply_blueprint system/bootstrap.yaml || true
|
||||
fi
|
||||
check_if_root "python -m manage worker --pid-file ${TMPDIR}/authentik-worker.pid $@"
|
||||
elif [[ "$1" == "bash" ]]; then
|
||||
/bin/bash
|
||||
elif [[ "$1" == "test-all" ]]; then
|
||||
prepare_debug
|
||||
chmod 777 /root
|
||||
check_if_root "python -m manage test authentik"
|
||||
elif [[ "$1" == "healthcheck" ]]; then
|
||||
run_authentik healthcheck $(cat $MODE_FILE)
|
||||
if [[ "$1" == "bash" ]]; then
|
||||
exec /usr/bin/env -S bash "$@"
|
||||
elif [[ "$1" == "dump_config" ]]; then
|
||||
shift
|
||||
exec python -m authentik.lib.config $@
|
||||
shift 1
|
||||
exec python -m authentik.lib.config "$@"
|
||||
elif [[ "$1" == "debug" ]]; then
|
||||
exec sleep infinity
|
||||
elif [[ "$1" == "test-all" ]]; then
|
||||
wait_for_db
|
||||
prepare_debug
|
||||
chmod 777 /root
|
||||
check_if_root_and_run manage test authentik
|
||||
elif [[ "$1" == "server" ]] || [[ "$1" == "worker" ]]; then
|
||||
wait_for_db
|
||||
check_if_root_and_run "$@"
|
||||
elif [[ "$1" == "healthcheck" ]]; then
|
||||
check_if_root_and_run "$@" "$(cat "$MODE_FILE")"
|
||||
else
|
||||
wait_for_db
|
||||
exec python -m manage "$@"
|
||||
fi
|
||||
|
||||
8
lifecycle/aws/package-lock.json
generated
8
lifecycle/aws/package-lock.json
generated
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# Stage 1: Build webui
|
||||
# Stage: Build webui
|
||||
FROM --platform=${BUILDPLATFORM} docker.io/library/node:24-trixie-slim@sha256:735dd688da64d22ebd9dd374b3e7e5a874635668fd2a6ec20ca1f99264294086 AS node-builder
|
||||
|
||||
ARG GIT_BUILD_HASH
|
||||
@@ -28,23 +28,14 @@ COPY ./website /work/website/
|
||||
RUN npm run build && \
|
||||
npm run build:sfe
|
||||
|
||||
# Stage 2: Build go proxy
|
||||
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.26.2-trixie@sha256:cd8540d626ab35272a8f5ef829c5e5f189ce1dfbf467c123320c191c69c23245 AS go-builder
|
||||
# Stage: Build go proxy
|
||||
FROM docker.io/library/golang:1.26.2-trixie@sha256:982ae929f9a74083a242c6e25d19d7d9ed78c6e97fab639a119e90707ba819e2 AS go-builder
|
||||
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
ARG TARGETVARIANT
|
||||
|
||||
ARG GOOS=$TARGETOS
|
||||
ARG GOARCH=$TARGETARCH
|
||||
|
||||
WORKDIR /go/src/goauthentik.io
|
||||
|
||||
RUN --mount=type=cache,id=apt-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/var/cache/apt \
|
||||
dpkg --add-architecture arm64 && \
|
||||
apt-get update && \
|
||||
apt-get install -y --no-install-recommends crossbuild-essential-arm64 gcc-aarch64-linux-gnu
|
||||
|
||||
RUN --mount=type=bind,target=/go/src/goauthentik.io/go.mod,src=./go.mod \
|
||||
--mount=type=bind,target=/go/src/goauthentik.io/go.sum,src=./go.sum \
|
||||
--mount=type=cache,target=/go/pkg/mod \
|
||||
@@ -62,11 +53,9 @@ COPY ./packages/client-go /go/src/goauthentik.io/packages/client-go
|
||||
|
||||
RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \
|
||||
--mount=type=cache,id=go-build-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/root/.cache/go-build \
|
||||
if [ "$TARGETARCH" = "arm64" ]; then export CC=aarch64-linux-gnu-gcc && export CC_FOR_TARGET=gcc-aarch64-linux-gnu; fi && \
|
||||
CGO_ENABLED=1 GOFIPS140=latest GOARM="${TARGETVARIANT#v}" \
|
||||
go build -o /go/authentik ./cmd/server
|
||||
CGO_ENABLED=1 GOFIPS140=latest go build -o /go/authentik-server ./cmd/server
|
||||
|
||||
# Stage 3: MaxMind GeoIP
|
||||
# Stage: MaxMind GeoIP
|
||||
FROM --platform=${BUILDPLATFORM} ghcr.io/maxmind/geoipupdate:v7.1.1@sha256:faecdca22579730ab0b7dea5aa9af350bb3c93cb9d39845c173639ead30346d2 AS geoip
|
||||
|
||||
ENV GEOIPUPDATE_EDITION_IDS="GeoLite2-City GeoLite2-ASN"
|
||||
@@ -79,9 +68,31 @@ RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \
|
||||
mkdir -p /usr/share/GeoIP && \
|
||||
/bin/sh -c "GEOIPUPDATE_LICENSE_KEY_FILE=/run/secrets/GEOIPUPDATE_LICENSE_KEY /usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0"
|
||||
|
||||
# Stage 4: Download uv
|
||||
# Stage: download Rust toolchain
|
||||
FROM ghcr.io/goauthentik/fips-debian:trixie-slim-fips@sha256:7726387c78b5787d2146868c2ccc8948a3591d0a5a6436f7780c8c28acc76341 AS rust-toolchain
|
||||
|
||||
ARG TARGETARCH
|
||||
ARG TARGETVARIANT
|
||||
|
||||
ENV PATH="/root/.cargo/bin:$PATH"
|
||||
SHELL ["/bin/sh", "-o", "pipefail", "-c"]
|
||||
RUN --mount=type=bind,target=rust-toolchain.toml,src=rust-toolchain.toml \
|
||||
apt-get update && \
|
||||
# Required for installing pip packages
|
||||
apt-get install -y --no-install-recommends \
|
||||
# Build essentials
|
||||
build-essential && \
|
||||
curl https://sh.rustup.rs -sSf | sh -s -- -y --profile minimal --default-toolchain none && \
|
||||
rustup install && \
|
||||
rustup default "$(sed -n 's/channel = "\(.*\)"/\1/p' rust-toolchain.toml)" && \
|
||||
rustc --version && \
|
||||
cargo --version
|
||||
|
||||
RUN cat /root/.rustup/settings.toml
|
||||
|
||||
# Stage: Download uv
|
||||
FROM ghcr.io/astral-sh/uv:0.11.5@sha256:555ac94f9a22e656fc5f2ce5dfee13b04e94d099e46bb8dd3a73ec7263f2e484 AS uv
|
||||
# Stage 5: Base python image
|
||||
# Stage: Base python image
|
||||
FROM ghcr.io/goauthentik/fips-python:3.14.3-slim-trixie-fips@sha256:bf45eb77a010d76fe6abd7ae137d1b0c44b6227cd984945042135fdf05ebf8d9 AS python-base
|
||||
|
||||
ENV VENV_PATH="/ak-root/.venv" \
|
||||
@@ -95,16 +106,53 @@ WORKDIR /ak-root/
|
||||
|
||||
COPY --from=uv /uv /uvx /bin/
|
||||
|
||||
# Stage 6: Python dependencies
|
||||
# Stage: build rust binary
|
||||
FROM python-base AS rust-builder
|
||||
|
||||
ARG TARGETARCH
|
||||
ARG TARGETVARIANT
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
ENV PATH="/root/.cargo/bin:$PATH"
|
||||
COPY --from=rust-toolchain /root/.rustup /root/.rustup
|
||||
COPY --from=rust-toolchain /root/.cargo /root/.cargo
|
||||
|
||||
RUN rm -f /etc/apt/apt.conf.d/docker-clean; echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache
|
||||
RUN --mount=type=cache,id=apt-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/var/cache/apt \
|
||||
--mount=type=bind,target=rust-toolchain.toml,src=rust-toolchain.toml \
|
||||
apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
# common dependencies
|
||||
build-essential \
|
||||
# aws-lc deps
|
||||
cmake clang golang
|
||||
# See https://github.com/aws/aws-lc-rs/issues/569
|
||||
ENV AWS_LC_FIPS_SYS_CC=clang
|
||||
|
||||
RUN --mount=type=bind,target=rust-toolchain.toml,src=rust-toolchain.toml \
|
||||
--mount=type=bind,target=Cargo.toml,src=Cargo.toml \
|
||||
--mount=type=bind,target=Cargo.lock,src=Cargo.lock \
|
||||
--mount=type=bind,target=.cargo/,src=.cargo/ \
|
||||
--mount=type=bind,target=src/,src=src/ \
|
||||
--mount=type=bind,target=packages/,src=packages/ \
|
||||
--mount=type=bind,target=authentik/lib/default.yml,src=authentik/lib/default.yml \
|
||||
# Required otherwise workspace discovery fails
|
||||
--mount=type=bind,target=website/scripts/docsmg/,src=website/scripts/docsmg/ \
|
||||
--mount=type=cache,id=cargo-git-db-$TARGETARCH$TARGETVARIANT,target=/root/.cargo/git/db/ \
|
||||
--mount=type=cache,id=cargo-registry-$TARGETARCH$TARGETVARIANT,target=/root/.cargo/registry/ \
|
||||
--mount=type=cache,id=rust-target-$TARGETARCH$TARGETVARIANT,target=/build/target/ \
|
||||
cargo build --package authentik --no-default-features --features core --locked --release && \
|
||||
cp ./target/release/authentik /bin/authentik
|
||||
|
||||
|
||||
# Stage: Python dependencies
|
||||
FROM python-base AS python-deps
|
||||
|
||||
ARG TARGETARCH
|
||||
ARG TARGETVARIANT
|
||||
|
||||
RUN rm -f /etc/apt/apt.conf.d/docker-clean; echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache
|
||||
|
||||
ENV PATH="/root/.cargo/bin:$PATH"
|
||||
|
||||
RUN --mount=type=cache,id=apt-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/var/cache/apt \
|
||||
--mount=type=bind,target=rust-toolchain.toml,src=rust-toolchain.toml \
|
||||
apt-get update && \
|
||||
@@ -121,28 +169,21 @@ RUN --mount=type=cache,id=apt-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/v
|
||||
# python-kadmin-rs
|
||||
krb5-multidev libkrb5-dev heimdal-multidev libclang-dev \
|
||||
# xmlsec
|
||||
libltdl-dev && \
|
||||
export RUST_TOOLCHAIN="$(awk -F'\"' '/^[[:space:]]*channel[[:space:]]*=/{print $2; exit}' rust-toolchain.toml)" && \
|
||||
curl https://sh.rustup.rs -sSf | sh -s -- -y --profile minimal --default-toolchain "${RUST_TOOLCHAIN}" && \
|
||||
rustup default "${RUST_TOOLCHAIN}" && \
|
||||
rustc --version && \
|
||||
cargo --version
|
||||
libltdl-dev
|
||||
|
||||
ENV UV_NO_BINARY_PACKAGE="cryptography lxml python-kadmin-rs xmlsec" \
|
||||
# https://github.com/rust-lang/rustup/issues/2949
|
||||
# Fixes issues where the rust version in the build cache is older than latest
|
||||
# and rustup tries to update it, which fails
|
||||
RUSTUP_PERMIT_COPY_RENAME="1"
|
||||
ENV PATH="/root/.cargo/bin:$PATH"
|
||||
COPY --from=rust-toolchain /root/.rustup /root/.rustup
|
||||
COPY --from=rust-toolchain /root/.cargo /root/.cargo
|
||||
|
||||
ENV UV_NO_BINARY_PACKAGE="cryptography lxml python-kadmin-rs xmlsec"
|
||||
RUN --mount=type=bind,target=pyproject.toml,src=pyproject.toml \
|
||||
--mount=type=bind,target=uv.lock,src=uv.lock \
|
||||
--mount=type=bind,target=packages,src=packages \
|
||||
--mount=type=bind,target=rust-toolchain.toml,src=rust-toolchain.toml \
|
||||
--mount=type=cache,id=uv-python-deps-$TARGETARCH$TARGETVARIANT,target=/root/.cache/uv \
|
||||
RUSTUP_TOOLCHAIN="$(awk -F'\"' '/^[[:space:]]*channel[[:space:]]*=/{print $2; exit}' rust-toolchain.toml)" \
|
||||
uv sync --frozen --no-install-project --no-dev
|
||||
|
||||
# Stage 7: Run
|
||||
# Stage: Run
|
||||
FROM python-base AS final-image
|
||||
|
||||
ARG VERSION
|
||||
@@ -193,7 +234,8 @@ COPY ./manage.py /
|
||||
COPY ./blueprints /blueprints
|
||||
COPY ./lifecycle/ /lifecycle
|
||||
COPY ./authentik/sources/kerberos/krb5.conf /etc/krb5.conf
|
||||
COPY --from=go-builder /go/authentik /bin/authentik
|
||||
COPY --from=rust-builder /bin/authentik /bin/authentik
|
||||
COPY --from=go-builder /go/authentik-server /bin/authentik-server
|
||||
COPY ./packages/ /ak-root/packages
|
||||
RUN ln -s /ak-root/packages /packages
|
||||
COPY --from=python-deps /ak-root/.venv /ak-root/.venv
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# Stage 1: Build
|
||||
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.26.2-trixie@sha256:cd8540d626ab35272a8f5ef829c5e5f189ce1dfbf467c123320c191c69c23245 AS builder
|
||||
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.26.2-trixie@sha256:982ae929f9a74083a242c6e25d19d7d9ed78c6e97fab639a119e90707ba819e2 AS builder
|
||||
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
|
||||
147
lifecycle/worker_process.py
Executable file
147
lifecycle/worker_process.py
Executable file
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import os
|
||||
import random
|
||||
import signal
|
||||
import sys
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from socket import AF_UNIX
|
||||
from threading import Event, Thread
|
||||
from typing import Any
|
||||
|
||||
from dramatiq import Worker, get_broker
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.lib.config import CONFIG
|
||||
|
||||
LOGGER = get_logger()
|
||||
INITIAL_WORKER_ID = 1000
|
||||
|
||||
|
||||
class HttpHandler(BaseHTTPRequestHandler):
|
||||
def check_db(self):
|
||||
from django.db import connections
|
||||
|
||||
for db_conn in connections.all():
|
||||
# Force connection reload
|
||||
db_conn.connect()
|
||||
_ = db_conn.cursor()
|
||||
|
||||
def do_GET(self):
|
||||
if self.path == "/-/metrics/":
|
||||
from authentik.root.monitoring import monitoring_set
|
||||
|
||||
monitoring_set.send_robust(self)
|
||||
self.send_response(200)
|
||||
self.end_headers()
|
||||
elif self.path == "/-/health/ready/":
|
||||
from django.db.utils import OperationalError
|
||||
|
||||
try:
|
||||
self.check_db()
|
||||
except OperationalError:
|
||||
self.send_response(503)
|
||||
self.send_response(200)
|
||||
self.end_headers()
|
||||
else:
|
||||
self.send_response(200)
|
||||
self.end_headers()
|
||||
|
||||
def log_message(self, format: str, *args: Any) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class UnixSocketServer(HTTPServer):
|
||||
address_family = AF_UNIX
|
||||
|
||||
|
||||
def main(worker_id: int, socket_path: str):
|
||||
shutdown = Event()
|
||||
try:
|
||||
os.remove(socket_path)
|
||||
except OSError, FileNotFoundError:
|
||||
pass
|
||||
srv = UnixSocketServer(socket_path, HttpHandler)
|
||||
|
||||
def immediate_shutdown(signum, frame):
|
||||
nonlocal srv
|
||||
srv.shutdown()
|
||||
sys.exit(0)
|
||||
|
||||
def graceful_shutdown(signum, frame):
|
||||
nonlocal shutdown
|
||||
shutdown.set()
|
||||
|
||||
signal.signal(signal.SIGHUP, immediate_shutdown)
|
||||
signal.signal(signal.SIGINT, immediate_shutdown)
|
||||
signal.signal(signal.SIGQUIT, immediate_shutdown)
|
||||
signal.signal(signal.SIGTERM, graceful_shutdown)
|
||||
|
||||
random.seed()
|
||||
|
||||
logger = LOGGER.bind(worker_id=worker_id)
|
||||
|
||||
logger.debug("Loading broker...")
|
||||
broker = get_broker()
|
||||
broker.emit_after("process_boot")
|
||||
|
||||
logger.debug("Starting worker threads...")
|
||||
queues = None # all queues
|
||||
worker = Worker(broker, queues=queues, worker_threads=CONFIG.get_int("worker.threads"))
|
||||
worker.worker_id = worker_id
|
||||
worker.start()
|
||||
logger.info("Worker process is ready for action.")
|
||||
|
||||
Thread(target=srv.serve_forever).start()
|
||||
|
||||
# Notify rust process that we are ready
|
||||
os.kill(os.getppid(), signal.SIGUSR2)
|
||||
|
||||
shutdown.wait()
|
||||
|
||||
logger.info("Shutting down worker...")
|
||||
|
||||
# 5 secs if debug, 5 mins otherwise
|
||||
worker.stop(timeout=5_000 if CONFIG.get_bool("debug") else 600_000)
|
||||
|
||||
srv.shutdown()
|
||||
|
||||
broker.close()
|
||||
logger.info("Worker shut down.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 3: # noqa: PLR2004
|
||||
print("USAGE: worker_process <worker_id> <socket_path>")
|
||||
sys.exit(1)
|
||||
|
||||
worker_id = int(sys.argv[1])
|
||||
socket_path = sys.argv[2]
|
||||
|
||||
from authentik.root.setup import setup
|
||||
|
||||
setup()
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "authentik.root.settings")
|
||||
|
||||
import django
|
||||
|
||||
django.setup()
|
||||
|
||||
from django.core.management import execute_from_command_line
|
||||
|
||||
if worker_id == INITIAL_WORKER_ID:
|
||||
from lifecycle.migrate import run_migrations
|
||||
|
||||
run_migrations()
|
||||
|
||||
if (
|
||||
"AUTHENTIK_BOOTSTRAP_PASSWORD" in os.environ
|
||||
or "AUTHENTIK_BOOTSTRAP_TOKEN" in os.environ
|
||||
):
|
||||
try:
|
||||
execute_from_command_line(["", "apply_blueprint", "system/bootstrap.yaml"])
|
||||
except Exception as exc: # noqa: BLE001
|
||||
sys.stderr.write(f"Failed to apply bootstrap blueprint: {exc}")
|
||||
|
||||
main(worker_id, socket_path)
|
||||
@@ -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 ""
|
||||
|
||||
@@ -4,3 +4,4 @@ Yubi
|
||||
Yubikey
|
||||
Yubikeys
|
||||
mycorp
|
||||
mocksaml
|
||||
|
||||
@@ -17,11 +17,13 @@ if __name__ == "__main__":
|
||||
if (
|
||||
len(sys.argv) > 1
|
||||
# Explicitly only run migrate for server and worker
|
||||
and sys.argv[1] in ["dev_server", "worker"]
|
||||
and sys.argv[1] in ["dev_server"]
|
||||
# and don't run if this is the child process of a dev_server
|
||||
and os.environ.get(DJANGO_AUTORELOAD_ENV, None) is None
|
||||
):
|
||||
run_migrations()
|
||||
if len(sys.argv) > 1 and sys.argv[1] in ["worker"]:
|
||||
raise RuntimeError(f"{sys.argv[1]} command not allowed.")
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Utilities for working with the authentik API client.
|
||||
|
||||
use ak_client::apis::configuration::Configuration;
|
||||
use ak_client::models::Pagination;
|
||||
use eyre::{Result, eyre};
|
||||
use url::Url;
|
||||
|
||||
@@ -60,6 +61,42 @@ pub fn make_config() -> Result<Configuration> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Fetch all pages from a paginated API endpoint, returning all results combined.
|
||||
///
|
||||
/// - `fetch`: a function that takes a page number and returns a future resolving to a paginated
|
||||
/// response.
|
||||
/// - `get_pagination`: a function that extracts the [`Pagination`] metadata from a response.
|
||||
/// - `get_results`: a function that extracts the result items from a response.
|
||||
pub async fn fetch_all<T, R, E, F, Fut>(
|
||||
fetch: F,
|
||||
get_pagination: impl Fn(&R) -> &Pagination,
|
||||
get_results: impl Fn(R) -> Vec<T>,
|
||||
) -> std::result::Result<Vec<T>, E>
|
||||
where
|
||||
F: Fn(i32) -> Fut,
|
||||
Fut: Future<Output = std::result::Result<R, E>>,
|
||||
{
|
||||
let mut page = 1;
|
||||
let mut results = Vec::with_capacity(0);
|
||||
|
||||
loop {
|
||||
let response = fetch(page).await?;
|
||||
let next = get_pagination(&response).next;
|
||||
if page == 1 {
|
||||
let count = get_pagination(&response).count as usize;
|
||||
results.reserve(count);
|
||||
}
|
||||
results.extend(get_results(response));
|
||||
if next > 0.0 {
|
||||
page += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
@@ -263,7 +263,7 @@ async fn watch_config(arbiter: Arbiter) -> Result<()> {
|
||||
/// Start the configuration watcher.
|
||||
///
|
||||
/// [`init`] must be called before this is used.
|
||||
pub fn run(tasks: &mut Tasks) -> Result<()> {
|
||||
pub fn start(tasks: &mut Tasks) -> Result<()> {
|
||||
info!("starting config file watcher");
|
||||
let arbiter = tasks.arbiter();
|
||||
tasks
|
||||
@@ -400,7 +400,7 @@ mod tests {
|
||||
let arbiter = tasks.arbiter();
|
||||
let mut events_rx = arbiter.events_subscribe();
|
||||
|
||||
super::run(&mut tasks).expect("failed to start watcher");
|
||||
super::start(&mut tasks).expect("failed to start watcher");
|
||||
|
||||
assert_eq!(super::get().secret_key, "my_secret_key");
|
||||
assert_eq!(super::get().postgresql.password, "my_postgres_pass");
|
||||
|
||||
@@ -30,12 +30,12 @@ pub fn install() -> Result<()> {
|
||||
}
|
||||
|
||||
if config.debug {
|
||||
let console_layer = console_subscriber::ConsoleLayer::builder()
|
||||
.server_addr(config.listen.debug_tokio)
|
||||
.spawn();
|
||||
// let console_layer = console_subscriber::ConsoleLayer::builder()
|
||||
// .server_addr(config.listen.debug_tokio)
|
||||
// .spawn();
|
||||
tracing_subscriber::registry()
|
||||
.with(ErrorLayer::default())
|
||||
.with(console_layer)
|
||||
// .with(console_layer)
|
||||
.with(
|
||||
fmt::layer()
|
||||
.compact()
|
||||
@@ -180,12 +180,9 @@ pub mod sentry {
|
||||
sentry_dsn: Some(config.sentry_dsn),
|
||||
environment: config.environment,
|
||||
send_pii: config.send_pii,
|
||||
#[expect(
|
||||
clippy::cast_possible_truncation,
|
||||
reason = "This is fine, we'll never get big values here."
|
||||
)]
|
||||
#[expect(
|
||||
clippy::as_conversions,
|
||||
clippy::cast_possible_truncation,
|
||||
reason = "This is fine, we'll never get big values here."
|
||||
)]
|
||||
sample_rate: config.traces_sample_rate as f32,
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
import platform
|
||||
import sys
|
||||
from argparse import Namespace
|
||||
from multiprocessing import set_start_method
|
||||
from typing import Any
|
||||
|
||||
from django.apps.registry import apps
|
||||
from django.core.management.base import BaseCommand, CommandParser
|
||||
from django.db import connections
|
||||
from django.utils.module_loading import import_string, module_has_submodule
|
||||
from dramatiq.cli import main
|
||||
|
||||
from django_dramatiq_postgres.conf import Conf
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""Run worker"""
|
||||
|
||||
def add_arguments(self, parser: CommandParser) -> None:
|
||||
parser.add_argument(
|
||||
"--pid-file",
|
||||
action="store",
|
||||
default=None,
|
||||
dest="pid_file",
|
||||
help="PID file",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--watch",
|
||||
action="store_true",
|
||||
default=False,
|
||||
dest="watch",
|
||||
help="Watch for file changes",
|
||||
)
|
||||
|
||||
def handle(
|
||||
self,
|
||||
pid_file: str,
|
||||
watch: bool,
|
||||
verbosity: int,
|
||||
**options: Any,
|
||||
) -> None:
|
||||
worker = Conf().worker
|
||||
setup, modules = self._discover_tasks_modules()
|
||||
args = Namespace(
|
||||
broker=setup,
|
||||
modules=modules,
|
||||
path=["."],
|
||||
queues=None,
|
||||
log_file=None,
|
||||
skip_logging=True,
|
||||
use_spawn=False,
|
||||
forks=[],
|
||||
worker_shutdown_timeout=600000,
|
||||
watch=None,
|
||||
watch_use_polling=False,
|
||||
include_patterns=["**.py"],
|
||||
exclude_patterns=None,
|
||||
verbose=0,
|
||||
)
|
||||
if watch:
|
||||
args.watch = worker["watch_folder"]
|
||||
if worker["watch_use_polling"]:
|
||||
args.watch_use_polling = True
|
||||
|
||||
if processes := worker["processes"]:
|
||||
args.processes = processes
|
||||
if threads := worker["threads"]:
|
||||
args.threads = threads
|
||||
|
||||
if pid_file is not None:
|
||||
args.pid_file = pid_file
|
||||
|
||||
args.verbose = verbosity - 1
|
||||
# > On macOS [...] the fork start method should be considered unsafe
|
||||
# https://docs.python.org/3/library/multiprocessing.html#contexts-and-start-methods
|
||||
if not platform.system() == "Darwin":
|
||||
set_start_method("fork")
|
||||
connections.close_all()
|
||||
sys.exit(main(args)) # type: ignore[no-untyped-call]
|
||||
|
||||
def _discover_tasks_modules(self) -> tuple[str, list[str]]:
|
||||
# Does not support a tasks directory
|
||||
autodiscovery = Conf().autodiscovery
|
||||
modules = []
|
||||
|
||||
if autodiscovery["enabled"]:
|
||||
for app in apps.get_app_configs():
|
||||
if autodiscovery["apps_prefix"] and not app.name.startswith(
|
||||
autodiscovery["apps_prefix"]
|
||||
):
|
||||
continue
|
||||
if module_has_submodule(app.module, autodiscovery["actors_module_name"]):
|
||||
modules.append(f"{app.name}.{autodiscovery['actors_module_name']}")
|
||||
else:
|
||||
modules_callback = autodiscovery["modules_callback"]
|
||||
callback = (
|
||||
modules_callback
|
||||
if not isinstance(modules_callback, str)
|
||||
else import_string(modules_callback)
|
||||
)
|
||||
modules.extend(callback())
|
||||
return autodiscovery["setup_module"], modules
|
||||
@@ -36,7 +36,7 @@ dependencies = [
|
||||
"django >=4.2,<6.0",
|
||||
"django-pglock >=1.7,<2",
|
||||
"django-pgtrigger >=4,<5",
|
||||
"dramatiq[watch] >=1.17,<1.18",
|
||||
"dramatiq >=1.17,<1.18",
|
||||
"tenacity >=9,<10",
|
||||
"structlog >=25,<26",
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -34355,6 +34355,7 @@ components:
|
||||
- require_superuser
|
||||
- require_redirect
|
||||
- require_outpost
|
||||
- require_token
|
||||
type: string
|
||||
AuthenticatorAttachmentEnum:
|
||||
enum:
|
||||
|
||||
108
src/main.rs
Normal file
108
src/main.rs
Normal file
@@ -0,0 +1,108 @@
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
#[cfg(feature = "core")]
|
||||
use ak_common::db;
|
||||
use ak_common::{Mode, Tasks, authentik_full_version, config, tls, tracing as ak_tracing};
|
||||
use argh::FromArgs;
|
||||
use eyre::{Result, eyre};
|
||||
use tracing::{error, info, trace};
|
||||
|
||||
mod metrics;
|
||||
#[cfg(feature = "proxy")]
|
||||
mod outpost;
|
||||
#[cfg(feature = "core")]
|
||||
mod server;
|
||||
#[cfg(feature = "core")]
|
||||
mod worker;
|
||||
|
||||
#[derive(Debug, FromArgs, PartialEq)]
|
||||
/// The authentication glue you need.
|
||||
struct Cli {
|
||||
#[argh(subcommand)]
|
||||
command: Command,
|
||||
}
|
||||
|
||||
#[derive(Debug, FromArgs, PartialEq)]
|
||||
#[argh(subcommand)]
|
||||
enum Command {
|
||||
#[cfg(feature = "core")]
|
||||
Worker(worker::Cli),
|
||||
#[cfg(feature = "proxy")]
|
||||
Proxy(outpost::proxy::Cli),
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let tracing_crude = ak_tracing::install_crude();
|
||||
info!(version = authentik_full_version(), "authentik is starting");
|
||||
|
||||
let cli: Cli = argh::from_env();
|
||||
|
||||
match &cli.command {
|
||||
#[cfg(feature = "core")]
|
||||
Command::Worker(_) => Mode::set(Mode::Worker)?,
|
||||
#[cfg(feature = "proxy")]
|
||||
Command::Proxy(_) => Mode::set(Mode::Proxy)?,
|
||||
}
|
||||
|
||||
trace!("installing error formatting");
|
||||
color_eyre::install()?;
|
||||
|
||||
#[cfg(feature = "core")]
|
||||
if Mode::is_core() {
|
||||
trace!("initializing Python");
|
||||
pyo3::Python::initialize();
|
||||
trace!("Python initialized");
|
||||
}
|
||||
|
||||
config::init()?;
|
||||
tls::init()?;
|
||||
|
||||
let _sentry = ak_tracing::sentry::install()?;
|
||||
ak_tracing::install()?;
|
||||
drop(tracing_crude);
|
||||
|
||||
tokio::runtime::Builder::new_multi_thread()
|
||||
.thread_name_fn(|| {
|
||||
static ATOMIC_ID: AtomicUsize = AtomicUsize::new(0);
|
||||
let id = ATOMIC_ID.fetch_add(1, Ordering::SeqCst);
|
||||
format!("tokio-{id}")
|
||||
})
|
||||
.enable_all()
|
||||
.build()?
|
||||
.block_on(async {
|
||||
let mut tasks = Tasks::new()?;
|
||||
|
||||
config::start(&mut tasks)?;
|
||||
|
||||
let metrics = metrics::start(&mut tasks)?;
|
||||
|
||||
#[cfg(feature = "core")]
|
||||
if Mode::get() == Mode::AllInOne || Mode::get() == Mode::Worker {
|
||||
db::init(&mut tasks).await?;
|
||||
}
|
||||
|
||||
match cli.command {
|
||||
#[cfg(feature = "core")]
|
||||
Command::Worker(args) => {
|
||||
let workers = worker::start(args, &mut tasks)?;
|
||||
metrics.workers.store(Some(workers));
|
||||
}
|
||||
#[cfg(feature = "proxy")]
|
||||
Command::Proxy(args) => {
|
||||
outpost::start::<outpost::proxy::ProxyOutpost>(args, &mut tasks).await?;
|
||||
}
|
||||
}
|
||||
|
||||
let errors = tasks.run().await;
|
||||
|
||||
Mode::cleanup();
|
||||
|
||||
if errors.is_empty() {
|
||||
info!("authentik exiting");
|
||||
Ok(())
|
||||
} else {
|
||||
error!(err = ?errors, "authentik encountered errors");
|
||||
Err(eyre!("Errors encountered: {:?}", errors))
|
||||
}
|
||||
})
|
||||
}
|
||||
73
src/metrics/handlers.rs
Normal file
73
src/metrics/handlers.rs
Normal file
@@ -0,0 +1,73 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use ak_axum::error::Result;
|
||||
use ak_common::mode::Mode;
|
||||
use axum::{body::Body, extract::State, http::StatusCode, response::Response};
|
||||
use tokio::task::spawn_blocking;
|
||||
|
||||
use super::Metrics;
|
||||
|
||||
pub(super) async fn metrics_handler(State(state): State<Arc<Metrics>>) -> Result<Response> {
|
||||
let mut metrics = Vec::new();
|
||||
state.prometheus.render_to_write(&mut metrics)?;
|
||||
|
||||
#[cfg(feature = "core")]
|
||||
if Mode::is_core() {
|
||||
if Mode::get() == Mode::Worker
|
||||
&& let Some(workers) = state.workers.load_full()
|
||||
{
|
||||
workers.notify_metrics().await?;
|
||||
}
|
||||
|
||||
metrics.extend(spawn_blocking(python::get_python_metrics).await??);
|
||||
}
|
||||
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "text/plain; version=1.0.0; charset=utf-8")
|
||||
.body(Body::from(metrics))?)
|
||||
}
|
||||
|
||||
#[cfg(feature = "core")]
|
||||
mod python {
|
||||
use eyre::{Report, Result};
|
||||
use pyo3::{
|
||||
IntoPyObjectExt as _,
|
||||
ffi::c_str,
|
||||
prelude::*,
|
||||
types::{PyBytes, PyDict},
|
||||
};
|
||||
|
||||
pub(super) fn get_python_metrics() -> Result<Vec<u8>> {
|
||||
let metrics = Python::attach(|py| {
|
||||
let locals = PyDict::new(py);
|
||||
Python::run(
|
||||
py,
|
||||
c_str!(
|
||||
r#"
|
||||
from prometheus_client import (
|
||||
CollectorRegistry,
|
||||
generate_latest,
|
||||
multiprocess,
|
||||
)
|
||||
|
||||
registry = CollectorRegistry()
|
||||
multiprocess.MultiProcessCollector(registry)
|
||||
output = generate_latest(registry)
|
||||
"#
|
||||
),
|
||||
None,
|
||||
Some(&locals),
|
||||
)?;
|
||||
let metrics = locals
|
||||
.get_item("output")?
|
||||
.unwrap_or(PyBytes::new(py, &[]).into_bound_py_any(py)?)
|
||||
.cast::<PyBytes>()
|
||||
.map_or_else(|_| PyBytes::new(py, &[]), |v| v.to_owned())
|
||||
.as_bytes()
|
||||
.to_owned();
|
||||
Ok::<_, Report>(metrics)
|
||||
})?;
|
||||
Ok::<_, Report>(metrics)
|
||||
}
|
||||
}
|
||||
99
src/metrics/mod.rs
Normal file
99
src/metrics/mod.rs
Normal file
@@ -0,0 +1,99 @@
|
||||
use std::{env::temp_dir, os::unix, path::PathBuf, sync::Arc};
|
||||
|
||||
use ak_axum::{router::wrap_router, server};
|
||||
use ak_common::{
|
||||
arbiter::{Arbiter, Tasks},
|
||||
config,
|
||||
};
|
||||
use arc_swap::ArcSwapOption;
|
||||
use axum::{Router, routing::any};
|
||||
use eyre::Result;
|
||||
use metrics_exporter_prometheus::{PrometheusBuilder, PrometheusHandle};
|
||||
use tokio::{
|
||||
task::spawn_blocking,
|
||||
time::{Duration, interval},
|
||||
};
|
||||
use tracing::info;
|
||||
|
||||
#[cfg(feature = "core")]
|
||||
use crate::worker::Workers;
|
||||
|
||||
mod handlers;
|
||||
|
||||
fn socket_path() -> PathBuf {
|
||||
temp_dir().join("authentik-metrics.sock")
|
||||
}
|
||||
|
||||
pub(crate) struct Metrics {
|
||||
prometheus: PrometheusHandle,
|
||||
#[cfg(feature = "core")]
|
||||
pub(crate) workers: ArcSwapOption<Workers>,
|
||||
}
|
||||
|
||||
impl Metrics {
|
||||
fn new() -> Result<Self> {
|
||||
info!("installing Prometheus recorder");
|
||||
let prometheus = PrometheusBuilder::new()
|
||||
.with_recommended_naming(true)
|
||||
.install_recorder()?;
|
||||
Ok(Self {
|
||||
prometheus,
|
||||
#[cfg(feature = "core")]
|
||||
workers: ArcSwapOption::empty(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_upkeep(arbiter: Arbiter, state: Arc<Metrics>) -> Result<()> {
|
||||
info!("starting metrics upkeep runner");
|
||||
let mut upkeep_interval = interval(Duration::from_secs(5));
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = upkeep_interval.tick() => {
|
||||
let state_clone = Arc::clone(&state);
|
||||
spawn_blocking(move || state_clone.prometheus.run_upkeep()).await?;
|
||||
},
|
||||
() = arbiter.shutdown() => return Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_router(state: Arc<Metrics>) -> Router {
|
||||
wrap_router(
|
||||
Router::new()
|
||||
.fallback(any(handlers::metrics_handler))
|
||||
.with_state(state),
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn start(tasks: &mut Tasks) -> Result<Arc<Metrics>> {
|
||||
let arbiter = tasks.arbiter();
|
||||
let metrics = Arc::new(Metrics::new()?);
|
||||
let router = build_router(Arc::clone(&metrics));
|
||||
|
||||
tasks
|
||||
.build_task()
|
||||
.name(&format!("{}::run_upkeep", module_path!()))
|
||||
.spawn(run_upkeep(arbiter, Arc::clone(&metrics)))?;
|
||||
|
||||
for addr in config::get().listen.metrics.iter().copied() {
|
||||
server::start_plain(
|
||||
tasks,
|
||||
"metrics",
|
||||
router.clone(),
|
||||
addr,
|
||||
true, // Allow failure in case the server is running on the same machine, like in dev
|
||||
)?;
|
||||
}
|
||||
|
||||
server::start_unix(
|
||||
tasks,
|
||||
"metrics",
|
||||
router,
|
||||
unix::net::SocketAddr::from_pathname(socket_path())?,
|
||||
true, // Allow failure in case the server is running on the same machine, like in dev
|
||||
)?;
|
||||
|
||||
Ok(metrics)
|
||||
}
|
||||
312
src/outpost/event.rs
Normal file
312
src/outpost/event.rs
Normal file
@@ -0,0 +1,312 @@
|
||||
use std::{fmt::Display, sync::Arc};
|
||||
|
||||
use ak_common::{Arbiter, Tasks, VERSION, api, arbiter, authentik_build_hash};
|
||||
use axum::http::{HeaderValue, header::AUTHORIZATION};
|
||||
use eyre::{Result, eyre};
|
||||
use futures::{SinkExt as _, StreamExt as _};
|
||||
use nix::unistd::gethostname;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_repr::{Deserialize_repr, Serialize_repr};
|
||||
use time::UtcDateTime;
|
||||
use tokio::{
|
||||
signal::unix::SignalKind,
|
||||
time::{Duration, interval, sleep},
|
||||
};
|
||||
use tokio_tungstenite::tungstenite::{Message, client::IntoClientRequest as _};
|
||||
use tracing::{debug, info, instrument, trace, warn};
|
||||
use url::Url;
|
||||
|
||||
use crate::outpost::{Outpost, OutpostController};
|
||||
|
||||
#[derive(Serialize_repr, Deserialize_repr, PartialEq, Debug, Clone, Copy, Eq)]
|
||||
#[repr(u8)]
|
||||
enum EventKind {
|
||||
/// Code used to acknowledge a previous message.
|
||||
Ack = 0,
|
||||
/// Code used to send a healthcheck keepalive.
|
||||
Hello = 1,
|
||||
/// Code received to trigger a config update.
|
||||
TriggerUpdate = 2,
|
||||
/// Code received to trigger some provider specific function.
|
||||
ProviderSpecific = 3,
|
||||
/// Code received to identify the end of a session.
|
||||
SessionEnd = 4,
|
||||
}
|
||||
|
||||
impl Display for EventKind {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Ack => write!(f, "Ack"),
|
||||
Self::Hello => write!(f, "Hello"),
|
||||
Self::TriggerUpdate => write!(f, "TriggerUpdate"),
|
||||
Self::ProviderSpecific => write!(f, "ProviderSpecific"),
|
||||
Self::SessionEnd => write!(f, "SessionEnd"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Event {
|
||||
instruction: EventKind,
|
||||
args: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct EventSessionEnd {
|
||||
session_id: String,
|
||||
}
|
||||
|
||||
fn build_ws_url(mut url: Url, outpost_pk: &str, instance_uuid: &str, attempt: u32) -> Result<Url> {
|
||||
let ws_scheme = match url.scheme() {
|
||||
"https" => "wss",
|
||||
"http" => "ws",
|
||||
other => return Err(eyre!("Unsupported scheme for WebSocket URL: {other}")),
|
||||
};
|
||||
|
||||
url.set_scheme(ws_scheme)
|
||||
.map_err(|()| eyre!("Failed to set URL scheme to {ws_scheme}"))?;
|
||||
url.set_path(&format!("{}ws/outpost/{outpost_pk}/", url.path()));
|
||||
url.query_pairs_mut()
|
||||
.append_pair("instance_uuid", instance_uuid)
|
||||
.append_pair("attempt", &attempt.to_string());
|
||||
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
fn hello_args(instance_uuid: &str) -> serde_json::Value {
|
||||
let raw_hostname = gethostname().unwrap_or_default();
|
||||
let hostname = raw_hostname.to_string_lossy();
|
||||
|
||||
serde_json::json!({
|
||||
"version": VERSION,
|
||||
"buildHash": authentik_build_hash(None),
|
||||
"uuid": instance_uuid,
|
||||
// TODO: rust version and AWS-LC versions
|
||||
"hostname": hostname,
|
||||
})
|
||||
}
|
||||
|
||||
#[instrument(skip_all)]
|
||||
async fn handle_event<O: Outpost>(
|
||||
controller: Arc<OutpostController>,
|
||||
outpost: Arc<O>,
|
||||
event: Event,
|
||||
) -> Result<()> {
|
||||
match event.instruction {
|
||||
EventKind::Ack | EventKind::Hello => {}
|
||||
EventKind::TriggerUpdate => {
|
||||
info!("received update trigger, refreshing outpost");
|
||||
sleep(controller.reload_offset).await;
|
||||
controller.refresh().await?;
|
||||
debug!("outpost controller has been refreshed");
|
||||
outpost.refresh().await?;
|
||||
debug!("outpost has been refreshed");
|
||||
#[expect(
|
||||
clippy::as_conversions,
|
||||
clippy::cast_precision_loss,
|
||||
reason = "This is fine, we'll never get big values here."
|
||||
)]
|
||||
controller
|
||||
.m_last_update
|
||||
.set(UtcDateTime::now().unix_timestamp() as f64);
|
||||
}
|
||||
EventKind::SessionEnd => {
|
||||
let event: EventSessionEnd = serde_json::from_value(event.args)?;
|
||||
outpost.end_session(event).await?;
|
||||
}
|
||||
#[expect(
|
||||
clippy::unimplemented,
|
||||
reason = "this is only relevant for the RAC provider"
|
||||
)]
|
||||
EventKind::ProviderSpecific => unimplemented!(),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn watch_events_inner<O: Outpost>(
|
||||
arbiter: Arbiter,
|
||||
controller: Arc<OutpostController>,
|
||||
outpost: Arc<O>,
|
||||
attempt: u32,
|
||||
) -> Result<()> {
|
||||
let server_config = api::ServerConfig::new()?;
|
||||
let ws_url = build_ws_url(
|
||||
server_config.host,
|
||||
&controller.outpost.load().pk.to_string(),
|
||||
&controller.instance_uuid.to_string(),
|
||||
attempt,
|
||||
)?;
|
||||
|
||||
debug!(url = %ws_url, "connecting to websocket");
|
||||
let mut request = ws_url.into_client_request()?;
|
||||
let token = controller
|
||||
.api_config
|
||||
.bearer_access_token
|
||||
.as_deref()
|
||||
.unwrap_or("");
|
||||
request.headers_mut().insert(
|
||||
AUTHORIZATION,
|
||||
HeaderValue::from_str(&format!("Bearer {token}"))?,
|
||||
);
|
||||
|
||||
let (ws_stream, _response) = tokio_tungstenite::connect_async(request).await?;
|
||||
let (mut ws_write, mut ws_read) = ws_stream.split();
|
||||
|
||||
info!(
|
||||
outpost = %controller.outpost.load().pk,
|
||||
"connected to websocket"
|
||||
);
|
||||
controller.m_connection.set(1_u8);
|
||||
|
||||
let get_refresh_interval = || {
|
||||
let mut interval = controller.outpost.load().refresh_interval_s;
|
||||
// Ensure timer interval is not negative or 0.
|
||||
// If it is, we default to 5 minutes.
|
||||
if interval <= 0_i32 {
|
||||
interval = 60_i32 * 5_i32;
|
||||
}
|
||||
// Clamp interval to be at least 30 seconds.
|
||||
if interval < 30_i32 {
|
||||
interval = 30_i32;
|
||||
}
|
||||
// infallible because we bound it to be positive above
|
||||
Duration::from_secs(interval.try_into().expect("infallible"))
|
||||
};
|
||||
let mut refresh_interval = interval(get_refresh_interval());
|
||||
let mut heartbeat_interval = interval(Duration::from_secs(10));
|
||||
|
||||
let mut events_rx = arbiter.events_subscribe();
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = refresh_interval.tick() => {
|
||||
info!("refreshing outpost on interval");
|
||||
if let Err(err) = handle_event(
|
||||
Arc::clone(&controller),
|
||||
Arc::clone(&outpost),
|
||||
Event {
|
||||
instruction: EventKind::TriggerUpdate,
|
||||
args: serde_json::Value::Null
|
||||
}
|
||||
).await {
|
||||
warn!(?err, "failed to refresh");
|
||||
}
|
||||
refresh_interval = interval(get_refresh_interval());
|
||||
// Since we re-create the interval, we need to make it tick instantly to avoid
|
||||
// ending up in a never-ending tick-loop.
|
||||
refresh_interval.tick().await;
|
||||
},
|
||||
_ = heartbeat_interval.tick() => {
|
||||
let ping = Event {
|
||||
instruction: EventKind::Hello,
|
||||
args: hello_args(&controller.instance_uuid.to_string()),
|
||||
};
|
||||
ws_write.send(Message::text(serde_json::to_string(&ping)?)).await?;
|
||||
trace!("sent websocket hello (heartbeat)");
|
||||
},
|
||||
Ok(arbiter::Event::Signal(signal)) = events_rx.recv() => {
|
||||
if signal == SignalKind::user_defined1() {
|
||||
info!("refreshing outpost on signal");
|
||||
if let Err(err) = handle_event(
|
||||
Arc::clone(&controller),
|
||||
Arc::clone(&outpost),
|
||||
Event {
|
||||
instruction: EventKind::TriggerUpdate,
|
||||
args: serde_json::Value::Null
|
||||
}
|
||||
).await {
|
||||
warn!(?err, "failed to refresh");
|
||||
}
|
||||
}
|
||||
},
|
||||
msg = ws_read.next() => {
|
||||
let Some(msg) = msg else {
|
||||
break;
|
||||
};
|
||||
let msg = msg?;
|
||||
match msg {
|
||||
Message::Text(text) => {
|
||||
let Ok(event): Result<Event, _> = serde_json::from_str(&text) else {
|
||||
warn!(data = text.as_str(), "failed to parse event");
|
||||
continue;
|
||||
};
|
||||
trace!(event = %event.instruction, "received websocket event");
|
||||
if let Err(err) = handle_event(
|
||||
Arc::clone(&controller),
|
||||
Arc::clone(&outpost),
|
||||
event,
|
||||
).await {
|
||||
warn!(?err, "failed to handle event");
|
||||
}
|
||||
},
|
||||
Message::Ping(data) => {
|
||||
ws_write.send(Message::Pong(data)).await?;
|
||||
},
|
||||
Message::Close(_) => {
|
||||
break;
|
||||
},
|
||||
_ => {},
|
||||
}
|
||||
},
|
||||
() = arbiter.shutdown() => break,
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn watch_events<O: Outpost>(
|
||||
arbiter: Arbiter,
|
||||
controller: Arc<OutpostController>,
|
||||
outpost: Arc<O>,
|
||||
) -> Result<()> {
|
||||
const MAX_BACKOFF: Duration = Duration::from_mins(5);
|
||||
let mut backoff = Duration::from_secs(1);
|
||||
let mut attempt: u32 = 0;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
() = arbiter.shutdown() => break,
|
||||
res = watch_events_inner(
|
||||
arbiter.clone(),
|
||||
Arc::clone(&controller),
|
||||
Arc::clone(&outpost),
|
||||
attempt
|
||||
) => {
|
||||
controller.m_connection.set(0_u8);
|
||||
match res {
|
||||
Ok(()) => debug!("websocket disconnected cleanly"),
|
||||
Err(err) => warn!(?err, attempt, "websocket error"),
|
||||
}
|
||||
|
||||
info!(attempt, delay = backoff.as_secs(), "reconnecting websocket in {}s...", backoff.as_secs());
|
||||
|
||||
tokio::select! {
|
||||
() = arbiter.shutdown() => break,
|
||||
() = sleep(backoff) => {}
|
||||
}
|
||||
|
||||
backoff = (backoff * 2).min(MAX_BACKOFF);
|
||||
attempt += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!("stopping event watcher");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn start<O: Outpost + 'static>(
|
||||
tasks: &mut Tasks,
|
||||
controller: Arc<OutpostController>,
|
||||
outpost: Arc<O>,
|
||||
) -> Result<()> {
|
||||
let arbiter = tasks.arbiter();
|
||||
tasks
|
||||
.build_task()
|
||||
.name(&format!("{}::watch_events", module_path!()))
|
||||
.spawn(watch_events(arbiter, controller, outpost))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
123
src/outpost/mod.rs
Normal file
123
src/outpost/mod.rs
Normal file
@@ -0,0 +1,123 @@
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
use ak_client::{
|
||||
apis::{configuration::Configuration, outposts_api::outposts_instances_list},
|
||||
models::Outpost as OutpostModel,
|
||||
};
|
||||
use ak_common::{Tasks, VERSION, api, authentik_build_hash};
|
||||
use arc_swap::ArcSwap;
|
||||
use eyre::{Result, eyre};
|
||||
use tracing::{debug, info, instrument};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub(crate) mod event;
|
||||
#[cfg(feature = "proxy")]
|
||||
pub(crate) mod proxy;
|
||||
|
||||
pub(crate) trait Outpost: Send + Sync + Sized {
|
||||
const OUTPOST_TYPE: &'static str;
|
||||
type Cli: Send + Sync;
|
||||
|
||||
async fn new(controller: Arc<OutpostController>) -> Result<Self>;
|
||||
|
||||
fn start(&self, tasks: &mut Tasks) -> Result<()>;
|
||||
fn refresh(&self) -> impl Future<Output = Result<()>> + Send;
|
||||
|
||||
fn end_session(&self, event: event::EventSessionEnd)
|
||||
-> impl Future<Output = Result<()>> + Send;
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct OutpostController {
|
||||
api_config: Configuration,
|
||||
outpost: ArcSwap<OutpostModel>,
|
||||
instance_uuid: Uuid,
|
||||
reload_offset: Duration,
|
||||
m_info: metrics::Gauge,
|
||||
m_last_update: metrics::Gauge,
|
||||
m_connection: metrics::Gauge,
|
||||
}
|
||||
|
||||
impl OutpostController {
|
||||
#[instrument(skip_all)]
|
||||
async fn get_outpost(api_config: &Configuration) -> Result<OutpostModel> {
|
||||
let outposts = outposts_instances_list(
|
||||
api_config, None, None, None, None, None, None, None, None, None, None, None, None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let Some(outpost) = outposts.results.into_iter().next() else {
|
||||
return Err(eyre!(
|
||||
"No outposts found with given token, ensure the given token corresponds to an \
|
||||
authentik Outpost"
|
||||
));
|
||||
};
|
||||
debug!(name = outpost.name, "fetched outpost configuration");
|
||||
|
||||
Ok(outpost)
|
||||
}
|
||||
|
||||
#[instrument(skip_all)]
|
||||
async fn new<O: Outpost>() -> Result<Self> {
|
||||
let api_config = api::make_config()?;
|
||||
let outpost = Self::get_outpost(&api_config).await?;
|
||||
let instance_uuid = Uuid::new_v4();
|
||||
|
||||
let m_labels = [
|
||||
("outpost_name", outpost.name.clone()),
|
||||
("outpost_type", O::OUTPOST_TYPE.to_owned()),
|
||||
("uuid", instance_uuid.to_string()),
|
||||
("version", VERSION.to_owned()),
|
||||
("build", authentik_build_hash(None)),
|
||||
];
|
||||
metrics::describe_gauge!("authentik_outpost_info", "Outpost info");
|
||||
let m_info = metrics::gauge!("authentik_outpost_info", &m_labels);
|
||||
metrics::describe_gauge!("authentik_outpost_last_update", "Time of last update");
|
||||
let m_last_update = metrics::gauge!("authentik_outpost_last_update", &m_labels);
|
||||
metrics::describe_gauge!("authentik_outpost_connection", "Connection status");
|
||||
let m_connection = metrics::gauge!("authentik_outpost_connection", &m_labels);
|
||||
|
||||
let reload_offset = Duration::from_secs(rand::random_range(0..10));
|
||||
let controller = Self {
|
||||
api_config,
|
||||
outpost: ArcSwap::from_pointee(outpost),
|
||||
instance_uuid,
|
||||
reload_offset,
|
||||
m_info,
|
||||
m_last_update,
|
||||
m_connection,
|
||||
};
|
||||
|
||||
info!(embedded = controller.is_embedded(), "outpost mode");
|
||||
debug!(?reload_offset, "HA Reload offset");
|
||||
|
||||
Ok(controller)
|
||||
}
|
||||
|
||||
fn is_embedded(&self) -> bool {
|
||||
self.outpost
|
||||
.load()
|
||||
.managed
|
||||
.as_ref()
|
||||
.and_then(|m| m.as_deref())
|
||||
.is_some_and(|m| m == "goauthentik.io/outposts/embedded")
|
||||
}
|
||||
|
||||
async fn refresh(&self) -> Result<()> {
|
||||
let outpost = Self::get_outpost(&self.api_config).await?;
|
||||
self.outpost.swap(Arc::new(outpost));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(skip_all)]
|
||||
pub(crate) async fn start<O: Outpost + 'static>(_cli: O::Cli, tasks: &mut Tasks) -> Result<()> {
|
||||
let controller = Arc::new(OutpostController::new::<O>().await?);
|
||||
let outpost = Arc::new(O::new(Arc::clone(&controller)).await?);
|
||||
|
||||
event::start(tasks, Arc::clone(&controller), Arc::clone(&outpost))?;
|
||||
outpost.start(tasks)?;
|
||||
controller.m_info.set(1_u8);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
38
src/outpost/proxy/application.rs
Normal file
38
src/outpost/proxy/application.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
use url::Url;
|
||||
|
||||
use ak_client::models::ProxyOutpostConfig;
|
||||
use eyre::{Result, eyre};
|
||||
use tracing::instrument;
|
||||
|
||||
use crate::outpost::proxy::ProxyOutpost;
|
||||
|
||||
const REDIRECT_PARAM: &str = "rd";
|
||||
const CALLBACK_SIGNATURE: &str = "X-authentik-auth-callback";
|
||||
const LOGOUT_SIGNATURE: &str = "X-authentik-logout";
|
||||
|
||||
pub(super) struct Application {
|
||||
pub(super) host: String,
|
||||
}
|
||||
|
||||
impl Application {
|
||||
#[instrument(skip_all)]
|
||||
pub(super) fn new(
|
||||
_existing_apps: &ProxyOutpost,
|
||||
provider: &ProxyOutpostConfig,
|
||||
) -> Result<Self> {
|
||||
let external_url = Url::parse(&provider.external_host)?;
|
||||
let external_host = external_url
|
||||
.host_str()
|
||||
.ok_or_else(|| eyre!("no host in external host"))?;
|
||||
|
||||
let _redirect_url = {
|
||||
let mut redirect_url = external_url.join("outpost.goauthentik.io/callback")?;
|
||||
redirect_url.set_query(Some(&format!("{CALLBACK_SIGNATURE}=true")));
|
||||
redirect_url
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
host: external_host.to_owned(),
|
||||
})
|
||||
}
|
||||
}
|
||||
151
src/outpost/proxy/mod.rs
Normal file
151
src/outpost/proxy/mod.rs
Normal file
@@ -0,0 +1,151 @@
|
||||
use ak_axum::extract::host::Host;
|
||||
use axum::extract::State;
|
||||
use axum::http::Method;
|
||||
use axum::routing::any;
|
||||
use metrics::{Histogram, histogram};
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
use tokio::time::Instant;
|
||||
|
||||
use ak_axum::router::wrap_router;
|
||||
use ak_client::apis::outposts_api::outposts_proxy_list;
|
||||
use ak_common::{Tasks, api::fetch_all};
|
||||
use arc_swap::ArcSwap;
|
||||
use argh::FromArgs;
|
||||
use axum::Router;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::IntoResponse;
|
||||
use eyre::Result;
|
||||
use tracing::{debug, error, info, instrument, warn};
|
||||
|
||||
use crate::outpost::proxy::application::Application;
|
||||
use crate::outpost::{Outpost, OutpostController};
|
||||
|
||||
mod application;
|
||||
|
||||
#[derive(Debug, Default, FromArgs, PartialEq, Eq)]
|
||||
/// Run the authentik proxy outpost.
|
||||
#[argh(subcommand, name = "proxy")]
|
||||
#[expect(
|
||||
clippy::empty_structs_with_brackets,
|
||||
reason = "argh doesn't support unit structs"
|
||||
)]
|
||||
pub(crate) struct Cli {}
|
||||
|
||||
pub(crate) struct ProxyOutpost {
|
||||
controller: Arc<OutpostController>,
|
||||
applications: ArcSwap<HashMap<String, Application>>,
|
||||
}
|
||||
|
||||
impl Outpost for ProxyOutpost {
|
||||
type Cli = Cli;
|
||||
|
||||
const OUTPOST_TYPE: &'static str = "proxy";
|
||||
|
||||
#[instrument(skip_all)]
|
||||
async fn new(controller: Arc<OutpostController>) -> Result<Self> {
|
||||
Ok(Self {
|
||||
controller,
|
||||
applications: ArcSwap::from_pointee(HashMap::with_capacity(0)),
|
||||
})
|
||||
}
|
||||
|
||||
fn start(&self, _tasks: &mut Tasks) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[instrument(skip_all)]
|
||||
async fn refresh(&self) -> Result<()> {
|
||||
debug!(
|
||||
outpost_pk = %self.controller.outpost.load().pk,
|
||||
"requesting providers for outpost"
|
||||
);
|
||||
|
||||
let providers = fetch_all(
|
||||
|page| {
|
||||
outposts_proxy_list(
|
||||
&self.controller.api_config,
|
||||
None,
|
||||
None,
|
||||
Some(page),
|
||||
Some(100_i32),
|
||||
None,
|
||||
)
|
||||
},
|
||||
|r| &r.pagination,
|
||||
|r| r.results,
|
||||
)
|
||||
.await
|
||||
.inspect_err(|err| error!(?err, "failed to fetch providers"))?;
|
||||
debug!(count = providers.len(), "fetched providers");
|
||||
|
||||
if providers.is_empty() && !self.controller.is_embedded() {
|
||||
warn!(
|
||||
"no providers assigned to this outpost, check outpost configuration in authentik"
|
||||
);
|
||||
}
|
||||
|
||||
for (i, provider) in providers.iter().enumerate() {
|
||||
debug!(
|
||||
index = i,
|
||||
name = provider.name,
|
||||
external_host = provider.external_host,
|
||||
assigned_to_app = provider.assigned_application_name,
|
||||
"provider details"
|
||||
);
|
||||
}
|
||||
|
||||
let mut apps = HashMap::with_capacity(providers.len());
|
||||
|
||||
for provider in providers {
|
||||
let Ok(application) = Application::new(self, &provider)
|
||||
.inspect_err(|err| warn!(?err, "failed to setup application, skipping provider"))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
info!(
|
||||
name = provider.name,
|
||||
host = application.host,
|
||||
"loaded application"
|
||||
);
|
||||
|
||||
apps.insert(application.host.clone(), application);
|
||||
}
|
||||
|
||||
self.applications.store(Arc::new(apps));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn end_session(&self, _event: super::event::EventSessionEnd) -> Result<()> {
|
||||
// todo!()
|
||||
warn!(?_event, "removing session");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_ping(
|
||||
method: Method,
|
||||
Host(host): Host,
|
||||
State(outpost): State<Arc<ProxyOutpost>>,
|
||||
) -> impl IntoResponse {
|
||||
let start = Instant::now();
|
||||
histogram!(
|
||||
"authentik_outpost_proxy_request_duration_seconds",
|
||||
"outpost_name" => outpost.controller.outpost.load().name.clone(),
|
||||
"method" => method.to_string(),
|
||||
"host" => host,
|
||||
"type" => "ping",
|
||||
)
|
||||
.record(start.elapsed().as_secs_f64());
|
||||
StatusCode::NO_CONTENT
|
||||
}
|
||||
|
||||
fn build_router(outpost: Arc<ProxyOutpost>) -> Router {
|
||||
// TODO: static files
|
||||
wrap_router(
|
||||
Router::new()
|
||||
.route("outpost.goauthentik.io/ping", any(handle_ping))
|
||||
.with_state(outpost),
|
||||
true,
|
||||
)
|
||||
}
|
||||
5
src/server/mod.rs
Normal file
5
src/server/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
use std::{env::temp_dir, path::PathBuf};
|
||||
|
||||
pub(crate) fn socket_path() -> PathBuf {
|
||||
temp_dir().join("authentik.sock")
|
||||
}
|
||||
42
src/worker/healthcheck.rs
Normal file
42
src/worker/healthcheck.rs
Normal file
@@ -0,0 +1,42 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use ak_axum::{error::Result, router::wrap_router};
|
||||
use ak_common::db;
|
||||
use axum::{Router, extract::State, http::StatusCode, response::IntoResponse, routing::any};
|
||||
|
||||
use super::Workers;
|
||||
|
||||
async fn health_ready(State(workers): State<Arc<Workers>>) -> Result<StatusCode> {
|
||||
if !workers.are_alive().await || sqlx::query("SELECT 1").execute(db::get()).await.is_err() {
|
||||
Ok(StatusCode::SERVICE_UNAVAILABLE)
|
||||
} else if workers.health_ready().await? {
|
||||
Ok(StatusCode::OK)
|
||||
} else {
|
||||
Ok(StatusCode::SERVICE_UNAVAILABLE)
|
||||
}
|
||||
}
|
||||
|
||||
async fn health_live(State(workers): State<Arc<Workers>>) -> Result<StatusCode> {
|
||||
if !workers.are_alive().await || sqlx::query("SELECT 1").execute(db::get()).await.is_err() {
|
||||
Ok(StatusCode::SERVICE_UNAVAILABLE)
|
||||
} else if workers.health_live().await? {
|
||||
Ok(StatusCode::OK)
|
||||
} else {
|
||||
Ok(StatusCode::SERVICE_UNAVAILABLE)
|
||||
}
|
||||
}
|
||||
|
||||
async fn fallback() -> impl IntoResponse {
|
||||
StatusCode::OK
|
||||
}
|
||||
|
||||
pub(super) fn build_router(workers: Arc<Workers>) -> Router {
|
||||
wrap_router(
|
||||
Router::new()
|
||||
.route("/-/heath/ready/", any(health_ready))
|
||||
.route("/-/heath/live/", any(health_live))
|
||||
.fallback(fallback)
|
||||
.with_state(workers),
|
||||
true,
|
||||
)
|
||||
}
|
||||
338
src/worker/mod.rs
Normal file
338
src/worker/mod.rs
Normal file
@@ -0,0 +1,338 @@
|
||||
use std::{
|
||||
env::temp_dir,
|
||||
os::unix,
|
||||
path::PathBuf,
|
||||
process::Stdio,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
};
|
||||
|
||||
use ak_common::{
|
||||
Event,
|
||||
arbiter::{Arbiter, Tasks},
|
||||
config,
|
||||
mode::Mode,
|
||||
};
|
||||
use argh::FromArgs;
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request, header::HOST},
|
||||
};
|
||||
use eyre::{Result, eyre};
|
||||
use hyper_unix_socket::UnixSocketConnector;
|
||||
use hyper_util::{client::legacy::Client, rt::TokioExecutor};
|
||||
use nix::{
|
||||
sys::signal::{Signal, kill},
|
||||
unistd::Pid,
|
||||
};
|
||||
use tokio::{
|
||||
net::UnixStream,
|
||||
process::{Child, Command},
|
||||
signal::unix::SignalKind,
|
||||
sync::Mutex,
|
||||
time::{Duration, interval},
|
||||
};
|
||||
use tracing::{info, trace, warn};
|
||||
|
||||
use crate::server::socket_path;
|
||||
|
||||
mod healthcheck;
|
||||
mod worker_status;
|
||||
|
||||
#[derive(Debug, Default, FromArgs, PartialEq, Eq)]
|
||||
/// Run the authentik worker.
|
||||
#[argh(subcommand, name = "worker")]
|
||||
#[expect(
|
||||
clippy::empty_structs_with_brackets,
|
||||
reason = "argh doesn't support unit structs"
|
||||
)]
|
||||
pub(crate) struct Cli {}
|
||||
|
||||
const INITIAL_WORKER_ID: usize = 1000;
|
||||
static INITIAL_WORKER_READY: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
pub(crate) struct Worker {
|
||||
worker: Child,
|
||||
client: Client<UnixSocketConnector<PathBuf>, Body>,
|
||||
socket_path: PathBuf,
|
||||
}
|
||||
|
||||
impl Worker {
|
||||
fn new(worker_id: usize, socket_path: PathBuf) -> Result<Self> {
|
||||
info!(worker_id, "starting worker");
|
||||
|
||||
let mut cmd = Command::new("python");
|
||||
cmd.arg("-m");
|
||||
cmd.arg("lifecycle.worker_process");
|
||||
cmd.arg(worker_id.to_string());
|
||||
cmd.arg(&socket_path);
|
||||
|
||||
let client = Client::builder(TokioExecutor::new())
|
||||
.pool_idle_timeout(Duration::from_mins(1))
|
||||
.set_host(false)
|
||||
.build(UnixSocketConnector::new(socket_path.clone()));
|
||||
|
||||
Ok(Self {
|
||||
worker: cmd
|
||||
.kill_on_drop(true)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::inherit())
|
||||
.stderr(Stdio::inherit())
|
||||
.spawn()?,
|
||||
client,
|
||||
socket_path,
|
||||
})
|
||||
}
|
||||
|
||||
async fn shutdown(&mut self, signal: Signal) -> Result<()> {
|
||||
trace!(
|
||||
signal = signal.as_str(),
|
||||
"sending shutdown signal to worker"
|
||||
);
|
||||
if let Some(id) = self.worker.id() {
|
||||
kill(Pid::from_raw(id.cast_signed()), signal)?;
|
||||
}
|
||||
self.worker.wait().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn graceful_shutdown(&mut self) -> Result<()> {
|
||||
info!("gracefully shutting down worker");
|
||||
self.shutdown(Signal::SIGTERM).await
|
||||
}
|
||||
|
||||
async fn fast_shutdown(&mut self) -> Result<()> {
|
||||
info!("immediately shutting down worker");
|
||||
self.shutdown(Signal::SIGINT).await
|
||||
}
|
||||
|
||||
fn is_alive(&mut self) -> bool {
|
||||
let try_wait = self.worker.try_wait();
|
||||
match try_wait {
|
||||
Ok(Some(code)) => {
|
||||
warn!(?code, "worker has exited");
|
||||
false
|
||||
}
|
||||
Ok(None) => true,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
?err,
|
||||
"failed to check the status of worker process, ignoring"
|
||||
);
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn is_socket_ready(&self) -> bool {
|
||||
let result = UnixStream::connect(&self.socket_path).await;
|
||||
trace!(?result, "checking if worker socket is ready");
|
||||
result.is_ok()
|
||||
}
|
||||
|
||||
async fn health_live(&self) -> Result<bool> {
|
||||
let req = Request::builder()
|
||||
.method("GET")
|
||||
.uri("http://localhost:8000/-/health/live/")
|
||||
.header(HOST, "localhost")
|
||||
.body(Body::from(""))?;
|
||||
Ok(self.client.request(req).await?.status().is_success())
|
||||
}
|
||||
|
||||
async fn health_ready(&self) -> Result<bool> {
|
||||
let req = Request::builder()
|
||||
.method("GET")
|
||||
.uri("http://localhost:8000/-/health/ready/")
|
||||
.header(HOST, "localhost")
|
||||
.body(Body::from(""))?;
|
||||
Ok(self.client.request(req).await?.status().is_success())
|
||||
}
|
||||
|
||||
async fn notify_metrics(&self) -> Result<()> {
|
||||
let req = Request::builder()
|
||||
.method("GET")
|
||||
.uri("http://localhost:8000/-/metrics/")
|
||||
.header(HOST, "localhost")
|
||||
.body(Body::from(""))?;
|
||||
self.client.request(req).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Worker {
|
||||
fn drop(&mut self) {
|
||||
if let Err(err) = std::fs::remove_file(&self.socket_path) {
|
||||
trace!(?err, "failed to remove socket, ignoring");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct Workers(Mutex<Vec<Worker>>);
|
||||
|
||||
impl Workers {
|
||||
fn new() -> Result<Self> {
|
||||
let mut workers = Vec::with_capacity(config::get().worker.processes.get());
|
||||
workers.push(Worker::new(
|
||||
INITIAL_WORKER_ID,
|
||||
temp_dir().join(format!("authentik-worker-{INITIAL_WORKER_ID}.sock")),
|
||||
)?);
|
||||
|
||||
Ok(Self(Mutex::new(workers)))
|
||||
}
|
||||
|
||||
async fn start_other_workers(&self) -> Result<()> {
|
||||
let mut workers = self.0.lock().await;
|
||||
while workers.len() != config::get().worker.processes.get() {
|
||||
let worker_id = INITIAL_WORKER_ID + workers.len();
|
||||
workers.push(Worker::new(
|
||||
worker_id,
|
||||
temp_dir().join(format!("authentik-worker-{worker_id}.sock")),
|
||||
)?);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn graceful_shutdown(&self) -> Result<()> {
|
||||
let mut results = Vec::with_capacity(self.0.lock().await.capacity());
|
||||
for worker in self.0.lock().await.iter_mut() {
|
||||
results.push(worker.graceful_shutdown().await);
|
||||
}
|
||||
|
||||
results.into_iter().find(Result::is_err).unwrap_or(Ok(()))
|
||||
}
|
||||
|
||||
async fn fast_shutdown(&self) -> Result<()> {
|
||||
let mut results = Vec::with_capacity(self.0.lock().await.capacity());
|
||||
for worker in self.0.lock().await.iter_mut() {
|
||||
results.push(worker.fast_shutdown().await);
|
||||
}
|
||||
|
||||
results.into_iter().find(Result::is_err).unwrap_or(Ok(()))
|
||||
}
|
||||
|
||||
async fn are_alive(&self) -> bool {
|
||||
for worker in self.0.lock().await.iter_mut() {
|
||||
if !worker.is_alive() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
async fn is_socket_ready(&self) -> bool {
|
||||
if let Some(initial_worker) = self.0.lock().await.iter_mut().next() {
|
||||
return initial_worker.is_socket_ready().await;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
async fn health_live(&self) -> Result<bool> {
|
||||
for worker in self.0.lock().await.iter() {
|
||||
if !worker.health_live().await? {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn health_ready(&self) -> Result<bool> {
|
||||
for worker in self.0.lock().await.iter() {
|
||||
if !worker.health_ready().await? {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub(crate) async fn notify_metrics(&self) -> Result<()> {
|
||||
if let Some(worker) = self.0.lock().await.iter().next() {
|
||||
worker.notify_metrics().await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn watch_workers(arbiter: Arbiter, workers: Arc<Workers>) -> Result<()> {
|
||||
info!("starting worker watcher");
|
||||
let mut events_rx = arbiter.events_subscribe();
|
||||
let mut check_interval = interval(Duration::from_secs(5));
|
||||
let mut start_interval = interval(Duration::from_secs(1));
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
Ok(Event::Signal(signal)) = events_rx.recv() => {
|
||||
if signal == SignalKind::user_defined2() && !INITIAL_WORKER_READY.load(Ordering::Relaxed) {
|
||||
info!("worker notified us ready, marked ready for operation");
|
||||
INITIAL_WORKER_READY.store(true, Ordering::Relaxed);
|
||||
workers.start_other_workers().await?;
|
||||
}
|
||||
},
|
||||
_ = start_interval.tick(), if !INITIAL_WORKER_READY.load(Ordering::Relaxed) => {
|
||||
// On some platforms the SIGUSR1 can be missed.
|
||||
// Fall back to probing the worker unix socket and mark ready once it accepts connections.
|
||||
if workers.is_socket_ready().await {
|
||||
info!("worker socket is accepting connections, marked ready for operation");
|
||||
INITIAL_WORKER_READY.store(true, Ordering::Relaxed);
|
||||
workers.start_other_workers().await?;
|
||||
}
|
||||
},
|
||||
_ = check_interval.tick() => {
|
||||
if !workers.are_alive().await {
|
||||
return Err(eyre!("one or more workers have exited unexpectedly"));
|
||||
}
|
||||
},
|
||||
() = arbiter.fast_shutdown() => {
|
||||
workers.fast_shutdown().await?;
|
||||
return Ok(());
|
||||
},
|
||||
() = arbiter.graceful_shutdown() => {
|
||||
workers.graceful_shutdown().await?;
|
||||
return Ok(());
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn start(_cli: Cli, tasks: &mut Tasks) -> Result<Arc<Workers>> {
|
||||
let arbiter = tasks.arbiter();
|
||||
|
||||
let workers = Arc::new(Workers::new()?);
|
||||
|
||||
tasks
|
||||
.build_task()
|
||||
.name(&format!("{}::watch_workers", module_path!()))
|
||||
.spawn(watch_workers(arbiter.clone(), Arc::clone(&workers)))?;
|
||||
|
||||
tasks
|
||||
.build_task()
|
||||
.name(&format!("{}::worker_status::run", module_path!()))
|
||||
.spawn(worker_status::run(arbiter))?;
|
||||
|
||||
// Only run HTTP server in worker mode, in allinone mode, they're handled by the server.
|
||||
if Mode::get() == Mode::Worker {
|
||||
let router = healthcheck::build_router(Arc::clone(&workers));
|
||||
|
||||
for addr in config::get().listen.http.iter().copied() {
|
||||
ak_axum::server::start_plain(
|
||||
tasks,
|
||||
"worker",
|
||||
router.clone(),
|
||||
addr,
|
||||
true, /* Allow failure in case the server is running on the same machine, like
|
||||
* in dev. */
|
||||
)?;
|
||||
}
|
||||
|
||||
ak_axum::server::start_unix(
|
||||
tasks,
|
||||
"worker",
|
||||
router,
|
||||
unix::net::SocketAddr::from_pathname(socket_path())?,
|
||||
true, // Allow failure in case the server is running on the same machine, like in dev.
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(workers)
|
||||
}
|
||||
48
src/worker/worker_status.rs
Normal file
48
src/worker/worker_status.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
use ak_common::{arbiter::Arbiter, authentik_full_version, db};
|
||||
use eyre::Result;
|
||||
use nix::unistd::gethostname;
|
||||
use tokio::time::{Duration, interval, sleep};
|
||||
use tracing::warn;
|
||||
use uuid::Uuid;
|
||||
|
||||
async fn keep(arbiter: Arbiter, id: Uuid, hostname: &str, version: &str) -> Result<()> {
|
||||
let query = "
|
||||
INSERT INTO authentik_tasks_workerstatus (id, hostname, version, last_seen)
|
||||
VALUES ($1, $2, $3, NOW())
|
||||
ON CONFLICT (id) DO UPDATE SET last_seen = NOW()
|
||||
";
|
||||
let mut keep_interval = interval(Duration::from_secs(30));
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = keep_interval.tick() => {
|
||||
sqlx::query(query)
|
||||
.bind(id)
|
||||
.bind(hostname)
|
||||
.bind(version)
|
||||
.execute(db::get())
|
||||
.await?;
|
||||
},
|
||||
() = arbiter.shutdown() => return Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn run(arbiter: Arbiter) -> Result<()> {
|
||||
let id = Uuid::new_v4();
|
||||
let raw_hostname = gethostname()?;
|
||||
let hostname = raw_hostname.to_string_lossy();
|
||||
let version = authentik_full_version();
|
||||
|
||||
loop {
|
||||
if let Err(err) = keep(arbiter.clone(), id, hostname.as_ref(), &version).await {
|
||||
warn!(?err, "failed to update worker status in database");
|
||||
}
|
||||
// `keep` returned. It's either an error in which case we wait 10s before
|
||||
// retrying.
|
||||
// Or we actually need to exit, which will happen here.
|
||||
tokio::select! {
|
||||
() = sleep(Duration::from_secs(10)) => {},
|
||||
() = arbiter.shutdown() => return Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
134
uv.lock
generated
134
uv.lock
generated
@@ -316,7 +316,7 @@ dev = [
|
||||
requires-dist = [
|
||||
{ name = "ak-guardian", editable = "packages/ak-guardian" },
|
||||
{ name = "argon2-cffi", specifier = "==25.1.0" },
|
||||
{ name = "cachetools", specifier = "==7.0.5" },
|
||||
{ name = "cachetools", specifier = "==7.0.6" },
|
||||
{ name = "channels", specifier = "==4.3.2" },
|
||||
{ name = "cryptography", specifier = "==46.0.7" },
|
||||
{ name = "dacite", specifier = "==1.9.2" },
|
||||
@@ -358,7 +358,7 @@ requires-dist = [
|
||||
{ name = "packaging", specifier = "==26.1" },
|
||||
{ name = "paramiko", specifier = "==4.0.0" },
|
||||
{ name = "psycopg", extras = ["c", "pool"], specifier = "==3.3.3" },
|
||||
{ name = "pydantic", specifier = "==2.13.2" },
|
||||
{ name = "pydantic", specifier = "==2.13.3" },
|
||||
{ name = "pydantic-scim", specifier = "==0.0.8" },
|
||||
{ name = "pyjwt", specifier = "==2.11.0" },
|
||||
{ name = "pyrad", specifier = "==2.5.4" },
|
||||
@@ -688,11 +688,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "cachetools"
|
||||
version = "7.0.5"
|
||||
version = "7.0.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/af/dd/57fe3fdb6e65b25a5987fd2cdc7e22db0aef508b91634d2e57d22928d41b/cachetools-7.0.5.tar.gz", hash = "sha256:0cd042c24377200c1dcd225f8b7b12b0ca53cc2c961b43757e774ebe190fd990", size = 37367, upload-time = "2026-03-09T20:51:29.451Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/76/7b/1755ed2c6bfabd1d98b37ae73152f8dcf94aa40fee119d163c19ed484704/cachetools-7.0.6.tar.gz", hash = "sha256:e5d524d36d65703a87243a26ff08ad84f73352adbeafb1cde81e207b456aaf24", size = 37526, upload-time = "2026-04-20T19:02:23.289Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/06/f3/39cf3367b8107baa44f861dc802cbf16263c945b62d8265d36034fc07bea/cachetools-7.0.5-py3-none-any.whl", hash = "sha256:46bc8ebefbe485407621d0a4264b23c080cedd913921bad7ac3ed2f26c183114", size = 13918, upload-time = "2026-03-09T20:51:27.33Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/c4/cf76242a5da1410917107ff14551764aa405a5fd10cd10cf9a5ca8fa77f4/cachetools-7.0.6-py3-none-any.whl", hash = "sha256:4e94956cfdd3086f12042cdd29318f5ced3893014f7d0d059bf3ead3f85b7f8b", size = 13976, upload-time = "2026-04-20T19:02:21.187Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1116,7 +1116,7 @@ dependencies = [
|
||||
{ name = "django" },
|
||||
{ name = "django-pglock" },
|
||||
{ name = "django-pgtrigger" },
|
||||
{ name = "dramatiq", extra = ["watch"] },
|
||||
{ name = "dramatiq" },
|
||||
{ name = "structlog" },
|
||||
{ name = "tenacity" },
|
||||
]
|
||||
@@ -1127,7 +1127,7 @@ requires-dist = [
|
||||
{ name = "django", specifier = ">=4.2,<6.0" },
|
||||
{ name = "django-pglock", specifier = ">=1.7,<2" },
|
||||
{ name = "django-pgtrigger", specifier = ">=4,<5" },
|
||||
{ name = "dramatiq", extras = ["watch"], specifier = ">=1.17,<1.18" },
|
||||
{ name = "dramatiq", specifier = ">=1.17,<1.18" },
|
||||
{ name = "structlog", specifier = ">=25,<26" },
|
||||
{ name = "tenacity", specifier = ">=9,<10" },
|
||||
]
|
||||
@@ -1375,12 +1375,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/36/925c7afd5db4f1a3f00676b9c3c58f31ff7ae29a347282d86c8d429280a5/dramatiq-1.17.1-py3-none-any.whl", hash = "sha256:951cdc334478dff8e5150bb02a6f7a947d215ee24b5aedaf738eff20e17913df", size = 120382, upload-time = "2024-10-26T05:09:26.436Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
watch = [
|
||||
{ name = "watchdog" },
|
||||
{ name = "watchdog-gevent" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "drf-jsonschema-serializer"
|
||||
version = "3.0.0"
|
||||
@@ -1572,28 +1566,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/15/cf2a69ade4b194aa524ac75112d5caac37414b20a3a03e6865dfe0bd1539/geopy-2.4.1-py3-none-any.whl", hash = "sha256:ae8b4bc5c1131820f4d75fce9d4aaaca0c85189b3aa5d64c3dcaf5e3b7b882a7", size = 125437, upload-time = "2023-11-23T21:49:30.421Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gevent"
|
||||
version = "25.9.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi", marker = "platform_python_implementation == 'CPython' and sys_platform == 'win32'" },
|
||||
{ name = "greenlet", marker = "platform_python_implementation == 'CPython'" },
|
||||
{ name = "zope-event" },
|
||||
{ name = "zope-interface" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9e/48/b3ef2673ffb940f980966694e40d6d32560f3ffa284ecaeb5ea3a90a6d3f/gevent-25.9.1.tar.gz", hash = "sha256:adf9cd552de44a4e6754c51ff2e78d9193b7fa6eab123db9578a210e657235dd", size = 5059025, upload-time = "2025-09-17T16:15:34.528Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/15/1a/948f8167b2cdce573cf01cec07afc64d0456dc134b07900b26ac7018b37e/gevent-25.9.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:1a3fe4ea1c312dbf6b375b416925036fe79a40054e6bf6248ee46526ea628be1", size = 2982934, upload-time = "2025-09-17T14:54:11.302Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/ec/726b146d1d3aad82e03d2e1e1507048ab6072f906e83f97f40667866e582/gevent-25.9.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0adb937f13e5fb90cca2edf66d8d7e99d62a299687400ce2edee3f3504009356", size = 1813982, upload-time = "2025-09-17T15:41:28.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/5d/5f83f17162301662bd1ce702f8a736a8a8cac7b7a35e1d8b9866938d1f9d/gevent-25.9.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:427f869a2050a4202d93cf7fd6ab5cffb06d3e9113c10c967b6e2a0d45237cb8", size = 1894902, upload-time = "2025-09-17T15:49:03.702Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/cd/cf5e74e353f60dab357829069ffc300a7bb414c761f52cf8c0c6e9728b8d/gevent-25.9.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c049880175e8c93124188f9d926af0a62826a3b81aa6d3074928345f8238279e", size = 1861792, upload-time = "2025-09-17T15:49:23.279Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/65/b9a4526d4a4edce26fe4b3b993914ec9dc64baabad625a3101e51adb17f3/gevent-25.9.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b5a67a0974ad9f24721034d1e008856111e0535f1541499f72a733a73d658d1c", size = 2113215, upload-time = "2025-09-17T15:15:16.34Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/be/7d35731dfaf8370795b606e515d964a0967e129db76ea7873f552045dd39/gevent-25.9.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1d0f5d8d73f97e24ea8d24d8be0f51e0cf7c54b8021c1fddb580bf239474690f", size = 1833449, upload-time = "2025-09-17T15:52:43.75Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/58/7bc52544ea5e63af88c4a26c90776feb42551b7555a1c89c20069c168a3f/gevent-25.9.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ddd3ff26e5c4240d3fbf5516c2d9d5f2a998ef87cfb73e1429cfaeaaec860fa6", size = 2176034, upload-time = "2025-09-17T15:24:15.676Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/69/a7c4ba2ffbc7c7dbf6d8b4f5d0f0a421f7815d229f4909854266c445a3d4/gevent-25.9.1-cp314-cp314-win_amd64.whl", hash = "sha256:bb63c0d6cb9950cc94036a4995b9cc4667b8915366613449236970f4394f94d7", size = 1703019, upload-time = "2025-09-17T19:30:55.272Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "google-api-core"
|
||||
version = "2.29.0"
|
||||
@@ -2824,7 +2796,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.13.2"
|
||||
version = "2.13.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-types" },
|
||||
@@ -2832,9 +2804,9 @@ dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/09/e5/06d23afac9973109d1e3c8ad38e1547a12e860610e327c05ee686827dc37/pydantic-2.13.2.tar.gz", hash = "sha256:b418196607e61081c3226dcd4f0672f2a194828abb9109e9cfb84026564df2d1", size = 843836, upload-time = "2026-04-17T09:31:59.636Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d9/e4/40d09941a2cebcb20609b86a559817d5b9291c49dd6f8c87e5feffbe703a/pydantic-2.13.3.tar.gz", hash = "sha256:af09e9d1d09f4e7fe37145c1f577e1d61ceb9a41924bf0094a36506285d0a84d", size = 844068, upload-time = "2026-04-20T14:46:43.632Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/77/ca/b45c378e6e8d0b90577288b533e04e95b7afd61bb1d51b6c263176435489/pydantic-2.13.2-py3-none-any.whl", hash = "sha256:a525087f4c03d7e7456a3de89b64cd693d2229933bb1068b9af6befd5563694e", size = 471947, upload-time = "2026-04-17T09:31:57.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/0a/fd7d723f8f8153418fb40cf9c940e82004fce7e987026b08a68a36dd3fe7/pydantic-2.13.3-py3-none-any.whl", hash = "sha256:6db14ac8dfc9a1e57f87ea2c0de670c251240f43cb0c30a5130e9720dc612927", size = 471981, upload-time = "2026-04-20T14:46:41.402Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -2844,43 +2816,43 @@ email = [
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
version = "2.46.2"
|
||||
version = "2.46.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/43/bb/4742f05b739b2478459bb16fa8470549518c802e06ddcf3f106c5081315e/pydantic_core-2.46.2.tar.gz", hash = "sha256:37bb079f9ee3f1a519392b73fda2a96379b31f2013c6b467fe693e7f2987f596", size = 471269, upload-time = "2026-04-17T09:10:07.017Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2a/ef/f7abb56c49382a246fd2ce9c799691e3c3e7175ec74b14d99e798bcddb1a/pydantic_core-2.46.3.tar.gz", hash = "sha256:41c178f65b8c29807239d47e6050262eb6bf84eb695e41101e62e38df4a5bc2c", size = 471412, upload-time = "2026-04-20T14:40:56.672Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/96/a50ccb6b539ae780f73cea74905468777680e30c6c3bdf714b9d4c116ea0/pydantic_core-2.46.2-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:4f59b45f3ef8650c0c736a57f59031d47ed9df4c0a64e83796849d7d14863a2d", size = 2097111, upload-time = "2026-04-17T09:10:49.617Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/5f/fdead7b3afa822ab6e5a18ee0ecffd54937de1877c01ed13a342e0fb3f07/pydantic_core-2.46.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3a075a29ebef752784a91532a1a85be6b234ccffec0a9d7978a92696387c3da6", size = 1951904, upload-time = "2026-04-17T09:12:32.062Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/e0/1c5d547e550cdab1bec737492aa08865337af6fe7fc9b96f7f45f17d9519/pydantic_core-2.46.2-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d12d786e30c04a9d307c5d7080bf720d9bac7f1668191d8e37633a9562749e2", size = 1978667, upload-time = "2026-04-17T09:11:35.589Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/cb/665ce629e218c8228302cb94beff4f6531082a2c87d3ecc3d5e63a26f392/pydantic_core-2.46.2-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0d5e6d6343b0b5dcacb3503b5de90022968da8ed0ab9ab39d3eda71c20cbf84e", size = 2046721, upload-time = "2026-04-17T09:11:47.725Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/e9/6cb2cf60f54c1472bbdfce19d957553b43dbba79d1d7b2930a195c594785/pydantic_core-2.46.2-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:233eebac0999b6b9ba76eb56f3ec8fce13164aa16b6d2225a36a79e0f95b5973", size = 2228483, upload-time = "2026-04-17T09:12:08.837Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/2a/93e018dd5571f781ebaeda8c0cf65398489d5bee9b1f484df0b6149b43b9/pydantic_core-2.46.2-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9cc0eee720dd2f14f3b7c349469402b99ad81a174ab49d3533974529e9d93992", size = 2294663, upload-time = "2026-04-17T09:12:52.053Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/4f/49e57ca55c770c93d9bb046666a54949b42e3c9099a0c5fe94557873fe30/pydantic_core-2.46.2-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83ee76bf2c9910513dbc19e7d82367131fa7508dedd6186a462393071cc11059", size = 2098742, upload-time = "2026-04-17T09:13:45.472Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/b0/6e46b5cd3332af665f794b8cdeea206618a8630bd9e7bcc36864518fce81/pydantic_core-2.46.2-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:d61db38eb4ee5192f0c261b7f2d38e420b554df8912245e3546aee5c45e2fd78", size = 2125922, upload-time = "2026-04-17T09:12:54.304Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/d1/40850c81585be443a2abfdf7f795f8fae831baf8e2f9b2133c8246ac671c/pydantic_core-2.46.2-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8f09a713d17bcd55da8ab02ebd9110c5246a49c44182af213b5212800af8bc83", size = 2183000, upload-time = "2026-04-17T09:10:59.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/af/8493d7dfa03ebb7866909e577c6aa65ea0de7377b86023cc51d0c8e11db3/pydantic_core-2.46.2-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:30cacc5fb696e64b8ef6fd31d9549d394dd7d52760db072eecb98e37e3af1677", size = 2180335, upload-time = "2026-04-17T09:12:57.01Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/5b/1f6a344c4ffdf284da41c6067b82d5ebcbd11ce1b515ae4b662d4adb6f61/pydantic_core-2.46.2-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:7ccfb105fcfe91a22bbb5563ad3dc124bc1aa75bfd2e53a780ab05f78cdf6108", size = 2330002, upload-time = "2026-04-17T09:12:02.958Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/ff/9a694126c12d6d2f48a0cafa6f8eef88ef0d8825600e18d03ff2e896c3b2/pydantic_core-2.46.2-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:13ffef637dc8370c249e5b26bd18e9a80a4fca3d809618c44e18ec834a7ca7a8", size = 2359920, upload-time = "2026-04-17T09:10:27.764Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/c8/3a35c763d68a9cb2675eb10ef242cf66c5d4701b28ae12e688d67d2c180e/pydantic_core-2.46.2-cp314-cp314-win32.whl", hash = "sha256:1b0ab6d756ca2704a938e6c31b53f290c2f9c10d3914235410302a149de1a83e", size = 1953701, upload-time = "2026-04-17T09:13:30.021Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/6a/f2726a780365f7dfd89d62036f984f7acb99978c60c5e1fa7c0cb898ed11/pydantic_core-2.46.2-cp314-cp314-win_amd64.whl", hash = "sha256:99ebade8c9ada4df975372d8dd25883daa0e379a05f1cd0c99aa0c04368d01a6", size = 2071867, upload-time = "2026-04-17T09:10:39.205Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/79/76baacb9feba3d7c399b245ca1a29c74ea0db04ea693811374827eec2290/pydantic_core-2.46.2-cp314-cp314-win_arm64.whl", hash = "sha256:de87422197cf7f83db91d89c86a21660d749b3cd76cd8a45d115b8e675670f02", size = 2017252, upload-time = "2026-04-17T09:10:26.175Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/3b/77c26938f817668d9ad9bab1a905cb23f11d9a3d4bf724d429b3e55a8eaf/pydantic_core-2.46.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:236f22b4a206b5b61db955396b7cf9e2e1ff77f372efe9570128ccfcd6a525eb", size = 2094545, upload-time = "2026-04-17T09:12:19.339Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/de/42c13f590e3c260966aa49bcdb1674774f975467c49abd51191e502bea28/pydantic_core-2.46.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c2012f64d2cd7cca50f49f22445aa5a88691ac2b4498ee0a9a977f8ca4f7289f", size = 1933953, upload-time = "2026-04-17T09:09:55.889Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/84/ebe3ebb3e2d8db656937cfa6f97f544cb7132f2307a4a7dfdcd0ea102a12/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d07d6c63106d3a9c9a333e2636f9c82c703b1a9e3b079299e58747964e4fdb72", size = 1974435, upload-time = "2026-04-17T09:10:12.371Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/15/0bf51ca6709477cd4ef86148b6d7844f3308f029eac361dd0383f1e17b1a/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c326a2b4b85e959d9a1fc3a11f32f84611b6ec07c053e1828a860edf8d068208", size = 2031113, upload-time = "2026-04-17T09:10:00.752Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/ae/b7b5af9b79db036d9e61a44c481c17a213dc8fc4b8b71fe6875a72fc778b/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac8a65e798f2462552c00d2e013d532c94d646729dda98458beaf51f9ec7b120", size = 2236325, upload-time = "2026-04-17T09:10:33.227Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/ae/ecef7477b5a03d4a499708f7e75d2836452ebb70b776c2d64612b334f57a/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5a3c2bc1cc8164bedbc160b7bb1e8cc1e8b9c27f69ae4f9ae2b976cdae02b2dd", size = 2278135, upload-time = "2026-04-17T09:10:23.287Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/e4/2f9d82faa47af6c39fc3f120145fd915971e1e0cb6b55b494fad9fdf8275/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e69aa5e10b7e8b1bb4a6888650fd12fcbf11d396ca11d4a44de1450875702830", size = 2109071, upload-time = "2026-04-17T09:11:06.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/9c/677cf10873fbd0b116575ab7b97c90482b21564f8a8040beb18edef7a577/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4e6df5c3301e65fb42bc5338bf9a1027a02b0a31dc7f54c33775229af474daf0", size = 2106028, upload-time = "2026-04-17T09:10:51.525Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/53/6a06183544daba51c059123a2064a99039df25f115a06bdb26f2ea177038/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2c2f6e32548ac8d559b47944effcf8ae4d81c161f6b6c885edc53bc08b8f192d", size = 2164816, upload-time = "2026-04-17T09:11:56.187Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/6f/10fcdd9e3eca66fc828eef0f6f5850f2dd3bca2c59e6e041fb8bc3da39be/pydantic_core-2.46.2-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:b089a81c58e6ea0485562bbbbbca4f65c0549521606d5ef27fba217aac9b665a", size = 2166130, upload-time = "2026-04-17T09:10:03.804Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/83/92d3fd0e0156cad2e3cb5c26de73794af78ac9fa0c22ab666e566dd67061/pydantic_core-2.46.2-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:7f700a6d6f64112ae9193709b84303bbab84424ad4b47d0253301aabce9dfc70", size = 2316605, upload-time = "2026-04-17T09:12:45.249Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/f1/facffdb970981068219582e499b8d0871ed163ffcc6b347de5c412669e4c/pydantic_core-2.46.2-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:67db6814beaa5fefe91101ec7eb9efda613795767be96f7cf58b1ca8c9ca9972", size = 2358385, upload-time = "2026-04-17T09:09:54.657Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/a1/b8160b2f22b2199467bc68581a4ed380643c16b348a27d6165c6c242d694/pydantic_core-2.46.2-cp314-cp314t-win32.whl", hash = "sha256:32fbc7447be8e3be99bf7869f7066308f16be55b61f9882c2cefc7931f5c7664", size = 1942373, upload-time = "2026-04-17T09:12:59.594Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/90/db89acabe5b150e11d1b59fe3d947dda2ef6abbfef5c82f056ff63802f5d/pydantic_core-2.46.2-cp314-cp314t-win_amd64.whl", hash = "sha256:b317a2b97019c0b95ce99f4f901ae383f40132da6706cdf1731066a73394c25c", size = 2052078, upload-time = "2026-04-17T09:10:19.96Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/32/e19b83ceb07a3f1bb21798407790bbc9a31740158fd132b94139cb84e16c/pydantic_core-2.46.2-cp314-cp314t-win_arm64.whl", hash = "sha256:7dcb9d40930dfad7ab6b20bcc6ca9d2b030b0f347a0cd9909b54bd53ead521b1", size = 2016941, upload-time = "2026-04-17T09:12:34.447Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/db/a7bcb4940183fda36022cd18ba8dd12f2dff40740ec7b58ce7457befa416/pydantic_core-2.46.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:afa3aa644f74e290cdede48a7b0bee37d1c35e71b05105f6b340d484af536d9b", size = 2097614, upload-time = "2026-04-20T14:44:38.374Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/35/e4066358a22e3e99519db370494c7528f5a2aa1367370e80e27e20283543/pydantic_core-2.46.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ced3310e51aa425f7f77da8bbbb5212616655bedbe82c70944320bc1dbe5e018", size = 1951896, upload-time = "2026-04-20T14:40:53.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/92/37cf4049d1636996e4b888c05a501f40a43ff218983a551d57f9d5e14f0d/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e29908922ce9da1a30b4da490bd1d3d82c01dcfdf864d2a74aacee674d0bfa34", size = 1979314, upload-time = "2026-04-20T14:41:49.446Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/36/9ff4d676dfbdfb2d591cf43f3d90ded01e15b1404fd101180ed2d62a2fd3/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0c9ff69140423eea8ed2d5477df3ba037f671f5e897d206d921bc9fdc39613e7", size = 2056133, upload-time = "2026-04-20T14:42:23.574Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/f0/405b442a4d7ba855b06eec8b2bf9c617d43b8432d099dfdc7bf999293495/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b675ab0a0d5b1c8fdb81195dc5bcefea3f3c240871cdd7ff9a2de8aa50772eb2", size = 2228726, upload-time = "2026-04-20T14:44:22.816Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/f8/65cd92dd5a0bd89ba277a98ecbfaf6fc36bbd3300973c7a4b826d6ab1391/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0087084960f209a9a4af50ecd1fb063d9ad3658c07bb81a7a53f452dacbfb2ba", size = 2301214, upload-time = "2026-04-20T14:44:48.792Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/86/ef96a4c6e79e7a2d0410826a68fbc0eccc0fd44aa733be199d5fcac3bb87/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed42e6cc8e1b0e2b9b96e2276bad70ae625d10d6d524aed0c93de974ae029f9f", size = 2099927, upload-time = "2026-04-20T14:41:40.196Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/53/269caf30e0096e0a8a8f929d1982a27b3879872cca2d917d17c2f9fdf4fe/pydantic_core-2.46.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:f1771ce258afb3e4201e67d154edbbae712a76a6081079fe247c2f53c6322c22", size = 2128789, upload-time = "2026-04-20T14:41:15.868Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/b0/1a6d9b6a587e118482910c244a1c5acf4d192604174132efd12bf0ac486f/pydantic_core-2.46.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a7610b6a5242a6c736d8ad47fd5fff87fcfe8f833b281b1c409c3d6835d9227f", size = 2173815, upload-time = "2026-04-20T14:44:25.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/56/e7e00d4041a7e62b5a40815590114db3b535bf3ca0bf4dca9f16cef25246/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:ff5e7783bcc5476e1db448bf268f11cb257b1c276d3e89f00b5727be86dd0127", size = 2181608, upload-time = "2026-04-20T14:41:28.933Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/22/4bd23c3d41f7c185d60808a1de83c76cf5aeabf792f6c636a55c3b1ec7f9/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:9d2e32edcc143bc01e95300671915d9ca052d4f745aa0a49c48d4803f8a85f2c", size = 2326968, upload-time = "2026-04-20T14:42:03.962Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/ac/66cd45129e3915e5ade3b292cb3bc7fd537f58f8f8dbdaba6170f7cabb74/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6e42d83d1c6b87fa56b521479cff237e626a292f3b31b6345c15a99121b454c1", size = 2369842, upload-time = "2026-04-20T14:41:35.52Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/51/dd4248abb84113615473aa20d5545b7c4cd73c8644003b5259686f93996c/pydantic_core-2.46.3-cp314-cp314-win32.whl", hash = "sha256:07bc6d2a28c3adb4f7c6ae46aa4f2d2929af127f587ed44057af50bf1ce0f505", size = 1959661, upload-time = "2026-04-20T14:41:00.042Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/eb/59980e5f1ae54a3b86372bd9f0fa373ea2d402e8cdcd3459334430f91e91/pydantic_core-2.46.3-cp314-cp314-win_amd64.whl", hash = "sha256:8940562319bc621da30714617e6a7eaa6b98c84e8c685bcdc02d7ed5e7c7c44e", size = 2071686, upload-time = "2026-04-20T14:43:16.471Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/db/1cf77e5247047dfee34bc01fa9bca134854f528c8eb053e144298893d370/pydantic_core-2.46.3-cp314-cp314-win_arm64.whl", hash = "sha256:5dcbbcf4d22210ced8f837c96db941bdb078f419543472aca5d9a0bb7cddc7df", size = 2026907, upload-time = "2026-04-20T14:43:31.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/c0/b3df9f6a543276eadba0a48487b082ca1f201745329d97dbfa287034a230/pydantic_core-2.46.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d0fe3dce1e836e418f912c1ad91c73357d03e556a4d286f441bf34fed2dbeecf", size = 2095047, upload-time = "2026-04-20T14:42:37.982Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/57/886a938073b97556c168fd99e1a7305bb363cd30a6d2c76086bf0587b32a/pydantic_core-2.46.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9ce92e58abc722dac1bf835a6798a60b294e48eb0e625ec9fd994b932ac5feee", size = 1934329, upload-time = "2026-04-20T14:43:49.655Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/7c/b42eaa5c34b13b07ecb51da21761297a9b8eb43044c864a035999998f328/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a03e6467f0f5ab796a486146d1b887b2dc5e5f9b3288898c1b1c3ad974e53e4a", size = 1974847, upload-time = "2026-04-20T14:42:10.737Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/9b/92b42db6543e7de4f99ae977101a2967b63122d4b6cf7773812da2d7d5b5/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2798b6ba041b9d70acfb9071a2ea13c8456dd1e6a5555798e41ba7b0790e329c", size = 2041742, upload-time = "2026-04-20T14:40:44.262Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/19/46fbe1efabb5aa2834b43b9454e70f9a83ad9c338c1291e48bdc4fecf167/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9be3e221bdc6d69abf294dcf7aff6af19c31a5cdcc8f0aa3b14be29df4bd03b1", size = 2236235, upload-time = "2026-04-20T14:41:27.307Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/da/b3f95bc009ad60ec53120f5d16c6faa8cabdbe8a20d83849a1f2b8728148/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f13936129ce841f2a5ddf6f126fea3c43cd128807b5a59588c37cf10178c2e64", size = 2282633, upload-time = "2026-04-20T14:44:33.271Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/6e/401336117722e28f32fb8220df676769d28ebdf08f2f4469646d404c43a3/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28b5f2ef03416facccb1c6ef744c69793175fd27e44ef15669201601cf423acb", size = 2109679, upload-time = "2026-04-20T14:44:41.065Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/53/b289f9bc8756a32fe718c46f55afaeaf8d489ee18d1a1e7be1db73f42cc4/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:830d1247d77ad23852314f069e9d7ddafeec5f684baf9d7e7065ed46a049c4e6", size = 2108342, upload-time = "2026-04-20T14:42:50.144Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/5b/8292fc7c1f9111f1b2b7c1b0dcf1179edcd014fc3ea4517499f50b829d71/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0793c90c1a3c74966e7975eaef3ed30ebdff3260a0f815a62a22adc17e4c01c", size = 2157208, upload-time = "2026-04-20T14:42:08.133Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/9e/f80044e9ec07580f057a89fc131f78dda7a58751ddf52bbe05eaf31db50f/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d2d0aead851b66f5245ec0c4fb2612ef457f8bbafefdf65a2bf9d6bac6140f47", size = 2167237, upload-time = "2026-04-20T14:42:25.412Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/84/6781a1b037f3b96be9227edbd1101f6d3946746056231bf4ac48cdff1a8d/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:2f40e4246676beb31c5ce77c38a55ca4e465c6b38d11ea1bd935420568e0b1ab", size = 2312540, upload-time = "2026-04-20T14:40:40.313Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/db/19c0839feeb728e7df03255581f198dfdf1c2aeb1e174a8420b63c5252e5/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:cf489cf8986c543939aeee17a09c04d6ffb43bfef8ca16fcbcc5cfdcbed24dba", size = 2369556, upload-time = "2026-04-20T14:41:09.427Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/15/3228774cb7cd45f5f721ddf1b2242747f4eb834d0c491f0c02d606f09fed/pydantic_core-2.46.3-cp314-cp314t-win32.whl", hash = "sha256:ffe0883b56cfc05798bf994164d2b2ff03efe2d22022a2bb080f3b626176dd56", size = 1949756, upload-time = "2026-04-20T14:41:25.717Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/2a/c79cf53fd91e5a87e30d481809f52f9a60dd221e39de66455cf04deaad37/pydantic_core-2.46.3-cp314-cp314t-win_amd64.whl", hash = "sha256:706d9d0ce9cf4593d07270d8e9f53b161f90c57d315aeec4fb4fd7a8b10240d8", size = 2051305, upload-time = "2026-04-20T14:43:18.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/db/d8182a7f1d9343a032265aae186eb063fe26ca4c40f256b21e8da4498e89/pydantic_core-2.46.3-cp314-cp314t-win_arm64.whl", hash = "sha256:77706aeb41df6a76568434701e0917da10692da28cb69d5fb6919ce5fdb07374", size = 2026310, upload-time = "2026-04-20T14:41:01.778Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3888,19 +3860,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "watchdog-gevent"
|
||||
version = "0.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "gevent" },
|
||||
{ name = "watchdog" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/97/69/91cfca7c21c382e3a8aca4251dcd7d4315228d9346381feb2dde36d14061/watchdog_gevent-0.2.1.tar.gz", hash = "sha256:ae6b94d0f8c8ce1c5956cd865f612b61f456cf19801744bba25a349fe8e8c337", size = 4296, upload-time = "2024-10-19T05:29:12.987Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/00/a9/54b88e150b77791958957e2188312477d09fc84820fc03f8b3a7569d10b0/watchdog_gevent-0.2.1-py3-none-any.whl", hash = "sha256:e8114658104a018f626ee54052335407c1438369febc776c4b4c4308ed002350", size = 3462, upload-time = "2024-10-19T05:29:11.421Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "watchfiles"
|
||||
version = "1.1.1"
|
||||
@@ -4082,15 +4041,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zope-event"
|
||||
version = "6.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/46/33/d3eeac228fc14de76615612ee208be2d8a5b5b0fada36bf9b62d6b40600c/zope_event-6.1.tar.gz", hash = "sha256:6052a3e0cb8565d3d4ef1a3a7809336ac519bc4fe38398cb8d466db09adef4f0", size = 18739, upload-time = "2025-11-07T08:05:49.934Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/b0/956902e5e1302f8c5d124e219c6bf214e2649f92ad5fce85b05c039a04c9/zope_event-6.1-py3-none-any.whl", hash = "sha256:0ca78b6391b694272b23ec1335c0294cc471065ed10f7f606858fc54566c25a0", size = 6414, upload-time = "2025-11-07T08:05:48.874Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zope-interface"
|
||||
version = "8.2"
|
||||
|
||||
82
web/package-lock.json
generated
82
web/package-lock.json
generated
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
@@ -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.")}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
68
web/src/common/ui/locale/plurals.ts
Normal file
68
web/src/common/ui/locale/plurals.ts
Normal 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)();
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
99
web/src/elements/entities/UsedByTable.ts
Normal file
99
web/src/elements/entities/UsedByTable.ts
Normal 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>;
|
||||
}
|
||||
}
|
||||
21
web/src/elements/entities/names.ts
Normal file
21
web/src/elements/entities/names.ts
Normal 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;
|
||||
}
|
||||
79
web/src/elements/entities/used-by.ts
Normal file
79
web/src/elements/entities/used-by.ts
Normal 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>`;
|
||||
};
|
||||
@@ -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>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
import "#elements/buttons/SpinnerButton/index";
|
||||
|
||||
import { EVENT_REFRESH } from "#common/constants";
|
||||
import { parseAPIResponseError, pluckErrorDetail } from "#common/errors/network";
|
||||
import { MessageLevel } from "#common/messages";
|
||||
|
||||
import { ModalButton } from "#elements/buttons/ModalButton";
|
||||
import { showMessage } from "#elements/messages/MessageContainer";
|
||||
|
||||
import { UsedBy, UsedByActionEnum } from "@goauthentik/api";
|
||||
|
||||
import { msg, str } from "@lit/localize";
|
||||
import { CSSResult, html, nothing, TemplateResult } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { until } from "lit/directives/until.js";
|
||||
|
||||
import PFList from "@patternfly/patternfly/components/List/list.css";
|
||||
|
||||
@customElement("ak-forms-delete")
|
||||
export class DeleteForm extends ModalButton {
|
||||
static styles: CSSResult[] = [...super.styles, PFList];
|
||||
|
||||
@property({ attribute: false })
|
||||
public obj?: Record<string, unknown>;
|
||||
|
||||
@property({ type: String, attribute: "object-label" })
|
||||
public objectLabel?: string;
|
||||
|
||||
@property({ attribute: false })
|
||||
public usedBy?: () => Promise<UsedBy[]>;
|
||||
|
||||
@property({ attribute: false })
|
||||
public delete!: () => Promise<unknown>;
|
||||
|
||||
/**
|
||||
* Get the display name for the object being deleted/updated.
|
||||
*/
|
||||
protected getObjectDisplayName(): string | undefined {
|
||||
return this.obj?.name as string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the formatted object name for display in messages.
|
||||
* Returns ` "displayName"` with quotes if display name exists, empty string otherwise.
|
||||
*/
|
||||
protected getFormattedObjectName(): string {
|
||||
const displayName = this.getObjectDisplayName();
|
||||
return displayName ? ` "${displayName}"` : "";
|
||||
}
|
||||
|
||||
confirm(): Promise<void> {
|
||||
return this.delete()
|
||||
.then(() => {
|
||||
this.onSuccess();
|
||||
this.open = false;
|
||||
|
||||
this.dispatchEvent(
|
||||
new CustomEvent(EVENT_REFRESH, {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
})
|
||||
.catch(async (error: unknown) => {
|
||||
await this.onError(error);
|
||||
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
onSuccess(): void {
|
||||
showMessage({
|
||||
message: msg(
|
||||
str`Successfully deleted ${this.objectLabel} ${this.getObjectDisplayName()}`,
|
||||
),
|
||||
level: MessageLevel.success,
|
||||
});
|
||||
}
|
||||
|
||||
onError(error: unknown): Promise<void> {
|
||||
return parseAPIResponseError(error).then((parsedError) => {
|
||||
showMessage({
|
||||
message: msg(
|
||||
str`Failed to delete ${this.objectLabel}: ${pluckErrorDetail(parsedError)}`,
|
||||
),
|
||||
level: MessageLevel.error,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
renderModalInner(): TemplateResult {
|
||||
const objName = this.getFormattedObjectName();
|
||||
return html`<section class="pf-c-modal-box__header pf-c-page__main-section pf-m-light">
|
||||
<div class="pf-c-content">
|
||||
<h1 class="pf-c-title pf-m-2xl">${msg(str`Delete ${this.objectLabel}`)}</h1>
|
||||
</div>
|
||||
</section>
|
||||
<section class="pf-c-modal-box__body pf-m-light">
|
||||
<form class="pf-c-form pf-m-horizontal">
|
||||
<p>
|
||||
${msg(str`Are you sure you want to delete ${this.objectLabel}${objName}?`)}
|
||||
</p>
|
||||
</form>
|
||||
</section>
|
||||
${this.usedBy
|
||||
? until(
|
||||
this.usedBy().then((usedBy) => {
|
||||
if (usedBy.length < 1) {
|
||||
return nothing;
|
||||
}
|
||||
return html`
|
||||
<section class="pf-c-modal-box__body pf-m-light">
|
||||
<form class="pf-c-form pf-m-horizontal">
|
||||
<p>${msg(str`The following objects use ${objName}`)}</p>
|
||||
<ul class="pf-c-list">
|
||||
${usedBy.map((ub) => {
|
||||
let consequence = "";
|
||||
switch (ub.action) {
|
||||
case UsedByActionEnum.Cascade:
|
||||
consequence = msg("object will be DELETED");
|
||||
break;
|
||||
case UsedByActionEnum.CascadeMany:
|
||||
consequence = msg(
|
||||
"connecting object will be deleted",
|
||||
);
|
||||
break;
|
||||
case UsedByActionEnum.SetDefault:
|
||||
consequence = msg(
|
||||
"reference will be reset to default value",
|
||||
);
|
||||
break;
|
||||
case UsedByActionEnum.SetNull:
|
||||
consequence = msg(
|
||||
"reference will be set to an empty value",
|
||||
);
|
||||
break;
|
||||
}
|
||||
return html`<li>
|
||||
${msg(str`${ub.name} (${consequence})`)}
|
||||
</li>`;
|
||||
})}
|
||||
</ul>
|
||||
</form>
|
||||
</section>
|
||||
`;
|
||||
}),
|
||||
)
|
||||
: nothing}
|
||||
<fieldset class="pf-c-modal-box__footer">
|
||||
<legend class="sr-only">${msg("Form actions")}</legend>
|
||||
|
||||
<ak-spinner-button
|
||||
.callAction=${async () => {
|
||||
this.open = false;
|
||||
}}
|
||||
class="pf-m-plain"
|
||||
>
|
||||
${msg("Cancel")}
|
||||
</ak-spinner-button>
|
||||
<ak-spinner-button
|
||||
.callAction=${() => {
|
||||
return this.confirm();
|
||||
}}
|
||||
class="pf-m-danger"
|
||||
>
|
||||
${msg("Delete")}
|
||||
</ak-spinner-button>
|
||||
</fieldset>`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ak-forms-delete": DeleteForm;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user