Files
harmony/fleet/harmony-fleet-operator/Cargo.toml
Jean-Gabriel Gill-Couture aad14cd04d feat(fleet-operator): require fleet-admin role on dashboard routes
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.
2026-05-24 16:39:37 -04:00

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"