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 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_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")]
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>,
},
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,
..
} => {
serve_unix(
authorizer,
initial_srl_version,
config,
socket_path,
socket_mode,
socket_group.as_deref(),
&cli,
)
.await?;
}
Commands::Serve {
port, config, bind, ..
} => {
serve_http(authorizer, initial_srl_version, config, bind, *port, &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 first_key = if let Some(keys) = trusted_keys {
let first = keys.split(',').next().unwrap_or("");
if first.is_empty() {
return Err("TENUO_TRUSTED_KEYS is empty".into());
}
let bytes = hex::decode(first)?;
let arr: [u8; 32] = bytes.try_into().map_err(|_| "invalid key length")?;
PublicKey::from_bytes(&arr)?
} else {
eprintln!("WARNING: No trusted keys configured. Set TENUO_TRUSTED_KEYS for production.");
let dummy = [0u8; 32];
PublicKey::from_bytes(&dummy).unwrap_or_else(|_| {
tenuo::SigningKey::generate().public_key()
})
};
let mut authorizer = Authorizer::new().with_trusted_root(first_key.clone());
if let Some(keys) = trusted_keys {
for key_hex in keys.split(',').skip(1) {
if !key_hex.is_empty() {
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)?);
}
}
}
let initial_srl_version = if let Some(path) = revocation_path {
let srl = load_signed_revocation_list(path)?;
let version = srl.version();
authorizer.set_revocation_list(srl, &first_key)?;
eprintln!("Loaded signed revocation list from: {}", path.display());
Some(version)
} else {
None
};
Ok((authorizer, initial_srl_version))
}
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};
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,
}
#[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_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,
}
async fn prepare_serve(
authorizer: Authorizer,
initial_srl_version: Option<u64>,
config_path: &PathBuf,
cli: &Cli,
) -> Result<ServeReady, Box<dyn std::error::Error>> {
let mut config = GatewayConfig::from_file(config_path)?;
if let Some(keys) = &cli.trusted_keys {
for key in keys.split(',') {
if !key.trim().is_empty() {
config.settings.trusted_roots.push(key.trim().to_string());
}
}
}
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| {
let first = keys.split(',').next()?;
let bytes = hex::decode(first.trim()).ok()?;
let arr: [u8; 32] = bytes.try_into().ok()?;
PublicKey::from_bytes(&arr).ok()
});
let (audit_tx, metrics) =
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) = initial_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 heartbeat_config = HeartbeatConfig {
control_plane_url: url.clone(),
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,
};
let authorizer_id_writer = shared_authorizer_id.clone();
tokio::spawn(async move {
heartbeat::start_heartbeat_loop_with_audit_and_id(
heartbeat_config,
Some(rx),
authorizer_id_writer,
)
.await;
});
info!("Heartbeat, metrics, and audit streaming enabled for control plane");
(Some(tx), Some(metrics))
} else {
(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(),
});
Ok(ServeReady {
state,
debug_mode,
control_plane_enabled,
})
}
fn build_router(state: Arc<AppState>) -> Router {
Router::new()
.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))
.fallback(handle_request)
.with_state(state)
}
fn print_banner_shared(
transport_line: &str,
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!("│ 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!();
}
async fn serve_http(
authorizer: Authorizer,
initial_srl_version: Option<u64>,
config_path: &PathBuf,
bind: &str,
port: u16,
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),
config_path,
ready.debug_mode,
initial_srl_version,
ready.control_plane_enabled,
cli.heartbeat_interval,
);
let app = build_router(ready.state);
let addr: SocketAddr = format!("{}:{}", bind, port).parse()?;
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, app).await?;
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>,
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()),
config_path,
ready.debug_mode,
initial_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);
axum::serve(listener, app).await?;
Ok(())
}
#[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,
}
})),
)
}
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 (
StatusCode::NOT_FOUND,
Json(json!({
"error": "no_route",
"message": format!("No route matches {} {}", method, path),
"request_id": request_id
})),
)
.into_response();
}
};
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 (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "invalid_header",
"message": format!("Invalid {} header encoding", warrant_header),
"request_id": request_id
})),
)
.into_response();
}
},
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 (
StatusCode::UNAUTHORIZED,
Json(json!({
"error": "missing_warrant",
"message": format!("Missing {} header", warrant_header),
"request_id": request_id
})),
)
.into_response();
}
};
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 (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "invalid_warrant",
"message": format!("Failed to decode warrant: {}", e),
"request_id": request_id
})),
)
.into_response();
}
};
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 (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "extraction_failed",
"message": format!("Failed to extract constraints: {}", e),
"request_id": request_id
})),
)
.into_response();
}
};
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 (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "invalid_approvals_header",
"message": format!("Could not base64-decode {} header", approval_header),
"request_id": request_id
})),
).into_response();
}
};
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 (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "invalid_approvals_header",
"message": format!("{} header too large ({} bytes, max {})", approval_header, bytes.len(), MAX_APPROVAL_HEADER_BYTES),
"request_id": request_id
})),
).into_response();
}
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 (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "invalid_approvals_header",
"message": format!("Could not deserialize CBOR from {} header", approval_header),
"request_id": request_id
})),
).into_response();
}
}
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();
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(
"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::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::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"
"#;
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(),
});
Router::new()
.route("/health", axum::routing::get(health_check))
.fallback(handle_request)
.with_state(state)
}
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())
);
}
#[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);
}
}
#[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(),
});
let app = build_router(state);
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 mut stream = tokio::net::UnixStream::connect(&socket_path).await.unwrap();
stream
.write_all(b"GET /health 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_str = String::from_utf8(response).unwrap();
assert!(
response_str.contains("200 OK"),
"expected 200 OK from /health over Unix socket, got:\n{response_str}"
);
assert!(
response_str.contains("healthy"),
"expected 'healthy' in body over Unix socket, got:\n{response_str}"
);
assert!(
response_str.contains("tenuo-authorizer"),
"expected service name in body, 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();
}
}