Some checks failed
Run Check Script / check (pull_request) Failing after -44h57m23s
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.
745 lines
26 KiB
Rust
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}"
|
|
);
|
|
}
|
|
}
|