Files
harmony/nats/callout/Cargo.toml
Jean-Gabriel Gill-Couture e9522464af
All checks were successful
Run Check Script / check (pull_request) Successful in 2m21s
refactor(zitadel-auth): typed Role/Roles, drop string-path role lookup
Removes the brittle pieces that landed in the original role-enforcement
commit: `extract_roles(value, path)` walking `serde_json::Value` with
a `path.contains("urn:")`-aware splitter, an env-driven
`FLEET_AUTH_ROLES_CLAIM`, and `VerifiedSession.roles: Vec<String>`
compared with `iter().any(|r| r == "fleet-admin")` in middleware.

This is a security-relevant code path; runtime string heuristics inside
it hide injection-shaped bugs (a misconfigured env or a custom Zitadel
mapper shifts the lookup to a path the attacker controls).

Replaced with end-to-end typed serde decoding:

* `Role` — closed enum (one variant today: `FleetAdmin`). Adding a
  variant is a deliberate code change to the security path. Unknown
  role names emitted by the IdP cannot be represented and therefore
  cannot grant access.
* `RoleClaims` — wire-side struct, flattened into the JWT `Claims`.
  The two well-known role-claim locations are matched verbatim by
  `#[serde(rename = "urn:zitadel:iam:org:project:roles")]` and
  `#[serde(rename = "roles")]`. No dotted-path navigation. No env
  string. If a future issuer adds a third location it is an additive
  `#[serde(rename = ...)]` field inside the security boundary.
* `Roles` — domain value on `VerifiedSession`. Construction is
  restricted to `RoleClaims::into_roles` plus a typed
  `FromIterator<Role>` for test fixtures. `Roles::has(Role::FleetAdmin)`
  is the only check the middleware needs; no string comparison exists
  anywhere downstream.
* Malformed shapes (scalar at the URN path, mixed-type array) now
  ERROR at decode rather than degrading to an empty-vec "closed door".
  Fail-loud is the security-correct default when the IdP misbehaves
  — the user re-logs in, the operator notices.

Callout side: reverted the shared-extract_roles delegation. The
callout retains its own, unchanged role-extraction logic. We do NOT
need cross-crate sharing here, and the shared extractor was the entry
point we were trying to delete — the callout's own behaviour was the
status quo and is preserved verbatim.

Dropped exports: `DEFAULT_ADMIN_ROLE`, `DEFAULT_ROLES_CLAIM`,
`extract_roles`, `ROLES_CLAIM_ENV`, `ZitadelAuthConfig.roles_claim`.
None had external consumers in the workspace.

Tests:
* 12 tests on `RoleClaims` deserialization: both shapes resolve,
  Zitadel URN wins precedence, unknown roles dropped, malformed
  scalar/mixed-type arrays error at decode, missing claim → empty,
  empty object/array → empty, extra unrelated claims are ignored,
  display matches wire spelling.
* 4 middleware tests on the typed `require_role(Role::FleetAdmin, …)`
  path. Dropped the redundant "admin-only vs admin+other" test —
  with a single-variant enum it duplicated the positive case.
2026-05-25 06:45:34 -04:00

35 lines
919 B
TOML

[package]
name = "harmony-nats-callout"
edition = "2024"
version.workspace = true
readme.workspace = true
license.workspace = true
description = "NATS auth callout service for Zitadel SSO with per-device permissions"
rust-version = "1.85"
[lib]
name = "harmony_nats_callout"
path = "src/lib.rs"
[[bin]]
name = "harmony-nats-callout"
path = "src/main.rs"
[dependencies]
nats-jwt = { path = "../jwt" }
async-nats.workspace = true
nkeys = "0.4"
jsonwebtoken = "9"
reqwest = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
thiserror.workspace = true
anyhow.workspace = true
tokio = { workspace = true, features = ["rt", "rt-multi-thread", "macros", "signal", "sync", "time"] }
futures-util.workspace = true
[dev-dependencies]
harmony-reconciler-contracts = { path = "../../harmony-reconciler-contracts" }