Files
harmony/nats/callout/src/handler.rs
Jean-Gabriel Gill-Couture d4fd4859ec
Some checks failed
Run Check Script / check (pull_request) Failing after -44h57m23s
fix(callout): align device permissions with KV key formats and machine-user prefix
Two bugs surfaced when the agent went live against NATS JetStream KV
in the VM-based e2e rehearsal:

1. The default `device` role only allowed flat `device-state.<id>` /
   `device-commands.<id>` subjects. The agent's actual data plane is
   JetStream KV, which puts every operation on `$KV.<bucket>.<key>`
   subjects with control-plane traffic on `$JS.API.>` and `$JS.ACK.>`.
   With the old role config, the very first KV publish died with
   `Permissions Violation for Publish to "$JS.API.INFO"`.

   The role now allows `$JS.API.>` + `$JS.ACK.>` plus the four
   per-device data subjects derived from
   harmony_reconciler_contracts::kv (info.<id>, state.<id>.<dep>,
   heartbeat.<id>, desired-state.<id>.<dep>). The legacy direct
   `device-state.<id>` / `device-commands.<id>` subjects are kept so
   non-JetStream callers of NatsAuthCalloutScore still work.

   A new unit test (`device_role_covers_reconciler_contract_kv_subjects`)
   imports the contract crate as a dev-dep and asserts each contract-
   produced subject is matched, plus that cross-device subjects are
   *not* matched. This locks the role config to the contract surface so
   future renames break the test before they break prod.

2. Zitadel's `client_id` claim for a machine user equals the userName
   verbatim. Both `fleet_rpi_setup` and `fleet_e2e_demo` create the
   user as `device-{device_id}`, so the JWT carries
   `device-vm-device-00` while the agent's KV keys use the bare
   `vm-device-00`. The callout was interpolating the prefixed string
   into permissions, producing rules that never matched what the
   agent actually publishes.

   Adds `device_id_prefix_strip` (env: `DEVICE_ID_PREFIX_STRIP`,
   defaults empty so existing deployments are unaffected). When set,
   the validator strips the prefix from the extracted claim before
   permission interpolation. The fleet_auth_callout example wires it
   to `device-` so the e2e harness stays end-to-end correct without
   reaching into either naming convention.

Verified end-to-end: both VM agents now publish DeviceInfo /
heartbeat through JetStream KV with no permission errors and zero
service restarts since the rollout.
2026-05-03 17:49:48 -04:00

745 lines
26 KiB
Rust

use async_nats::Client;
use nats_jwt::algorithm::decode_unverified;
use nats_jwt::builder::{AuthorizationResponseBuilder, UserClaimsBuilder};
use nats_jwt::claims::auth_request::AuthorizationRequestClaims;
use tracing::{info, warn};
use crate::config::AuthCalloutConfig;
use crate::permissions::{InterpolatedPermissions, interpolate_permissions};
use crate::roles::{DeviceIdError, ResolvedRole, resolve as resolve_role, validate_device_id};
use crate::zitadel::{ZitadelClaims, ZitadelValidationError, ZitadelValidator};
/// Outcome of the **pure** authorization decision applied to a validated
/// Zitadel JWT. This is the security-critical decision point — every
/// branch is exhaustively unit-tested in `mod tests` below.
#[derive(Debug)]
pub enum Decision {
Authorize {
device_id: String,
role: ResolvedRole,
perms: InterpolatedPermissions,
},
Reject(RejectReason),
}
#[derive(Debug, PartialEq, Eq)]
pub enum RejectReason {
/// The configured `device_id_claim` path is not present in the JWT.
DeviceIdMissing(String),
/// The configured `device_id_claim` is present but not a string.
DeviceIdNotString(String),
/// The device_id failed the NATS-subject-safe character whitelist —
/// either it would let the user inject metacharacters into the
/// `{device_id}` placeholder, or it was empty.
DeviceIdUnsafe(DeviceIdError),
/// No configured role (admin or device) is present on the JWT.
NoAuthorizedRole,
}
impl std::fmt::Display for RejectReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RejectReason::DeviceIdMissing(p) => {
write!(f, "device_id claim '{p}' missing from token")
}
RejectReason::DeviceIdNotString(p) => {
write!(f, "device_id claim '{p}' is not a string")
}
RejectReason::DeviceIdUnsafe(e) => write!(f, "device_id rejected: {e}"),
RejectReason::NoAuthorizedRole => write!(f, "no authorized role in token"),
}
}
}
/// Pure authorization decision against a verified Zitadel JWT.
///
/// **Does no I/O and no signature checking.** Caller is responsible for
/// having already validated the JWT signature, issuer, audience, and
/// expiry through [`ZitadelValidator::validate`].
///
/// The branching here is the exhaustive decision tree for the security
/// boundary; tests below cover every reachable outcome.
pub fn decide(
claims: &ZitadelClaims,
config: &AuthCalloutConfig,
validator: &ZitadelValidator,
) -> Decision {
let device_id = match validator.extract_device_id(claims) {
Ok(id) => id,
Err(ZitadelValidationError::ClaimNotFound(p)) => {
return Decision::Reject(RejectReason::DeviceIdMissing(p));
}
Err(ZitadelValidationError::ClaimNotString(p)) => {
return Decision::Reject(RejectReason::DeviceIdNotString(p));
}
// Only the two variants above are produced by extract_device_id;
// anything else from validator surface area would be a bug to
// fail closed on rather than silently allow.
Err(_) => {
return Decision::Reject(RejectReason::DeviceIdMissing(
config.device_id_claim.clone(),
));
}
};
if let Err(e) = validate_device_id(&device_id) {
return Decision::Reject(RejectReason::DeviceIdUnsafe(e));
}
let roles = validator.extract_roles(claims, &config.roles_claim);
let role = match resolve_role(&roles, config) {
Some(r) => r,
None => return Decision::Reject(RejectReason::NoAuthorizedRole),
};
let perms_template = match role {
ResolvedRole::Admin => &config.admin_permissions,
ResolvedRole::Device => &config.device_permissions,
};
Decision::Authorize {
device_id: device_id.clone(),
role,
perms: interpolate_permissions(perms_template, &device_id),
}
}
/// Handle a single NATS auth callout request.
///
/// 1. Decode the auth request JWT (signed by NATS server, trusted).
/// 2. Extract the Zitadel JWT from `connect_opts.auth_token`.
/// 3. Verify the Zitadel JWT signature/issuer/audience/exp/nbf.
/// 4. Extract `device_id` and **validate** it against NATS subject syntax —
/// this is a critical security gate (a malicious or buggy issuer that
/// emits `device_id = "x.>"` would otherwise escalate via the
/// `{device_id}` placeholder in the per-device permissions block).
/// 5. Extract roles and pick admin/device permissions accordingly. Reject
/// when no configured role is present.
/// 6. Build a user JWT with the interpolated permissions and respond.
pub async fn handle_auth_request(
nc: &Client,
msg: &async_nats::Message,
config: &AuthCalloutConfig,
validator: &ZitadelValidator,
) -> anyhow::Result<()> {
let payload_str = String::from_utf8_lossy(&msg.payload);
let token_str = payload_str.trim();
let request_claims: AuthorizationRequestClaims = decode_unverified(token_str)
.map_err(|e| anyhow::anyhow!("failed to decode auth request JWT: {e}"))?;
info!(
user_nkey = %request_claims.nats.user_nkey,
"received auth callout request"
);
let connect_opts = &request_claims.nats.connect_opts;
let token = connect_opts
.auth_token
.as_deref()
.or(connect_opts.jwt.as_deref());
let reply = msg
.reply
.clone()
.ok_or_else(|| anyhow::anyhow!("no reply subject on auth request"))?;
let Some(token) = token else {
info!("no auth token in request, rejecting");
return reject(nc, &request_claims, config, reply, "no auth token provided").await;
};
let oidc_claims = match validator.validate(token).await {
Ok(claims) => claims,
Err(e) => {
warn!(error = %e.to_string(), "Zitadel JWT validation failed");
return reject(
nc,
&request_claims,
config,
reply,
&format!("invalid credentials: {e}"),
)
.await;
}
};
let (device_id, role, interpolated) = match decide(&oidc_claims, config, validator) {
Decision::Authorize {
device_id,
role,
perms,
} => (device_id, role, perms),
Decision::Reject(reason) => {
warn!(reason = %reason, "rejecting auth callout");
return reject(nc, &request_claims, config, reply, &reason.to_string()).await;
}
};
let role_name = match role {
ResolvedRole::Admin => config.admin_role.as_str(),
ResolvedRole::Device => config.device_role.as_str(),
};
info!(
device_id = %device_id,
role = %role_name,
"Zitadel JWT validated, generating user JWT"
);
let user_jwt = build_user_jwt(
&request_claims.nats.user_nkey,
&device_id,
&interpolated,
config,
)?;
let response = AuthorizationResponseBuilder::new(&request_claims.nats.user_nkey)
.audience(&request_claims.nats.server_id.id)
.issuer(&config.issuer_kp)
.with_jwt(&user_jwt)
.sign(&config.issuer_kp)?;
info!("sending auth response");
nc.publish(reply, response.into()).await?;
nc.flush().await?;
Ok(())
}
/// Build a NATS user JWT for `user_nkey` carrying the resolved permissions.
///
/// Pure function — no I/O. Tested standalone in unit tests; the live
/// handler path is covered by the integration test suite.
pub(crate) fn build_user_jwt(
user_nkey: &str,
device_id: &str,
perms: &InterpolatedPermissions,
config: &AuthCalloutConfig,
) -> anyhow::Result<String> {
let mut builder = UserClaimsBuilder::new(user_nkey)
.issuer(&config.issuer_kp)
.audience(&config.target_account)
.name(device_id);
for s in &perms.pub_allow {
builder = builder.pub_allow(s);
}
for s in &perms.pub_deny {
builder = builder.pub_deny(s);
}
for s in &perms.sub_allow {
builder = builder.sub_allow(s);
}
for s in &perms.sub_deny {
builder = builder.sub_deny(s);
}
Ok(builder.sign(&config.issuer_kp)?)
}
async fn reject(
nc: &Client,
request_claims: &AuthorizationRequestClaims,
config: &AuthCalloutConfig,
reply: async_nats::Subject,
reason: &str,
) -> anyhow::Result<()> {
let response = AuthorizationResponseBuilder::new(&request_claims.nats.user_nkey)
.audience(&request_claims.nats.server_id.id)
.issuer(&config.issuer_kp)
.with_error(reason)
.sign(&config.issuer_kp)?;
nc.publish(reply, response.into()).await?;
nc.flush().await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::permissions::{PermissionSubjects, PermissionsConfig};
use crate::zitadel::ZitadelValidator;
use nats_jwt::algorithm::decode;
use nats_jwt::claims::user::UserClaims;
use nkeys::KeyPair;
use serde_json::json;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
fn test_config() -> AuthCalloutConfig {
AuthCalloutConfig::builder()
.nats_url("nats://localhost:4222")
.issuer_kp(KeyPair::new_account())
.target_account("DEVICES")
.oidc_issuer_url("http://localhost")
.oidc_audience("test-aud")
.build()
.unwrap()
}
#[test]
fn build_user_jwt_carries_interpolated_permissions() {
let config = test_config();
let user_kp = KeyPair::new_user();
let perms = InterpolatedPermissions {
pub_allow: vec!["device-state.sensor-1".into()],
pub_deny: vec![],
sub_allow: vec!["device-commands.sensor-1".into()],
sub_deny: vec![],
};
let jwt =
build_user_jwt(&user_kp.public_key(), "sensor-1", &perms, &config).expect("sign user");
let claims: UserClaims = decode(&jwt).expect("decode user jwt");
assert_eq!(claims.claims_data.sub, user_kp.public_key());
assert_eq!(claims.claims_data.aud, "DEVICES");
assert_eq!(claims.claims_data.name.as_deref(), Some("sensor-1"));
assert_eq!(
claims.nats.pub_perm.allow.as_ref().expect("pub_allow set")[0],
"device-state.sensor-1"
);
assert_eq!(
claims.nats.sub_perm.allow.as_ref().expect("sub_allow set")[0],
"device-commands.sensor-1"
);
}
#[test]
fn build_user_jwt_with_deny_lists_emits_them() {
let config = test_config();
let user_kp = KeyPair::new_user();
let perms = InterpolatedPermissions {
pub_allow: vec![">".into()],
pub_deny: vec!["secret.>".into()],
sub_allow: vec![">".into()],
sub_deny: vec!["secret.>".into()],
};
let jwt =
build_user_jwt(&user_kp.public_key(), "ignored", &perms, &config).expect("sign user");
let claims: UserClaims = decode(&jwt).expect("decode");
assert_eq!(
claims.nats.pub_perm.deny.as_ref().expect("pub_deny set")[0],
"secret.>"
);
assert_eq!(
claims.nats.sub_perm.deny.as_ref().expect("sub_deny set")[0],
"secret.>"
);
}
#[test]
fn build_user_jwt_target_account_drives_audience() {
// The audience MUST match the NATS server's configured callout
// account; otherwise NATS rejects the response.
let mut cfg = test_config();
cfg.target_account = "ACME".to_string();
let user_kp = KeyPair::new_user();
let jwt = build_user_jwt(
&user_kp.public_key(),
"x",
&InterpolatedPermissions {
pub_allow: vec![],
pub_deny: vec![],
sub_allow: vec![],
sub_deny: vec![],
},
&cfg,
)
.unwrap();
let claims: UserClaims = decode(&jwt).unwrap();
assert_eq!(claims.claims_data.aud, "ACME");
}
#[test]
fn admin_default_grants_full_access_after_interpolation() {
// Admin permissions don't carry `{device_id}` placeholders, so
// interpolation must be a no-op and the resulting subjects must
// be `>` (NATS wildcard for "everything").
let perms = interpolate_permissions(&PermissionsConfig::admin_default(), "any-id");
assert_eq!(perms.pub_allow, vec![">"]);
assert_eq!(perms.sub_allow, vec![">"]);
}
#[test]
fn empty_permissions_block_results_in_no_allow_or_deny() {
let empty = PermissionsConfig {
r#pub: PermissionSubjects::default(),
sub: PermissionSubjects::default(),
};
let perms = interpolate_permissions(&empty, "x");
assert!(perms.pub_allow.is_empty());
assert!(perms.pub_deny.is_empty());
assert!(perms.sub_allow.is_empty());
assert!(perms.sub_deny.is_empty());
}
#[test]
fn multiple_device_id_placeholders_in_one_subject_are_all_replaced() {
let cfg = PermissionsConfig {
r#pub: PermissionSubjects {
allow: vec!["{device_id}.{device_id}.event".to_string()],
deny: vec![],
},
sub: PermissionSubjects::default(),
};
let perms = interpolate_permissions(&cfg, "abc");
assert_eq!(perms.pub_allow, vec!["abc.abc.event"]);
}
// ----------------------------------------------------------------
// decide() — every reachable branch of the security decision tree
// ----------------------------------------------------------------
/// Build a `ZitadelValidator` whose `extract_device_id`/`extract_roles`
/// surface area is enough for `decide` — it never needs network or
/// signing keys for this code path. We hand-stuff the internal fields
/// the same way the live constructor would, just empty.
fn validator_for_decide(device_id_claim: &str) -> ZitadelValidator {
ZitadelValidator {
issuer_url: "https://issuer.example".to_string(),
audience: "aud".to_string(),
device_id_claim: device_id_claim.to_string(),
device_id_prefix_strip: String::new(),
http: reqwest::Client::new(),
keys: Arc::new(RwLock::new(HashMap::new())),
}
}
fn claims_with(device_id: serde_json::Value, roles: serde_json::Value) -> ZitadelClaims {
let mut extra = HashMap::new();
if !device_id.is_null() {
extra.insert("device_id".to_string(), device_id);
}
if !roles.is_null() {
extra.insert("urn:zitadel:iam:org:project:roles".to_string(), roles);
}
ZitadelClaims {
iss: "https://issuer.example".to_string(),
sub: "user-1".to_string(),
aud: json!("aud"),
exp: 0,
iat: 0,
extra,
}
}
fn cfg_with_defaults() -> AuthCalloutConfig {
AuthCalloutConfig::builder()
.nats_url("nats://x")
.issuer_kp(KeyPair::new_account())
.oidc_issuer_url("https://issuer.example")
.oidc_audience("aud")
.build()
.unwrap()
}
fn role_map(role: &str) -> serde_json::Value {
json!({ role: { "test-org": "Org" } })
}
#[test]
fn decide_authorizes_admin_role_with_full_perms() {
let cfg = cfg_with_defaults();
let v = validator_for_decide("device_id");
let claims = claims_with(json!("ops-1"), role_map("fleet-admin"));
match decide(&claims, &cfg, &v) {
Decision::Authorize {
device_id,
role,
perms,
} => {
assert_eq!(device_id, "ops-1");
assert_eq!(role, ResolvedRole::Admin);
assert_eq!(perms.pub_allow, vec![">"]);
assert_eq!(perms.sub_allow, vec![">"]);
}
other => panic!("expected Authorize(admin), got {other:?}"),
}
}
#[test]
fn decide_authorizes_device_role_with_interpolated_perms() {
let cfg = cfg_with_defaults();
let v = validator_for_decide("device_id");
let claims = claims_with(json!("sensor-7"), role_map("device"));
match decide(&claims, &cfg, &v) {
Decision::Authorize {
device_id,
role,
perms,
} => {
assert_eq!(device_id, "sensor-7");
assert_eq!(role, ResolvedRole::Device);
assert!(
perms.pub_allow.iter().any(|s| s == "device-state.sensor-7"),
"device_id must be interpolated into pub_allow: {:?}",
perms.pub_allow
);
assert!(
perms
.sub_allow
.iter()
.any(|s| s == "device-commands.sensor-7"),
"device_id must be interpolated into sub_allow: {:?}",
perms.sub_allow
);
}
other => panic!("expected Authorize(device), got {other:?}"),
}
}
#[test]
fn decide_admin_wins_when_user_has_both_roles() {
// Privilege escalation invariant: a user enrolled as both
// fleet-admin and device must not be silently downgraded.
let cfg = cfg_with_defaults();
let v = validator_for_decide("device_id");
let roles = json!({
"fleet-admin": { "org": "Org" },
"device": { "org": "Org" }
});
let claims = claims_with(json!("ops-and-device"), roles);
match decide(&claims, &cfg, &v) {
Decision::Authorize { role, perms, .. } => {
assert_eq!(role, ResolvedRole::Admin);
assert_eq!(perms.pub_allow, vec![">"]);
}
other => panic!("expected Authorize(admin), got {other:?}"),
}
}
#[test]
fn decide_rejects_when_no_role_present() {
let cfg = cfg_with_defaults();
let v = validator_for_decide("device_id");
let claims = claims_with(json!("user-1"), role_map("some-other-role"));
assert!(matches!(
decide(&claims, &cfg, &v),
Decision::Reject(RejectReason::NoAuthorizedRole)
));
}
#[test]
fn decide_rejects_when_roles_claim_absent_entirely() {
let cfg = cfg_with_defaults();
let v = validator_for_decide("device_id");
let claims = claims_with(json!("user-1"), serde_json::Value::Null);
assert!(matches!(
decide(&claims, &cfg, &v),
Decision::Reject(RejectReason::NoAuthorizedRole)
));
}
#[test]
fn decide_rejects_when_device_id_claim_missing() {
let cfg = cfg_with_defaults();
let v = validator_for_decide("device_id");
let claims = claims_with(serde_json::Value::Null, role_map("device"));
match decide(&claims, &cfg, &v) {
Decision::Reject(RejectReason::DeviceIdMissing(p)) => assert_eq!(p, "device_id"),
other => panic!("expected DeviceIdMissing, got {other:?}"),
}
}
#[test]
fn decide_rejects_when_device_id_is_not_a_string() {
let cfg = cfg_with_defaults();
let v = validator_for_decide("device_id");
let claims = claims_with(json!(42), role_map("device"));
assert!(matches!(
decide(&claims, &cfg, &v),
Decision::Reject(RejectReason::DeviceIdNotString(_))
));
}
#[test]
fn decide_rejects_device_id_with_subject_metacharacters() {
// Critical security gate: a malicious or buggy issuer that emits
// device_id="x.>" must NOT pass through to permissions
// interpolation. Each tested character would otherwise grant
// wildcard access on `device-state.x.<anything>`.
let cfg = cfg_with_defaults();
let v = validator_for_decide("device_id");
for evil in [".", "*", ">", " ", "a.b", "a*b", "a>b", "a b", ""] {
let claims = claims_with(json!(evil), role_map("device"));
let decision = decide(&claims, &cfg, &v);
assert!(
matches!(
decision,
Decision::Reject(
RejectReason::DeviceIdUnsafe(_) | RejectReason::DeviceIdMissing(_)
)
),
"evil device_id {evil:?} must reject, got {decision:?}"
);
}
}
#[test]
fn decide_rejects_runs_first_on_unsafe_device_id_even_when_role_is_admin() {
// Defense in depth: the device_id validation runs even for admin
// role, so a Zitadel mis-mapping that puts ".." into a
// fleet-admin user's device_id can't elevate via the {device_id}
// template (admin perms don't use it today, but the assertion
// protects future configurations that might).
let cfg = cfg_with_defaults();
let v = validator_for_decide("device_id");
let claims = claims_with(json!("ops.>"), role_map("fleet-admin"));
assert!(matches!(
decide(&claims, &cfg, &v),
Decision::Reject(RejectReason::DeviceIdUnsafe(_))
));
}
#[test]
fn decide_honours_custom_role_names_from_config() {
let cfg = AuthCalloutConfig::builder()
.nats_url("nats://x")
.issuer_kp(KeyPair::new_account())
.oidc_issuer_url("https://x")
.oidc_audience("y")
.admin_role("super-user")
.device_role("iot-thing")
.build()
.unwrap();
let v = validator_for_decide("device_id");
let su = claims_with(json!("svc"), role_map("super-user"));
match decide(&su, &cfg, &v) {
Decision::Authorize { role, .. } => assert_eq!(role, ResolvedRole::Admin),
other => panic!("expected Admin, got {other:?}"),
}
let iot = claims_with(json!("svc"), role_map("iot-thing"));
match decide(&iot, &cfg, &v) {
Decision::Authorize { role, .. } => assert_eq!(role, ResolvedRole::Device),
other => panic!("expected Device, got {other:?}"),
}
// The default role names must NOT match when custom names are set.
let stale = claims_with(json!("svc"), role_map("fleet-admin"));
assert!(matches!(
decide(&stale, &cfg, &v),
Decision::Reject(RejectReason::NoAuthorizedRole)
));
}
#[test]
fn decide_handles_array_shape_roles_claim() {
// OIDC providers other than Zitadel emit roles as a string array.
// The validator's extract_roles already handles both shapes; this
// test confirms decide() propagates that correctly.
let cfg = cfg_with_defaults();
let v = validator_for_decide("device_id");
let mut extra = HashMap::new();
extra.insert("device_id".to_string(), json!("sensor-1"));
extra.insert(
"urn:zitadel:iam:org:project:roles".to_string(),
json!(["device", "viewer"]),
);
let claims = ZitadelClaims {
iss: "https://issuer.example".to_string(),
sub: "user".to_string(),
aud: json!("aud"),
exp: 0,
iat: 0,
extra,
};
match decide(&claims, &cfg, &v) {
Decision::Authorize { role, .. } => assert_eq!(role, ResolvedRole::Device),
other => panic!("expected Device from array roles, got {other:?}"),
}
}
#[test]
fn decide_uses_sub_claim_when_device_id_claim_path_is_sub() {
let mut cfg = cfg_with_defaults();
cfg.device_id_claim = "sub".to_string();
let v = validator_for_decide("sub");
// No device_id key in extra; sub is the JWT subject.
let mut extra = HashMap::new();
extra.insert(
"urn:zitadel:iam:org:project:roles".to_string(),
role_map("device"),
);
let claims = ZitadelClaims {
iss: "https://issuer.example".to_string(),
sub: "sensor-from-sub".to_string(),
aud: json!("aud"),
exp: 0,
iat: 0,
extra,
};
match decide(&claims, &cfg, &v) {
Decision::Authorize { device_id, .. } => assert_eq!(device_id, "sensor-from-sub"),
other => panic!("expected Authorize, got {other:?}"),
}
}
#[test]
fn decide_uses_nested_dotted_device_id_path() {
let mut cfg = cfg_with_defaults();
cfg.device_id_claim = "metadata.hardware.id".to_string();
let v = validator_for_decide("metadata.hardware.id");
let mut extra = HashMap::new();
extra.insert(
"metadata".to_string(),
json!({ "hardware": { "id": "esp32-1" } }),
);
extra.insert(
"urn:zitadel:iam:org:project:roles".to_string(),
role_map("device"),
);
let claims = ZitadelClaims {
iss: "https://issuer.example".to_string(),
sub: "user".to_string(),
aud: json!("aud"),
exp: 0,
iat: 0,
extra,
};
match decide(&claims, &cfg, &v) {
Decision::Authorize { device_id, .. } => assert_eq!(device_id, "esp32-1"),
other => panic!("expected Authorize, got {other:?}"),
}
}
#[test]
fn reject_reason_display_is_actionable() {
// Operators read this string in NATS server logs when a callout
// rejects. It must name the failure category clearly.
assert_eq!(
RejectReason::NoAuthorizedRole.to_string(),
"no authorized role in token"
);
assert!(
RejectReason::DeviceIdMissing("device_id".to_string())
.to_string()
.contains("device_id")
);
let unsafe_msg =
RejectReason::DeviceIdUnsafe(crate::roles::DeviceIdError::Empty).to_string();
assert!(
unsafe_msg.contains("empty"),
"unsafe message must explain why: {unsafe_msg}"
);
}
}