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.
154 lines
5.9 KiB
Rust
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;
|
|
}
|