Add identity and target authorization package
OIDC validation, principal mapping, remora:v1 roles, and current-authz filtering with kind acceptance on Keycloak and ZITADEL.
This commit is contained in:
parent
91ac6dee85
commit
bf028161ae
21 changed files with 3153 additions and 21 deletions
585
Cargo.lock
generated
585
Cargo.lock
generated
|
|
@ -49,6 +49,12 @@ dependencies = [
|
||||||
"num-traits",
|
"num-traits",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "atomic-waker"
|
||||||
|
version = "1.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "autocfg"
|
name = "autocfg"
|
||||||
version = "1.5.1"
|
version = "1.5.1"
|
||||||
|
|
@ -70,6 +76,12 @@ version = "0.22.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64ct"
|
||||||
|
version = "1.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bit-set"
|
name = "bit-set"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
|
|
@ -158,6 +170,12 @@ version = "1.0.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg_aliases"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chacha20"
|
name = "chacha20"
|
||||||
version = "0.10.1"
|
version = "0.10.1"
|
||||||
|
|
@ -166,7 +184,7 @@ checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cpufeatures 0.3.0",
|
"cpufeatures 0.3.0",
|
||||||
"rand_core",
|
"rand_core 0.10.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -198,6 +216,12 @@ dependencies = [
|
||||||
"crossbeam-utils",
|
"crossbeam-utils",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "const-oid"
|
||||||
|
version = "0.9.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "const-oid"
|
name = "const-oid"
|
||||||
version = "0.10.2"
|
version = "0.10.2"
|
||||||
|
|
@ -286,6 +310,17 @@ version = "2.11.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
|
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "der"
|
||||||
|
version = "0.7.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
||||||
|
dependencies = [
|
||||||
|
"const-oid 0.9.6",
|
||||||
|
"pem-rfc7468",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deranged"
|
name = "deranged"
|
||||||
version = "0.5.8"
|
version = "0.5.8"
|
||||||
|
|
@ -302,6 +337,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"block-buffer 0.10.4",
|
"block-buffer 0.10.4",
|
||||||
|
"const-oid 0.9.6",
|
||||||
"crypto-common 0.1.6",
|
"crypto-common 0.1.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -312,7 +348,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"block-buffer 0.12.1",
|
"block-buffer 0.12.1",
|
||||||
"const-oid",
|
"const-oid 0.10.2",
|
||||||
"crypto-common 0.2.2",
|
"crypto-common 0.2.2",
|
||||||
"ctutils",
|
"ctutils",
|
||||||
]
|
]
|
||||||
|
|
@ -537,8 +573,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"wasi",
|
"wasi",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -562,9 +600,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi 6.0.0",
|
"r-efi 6.0.0",
|
||||||
"rand_core",
|
"rand_core 0.10.1",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -628,6 +668,45 @@ dependencies = [
|
||||||
"digest 0.11.3",
|
"digest 0.11.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "http"
|
||||||
|
version = "1.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"itoa",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "http-body"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"http",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "http-body-util"
|
||||||
|
version = "0.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"futures-core",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"pin-project-lite",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httparse"
|
||||||
|
version = "1.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hybrid-array"
|
name = "hybrid-array"
|
||||||
version = "0.4.13"
|
version = "0.4.13"
|
||||||
|
|
@ -637,6 +716,65 @@ dependencies = [
|
||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hyper"
|
||||||
|
version = "1.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
|
||||||
|
dependencies = [
|
||||||
|
"atomic-waker",
|
||||||
|
"bytes",
|
||||||
|
"futures-channel",
|
||||||
|
"futures-core",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"httparse",
|
||||||
|
"itoa",
|
||||||
|
"pin-project-lite",
|
||||||
|
"smallvec",
|
||||||
|
"tokio",
|
||||||
|
"want",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hyper-rustls"
|
||||||
|
version = "0.27.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
|
||||||
|
dependencies = [
|
||||||
|
"http",
|
||||||
|
"hyper",
|
||||||
|
"hyper-util",
|
||||||
|
"rustls",
|
||||||
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
|
"tower-service",
|
||||||
|
"webpki-roots",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hyper-util"
|
||||||
|
version = "0.1.20"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
|
||||||
|
dependencies = [
|
||||||
|
"base64",
|
||||||
|
"bytes",
|
||||||
|
"futures-channel",
|
||||||
|
"futures-util",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"hyper",
|
||||||
|
"ipnet",
|
||||||
|
"libc",
|
||||||
|
"percent-encoding",
|
||||||
|
"pin-project-lite",
|
||||||
|
"socket2",
|
||||||
|
"tokio",
|
||||||
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "icu_collections"
|
name = "icu_collections"
|
||||||
version = "2.2.0"
|
version = "2.2.0"
|
||||||
|
|
@ -750,6 +888,12 @@ dependencies = [
|
||||||
"hashbrown 0.17.1",
|
"hashbrown 0.17.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ipnet"
|
||||||
|
version = "2.12.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itoa"
|
name = "itoa"
|
||||||
version = "1.0.18"
|
version = "1.0.18"
|
||||||
|
|
@ -763,6 +907,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
|
checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"futures-util",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -802,11 +947,29 @@ dependencies = [
|
||||||
"regex-syntax",
|
"regex-syntax",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jsonwebtoken"
|
||||||
|
version = "9.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"
|
||||||
|
dependencies = [
|
||||||
|
"base64",
|
||||||
|
"js-sys",
|
||||||
|
"pem",
|
||||||
|
"ring",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"simple_asn1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lazy_static"
|
name = "lazy_static"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||||
|
dependencies = [
|
||||||
|
"spin",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
|
|
@ -814,6 +977,12 @@ version = "0.2.186"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libm"
|
||||||
|
version = "0.2.16"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libsqlite3-sys"
|
name = "libsqlite3-sys"
|
||||||
version = "0.37.0"
|
version = "0.37.0"
|
||||||
|
|
@ -845,6 +1014,12 @@ version = "0.4.33"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lru-slab"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "md-5"
|
name = "md-5"
|
||||||
version = "0.11.0"
|
version = "0.11.0"
|
||||||
|
|
@ -902,6 +1077,22 @@ dependencies = [
|
||||||
"num-traits",
|
"num-traits",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-bigint-dig"
|
||||||
|
version = "0.8.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7"
|
||||||
|
dependencies = [
|
||||||
|
"lazy_static",
|
||||||
|
"libm",
|
||||||
|
"num-integer",
|
||||||
|
"num-iter",
|
||||||
|
"num-traits",
|
||||||
|
"rand 0.8.7",
|
||||||
|
"smallvec",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-cmp"
|
name = "num-cmp"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
@ -960,6 +1151,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"autocfg",
|
"autocfg",
|
||||||
|
"libm",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1003,6 +1195,25 @@ dependencies = [
|
||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pem"
|
||||||
|
version = "3.0.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
|
||||||
|
dependencies = [
|
||||||
|
"base64",
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pem-rfc7468"
|
||||||
|
version = "0.7.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
|
||||||
|
dependencies = [
|
||||||
|
"base64ct",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "percent-encoding"
|
name = "percent-encoding"
|
||||||
version = "2.3.2"
|
version = "2.3.2"
|
||||||
|
|
@ -1015,6 +1226,27 @@ version = "0.2.17"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pkcs1"
|
||||||
|
version = "0.7.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f"
|
||||||
|
dependencies = [
|
||||||
|
"der",
|
||||||
|
"pkcs8",
|
||||||
|
"spki",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pkcs8"
|
||||||
|
version = "0.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
|
||||||
|
dependencies = [
|
||||||
|
"der",
|
||||||
|
"spki",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pkg-config"
|
name = "pkg-config"
|
||||||
version = "0.3.33"
|
version = "0.3.33"
|
||||||
|
|
@ -1036,6 +1268,15 @@ version = "0.2.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ppv-lite86"
|
||||||
|
version = "0.2.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||||
|
dependencies = [
|
||||||
|
"zerocopy",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro2"
|
name = "proc-macro2"
|
||||||
version = "1.0.106"
|
version = "1.0.106"
|
||||||
|
|
@ -1045,6 +1286,62 @@ dependencies = [
|
||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quinn"
|
||||||
|
version = "0.11.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"cfg_aliases",
|
||||||
|
"pin-project-lite",
|
||||||
|
"quinn-proto",
|
||||||
|
"quinn-udp",
|
||||||
|
"rustc-hash",
|
||||||
|
"rustls",
|
||||||
|
"socket2",
|
||||||
|
"thiserror",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"web-time",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quinn-proto"
|
||||||
|
version = "0.11.16"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"getrandom 0.4.3",
|
||||||
|
"lru-slab",
|
||||||
|
"rand 0.10.2",
|
||||||
|
"rand_pcg",
|
||||||
|
"ring",
|
||||||
|
"rustc-hash",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"slab",
|
||||||
|
"thiserror",
|
||||||
|
"tinyvec",
|
||||||
|
"tracing",
|
||||||
|
"web-time",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quinn-udp"
|
||||||
|
version = "0.5.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
|
||||||
|
dependencies = [
|
||||||
|
"cfg_aliases",
|
||||||
|
"libc",
|
||||||
|
"once_cell",
|
||||||
|
"socket2",
|
||||||
|
"tracing",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.46"
|
version = "1.0.46"
|
||||||
|
|
@ -1066,6 +1363,17 @@ version = "6.0.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand"
|
||||||
|
version = "0.8.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"rand_chacha",
|
||||||
|
"rand_core 0.6.4",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand"
|
name = "rand"
|
||||||
version = "0.10.2"
|
version = "0.10.2"
|
||||||
|
|
@ -1074,7 +1382,26 @@ checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chacha20",
|
"chacha20",
|
||||||
"getrandom 0.4.3",
|
"getrandom 0.4.3",
|
||||||
"rand_core",
|
"rand_core 0.10.1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_chacha"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||||
|
dependencies = [
|
||||||
|
"ppv-lite86",
|
||||||
|
"rand_core 0.6.4",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_core"
|
||||||
|
version = "0.6.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.2.17",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1083,6 +1410,15 @@ version = "0.10.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_pcg"
|
||||||
|
version = "0.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
|
||||||
|
dependencies = [
|
||||||
|
"rand_core 0.10.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redis"
|
name = "redis"
|
||||||
version = "0.32.7"
|
version = "0.32.7"
|
||||||
|
|
@ -1238,10 +1574,69 @@ dependencies = [
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "remora-identity"
|
||||||
|
version = "0.0.0"
|
||||||
|
dependencies = [
|
||||||
|
"base64",
|
||||||
|
"jsonwebtoken",
|
||||||
|
"pkcs8",
|
||||||
|
"rand 0.8.7",
|
||||||
|
"remora-domain",
|
||||||
|
"remora-durable",
|
||||||
|
"reqwest",
|
||||||
|
"rsa",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"sha2 0.11.0",
|
||||||
|
"sqlx",
|
||||||
|
"thiserror",
|
||||||
|
"time",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "remora-worker"
|
name = "remora-worker"
|
||||||
version = "0.0.0"
|
version = "0.0.0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "reqwest"
|
||||||
|
version = "0.12.28"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||||
|
dependencies = [
|
||||||
|
"base64",
|
||||||
|
"bytes",
|
||||||
|
"futures-core",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
|
"hyper",
|
||||||
|
"hyper-rustls",
|
||||||
|
"hyper-util",
|
||||||
|
"js-sys",
|
||||||
|
"log",
|
||||||
|
"percent-encoding",
|
||||||
|
"pin-project-lite",
|
||||||
|
"quinn",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"serde_urlencoded",
|
||||||
|
"sync_wrapper",
|
||||||
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
|
"tower",
|
||||||
|
"tower-http",
|
||||||
|
"tower-service",
|
||||||
|
"url",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"wasm-bindgen-futures",
|
||||||
|
"web-sys",
|
||||||
|
"webpki-roots",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ring"
|
name = "ring"
|
||||||
version = "0.17.14"
|
version = "0.17.14"
|
||||||
|
|
@ -1256,6 +1651,32 @@ dependencies = [
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rsa"
|
||||||
|
version = "0.9.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
|
||||||
|
dependencies = [
|
||||||
|
"const-oid 0.9.6",
|
||||||
|
"digest 0.10.7",
|
||||||
|
"num-bigint-dig",
|
||||||
|
"num-integer",
|
||||||
|
"num-traits",
|
||||||
|
"pkcs1",
|
||||||
|
"pkcs8",
|
||||||
|
"rand_core 0.6.4",
|
||||||
|
"signature",
|
||||||
|
"spki",
|
||||||
|
"subtle",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustc-hash"
|
||||||
|
version = "2.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls"
|
name = "rustls"
|
||||||
version = "0.23.42"
|
version = "0.23.42"
|
||||||
|
|
@ -1276,6 +1697,7 @@ version = "1.15.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046"
|
checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"web-time",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -1368,6 +1790,18 @@ dependencies = [
|
||||||
"serde_json",
|
"serde_json",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_urlencoded"
|
||||||
|
version = "0.7.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
|
||||||
|
dependencies = [
|
||||||
|
"form_urlencoded",
|
||||||
|
"itoa",
|
||||||
|
"ryu",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_yaml_ng"
|
name = "serde_yaml_ng"
|
||||||
version = "0.10.0"
|
version = "0.10.0"
|
||||||
|
|
@ -1426,6 +1860,28 @@ version = "2.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "signature"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||||
|
dependencies = [
|
||||||
|
"digest 0.10.7",
|
||||||
|
"rand_core 0.6.4",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simple_asn1"
|
||||||
|
version = "0.6.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
|
||||||
|
dependencies = [
|
||||||
|
"num-bigint",
|
||||||
|
"num-traits",
|
||||||
|
"thiserror",
|
||||||
|
"time",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "slab"
|
name = "slab"
|
||||||
version = "0.4.12"
|
version = "0.4.12"
|
||||||
|
|
@ -1460,6 +1916,16 @@ dependencies = [
|
||||||
"lock_api",
|
"lock_api",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "spki"
|
||||||
|
version = "0.7.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
|
||||||
|
dependencies = [
|
||||||
|
"base64ct",
|
||||||
|
"der",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sqlx"
|
name = "sqlx"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
|
|
@ -1599,7 +2065,7 @@ dependencies = [
|
||||||
"log",
|
"log",
|
||||||
"md-5",
|
"md-5",
|
||||||
"memchr",
|
"memchr",
|
||||||
"rand",
|
"rand 0.10.2",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2 0.11.0",
|
"sha2 0.11.0",
|
||||||
|
|
@ -1671,6 +2137,15 @@ dependencies = [
|
||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sync_wrapper"
|
||||||
|
version = "1.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "synstructure"
|
name = "synstructure"
|
||||||
version = "0.13.2"
|
version = "0.13.2"
|
||||||
|
|
@ -1783,6 +2258,16 @@ dependencies = [
|
||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-rustls"
|
||||||
|
version = "0.26.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
|
||||||
|
dependencies = [
|
||||||
|
"rustls",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-stream"
|
name = "tokio-stream"
|
||||||
version = "0.1.18"
|
version = "0.1.18"
|
||||||
|
|
@ -1807,6 +2292,51 @@ dependencies = [
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tower"
|
||||||
|
version = "0.5.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"futures-util",
|
||||||
|
"pin-project-lite",
|
||||||
|
"sync_wrapper",
|
||||||
|
"tokio",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tower-http"
|
||||||
|
version = "0.6.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"bytes",
|
||||||
|
"futures-util",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tower",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tower-layer"
|
||||||
|
version = "0.3.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tower-service"
|
||||||
|
version = "0.3.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tracing"
|
name = "tracing"
|
||||||
version = "0.1.44"
|
version = "0.1.44"
|
||||||
|
|
@ -1839,6 +2369,12 @@ dependencies = [
|
||||||
"once_cell",
|
"once_cell",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "try-lock"
|
||||||
|
version = "0.2.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typenum"
|
name = "typenum"
|
||||||
version = "1.20.1"
|
version = "1.20.1"
|
||||||
|
|
@ -1936,6 +2472,15 @@ version = "0.8.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64"
|
checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "want"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
|
||||||
|
dependencies = [
|
||||||
|
"try-lock",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasi"
|
name = "wasi"
|
||||||
version = "0.11.1+wasi-snapshot-preview1"
|
version = "0.11.1+wasi-snapshot-preview1"
|
||||||
|
|
@ -1964,6 +2509,16 @@ dependencies = [
|
||||||
"wasm-bindgen-shared",
|
"wasm-bindgen-shared",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-bindgen-futures"
|
||||||
|
version = "0.4.76"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d"
|
||||||
|
dependencies = [
|
||||||
|
"js-sys",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-macro"
|
name = "wasm-bindgen-macro"
|
||||||
version = "0.2.126"
|
version = "0.2.126"
|
||||||
|
|
@ -1996,6 +2551,26 @@ dependencies = [
|
||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "web-sys"
|
||||||
|
version = "0.3.103"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141"
|
||||||
|
dependencies = [
|
||||||
|
"js-sys",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "web-time"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
|
||||||
|
dependencies = [
|
||||||
|
"js-sys",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "webpki-roots"
|
name = "webpki-roots"
|
||||||
version = "1.0.8"
|
version = "1.0.8"
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ members = [
|
||||||
"crates/remora-coordination",
|
"crates/remora-coordination",
|
||||||
"crates/remora-domain",
|
"crates/remora-domain",
|
||||||
"crates/remora-durable",
|
"crates/remora-durable",
|
||||||
|
"crates/remora-identity",
|
||||||
"crates/remora-worker",
|
"crates/remora-worker",
|
||||||
]
|
]
|
||||||
resolver = "3"
|
resolver = "3"
|
||||||
|
|
@ -18,6 +19,7 @@ version = "0.0.0"
|
||||||
base64 = "0.22.1"
|
base64 = "0.22.1"
|
||||||
hmac = "0.13.0"
|
hmac = "0.13.0"
|
||||||
remora-domain = { path = "crates/remora-domain" }
|
remora-domain = { path = "crates/remora-domain" }
|
||||||
|
remora-durable = { path = "crates/remora-durable" }
|
||||||
serde = { version = "1.0.228", features = ["derive"] }
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
serde_json = "1.0.150"
|
serde_json = "1.0.150"
|
||||||
serde_json_canonicalizer = "0.3.2"
|
serde_json_canonicalizer = "0.3.2"
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,26 @@ COPY crates crates
|
||||||
COPY --from=kind-coordination-build /tmp/remora-coordination-tests.tar.zst /tests/remora-coordination-tests.tar.zst
|
COPY --from=kind-coordination-build /tmp/remora-coordination-tests.tar.zst /tests/remora-coordination-tests.tar.zst
|
||||||
CMD ["bash", "-ec", "test -s /run/remora-fixture/profile.env && test -n \"$REMORA_COORDINATION_URL\" && export REMORA_COORDINATION_REQUIRE=1 && cargo-nextest nextest run --archive-file /tests/remora-coordination-tests.tar.zst --workspace-remap /workspace"]
|
CMD ["bash", "-ec", "test -s /run/remora-fixture/profile.env && test -n \"$REMORA_COORDINATION_URL\" && export REMORA_COORDINATION_REQUIRE=1 && cargo-nextest nextest run --archive-file /tests/remora-coordination-tests.tar.zst --workspace-remap /workspace"]
|
||||||
|
|
||||||
|
FROM rust-tools AS kind-identity-build
|
||||||
|
WORKDIR /workspace
|
||||||
|
COPY Cargo.toml Cargo.lock rust-toolchain.toml ./
|
||||||
|
COPY .config .config
|
||||||
|
COPY contracts contracts
|
||||||
|
COPY crates crates
|
||||||
|
COPY docs/api docs/api
|
||||||
|
RUN cargo nextest archive \
|
||||||
|
--package remora-identity \
|
||||||
|
--build-jobs 4 \
|
||||||
|
--locked \
|
||||||
|
--archive-file /tmp/remora-identity-tests.tar.zst
|
||||||
|
|
||||||
|
FROM rust-tools AS kind-identity-runner
|
||||||
|
WORKDIR /workspace
|
||||||
|
COPY Cargo.toml ./
|
||||||
|
COPY crates crates
|
||||||
|
COPY --from=kind-identity-build /tmp/remora-identity-tests.tar.zst /tests/remora-identity-tests.tar.zst
|
||||||
|
CMD ["bash", "-ec", "test -s /run/remora-fixture/profile.env && test -n \"$DATABASE_URL\" && test -n \"$REMORA_OIDC_ISSUER\" && export REMORA_IDENTITY_REQUIRE=1 && cargo-nextest nextest run --archive-file /tests/remora-identity-tests.tar.zst --workspace-remap /workspace"]
|
||||||
|
|
||||||
FROM ${NODE_IMAGE} AS node-runtime
|
FROM ${NODE_IMAGE} AS node-runtime
|
||||||
|
|
||||||
FROM ${PLAYWRIGHT_IMAGE} AS web-dependencies
|
FROM ${PLAYWRIGHT_IMAGE} AS web-dependencies
|
||||||
|
|
|
||||||
5
Makefile
5
Makefile
|
|
@ -1,4 +1,4 @@
|
||||||
.PHONY: check-dependencies check-fixture-lib check-kind-sample check-durable check-coordination check-fast check-full check-fast-host check-full-host rust-check web-install web-check web-test-fast web-test-full
|
.PHONY: check-dependencies check-fixture-lib check-kind-sample check-durable check-coordination check-identity check-fast check-full check-fast-host check-full-host rust-check web-install web-check web-test-fast web-test-full
|
||||||
|
|
||||||
CONTAINER_ENGINE ?= nerdctl
|
CONTAINER_ENGINE ?= nerdctl
|
||||||
CONTAINER_RUN_FLAGS ?= --memory=16g --memory-swap=24g --cpus=4
|
CONTAINER_RUN_FLAGS ?= --memory=16g --memory-swap=24g --cpus=4
|
||||||
|
|
@ -61,3 +61,6 @@ check-durable:
|
||||||
|
|
||||||
check-coordination:
|
check-coordination:
|
||||||
./tests/kind/run-coordination-job.sh
|
./tests/kind/run-coordination-job.sh
|
||||||
|
|
||||||
|
check-identity:
|
||||||
|
./tests/kind/run-identity-job.sh
|
||||||
|
|
|
||||||
29
crates/remora-identity/Cargo.toml
Normal file
29
crates/remora-identity/Cargo.toml
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
[package]
|
||||||
|
name = "remora-identity"
|
||||||
|
edition.workspace = true
|
||||||
|
publish = false
|
||||||
|
rust-version.workspace = true
|
||||||
|
version.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
base64.workspace = true
|
||||||
|
jsonwebtoken = { version = "9.3.1", default-features = false, features = ["use_pem"] }
|
||||||
|
remora-domain.workspace = true
|
||||||
|
remora-durable.workspace = true
|
||||||
|
reqwest = { version = "0.12.28", default-features = false, features = [
|
||||||
|
"json",
|
||||||
|
"rustls-tls-webpki-roots",
|
||||||
|
] }
|
||||||
|
serde = { workspace = true, features = ["derive"] }
|
||||||
|
serde_json.workspace = true
|
||||||
|
sha2.workspace = true
|
||||||
|
sqlx.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
|
time.workspace = true
|
||||||
|
tokio.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
pkcs8 = { version = "0.10.2", features = ["pem"] }
|
||||||
|
rand = "0.8.5"
|
||||||
|
rsa = { version = "0.9.8", default-features = false, features = ["std", "pem"] }
|
||||||
|
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "sync", "time"] }
|
||||||
10
crates/remora-identity/migrations/0001_principals.sql
Normal file
10
crates/remora-identity/migrations/0001_principals.sql
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
-- Stable principal mapping from OIDC issuer+subject pairs.
|
||||||
|
-- Feature package remora-identity owns this schema.
|
||||||
|
|
||||||
|
CREATE TABLE principal_identities (
|
||||||
|
principal_id TEXT PRIMARY KEY,
|
||||||
|
issuer TEXT NOT NULL,
|
||||||
|
subject TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (issuer, subject)
|
||||||
|
);
|
||||||
109
crates/remora-identity/src/authz.rs
Normal file
109
crates/remora-identity/src/authz.rs
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
use remora_domain::{OpaqueId, StorageScope, Target};
|
||||||
|
|
||||||
|
use crate::error::IdentityError;
|
||||||
|
use crate::principal::Principal;
|
||||||
|
use crate::target::{DefaultTarget, TargetOverride, resolve_target};
|
||||||
|
|
||||||
|
/// Management operations governed by current authorization (architecture A-3).
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum ManagementOperation {
|
||||||
|
List,
|
||||||
|
Read,
|
||||||
|
Cancel,
|
||||||
|
SoftDelete,
|
||||||
|
Restore,
|
||||||
|
HardDelete,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Indistinguishable missing-or-unauthorized outcome for single-resource reads.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum ResourceVisibility {
|
||||||
|
Visible,
|
||||||
|
NotFound,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Minimal durable job projection needed for current-authorization decisions.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct AuthorizedResource {
|
||||||
|
pub owner: OpaqueId,
|
||||||
|
pub target: Target,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn can_target(principal: &Principal, target: &Target) -> bool {
|
||||||
|
if principal.administrator {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
principal.scopes.iter().any(|scope| scope.covers(target))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Canonicalize defaults/overrides then authorize the result for the principal.
|
||||||
|
///
|
||||||
|
/// Submission and other admission paths should use this rather than calling
|
||||||
|
/// [`resolve_target`] without a subsequent authorization check.
|
||||||
|
pub fn resolve_authorized_target(
|
||||||
|
principal: &Principal,
|
||||||
|
defaults: &DefaultTarget,
|
||||||
|
override_: &TargetOverride,
|
||||||
|
reserved_segments: &[&str],
|
||||||
|
) -> Result<Target, IdentityError> {
|
||||||
|
let target = resolve_target(defaults, override_, reserved_segments)?;
|
||||||
|
if can_target(principal, &target) {
|
||||||
|
Ok(target)
|
||||||
|
} else {
|
||||||
|
Err(IdentityError::UnauthorizedTarget)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn may_manage(
|
||||||
|
principal: &Principal,
|
||||||
|
resource: &AuthorizedResource,
|
||||||
|
_operation: ManagementOperation,
|
||||||
|
) -> bool {
|
||||||
|
if principal.administrator {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
principal.id == resource.owner && can_target(principal, &resource.target)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the principal is an administrator eligible to *request* revocation
|
||||||
|
/// of an accepted execution grant. Does not mutate grants; durable revoke and
|
||||||
|
/// cancellation mapping belong to admission/lifecycle packages.
|
||||||
|
#[must_use]
|
||||||
|
pub fn may_request_execution_grant_revocation(principal: &Principal) -> bool {
|
||||||
|
principal.administrator
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Single-resource access: missing and unauthorized are the same response class.
|
||||||
|
#[must_use]
|
||||||
|
pub fn resource_visibility(
|
||||||
|
principal: &Principal,
|
||||||
|
resource: Option<&AuthorizedResource>,
|
||||||
|
operation: ManagementOperation,
|
||||||
|
) -> ResourceVisibility {
|
||||||
|
match resource {
|
||||||
|
Some(resource) if may_manage(principal, resource, operation) => ResourceVisibility::Visible,
|
||||||
|
_ => ResourceVisibility::NotFound,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List filtering never reveals unauthorized existence.
|
||||||
|
pub fn filter_visible<'a>(
|
||||||
|
principal: &Principal,
|
||||||
|
resources: impl IntoIterator<Item = &'a AuthorizedResource>,
|
||||||
|
) -> Vec<&'a AuthorizedResource> {
|
||||||
|
resources
|
||||||
|
.into_iter()
|
||||||
|
.filter(|resource| may_manage(principal, resource, ManagementOperation::List))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn covering_scopes<'a>(principal: &'a Principal, target: &Target) -> Vec<&'a StorageScope> {
|
||||||
|
principal
|
||||||
|
.scopes
|
||||||
|
.iter()
|
||||||
|
.filter(|scope| scope.covers(target))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
40
crates/remora-identity/src/error.rs
Normal file
40
crates/remora-identity/src/error.rs
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
use remora_domain::{IdError, TargetStoreIdError, ValidationError};
|
||||||
|
use remora_durable::DurableError;
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum IdentityError {
|
||||||
|
#[error("invalid or untrusted bearer token")]
|
||||||
|
InvalidToken,
|
||||||
|
#[error("identity provider unavailable: {0}")]
|
||||||
|
ProviderUnavailable(String),
|
||||||
|
#[error("invalid role claim: {0}")]
|
||||||
|
InvalidRole(String),
|
||||||
|
#[error("invalid target: {0}")]
|
||||||
|
InvalidTarget(String),
|
||||||
|
#[error("target not authorized for principal")]
|
||||||
|
UnauthorizedTarget,
|
||||||
|
#[error("invalid principal identifier")]
|
||||||
|
InvalidPrincipal,
|
||||||
|
#[error("configuration error: {0}")]
|
||||||
|
Config(String),
|
||||||
|
#[error(transparent)]
|
||||||
|
Durable(#[from] DurableError),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ValidationError> for IdentityError {
|
||||||
|
fn from(error: ValidationError) -> Self {
|
||||||
|
Self::InvalidTarget(error.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<TargetStoreIdError> for IdentityError {
|
||||||
|
fn from(error: TargetStoreIdError) -> Self {
|
||||||
|
Self::InvalidTarget(error.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<IdError> for IdentityError {
|
||||||
|
fn from(_: IdError) -> Self {
|
||||||
|
Self::InvalidPrincipal
|
||||||
|
}
|
||||||
|
}
|
||||||
33
crates/remora-identity/src/lib.rs
Normal file
33
crates/remora-identity/src/lib.rs
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
//! Identity and current target authorization.
|
||||||
|
//!
|
||||||
|
//! Validates OIDC bearer tokens, maps issuer+subject pairs to stable principal
|
||||||
|
//! identifiers, decodes administrator and storage-scope roles, canonicalizes
|
||||||
|
//! concrete targets from deployment defaults, and applies current-authorization
|
||||||
|
//! filtering for management operations. Execution grants and job admission are
|
||||||
|
//! owned by later packages; this crate supplies the identity and policy surface
|
||||||
|
//! those packages compose.
|
||||||
|
|
||||||
|
mod authz;
|
||||||
|
mod error;
|
||||||
|
mod migrate;
|
||||||
|
mod oidc;
|
||||||
|
mod principal;
|
||||||
|
mod roles;
|
||||||
|
mod store;
|
||||||
|
mod target;
|
||||||
|
|
||||||
|
pub use authz::{
|
||||||
|
AuthorizedResource, ManagementOperation, ResourceVisibility, can_target, covering_scopes,
|
||||||
|
filter_visible, may_manage, may_request_execution_grant_revocation, resolve_authorized_target,
|
||||||
|
resource_visibility,
|
||||||
|
};
|
||||||
|
pub use error::IdentityError;
|
||||||
|
pub use migrate::{migrate, migrator};
|
||||||
|
pub use oidc::{OidcConfig, OidcIdentityProvider, ZITADEL_PROJECT_ROLES_CLAIM, roles_from_names};
|
||||||
|
pub use principal::{ExternalIdentity, Principal, principal_id_for};
|
||||||
|
pub use roles::{
|
||||||
|
ADMIN_ROLE, DecodedRoles, ROLE_GRAMMAR_VERSION, decode_roles, encode_admin_role,
|
||||||
|
encode_storage_role,
|
||||||
|
};
|
||||||
|
pub use store::{PrincipalRecord, load_by_external, load_by_id, resolve_principal};
|
||||||
|
pub use target::{DefaultTarget, TargetOverride, parse_default_target, resolve_target};
|
||||||
19
crates/remora-identity/src/migrate.rs
Normal file
19
crates/remora-identity/src/migrate.rs
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
use remora_durable::DurableError;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
/// Separate migration history from the DurableStore substrate so feature-package
|
||||||
|
/// versions do not collide with `remora-durable` in `_sqlx_migrations`.
|
||||||
|
const MIGRATION_TABLE: &str = "_remora_identity_migrations";
|
||||||
|
|
||||||
|
pub async fn migrate(pool: &PgPool) -> Result<(), DurableError> {
|
||||||
|
let mut migrator = sqlx::migrate!("./migrations");
|
||||||
|
migrator.dangerous_set_table_name(MIGRATION_TABLE);
|
||||||
|
migrator.run(pool).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn migrator() -> sqlx::migrate::Migrator {
|
||||||
|
let mut migrator = sqlx::migrate!("./migrations");
|
||||||
|
migrator.dangerous_set_table_name(MIGRATION_TABLE);
|
||||||
|
migrator
|
||||||
|
}
|
||||||
498
crates/remora-identity/src/oidc.rs
Normal file
498
crates/remora-identity/src/oidc.rs
Normal file
|
|
@ -0,0 +1,498 @@
|
||||||
|
//! OIDC bearer-token validation against discovery + JWKS.
|
||||||
|
//!
|
||||||
|
//! Validates signature, `iss`, `exp`, and a mandatory expected `aud`. Role
|
||||||
|
//! claims are taken only from explicitly configured sources: Keycloak
|
||||||
|
//! `resource_access[<client>]` and exact ZITADEL project-role claim names.
|
||||||
|
//! Remora authority is encoded only in namespaced `remora:v1:…` roles.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode, decode_header};
|
||||||
|
use remora_domain::ports::IdentityProvider;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::Value;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
use crate::error::IdentityError;
|
||||||
|
use crate::principal::ExternalIdentity;
|
||||||
|
use crate::roles::{DecodedRoles, decode_roles};
|
||||||
|
|
||||||
|
/// Default JWKS cache lifetime before re-fetch (key rotation / removal).
|
||||||
|
const DEFAULT_JWKS_CACHE_TTL: Duration = Duration::from_secs(300);
|
||||||
|
|
||||||
|
/// Well-known ZITADEL claim that carries project role keys when role assertion
|
||||||
|
/// is enabled for the application.
|
||||||
|
pub const ZITADEL_PROJECT_ROLES_CLAIM: &str = "urn:zitadel:iam:org:project:roles";
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct OidcConfig {
|
||||||
|
pub issuer: String,
|
||||||
|
/// Required expected `aud` claim (typically the OIDC client id).
|
||||||
|
pub audience: String,
|
||||||
|
/// Keycloak client id whose `resource_access` roles are trusted.
|
||||||
|
/// Defaults to `audience` when not overridden.
|
||||||
|
pub resource_access_client: String,
|
||||||
|
/// Exact JWT claim names that carry ZITADEL (or similar) project role maps.
|
||||||
|
pub project_role_claims: Vec<String>,
|
||||||
|
pub reserved_segments: Vec<String>,
|
||||||
|
pub http_timeout: Duration,
|
||||||
|
/// How long a successful JWKS fetch remains valid before revalidation.
|
||||||
|
pub jwks_cache_ttl: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OidcConfig {
|
||||||
|
/// Production constructor: non-empty issuer and audience are mandatory.
|
||||||
|
pub fn new(
|
||||||
|
issuer: impl Into<String>,
|
||||||
|
audience: impl Into<String>,
|
||||||
|
) -> Result<Self, IdentityError> {
|
||||||
|
let issuer = issuer.into();
|
||||||
|
let audience = audience.into();
|
||||||
|
if issuer.is_empty() {
|
||||||
|
return Err(IdentityError::Config("issuer must be non-empty".into()));
|
||||||
|
}
|
||||||
|
if audience.is_empty() {
|
||||||
|
return Err(IdentityError::Config("audience must be non-empty".into()));
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
resource_access_client: audience.clone(),
|
||||||
|
issuer,
|
||||||
|
audience,
|
||||||
|
project_role_claims: Vec::new(),
|
||||||
|
reserved_segments: Vec::new(),
|
||||||
|
http_timeout: Duration::from_secs(10),
|
||||||
|
jwks_cache_ttl: DEFAULT_JWKS_CACHE_TTL,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trust Keycloak client roles under a specific client id (defaults to audience).
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_resource_access_client(mut self, client_id: impl Into<String>) -> Self {
|
||||||
|
self.resource_access_client = client_id.into();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trust an exact ZITADEL project-role claim name (repeatable).
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_project_role_claim(mut self, claim: impl Into<String>) -> Self {
|
||||||
|
let claim = claim.into();
|
||||||
|
if !self
|
||||||
|
.project_role_claims
|
||||||
|
.iter()
|
||||||
|
.any(|existing| existing == &claim)
|
||||||
|
{
|
||||||
|
self.project_role_claims.push(claim);
|
||||||
|
}
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience: trust the standard ZITADEL project roles claim.
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_zitadel_project_roles(self) -> Self {
|
||||||
|
self.with_project_role_claim(ZITADEL_PROJECT_ROLES_CLAIM)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_reserved_segments(mut self, segments: impl IntoIterator<Item = String>) -> Self {
|
||||||
|
self.reserved_segments = segments.into_iter().collect();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_jwks_cache_ttl(mut self, ttl: Duration) -> Self {
|
||||||
|
self.jwks_cache_ttl = ttl;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct OidcIdentityProvider {
|
||||||
|
config: OidcConfig,
|
||||||
|
client: reqwest::Client,
|
||||||
|
state: Arc<RwLock<ProviderState>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct ProviderState {
|
||||||
|
jwks_uri: Option<String>,
|
||||||
|
keys: HashMap<String, DecodingKey>,
|
||||||
|
keys_fetched_at: Option<Instant>,
|
||||||
|
static_keys: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProviderState {
|
||||||
|
fn is_stale(&self, ttl: Duration) -> bool {
|
||||||
|
if self.static_keys {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
match self.keys_fetched_at {
|
||||||
|
Some(at) => at.elapsed() >= ttl,
|
||||||
|
None => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct DiscoveryDocument {
|
||||||
|
issuer: String,
|
||||||
|
jwks_uri: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct JwksDocument {
|
||||||
|
keys: Vec<Jwk>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct Jwk {
|
||||||
|
kid: Option<String>,
|
||||||
|
kty: String,
|
||||||
|
#[serde(rename = "use")]
|
||||||
|
use_: Option<String>,
|
||||||
|
alg: Option<String>,
|
||||||
|
n: Option<String>,
|
||||||
|
e: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct TokenClaims {
|
||||||
|
iss: String,
|
||||||
|
sub: String,
|
||||||
|
#[serde(default)]
|
||||||
|
aud: Audience,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
exp: i64,
|
||||||
|
#[serde(flatten)]
|
||||||
|
extra: HashMap<String, Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
enum Audience {
|
||||||
|
String(String),
|
||||||
|
List(Vec<String>),
|
||||||
|
#[default]
|
||||||
|
Missing,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OidcIdentityProvider {
|
||||||
|
pub fn new(config: OidcConfig) -> Result<Self, IdentityError> {
|
||||||
|
if config.audience.is_empty() {
|
||||||
|
return Err(IdentityError::Config("audience must be non-empty".into()));
|
||||||
|
}
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(config.http_timeout)
|
||||||
|
.build()
|
||||||
|
.map_err(|error| IdentityError::Config(error.to_string()))?;
|
||||||
|
Ok(Self {
|
||||||
|
config,
|
||||||
|
client,
|
||||||
|
state: Arc::new(RwLock::new(ProviderState::default())),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test and offline harness helper: install fixed RS256 keys without discovery.
|
||||||
|
pub fn with_static_keys(
|
||||||
|
config: OidcConfig,
|
||||||
|
keys: HashMap<String, DecodingKey>,
|
||||||
|
) -> Result<Self, IdentityError> {
|
||||||
|
let provider = Self::new(config)?;
|
||||||
|
let mut state = provider
|
||||||
|
.state
|
||||||
|
.try_write()
|
||||||
|
.map_err(|_| IdentityError::Config("provider state lock busy".into()))?;
|
||||||
|
state.keys = keys;
|
||||||
|
state.jwks_uri = Some("static://test-jwks".into());
|
||||||
|
state.keys_fetched_at = Some(Instant::now());
|
||||||
|
state.static_keys = true;
|
||||||
|
drop(state);
|
||||||
|
Ok(provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn authenticate_token(
|
||||||
|
&self,
|
||||||
|
bearer_token: &str,
|
||||||
|
) -> Result<ExternalIdentity, IdentityError> {
|
||||||
|
let token = bearer_token
|
||||||
|
.strip_prefix("Bearer ")
|
||||||
|
.or_else(|| bearer_token.strip_prefix("bearer "))
|
||||||
|
.unwrap_or(bearer_token)
|
||||||
|
.trim();
|
||||||
|
if token.is_empty() {
|
||||||
|
return Err(IdentityError::InvalidToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
let header = decode_header(token).map_err(|_| IdentityError::InvalidToken)?;
|
||||||
|
let kid = header.kid.ok_or(IdentityError::InvalidToken)?;
|
||||||
|
let key = self.decoding_key(&kid).await?;
|
||||||
|
|
||||||
|
let mut validation = Validation::new(Algorithm::RS256);
|
||||||
|
validation.set_issuer(std::slice::from_ref(&self.config.issuer));
|
||||||
|
validation.set_audience(std::slice::from_ref(&self.config.audience));
|
||||||
|
validation.leeway = 30;
|
||||||
|
|
||||||
|
let data = decode::<TokenClaims>(token, &key, &validation).map_err(|error| {
|
||||||
|
if is_provider_side(error.kind()) {
|
||||||
|
IdentityError::ProviderUnavailable(error.to_string())
|
||||||
|
} else {
|
||||||
|
IdentityError::InvalidToken
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
let claims = data.claims;
|
||||||
|
if claims.iss != self.config.issuer {
|
||||||
|
return Err(IdentityError::InvalidToken);
|
||||||
|
}
|
||||||
|
if !audience_matches(&claims.aud, &self.config.audience) {
|
||||||
|
return Err(IdentityError::InvalidToken);
|
||||||
|
}
|
||||||
|
if claims.sub.is_empty() {
|
||||||
|
return Err(IdentityError::InvalidToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
let role_names = extract_role_names(
|
||||||
|
&claims.extra,
|
||||||
|
&self.config.resource_access_client,
|
||||||
|
&self.config.project_role_claims,
|
||||||
|
);
|
||||||
|
let reserved: Vec<&str> = self
|
||||||
|
.config
|
||||||
|
.reserved_segments
|
||||||
|
.iter()
|
||||||
|
.map(String::as_str)
|
||||||
|
.collect();
|
||||||
|
let roles = decode_roles(role_names.iter().map(String::as_str), &reserved)?;
|
||||||
|
Ok(ExternalIdentity {
|
||||||
|
issuer: claims.iss,
|
||||||
|
subject: claims.sub,
|
||||||
|
roles,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn decoding_key(&self, kid: &str) -> Result<DecodingKey, IdentityError> {
|
||||||
|
{
|
||||||
|
let state = self.state.read().await;
|
||||||
|
if state.static_keys {
|
||||||
|
return state
|
||||||
|
.keys
|
||||||
|
.get(kid)
|
||||||
|
.cloned()
|
||||||
|
.ok_or(IdentityError::InvalidToken);
|
||||||
|
}
|
||||||
|
if !state.is_stale(self.config.jwks_cache_ttl)
|
||||||
|
&& let Some(key) = state.keys.get(kid)
|
||||||
|
{
|
||||||
|
return Ok(key.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.refresh_jwks().await?;
|
||||||
|
let state = self.state.read().await;
|
||||||
|
state
|
||||||
|
.keys
|
||||||
|
.get(kid)
|
||||||
|
.cloned()
|
||||||
|
.ok_or(IdentityError::InvalidToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn refresh_jwks(&self) -> Result<(), IdentityError> {
|
||||||
|
let jwks_uri = {
|
||||||
|
let state = self.state.read().await;
|
||||||
|
if state.static_keys {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
state.jwks_uri.clone()
|
||||||
|
};
|
||||||
|
let jwks_uri = match jwks_uri {
|
||||||
|
Some(uri) => uri,
|
||||||
|
None => self.discover().await?,
|
||||||
|
};
|
||||||
|
let document = self
|
||||||
|
.client
|
||||||
|
.get(&jwks_uri)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|error| IdentityError::ProviderUnavailable(error.to_string()))?
|
||||||
|
.error_for_status()
|
||||||
|
.map_err(|error| IdentityError::ProviderUnavailable(error.to_string()))?
|
||||||
|
.json::<JwksDocument>()
|
||||||
|
.await
|
||||||
|
.map_err(|error| IdentityError::ProviderUnavailable(error.to_string()))?;
|
||||||
|
|
||||||
|
let mut keys = HashMap::new();
|
||||||
|
for jwk in document.keys {
|
||||||
|
if jwk.kty != "RSA" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(use_) = &jwk.use_
|
||||||
|
&& use_ != "sig"
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(alg) = &jwk.alg
|
||||||
|
&& alg != "RS256"
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (Some(n), Some(e), Some(kid)) = (jwk.n, jwk.e, jwk.kid) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let key = DecodingKey::from_rsa_components(&n, &e)
|
||||||
|
.map_err(|error| IdentityError::ProviderUnavailable(error.to_string()))?;
|
||||||
|
keys.insert(kid, key);
|
||||||
|
}
|
||||||
|
if keys.is_empty() {
|
||||||
|
return Err(IdentityError::ProviderUnavailable(
|
||||||
|
"JWKS contained no usable RS256 signing keys".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.jwks_uri = Some(jwks_uri);
|
||||||
|
state.keys = keys;
|
||||||
|
state.keys_fetched_at = Some(Instant::now());
|
||||||
|
state.static_keys = false;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn discover(&self) -> Result<String, IdentityError> {
|
||||||
|
let url = format!(
|
||||||
|
"{}/.well-known/openid-configuration",
|
||||||
|
self.config.issuer.trim_end_matches('/')
|
||||||
|
);
|
||||||
|
let document = self
|
||||||
|
.client
|
||||||
|
.get(url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|error| IdentityError::ProviderUnavailable(error.to_string()))?
|
||||||
|
.error_for_status()
|
||||||
|
.map_err(|error| IdentityError::ProviderUnavailable(error.to_string()))?
|
||||||
|
.json::<DiscoveryDocument>()
|
||||||
|
.await
|
||||||
|
.map_err(|error| IdentityError::ProviderUnavailable(error.to_string()))?;
|
||||||
|
if document.issuer != self.config.issuer {
|
||||||
|
return Err(IdentityError::ProviderUnavailable(format!(
|
||||||
|
"discovery issuer {} does not match configured {}",
|
||||||
|
document.issuer, self.config.issuer
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.jwks_uri = Some(document.jwks_uri.clone());
|
||||||
|
Ok(document.jwks_uri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IdentityProvider for OidcIdentityProvider {
|
||||||
|
type Error = IdentityError;
|
||||||
|
type Principal = ExternalIdentity;
|
||||||
|
|
||||||
|
async fn authenticate(&self, bearer_token: &str) -> Result<Self::Principal, Self::Error> {
|
||||||
|
self.authenticate_token(bearer_token).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn audience_matches(aud: &Audience, expected: &str) -> bool {
|
||||||
|
match aud {
|
||||||
|
Audience::String(value) => value == expected,
|
||||||
|
Audience::List(values) => values.iter().any(|value| value == expected),
|
||||||
|
Audience::Missing => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract Remora role *names* only from configured Keycloak client roles and
|
||||||
|
/// exact project-role claim maps. Plain `roles`, realm roles, and unlisted
|
||||||
|
/// project claims are ignored.
|
||||||
|
fn extract_role_names(
|
||||||
|
extra: &HashMap<String, Value>,
|
||||||
|
resource_access_client: &str,
|
||||||
|
project_role_claims: &[String],
|
||||||
|
) -> Vec<String> {
|
||||||
|
let mut roles = Vec::new();
|
||||||
|
if let Some(Value::Object(resource_access)) = extra.get("resource_access")
|
||||||
|
&& let Some(Value::Object(client_roles)) = resource_access.get(resource_access_client)
|
||||||
|
&& let Some(Value::Array(values)) = client_roles.get("roles")
|
||||||
|
{
|
||||||
|
push_string_array(&mut roles, values);
|
||||||
|
}
|
||||||
|
for claim in project_role_claims {
|
||||||
|
if let Some(Value::Object(map)) = extra.get(claim) {
|
||||||
|
for role_key in map.keys() {
|
||||||
|
roles.push(role_key.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
roles.sort();
|
||||||
|
roles.dedup();
|
||||||
|
roles
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_string_array(roles: &mut Vec<String>, values: &[Value]) {
|
||||||
|
for value in values {
|
||||||
|
if let Value::String(role) = value {
|
||||||
|
roles.push(role.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_provider_side(kind: &jsonwebtoken::errors::ErrorKind) -> bool {
|
||||||
|
matches!(
|
||||||
|
kind,
|
||||||
|
jsonwebtoken::errors::ErrorKind::InvalidKeyFormat
|
||||||
|
| jsonwebtoken::errors::ErrorKind::InvalidRsaKey(_)
|
||||||
|
| jsonwebtoken::errors::ErrorKind::InvalidEcdsaKey
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn roles_from_names(
|
||||||
|
roles: &[&str],
|
||||||
|
reserved_segments: &[&str],
|
||||||
|
) -> Result<DecodedRoles, IdentityError> {
|
||||||
|
decode_roles(roles.iter().copied(), reserved_segments)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
fn claim_map(value: Value) -> HashMap<String, Value> {
|
||||||
|
match value {
|
||||||
|
Value::Object(map) => map.into_iter().collect(),
|
||||||
|
_ => panic!("expected object"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_configured_client_resource_access_is_trusted() {
|
||||||
|
let map = claim_map(json!({
|
||||||
|
"resource_access": {
|
||||||
|
"remora-client": { "roles": ["remora:v1:admin"] },
|
||||||
|
"evil-client": { "roles": ["remora:v1:admin"] }
|
||||||
|
},
|
||||||
|
"realm_access": { "roles": ["admin", "remora:v1:admin"] },
|
||||||
|
"roles": ["remora:v1:admin"]
|
||||||
|
}));
|
||||||
|
let roles = extract_role_names(&map, "remora-client", &[]);
|
||||||
|
assert_eq!(roles, vec!["remora:v1:admin".to_owned()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_configured_project_role_claims_are_trusted() {
|
||||||
|
let map = claim_map(json!({
|
||||||
|
"urn:zitadel:iam:org:project:roles": {
|
||||||
|
"remora:v1:admin": { "org": "x" }
|
||||||
|
},
|
||||||
|
"urn:zitadel:iam:org:project:999:roles": {
|
||||||
|
"remora:v1:admin": { "org": "x" }
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
let allowed = vec![ZITADEL_PROJECT_ROLES_CLAIM.to_owned()];
|
||||||
|
let roles = extract_role_names(&map, "unused", &allowed);
|
||||||
|
assert_eq!(roles, vec!["remora:v1:admin".to_owned()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_rejects_empty_audience() {
|
||||||
|
assert!(OidcConfig::new("https://issuer.example", "").is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
51
crates/remora-identity/src/principal.rs
Normal file
51
crates/remora-identity/src/principal.rs
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
use remora_domain::{OpaqueId, StorageScope};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
use crate::error::IdentityError;
|
||||||
|
use crate::roles::DecodedRoles;
|
||||||
|
|
||||||
|
/// Authenticated caller after token validation and role decoding.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct Principal {
|
||||||
|
pub id: OpaqueId,
|
||||||
|
pub issuer: String,
|
||||||
|
pub subject: String,
|
||||||
|
pub administrator: bool,
|
||||||
|
pub scopes: Vec<StorageScope>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OIDC identity pair before durable principal-id binding.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct ExternalIdentity {
|
||||||
|
pub issuer: String,
|
||||||
|
pub subject: String,
|
||||||
|
pub roles: DecodedRoles,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Principal {
|
||||||
|
pub fn from_external(id: OpaqueId, external: ExternalIdentity) -> Self {
|
||||||
|
Self {
|
||||||
|
id,
|
||||||
|
issuer: external.issuer,
|
||||||
|
subject: external.subject,
|
||||||
|
administrator: external.roles.administrator,
|
||||||
|
scopes: external.roles.scopes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deterministic stable principal id for one exact issuer+subject pair.
|
||||||
|
pub fn principal_id_for(issuer: &str, subject: &str) -> Result<OpaqueId, IdentityError> {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(b"remora-principal-v1\0");
|
||||||
|
hasher.update(issuer.as_bytes());
|
||||||
|
hasher.update([0]);
|
||||||
|
hasher.update(subject.as_bytes());
|
||||||
|
let digest = hasher.finalize();
|
||||||
|
let hex = digest
|
||||||
|
.iter()
|
||||||
|
.take(16)
|
||||||
|
.map(|byte| format!("{byte:02x}"))
|
||||||
|
.collect::<String>();
|
||||||
|
OpaqueId::parse(format!("p{hex}")).map_err(Into::into)
|
||||||
|
}
|
||||||
178
crates/remora-identity/src/roles.rs
Normal file
178
crates/remora-identity/src/roles.rs
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
//! Remora OIDC project-role grammar (`remora:v1`).
|
||||||
|
//!
|
||||||
|
//! - `remora:v1:admin` — deployment administrator
|
||||||
|
//! - `remora:v1:storage|<store_b64url>|<bucket_b64url>|<prefix_b64url>` — one storage scope
|
||||||
|
//!
|
||||||
|
//! The versioned `remora:v1:` prefix is part of every role key so generic IdP
|
||||||
|
//! roles such as `admin` never grant Remora authority. Store, bucket, and
|
||||||
|
//! prefix are unpadded base64url encodings of the UTF-8 canonical values. An
|
||||||
|
//! empty prefix means the complete bucket. Malformed roles are ignored so an
|
||||||
|
//! unrelated IdP role cannot fail authentication closed.
|
||||||
|
|
||||||
|
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||||
|
use remora_domain::{BucketName, StorageScope, TargetPrefix, TargetStoreId};
|
||||||
|
|
||||||
|
use crate::error::IdentityError;
|
||||||
|
|
||||||
|
pub const ROLE_GRAMMAR_VERSION: &str = "v1";
|
||||||
|
const ROLE_NAMESPACE: &str = "remora:v1:";
|
||||||
|
pub const ADMIN_ROLE: &str = "remora:v1:admin";
|
||||||
|
const STORAGE_PREFIX: &str = "remora:v1:storage|";
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||||
|
pub struct DecodedRoles {
|
||||||
|
pub administrator: bool,
|
||||||
|
pub scopes: Vec<StorageScope>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decode_roles<'a>(
|
||||||
|
roles: impl IntoIterator<Item = &'a str>,
|
||||||
|
reserved_segments: &[&str],
|
||||||
|
) -> Result<DecodedRoles, IdentityError> {
|
||||||
|
let mut administrator = false;
|
||||||
|
let mut scopes = Vec::new();
|
||||||
|
for role in roles {
|
||||||
|
if role == ADMIN_ROLE {
|
||||||
|
administrator = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(scope) = try_decode_storage_role(role, reserved_segments)?
|
||||||
|
&& !scopes.iter().any(|existing| existing == &scope)
|
||||||
|
{
|
||||||
|
scopes.push(scope);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scopes.sort_by(|left, right| {
|
||||||
|
(
|
||||||
|
left.store.as_str(),
|
||||||
|
left.bucket.as_str(),
|
||||||
|
left.prefix.canonical(),
|
||||||
|
)
|
||||||
|
.cmp(&(
|
||||||
|
right.store.as_str(),
|
||||||
|
right.bucket.as_str(),
|
||||||
|
right.prefix.canonical(),
|
||||||
|
))
|
||||||
|
});
|
||||||
|
Ok(DecodedRoles {
|
||||||
|
administrator,
|
||||||
|
scopes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn encode_admin_role() -> String {
|
||||||
|
ADMIN_ROLE.to_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn encode_storage_role(scope: &StorageScope) -> Result<String, IdentityError> {
|
||||||
|
Ok(format!(
|
||||||
|
"{STORAGE_PREFIX}{}|{}|{}",
|
||||||
|
encode_component(scope.store.as_str()),
|
||||||
|
encode_component(scope.bucket.as_str()),
|
||||||
|
encode_component(&scope.prefix.canonical()),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_decode_storage_role(
|
||||||
|
role: &str,
|
||||||
|
reserved_segments: &[&str],
|
||||||
|
) -> Result<Option<StorageScope>, IdentityError> {
|
||||||
|
let Some(rest) = role.strip_prefix(STORAGE_PREFIX) else {
|
||||||
|
// Reject unversioned `storage|…` and other non-Remora names.
|
||||||
|
let _ = ROLE_NAMESPACE;
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let parts: Vec<&str> = rest.splitn(3, '|').collect();
|
||||||
|
if parts.len() != 3 {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let store = match decode_component(parts[0]) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(_) => return Ok(None),
|
||||||
|
};
|
||||||
|
let bucket = match decode_component(parts[1]) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(_) => return Ok(None),
|
||||||
|
};
|
||||||
|
let prefix = match decode_component(parts[2]) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(_) => return Ok(None),
|
||||||
|
};
|
||||||
|
let store = match TargetStoreId::parse(store) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(_) => return Ok(None),
|
||||||
|
};
|
||||||
|
let bucket = match BucketName::parse(bucket) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(_) => return Ok(None),
|
||||||
|
};
|
||||||
|
let prefix = match TargetPrefix::parse(&prefix, reserved_segments.iter().copied()) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(_) => return Ok(None),
|
||||||
|
};
|
||||||
|
Ok(Some(StorageScope {
|
||||||
|
store,
|
||||||
|
bucket,
|
||||||
|
prefix,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_component(value: &str) -> String {
|
||||||
|
URL_SAFE_NO_PAD.encode(value.as_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_component(value: &str) -> Result<String, IdentityError> {
|
||||||
|
let bytes = URL_SAFE_NO_PAD
|
||||||
|
.decode(value.as_bytes())
|
||||||
|
.map_err(|_| IdentityError::InvalidRole("invalid base64url component".into()))?;
|
||||||
|
String::from_utf8(bytes)
|
||||||
|
.map_err(|_| IdentityError::InvalidRole("role component is not UTF-8".into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn scope(store: &str, bucket: &str, prefix: &str) -> StorageScope {
|
||||||
|
StorageScope {
|
||||||
|
store: TargetStoreId::parse(store).unwrap(),
|
||||||
|
bucket: BucketName::parse(bucket).unwrap(),
|
||||||
|
prefix: TargetPrefix::parse(prefix, []).unwrap(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn round_trips_storage_roles_including_unicode_prefix() {
|
||||||
|
let original = scope("target-main", "target-bucket", "caf\u{e9}/data");
|
||||||
|
let encoded = encode_storage_role(&original).unwrap();
|
||||||
|
assert!(encoded.starts_with("remora:v1:storage|"));
|
||||||
|
let decoded = decode_roles([encoded.as_str()], &[]).unwrap();
|
||||||
|
assert_eq!(decoded.scopes, vec![original]);
|
||||||
|
assert!(!decoded.administrator);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn namespaced_admin_required_foreign_admin_ignored() {
|
||||||
|
let storage = encode_storage_role(&scope("s1", "bucket-one", "models")).unwrap();
|
||||||
|
let decoded = decode_roles(["admin", "test-user", storage.as_str(), "other"], &[]).unwrap();
|
||||||
|
assert!(!decoded.administrator);
|
||||||
|
assert_eq!(decoded.scopes.len(), 1);
|
||||||
|
|
||||||
|
let with_admin = decode_roles([ADMIN_ROLE, storage.as_str()], &[]).unwrap();
|
||||||
|
assert!(with_admin.administrator);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_prefix_means_whole_bucket() {
|
||||||
|
let original = scope("target-main", "target-bucket", "");
|
||||||
|
let encoded = encode_storage_role(&original).unwrap();
|
||||||
|
let decoded = decode_roles([encoded.as_str()], &[]).unwrap();
|
||||||
|
assert_eq!(decoded.scopes[0].prefix.segments(), &[] as &[String]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unversioned_storage_role_is_ignored() {
|
||||||
|
let decoded = decode_roles(["storage|YQ|YnVja2V0|"], &[]).unwrap();
|
||||||
|
assert!(decoded.scopes.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
96
crates/remora-identity/src/store.rs
Normal file
96
crates/remora-identity/src/store.rs
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
use remora_domain::OpaqueId;
|
||||||
|
use remora_durable::DurableError;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use time::OffsetDateTime;
|
||||||
|
|
||||||
|
use crate::error::IdentityError;
|
||||||
|
use crate::principal::{ExternalIdentity, Principal, principal_id_for};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct PrincipalRecord {
|
||||||
|
pub principal_id: OpaqueId,
|
||||||
|
pub issuer: String,
|
||||||
|
pub subject: String,
|
||||||
|
pub created_at: OffsetDateTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve or create the durable principal mapping for an external identity.
|
||||||
|
pub async fn resolve_principal(
|
||||||
|
pool: &PgPool,
|
||||||
|
external: ExternalIdentity,
|
||||||
|
) -> Result<Principal, IdentityError> {
|
||||||
|
let principal_id = principal_id_for(&external.issuer, &external.subject)?;
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO principal_identities (principal_id, issuer, subject)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (issuer, subject) DO NOTHING",
|
||||||
|
)
|
||||||
|
.bind(principal_id.as_str())
|
||||||
|
.bind(&external.issuer)
|
||||||
|
.bind(&external.subject)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DurableError::from)?;
|
||||||
|
|
||||||
|
let record = load_by_external(pool, &external.issuer, &external.subject)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
IdentityError::Config("principal insert succeeded but row is missing".into())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Prefer the stored id so a future non-deterministic scheme remains stable.
|
||||||
|
Ok(Principal::from_external(record.principal_id, external))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_by_id(
|
||||||
|
pool: &PgPool,
|
||||||
|
principal_id: &OpaqueId,
|
||||||
|
) -> Result<Option<PrincipalRecord>, IdentityError> {
|
||||||
|
let row = sqlx::query_as::<_, PrincipalRow>(
|
||||||
|
"SELECT principal_id, issuer, subject, created_at
|
||||||
|
FROM principal_identities
|
||||||
|
WHERE principal_id = $1",
|
||||||
|
)
|
||||||
|
.bind(principal_id.as_str())
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DurableError::from)?;
|
||||||
|
row.map(PrincipalRow::into_record).transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_by_external(
|
||||||
|
pool: &PgPool,
|
||||||
|
issuer: &str,
|
||||||
|
subject: &str,
|
||||||
|
) -> Result<Option<PrincipalRecord>, IdentityError> {
|
||||||
|
let row = sqlx::query_as::<_, PrincipalRow>(
|
||||||
|
"SELECT principal_id, issuer, subject, created_at
|
||||||
|
FROM principal_identities
|
||||||
|
WHERE issuer = $1 AND subject = $2",
|
||||||
|
)
|
||||||
|
.bind(issuer)
|
||||||
|
.bind(subject)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.map_err(DurableError::from)?;
|
||||||
|
row.map(PrincipalRow::into_record).transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct PrincipalRow {
|
||||||
|
principal_id: String,
|
||||||
|
issuer: String,
|
||||||
|
subject: String,
|
||||||
|
created_at: OffsetDateTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PrincipalRow {
|
||||||
|
fn into_record(self) -> Result<PrincipalRecord, IdentityError> {
|
||||||
|
Ok(PrincipalRecord {
|
||||||
|
principal_id: OpaqueId::parse(self.principal_id)?,
|
||||||
|
issuer: self.issuer,
|
||||||
|
subject: self.subject,
|
||||||
|
created_at: self.created_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
56
crates/remora-identity/src/target.rs
Normal file
56
crates/remora-identity/src/target.rs
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
use remora_domain::{BucketName, Target, TargetPrefix, TargetStoreId};
|
||||||
|
|
||||||
|
use crate::error::IdentityError;
|
||||||
|
|
||||||
|
/// Configured deployment default concrete target used when a submission omits
|
||||||
|
/// bucket and/or prefix overrides.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct DefaultTarget {
|
||||||
|
pub store: TargetStoreId,
|
||||||
|
pub bucket: BucketName,
|
||||||
|
pub prefix: TargetPrefix,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||||
|
pub struct TargetOverride {
|
||||||
|
pub bucket: Option<String>,
|
||||||
|
pub prefix: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Canonicalize a concrete target from deployment defaults and optional
|
||||||
|
/// request overrides. Store identity is never caller-selected.
|
||||||
|
///
|
||||||
|
/// Validation only. For submission, use [`crate::resolve_authorized_target`]
|
||||||
|
/// so overrides cannot be accepted without a covering scope.
|
||||||
|
pub fn resolve_target(
|
||||||
|
defaults: &DefaultTarget,
|
||||||
|
override_: &TargetOverride,
|
||||||
|
reserved_segments: &[&str],
|
||||||
|
) -> Result<Target, IdentityError> {
|
||||||
|
let bucket = match override_.bucket.as_deref() {
|
||||||
|
Some(value) => BucketName::parse(value)?,
|
||||||
|
None => defaults.bucket.clone(),
|
||||||
|
};
|
||||||
|
let prefix = match override_.prefix.as_deref() {
|
||||||
|
Some(value) => TargetPrefix::parse(value, reserved_segments.iter().copied())?,
|
||||||
|
None => defaults.prefix.clone(),
|
||||||
|
};
|
||||||
|
Ok(Target {
|
||||||
|
store: defaults.store.clone(),
|
||||||
|
bucket,
|
||||||
|
prefix,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_default_target(
|
||||||
|
store: &str,
|
||||||
|
bucket: &str,
|
||||||
|
prefix: &str,
|
||||||
|
reserved_segments: &[&str],
|
||||||
|
) -> Result<DefaultTarget, IdentityError> {
|
||||||
|
Ok(DefaultTarget {
|
||||||
|
store: TargetStoreId::parse(store)?,
|
||||||
|
bucket: BucketName::parse(bucket)?,
|
||||||
|
prefix: TargetPrefix::parse(prefix, reserved_segments.iter().copied())?,
|
||||||
|
})
|
||||||
|
}
|
||||||
289
crates/remora-identity/tests/policy.rs
Normal file
289
crates/remora-identity/tests/policy.rs
Normal file
|
|
@ -0,0 +1,289 @@
|
||||||
|
use remora_domain::{BucketName, OpaqueId, StorageScope, Target, TargetPrefix, TargetStoreId};
|
||||||
|
use remora_identity::{
|
||||||
|
AuthorizedResource, DefaultTarget, IdentityError, ManagementOperation, ResourceVisibility,
|
||||||
|
TargetOverride, can_target, decode_roles, encode_admin_role, encode_storage_role,
|
||||||
|
filter_visible, may_manage, may_request_execution_grant_revocation, parse_default_target,
|
||||||
|
principal_id_for, resolve_authorized_target, resolve_target, resource_visibility,
|
||||||
|
roles_from_names,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn scope(store: &str, bucket: &str, prefix: &str) -> StorageScope {
|
||||||
|
StorageScope {
|
||||||
|
store: TargetStoreId::parse(store).unwrap(),
|
||||||
|
bucket: BucketName::parse(bucket).unwrap(),
|
||||||
|
prefix: TargetPrefix::parse(prefix, ["_remora"]).unwrap(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn target(store: &str, bucket: &str, prefix: &str) -> Target {
|
||||||
|
Target {
|
||||||
|
store: TargetStoreId::parse(store).unwrap(),
|
||||||
|
bucket: BucketName::parse(bucket).unwrap(),
|
||||||
|
prefix: TargetPrefix::parse(prefix, ["_remora"]).unwrap(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn principal(
|
||||||
|
label: &str,
|
||||||
|
administrator: bool,
|
||||||
|
scopes: Vec<StorageScope>,
|
||||||
|
) -> remora_identity::Principal {
|
||||||
|
remora_identity::Principal {
|
||||||
|
id: OpaqueId::parse(format!("principal-{label}-aaaa")).unwrap(),
|
||||||
|
issuer: format!("https://issuer.example/{label}"),
|
||||||
|
subject: format!("subject-{label}"),
|
||||||
|
administrator,
|
||||||
|
scopes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scope_segment_matching_rejects_textual_prefix_coincidence() {
|
||||||
|
let owner = principal(
|
||||||
|
"owner",
|
||||||
|
false,
|
||||||
|
vec![scope("target-main", "target-bucket", "research")],
|
||||||
|
);
|
||||||
|
assert!(can_target(
|
||||||
|
&owner,
|
||||||
|
&target("target-main", "target-bucket", "research/data")
|
||||||
|
));
|
||||||
|
assert!(!can_target(
|
||||||
|
&owner,
|
||||||
|
&target("target-main", "target-bucket", "researchers")
|
||||||
|
));
|
||||||
|
assert!(!can_target(
|
||||||
|
&owner,
|
||||||
|
&target("target-main", "target-bucket", "researc")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unicode_prefix_nfc_canonicalization_and_reserved_segments() {
|
||||||
|
let defaults = parse_default_target("target-main", "target-bucket", "", &["_remora"]).unwrap();
|
||||||
|
let resolved = resolve_target(
|
||||||
|
&defaults,
|
||||||
|
&TargetOverride {
|
||||||
|
bucket: None,
|
||||||
|
prefix: Some("cafe\u{0301}/models".into()),
|
||||||
|
},
|
||||||
|
&["_remora"],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resolved.prefix.canonical(), "caf\u{e9}/models");
|
||||||
|
|
||||||
|
let reserved = resolve_target(
|
||||||
|
&defaults,
|
||||||
|
&TargetOverride {
|
||||||
|
bucket: None,
|
||||||
|
prefix: Some("_remora/private".into()),
|
||||||
|
},
|
||||||
|
&["_remora"],
|
||||||
|
);
|
||||||
|
assert!(reserved.is_err());
|
||||||
|
|
||||||
|
let category_c = resolve_target(
|
||||||
|
&defaults,
|
||||||
|
&TargetOverride {
|
||||||
|
bucket: None,
|
||||||
|
prefix: Some("private/\u{e000}".into()),
|
||||||
|
},
|
||||||
|
&["_remora"],
|
||||||
|
);
|
||||||
|
assert!(category_c.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_target_applies_bucket_and_prefix_overrides() {
|
||||||
|
let defaults = DefaultTarget {
|
||||||
|
store: TargetStoreId::parse("target-main").unwrap(),
|
||||||
|
bucket: BucketName::parse("default-bucket").unwrap(),
|
||||||
|
prefix: TargetPrefix::parse("defaults", []).unwrap(),
|
||||||
|
};
|
||||||
|
let full_default = resolve_target(&defaults, &TargetOverride::default(), &[]).unwrap();
|
||||||
|
assert_eq!(full_default.bucket.as_str(), "default-bucket");
|
||||||
|
assert_eq!(full_default.prefix.canonical(), "defaults");
|
||||||
|
|
||||||
|
let overridden = resolve_target(
|
||||||
|
&defaults,
|
||||||
|
&TargetOverride {
|
||||||
|
bucket: Some("other-bucket".into()),
|
||||||
|
prefix: Some("models/current/".into()),
|
||||||
|
},
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(overridden.store.as_str(), "target-main");
|
||||||
|
assert_eq!(overridden.bucket.as_str(), "other-bucket");
|
||||||
|
assert_eq!(overridden.prefix.canonical(), "models/current");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_authorized_target_requires_covering_scope() {
|
||||||
|
let defaults = DefaultTarget {
|
||||||
|
store: TargetStoreId::parse("target-main").unwrap(),
|
||||||
|
bucket: BucketName::parse("target-bucket").unwrap(),
|
||||||
|
prefix: TargetPrefix::parse("defaults", []).unwrap(),
|
||||||
|
};
|
||||||
|
let scoped = principal(
|
||||||
|
"scoped",
|
||||||
|
false,
|
||||||
|
vec![scope("target-main", "target-bucket", "models")],
|
||||||
|
);
|
||||||
|
let ok = resolve_authorized_target(
|
||||||
|
&scoped,
|
||||||
|
&defaults,
|
||||||
|
&TargetOverride {
|
||||||
|
bucket: None,
|
||||||
|
prefix: Some("models/current".into()),
|
||||||
|
},
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(ok.prefix.canonical(), "models/current");
|
||||||
|
|
||||||
|
let denied = resolve_authorized_target(
|
||||||
|
&scoped,
|
||||||
|
&defaults,
|
||||||
|
&TargetOverride {
|
||||||
|
bucket: None,
|
||||||
|
prefix: Some("other".into()),
|
||||||
|
},
|
||||||
|
&[],
|
||||||
|
);
|
||||||
|
assert!(matches!(denied, Err(IdentityError::UnauthorizedTarget)));
|
||||||
|
|
||||||
|
let admin = principal("admin", true, vec![]);
|
||||||
|
let admin_ok = resolve_authorized_target(
|
||||||
|
&admin,
|
||||||
|
&defaults,
|
||||||
|
&TargetOverride {
|
||||||
|
bucket: Some("any-bucket".into()),
|
||||||
|
prefix: Some("anywhere".into()),
|
||||||
|
},
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(admin_ok.bucket.as_str(), "any-bucket");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn revocation_of_roles_removes_current_management_not_admin_revoke_for_owners() {
|
||||||
|
let target = target("target-main", "target-bucket", "models/current");
|
||||||
|
let resource = AuthorizedResource {
|
||||||
|
owner: OpaqueId::parse("principal-owner-aaaa").unwrap(),
|
||||||
|
target: target.clone(),
|
||||||
|
};
|
||||||
|
let with_scope = principal(
|
||||||
|
"owner",
|
||||||
|
false,
|
||||||
|
vec![scope("target-main", "target-bucket", "models")],
|
||||||
|
);
|
||||||
|
assert!(may_manage(
|
||||||
|
&with_scope,
|
||||||
|
&resource,
|
||||||
|
ManagementOperation::Cancel
|
||||||
|
));
|
||||||
|
assert!(!may_request_execution_grant_revocation(&with_scope));
|
||||||
|
|
||||||
|
let after_role_loss = principal("owner", false, vec![]);
|
||||||
|
assert!(!may_manage(
|
||||||
|
&after_role_loss,
|
||||||
|
&resource,
|
||||||
|
ManagementOperation::Read
|
||||||
|
));
|
||||||
|
assert_eq!(
|
||||||
|
resource_visibility(&after_role_loss, Some(&resource), ManagementOperation::Read),
|
||||||
|
ResourceVisibility::NotFound
|
||||||
|
);
|
||||||
|
|
||||||
|
let admin = principal("admin", true, vec![]);
|
||||||
|
assert!(may_manage(
|
||||||
|
&admin,
|
||||||
|
&resource,
|
||||||
|
ManagementOperation::HardDelete
|
||||||
|
));
|
||||||
|
assert!(may_request_execution_grant_revocation(&admin));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_and_unauthorized_resources_are_indistinguishable() {
|
||||||
|
let owner = principal(
|
||||||
|
"owner",
|
||||||
|
false,
|
||||||
|
vec![scope("target-main", "target-bucket", "")],
|
||||||
|
);
|
||||||
|
let other = principal(
|
||||||
|
"other",
|
||||||
|
false,
|
||||||
|
vec![scope("target-main", "target-bucket", "")],
|
||||||
|
);
|
||||||
|
let resource = AuthorizedResource {
|
||||||
|
owner: owner.id.clone(),
|
||||||
|
target: target("target-main", "target-bucket", "models"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let missing = resource_visibility(&owner, None, ManagementOperation::Read);
|
||||||
|
let unauthorized = resource_visibility(&other, Some(&resource), ManagementOperation::Read);
|
||||||
|
assert_eq!(missing, ResourceVisibility::NotFound);
|
||||||
|
assert_eq!(unauthorized, ResourceVisibility::NotFound);
|
||||||
|
assert_eq!(missing, unauthorized);
|
||||||
|
|
||||||
|
let visible = resource_visibility(&owner, Some(&resource), ManagementOperation::Read);
|
||||||
|
assert_eq!(visible, ResourceVisibility::Visible);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn list_filtering_hides_unauthorized_existence() {
|
||||||
|
let owner = principal(
|
||||||
|
"owner",
|
||||||
|
false,
|
||||||
|
vec![scope("target-main", "target-bucket", "models")],
|
||||||
|
);
|
||||||
|
let mine = AuthorizedResource {
|
||||||
|
owner: owner.id.clone(),
|
||||||
|
target: target("target-main", "target-bucket", "models/a"),
|
||||||
|
};
|
||||||
|
let outside = AuthorizedResource {
|
||||||
|
owner: owner.id.clone(),
|
||||||
|
target: target("target-main", "target-bucket", "other"),
|
||||||
|
};
|
||||||
|
let peer = AuthorizedResource {
|
||||||
|
owner: OpaqueId::parse("principal-peer-aaaaaa").unwrap(),
|
||||||
|
target: target("target-main", "target-bucket", "models/b"),
|
||||||
|
};
|
||||||
|
let visible = filter_visible(&owner, [&mine, &outside, &peer]);
|
||||||
|
assert_eq!(visible, vec![&mine]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn principal_ids_are_stable_for_issuer_subject_pairs() {
|
||||||
|
let first = principal_id_for("https://issuer.example", "user-1").unwrap();
|
||||||
|
let second = principal_id_for("https://issuer.example", "user-1").unwrap();
|
||||||
|
let other = principal_id_for("https://issuer.example", "user-2").unwrap();
|
||||||
|
assert_eq!(first, second);
|
||||||
|
assert_ne!(first, other);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn role_encoding_round_trip_and_foreign_roles_ignored() {
|
||||||
|
let storage =
|
||||||
|
encode_storage_role(&scope("target-main", "target-bucket", "models/current")).unwrap();
|
||||||
|
let decoded = decode_roles(
|
||||||
|
[encode_admin_role().as_str(), "test-user", storage.as_str()],
|
||||||
|
&["_remora"],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(decoded.administrator);
|
||||||
|
assert_eq!(decoded.scopes.len(), 1);
|
||||||
|
assert!(decoded.scopes[0].covers(&target(
|
||||||
|
"target-main",
|
||||||
|
"target-bucket",
|
||||||
|
"models/current/file"
|
||||||
|
)));
|
||||||
|
|
||||||
|
let roles = roles_from_names(&[remora_identity::ADMIN_ROLE], &[]).unwrap();
|
||||||
|
assert!(roles.administrator);
|
||||||
|
let bare = roles_from_names(&["admin"], &[]).unwrap();
|
||||||
|
assert!(!bare.administrator);
|
||||||
|
}
|
||||||
668
crates/remora-identity/tests/substrate.rs
Normal file
668
crates/remora-identity/tests/substrate.rs
Normal file
|
|
@ -0,0 +1,668 @@
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||||
|
use jsonwebtoken::{Algorithm, EncodingKey, Header, encode};
|
||||||
|
use remora_durable::migrate as durable_migrate;
|
||||||
|
use remora_durable::{PoolConfig, connect};
|
||||||
|
use remora_identity::{
|
||||||
|
OidcConfig, OidcIdentityProvider, migrate as identity_migrate, resolve_principal,
|
||||||
|
};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
fn database_url() -> Option<String> {
|
||||||
|
std::env::var("DATABASE_URL")
|
||||||
|
.ok()
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn oidc_issuer() -> Option<String> {
|
||||||
|
std::env::var("REMORA_OIDC_ISSUER")
|
||||||
|
.ok()
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn require_env() -> bool {
|
||||||
|
std::env::var("REMORA_IDENTITY_REQUIRE").is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn env_required(name: &str) -> String {
|
||||||
|
std::env::var(name)
|
||||||
|
.unwrap_or_else(|_| panic!("{name} is required when REMORA_IDENTITY_REQUIRE is set"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn test_pool() -> Option<PgPool> {
|
||||||
|
let Some(url) = database_url() else {
|
||||||
|
if require_env() {
|
||||||
|
panic!("DATABASE_URL is required when REMORA_IDENTITY_REQUIRE is set");
|
||||||
|
}
|
||||||
|
eprintln!("skipping identity durable test: DATABASE_URL is unset");
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
let pool = connect(
|
||||||
|
&url,
|
||||||
|
PoolConfig {
|
||||||
|
max_connections: 8,
|
||||||
|
acquire_timeout: Duration::from_secs(10),
|
||||||
|
idle_timeout: Some(Duration::from_secs(30)),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("connect postgres");
|
||||||
|
durable_migrate(&pool).await.expect("durable migrate");
|
||||||
|
identity_migrate(&pool).await.expect("identity migrate");
|
||||||
|
Some(pool)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn oidc_provider() -> OidcIdentityProvider {
|
||||||
|
let issuer = env_required("REMORA_OIDC_ISSUER");
|
||||||
|
let provider_kind = std::env::var("REMORA_OIDC_PROVIDER").unwrap_or_default();
|
||||||
|
// Keycloak ROPC tokens use the OIDC client id as `aud` (via audience mapper).
|
||||||
|
// ZITADEL JWT-profile access tokens assert the project resource as `aud`.
|
||||||
|
let audience = if provider_kind == "zitadel" {
|
||||||
|
env_required("REMORA_OIDC_PROJECT_ID")
|
||||||
|
} else {
|
||||||
|
env_required("REMORA_OIDC_CLIENT_ID")
|
||||||
|
};
|
||||||
|
let mut config = OidcConfig::new(issuer, audience).expect("oidc config");
|
||||||
|
if provider_kind == "zitadel" {
|
||||||
|
let project_id = env_required("REMORA_OIDC_PROJECT_ID");
|
||||||
|
config = config
|
||||||
|
.with_zitadel_project_roles()
|
||||||
|
// Preferred project-scoped claim shape (ZITADEL docs).
|
||||||
|
.with_project_role_claim(format!("urn:zitadel:iam:org:project:{project_id}:roles"))
|
||||||
|
.with_resource_access_client(env_required("REMORA_OIDC_CLIENT_ID"));
|
||||||
|
}
|
||||||
|
OidcIdentityProvider::new(config).expect("provider")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn principal_mapping_is_stable_and_idempotent() {
|
||||||
|
let Some(pool) = test_pool().await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let external = remora_identity::ExternalIdentity {
|
||||||
|
issuer: "https://issuer.example/lane".into(),
|
||||||
|
subject: "subject-stable-1".into(),
|
||||||
|
roles: remora_identity::DecodedRoles {
|
||||||
|
administrator: false,
|
||||||
|
scopes: vec![],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let first = resolve_principal(&pool, external.clone())
|
||||||
|
.await
|
||||||
|
.expect("first resolve");
|
||||||
|
let second = resolve_principal(&pool, external)
|
||||||
|
.await
|
||||||
|
.expect("second resolve");
|
||||||
|
assert_eq!(first.id, second.id);
|
||||||
|
assert_eq!(first.issuer, "https://issuer.example/lane");
|
||||||
|
assert_eq!(first.subject, "subject-stable-1");
|
||||||
|
|
||||||
|
let loaded = remora_identity::load_by_id(&pool, &first.id)
|
||||||
|
.await
|
||||||
|
.expect("load")
|
||||||
|
.expect("present");
|
||||||
|
assert_eq!(loaded.subject, "subject-stable-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn real_oidc_jwks_rejects_invalid_tokens() {
|
||||||
|
if oidc_issuer().is_none() {
|
||||||
|
if require_env() {
|
||||||
|
panic!("REMORA_OIDC_ISSUER is required when REMORA_IDENTITY_REQUIRE is set");
|
||||||
|
}
|
||||||
|
eprintln!("skipping oidc invalid-token test: REMORA_OIDC_ISSUER is unset");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let provider = oidc_provider();
|
||||||
|
let minted = mint_unsigned_looking_token();
|
||||||
|
let cases = [
|
||||||
|
"",
|
||||||
|
"not-a-jwt",
|
||||||
|
"Bearer ",
|
||||||
|
"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.e30.signature",
|
||||||
|
minted.as_str(),
|
||||||
|
];
|
||||||
|
for token in cases {
|
||||||
|
let result = provider.authenticate_token(token).await;
|
||||||
|
assert!(
|
||||||
|
matches!(result, Err(remora_identity::IdentityError::InvalidToken)),
|
||||||
|
"expected InvalidToken for {token:?}, got {result:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sequential mint then revoke: one fixture principal, one nextest process.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn provider_issued_token_and_grant_loss() {
|
||||||
|
if oidc_issuer().is_none() {
|
||||||
|
if require_env() {
|
||||||
|
panic!("REMORA_OIDC_ISSUER is required when REMORA_IDENTITY_REQUIRE is set");
|
||||||
|
}
|
||||||
|
eprintln!("skipping provider-issued token test: REMORA_OIDC_ISSUER is unset");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ensure_remora_grants()
|
||||||
|
.await
|
||||||
|
.expect("ensure remora grants before mint");
|
||||||
|
let provider = oidc_provider();
|
||||||
|
let before = mint_provider_token().await.expect("mint with grants");
|
||||||
|
let with_grants = match provider.authenticate_token(&before).await {
|
||||||
|
Ok(identity) => identity,
|
||||||
|
Err(error) => panic!(
|
||||||
|
"authenticate provider token: {error}; claims={}",
|
||||||
|
jwt_payload_preview(&before)
|
||||||
|
),
|
||||||
|
};
|
||||||
|
assert!(!with_grants.subject.is_empty());
|
||||||
|
assert_eq!(with_grants.issuer, env_required("REMORA_OIDC_ISSUER"));
|
||||||
|
assert!(
|
||||||
|
with_grants.roles.administrator || !with_grants.roles.scopes.is_empty(),
|
||||||
|
"expected remora roles on provider token, got {with_grants:?}; claims={}",
|
||||||
|
jwt_payload_preview(&before)
|
||||||
|
);
|
||||||
|
let storage_role = std::env::var("REMORA_OIDC_STORAGE_ROLE").unwrap_or_default();
|
||||||
|
if !storage_role.is_empty() {
|
||||||
|
assert!(
|
||||||
|
!with_grants.roles.scopes.is_empty(),
|
||||||
|
"expected storage scope from fixture role {storage_role}"
|
||||||
|
);
|
||||||
|
assert_eq!(with_grants.roles.scopes[0].store.as_str(), "target-main");
|
||||||
|
assert_eq!(with_grants.roles.scopes[0].bucket.as_str(), "target-bucket");
|
||||||
|
assert_eq!(with_grants.roles.scopes[0].prefix.canonical(), "models");
|
||||||
|
}
|
||||||
|
|
||||||
|
revoke_remora_grants().await.expect("revoke remora grants");
|
||||||
|
|
||||||
|
let after = mint_provider_token().await.expect("mint after revoke");
|
||||||
|
let without_grants = provider
|
||||||
|
.authenticate_token(&after)
|
||||||
|
.await
|
||||||
|
.expect("auth after revoke");
|
||||||
|
assert!(
|
||||||
|
!without_grants.roles.administrator,
|
||||||
|
"new token must not be administrator after grant loss"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
without_grants.roles.scopes.is_empty(),
|
||||||
|
"new token must not carry storage scopes after grant loss"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn mint_provider_token() -> Result<String, String> {
|
||||||
|
match env_required("REMORA_OIDC_PROVIDER").as_str() {
|
||||||
|
"keycloak" => mint_keycloak_password_token().await,
|
||||||
|
"zitadel" => mint_zitadel_jwt_bearer_token().await,
|
||||||
|
other => Err(format!("unsupported REMORA_OIDC_PROVIDER={other}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn mint_keycloak_password_token() -> Result<String, String> {
|
||||||
|
let issuer = env_required("REMORA_OIDC_ISSUER");
|
||||||
|
let client_id = env_required("REMORA_OIDC_CLIENT_ID");
|
||||||
|
let username = env_required("REMORA_OIDC_USERNAME");
|
||||||
|
let password = env_required("REMORA_OIDC_PASSWORD");
|
||||||
|
let token_url = format!(
|
||||||
|
"{}/protocol/openid-connect/token",
|
||||||
|
issuer.trim_end_matches('/')
|
||||||
|
);
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let response = client
|
||||||
|
.post(token_url)
|
||||||
|
.form(&[
|
||||||
|
("grant_type", "password"),
|
||||||
|
("client_id", client_id.as_str()),
|
||||||
|
("username", username.as_str()),
|
||||||
|
("password", password.as_str()),
|
||||||
|
("scope", "openid"),
|
||||||
|
])
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(format!(
|
||||||
|
"keycloak token endpoint: {} {}",
|
||||||
|
response.status(),
|
||||||
|
response.text().await.unwrap_or_default()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let body: Value = response.json().await.map_err(|error| error.to_string())?;
|
||||||
|
body.get("access_token")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::to_owned)
|
||||||
|
.ok_or_else(|| "keycloak token response missing access_token".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mint a provider-issued ZITADEL access token via JWT Profile (user application key).
|
||||||
|
async fn mint_zitadel_jwt_bearer_token() -> Result<String, String> {
|
||||||
|
let issuer = env_required("REMORA_OIDC_ISSUER");
|
||||||
|
let user_id = env_required("REMORA_OIDC_USER_ID");
|
||||||
|
let project_id = env_required("REMORA_OIDC_PROJECT_ID");
|
||||||
|
let key_b64 = env_required("REMORA_OIDC_USER_KEY_B64");
|
||||||
|
let key_json = URL_SAFE_NO_PAD
|
||||||
|
.decode(key_b64.as_bytes())
|
||||||
|
.or_else(|_| base64::engine::general_purpose::STANDARD.decode(key_b64.as_bytes()))
|
||||||
|
.map_err(|error| format!("decode user key: {error}"))?;
|
||||||
|
let key: Value =
|
||||||
|
serde_json::from_slice(&key_json).map_err(|error| format!("parse user key: {error}"))?;
|
||||||
|
let key_id = key
|
||||||
|
.get("keyId")
|
||||||
|
.or_else(|| key.get("key_id"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.ok_or("user key missing keyId")?
|
||||||
|
.to_owned();
|
||||||
|
let private_key = key
|
||||||
|
.get("key")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.ok_or("user key missing private key PEM")?
|
||||||
|
.replace("\\n", "\n");
|
||||||
|
let issuer = issuer.trim_end_matches('/').to_owned();
|
||||||
|
let token_url = format!("{issuer}/oauth/v2/token");
|
||||||
|
let now = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map_err(|error| error.to_string())?
|
||||||
|
.as_secs() as i64;
|
||||||
|
// Prefer userId from the key document when present.
|
||||||
|
let subject = key
|
||||||
|
.get("userId")
|
||||||
|
.or_else(|| key.get("userID"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or(user_id.as_str())
|
||||||
|
.to_owned();
|
||||||
|
let encoding = EncodingKey::from_rsa_pem(private_key.as_bytes()).map_err(|error| {
|
||||||
|
format!(
|
||||||
|
"user key pem: {error}; fields={:?}",
|
||||||
|
key.as_object().map(|o| o.keys().collect::<Vec<_>>())
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
// RFC 7523 assertion aud is the token endpoint; some builds also accept issuer.
|
||||||
|
let audiences = [token_url.clone(), issuer.clone()];
|
||||||
|
// Non-default port must appear in Host so issued iss matches REMORA_OIDC_ISSUER.
|
||||||
|
let host = issuer
|
||||||
|
.strip_prefix("http://")
|
||||||
|
.or_else(|| issuer.strip_prefix("https://"))
|
||||||
|
.unwrap_or(issuer.as_str());
|
||||||
|
let scope = format!(
|
||||||
|
"openid profile \
|
||||||
|
urn:zitadel:iam:org:project:id:{project_id}:aud \
|
||||||
|
urn:zitadel:iam:org:project:roles \
|
||||||
|
urn:zitadel:iam:org:projects:roles \
|
||||||
|
urn:zitadel:iam:org:project:{project_id}:roles"
|
||||||
|
);
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let mut last_error = String::new();
|
||||||
|
for audience in audiences {
|
||||||
|
let mut header = Header::new(Algorithm::RS256);
|
||||||
|
header.kid = Some(key_id.clone());
|
||||||
|
let assertion = match encode(
|
||||||
|
&header,
|
||||||
|
&json!({
|
||||||
|
"iss": subject,
|
||||||
|
"sub": subject,
|
||||||
|
"aud": audience,
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + 3600,
|
||||||
|
}),
|
||||||
|
&encoding,
|
||||||
|
) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => {
|
||||||
|
last_error = format!("sign assertion: {error}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let response = client
|
||||||
|
.post(&token_url)
|
||||||
|
.header("Host", host)
|
||||||
|
.form(&[
|
||||||
|
("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer"),
|
||||||
|
("assertion", assertion.as_str()),
|
||||||
|
("scope", scope.as_str()),
|
||||||
|
])
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
if response.status().is_success() {
|
||||||
|
let body: Value = response.json().await.map_err(|error| error.to_string())?;
|
||||||
|
// Prefer whichever JWT carries remora role keys; fall back to access_token.
|
||||||
|
let access = body.get("access_token").and_then(Value::as_str);
|
||||||
|
let id_token = body.get("id_token").and_then(Value::as_str);
|
||||||
|
for token in [access, id_token].into_iter().flatten() {
|
||||||
|
if jwt_payload_preview(token).contains("remora:v1:") {
|
||||||
|
return Ok(token.to_owned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(token) = access.or(id_token) {
|
||||||
|
return Ok(token.to_owned());
|
||||||
|
}
|
||||||
|
return Err(format!(
|
||||||
|
"zitadel token response missing tokens; body_keys={:?}",
|
||||||
|
body.as_object().map(|o| o.keys().collect::<Vec<_>>())
|
||||||
|
));
|
||||||
|
}
|
||||||
|
last_error = format!(
|
||||||
|
"aud={audience}: {} {}",
|
||||||
|
response.status(),
|
||||||
|
response.text().await.unwrap_or_default()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(format!("zitadel jwt-bearer token failed: {last_error}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ensure_remora_grants() -> Result<(), String> {
|
||||||
|
match env_required("REMORA_OIDC_PROVIDER").as_str() {
|
||||||
|
"keycloak" => ensure_keycloak_remora_client_roles().await,
|
||||||
|
"zitadel" => {
|
||||||
|
let storage_role = env_required("REMORA_OIDC_STORAGE_ROLE");
|
||||||
|
set_zitadel_user_grant_roles(&["remora:v1:admin", storage_role.as_str()]).await
|
||||||
|
}
|
||||||
|
other => Err(format!("unsupported REMORA_OIDC_PROVIDER={other}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn revoke_remora_grants() -> Result<(), String> {
|
||||||
|
match env_required("REMORA_OIDC_PROVIDER").as_str() {
|
||||||
|
"keycloak" => revoke_keycloak_remora_client_roles().await,
|
||||||
|
"zitadel" => set_zitadel_user_grant_roles(&["test-user"]).await,
|
||||||
|
other => Err(format!("unsupported REMORA_OIDC_PROVIDER={other}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn revoke_keycloak_remora_client_roles() -> Result<(), String> {
|
||||||
|
let (admin_token, realm, user_id, client_uuid, storage_role) =
|
||||||
|
keycloak_admin_role_context().await?;
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let roles_url = format!(
|
||||||
|
"http://keycloak.remora.test:8080/admin/realms/{realm}/users/{user_id}/role-mappings/clients/{client_uuid}"
|
||||||
|
);
|
||||||
|
let mapped: Value = client
|
||||||
|
.get(&roles_url)
|
||||||
|
.bearer_auth(&admin_token)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|error| error.to_string())?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
let remora_roles: Vec<Value> = mapped
|
||||||
|
.as_array()
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|role| {
|
||||||
|
role.get("name")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.is_some_and(|name| {
|
||||||
|
name == "remora:v1:admin"
|
||||||
|
|| name == storage_role
|
||||||
|
|| name.starts_with("remora:v1:")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if remora_roles.is_empty() {
|
||||||
|
return Err("keycloak user has no remora client roles to revoke".into());
|
||||||
|
}
|
||||||
|
let delete = client
|
||||||
|
.delete(&roles_url)
|
||||||
|
.bearer_auth(&admin_token)
|
||||||
|
.json(&remora_roles)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
if !delete.status().is_success() {
|
||||||
|
return Err(format!(
|
||||||
|
"keycloak delete client roles: {} {}",
|
||||||
|
delete.status(),
|
||||||
|
delete.text().await.unwrap_or_default()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_zitadel_user_grant_roles(role_keys: &[&str]) -> Result<(), String> {
|
||||||
|
let issuer = env_required("REMORA_OIDC_ISSUER");
|
||||||
|
let org_id = env_required("REMORA_OIDC_TENANT");
|
||||||
|
let user_id = env_required("REMORA_OIDC_USER_ID");
|
||||||
|
let project_id = env_required("REMORA_OIDC_PROJECT_ID");
|
||||||
|
let mgmt_token = env_required("REMORA_OIDC_MGMT_TOKEN");
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
let search_url = format!(
|
||||||
|
"{}/management/v1/users/grants/_search",
|
||||||
|
issuer.trim_end_matches('/')
|
||||||
|
);
|
||||||
|
let host = issuer
|
||||||
|
.trim_end_matches('/')
|
||||||
|
.strip_prefix("http://")
|
||||||
|
.or_else(|| issuer.trim_end_matches('/').strip_prefix("https://"))
|
||||||
|
.unwrap_or(issuer.trim_end_matches('/'));
|
||||||
|
let search = client
|
||||||
|
.post(search_url)
|
||||||
|
.header("Host", host)
|
||||||
|
.header("Authorization", format!("Bearer {mgmt_token}"))
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("x-zitadel-orgid", &org_id)
|
||||||
|
.json(&json!({
|
||||||
|
"queries": [{
|
||||||
|
"userIdQuery": { "userId": user_id }
|
||||||
|
}]
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
if !search.status().is_success() {
|
||||||
|
return Err(format!(
|
||||||
|
"zitadel search grants: {} {}",
|
||||||
|
search.status(),
|
||||||
|
search.text().await.unwrap_or_default()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let body: Value = search.json().await.map_err(|error| error.to_string())?;
|
||||||
|
let grants = body
|
||||||
|
.get("result")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
|
let grant = grants
|
||||||
|
.iter()
|
||||||
|
.find(|row| {
|
||||||
|
row.get("projectId")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.is_some_and(|id| id == project_id)
|
||||||
|
})
|
||||||
|
.ok_or("zitadel user project grant not found")?;
|
||||||
|
let grant_id = grant
|
||||||
|
.get("id")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.ok_or("zitadel grant missing id")?;
|
||||||
|
|
||||||
|
let update_url = format!(
|
||||||
|
"{}/management/v1/users/{user_id}/grants/{grant_id}",
|
||||||
|
issuer.trim_end_matches('/')
|
||||||
|
);
|
||||||
|
let update = client
|
||||||
|
.put(update_url)
|
||||||
|
.header("Host", host)
|
||||||
|
.header("Authorization", format!("Bearer {mgmt_token}"))
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("x-zitadel-orgid", &org_id)
|
||||||
|
.json(&json!({ "roleKeys": role_keys }))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
if !update.status().is_success() {
|
||||||
|
let status = update.status();
|
||||||
|
let body = update.text().await.unwrap_or_default();
|
||||||
|
// Idempotent ensure: already-correct grants are rejected as unchanged.
|
||||||
|
if status.as_u16() == 400 && body.contains("User grant has not been changed") {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
return Err(format!("zitadel update grant: {status} {body}"));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ensure_keycloak_remora_client_roles() -> Result<(), String> {
|
||||||
|
let (admin_token, realm, user_id, client_uuid, storage_role) =
|
||||||
|
keycloak_admin_role_context().await?;
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let available_url = format!(
|
||||||
|
"http://keycloak.remora.test:8080/admin/realms/{realm}/users/{user_id}/role-mappings/clients/{client_uuid}/available"
|
||||||
|
);
|
||||||
|
let available: Value = client
|
||||||
|
.get(available_url)
|
||||||
|
.bearer_auth(&admin_token)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|error| error.to_string())?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
let wanted: Vec<Value> = available
|
||||||
|
.as_array()
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|role| {
|
||||||
|
role.get("name")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.is_some_and(|name| name == "remora:v1:admin" || name == storage_role)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if wanted.is_empty() {
|
||||||
|
// Roles already mapped (or missing from the client entirely).
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let roles_url = format!(
|
||||||
|
"http://keycloak.remora.test:8080/admin/realms/{realm}/users/{user_id}/role-mappings/clients/{client_uuid}"
|
||||||
|
);
|
||||||
|
let post = client
|
||||||
|
.post(roles_url)
|
||||||
|
.bearer_auth(&admin_token)
|
||||||
|
.json(&wanted)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
if !post.status().is_success() {
|
||||||
|
return Err(format!(
|
||||||
|
"keycloak assign client roles: {} {}",
|
||||||
|
post.status(),
|
||||||
|
post.text().await.unwrap_or_default()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn keycloak_admin_role_context() -> Result<(String, String, String, String, String), String> {
|
||||||
|
let realm = env_required("REMORA_OIDC_TENANT");
|
||||||
|
let client_id = env_required("REMORA_OIDC_CLIENT_ID");
|
||||||
|
let username = env_required("REMORA_OIDC_USERNAME");
|
||||||
|
let storage_role = env_required("REMORA_OIDC_STORAGE_ROLE");
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
let admin_token_url =
|
||||||
|
"http://keycloak.remora.test:8080/realms/master/protocol/openid-connect/token";
|
||||||
|
let admin_token_response = client
|
||||||
|
.post(admin_token_url)
|
||||||
|
.form(&[
|
||||||
|
("grant_type", "password"),
|
||||||
|
("client_id", "admin-cli"),
|
||||||
|
("username", "remora-admin"),
|
||||||
|
("password", "synthetic-keycloak"),
|
||||||
|
])
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
if !admin_token_response.status().is_success() {
|
||||||
|
return Err(format!(
|
||||||
|
"keycloak admin token: {} {}",
|
||||||
|
admin_token_response.status(),
|
||||||
|
admin_token_response.text().await.unwrap_or_default()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let admin_body: Value = admin_token_response
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
let admin_token = admin_body
|
||||||
|
.get("access_token")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.ok_or("keycloak admin token missing access_token")?
|
||||||
|
.to_owned();
|
||||||
|
|
||||||
|
let users_url = format!(
|
||||||
|
"http://keycloak.remora.test:8080/admin/realms/{realm}/users?username={username}&exact=true"
|
||||||
|
);
|
||||||
|
let users: Value = client
|
||||||
|
.get(users_url)
|
||||||
|
.bearer_auth(&admin_token)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|error| error.to_string())?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
let user_id = users
|
||||||
|
.as_array()
|
||||||
|
.and_then(|rows| rows.first())
|
||||||
|
.and_then(|row| row.get("id"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.ok_or("keycloak user not found")?
|
||||||
|
.to_owned();
|
||||||
|
|
||||||
|
let clients_url = format!(
|
||||||
|
"http://keycloak.remora.test:8080/admin/realms/{realm}/clients?clientId={client_id}"
|
||||||
|
);
|
||||||
|
let clients: Value = client
|
||||||
|
.get(clients_url)
|
||||||
|
.bearer_auth(&admin_token)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|error| error.to_string())?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
let client_uuid = clients
|
||||||
|
.as_array()
|
||||||
|
.and_then(|rows| rows.first())
|
||||||
|
.and_then(|row| row.get("id"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.ok_or("keycloak client not found")?
|
||||||
|
.to_owned();
|
||||||
|
|
||||||
|
Ok((admin_token, realm, user_id, client_uuid, storage_role))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn jwt_payload_preview(token: &str) -> String {
|
||||||
|
let parts: Vec<&str> = token.split('.').collect();
|
||||||
|
if parts.len() != 3 {
|
||||||
|
return format!("<not-a-jwt parts={} len={}>", parts.len(), token.len());
|
||||||
|
}
|
||||||
|
let Ok(bytes) = URL_SAFE_NO_PAD
|
||||||
|
.decode(parts[1].as_bytes())
|
||||||
|
.or_else(|_| base64::engine::general_purpose::STANDARD.decode(parts[1].as_bytes()))
|
||||||
|
else {
|
||||||
|
return "<payload-decode-failed>".into();
|
||||||
|
};
|
||||||
|
match String::from_utf8(bytes) {
|
||||||
|
Ok(text) => text,
|
||||||
|
Err(_) => "<payload-not-utf8>".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mint_unsigned_looking_token() -> String {
|
||||||
|
let header = Header::new(Algorithm::HS256);
|
||||||
|
let claims = json!({
|
||||||
|
"iss": "https://attacker.example",
|
||||||
|
"sub": "attacker",
|
||||||
|
"aud": "remora-client",
|
||||||
|
"exp": 4_102_444_800_i64
|
||||||
|
});
|
||||||
|
encode(
|
||||||
|
&header,
|
||||||
|
&claims,
|
||||||
|
&EncodingKey::from_secret(b"not-the-fixture-secret"),
|
||||||
|
)
|
||||||
|
.expect("mint")
|
||||||
|
}
|
||||||
163
crates/remora-identity/tests/token_validation.rs
Normal file
163
crates/remora-identity/tests/token_validation.rs
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, encode};
|
||||||
|
use remora_domain::{BucketName, StorageScope, TargetPrefix, TargetStoreId};
|
||||||
|
use remora_identity::{
|
||||||
|
OidcConfig, OidcIdentityProvider, ZITADEL_PROJECT_ROLES_CLAIM, encode_admin_role,
|
||||||
|
encode_storage_role,
|
||||||
|
};
|
||||||
|
use rsa::RsaPrivateKey;
|
||||||
|
use rsa::pkcs8::{EncodePrivateKey, EncodePublicKey, LineEnding};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
struct EphemeralRsa {
|
||||||
|
encoding: EncodingKey,
|
||||||
|
decoding: DecodingKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
static EPHEMERAL_RSA: LazyLock<EphemeralRsa> = LazyLock::new(|| {
|
||||||
|
let mut rng = rand::thread_rng();
|
||||||
|
let private = RsaPrivateKey::new(&mut rng, 2048).expect("generate ephemeral RSA key");
|
||||||
|
let private_pem = private
|
||||||
|
.to_pkcs8_pem(LineEnding::LF)
|
||||||
|
.expect("encode private pem");
|
||||||
|
let public_pem = private
|
||||||
|
.to_public_key()
|
||||||
|
.to_public_key_pem(LineEnding::LF)
|
||||||
|
.expect("encode public pem");
|
||||||
|
EphemeralRsa {
|
||||||
|
encoding: EncodingKey::from_rsa_pem(private_pem.as_bytes()).expect("encoding key"),
|
||||||
|
decoding: DecodingKey::from_rsa_pem(public_pem.as_bytes()).expect("decoding key"),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
fn storage_scope() -> StorageScope {
|
||||||
|
StorageScope {
|
||||||
|
store: TargetStoreId::parse("target-main").unwrap(),
|
||||||
|
bucket: BucketName::parse("target-bucket").unwrap(),
|
||||||
|
prefix: TargetPrefix::parse("models", []).unwrap(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn provider(with_zitadel_claim: bool) -> OidcIdentityProvider {
|
||||||
|
let mut keys = HashMap::new();
|
||||||
|
keys.insert("test-kid".into(), EPHEMERAL_RSA.decoding.clone());
|
||||||
|
let mut config = OidcConfig::new("https://issuer.example", "remora-client").expect("config");
|
||||||
|
if with_zitadel_claim {
|
||||||
|
config = config.with_zitadel_project_roles();
|
||||||
|
}
|
||||||
|
OidcIdentityProvider::with_static_keys(config, keys).expect("provider")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mint(claims: serde_json::Value) -> String {
|
||||||
|
let mut header = Header::new(Algorithm::RS256);
|
||||||
|
header.kid = Some("test-kid".into());
|
||||||
|
encode(&header, &claims, &EPHEMERAL_RSA.encoding).expect("encode")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn valid_token_maps_roles_from_configured_zitadel_claim() {
|
||||||
|
let storage = encode_storage_role(&storage_scope()).unwrap();
|
||||||
|
let admin = encode_admin_role();
|
||||||
|
let mut project_roles = serde_json::Map::new();
|
||||||
|
project_roles.insert(admin, json!({ "org-1": "example.local" }));
|
||||||
|
project_roles.insert(storage, json!({ "org-1": "example.local" }));
|
||||||
|
let token = mint(json!({
|
||||||
|
"iss": "https://issuer.example",
|
||||||
|
"sub": "user-12345678",
|
||||||
|
"aud": "remora-client",
|
||||||
|
"exp": 4_102_444_800_i64,
|
||||||
|
ZITADEL_PROJECT_ROLES_CLAIM: project_roles,
|
||||||
|
// Untrusted sources must not grant authority.
|
||||||
|
"realm_access": { "roles": ["admin", "remora:v1:admin"] },
|
||||||
|
"roles": ["remora:v1:admin"],
|
||||||
|
}));
|
||||||
|
let identity = provider(true)
|
||||||
|
.authenticate_token(&token)
|
||||||
|
.await
|
||||||
|
.expect("valid token");
|
||||||
|
assert_eq!(identity.subject, "user-12345678");
|
||||||
|
assert!(identity.roles.administrator);
|
||||||
|
assert_eq!(identity.roles.scopes.len(), 1);
|
||||||
|
assert_eq!(identity.roles.scopes[0].prefix.canonical(), "models");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn unconfigured_project_claim_and_bare_admin_do_not_grant() {
|
||||||
|
let mut project_roles = serde_json::Map::new();
|
||||||
|
project_roles.insert(encode_admin_role(), json!({ "org-1": "example.local" }));
|
||||||
|
let token = mint(json!({
|
||||||
|
"iss": "https://issuer.example",
|
||||||
|
"sub": "user-12345678",
|
||||||
|
"aud": "remora-client",
|
||||||
|
"exp": 4_102_444_800_i64,
|
||||||
|
"urn:zitadel:iam:org:project:other:roles": project_roles,
|
||||||
|
"resource_access": {
|
||||||
|
"evil-client": { "roles": ["remora:v1:admin"] }
|
||||||
|
},
|
||||||
|
"realm_access": { "roles": ["admin"] },
|
||||||
|
}));
|
||||||
|
let identity = provider(true)
|
||||||
|
.authenticate_token(&token)
|
||||||
|
.await
|
||||||
|
.expect("valid token without remora grants");
|
||||||
|
assert!(!identity.roles.administrator);
|
||||||
|
assert!(identity.roles.scopes.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn keycloak_resource_access_client_roles_decode() {
|
||||||
|
let token = mint(json!({
|
||||||
|
"iss": "https://issuer.example",
|
||||||
|
"sub": "kc-user-0001",
|
||||||
|
"aud": ["remora-client", "account"],
|
||||||
|
"exp": 4_102_444_800_i64,
|
||||||
|
"resource_access": {
|
||||||
|
"remora-client": { "roles": [encode_admin_role()] },
|
||||||
|
"account": { "roles": ["manage-account"] }
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
let identity = provider(false)
|
||||||
|
.authenticate_token(&token)
|
||||||
|
.await
|
||||||
|
.expect("valid keycloak-shaped token");
|
||||||
|
assert!(identity.roles.administrator);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn invalid_issuer_audience_exp_and_signature_fail_closed() {
|
||||||
|
let good_base = json!({
|
||||||
|
"iss": "https://issuer.example",
|
||||||
|
"sub": "user-12345678",
|
||||||
|
"aud": "remora-client",
|
||||||
|
"exp": 4_102_444_800_i64,
|
||||||
|
"resource_access": {
|
||||||
|
"remora-client": { "roles": [encode_admin_role()] }
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let wrong_issuer = {
|
||||||
|
let mut claims = good_base.clone();
|
||||||
|
claims["iss"] = json!("https://evil.example");
|
||||||
|
mint(claims)
|
||||||
|
};
|
||||||
|
let wrong_aud = {
|
||||||
|
let mut claims = good_base.clone();
|
||||||
|
claims["aud"] = json!("other-client");
|
||||||
|
mint(claims)
|
||||||
|
};
|
||||||
|
let expired = {
|
||||||
|
let mut claims = good_base.clone();
|
||||||
|
claims["exp"] = json!(1_000_000_i64);
|
||||||
|
mint(claims)
|
||||||
|
};
|
||||||
|
|
||||||
|
for token in [wrong_issuer, wrong_aud, expired, "totally-not-a-jwt".into()] {
|
||||||
|
let result = provider(false).authenticate_token(&token).await;
|
||||||
|
assert!(
|
||||||
|
matches!(result, Err(remora_identity::IdentityError::InvalidToken)),
|
||||||
|
"token should be rejected: {result:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
40
tests/kind/identity-job.yaml.in
Normal file
40
tests/kind/identity-job.yaml.in
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
apiVersion: batch/v1
|
||||||
|
kind: Job
|
||||||
|
metadata:
|
||||||
|
name: @@JOB_NAME@@
|
||||||
|
namespace: remora-testbed
|
||||||
|
spec:
|
||||||
|
activeDeadlineSeconds: 600
|
||||||
|
backoffLimit: 0
|
||||||
|
ttlSecondsAfterFinished: 300
|
||||||
|
template:
|
||||||
|
spec:
|
||||||
|
automountServiceAccountToken: false
|
||||||
|
enableServiceLinks: false
|
||||||
|
restartPolicy: Never
|
||||||
|
dnsPolicy: None
|
||||||
|
dnsConfig:
|
||||||
|
nameservers: [10.96.0.53]
|
||||||
|
searches: [remora-testbed.svc.cluster.local, svc.cluster.local, cluster.local]
|
||||||
|
options:
|
||||||
|
- {name: ndots, value: "5"}
|
||||||
|
containers:
|
||||||
|
- name: tests
|
||||||
|
image: @@TEST_IMAGE@@
|
||||||
|
imagePullPolicy: Never
|
||||||
|
envFrom:
|
||||||
|
- configMapRef: {name: @@PROFILE_NAME@@}
|
||||||
|
env:
|
||||||
|
- name: REMORA_IDENTITY_REQUIRE
|
||||||
|
value: "1"
|
||||||
|
volumeMounts:
|
||||||
|
- {name: fixture-profile, mountPath: /run/remora-fixture, readOnly: true}
|
||||||
|
resources:
|
||||||
|
requests: {cpu: 250m, memory: 256Mi}
|
||||||
|
limits: {cpu: "2", memory: 1Gi}
|
||||||
|
volumes:
|
||||||
|
- name: fixture-profile
|
||||||
|
configMap:
|
||||||
|
name: @@PROFILE_NAME@@-file
|
||||||
|
items:
|
||||||
|
- {key: profile.env, path: profile.env}
|
||||||
|
|
@ -451,11 +451,26 @@ reset_lane_target_store() {
|
||||||
aws --endpoint-url "$endpoint" s3api create-bucket --bucket "$bucket" >&2 || return
|
aws --endpoint-url "$endpoint" s3api create-bucket --bucket "$bucket" >&2 || return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Remora storage role for fixture lanes: target-main / target-bucket / models.
|
||||||
|
remora_fixture_storage_role() {
|
||||||
|
local b64url
|
||||||
|
b64url() {
|
||||||
|
printf '%s' "$1" | openssl base64 -A | tr '+/' '-_' | tr -d '='
|
||||||
|
}
|
||||||
|
printf 'remora:v1:storage|%s|%s|%s\n' \
|
||||||
|
"$(b64url target-main)" \
|
||||||
|
"$(b64url target-bucket)" \
|
||||||
|
"$(b64url models)"
|
||||||
|
}
|
||||||
|
|
||||||
reset_keycloak_lane() {
|
reset_keycloak_lane() {
|
||||||
local lane="$1"
|
local lane="$1"
|
||||||
local realm="remora-lane-$lane"
|
local realm="remora-lane-$lane"
|
||||||
local username="remora-user-$lane"
|
local username="remora-user-$lane"
|
||||||
local client_id="remora-client-$lane"
|
local client_id="remora-client-$lane"
|
||||||
|
local storage_role
|
||||||
|
local client_uuid
|
||||||
|
storage_role="$(remora_fixture_storage_role)"
|
||||||
if kube --namespace "$FIXTURE_NAMESPACE" exec deployment/keycloak -- \
|
if kube --namespace "$FIXTURE_NAMESPACE" exec deployment/keycloak -- \
|
||||||
/opt/keycloak/bin/kcadm.sh get "realms/$realm" \
|
/opt/keycloak/bin/kcadm.sh get "realms/$realm" \
|
||||||
--no-config \
|
--no-config \
|
||||||
|
|
@ -473,7 +488,26 @@ reset_keycloak_lane() {
|
||||||
--user remora-admin \
|
--user remora-admin \
|
||||||
--password synthetic-keycloak \
|
--password synthetic-keycloak \
|
||||||
--set "realm=$realm" \
|
--set "realm=$realm" \
|
||||||
--set enabled=true >&2 || return
|
--set enabled=true \
|
||||||
|
--set registrationEmailAsUsername=false \
|
||||||
|
--set verifyEmail=false \
|
||||||
|
--set resetPasswordAllowed=false \
|
||||||
|
--set loginWithEmailAllowed=true \
|
||||||
|
--set duplicateEmailsAllowed=false \
|
||||||
|
--set sslRequired=none >&2 || return
|
||||||
|
# Keycloak 26 enables VERIFY_PROFILE by default; that blocks ROPC with
|
||||||
|
# "Account is not fully set up".
|
||||||
|
kube --namespace "$FIXTURE_NAMESPACE" exec deployment/keycloak -- \
|
||||||
|
/opt/keycloak/bin/kcadm.sh update authentication/required-actions/VERIFY_PROFILE \
|
||||||
|
--no-config \
|
||||||
|
--server http://127.0.0.1:8080 \
|
||||||
|
--realm master \
|
||||||
|
--user remora-admin \
|
||||||
|
--password synthetic-keycloak \
|
||||||
|
-r "$realm" \
|
||||||
|
--set enabled=false \
|
||||||
|
--set defaultAction=false >&2 || true
|
||||||
|
# Public client with direct access grants so package Jobs can mint ROPC tokens.
|
||||||
kube --namespace "$FIXTURE_NAMESPACE" exec deployment/keycloak -- \
|
kube --namespace "$FIXTURE_NAMESPACE" exec deployment/keycloak -- \
|
||||||
/opt/keycloak/bin/kcadm.sh create clients \
|
/opt/keycloak/bin/kcadm.sh create clients \
|
||||||
--no-config \
|
--no-config \
|
||||||
|
|
@ -485,7 +519,38 @@ reset_keycloak_lane() {
|
||||||
--set "clientId=$client_id" \
|
--set "clientId=$client_id" \
|
||||||
--set publicClient=true \
|
--set publicClient=true \
|
||||||
--set standardFlowEnabled=true \
|
--set standardFlowEnabled=true \
|
||||||
--set 'redirectUris=["http://localhost/callback"]' >&2 || return
|
--set directAccessGrantsEnabled=true \
|
||||||
|
--set 'redirectUris=["http://localhost/callback"]' \
|
||||||
|
--set 'attributes={"access.token.lifespan":"3600"}' >&2 || return
|
||||||
|
client_uuid="$(kube --namespace "$FIXTURE_NAMESPACE" exec deployment/keycloak -- \
|
||||||
|
/opt/keycloak/bin/kcadm.sh get clients \
|
||||||
|
--no-config \
|
||||||
|
--server http://127.0.0.1:8080 \
|
||||||
|
--realm master \
|
||||||
|
--user remora-admin \
|
||||||
|
--password synthetic-keycloak \
|
||||||
|
-r "$realm" \
|
||||||
|
--query "clientId=$client_id" \
|
||||||
|
--fields id \
|
||||||
|
--format csv \
|
||||||
|
--noquotes)" || return
|
||||||
|
client_uuid="$(printf '%s' "$client_uuid" | tr -d '\r' | head -n1)"
|
||||||
|
[[ -n "$client_uuid" ]] || return
|
||||||
|
# Include this client in access-token `aud` so mandatory audience checks pass.
|
||||||
|
kube --namespace "$FIXTURE_NAMESPACE" exec deployment/keycloak -- \
|
||||||
|
/opt/keycloak/bin/kcadm.sh create "clients/$client_uuid/protocol-mappers/models" \
|
||||||
|
--no-config \
|
||||||
|
--server http://127.0.0.1:8080 \
|
||||||
|
--realm master \
|
||||||
|
--user remora-admin \
|
||||||
|
--password synthetic-keycloak \
|
||||||
|
-r "$realm" \
|
||||||
|
--set name=remora-audience \
|
||||||
|
--set protocol=openid-connect \
|
||||||
|
--set protocolMapper=oidc-audience-mapper \
|
||||||
|
--set 'config."included.client.audience"='"$client_id" \
|
||||||
|
--set 'config."id.token.claim"=false' \
|
||||||
|
--set 'config."access.token.claim"=true' >&2 || return
|
||||||
kube --namespace "$FIXTURE_NAMESPACE" exec deployment/keycloak -- \
|
kube --namespace "$FIXTURE_NAMESPACE" exec deployment/keycloak -- \
|
||||||
/opt/keycloak/bin/kcadm.sh create roles \
|
/opt/keycloak/bin/kcadm.sh create roles \
|
||||||
--no-config \
|
--no-config \
|
||||||
|
|
@ -495,6 +560,26 @@ reset_keycloak_lane() {
|
||||||
--password synthetic-keycloak \
|
--password synthetic-keycloak \
|
||||||
-r "$realm" \
|
-r "$realm" \
|
||||||
--set name=test-user >&2 || return
|
--set name=test-user >&2 || return
|
||||||
|
# Client roles are the only Keycloak claim source Remora trusts (resource_access).
|
||||||
|
# Use -b JSON so `|` in storage roles is not percent-encoded by --set.
|
||||||
|
kube --namespace "$FIXTURE_NAMESPACE" exec deployment/keycloak -- \
|
||||||
|
/opt/keycloak/bin/kcadm.sh create "clients/$client_uuid/roles" \
|
||||||
|
--no-config \
|
||||||
|
--server http://127.0.0.1:8080 \
|
||||||
|
--realm master \
|
||||||
|
--user remora-admin \
|
||||||
|
--password synthetic-keycloak \
|
||||||
|
-r "$realm" \
|
||||||
|
-b "{\"name\":\"remora:v1:admin\"}" >&2 || return
|
||||||
|
kube --namespace "$FIXTURE_NAMESPACE" exec deployment/keycloak -- \
|
||||||
|
/opt/keycloak/bin/kcadm.sh create "clients/$client_uuid/roles" \
|
||||||
|
--no-config \
|
||||||
|
--server http://127.0.0.1:8080 \
|
||||||
|
--realm master \
|
||||||
|
--user remora-admin \
|
||||||
|
--password synthetic-keycloak \
|
||||||
|
-r "$realm" \
|
||||||
|
-b "{\"name\":\"$storage_role\"}" >&2 || return
|
||||||
kube --namespace "$FIXTURE_NAMESPACE" exec deployment/keycloak -- \
|
kube --namespace "$FIXTURE_NAMESPACE" exec deployment/keycloak -- \
|
||||||
/opt/keycloak/bin/kcadm.sh create users \
|
/opt/keycloak/bin/kcadm.sh create users \
|
||||||
--no-config \
|
--no-config \
|
||||||
|
|
@ -506,7 +591,35 @@ reset_keycloak_lane() {
|
||||||
--set "username=$username" \
|
--set "username=$username" \
|
||||||
--set enabled=true \
|
--set enabled=true \
|
||||||
--set emailVerified=true \
|
--set emailVerified=true \
|
||||||
--set "email=$username@remora.test" >&2 || return
|
--set "email=$username@remora.test" \
|
||||||
|
--set 'requiredActions=[]' >&2 || return
|
||||||
|
local user_id
|
||||||
|
user_id="$(kube --namespace "$FIXTURE_NAMESPACE" exec deployment/keycloak -- \
|
||||||
|
/opt/keycloak/bin/kcadm.sh get users \
|
||||||
|
--no-config \
|
||||||
|
--server http://127.0.0.1:8080 \
|
||||||
|
--realm master \
|
||||||
|
--user remora-admin \
|
||||||
|
--password synthetic-keycloak \
|
||||||
|
-r "$realm" \
|
||||||
|
--query "username=$username" \
|
||||||
|
--fields id \
|
||||||
|
--format csv \
|
||||||
|
--noquotes)" || return
|
||||||
|
user_id="$(printf '%s' "$user_id" | tr -d '\r' | head -n1)"
|
||||||
|
[[ -n "$user_id" ]] || return
|
||||||
|
# Clear any required actions and mark email verified for ROPC.
|
||||||
|
kube --namespace "$FIXTURE_NAMESPACE" exec deployment/keycloak -- \
|
||||||
|
/opt/keycloak/bin/kcadm.sh update "users/$user_id" \
|
||||||
|
--no-config \
|
||||||
|
--server http://127.0.0.1:8080 \
|
||||||
|
--realm master \
|
||||||
|
--user remora-admin \
|
||||||
|
--password synthetic-keycloak \
|
||||||
|
-r "$realm" \
|
||||||
|
--set emailVerified=true \
|
||||||
|
--set 'requiredActions=[]' \
|
||||||
|
--set enabled=true >&2 || return
|
||||||
kube --namespace "$FIXTURE_NAMESPACE" exec deployment/keycloak -- \
|
kube --namespace "$FIXTURE_NAMESPACE" exec deployment/keycloak -- \
|
||||||
/opt/keycloak/bin/kcadm.sh set-password \
|
/opt/keycloak/bin/kcadm.sh set-password \
|
||||||
--no-config \
|
--no-config \
|
||||||
|
|
@ -515,8 +628,9 @@ reset_keycloak_lane() {
|
||||||
--user remora-admin \
|
--user remora-admin \
|
||||||
--password synthetic-keycloak \
|
--password synthetic-keycloak \
|
||||||
-r "$realm" \
|
-r "$realm" \
|
||||||
--username "$username" \
|
--userid "$user_id" \
|
||||||
--new-password Synthetic-OIDC-1! >&2 || return
|
--new-password Synthetic-OIDC-1! \
|
||||||
|
--temporary=false >&2 || return
|
||||||
kube --namespace "$FIXTURE_NAMESPACE" exec deployment/keycloak -- \
|
kube --namespace "$FIXTURE_NAMESPACE" exec deployment/keycloak -- \
|
||||||
/opt/keycloak/bin/kcadm.sh add-roles \
|
/opt/keycloak/bin/kcadm.sh add-roles \
|
||||||
--no-config \
|
--no-config \
|
||||||
|
|
@ -527,11 +641,30 @@ reset_keycloak_lane() {
|
||||||
-r "$realm" \
|
-r "$realm" \
|
||||||
--uusername "$username" \
|
--uusername "$username" \
|
||||||
--rolename test-user >&2 || return
|
--rolename test-user >&2 || return
|
||||||
printf '%s\t%s\t%s\t%s\n' \
|
kube --namespace "$FIXTURE_NAMESPACE" exec deployment/keycloak -- \
|
||||||
|
/opt/keycloak/bin/kcadm.sh add-roles \
|
||||||
|
--no-config \
|
||||||
|
--server http://127.0.0.1:8080 \
|
||||||
|
--realm master \
|
||||||
|
--user remora-admin \
|
||||||
|
--password synthetic-keycloak \
|
||||||
|
-r "$realm" \
|
||||||
|
--uusername "$username" \
|
||||||
|
--cclientid "$client_id" \
|
||||||
|
--rolename 'remora:v1:admin' \
|
||||||
|
--rolename "$storage_role" >&2 || return
|
||||||
|
# issuer \t tenant \t client_id \t username \t storage_role \t project_id \t user_id \t mgmt_token
|
||||||
|
# issuer \t tenant \t client_id \t username \t storage_role \t project_id \t user_id \t mgmt_token \t user_key_b64
|
||||||
|
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
|
||||||
"http://keycloak.remora.test:8080/realms/$realm" \
|
"http://keycloak.remora.test:8080/realms/$realm" \
|
||||||
"$realm" \
|
"$realm" \
|
||||||
"$client_id" \
|
"$client_id" \
|
||||||
"$username"
|
"$username" \
|
||||||
|
"$storage_role" \
|
||||||
|
"" \
|
||||||
|
"" \
|
||||||
|
"" \
|
||||||
|
""
|
||||||
}
|
}
|
||||||
|
|
||||||
zitadel_request() {
|
zitadel_request() {
|
||||||
|
|
@ -551,7 +684,7 @@ zitadel_request() {
|
||||||
--silent \
|
--silent \
|
||||||
--show-error \
|
--show-error \
|
||||||
--request "$method" \
|
--request "$method" \
|
||||||
--header "Host: zitadel.remora.test" \
|
--header "Host: zitadel.remora.test:8080" \
|
||||||
--header "Authorization: Bearer $token" \
|
--header "Authorization: Bearer $token" \
|
||||||
--header "Content-Type: application/json"
|
--header "Content-Type: application/json"
|
||||||
if test -n "$organization"; then
|
if test -n "$organization"; then
|
||||||
|
|
@ -577,11 +710,15 @@ reset_zitadel_lane() {
|
||||||
local lane="$1"
|
local lane="$1"
|
||||||
local tenant="remora-lane-$lane"
|
local tenant="remora-lane-$lane"
|
||||||
local username="remora-user-$lane"
|
local username="remora-user-$lane"
|
||||||
|
local storage_role
|
||||||
local response
|
local response
|
||||||
local organization_id
|
local organization_id
|
||||||
local project_id
|
local project_id
|
||||||
local user_id
|
local user_id
|
||||||
local client_id
|
local client_id
|
||||||
|
local mgmt_token
|
||||||
|
local grant_id
|
||||||
|
storage_role="$(remora_fixture_storage_role)"
|
||||||
cleanup_zitadel_lane "$lane" || return
|
cleanup_zitadel_lane "$lane" || return
|
||||||
response="$(zitadel_request /management/v1/orgs "{\"name\":\"$tenant\"}")" || return
|
response="$(zitadel_request /management/v1/orgs "{\"name\":\"$tenant\"}")" || return
|
||||||
organization_id="$(json_string_field "$response" id)" || return
|
organization_id="$(json_string_field "$response" id)" || return
|
||||||
|
|
@ -591,25 +728,74 @@ reset_zitadel_lane() {
|
||||||
"/management/v1/projects/$project_id/roles" \
|
"/management/v1/projects/$project_id/roles" \
|
||||||
'{"roleKey":"test-user","displayName":"Test User","group":"fixture"}' \
|
'{"roleKey":"test-user","displayName":"Test User","group":"fixture"}' \
|
||||||
"$organization_id" >/dev/null || return
|
"$organization_id" >/dev/null || return
|
||||||
|
zitadel_request \
|
||||||
|
"/management/v1/projects/$project_id/roles" \
|
||||||
|
'{"roleKey":"remora:v1:admin","displayName":"Remora Admin","group":"remora"}' \
|
||||||
|
"$organization_id" >/dev/null || return
|
||||||
|
zitadel_request \
|
||||||
|
"/management/v1/projects/$project_id/roles" \
|
||||||
|
"{\"roleKey\":\"$storage_role\",\"displayName\":\"Remora Storage\",\"group\":\"remora\"}" \
|
||||||
|
"$organization_id" >/dev/null || return
|
||||||
|
# Native app: PKCE public client with JWT access tokens and project role assertion.
|
||||||
response="$(zitadel_request \
|
response="$(zitadel_request \
|
||||||
"/management/v1/projects/$project_id/apps/oidc" \
|
"/management/v1/projects/$project_id/apps/oidc" \
|
||||||
"{\"name\":\"$tenant\",\"redirectUris\":[\"http://localhost/callback\"],\"postLogoutRedirectUris\":[\"http://localhost/logout\"],\"responseTypes\":[\"OIDC_RESPONSE_TYPE_CODE\"],\"grantTypes\":[\"OIDC_GRANT_TYPE_AUTHORIZATION_CODE\",\"OIDC_GRANT_TYPE_REFRESH_TOKEN\"],\"appType\":\"OIDC_APP_TYPE_USER_AGENT\",\"authMethodType\":\"OIDC_AUTH_METHOD_TYPE_NONE\",\"version\":\"OIDC_VERSION_1_0\",\"devMode\":true,\"accessTokenType\":\"OIDC_TOKEN_TYPE_JWT\",\"accessTokenRoleAssertion\":true}" \
|
"{\"name\":\"$tenant\",\"redirectUris\":[\"http://localhost/callback\"],\"postLogoutRedirectUris\":[\"http://localhost/logout\"],\"responseTypes\":[\"OIDC_RESPONSE_TYPE_CODE\"],\"grantTypes\":[\"OIDC_GRANT_TYPE_AUTHORIZATION_CODE\",\"OIDC_GRANT_TYPE_REFRESH_TOKEN\"],\"appType\":\"OIDC_APP_TYPE_NATIVE\",\"authMethodType\":\"OIDC_AUTH_METHOD_TYPE_NONE\",\"version\":\"OIDC_VERSION_1_0\",\"devMode\":true,\"accessTokenType\":\"OIDC_TOKEN_TYPE_JWT\",\"accessTokenRoleAssertion\":true,\"idTokenRoleAssertion\":true,\"idTokenUserinfoAssertion\":true}" \
|
||||||
"$organization_id")" || return
|
"$organization_id")" || return
|
||||||
client_id="$(json_string_field "$response" clientId)" || return
|
client_id="$(json_string_field "$response" clientId)" || return
|
||||||
response="$(zitadel_request \
|
response="$(zitadel_request \
|
||||||
/management/v1/users/human \
|
/management/v1/users/human \
|
||||||
"{\"userName\":\"$username\",\"profile\":{\"firstName\":\"Remora\",\"lastName\":\"Lane $lane\",\"displayName\":\"Remora Lane $lane\",\"preferredLanguage\":\"en\",\"gender\":\"GENDER_UNSPECIFIED\"},\"email\":{\"email\":\"$username@remora.test\",\"isEmailVerified\":true},\"password\":\"Synthetic-OIDC-1!\",\"passwordChangeRequired\":false}" \
|
"{\"userName\":\"$username\",\"profile\":{\"firstName\":\"Remora\",\"lastName\":\"Lane $lane\",\"displayName\":\"Remora Lane $lane\",\"preferredLanguage\":\"en\",\"gender\":\"GENDER_UNSPECIFIED\"},\"email\":{\"email\":\"$username@remora.test\",\"isEmailVerified\":true},\"initialPassword\":\"Synthetic-OIDC-1!\"}" \
|
||||||
"$organization_id")" || return
|
"$organization_id")" || return
|
||||||
user_id="$(json_string_field "$response" userId)" || return
|
user_id="$(json_string_field "$response" userId)" || return
|
||||||
|
# Set a permanent password for Session API checks (initialPassword alone is not enough on all versions).
|
||||||
|
if ! zitadel_request \
|
||||||
|
"/management/v1/users/$user_id/password" \
|
||||||
|
'{"password":"Synthetic-OIDC-1!","noChangeRequired":true}' \
|
||||||
|
"$organization_id" >/dev/null; then
|
||||||
|
# Fallback field name used by older management API payloads.
|
||||||
zitadel_request \
|
zitadel_request \
|
||||||
"/management/v1/users/$user_id/grants" \
|
"/management/v1/users/$user_id/password" \
|
||||||
"{\"projectId\":\"$project_id\",\"roleKeys\":[\"test-user\"]}" \
|
'{"password":"Synthetic-OIDC-1!","changeRequired":false}' \
|
||||||
"$organization_id" >/dev/null || return
|
"$organization_id" >/dev/null || return
|
||||||
printf '%s\t%s\t%s\t%s\n' \
|
fi
|
||||||
|
response="$(zitadel_request \
|
||||||
|
"/management/v1/users/$user_id/grants" \
|
||||||
|
"{\"projectId\":\"$project_id\",\"roleKeys\":[\"test-user\",\"remora:v1:admin\",\"$storage_role\"]}" \
|
||||||
|
"$organization_id")" || return
|
||||||
|
# Machine user + application key for JWT-bearer access tokens with project roles.
|
||||||
|
local machine_name="remora-machine-$lane"
|
||||||
|
# Bearer defaults to opaque tokens; Remora validates only JWKS-signed JWTs.
|
||||||
|
response="$(zitadel_request \
|
||||||
|
/management/v1/users/machine \
|
||||||
|
"{\"userName\":\"$machine_name\",\"name\":\"Remora Machine $lane\",\"description\":\"JWT bearer minting\",\"accessTokenType\":\"ACCESS_TOKEN_TYPE_JWT\"}" \
|
||||||
|
"$organization_id")" || return
|
||||||
|
local machine_id
|
||||||
|
machine_id="$(json_string_field "$response" userId)" || return
|
||||||
|
zitadel_request \
|
||||||
|
"/management/v1/users/$machine_id/grants" \
|
||||||
|
"{\"projectId\":\"$project_id\",\"roleKeys\":[\"remora:v1:admin\",\"$storage_role\"]}" \
|
||||||
|
"$organization_id" >/dev/null || return
|
||||||
|
response="$(zitadel_request \
|
||||||
|
"/management/v1/users/$machine_id/keys" \
|
||||||
|
'{"type":"KEY_TYPE_JSON","expirationDate":"2099-01-01T00:00:00Z"}' \
|
||||||
|
"$organization_id")" || return
|
||||||
|
local key_details_b64
|
||||||
|
key_details_b64="$(json_string_field "$response" keyDetails)" || return
|
||||||
|
mgmt_token="$(kube --namespace "$FIXTURE_NAMESPACE" exec deployment/zitadel --container provisioner -- \
|
||||||
|
cat /bootstrap/admin.pat)" || return
|
||||||
|
mgmt_token="$(printf '%s' "$mgmt_token" | tr -d '\r\n')"
|
||||||
|
# issuer \t tenant \t client_id \t username \t storage_role \t project_id \t user_id \t mgmt_token \t key_details_b64
|
||||||
|
# user_id is the machine user used for JWT-bearer minting (and grant loss).
|
||||||
|
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
|
||||||
http://zitadel.remora.test:8080 \
|
http://zitadel.remora.test:8080 \
|
||||||
"$organization_id" \
|
"$organization_id" \
|
||||||
"$client_id" \
|
"$client_id" \
|
||||||
"$username"
|
"$username" \
|
||||||
|
"$storage_role" \
|
||||||
|
"$project_id" \
|
||||||
|
"$machine_id" \
|
||||||
|
"$mgmt_token" \
|
||||||
|
"$key_details_b64"
|
||||||
}
|
}
|
||||||
|
|
||||||
reset_lane_oidc_provider() {
|
reset_lane_oidc_provider() {
|
||||||
|
|
@ -767,6 +953,11 @@ write_lane_profile() {
|
||||||
local oidc_tenant=
|
local oidc_tenant=
|
||||||
local oidc_client_id=
|
local oidc_client_id=
|
||||||
local oidc_username=
|
local oidc_username=
|
||||||
|
local oidc_storage_role=
|
||||||
|
local oidc_project_id=
|
||||||
|
local oidc_user_id=
|
||||||
|
local oidc_mgmt_token=
|
||||||
|
local oidc_user_key_b64=
|
||||||
local oidc_profile
|
local oidc_profile
|
||||||
local target_endpoint=
|
local target_endpoint=
|
||||||
local target_bucket=
|
local target_bucket=
|
||||||
|
|
@ -787,7 +978,9 @@ write_lane_profile() {
|
||||||
fi
|
fi
|
||||||
if [[ "$oidc" != - ]]; then
|
if [[ "$oidc" != - ]]; then
|
||||||
oidc_profile="$(reset_lane_oidc_provider "$oidc" "$lane")" || return
|
oidc_profile="$(reset_lane_oidc_provider "$oidc" "$lane")" || return
|
||||||
IFS=$'\t' read -r oidc_issuer oidc_tenant oidc_client_id oidc_username <<< "$oidc_profile"
|
IFS=$'\t' read -r oidc_issuer oidc_tenant oidc_client_id oidc_username \
|
||||||
|
oidc_storage_role oidc_project_id oidc_user_id oidc_mgmt_token oidc_user_key_b64 \
|
||||||
|
<<< "$oidc_profile"
|
||||||
fi
|
fi
|
||||||
if [[ "$target" != - ]]; then
|
if [[ "$target" != - ]]; then
|
||||||
target_bucket="remora-lane-$lane"
|
target_bucket="remora-lane-$lane"
|
||||||
|
|
@ -820,6 +1013,11 @@ write_lane_profile() {
|
||||||
"REMORA_OIDC_CLIENT_ID=$oidc_client_id" \
|
"REMORA_OIDC_CLIENT_ID=$oidc_client_id" \
|
||||||
"REMORA_OIDC_USERNAME=$oidc_username" \
|
"REMORA_OIDC_USERNAME=$oidc_username" \
|
||||||
"REMORA_OIDC_PASSWORD=Synthetic-OIDC-1!" \
|
"REMORA_OIDC_PASSWORD=Synthetic-OIDC-1!" \
|
||||||
|
"REMORA_OIDC_STORAGE_ROLE=$oidc_storage_role" \
|
||||||
|
"REMORA_OIDC_PROJECT_ID=$oidc_project_id" \
|
||||||
|
"REMORA_OIDC_USER_ID=$oidc_user_id" \
|
||||||
|
"REMORA_OIDC_MGMT_TOKEN=$oidc_mgmt_token" \
|
||||||
|
"REMORA_OIDC_USER_KEY_B64=$oidc_user_key_b64" \
|
||||||
"REMORA_TARGET_PROVIDER=$target" \
|
"REMORA_TARGET_PROVIDER=$target" \
|
||||||
"REMORA_TARGET_ENDPOINT=$target_endpoint" \
|
"REMORA_TARGET_ENDPOINT=$target_endpoint" \
|
||||||
"REMORA_TARGET_BUCKET=$target_bucket" \
|
"REMORA_TARGET_BUCKET=$target_bucket" \
|
||||||
|
|
@ -847,6 +1045,11 @@ write_lane_profile() {
|
||||||
--from-literal="REMORA_OIDC_CLIENT_ID=$oidc_client_id" \
|
--from-literal="REMORA_OIDC_CLIENT_ID=$oidc_client_id" \
|
||||||
--from-literal="REMORA_OIDC_USERNAME=$oidc_username" \
|
--from-literal="REMORA_OIDC_USERNAME=$oidc_username" \
|
||||||
--from-literal=REMORA_OIDC_PASSWORD=Synthetic-OIDC-1! \
|
--from-literal=REMORA_OIDC_PASSWORD=Synthetic-OIDC-1! \
|
||||||
|
--from-literal="REMORA_OIDC_STORAGE_ROLE=$oidc_storage_role" \
|
||||||
|
--from-literal="REMORA_OIDC_PROJECT_ID=$oidc_project_id" \
|
||||||
|
--from-literal="REMORA_OIDC_USER_ID=$oidc_user_id" \
|
||||||
|
--from-literal="REMORA_OIDC_MGMT_TOKEN=$oidc_mgmt_token" \
|
||||||
|
--from-literal="REMORA_OIDC_USER_KEY_B64=$oidc_user_key_b64" \
|
||||||
--from-literal="REMORA_TARGET_PROVIDER=$target" \
|
--from-literal="REMORA_TARGET_PROVIDER=$target" \
|
||||||
--from-literal="REMORA_TARGET_ENDPOINT=$target_endpoint" \
|
--from-literal="REMORA_TARGET_ENDPOINT=$target_endpoint" \
|
||||||
--from-literal="REMORA_TARGET_BUCKET=$target_bucket" \
|
--from-literal="REMORA_TARGET_BUCKET=$target_bucket" \
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue