Previously the dashboard granted full access to any Zitadel user with a valid JWT — issuer, audience and signature were the only checks. Operators routinely give non-admin users an account in Zitadel for SSO into adjacent apps, so a valid JWT is not by itself evidence the user should manage a fleet. This change closes that gap. Behaviour: - Verified sessions now carry a `roles: Vec<String>` extracted from a configurable JWT claim (env `FLEET_AUTH_ROLES_CLAIM`, default the Zitadel role URN). The extractor accepts both OIDC array shape and Zitadel's object-map shape; missing/malformed claim yields empty vec (closed door, never wildcard). - `require_fleet_admin` middleware layers above `require_auth` on the private routes. A logged-in user without the role gets a human- readable 403 page that names the missing role and offers a sign-out link. - `/logout` is in `public_routes` so a non-admin can switch accounts without being trapped by the gate that rendered the 403. Shared role extraction: - `harmony_zitadel_auth::roles::extract_roles` is the single source of truth for Zitadel role parsing. - The NATS auth callout's `ZitadelValidator::extract_roles` now delegates to it. Any future quirk in Zitadel's role-emission shape is fixed once. Tests: - 9 unit tests on the role extractor: both claim shapes, empty/missing fail closed, non-string array entries skipped, dotted paths, Zitadel URN paths. - 5 axum middleware tests via `tower::ServiceExt::oneshot`: no session → unauthenticated redirect, role-less session → 403 with expected role name + logout link, fleet-admin session → handler reached, HTML (not JSON) content-type on 403. Defers to follow-ups: a `fleet-viewer` read-only role; unifying the dashboard role policy with the NATS callout's per-device scoping.
54 lines
1.9 KiB
TOML
54 lines
1.9 KiB
TOML
[package]
|
|
name = "harmony-fleet-operator"
|
|
version = "0.1.0"
|
|
edition = "2024"
|
|
rust-version = "1.85"
|
|
build = "build.rs"
|
|
|
|
[features]
|
|
default = []
|
|
# Server-side dashboard (axum + Maud + HTMX). Tailwind CSS is embedded at
|
|
# build time when the standalone `tailwindcss` CLI is on PATH; otherwise
|
|
# the bundled CSS is empty and `--css-from <path>` must be used at runtime
|
|
# (the sidecar-watch dev workflow does this).
|
|
web-frontend = ["dep:axum", "dep:axum-extra", "dep:maud", "dep:tokio-stream", "harmony_zitadel_auth/axum"]
|
|
|
|
[dependencies]
|
|
harmony = { path = "../../harmony", features = ["podman"] }
|
|
harmony-fleet-auth = { path = "../harmony-fleet-auth" }
|
|
harmony-reconciler-contracts = { path = "../../harmony-reconciler-contracts" }
|
|
harmony_zitadel_auth = { path = "../../harmony_zitadel_auth" }
|
|
toml = { workspace = true }
|
|
chrono = { workspace = true, features = ["serde"] }
|
|
kube = { workspace = true, features = ["runtime", "derive"] }
|
|
k8s-openapi.workspace = true
|
|
async-nats = { workspace = true }
|
|
serde.workspace = true
|
|
serde_json.workspace = true
|
|
schemars = "0.8.22"
|
|
tokio.workspace = true
|
|
tracing = { workspace = true }
|
|
tracing-subscriber = { workspace = true }
|
|
anyhow.workspace = true
|
|
clap.workspace = true
|
|
futures-util = { workspace = true }
|
|
thiserror.workspace = true
|
|
async-trait.workspace = true
|
|
url.workspace = true
|
|
base64.workspace = true
|
|
reqwest.workspace = true
|
|
|
|
axum = { version = "0.8", optional = true }
|
|
axum-extra = { version = "0.10", features = ["cookie", "cookie-private"], optional = true }
|
|
maud = { version = "0.27", features = ["axum"], optional = true }
|
|
tokio-stream = { version = "0.1", optional = true }
|
|
dotenvy = "0.15"
|
|
|
|
[dev-dependencies]
|
|
# `oneshot` lets the middleware tests drive a `Router` end-to-end
|
|
# without binding a TCP port — the only way to exercise the
|
|
# require_auth/require_fleet_admin composition as the production
|
|
# stack actually layers them.
|
|
tower = { version = "0.5", features = ["util"] }
|
|
http-body-util = "0.1"
|