use base64::Engine;
use clap::{Parser, Subcommand};
use std::collections::HashMap;
use std::io::{self, Read};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use tenuo::{
approval::SignedApproval,
constraints::ConstraintValue,
crypto::SigningKey,
extraction::RequestContext,
gateway_config::{CompiledGatewayConfig, GatewayConfig},
heartbeat::{
self, create_audit_channel, ApprovalRecord, AuditEventSender, AuthorizationEvent,
EnvironmentInfo, HeartbeatConfig, MetricsCollector,
},
planes::Authorizer,
revocation::SignedRevocationList,
wire, PublicKey,
};
pub const AUTHORIZER_BUILD: u32 = 3;
pub fn authorizer_version() -> String {
format!("{}+authz.{}", env!("CARGO_PKG_VERSION"), AUTHORIZER_BUILD)
}
#[derive(Parser)]
#[command(name = "tenuo-authorizer")]
#[command(about = "Tenuo Data Plane Authorizer", long_about = None)]
struct Cli {
#[arg(long, env = "TENUO_TRUSTED_KEYS")]
trusted_keys: Option<String>,
#[arg(long, env = "TENUO_REVOCATION_LIST")]
revocation_list: Option<PathBuf>,
#[arg(long, env = "TENUO_REVOCATION_FLOOR")]
revocation_floor: Option<PathBuf>,
#[arg(long, env = "TENUO_CONNECT_TOKEN")]
connect_token: Option<String>,
#[arg(long, env = "TENUO_CONTROL_PLANE_URL")]
control_plane_url: Option<String>,
#[arg(long, env = "TENUO_API_KEY")]
api_key: Option<String>,
#[arg(long, env = "TENUO_AUTHORIZER_NAME")]
authorizer_name: Option<String>,
#[arg(long, env = "TENUO_AUTHORIZER_TYPE", default_value = "sidecar")]
authorizer_type: String,
#[arg(
long,
env = "TENUO_HEARTBEAT_INTERVAL",
default_value = "30",
value_parser = clap::value_parser!(u64).range(1..)
)]
heartbeat_interval: u64,
#[arg(long, env = "TENUO_AUDIT_BATCH_SIZE", default_value = "100")]
audit_batch_size: usize,
#[arg(long, env = "TENUO_AUDIT_FLUSH_INTERVAL", default_value = "10")]
audit_flush_interval: u64,
#[arg(long, env = "TENUO_SIGNING_KEY")]
signing_key: Option<String>,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Serve {
#[arg(short, long, default_value = "9090")]
port: u16,
#[arg(short, long)]
config: PathBuf,
#[arg(short, long, default_value = "0.0.0.0")]
bind: String,
#[cfg(unix)]
#[arg(long, conflicts_with_all = ["port", "bind"])]
socket: Option<PathBuf>,
#[cfg(unix)]
#[arg(long, default_value = "0660")]
socket_mode: String,
#[cfg(unix)]
#[arg(long)]
socket_group: Option<String>,
#[arg(long, env = "TENUO_HEALTH_PORT")]
health_port: Option<u16>,
#[arg(long, env = "TENUO_HEALTH_BIND")]
health_bind: Option<String>,
#[arg(
long,
env = "TENUO_LEGACY_HEALTH_ON_MAIN_PORT",
action = clap::ArgAction::SetTrue,
value_parser = clap::builder::BoolishValueParser::new()
)]
legacy_health_on_main_port: bool,
},
Verify {
#[arg(short, long)]
warrant: Option<String>,
#[arg(short, long)]
tool: String,
#[arg(short, long)]
arg: Vec<String>,
#[arg(short, long)]
pop: String,
#[arg(short, long, default_value = "exit-code")]
output: String,
},
Check {
#[arg(short, long)]
warrant: Option<String>,
},
Info,
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::from_default_env()
.add_directive(tracing::Level::INFO.into()),
)
.init();
let cli = Cli::parse();
let (authorizer, initial_srl_version) =
build_authorizer(&cli.trusted_keys, &cli.revocation_list)?;
match &cli.command {
#[cfg(unix)]
Commands::Serve {
config,
socket: Some(socket_path),
socket_mode,
socket_group,
health_port,
health_bind,
legacy_health_on_main_port,
..
} => {
let health = HealthOptions::resolve_unix(
*health_port,
health_bind.as_deref(),
*legacy_health_on_main_port,
)?;
serve_unix(
authorizer,
initial_srl_version,
config,
socket_path,
socket_mode,
socket_group.as_deref(),
&health,
&cli,
)
.await?;
}
Commands::Serve {
port,
config,
bind,
health_port,
health_bind,
legacy_health_on_main_port,
..
} => {
let health = HealthOptions::resolve_tcp(
bind,
*port,
*health_port,
health_bind.as_deref(),
*legacy_health_on_main_port,
)?;
serve_http(
authorizer,
initial_srl_version,
config,
bind,
*port,
&health,
&cli,
)
.await?;
}
Commands::Verify {
warrant,
tool,
arg,
pop,
output,
} => {
let warrant_str = read_warrant(warrant.clone())?;
let w = wire::decode_base64(&warrant_str)?;
let mut args = HashMap::new();
for a in arg {
let parts: Vec<&str> = a.splitn(2, '=').collect();
if parts.len() != 2 {
return Err(format!("Invalid argument format: {}", a).into());
}
args.insert(
parts[0].to_string(),
ConstraintValue::String(parts[1].to_string()),
);
}
let pop_bytes =
hex::decode(pop).map_err(|_| "Invalid PoP signature: must be hex-encoded")?;
let pop_arr: [u8; 64] = pop_bytes
.try_into()
.map_err(|_| "Invalid PoP signature: must be exactly 64 bytes")?;
let pop_signature = tenuo::Signature::from_bytes(&pop_arr)
.map_err(|_| "Invalid PoP signature format")?;
let result = authorizer.authorize_one(&w, tool, &args, Some(&pop_signature), &[]);
match output.as_str() {
"exit-code" => match result {
Ok(_) => std::process::exit(0),
Err(e) => {
eprintln!("Authorization failed: {}", e);
std::process::exit(1);
}
},
"json" => {
let json = serde_json::json!({
"authorized": result.is_ok(),
"error": result.err().map(|e| e.to_string()),
"warrant_id": w.id().to_string(),
"tool": tool,
});
println!("{}", serde_json::to_string_pretty(&json)?);
}
"quiet" => {
if result.is_err() {
std::process::exit(1);
}
}
_ => return Err(format!("Unknown output format: {}", output).into()),
}
}
Commands::Check { warrant } => {
let warrant_str = read_warrant(warrant.clone())?;
let w = wire::decode_base64(&warrant_str)?;
match w.verify(w.issuer()) {
Ok(()) => {
println!("✓ Warrant signature is valid");
println!(" ID: {}", w.id());
let tools = w.tools();
if !tools.is_empty() {
println!(" Tools: {}", tools.join(", "));
}
println!(" Expires: {}", w.expires_at());
if w.is_expired() {
println!(" ⚠ WARNING: Warrant has EXPIRED");
std::process::exit(2);
}
}
Err(e) => {
eprintln!("✗ Warrant invalid: {}", e);
std::process::exit(1);
}
}
}
Commands::Info => {
println!("Tenuo Authorizer v{}", authorizer_version());
println!();
if let Some(keys) = &cli.trusted_keys {
let count = keys.split(',').filter(|s| !s.is_empty()).count();
println!("Trusted keys: {} configured", count);
} else {
println!("Trusted keys: None (Warning: Will reject all delegated warrants unless keys are provided)");
}
if let Some(path) = &cli.revocation_list {
println!("Revocation list: {}", path.display());
} else {
println!("Revocation list: None");
}
}
}
Ok(())
}
fn build_authorizer(
trusted_keys: &Option<String>,
revocation_path: &Option<PathBuf>,
) -> Result<(Authorizer, Option<u64>), Box<dyn std::error::Error>> {
let mut authorizer = Authorizer::new();
if let Some(keys) = trusted_keys {
let mut any = false;
for key_hex in keys.split(',') {
let key_hex = key_hex.trim();
if key_hex.is_empty() {
continue;
}
let bytes = hex::decode(key_hex)?;
let arr: [u8; 32] = bytes.try_into().map_err(|_| "invalid key length")?;
authorizer.add_trusted_root(PublicKey::from_bytes(&arr)?);
any = true;
}
if !any {
return Err("TENUO_TRUSTED_KEYS is empty".into());
}
}
let initial_srl_version = if let (Some(path), Some(first)) = (
revocation_path,
authorizer.trusted_root_keys().first().cloned(),
) {
let srl = load_signed_revocation_list(path)?;
let version = srl.version();
authorizer.set_revocation_list(srl, &first)?;
eprintln!("Loaded signed revocation list from: {}", path.display());
Some(version)
} else {
None
};
Ok((authorizer, initial_srl_version))
}
fn parse_root_hex(hex_key: &str) -> Option<tenuo::crypto::PublicKey> {
let bytes = hex::decode(hex_key.trim()).ok()?;
let arr: [u8; 32] = bytes.try_into().ok()?;
tenuo::crypto::PublicKey::from_bytes(&arr).ok()
}
fn parse_first_root(keys: &str) -> Option<tenuo::crypto::PublicKey> {
keys.split(',').find_map(parse_root_hex)
}
fn apply_settings_trusted_roots(
authorizer: &mut Authorizer,
roots: &[String],
) -> Result<(), Box<dyn std::error::Error>> {
for hex_key in roots {
let Some(key) = parse_root_hex(hex_key) else {
return Err(format!("invalid trusted root hex: {hex_key}").into());
};
authorizer.add_trusted_root(key);
}
Ok(())
}
async fn shutdown_signal() {
let ctrl_c = tokio::signal::ctrl_c();
#[cfg(unix)]
{
let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("install SIGTERM handler");
tokio::select! {
_ = ctrl_c => {}
_ = sigterm.recv() => {}
}
}
#[cfg(not(unix))]
{
let _ = ctrl_c.await;
}
info!("shutdown signal received");
}
fn load_signed_revocation_list(
path: &PathBuf,
) -> Result<SignedRevocationList, Box<dyn std::error::Error>> {
let bytes = std::fs::read(path)?;
let srl = SignedRevocationList::from_bytes(&bytes)?;
Ok(srl)
}
fn read_warrant(warrant: Option<String>) -> Result<String, Box<dyn std::error::Error>> {
match warrant {
Some(w) if w == "-" => {
let mut buf = String::new();
io::stdin().read_to_string(&mut buf)?;
Ok(buf.trim().to_string())
}
Some(w) => Ok(w),
None => {
let mut buf = String::new();
io::stdin().read_to_string(&mut buf)?;
Ok(buf.trim().to_string())
}
}
}
use axum::{
body::Bytes,
extract::{Query, State},
http::{header::HeaderName, HeaderMap, HeaderValue, Method, StatusCode},
response::{IntoResponse, Response},
Json, Router,
};
use serde_json::{json, Value};
use std::net::SocketAddr;
use tracing::{debug, error, info, warn};
const SRL_WARMUP: Duration = Duration::from_secs(60);
const DEFAULT_SRL_MAX_AGE_SECS: u64 = 300;
const SRL_FRESHNESS_SLACK_SECS: u64 = 60;
struct AppState {
authorizer: Arc<tokio::sync::RwLock<Authorizer>>,
config: CompiledGatewayConfig,
debug_mode: bool,
audit_tx: Option<AuditEventSender>,
authorizer_id: Arc<tokio::sync::RwLock<Option<String>>>,
metrics: Option<MetricsCollector>,
started_at: std::time::Instant,
revocation_tracker: Option<Arc<tenuo::revocation_tracker::RevocationTracker>>,
srl_warmup: Duration,
}
struct HeartbeatShutdown {
handle: tokio::task::JoinHandle<()>,
stop: tokio::sync::oneshot::Sender<()>,
}
#[derive(Debug, serde::Serialize)]
struct DenyReason {
level: &'static str,
event: &'static str,
reason: String,
tool: String,
#[serde(skip_serializing_if = "Option::is_none")]
constraint: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
expected: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
actual: Option<Value>,
warrant_id: String,
request_id: String,
}
impl DenyReason {
fn new(tool: &str, warrant_id: &str, request_id: &str) -> Self {
Self {
level: "warn",
event: "authorization_denied",
reason: String::new(),
tool: tool.to_string(),
constraint: None,
expected: None,
actual: None,
warrant_id: warrant_id.to_string(),
request_id: request_id.to_string(),
}
}
fn with_reason(mut self, reason: impl Into<String>) -> Self {
self.reason = reason.into();
self
}
fn with_tool_not_allowed(mut self) -> Self {
self.reason = "tool_not_allowed".to_string();
self.constraint = Some("tool".to_string());
self
}
fn with_constraint(mut self, name: &str, expected: &str, actual: Value) -> Self {
self.reason = "constraint_violation".to_string();
self.constraint = Some(name.to_string());
self.expected = Some(expected.to_string());
self.actual = Some(actual);
self
}
fn to_header_value(&self) -> String {
if let (Some(constraint), Some(expected), Some(actual)) =
(&self.constraint, &self.expected, &self.actual)
{
format!(
"{}: {}={} exceeds {}",
self.reason, constraint, actual, expected
)
} else {
self.reason.clone()
}
}
}
struct ServeReady {
state: Arc<AppState>,
debug_mode: bool,
control_plane_enabled: bool,
heartbeat: Option<HeartbeatShutdown>,
srl_version: Option<u64>,
}
async fn prepare_serve(
authorizer: Authorizer,
initial_srl_version: Option<u64>,
config_path: &PathBuf,
cli: &Cli,
) -> Result<ServeReady, Box<dyn std::error::Error>> {
let config = GatewayConfig::from_file(config_path)?;
let mut authorizer = authorizer;
apply_settings_trusted_roots(&mut authorizer, &config.settings.trusted_roots)?;
authorizer.set_clock_tolerance(chrono::Duration::seconds(
config.settings.clock_tolerance_secs as i64,
));
if authorizer.trusted_root_keys().is_empty() {
eprintln!(
"WARNING: No trusted keys configured. Set TENUO_TRUSTED_KEYS or settings.trusted_roots."
);
}
let settings_first_root = authorizer.trusted_root_keys().first().cloned();
let loaded_srl = if let Some(path) = &cli.revocation_list {
let srl = load_signed_revocation_list(path)?;
let Some(first) = authorizer.trusted_root_keys().first().cloned() else {
return Err("a trusted root is required to verify the revocation list".into());
};
authorizer.set_revocation_list(srl.clone(), &first)?;
eprintln!("Loaded signed revocation list from: {}", path.display());
Some(srl)
} else {
None
};
let loaded_srl_version = loaded_srl
.as_ref()
.map(|srl| srl.version())
.or(initial_srl_version);
let debug_mode = config.settings.debug_mode;
let compiled = CompiledGatewayConfig::compile(config)?;
let (resolved_url, resolved_key, resolved_agent_id, resolved_connect_token) =
match cli.connect_token.as_deref() {
Some(token) => match tenuo::connect_token::ConnectToken::parse(token) {
Ok(ct) => {
info!(
endpoint = %ct.endpoint,
has_agent_id = ct.agent_id.is_some(),
"Connect token parsed"
);
let url = cli.control_plane_url.clone().or(Some(ct.endpoint.clone()));
let key = cli.api_key.clone().or(Some(ct.api_key.clone()));
let agent_id = ct.agent_id.clone();
(url, key, agent_id, Some(ct))
}
Err(e) => {
error!(error = %e, "TENUO_CONNECT_TOKEN is invalid");
std::process::exit(1);
}
},
None => (
cli.control_plane_url.clone(),
cli.api_key.clone(),
None,
None,
),
};
let resolved_name: Option<String> = cli.authorizer_name.clone().or_else(|| {
if resolved_connect_token.is_some() {
std::env::var("POD_NAME")
.or_else(|_| std::env::var("HOSTNAME"))
.ok()
.filter(|s| !s.is_empty())
.or_else(|| Some("tenuo-authorizer".to_string()))
} else {
None
}
});
let control_plane_enabled =
resolved_url.is_some() && resolved_key.is_some() && resolved_name.is_some();
let shared_authorizer = Arc::new(tokio::sync::RwLock::new(authorizer));
let shared_authorizer_id: Arc<tokio::sync::RwLock<Option<String>>> =
Arc::new(tokio::sync::RwLock::new(None));
let trusted_root = cli
.trusted_keys
.as_ref()
.and_then(|keys| parse_first_root(keys))
.or(settings_first_root);
let (audit_tx, metrics, heartbeat, revocation_tracker) =
if let (Some(url), Some(key), Some(name)) = (resolved_url, resolved_key, resolved_name) {
let (tx, rx) = create_audit_channel(1000);
let metrics = MetricsCollector::new();
if let Some(version) = loaded_srl_version {
metrics.record_srl_fetch(true, Some(version)).await;
}
let mut environment = EnvironmentInfo::from_env();
if let Some(ref agent_id) = resolved_agent_id {
environment
.metadata
.insert("agent_id".to_string(), agent_id.clone());
}
let signing_key = match &cli.signing_key {
Some(hex_key) => match hex::decode(hex_key) {
Ok(bytes) if bytes.len() == 32 => {
let mut arr = [0u8; 32];
arr.copy_from_slice(&bytes);
let key = SigningKey::from_bytes(&arr);
info!(
public_key = %hex::encode(key.public_key().to_bytes()),
"Signing key configured"
);
key
}
Ok(bytes) => {
error!(
got_len = bytes.len(),
"TENUO_SIGNING_KEY must be 32 bytes (64 hex chars)"
);
std::process::exit(1);
}
Err(e) => {
error!(error = %e, "TENUO_SIGNING_KEY must be valid hex");
std::process::exit(1);
}
},
None => {
let key = SigningKey::generate();
info!(
public_key = %hex::encode(key.public_key().to_bytes()),
"No TENUO_SIGNING_KEY set — using ephemeral signing key. \
Set TENUO_SIGNING_KEY to persist the key across restarts."
);
key
}
};
let revocation_tracker = if let Some(root) = trusted_root.as_ref() {
let floor = cli
.revocation_floor
.clone()
.unwrap_or_else(|| std::env::temp_dir().join("tenuo-srl-floors"));
let store =
tenuo::revocation_tracker::FileFloorStore::open(&floor).map_err(|e| {
format!(
"revocation floor unavailable at {}: {e}. \
Mount a writable path and set TENUO_REVOCATION_FLOOR",
floor.display()
)
})?;
let tracker = tenuo::revocation_tracker::RevocationTracker::new(
vec![root.clone()],
srl_max_age(cli.heartbeat_interval),
Duration::from_secs(30),
Arc::new(store),
)
.map_err(|e| format!("revocation tracker unavailable: {e}"))?;
Some(Arc::new(tracker))
} else {
None
};
if let (Some(tracker), Some(srl)) = (&revocation_tracker, &loaded_srl) {
let fetched_at = chrono::Utc::now();
if let Err(e) = tracker.accept(
tenuo::revocation_tracker::RevocationUpdate {
srl: srl.clone(),
fetched_at,
},
fetched_at,
) {
warn!(
error = %e,
"file revocation list was not accepted into the tracker"
);
}
}
let heartbeat_config = HeartbeatConfig {
control_plane_url: tenuo::connect_token::normalize_control_plane_url(&url),
api_key: key.clone(),
authorizer_name: name.clone(),
authorizer_type: cli.authorizer_type.clone(),
version: authorizer_version(),
interval_secs: cli.heartbeat_interval,
authorizer: Some(shared_authorizer.clone()),
trusted_root: trusted_root.clone(),
audit_batch_size: cli.audit_batch_size,
audit_flush_interval_secs: cli.audit_flush_interval,
environment,
metrics: Some(metrics.clone()),
signing_key,
id_notify: None,
agent_id: resolved_agent_id,
connect_token: resolved_connect_token,
revocation_tracker: revocation_tracker.clone(),
};
let (stop_tx, stop_rx) = tokio::sync::oneshot::channel();
let authorizer_id_writer = shared_authorizer_id.clone();
let handle = tokio::spawn(async move {
heartbeat::start_heartbeat_loop_until(
heartbeat_config,
Some(rx),
authorizer_id_writer,
Some(stop_rx),
)
.await;
});
info!("Heartbeat, metrics, and audit streaming enabled for control plane");
(
Some(tx),
Some(metrics),
Some(HeartbeatShutdown {
handle,
stop: stop_tx,
}),
revocation_tracker,
)
} else {
(None, None, None, None)
};
let state = Arc::new(AppState {
authorizer: shared_authorizer,
config: compiled,
debug_mode,
audit_tx,
authorizer_id: shared_authorizer_id,
metrics,
started_at: std::time::Instant::now(),
revocation_tracker,
srl_warmup: SRL_WARMUP,
});
Ok(ServeReady {
state,
debug_mode,
control_plane_enabled,
heartbeat,
srl_version: loaded_srl_version,
})
}
const DEFAULT_HEALTH_PORT: u16 = 9091;
#[derive(Debug, Clone, PartialEq, Eq)]
struct HealthOptions {
addr: Option<SocketAddr>,
legacy_on_main: bool,
}
impl HealthOptions {
fn resolve_tcp(
bind: &str,
port: u16,
health_port: Option<u16>,
health_bind: Option<&str>,
legacy_on_main: bool,
) -> Result<Self, Box<dyn std::error::Error>> {
let health_port = health_port.unwrap_or(DEFAULT_HEALTH_PORT);
if health_port != 0 && health_port == port {
return Err(format!(
"--health-port {health_port} is the same as --port: health endpoints must not \
share the authorization listener (Envoy/Istio ext_authz would treat their \
200 as ALLOW). Choose another --health-port, or 0 to disable it."
)
.into());
}
Ok(Self {
addr: Self::addr(health_bind.unwrap_or(bind), health_port)?,
legacy_on_main,
})
}
#[cfg(unix)]
fn resolve_unix(
health_port: Option<u16>,
health_bind: Option<&str>,
legacy_on_main: bool,
) -> Result<Self, Box<dyn std::error::Error>> {
Ok(Self {
addr: Self::addr(health_bind.unwrap_or("127.0.0.1"), health_port.unwrap_or(0))?,
legacy_on_main,
})
}
fn addr(bind: &str, port: u16) -> Result<Option<SocketAddr>, Box<dyn std::error::Error>> {
if port == 0 {
return Ok(None);
}
let addr: SocketAddr = format!("{}:{}", bind, port)
.parse()
.map_err(|e| format!("invalid health listener address {bind}:{port}: {e}"))?;
Ok(Some(addr))
}
fn banner_line(&self) -> String {
match self.addr {
Some(addr) => format!("health=http://{addr} (/health /healthz /ready /status)"),
None => "health=disabled".to_string(),
}
}
}
fn with_health_routes(router: Router<Arc<AppState>>) -> Router<Arc<AppState>> {
router
.route("/health", axum::routing::get(health_check))
.route("/healthz", axum::routing::get(health_check))
.route("/ready", axum::routing::get(health_check))
.route("/status", axum::routing::get(status_handler))
}
fn build_router(state: Arc<AppState>, legacy_health: bool) -> Router {
let router = Router::new();
let router = if legacy_health {
with_health_routes(router)
} else {
router
};
router.fallback(handle_request).with_state(state)
}
fn build_health_router(state: Arc<AppState>) -> Router {
with_health_routes(Router::new()).with_state(state)
}
struct HealthServer {
handle: tokio::task::JoinHandle<()>,
stop: tokio::sync::oneshot::Sender<()>,
#[cfg_attr(not(test), allow(dead_code))]
local_addr: SocketAddr,
}
async fn start_health_listener(
health: &HealthOptions,
state: Arc<AppState>,
) -> Result<Option<HealthServer>, Box<dyn std::error::Error>> {
let Some(addr) = health.addr else {
return Ok(None);
};
let listener = tokio::net::TcpListener::bind(addr)
.await
.map_err(|e| format!("failed to bind health listener on {addr}: {e}"))?;
let local_addr = listener.local_addr()?;
info!(addr = %local_addr, "Health listener ready");
let app = build_health_router(state);
let (stop, stop_rx) = tokio::sync::oneshot::channel::<()>();
let handle = tokio::spawn(async move {
let result = axum::serve(listener, app)
.with_graceful_shutdown(async {
let _ = stop_rx.await;
})
.await;
if let Err(e) = result {
error!(error = %e, "health listener failed");
}
});
Ok(Some(HealthServer {
handle,
stop,
local_addr,
}))
}
async fn stop_health_listener(server: Option<HealthServer>) {
if let Some(server) = server {
let _ = server.stop.send(());
let _ = server.handle.await;
}
}
fn warn_legacy_health(legacy_on_main: bool) {
if !legacy_on_main {
return;
}
eprintln!("!!! --legacy-health-on-main-port is set (UNSAFE, migration only)");
eprintln!("!!! /health, /healthz, /ready and /status answer 200 WITHOUT a warrant on the");
eprintln!("!!! authorization port. Behind Envoy/Istio HTTP ext_authz a 200 means ALLOW, so");
eprintln!("!!! unless the proxy sets path_prefix (e.g. /ext_authz) any client can reach");
eprintln!("!!! those paths on the backend with no warrant. Move probes to --health-port");
eprintln!("!!! and remove this flag. It is deprecated and will be removed in 0.4.0.");
eprintln!();
warn!(
"legacy_health_on_main_port is enabled: health and status routes answer 200 without a \
warrant on the authorization listener; behind ext_authz without path_prefix this is an \
authorization bypass for /health, /healthz, /ready and /status; the flag is deprecated \
and will be removed in 0.4.0"
);
}
fn srl_max_age(heartbeat_interval_secs: u64) -> Duration {
Duration::from_secs(
DEFAULT_SRL_MAX_AGE_SECS
.max(heartbeat_interval_secs.saturating_add(SRL_FRESHNESS_SLACK_SECS)),
)
}
fn srl_freshness_allows(
tracker: &tenuo::revocation_tracker::RevocationTracker,
warmup_active: bool,
) -> Result<(), tenuo::revocation_tracker::RevocationError> {
if tracker.has_accepted() {
tracker.latest(chrono::Utc::now()).map(|_| ())
} else if warmup_active {
Ok(())
} else {
Err(tenuo::revocation_tracker::RevocationError::Unavailable)
}
}
fn print_banner_shared(
transport_line: &str,
health: &HealthOptions,
config_path: &std::path::Path,
debug_mode: bool,
initial_srl_version: Option<u64>,
control_plane_enabled: bool,
heartbeat_interval: u64,
) {
eprintln!("┌─────────────────────────────────────────────────────────");
eprintln!("│ Tenuo Authorizer Server v{}", authorizer_version());
eprintln!("├─────────────────────────────────────────────────────────");
eprintln!("│ {}", transport_line);
eprintln!("│ {}", health.banner_line());
if health.legacy_on_main {
eprintln!("│ ⚠️ Health routes on the authorization port: ENABLED (unsafe, see below)");
}
eprintln!("│ Config: {}", config_path.display());
if debug_mode {
eprintln!("│ ⚠️ Debug mode: ENABLED (not for production!)");
}
if let Some(version) = initial_srl_version {
eprintln!("│ Revocation List: v{} (loaded from file)", version);
}
if control_plane_enabled {
eprintln!(
"│ Control Plane: ENABLED (heartbeat every {}s, SRL sync)",
heartbeat_interval
);
}
eprintln!("└─────────────────────────────────────────────────────────");
eprintln!();
warn_legacy_health(health.legacy_on_main);
}
async fn serve_http(
authorizer: Authorizer,
initial_srl_version: Option<u64>,
config_path: &PathBuf,
bind: &str,
port: u16,
health: &HealthOptions,
cli: &Cli,
) -> Result<(), Box<dyn std::error::Error>> {
let ready = prepare_serve(authorizer, initial_srl_version, config_path, cli).await?;
print_banner_shared(
&format!("transport=tcp listen={}:{}", bind, port),
health,
config_path,
ready.debug_mode,
ready.srl_version,
ready.control_plane_enabled,
cli.heartbeat_interval,
);
let app = build_router(ready.state.clone(), health.legacy_on_main);
let addr: SocketAddr = format!("{}:{}", bind, port).parse()?;
let listener = tokio::net::TcpListener::bind(addr).await?;
let health_server = start_health_listener(health, ready.state.clone()).await?;
let served = axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await;
stop_health_listener(health_server).await;
finish_heartbeat(ready.state, ready.heartbeat).await;
served?;
Ok(())
}
#[cfg(unix)]
fn prepare_unix_socket(path: &std::path::Path) -> Result<(), Box<dyn std::error::Error>> {
use std::os::unix::fs::{FileTypeExt as _, PermissionsExt as _};
if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
if !parent.exists() {
std::fs::create_dir_all(parent).map_err(|e| {
format!(
"failed to create socket directory '{}': {}",
parent.display(),
e
)
})?;
}
let dir_meta = std::fs::metadata(parent)
.map_err(|e| format!("cannot stat socket directory '{}': {}", parent.display(), e))?;
let mode = dir_meta.permissions().mode();
if mode & 0o022 != 0 {
return Err(format!(
"refusing to bind socket in '{}': directory is group/world-writable \
(mode {:o}). An attacker who can write here could impersonate the \
authorizer. Use a directory writable only by the service user (e.g. \
a systemd RuntimeDirectory).",
parent.display(),
mode & 0o777,
)
.into());
}
}
match std::fs::symlink_metadata(path) {
Ok(meta) => {
let ft = meta.file_type();
if ft.is_symlink() {
return Err(format!(
"refusing to remove '{}': path is a symlink — possible path-confusion attack",
path.display()
)
.into());
}
if !ft.is_socket() {
return Err(format!(
"refusing to remove '{}': path exists but is not a socket (is a {})",
path.display(),
if ft.is_dir() {
"directory"
} else {
"regular file"
},
)
.into());
}
info!(socket = %path.display(), "Removing stale Unix socket");
std::fs::remove_file(path)?;
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
}
Err(e) => return Err(e.into()),
}
Ok(())
}
#[cfg(unix)]
#[allow(clippy::too_many_arguments)]
async fn serve_unix(
authorizer: Authorizer,
initial_srl_version: Option<u64>,
config_path: &PathBuf,
socket_path: &PathBuf,
socket_mode: &str,
socket_group: Option<&str>,
health: &HealthOptions,
cli: &Cli,
) -> Result<(), Box<dyn std::error::Error>> {
use std::os::unix::fs::PermissionsExt as _;
let mode = parse_socket_mode(socket_mode)?;
let gid = match socket_group {
Some(group) => Some(resolve_gid(group)?),
None => None,
};
let ready = prepare_serve(authorizer, initial_srl_version, config_path, cli).await?;
print_banner_shared(
&format!("transport=unix socket={}", socket_path.display()),
health,
config_path,
ready.debug_mode,
ready.srl_version,
ready.control_plane_enabled,
cli.heartbeat_interval,
);
prepare_unix_socket(socket_path)?;
let listener = tokio::net::UnixListener::bind(socket_path)?;
if let Some(gid) = gid {
std::os::unix::fs::chown(socket_path, None, Some(gid)).map_err(|e| {
format!(
"failed to set socket group (gid {}) on '{}': {}",
gid,
socket_path.display(),
e
)
})?;
}
std::fs::set_permissions(socket_path, std::fs::Permissions::from_mode(mode))?;
if mode & 0o002 != 0 {
warn!(
socket = %socket_path.display(),
mode = format!("{:04o}", mode),
"Unix socket is world-connectable (any local user can send requests)"
);
}
info!(
socket = %socket_path.display(),
mode = format!("{:04o}", mode),
group = socket_group.unwrap_or("(unchanged)"),
"Unix socket ready"
);
let app = build_router(ready.state.clone(), health.legacy_on_main);
let health_server = start_health_listener(health, ready.state.clone()).await?;
let served = axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await;
stop_health_listener(health_server).await;
finish_heartbeat(ready.state, ready.heartbeat).await;
served?;
Ok(())
}
async fn finish_heartbeat(state: Arc<AppState>, heartbeat: Option<HeartbeatShutdown>) {
let Some(heartbeat) = heartbeat else {
return;
};
drop(state);
let _ = heartbeat.stop.send(());
match tokio::time::timeout(Duration::from_secs(10), heartbeat.handle).await {
Ok(Ok(())) => info!("control-plane flush finished"),
Ok(Err(e)) => warn!(error = %e, "control-plane task failed during shutdown"),
Err(_) => warn!("control-plane flush timed out after 10s"),
}
}
#[cfg(unix)]
fn parse_socket_mode(s: &str) -> Result<u32, Box<dyn std::error::Error>> {
let trimmed = s.trim();
let digits = trimmed
.strip_prefix("0o")
.or_else(|| trimmed.strip_prefix("0O"))
.unwrap_or(trimmed);
if digits.is_empty() {
return Err(format!("invalid --socket-mode '{}': empty", s).into());
}
let mode = u32::from_str_radix(digits, 8)
.map_err(|_| format!("invalid --socket-mode '{}': expected octal like 0660", s))?;
if mode > 0o7777 {
return Err(format!("invalid --socket-mode '{}': out of range", s).into());
}
Ok(mode)
}
#[cfg(unix)]
fn resolve_gid(group: &str) -> Result<u32, Box<dyn std::error::Error>> {
let group = group.trim();
if group.is_empty() {
return Err("invalid --socket-group: empty".into());
}
if let Ok(gid) = group.parse::<u32>() {
return Ok(gid);
}
let cname = std::ffi::CString::new(group)
.map_err(|_| format!("invalid --socket-group '{}': contains NUL", group))?;
let gid = unsafe {
let grp = libc::getgrnam(cname.as_ptr());
if grp.is_null() {
return Err(format!("unknown --socket-group '{}': no such group", group).into());
}
(*grp).gr_gid
};
Ok(gid)
}
async fn health_check() -> impl axum::response::IntoResponse {
(
StatusCode::OK,
Json(serde_json::json!({
"status": "healthy",
"service": "tenuo-authorizer"
})),
)
}
async fn status_handler(State(state): State<Arc<AppState>>) -> impl axum::response::IntoResponse {
let uptime_secs = state.started_at.elapsed().as_secs();
let cp_enabled = state.audit_tx.is_some();
let (cp_status, authorizer_id) = if cp_enabled {
let id = state.authorizer_id.read().await.clone();
let status = if id.is_some() {
"registered"
} else {
"registering"
};
(status, id)
} else {
("disabled", None)
};
(
StatusCode::OK,
Json(serde_json::json!({
"version": env!("CARGO_PKG_VERSION"),
"uptime_secs": uptime_secs,
"cp": {
"enabled": cp_enabled,
"status": cp_status,
"authorizer_id": authorizer_id,
}
})),
)
}
fn deny_response(debug_mode: bool, status: StatusCode, body: Value) -> Response {
let reason = body
.get("error")
.and_then(Value::as_str)
.and_then(|r| HeaderValue::from_str(r).ok());
let mut response = (status, Json(body)).into_response();
if debug_mode {
if let Some(value) = reason {
response
.headers_mut()
.insert(HeaderName::from_static("x-tenuo-deny-reason"), value);
}
}
response
}
async fn handle_request(
State(state): State<Arc<AppState>>,
method: Method,
headers: HeaderMap,
uri: axum::http::Uri,
Query(query): Query<HashMap<String, String>>,
body: Bytes,
) -> Response {
let request_id = format!("req_{}", uuid::Uuid::now_v7().simple());
let path = uri.path().to_string();
let route_match = match state.config.match_route(method.as_str(), &path) {
Some(m) => m,
None => {
return deny_response(
state.debug_mode,
StatusCode::NOT_FOUND,
json!({
"error": "no_route",
"message": format!("No route matches {} {}", method, path),
"request_id": request_id
}),
);
}
};
let warrant_header = &state.config.settings.warrant_header;
let warrant_b64 = match headers.get(warrant_header) {
Some(v) => match v.to_str() {
Ok(s) => s.to_string(),
Err(_) => {
return deny_response(
state.debug_mode,
StatusCode::BAD_REQUEST,
json!({
"error": "invalid_header",
"message": format!("Invalid {} header encoding", warrant_header),
"request_id": request_id
}),
);
}
},
None => {
warn!(
request_id = %request_id,
event = "authorization_denied",
reason = "missing_warrant",
"Missing warrant header"
);
emit_audit_event(
&state,
AuthorizationEvent::deny(
String::new(), String::new(), route_match.route.tool.to_string(),
"missing_warrant".to_string(),
None,
0,
None,
None,
0,
request_id.clone(),
None,
None,
),
)
.await;
if let Some(ref metrics) = state.metrics {
metrics
.record_authorization(
false,
&route_match.route.tool,
0,
"",
None,
Some("missing_warrant"),
)
.await;
}
return deny_response(
state.debug_mode,
StatusCode::UNAUTHORIZED,
json!({
"error": "missing_warrant",
"message": format!("Missing {} header", warrant_header),
"request_id": request_id
}),
);
}
};
let chain = match decode_warrant_or_chain(&warrant_b64) {
Ok(c) => c,
Err(e) => {
warn!(
request_id = %request_id,
event = "authorization_denied",
reason = "invalid_warrant",
error = %e,
"Failed to decode warrant/chain"
);
emit_audit_event(
&state,
AuthorizationEvent::deny(
String::new(), String::new(), route_match.route.tool.to_string(),
format!("invalid_warrant: {}", e),
None,
0,
None,
None,
0,
request_id.clone(),
None,
None,
),
)
.await;
if let Some(ref metrics) = state.metrics {
metrics
.record_authorization(
false,
&route_match.route.tool,
0,
"",
None,
Some("invalid_warrant"),
)
.await;
}
return deny_response(
state.debug_mode,
StatusCode::BAD_REQUEST,
json!({
"error": "invalid_warrant",
"message": format!("Failed to decode warrant: {}", e),
"request_id": request_id
}),
);
}
};
let leaf_warrant = chain.last().unwrap(); let warrant_id = leaf_warrant.id().to_string();
let chain_length = chain.len();
if chain_length > 1 {
debug!(
request_id = %request_id,
warrant_id = %warrant_id,
chain_length = %chain_length,
"Received warrant chain"
);
} else if leaf_warrant.depth() > 0 {
warn!(
request_id = %request_id,
warrant_id = %warrant_id,
depth = %leaf_warrant.depth(),
"Received orphaned delegated warrant (depth > 0) without parent chain. Zero-Trust best practice is to send full WarrantStack."
);
}
let json_body: Value = if body.is_empty() {
Value::Null
} else {
match serde_json::from_slice(&body) {
Ok(v) => v,
Err(_) => Value::Null, }
};
let arguments_json = if json_body.is_null() {
None
} else {
Some(json_body.to_string())
};
let mut http_headers = HashMap::new();
for (name, value) in headers.iter() {
if let Ok(v) = value.to_str() {
http_headers.insert(name.to_string().to_lowercase(), v.to_string());
}
}
let mut ctx = RequestContext::with_body(json_body.clone());
ctx.path_params = route_match.path_params.clone();
ctx.query_params = query;
ctx.headers = http_headers;
let extraction_result = match state.config.extract_constraints(&route_match, &ctx) {
Ok(res) => res,
Err(e) => {
warn!(
request_id = %request_id,
route = %path,
event = "authorization_denied",
reason = "extraction_failed",
error = %e,
"Failed to extract constraints from request"
);
return deny_response(
state.debug_mode,
StatusCode::BAD_REQUEST,
json!({
"error": "extraction_failed",
"message": format!("Failed to extract constraints: {}", e),
"request_id": request_id
}),
);
}
};
let pop_header = &state.config.settings.pop_header;
let pop_signature: Option<tenuo::Signature> = headers
.get(pop_header)
.and_then(|v| v.to_str().ok())
.and_then(|encoded| {
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(encoded)
.or_else(|_| base64::engine::general_purpose::STANDARD.decode(encoded))
.or_else(|_| hex::decode(encoded))
.ok()?;
if bytes.len() == 64 {
let arr: [u8; 64] = bytes.try_into().ok()?;
tenuo::Signature::from_bytes(&arr).ok()
} else {
None
}
});
let approval_header = &state.config.settings.approval_header;
let approvals: Vec<SignedApproval> = match headers
.get(approval_header)
.and_then(|v| v.to_str().ok())
.filter(|s| !s.is_empty())
{
Some(encoded) => {
let bytes = match base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(encoded)
.or_else(|_| base64::engine::general_purpose::STANDARD.decode(encoded))
{
Ok(b) => b,
Err(_) => {
warn!(request_id = %request_id, "Invalid base64 in {} header", approval_header);
return deny_response(
state.debug_mode,
StatusCode::BAD_REQUEST,
json!({
"error": "invalid_approvals_header",
"message": format!("Could not base64-decode {} header", approval_header),
"request_id": request_id
}),
);
}
};
const MAX_APPROVAL_HEADER_BYTES: usize = 65_536;
if bytes.len() > MAX_APPROVAL_HEADER_BYTES {
warn!(
request_id = %request_id,
size = bytes.len(),
"Approvals header exceeds size limit"
);
return deny_response(
state.debug_mode,
StatusCode::BAD_REQUEST,
json!({
"error": "invalid_approvals_header",
"message": format!("{} header too large ({} bytes, max {})", approval_header, bytes.len(), MAX_APPROVAL_HEADER_BYTES),
"request_id": request_id
}),
);
}
if let Ok(vec) = ciborium::de::from_reader::<Vec<SignedApproval>, _>(&bytes[..]) {
vec
} else if let Ok(single) = ciborium::de::from_reader::<SignedApproval, _>(&bytes[..]) {
vec![single]
} else {
warn!(request_id = %request_id, "Failed to deserialize CBOR from {} header", approval_header);
return deny_response(
state.debug_mode,
StatusCode::BAD_REQUEST,
json!({
"error": "invalid_approvals_header",
"message": format!("Could not deserialize CBOR from {} header", approval_header),
"request_id": request_id
}),
);
}
}
None => Vec::new(),
};
if !approvals.is_empty() {
debug!(
request_id = %request_id,
count = approvals.len(),
"Extracted signed approvals from header"
);
}
let total_start = std::time::Instant::now();
if let Some(tracker) = &state.revocation_tracker {
if let Err(e) = srl_freshness_allows(tracker, state.started_at.elapsed() < state.srl_warmup)
{
warn!(
request_id = %request_id,
event = "authorization_denied",
reason = "revocation_stale",
error = %e,
"Signed revocation list is missing or stale"
);
return deny_response(
state.debug_mode,
StatusCode::FORBIDDEN,
json!({
"error": "revocation_unavailable",
"message": "signed revocation list is missing or stale",
"request_id": request_id
}),
);
}
}
let lock_start = std::time::Instant::now();
let authorizer = state.authorizer.read().await;
let lock_us = lock_start.elapsed().as_micros() as u64;
let check_start = std::time::Instant::now();
let result = authorizer.check_chain(
&chain,
&extraction_result.tool,
&extraction_result.constraints,
pop_signature.as_ref(),
&approvals,
);
let check_us = check_start.elapsed().as_micros() as u64;
drop(authorizer);
let total_us = total_start.elapsed().as_micros() as u64;
let latency_us = check_us;
if state.debug_mode {
info!(
request_id = %request_id,
lock_us = %lock_us,
check_chain_us = %check_us,
core_us = %latency_us,
total_us = %total_us,
chain_depth = %chain.len(),
"Timing breakdown"
);
}
let chain_depth = leaf_warrant.depth() as u8;
let root_principal = chain.first().map(|w| hex::encode(w.issuer().to_bytes()));
let warrant_stack_b64 = encode_warrant_stack_for_audit(&chain);
match result {
Ok(ref cvr) => {
info!(
request_id = %request_id,
warrant_id = %warrant_id,
tool = %extraction_result.tool,
event = "authorization_success",
"Request authorized"
);
let approval_records = if cvr.verified_approvals.is_empty() {
None
} else {
Some(
cvr.verified_approvals
.iter()
.map(|va| ApprovalRecord {
approver_key: hex::encode(va.approver_key),
external_id: va.external_id.clone(),
approved_at: va.approved_at,
expires_at: va.expires_at,
request_hash: hex::encode(va.request_hash),
signed_approval_cbor_b64: va.signed_approval_cbor_b64.clone(),
})
.collect(),
)
};
emit_audit_event(
&state,
AuthorizationEvent::allow(
String::new(), warrant_id.clone(),
extraction_result.tool.clone(),
chain_depth,
root_principal.clone(),
warrant_stack_b64.clone(),
latency_us,
request_id.clone(),
arguments_json.clone(),
approval_records,
),
)
.await;
if let Some(ref metrics) = state.metrics {
metrics
.record_authorization(
true,
&extraction_result.tool,
latency_us,
&warrant_id,
root_principal.as_deref(),
None,
)
.await;
}
(
StatusCode::OK,
Json(json!({
"authorized": true,
"warrant_id": warrant_id,
"tool": extraction_result.tool,
"request_id": request_id
})),
)
.into_response()
}
Err(e) => {
let deny_reason = parse_deny_reason(
&e,
&extraction_result.tool,
&warrant_id,
&request_id,
&extraction_result.constraints,
);
if let Ok(log_json) = serde_json::to_string(&deny_reason) {
eprintln!("{}", log_json);
}
warn!(
request_id = %request_id,
warrant_id = %warrant_id,
tool = %extraction_result.tool,
event = "authorization_denied",
reason = %deny_reason.reason,
constraint = ?deny_reason.constraint,
expected = ?deny_reason.expected,
actual = ?deny_reason.actual,
"Authorization denied"
);
let (error_code, error_name, safe_message) = sanitize_error(&e);
let mut body = json!({
"authorized": false,
"error": error_name, "error_code": error_code, "message": safe_message,
"warrant_id": warrant_id,
"tool": extraction_result.tool,
"request_id": request_id
});
match &e {
tenuo::Error::ApprovalRequired { request, .. } => {
if let Some(obj) = body.as_object_mut() {
obj.insert("message".to_string(), json!(request.message.clone()));
obj.insert(
"request_hash".to_string(),
json!(hex::encode(request.request_hash)),
);
obj.insert(
"required_approvals".to_string(),
json!(request.min_approvals),
);
obj.insert("received_approvals".to_string(), json!(0));
let keys: Vec<String> = request
.required_approvers
.iter()
.map(|k| hex::encode(k.to_bytes()))
.collect();
obj.insert("required_approvers".to_string(), json!(keys));
}
}
tenuo::Error::InsufficientApprovals {
required, received, ..
} => {
let request_hash = tenuo::approval::compute_request_hash(
&warrant_id,
&extraction_result.tool,
&extraction_result.constraints,
Some(leaf_warrant.authorized_holder()),
);
if let Some(obj) = body.as_object_mut() {
obj.insert("request_hash".to_string(), json!(hex::encode(request_hash)));
obj.insert("required_approvals".to_string(), json!(required));
obj.insert("received_approvals".to_string(), json!(received));
if let Some(approvers) = leaf_warrant.required_approvers() {
let keys: Vec<String> = approvers
.iter()
.map(|k| hex::encode(k.to_bytes()))
.collect();
obj.insert("required_approvers".to_string(), json!(keys));
}
}
}
_ => {}
}
if state.debug_mode {
if let Some(obj) = body.as_object_mut() {
obj.insert("debug_error".to_string(), json!(format!("{}", e)));
obj.insert("debug_details".to_string(), json!(deny_reason));
}
}
let mut response = (StatusCode::FORBIDDEN, Json(body)).into_response();
if state.debug_mode {
if let Ok(header_value) = HeaderValue::from_str(&deny_reason.to_header_value()) {
response
.headers_mut()
.insert(HeaderName::from_static("x-tenuo-deny-reason"), header_value);
}
}
emit_audit_event(
&state,
AuthorizationEvent::deny(
String::new(), warrant_id.clone(),
extraction_result.tool.clone(),
deny_reason.reason.clone(),
deny_reason.constraint.clone(),
chain_depth,
root_principal.clone(),
warrant_stack_b64,
latency_us,
request_id.clone(),
arguments_json,
None,
),
)
.await;
if let Some(ref metrics) = state.metrics {
metrics
.record_authorization(
false,
&extraction_result.tool,
latency_us,
&warrant_id,
root_principal.as_deref(),
Some(&deny_reason.reason),
)
.await;
}
response
}
}
}
fn encode_warrant_stack_for_audit(chain: &[tenuo::Warrant]) -> Option<String> {
if chain.is_empty() {
return None;
}
let stack = wire::WarrantStack(chain.to_vec());
match wire::encode_stack(&stack) {
Ok(bytes) => Some(base64::engine::general_purpose::STANDARD.encode(&bytes)),
Err(e) => {
warn!(error = %e, "Failed to encode warrant stack for audit");
None
}
}
}
async fn emit_audit_event(state: &AppState, mut event: AuthorizationEvent) {
if let Some(ref tx) = state.audit_tx {
let authorizer_id = state.authorizer_id.read().await;
if let Some(ref id) = *authorizer_id {
event.authorizer_id = id.clone();
let decision = event.decision;
let tool = event.tool.clone();
if let Err(e) = tx.try_send(event) {
match e {
tokio::sync::mpsc::error::TrySendError::Full(_) => {
warn!(
decision = decision,
tool = %tool,
"Audit event dropped: channel buffer full (high authorization rate or slow control plane)"
);
}
tokio::sync::mpsc::error::TrySendError::Closed(_) => {
debug!("Audit event dropped: channel closed (shutdown in progress)");
}
}
}
} else {
warn!(
decision = event.decision,
tool = %event.tool,
request_id = %event.request_id,
"Audit event dropped: authorizer not registered with control plane yet (early request)"
);
}
}
}
fn decode_warrant_or_chain(b64: &str) -> Result<Vec<tenuo::Warrant>, tenuo::Error> {
use base64::Engine;
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(b64.trim())
.or_else(|_| base64::engine::general_purpose::STANDARD.decode(b64.trim()))
.map_err(|e| tenuo::Error::DeserializationError(format!("Base64 decode failed: {}", e)))?;
if let Ok(stack) = wire::decode_stack(&bytes) {
let chain = stack.0;
if chain.is_empty() {
return Err(tenuo::Error::DeserializationError(
"Empty warrant stack".into(),
));
}
return Ok(chain);
}
let warrant: tenuo::Warrant = ciborium::de::from_reader(&bytes[..])
.map_err(|e| tenuo::Error::DeserializationError(format!("CBOR decode failed: {}", e)))?;
Ok(vec![warrant])
}
fn parse_deny_reason(
error: &tenuo::Error,
tool: &str,
warrant_id: &str,
request_id: &str,
constraints: &HashMap<String, ConstraintValue>,
) -> DenyReason {
use tenuo::Error;
let mut deny = DenyReason::new(tool, warrant_id, request_id);
match error {
Error::ToolNotAuthorized { .. } => {
deny = deny.with_tool_not_allowed();
}
Error::ConstraintNotSatisfied { field, reason } => {
let actual = constraints
.get(field)
.map(|v| match v {
ConstraintValue::String(s) => json!(s),
ConstraintValue::Integer(i) => json!(i),
ConstraintValue::Float(f) => json!(f),
ConstraintValue::Boolean(b) => json!(b),
ConstraintValue::List(l) => json!(l),
ConstraintValue::Object(o) => json!(o),
ConstraintValue::Null => json!(null),
})
.unwrap_or(json!(null));
deny = deny.with_constraint(field, reason, actual);
}
Error::WarrantExpired {
warrant_id,
expired_at,
} => {
deny = deny.with_reason(format!("warrant_expired: {} at {}", warrant_id, expired_at));
}
Error::WarrantRevoked(id) => {
deny = deny.with_reason(format!("warrant_revoked: {}", id));
}
Error::SignatureInvalid(msg) => {
deny = deny.with_reason(format!("signature_invalid: {}", msg));
}
Error::UntrustedRoot => {
deny = deny.with_reason("untrusted_root: root warrant issuer not trusted".to_string());
}
Error::MissingSignature(msg) => {
deny = deny.with_reason(format!("missing_pop: {}", msg));
}
Error::Unauthorized(msg) => {
deny = deny.with_reason(format!("unauthorized: {}", msg));
}
Error::DepthExceeded(current, max) => {
deny = deny.with_reason(format!("depth_exceeded: {} > {}", current, max));
}
Error::ChainVerificationFailed(msg) => {
deny = deny.with_reason(format!("chain_verification_failed: {}", msg));
}
_ => {
deny = deny.with_reason(format!("{}", error));
}
}
deny
}
fn sanitize_error(error: &tenuo::Error) -> (u16, &'static str, &'static str) {
let error_code = error.code();
let name = error_code.name();
let description = error_code.description();
(error_code.code(), name, description)
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use axum::http::Request;
use base64::Engine;
use tenuo::{
approval::{compute_request_hash, ApprovalPayload, SignedApproval},
approval_gate::{encode_approval_gate_map, ApprovalGateMap, ToolApprovalGate},
constraints::ConstraintSet,
};
use tower::ServiceExt;
const GATEWAY_YAML: &str = r#"
version: "1"
settings:
debug_mode: true
tools:
deploy:
description: "Deploy service"
constraints:
service:
from: path
path: "service"
required: true
routes:
- pattern: "/deploy/{service}"
method: ["POST"]
tool: "deploy"
"#;
#[test]
fn parse_deny_reason_distinguishes_tool_scope_from_argument_constraint() {
let missing_tool = tenuo::Error::ToolNotAuthorized {
tool: "delete_file".to_string(),
};
let deny = parse_deny_reason(
&missing_tool,
"delete_file",
"tnu_wrt_test",
"request-1",
&HashMap::new(),
);
assert_eq!(deny.reason, "tool_not_allowed");
assert_eq!(deny.constraint.as_deref(), Some("tool"));
let invalid_argument = tenuo::Error::ConstraintNotSatisfied {
field: "path".to_string(),
reason: "outside allowed prefix".to_string(),
};
let deny = parse_deny_reason(
&invalid_argument,
"read_file",
"tnu_wrt_test",
"request-2",
&HashMap::new(),
);
assert_eq!(deny.reason, "constraint_violation");
assert_eq!(deny.constraint.as_deref(), Some("path"));
}
fn build_test_app(authorizer: Authorizer) -> Router {
let config = GatewayConfig::from_yaml(GATEWAY_YAML).unwrap();
let compiled = CompiledGatewayConfig::compile(config).unwrap();
let state = Arc::new(AppState {
authorizer: Arc::new(tokio::sync::RwLock::new(authorizer)),
config: compiled,
debug_mode: true,
audit_tx: None,
authorizer_id: Arc::new(tokio::sync::RwLock::new(None)),
metrics: None,
started_at: std::time::Instant::now(),
revocation_tracker: None,
srl_warmup: Duration::from_secs(60),
});
build_router(state, false)
}
fn create_gated_warrant(
root_key: &SigningKey,
approver_key: &tenuo::crypto::PublicKey,
) -> tenuo::Warrant {
let mut gates = ApprovalGateMap::new();
gates.insert("deploy".to_string(), ToolApprovalGate::whole_tool());
tenuo::Warrant::builder()
.capability("deploy", ConstraintSet::new())
.ttl(std::time::Duration::from_secs(300))
.required_approvers(vec![approver_key.clone()])
.min_approvals(1)
.holder(root_key.public_key())
.extension(
"tenuo.approval_gates",
encode_approval_gate_map(&gates).unwrap(),
)
.build(root_key)
.unwrap()
}
fn encode_warrant_header(warrant: &tenuo::Warrant) -> String {
wire::encode_base64(warrant).unwrap()
}
fn encode_pop_header(sig: &tenuo::Signature) -> String {
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(sig.to_bytes())
}
fn create_approval(
warrant: &tenuo::Warrant,
tool: &str,
args: &HashMap<String, ConstraintValue>,
approver_key: &SigningKey,
) -> SignedApproval {
let request_hash = compute_request_hash(
&warrant.id().to_string(),
tool,
args,
Some(warrant.authorized_holder()),
);
let now = chrono::Utc::now();
let payload = ApprovalPayload {
version: 1,
request_hash,
nonce: rand::random(),
external_id: "test-approver@test.com".to_string(),
approved_at: now.timestamp() as u64,
expires_at: (now + chrono::Duration::seconds(300)).timestamp() as u64,
extensions: None,
};
SignedApproval::create(payload, approver_key)
}
fn encode_approvals_header(approvals: &[SignedApproval]) -> String {
let mut buf = Vec::new();
ciborium::ser::into_writer(approvals, &mut buf).unwrap();
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&buf)
}
async fn parse_body(response: Response) -> Value {
let bytes = axum::body::to_bytes(response.into_body(), 1_000_000)
.await
.unwrap();
serde_json::from_slice(&bytes).unwrap()
}
#[tokio::test]
async fn approval_required_response_includes_actionable_data() {
let root_key = SigningKey::generate();
let approver_key = SigningKey::generate();
let authorizer = Authorizer::new().with_trusted_root(root_key.public_key());
let app = build_test_app(authorizer);
let warrant = create_gated_warrant(&root_key, &approver_key.public_key());
let args: HashMap<String, ConstraintValue> = [(
"service".to_string(),
ConstraintValue::String("api".to_string()),
)]
.into();
let pop = warrant.sign(&root_key, "deploy", &args).unwrap();
let req = Request::builder()
.method("POST")
.uri("/deploy/api")
.header("X-Tenuo-Warrant", encode_warrant_header(&warrant))
.header("X-Tenuo-PoP", encode_pop_header(&pop))
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
let body = parse_body(resp).await;
assert_eq!(body["error"], "approval-required");
assert_eq!(body["error_code"], 1707);
assert!(
body["request_hash"].is_string(),
"missing request_hash: {body}"
);
assert!(!body["request_hash"].as_str().unwrap().is_empty());
assert_eq!(body["required_approvals"], 1);
assert_eq!(body["received_approvals"], 0);
let approvers = body["required_approvers"]
.as_array()
.expect("missing required_approvers");
assert_eq!(approvers.len(), 1);
assert_eq!(
approvers[0].as_str().unwrap(),
hex::encode(approver_key.public_key().to_bytes())
);
assert_eq!(body["message"], "Approval required for tool 'deploy'");
}
#[tokio::test]
async fn approval_required_response_copies_gate_message() {
let root_key = SigningKey::generate();
let approver_key = SigningKey::generate();
let authorizer = Authorizer::new().with_trusted_root(root_key.public_key());
let app = build_test_app(authorizer);
let mut gates = ApprovalGateMap::new();
gates.insert(
"deploy".to_string(),
ToolApprovalGate::whole_tool().with_message("A human must confirm this deploy."),
);
let warrant = tenuo::Warrant::builder()
.capability("deploy", ConstraintSet::new())
.ttl(std::time::Duration::from_secs(300))
.required_approvers(vec![approver_key.public_key()])
.min_approvals(1)
.holder(root_key.public_key())
.extension(
"tenuo.approval_gates",
encode_approval_gate_map(&gates).unwrap(),
)
.build(&root_key)
.unwrap();
let args: HashMap<String, ConstraintValue> = [(
"service".to_string(),
ConstraintValue::String("api".to_string()),
)]
.into();
let pop = warrant.sign(&root_key, "deploy", &args).unwrap();
let req = Request::builder()
.method("POST")
.uri("/deploy/api")
.header("X-Tenuo-Warrant", encode_warrant_header(&warrant))
.header("X-Tenuo-PoP", encode_pop_header(&pop))
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
let body = parse_body(resp).await;
assert_eq!(body["error"], "approval-required");
assert_eq!(body["message"], "A human must confirm this deploy.");
}
#[tokio::test]
async fn insufficient_approvals_response_includes_actionable_data() {
let root_key = SigningKey::generate();
let approver1 = SigningKey::generate();
let approver2 = SigningKey::generate();
let wrong_approver = SigningKey::generate();
let mut gates = ApprovalGateMap::new();
gates.insert("deploy".to_string(), ToolApprovalGate::whole_tool());
let warrant = tenuo::Warrant::builder()
.capability("deploy", ConstraintSet::new())
.ttl(std::time::Duration::from_secs(300))
.required_approvers(vec![approver1.public_key(), approver2.public_key()])
.min_approvals(2)
.holder(root_key.public_key())
.extension(
"tenuo.approval_gates",
encode_approval_gate_map(&gates).unwrap(),
)
.build(&root_key)
.unwrap();
let authorizer = Authorizer::new().with_trusted_root(root_key.public_key());
let app = build_test_app(authorizer);
let args: HashMap<String, ConstraintValue> = [(
"service".to_string(),
ConstraintValue::String("api".to_string()),
)]
.into();
let pop = warrant.sign(&root_key, "deploy", &args).unwrap();
let bad_approval = create_approval(&warrant, "deploy", &args, &wrong_approver);
let approvals_b64 = encode_approvals_header(&[bad_approval]);
let req = Request::builder()
.method("POST")
.uri("/deploy/api")
.header("X-Tenuo-Warrant", encode_warrant_header(&warrant))
.header("X-Tenuo-PoP", encode_pop_header(&pop))
.header("X-Tenuo-Approvals", &approvals_b64)
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
let body = parse_body(resp).await;
assert!(
body["request_hash"].is_string(),
"missing request_hash: {body}"
);
assert_eq!(body["required_approvals"], 2);
let approvers = body["required_approvers"]
.as_array()
.expect("missing required_approvers");
assert_eq!(approvers.len(), 2);
}
#[tokio::test]
async fn malformed_base64_approval_header_returns_400() {
let root_key = SigningKey::generate();
let authorizer = Authorizer::new().with_trusted_root(root_key.public_key());
let app = build_test_app(authorizer);
let warrant = tenuo::Warrant::builder()
.capability("deploy", ConstraintSet::new())
.ttl(std::time::Duration::from_secs(300))
.holder(root_key.public_key())
.build(&root_key)
.unwrap();
let args: HashMap<String, ConstraintValue> = [(
"service".to_string(),
ConstraintValue::String("api".to_string()),
)]
.into();
let pop = warrant.sign(&root_key, "deploy", &args).unwrap();
let req = Request::builder()
.method("POST")
.uri("/deploy/api")
.header("X-Tenuo-Warrant", encode_warrant_header(&warrant))
.header("X-Tenuo-PoP", encode_pop_header(&pop))
.header("X-Tenuo-Approvals", "!!!not-valid-base64!!!")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let body = parse_body(resp).await;
assert_eq!(body["error"], "invalid_approvals_header");
}
#[tokio::test]
async fn malformed_cbor_approval_header_returns_400() {
let root_key = SigningKey::generate();
let authorizer = Authorizer::new().with_trusted_root(root_key.public_key());
let app = build_test_app(authorizer);
let warrant = tenuo::Warrant::builder()
.capability("deploy", ConstraintSet::new())
.ttl(std::time::Duration::from_secs(300))
.holder(root_key.public_key())
.build(&root_key)
.unwrap();
let args: HashMap<String, ConstraintValue> = [(
"service".to_string(),
ConstraintValue::String("api".to_string()),
)]
.into();
let pop = warrant.sign(&root_key, "deploy", &args).unwrap();
let garbage_cbor =
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"this is not cbor");
let req = Request::builder()
.method("POST")
.uri("/deploy/api")
.header("X-Tenuo-Warrant", encode_warrant_header(&warrant))
.header("X-Tenuo-PoP", encode_pop_header(&pop))
.header("X-Tenuo-Approvals", &garbage_cbor)
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let body = parse_body(resp).await;
assert_eq!(body["error"], "invalid_approvals_header");
}
#[tokio::test]
async fn oversized_approval_header_returns_400() {
let root_key = SigningKey::generate();
let authorizer = Authorizer::new().with_trusted_root(root_key.public_key());
let app = build_test_app(authorizer);
let warrant = tenuo::Warrant::builder()
.capability("deploy", ConstraintSet::new())
.ttl(std::time::Duration::from_secs(300))
.holder(root_key.public_key())
.build(&root_key)
.unwrap();
let args: HashMap<String, ConstraintValue> = [(
"service".to_string(),
ConstraintValue::String("api".to_string()),
)]
.into();
let pop = warrant.sign(&root_key, "deploy", &args).unwrap();
let huge_payload = vec![0xA0u8; 128_000];
let huge_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&huge_payload);
let req = Request::builder()
.method("POST")
.uri("/deploy/api")
.header("X-Tenuo-Warrant", encode_warrant_header(&warrant))
.header("X-Tenuo-PoP", encode_pop_header(&pop))
.header("X-Tenuo-Approvals", &huge_b64)
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let body = parse_body(resp).await;
assert_eq!(body["error"], "invalid_approvals_header");
assert!(body["message"].as_str().unwrap().contains("too large"));
}
#[tokio::test]
async fn valid_approval_succeeds() {
let root_key = SigningKey::generate();
let approver_key = SigningKey::generate();
let authorizer = Authorizer::new().with_trusted_root(root_key.public_key());
let app = build_test_app(authorizer);
let warrant = create_gated_warrant(&root_key, &approver_key.public_key());
let args: HashMap<String, ConstraintValue> = [(
"service".to_string(),
ConstraintValue::String("api".to_string()),
)]
.into();
let pop = warrant.sign(&root_key, "deploy", &args).unwrap();
let approval = create_approval(&warrant, "deploy", &args, &approver_key);
let approvals_b64 = encode_approvals_header(&[approval]);
let req = Request::builder()
.method("POST")
.uri("/deploy/api")
.header("X-Tenuo-Warrant", encode_warrant_header(&warrant))
.header("X-Tenuo-PoP", encode_pop_header(&pop))
.header("X-Tenuo-Approvals", &approvals_b64)
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = parse_body(resp).await;
assert_eq!(body["authorized"], true);
}
#[tokio::test]
async fn empty_approval_header_treated_as_absent() {
let root_key = SigningKey::generate();
let authorizer = Authorizer::new().with_trusted_root(root_key.public_key());
let app = build_test_app(authorizer);
let warrant = tenuo::Warrant::builder()
.capability("deploy", ConstraintSet::new())
.ttl(std::time::Duration::from_secs(300))
.holder(root_key.public_key())
.build(&root_key)
.unwrap();
let args: HashMap<String, ConstraintValue> = [(
"service".to_string(),
ConstraintValue::String("api".to_string()),
)]
.into();
let pop = warrant.sign(&root_key, "deploy", &args).unwrap();
let req = Request::builder()
.method("POST")
.uri("/deploy/api")
.header("X-Tenuo-Warrant", encode_warrant_header(&warrant))
.header("X-Tenuo-PoP", encode_pop_header(&pop))
.header("X-Tenuo-Approvals", "")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"empty header should be treated as absent, not 400"
);
}
#[tokio::test]
async fn no_approval_header_succeeds_without_gate() {
let root_key = SigningKey::generate();
let authorizer = Authorizer::new().with_trusted_root(root_key.public_key());
let app = build_test_app(authorizer);
let warrant = tenuo::Warrant::builder()
.capability("deploy", ConstraintSet::new())
.ttl(std::time::Duration::from_secs(300))
.holder(root_key.public_key())
.build(&root_key)
.unwrap();
let args: HashMap<String, ConstraintValue> = [(
"service".to_string(),
ConstraintValue::String("api".to_string()),
)]
.into();
let pop = warrant.sign(&root_key, "deploy", &args).unwrap();
let req = Request::builder()
.method("POST")
.uri("/deploy/api")
.header("X-Tenuo-Warrant", encode_warrant_header(&warrant))
.header("X-Tenuo-PoP", encode_pop_header(&pop))
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = parse_body(resp).await;
assert_eq!(body["authorized"], true);
}
#[tokio::test]
async fn early_denials_carry_deny_reason_header_in_debug_mode() {
let root_key = SigningKey::generate();
let app = build_test_app(Authorizer::new().with_trusted_root(root_key.public_key()));
let cases = [
(
"POST",
"/deploy/api",
None,
StatusCode::UNAUTHORIZED,
"missing_warrant",
),
(
"POST",
"/deploy/api",
Some("not-a-warrant"),
StatusCode::BAD_REQUEST,
"invalid_warrant",
),
("DELETE", "/nope", None, StatusCode::NOT_FOUND, "no_route"),
];
for (method, uri, warrant, status, reason) in cases {
let mut req = Request::builder().method(method).uri(uri);
if let Some(w) = warrant {
req = req.header("X-Tenuo-Warrant", w);
}
let resp = app
.clone()
.oneshot(req.body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.status(), status, "{method} {uri}");
assert_eq!(
resp.headers()
.get("x-tenuo-deny-reason")
.and_then(|v| v.to_str().ok()),
Some(reason),
"{method} {uri}"
);
assert_eq!(parse_body(resp).await["error"], reason);
}
}
#[tokio::test]
async fn early_denials_omit_deny_reason_header_without_debug_mode() {
let resp = deny_response(
false,
StatusCode::UNAUTHORIZED,
json!({ "error": "missing_warrant" }),
);
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
assert!(resp.headers().get("x-tenuo-deny-reason").is_none());
}
fn empty_tracker(
root: &tenuo::crypto::PublicKey,
) -> Arc<tenuo::revocation_tracker::RevocationTracker> {
Arc::new(
tenuo::revocation_tracker::RevocationTracker::with_in_memory_floors(
vec![root.clone()],
Duration::from_secs(60),
Duration::from_secs(5),
)
.unwrap(),
)
}
fn build_test_app_tracked(
authorizer: Authorizer,
tracker: Arc<tenuo::revocation_tracker::RevocationTracker>,
started_at: std::time::Instant,
warmup: Duration,
) -> Router {
let config = GatewayConfig::from_yaml(GATEWAY_YAML).unwrap();
let compiled = CompiledGatewayConfig::compile(config).unwrap();
let state = Arc::new(AppState {
authorizer: Arc::new(tokio::sync::RwLock::new(authorizer)),
config: compiled,
debug_mode: true,
audit_tx: None,
authorizer_id: Arc::new(tokio::sync::RwLock::new(None)),
metrics: None,
started_at,
revocation_tracker: Some(tracker),
srl_warmup: warmup,
});
build_router(state, false)
}
async fn deploy_request(root_key: &SigningKey, app: Router) -> (StatusCode, Value) {
let warrant = tenuo::Warrant::builder()
.capability("deploy", ConstraintSet::new())
.ttl(std::time::Duration::from_secs(300))
.holder(root_key.public_key())
.build(root_key)
.unwrap();
let args: HashMap<String, ConstraintValue> = [(
"service".to_string(),
ConstraintValue::String("api".to_string()),
)]
.into();
let pop = warrant.sign(root_key, "deploy", &args).unwrap();
let req = Request::builder()
.method("POST")
.uri("/deploy/api")
.header("X-Tenuo-Warrant", encode_warrant_header(&warrant))
.header("X-Tenuo-PoP", encode_pop_header(&pop))
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
let status = resp.status();
(status, parse_body(resp).await)
}
#[test]
fn srl_max_age_outlasts_long_heartbeat() {
assert_eq!(srl_max_age(30), Duration::from_secs(300));
assert_eq!(srl_max_age(300), Duration::from_secs(360));
assert_eq!(srl_max_age(310), Duration::from_secs(370));
}
#[test]
fn warmup_allows_only_when_never_fetched() {
let issuer = SigningKey::generate();
let tracker = tenuo::revocation_tracker::RevocationTracker::with_in_memory_floors(
vec![issuer.public_key()],
Duration::from_secs(1),
Duration::from_secs(0),
)
.unwrap();
assert!(srl_freshness_allows(&tracker, true).is_ok());
assert!(srl_freshness_allows(&tracker, false).is_err());
let now = chrono::Utc::now();
tracker
.accept(
tenuo::revocation_tracker::RevocationUpdate {
srl: tenuo::revocation::SignedRevocationList::builder()
.version(1)
.build(&issuer)
.unwrap(),
fetched_at: now - chrono::Duration::seconds(5),
},
now - chrono::Duration::seconds(5),
)
.unwrap();
assert!(tracker.has_accepted());
assert!(
srl_freshness_allows(&tracker, true).is_err(),
"stale list must deny even during warm-up"
);
}
#[tokio::test]
async fn warmup_never_fetched_allows_request() {
let root_key = SigningKey::generate();
let authorizer = Authorizer::new().with_trusted_root(root_key.public_key());
let tracker = empty_tracker(&root_key.public_key());
let app = build_test_app_tracked(
authorizer,
tracker,
std::time::Instant::now(),
Duration::from_secs(60),
);
let (status, body) = deploy_request(&root_key, app).await;
assert_eq!(status, StatusCode::OK, "{body}");
assert_ne!(body["error"], "revocation_unavailable");
}
#[tokio::test]
async fn warmup_expired_without_list_denies() {
let root_key = SigningKey::generate();
let authorizer = Authorizer::new().with_trusted_root(root_key.public_key());
let tracker = empty_tracker(&root_key.public_key());
let started = std::time::Instant::now()
.checked_sub(Duration::from_secs(120))
.unwrap();
let app = build_test_app_tracked(authorizer, tracker, started, Duration::from_secs(60));
let (status, body) = deploy_request(&root_key, app).await;
assert_eq!(status, StatusCode::FORBIDDEN);
assert_eq!(body["error"], "revocation_unavailable");
}
const HEALTH_PATHS: [&str; 4] = ["/health", "/healthz", "/ready", "/status"];
fn test_state(yaml: &str) -> Arc<AppState> {
let config = GatewayConfig::from_yaml(yaml).unwrap();
let compiled = CompiledGatewayConfig::compile(config).unwrap();
let root_key = SigningKey::generate();
Arc::new(AppState {
authorizer: Arc::new(tokio::sync::RwLock::new(
Authorizer::new().with_trusted_root(root_key.public_key()),
)),
config: compiled,
debug_mode: true,
audit_tx: None,
authorizer_id: Arc::new(tokio::sync::RwLock::new(None)),
metrics: None,
started_at: std::time::Instant::now(),
revocation_tracker: None,
srl_warmup: Duration::from_secs(60),
})
}
async fn get(app: Router, path: &str) -> (StatusCode, Value) {
let resp = app
.oneshot(Request::get(path).body(Body::empty()).unwrap())
.await
.unwrap();
let status = resp.status();
(status, parse_body(resp).await)
}
#[tokio::test]
async fn main_router_denies_health_paths_without_warrant() {
let state = test_state(GATEWAY_YAML);
for path in HEALTH_PATHS {
let (status, body) = get(build_router(state.clone(), false), path).await;
assert_eq!(status, StatusCode::NOT_FOUND, "{path}: {body}");
assert_eq!(body["error"], "no_route", "{path}: {body}");
}
}
#[tokio::test]
async fn main_router_authorizes_health_paths_through_routes() {
let yaml = r#"
version: "1"
settings:
debug_mode: true
tools:
read_page:
description: "Read a page"
constraints:
page:
from: path
path: "page"
required: true
routes:
- pattern: "/{page}"
method: ["GET"]
tool: "read_page"
"#;
let state = test_state(yaml);
for path in HEALTH_PATHS {
let (status, body) = get(build_router(state.clone(), false), path).await;
assert_eq!(status, StatusCode::UNAUTHORIZED, "{path}: {body}");
assert_eq!(body["error"], "missing_warrant", "{path}: {body}");
}
}
#[tokio::test]
async fn health_router_answers_health_and_status() {
let state = test_state(GATEWAY_YAML);
for path in ["/health", "/healthz", "/ready"] {
let (status, body) = get(build_health_router(state.clone()), path).await;
assert_eq!(status, StatusCode::OK, "{path}");
assert_eq!(body["status"], "healthy", "{path}");
}
let (status, body) = get(build_health_router(state.clone()), "/status").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["cp"]["status"], "disabled", "{body}");
}
#[tokio::test]
async fn health_router_does_not_authorize_requests() {
let state = test_state(GATEWAY_YAML);
let resp = build_health_router(state)
.oneshot(Request::post("/deploy/api").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn legacy_flag_restores_health_routes_on_main_router() {
let state = test_state(GATEWAY_YAML);
for path in HEALTH_PATHS {
let (status, _) = get(build_router(state.clone(), true), path).await;
assert_eq!(status, StatusCode::OK, "{path}");
}
let (status, body) = get(build_router(state, true), "/deploy/api").await;
assert_ne!(status, StatusCode::OK, "{body}");
warn_legacy_health(true);
warn_legacy_health(false);
}
#[tokio::test]
async fn health_listener_serves_over_tcp() {
let state = test_state(GATEWAY_YAML);
let health = HealthOptions {
addr: Some("127.0.0.1:0".parse().unwrap()),
legacy_on_main: false,
};
let server = start_health_listener(&health, state)
.await
.unwrap()
.expect("health listener enabled");
let addr = server.local_addr;
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
let mut stream = tokio::net::TcpStream::connect(addr).await.unwrap();
stream
.write_all(b"GET /ready HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
.await
.unwrap();
let mut response = Vec::new();
stream.read_to_end(&mut response).await.unwrap();
let response = String::from_utf8(response).unwrap();
assert!(response.contains("200 OK"), "{response}");
assert!(response.contains("healthy"), "{response}");
stop_health_listener(Some(server)).await;
assert!(
tokio::net::TcpStream::connect(addr).await.is_err(),
"health listener should be closed after stop"
);
}
#[tokio::test]
async fn disabled_health_listener_starts_nothing() {
let health = HealthOptions {
addr: None,
legacy_on_main: false,
};
let server = start_health_listener(&health, test_state(GATEWAY_YAML))
.await
.unwrap();
assert!(server.is_none());
}
#[test]
fn health_options_tcp_defaults_to_bind_and_9091() {
let h = HealthOptions::resolve_tcp("0.0.0.0", 9090, None, None, false).unwrap();
assert_eq!(h.addr, Some("0.0.0.0:9091".parse().unwrap()));
assert!(!h.legacy_on_main);
let h = HealthOptions::resolve_tcp("0.0.0.0", 9090, Some(8081), Some("127.0.0.1"), true)
.unwrap();
assert_eq!(h.addr, Some("127.0.0.1:8081".parse().unwrap()));
assert!(h.legacy_on_main);
}
#[test]
fn health_options_tcp_port_zero_disables() {
let h = HealthOptions::resolve_tcp("0.0.0.0", 9090, Some(0), None, false).unwrap();
assert_eq!(h.addr, None);
}
#[test]
fn health_options_tcp_refuses_the_authorization_port() {
let err = HealthOptions::resolve_tcp("0.0.0.0", 9090, Some(9090), None, false)
.unwrap_err()
.to_string();
assert!(err.contains("same as --port"), "{err}");
assert!(HealthOptions::resolve_tcp("0.0.0.0", 9091, None, None, false).is_err());
}
#[test]
fn health_options_rejects_bad_bind() {
assert!(
HealthOptions::resolve_tcp("0.0.0.0", 9090, None, Some("not an ip"), false).is_err()
);
}
#[cfg(unix)]
#[test]
fn health_options_unix_is_off_unless_asked() {
let h = HealthOptions::resolve_unix(None, None, false).unwrap();
assert_eq!(h.addr, None);
let h = HealthOptions::resolve_unix(Some(9091), None, false).unwrap();
assert_eq!(h.addr, Some("127.0.0.1:9091".parse().unwrap()));
let h = HealthOptions::resolve_unix(Some(9091), Some("0.0.0.0"), false).unwrap();
assert_eq!(h.addr, Some("0.0.0.0:9091".parse().unwrap()));
}
#[test]
fn serve_cli_parses_health_flags() {
let cli = Cli::try_parse_from([
"tenuo-authorizer",
"serve",
"--config",
"g.yaml",
"--health-port",
"8081",
"--health-bind",
"127.0.0.1",
"--legacy-health-on-main-port",
])
.unwrap();
let Commands::Serve {
health_port,
health_bind,
legacy_health_on_main_port,
..
} = cli.command
else {
panic!("expected serve");
};
assert_eq!(health_port, Some(8081));
assert_eq!(health_bind.as_deref(), Some("127.0.0.1"));
assert!(legacy_health_on_main_port);
}
}
#[cfg(all(test, unix))]
mod unix_socket_tests {
use super::*;
use std::os::unix::fs::FileTypeExt as _;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
#[test]
fn stale_socket_is_removed() {
let dir = tempfile::tempdir().unwrap();
let socket_path = dir.path().join("auth.sock");
{
std::os::unix::net::UnixListener::bind(&socket_path).unwrap();
}
assert!(
std::fs::symlink_metadata(&socket_path)
.unwrap()
.file_type()
.is_socket(),
"pre-condition: path must be a socket"
);
prepare_unix_socket(&socket_path).expect("should remove stale socket");
assert!(
!socket_path.exists(),
"stale socket should have been removed"
);
}
#[test]
fn symlink_at_socket_path_is_refused() {
let dir = tempfile::tempdir().unwrap();
let socket_path = dir.path().join("auth.sock");
let target = dir.path().join("somewhere_else.sock");
std::os::unix::fs::symlink(&target, &socket_path).unwrap();
let err = prepare_unix_socket(&socket_path).expect_err("symlink should be refused");
assert!(
err.to_string().contains("symlink"),
"error message should mention symlink: {err}"
);
}
#[test]
fn regular_file_at_socket_path_is_refused() {
let dir = tempfile::tempdir().unwrap();
let socket_path = dir.path().join("auth.sock");
std::fs::write(&socket_path, b"not a socket").unwrap();
let err = prepare_unix_socket(&socket_path).expect_err("regular file should be refused");
let msg = err.to_string();
assert!(
msg.contains("regular file") || msg.contains("not a socket"),
"error message should describe the file type: {msg}"
);
}
#[test]
fn world_writable_parent_dir_is_refused() {
use std::os::unix::fs::PermissionsExt as _;
let dir = tempfile::tempdir().unwrap();
let writable = dir.path().join("open");
std::fs::create_dir(&writable).unwrap();
std::fs::set_permissions(&writable, std::fs::Permissions::from_mode(0o777)).unwrap();
let socket_path = writable.join("auth.sock");
let err = prepare_unix_socket(&socket_path)
.expect_err("world-writable parent dir should be refused");
assert!(
err.to_string().contains("group/world-writable"),
"error should mention writability: {err}"
);
}
#[test]
fn group_writable_parent_dir_is_refused() {
use std::os::unix::fs::PermissionsExt as _;
let dir = tempfile::tempdir().unwrap();
let writable = dir.path().join("grp");
std::fs::create_dir(&writable).unwrap();
std::fs::set_permissions(&writable, std::fs::Permissions::from_mode(0o775)).unwrap();
let socket_path = writable.join("auth.sock");
let err = prepare_unix_socket(&socket_path)
.expect_err("group-writable parent dir should be refused");
assert!(err.to_string().contains("group/world-writable"));
}
#[test]
fn safe_parent_dir_is_accepted() {
use std::os::unix::fs::PermissionsExt as _;
let dir = tempfile::tempdir().unwrap();
let safe = dir.path().join("safe");
std::fs::create_dir(&safe).unwrap();
std::fs::set_permissions(&safe, std::fs::Permissions::from_mode(0o755)).unwrap();
let socket_path = safe.join("auth.sock");
prepare_unix_socket(&socket_path).expect("0755 parent dir should be accepted");
}
#[test]
fn parse_socket_mode_accepts_common_forms() {
assert_eq!(parse_socket_mode("0660").unwrap(), 0o660);
assert_eq!(parse_socket_mode("660").unwrap(), 0o660);
assert_eq!(parse_socket_mode("0666").unwrap(), 0o666);
assert_eq!(parse_socket_mode("0600").unwrap(), 0o600);
assert_eq!(parse_socket_mode("0o640").unwrap(), 0o640);
assert_eq!(parse_socket_mode(" 0660 ").unwrap(), 0o660);
}
#[test]
fn parse_socket_mode_rejects_invalid() {
assert!(parse_socket_mode("").is_err());
assert!(parse_socket_mode("rwx").is_err());
assert!(parse_socket_mode("0899").is_err()); assert!(parse_socket_mode("77777").is_err()); }
#[test]
fn resolve_gid_numeric() {
assert_eq!(resolve_gid("0").unwrap(), 0);
assert_eq!(resolve_gid("1000").unwrap(), 1000);
assert_eq!(resolve_gid(" 42 ").unwrap(), 42);
}
#[test]
fn resolve_gid_unknown_group_errors() {
let err = resolve_gid("definitely_no_such_group_xyz123").expect_err("should fail");
assert!(err.to_string().contains("no such group"));
}
#[test]
fn resolve_gid_empty_errors() {
assert!(resolve_gid(" ").is_err());
}
#[tokio::test]
async fn socket_mode_is_applied_on_bind() {
use std::os::unix::fs::PermissionsExt as _;
let dir = tempfile::tempdir().unwrap();
let socket_path = dir.path().join("auth.sock");
let mode = parse_socket_mode("0666").unwrap();
let _listener = tokio::net::UnixListener::bind(&socket_path).unwrap();
std::fs::set_permissions(&socket_path, std::fs::Permissions::from_mode(mode)).unwrap();
let got = std::fs::metadata(&socket_path)
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(
got, 0o666,
"socket should be world-connectable when 0666 requested"
);
}
#[tokio::test]
async fn unix_socket_serves_same_api_as_tcp() {
let dir = tempfile::tempdir().unwrap();
let socket_path = dir.path().join("auth.sock");
let root_key = SigningKey::generate();
let authorizer = Authorizer::new().with_trusted_root(root_key.public_key());
let config = GatewayConfig::from_yaml(
r#"
version: "1"
settings:
debug_mode: true
tools:
deploy:
description: "Deploy service"
constraints:
service:
from: path
path: "service"
required: true
routes:
- pattern: "/deploy/{service}"
method: ["POST"]
tool: "deploy"
"#,
)
.unwrap();
let compiled = CompiledGatewayConfig::compile(config).unwrap();
let state = Arc::new(AppState {
authorizer: Arc::new(tokio::sync::RwLock::new(authorizer)),
config: compiled,
debug_mode: true,
audit_tx: None,
authorizer_id: Arc::new(tokio::sync::RwLock::new(None)),
metrics: None,
started_at: std::time::Instant::now(),
revocation_tracker: None,
srl_warmup: Duration::from_secs(60),
});
let app = build_router(state, false);
let listener = tokio::net::UnixListener::bind(&socket_path).unwrap();
let serve_handle = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
let response_str = raw_unix_get(&socket_path, "/health").await;
assert!(
response_str.contains("404 Not Found") && response_str.contains("no_route"),
"expected /health to be unrouted on the authorization socket, got:\n{response_str}"
);
let mut stream = tokio::net::UnixStream::connect(&socket_path).await.unwrap();
stream
.write_all(
b"POST /deploy/api HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
)
.await
.unwrap();
let mut response = Vec::new();
stream.read_to_end(&mut response).await.unwrap();
let response_str = String::from_utf8(response).unwrap();
assert!(
response_str.contains("401 Unauthorized") && response_str.contains("missing_warrant"),
"expected missing_warrant over Unix socket, got:\n{response_str}"
);
assert!(
std::fs::symlink_metadata(&socket_path)
.unwrap()
.file_type()
.is_socket(),
"socket file should exist while server is running"
);
serve_handle.abort();
}
async fn raw_unix_get(socket_path: &std::path::Path, path: &str) -> String {
let mut stream = tokio::net::UnixStream::connect(socket_path).await.unwrap();
stream
.write_all(
format!("GET {path} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
.as_bytes(),
)
.await
.unwrap();
let mut response = Vec::new();
stream.read_to_end(&mut response).await.unwrap();
String::from_utf8(response).unwrap()
}
}