Files
harmony/nats/callout/src/main.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

154 lines
5.9 KiB
Rust

//! Standalone NATS auth callout service binary.
//!
//! Configuration is read from environment variables. The service runs until
//! it receives SIGINT or SIGTERM, or its NATS subscription closes.
//!
//! ## Required env vars
//!
//! - `NATS_URL` — NATS server to connect to (e.g. `nats://nats:4222`).
//! - `OIDC_ISSUER_URL` — OIDC issuer (e.g. `https://auth.example.com`).
//! - `OIDC_AUDIENCE` — expected `aud` claim in inbound user JWTs.
//! - One of `ISSUER_NKEY_SEED_FILE` (path to a file containing the seed) or
//! `ISSUER_NKEY_SEED` (raw seed string `SAA...`). The file form is preferred
//! when running in K8s with a mounted secret.
//!
//! ## Optional env vars
//!
//! - `NATS_AUTH_USER` (default `auth`) — service's NATS account user.
//! - `NATS_AUTH_PASS_FILE` / `NATS_AUTH_PASS` (default `auth`) — service's password.
//! - `TARGET_ACCOUNT` (default `DEVICES`) — account name issued users land in.
//! - `DEVICE_ID_CLAIM` (default `device_id`) — JSON path to device identifier.
//! - `DEVICE_ID_PREFIX_STRIP` (default empty) — prefix stripped from the
//! extracted device id before permission interpolation. Set to `device-`
//! when consuming Zitadel's `client_id` claim with the
//! `device-{device_id}` machine-user naming convention.
//! - `ROLES_CLAIM` (default Zitadel URN) — JSON path to roles claim.
//! - `ADMIN_ROLE` (default `fleet-admin`) — role granting unrestricted perms.
//! - `DEVICE_ROLE` (default `device`) — role granting per-device perms.
//! - `DANGER_ACCEPT_INVALID_CERTS` (`true` for local dev with self-signed certs).
//! - `RUST_LOG` (default `info`) — tracing filter.
use std::env;
use std::fs;
use anyhow::{Context, Result};
use harmony_nats_callout::{
AuthCalloutConfig, AuthCalloutService, DEFAULT_ADMIN_ROLE, DEFAULT_DEVICE_ROLE,
DEFAULT_ROLES_CLAIM,
};
use nkeys::KeyPair;
use tracing::{error, info};
use tracing_subscriber::EnvFilter;
#[tokio::main]
async fn main() -> Result<()> {
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
tracing_subscriber::fmt().with_env_filter(filter).init();
let config = load_config_from_env().context("loading auth callout config from environment")?;
info!(
nats_url = %config.nats_url,
oidc_issuer = %config.oidc_issuer_url,
target_account = %config.target_account,
admin_role = %config.admin_role,
device_role = %config.device_role,
"starting harmony NATS auth callout"
);
let service = AuthCalloutService::new(config);
tokio::select! {
result = service.run() => {
if let Err(e) = result {
error!(error = %e, "auth callout service exited with error");
return Err(e);
}
}
_ = shutdown_signal() => {
info!("shutdown signal received, exiting");
}
}
Ok(())
}
fn load_config_from_env() -> Result<AuthCalloutConfig> {
let nats_url = require_env("NATS_URL")?;
let oidc_issuer_url = require_env("OIDC_ISSUER_URL")?;
let oidc_audience = require_env("OIDC_AUDIENCE")?;
let auth_user = env::var("NATS_AUTH_USER").unwrap_or_else(|_| "auth".to_string());
let auth_pass = read_secret("NATS_AUTH_PASS").unwrap_or_else(|| "auth".to_string());
let issuer_seed = read_secret("ISSUER_NKEY_SEED").ok_or_else(|| {
anyhow::anyhow!(
"issuer NKey seed is required: set ISSUER_NKEY_SEED_FILE (preferred) or ISSUER_NKEY_SEED"
)
})?;
let issuer_kp = KeyPair::from_seed(issuer_seed.trim())
.map_err(|e| anyhow::anyhow!("invalid ISSUER_NKEY_SEED: {e}"))?;
let target_account = env::var("TARGET_ACCOUNT").unwrap_or_else(|_| "DEVICES".to_string());
let device_id_claim = env::var("DEVICE_ID_CLAIM").unwrap_or_else(|_| "device_id".to_string());
let device_id_prefix_strip = env::var("DEVICE_ID_PREFIX_STRIP").unwrap_or_default();
let roles_claim = env::var("ROLES_CLAIM").unwrap_or_else(|_| DEFAULT_ROLES_CLAIM.to_string());
let admin_role = env::var("ADMIN_ROLE").unwrap_or_else(|_| DEFAULT_ADMIN_ROLE.to_string());
let device_role = env::var("DEVICE_ROLE").unwrap_or_else(|_| DEFAULT_DEVICE_ROLE.to_string());
let danger_accept_invalid_certs = env::var("DANGER_ACCEPT_INVALID_CERTS")
.ok()
.map(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes"))
.unwrap_or(false);
AuthCalloutConfig::builder()
.nats_url(nats_url)
.auth_user(auth_user)
.auth_pass(auth_pass)
.issuer_kp(issuer_kp)
.target_account(target_account)
.oidc_issuer_url(oidc_issuer_url)
.oidc_audience(oidc_audience)
.device_id_claim(device_id_claim)
.device_id_prefix_strip(device_id_prefix_strip)
.roles_claim(roles_claim)
.admin_role(admin_role)
.device_role(device_role)
.danger_accept_invalid_certs(danger_accept_invalid_certs)
.build()
}
fn require_env(name: &str) -> Result<String> {
env::var(name).map_err(|_| anyhow::anyhow!("required env var {name} is not set"))
}
/// Read a secret-style value: prefer `<NAME>_FILE` (path to a mounted secret)
/// over `<NAME>` (raw value) so K8s secret mounts are first-class.
fn read_secret(name: &str) -> Option<String> {
if let Ok(path) = env::var(format!("{name}_FILE")) {
match fs::read_to_string(&path) {
Ok(s) => return Some(s),
Err(e) => {
error!(path = %path, error = %e, "failed to read secret file");
}
}
}
env::var(name).ok()
}
#[cfg(unix)]
async fn shutdown_signal() {
use tokio::signal::unix::{SignalKind, signal};
let mut sigterm = signal(SignalKind::terminate()).expect("install SIGTERM handler");
let mut sigint = signal(SignalKind::interrupt()).expect("install SIGINT handler");
tokio::select! {
_ = sigterm.recv() => {},
_ = sigint.recv() => {},
}
}
#[cfg(not(unix))]
async fn shutdown_signal() {
let _ = tokio::signal::ctrl_c().await;
}