use std::fs::{File, OpenOptions};
use std::io::Write;
use std::net::IpAddr;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};
use dashmap::DashSet;
use ipnet::IpNet;
use serde::Serialize;
use uuid::Uuid;
use tensor_wasm_core::types::TenantId;
use crate::rate_limit::{AuthContext, TokenId};
use crate::token_scope::{TenantScope, TokenScope};
pub const ENV_AUDIT_LOG: &str = "TENSOR_WASM_API_AUDIT_LOG";
pub const AUDIT_LOG_DISABLED_VALUE: &str = "none";
pub const AUDIT_LOG_STDOUT_VALUE: &str = "stdout";
pub const AUDIT_LOG_FILE_PREFIX: &str = "file:";
#[derive(Debug, Clone, Serialize)]
pub struct AuditRecord {
pub ts_unix_ms: u64,
pub request_id: Uuid,
pub actor: AuditActor,
pub action: AuditAction,
pub resource: AuditResource,
pub outcome: AuditOutcome,
pub latency_ms: u64,
pub peer_addr: Option<String>,
pub client_cert_subject: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct AuditActor {
pub kind: AuditActorKind,
pub token_id: Option<TokenId>,
pub scope: TokenScopeView,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AuditActorKind {
Bearer,
Dev,
}
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum TokenScopeView {
Wildcard,
TenantSet {
tenants: Vec<u64>,
},
Dev,
}
impl TokenScopeView {
pub fn from_scope(scope: &TokenScope) -> Self {
match &scope.tenants {
TenantScope::All => TokenScopeView::Wildcard,
TenantScope::Set(s) => {
let mut tenants: Vec<u64> = s.iter().map(|t| t.0).collect();
tenants.sort_unstable();
TokenScopeView::TenantSet { tenants }
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AuditAction {
CreateFunction,
DeleteFunction,
InvokeFunction,
InvokeFunctionAsync,
InvokeFunctionStream,
}
impl AuditAction {
pub fn classify(method: &axum::http::Method, path: &str) -> Option<Self> {
use axum::http::Method;
let trimmed = path.trim_start_matches('/');
let segments: Vec<&str> = trimmed.split('/').filter(|s| !s.is_empty()).collect();
match (method, segments.as_slice()) {
(&Method::POST, ["functions"]) => Some(AuditAction::CreateFunction),
(&Method::DELETE, ["functions", _id]) => Some(AuditAction::DeleteFunction),
(&Method::POST, ["functions", _id, "invoke"]) => Some(AuditAction::InvokeFunction),
(&Method::POST, ["functions", _id, "invoke-async"]) => {
Some(AuditAction::InvokeFunctionAsync)
}
(&Method::POST, ["functions", _id, "invoke-stream"]) => {
Some(AuditAction::InvokeFunctionStream)
}
_ => None,
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct AuditResource {
#[serde(skip_serializing_if = "Option::is_none")]
pub function_id: Option<Uuid>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tenant_id: Option<TenantId>,
}
#[derive(Debug, Clone, Serialize)]
pub struct AuditOutcome {
pub status_code: u16,
#[serde(skip_serializing_if = "Option::is_none")]
pub error_kind: Option<String>,
}
#[derive(Debug, Clone)]
pub struct AuditOutcomeExt {
pub error_kind: String,
}
pub trait AuditSink: Send + Sync + std::fmt::Debug {
fn emit(&self, record: &AuditRecord);
}
#[derive(Debug, Clone, Copy, Default)]
pub struct StdoutJsonSink;
impl StdoutJsonSink {
pub fn new() -> Self {
Self
}
}
impl AuditSink for StdoutJsonSink {
fn emit(&self, record: &AuditRecord) {
let line = match serde_json::to_string(record) {
Ok(line) => line,
Err(e) => {
tracing::error!(
target: "tensor_wasm_api::audit",
error = %e,
"failed to serialise audit record",
);
return;
}
};
tracing::info!(
target: "tensor_wasm_api::audit",
audit = %line,
"audit",
);
match tokio::runtime::Handle::try_current() {
Ok(_) => {
tokio::task::spawn_blocking(move || {
println!("{line}");
});
}
Err(_) => {
println!("{line}");
}
}
}
}
#[derive(Debug)]
pub struct FileJsonSink {
pub path: PathBuf,
pub file: Arc<Mutex<File>>,
}
impl FileJsonSink {
pub fn open(path: impl Into<PathBuf>) -> std::io::Result<Self> {
let path = path.into();
let file = OpenOptions::new().create(true).append(true).open(&path)?;
Ok(Self {
path,
file: Arc::new(Mutex::new(file)),
})
}
fn write_line(file: &Mutex<File>, path: &std::path::Path, line: &str) {
let mut guard = match file.lock() {
Ok(g) => g,
Err(poisoned) => {
tracing::warn!(
target: "tensor_wasm_api::audit",
path = %path.display(),
"audit file mutex was poisoned; recovering",
);
poisoned.into_inner()
}
};
if let Err(e) = writeln!(&mut *guard, "{line}") {
tracing::error!(
target: "tensor_wasm_api::audit",
error = %e,
path = %path.display(),
"failed to write audit record",
);
return;
}
if let Err(e) = guard.flush() {
tracing::error!(
target: "tensor_wasm_api::audit",
error = %e,
path = %path.display(),
"failed to flush audit record",
);
}
}
}
impl AuditSink for FileJsonSink {
fn emit(&self, record: &AuditRecord) {
let line = match serde_json::to_string(record) {
Ok(s) => s,
Err(e) => {
tracing::error!(
target: "tensor_wasm_api::audit",
error = %e,
"failed to serialise audit record",
);
return;
}
};
match tokio::runtime::Handle::try_current() {
Ok(_) => {
let file = Arc::clone(&self.file);
let path = self.path.clone();
tokio::task::spawn_blocking(move || {
Self::write_line(&file, &path, &line);
});
}
Err(_) => {
Self::write_line(&self.file, &self.path, &line);
}
}
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct NoopSink;
impl NoopSink {
pub fn new() -> Self {
Self
}
}
impl AuditSink for NoopSink {
fn emit(&self, _record: &AuditRecord) {
}
}
#[derive(Debug, Clone)]
pub struct AuditConfig {
pub sink: Arc<dyn AuditSink>,
}
impl AuditConfig {
pub fn from_sink(sink: Arc<dyn AuditSink>) -> Self {
Self { sink }
}
pub fn stdout() -> Self {
Self::from_sink(Arc::new(StdoutJsonSink::new()))
}
pub fn disabled() -> Self {
Self::from_sink(Arc::new(NoopSink::new()))
}
pub fn from_env() -> Self {
let raw = std::env::var(ENV_AUDIT_LOG).unwrap_or_default();
let trimmed = raw.trim();
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case(AUDIT_LOG_STDOUT_VALUE) {
tracing::info!(
target: "tensor_wasm_api::audit",
"audit log enabled — sink: stdout (JSONL)",
);
return Self::stdout();
}
if trimmed.eq_ignore_ascii_case(AUDIT_LOG_DISABLED_VALUE) {
tracing::warn!(
target: "tensor_wasm_api::audit",
env = ENV_AUDIT_LOG,
"audit log disabled ({}={})",
ENV_AUDIT_LOG,
AUDIT_LOG_DISABLED_VALUE,
);
return Self::disabled();
}
if let Some(path) = trimmed.strip_prefix(AUDIT_LOG_FILE_PREFIX) {
let path = path.trim();
if path.is_empty() {
tracing::error!(
target: "tensor_wasm_api::audit",
env = ENV_AUDIT_LOG,
"audit log file: prefix had no path; falling back to stdout",
);
return Self::stdout();
}
match FileJsonSink::open(path) {
Ok(sink) => {
tracing::info!(
target: "tensor_wasm_api::audit",
path,
"audit log enabled — sink: file (JSONL, append-only)",
);
return Self::from_sink(Arc::new(sink));
}
Err(e) => {
tracing::error!(
target: "tensor_wasm_api::audit",
path,
error = %e,
"failed to open audit log file; falling back to stdout",
);
return Self::stdout();
}
}
}
tracing::warn!(
target: "tensor_wasm_api::audit",
env = ENV_AUDIT_LOG,
value = trimmed,
"unrecognised TENSOR_WASM_API_AUDIT_LOG value; expected `none`, \
`stdout`, or `file:/path/to/log` — falling back to stdout",
);
Self::stdout()
}
}
impl Default for AuditConfig {
fn default() -> Self {
Self::stdout()
}
}
pub(crate) fn now_unix_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
pub(crate) fn actor_from_auth(auth: &AuthContext) -> AuditActor {
if auth.token_id == TokenId::DEV {
AuditActor {
kind: AuditActorKind::Dev,
token_id: None,
scope: TokenScopeView::Dev,
}
} else {
AuditActor {
kind: AuditActorKind::Bearer,
token_id: Some(auth.token_id),
scope: TokenScopeView::from_scope(&auth.scope),
}
}
}
pub(crate) fn default_actor() -> AuditActor {
AuditActor {
kind: AuditActorKind::Dev,
token_id: None,
scope: TokenScopeView::Dev,
}
}
pub const HEADER_XFCC: &str = "X-Forwarded-Client-Cert";
pub const ENV_TRUSTED_XFCC_PROXIES: &str = "TENSOR_WASM_API_TRUSTED_XFCC_PROXIES";
const MAX_CERT_SUBJECT_LEN: usize = 256;
#[derive(Debug, Clone, Default)]
pub struct TrustedProxies {
ranges: Arc<Vec<IpNet>>,
warned: Arc<DashSet<IpAddr>>,
}
impl TrustedProxies {
pub fn empty() -> Self {
Self::default()
}
pub fn from_env() -> Self {
let raw = std::env::var(ENV_TRUSTED_XFCC_PROXIES).unwrap_or_default();
Self::parse(&raw)
}
pub fn parse(s: &str) -> Self {
let mut ranges: Vec<IpNet> = Vec::new();
let mut had_any = false;
for entry in s.split(',') {
let trimmed = entry.trim();
if trimmed.is_empty() {
continue;
}
had_any = true;
match trimmed.parse::<IpNet>() {
Ok(net) => ranges.push(net),
Err(_) => match trimmed.parse::<IpAddr>() {
Ok(ip) => ranges.push(ip.into()),
Err(e) => {
tracing::warn!(
target: "tensor_wasm_api::audit",
env = ENV_TRUSTED_XFCC_PROXIES,
entry = trimmed,
error = %e,
"ignored malformed XFCC trusted-proxy entry; \
expected an IPv4/IPv6 address or CIDR range",
);
}
},
}
}
if had_any && ranges.is_empty() {
tracing::warn!(
target: "tensor_wasm_api::audit",
env = ENV_TRUSTED_XFCC_PROXIES,
"{} was set but no entries parsed; XFCC will be dropped \
from every request",
ENV_TRUSTED_XFCC_PROXIES,
);
} else if ranges.is_empty() {
tracing::debug!(
target: "tensor_wasm_api::audit",
"{} unset; XFCC headers will be ignored from every peer \
(set this to the IPs / CIDR ranges of your mTLS-terminating \
proxies to enable XFCC ingestion)",
ENV_TRUSTED_XFCC_PROXIES,
);
} else {
tracing::info!(
target: "tensor_wasm_api::audit",
count = ranges.len(),
"XFCC trusted-proxy allowlist configured",
);
}
Self {
ranges: Arc::new(ranges),
warned: Arc::new(DashSet::new()),
}
}
pub fn is_empty(&self) -> bool {
self.ranges.is_empty()
}
pub fn contains(&self, ip: IpAddr) -> bool {
self.ranges.iter().any(|net| net.contains(&ip))
}
pub fn warn_once_untrusted(&self, peer: IpAddr) -> bool {
if self.warned.insert(peer) {
tracing::warn!(
target: "tensor_wasm_api::audit",
env = ENV_TRUSTED_XFCC_PROXIES,
%peer,
"dropped X-Forwarded-Client-Cert from peer not in the \
trusted-proxy allowlist — set {} if this peer is a known \
mTLS-terminating proxy",
ENV_TRUSTED_XFCC_PROXIES,
);
true
} else {
false
}
}
}
fn sanitise_cert_subject(raw: &str) -> std::borrow::Cow<'_, str> {
use std::borrow::Cow;
let is_clean =
raw.len() <= MAX_CERT_SUBJECT_LEN && raw.bytes().all(|b| (0x20..=0x7E).contains(&b));
if is_clean {
return Cow::Borrowed(raw);
}
let mut out = String::with_capacity(raw.len().min(MAX_CERT_SUBJECT_LEN));
for ch in raw.chars() {
let b = ch as u32;
if !(0x20..=0x7E).contains(&b) {
continue;
}
let ch_len = ch.len_utf8();
if out.len() + ch_len > MAX_CERT_SUBJECT_LEN {
break;
}
out.push(ch);
}
Cow::Owned(out)
}
pub(crate) fn extract_client_cert_subject(headers: &axum::http::HeaderMap) -> Option<String> {
let raw = headers.get(HEADER_XFCC)?.to_str().ok()?;
for component in raw.split(';') {
let (k, v) = component.split_once('=')?;
if k.trim().eq_ignore_ascii_case("Subject") {
let v = v.trim();
let inner = if v.starts_with('"') && v.ends_with('"') && v.len() >= 2 {
&v[1..v.len() - 1]
} else {
v
};
let unescaped = inner.replace("\\\"", "\"");
return Some(sanitise_cert_subject(&unescaped).into_owned());
}
}
None
}
pub(crate) fn extract_client_cert_subject_gated(
headers: &axum::http::HeaderMap,
peer_ip: Option<IpAddr>,
trusted: &TrustedProxies,
) -> Option<String> {
let Some(ip) = peer_ip else {
return None;
};
if trusted.contains(ip) {
return extract_client_cert_subject(headers);
}
if headers.contains_key(HEADER_XFCC) {
trusted.warn_once_untrusted(ip);
}
None
}
pub async fn audit_log_middleware(
mut req: axum::extract::Request,
next: axum::middleware::Next,
) -> axum::response::Response {
use std::time::Instant;
let method = req.method().clone();
let path = req.uri().path().to_owned();
let action = AuditAction::classify(&method, &path);
let Some(action) = action else {
return next.run(req).await;
};
let request_id = Uuid::new_v4();
req.extensions_mut().insert(request_id);
let cfg = req
.extensions()
.get::<AuditConfig>()
.cloned()
.unwrap_or_else(AuditConfig::disabled);
let actor = req
.extensions()
.get::<AuthContext>()
.map(actor_from_auth)
.unwrap_or_else(default_actor);
let tenant_id = req.extensions().get::<TenantId>().copied();
let function_id = parse_function_id_from_path(&path);
let connect_info = req
.extensions()
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
.map(|ci| ci.0);
let peer_ip = connect_info.map(|sa| sa.ip());
let trusted_proxies = req
.extensions()
.get::<TrustedProxies>()
.cloned()
.unwrap_or_default();
let client_cert_subject =
extract_client_cert_subject_gated(req.headers(), peer_ip, &trusted_proxies);
let peer_addr = connect_info.map(|sa| sa.to_string());
let start = Instant::now();
let response = next.run(req).await;
let elapsed = start.elapsed();
let status_code = response.status().as_u16();
let error_kind = response
.extensions()
.get::<AuditOutcomeExt>()
.map(|e| e.error_kind.clone());
let record = AuditRecord {
ts_unix_ms: now_unix_ms(),
request_id,
actor,
action,
resource: AuditResource {
function_id,
tenant_id,
},
outcome: AuditOutcome {
status_code,
error_kind,
},
latency_ms: elapsed.as_millis() as u64,
peer_addr,
client_cert_subject,
};
cfg.sink.emit(&record);
response
}
fn parse_function_id_from_path(path: &str) -> Option<Uuid> {
let trimmed = path.trim_start_matches('/');
let segments: Vec<&str> = trimmed.split('/').filter(|s| !s.is_empty()).collect();
if segments.len() >= 2 && segments[0] == "functions" {
return Uuid::parse_str(segments[1]).ok();
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex as StdMutex;
use std::sync::OnceLock;
#[derive(Debug, Default)]
struct CapturingSink {
records: StdMutex<Vec<AuditRecord>>,
}
impl CapturingSink {
fn new() -> Self {
Self::default()
}
fn snapshot(&self) -> Vec<AuditRecord> {
self.records.lock().unwrap().clone()
}
}
impl AuditSink for CapturingSink {
fn emit(&self, record: &AuditRecord) {
self.records.lock().unwrap().push(record.clone());
}
}
fn sample_record() -> AuditRecord {
AuditRecord {
ts_unix_ms: 1_716_491_220_123,
request_id: Uuid::nil(),
actor: AuditActor {
kind: AuditActorKind::Bearer,
token_id: Some(TokenId(1234)),
scope: TokenScopeView::Wildcard,
},
action: AuditAction::CreateFunction,
resource: AuditResource {
function_id: None,
tenant_id: Some(TenantId(7)),
},
outcome: AuditOutcome {
status_code: 200,
error_kind: None,
},
latency_ms: 12,
peer_addr: None,
client_cert_subject: None,
}
}
#[test]
fn classify_state_mutating_routes() {
use axum::http::Method;
assert_eq!(
AuditAction::classify(&Method::POST, "/functions"),
Some(AuditAction::CreateFunction),
);
assert_eq!(
AuditAction::classify(
&Method::DELETE,
"/functions/f47ac10b-58cc-4372-a567-0e02b2c3d479",
),
Some(AuditAction::DeleteFunction),
);
assert_eq!(
AuditAction::classify(
&Method::POST,
"/functions/f47ac10b-58cc-4372-a567-0e02b2c3d479/invoke",
),
Some(AuditAction::InvokeFunction),
);
assert_eq!(
AuditAction::classify(
&Method::POST,
"/functions/f47ac10b-58cc-4372-a567-0e02b2c3d479/invoke-async",
),
Some(AuditAction::InvokeFunctionAsync),
);
assert_eq!(
AuditAction::classify(
&Method::POST,
"/functions/f47ac10b-58cc-4372-a567-0e02b2c3d479/invoke-stream",
),
Some(AuditAction::InvokeFunctionStream),
);
}
#[test]
fn classify_read_only_routes_returns_none() {
use axum::http::Method;
assert!(AuditAction::classify(&Method::GET, "/healthz").is_none());
assert!(AuditAction::classify(&Method::GET, "/metrics").is_none());
assert!(
AuditAction::classify(&Method::GET, "/jobs/abcd-1234").is_none(),
"GET /jobs/<id> is read-only and must be filtered out",
);
assert!(AuditAction::classify(&Method::GET, "/").is_none());
}
#[test]
fn record_serialises_with_stable_keys() {
let rec = sample_record();
let v: serde_json::Value = serde_json::to_value(&rec).expect("serialises");
assert_eq!(v["ts_unix_ms"], 1_716_491_220_123u64);
assert_eq!(v["action"], "create_function");
assert_eq!(v["actor"]["kind"], "bearer");
assert_eq!(v["actor"]["scope"]["kind"], "wildcard");
assert_eq!(v["resource"]["tenant_id"], 7);
assert_eq!(v["outcome"]["status_code"], 200);
assert!(v["resource"].get("function_id").is_none());
assert!(v["outcome"].get("error_kind").is_none());
}
#[test]
fn record_round_trips_through_serde_json() {
let rec = sample_record();
let s = serde_json::to_string(&rec).expect("serialises");
let back: serde_json::Value = serde_json::from_str(&s).expect("deserialises");
assert_eq!(back["action"], "create_function");
assert_eq!(back["request_id"], "00000000-0000-0000-0000-000000000000");
}
#[test]
fn token_scope_view_from_scope_is_stable_order() {
let scope = TokenScope::from_tenants([TenantId(3), TenantId(1), TenantId(2)]);
let view = TokenScopeView::from_scope(&scope);
match view {
TokenScopeView::TenantSet { tenants } => {
assert_eq!(tenants, vec![1, 2, 3]);
}
other => panic!("expected TenantSet, got {other:?}"),
}
}
#[test]
fn actor_from_dev_context_renders_as_dev() {
let ctx = AuthContext::dev();
let actor = actor_from_auth(&ctx);
assert_eq!(actor.kind, AuditActorKind::Dev);
assert!(actor.token_id.is_none());
assert!(matches!(actor.scope, TokenScopeView::Dev));
}
#[test]
fn actor_from_bearer_context_renders_as_bearer() {
let ctx = AuthContext::with_scope("alpha", TokenScope::from_tenants([TenantId(1)]));
let actor = actor_from_auth(&ctx);
assert_eq!(actor.kind, AuditActorKind::Bearer);
assert!(actor.token_id.is_some());
match actor.scope {
TokenScopeView::TenantSet { tenants } => assert_eq!(tenants, vec![1]),
other => panic!("expected TenantSet, got {other:?}"),
}
}
#[test]
fn noop_sink_drops_records() {
let sink = NoopSink::new();
sink.emit(&sample_record());
}
#[test]
fn capturing_sink_records_emission() {
let sink = CapturingSink::new();
sink.emit(&sample_record());
sink.emit(&sample_record());
assert_eq!(sink.snapshot().len(), 2);
}
#[test]
fn file_sink_appends_jsonl_records() {
let dir = std::env::temp_dir();
static N: OnceLock<std::sync::atomic::AtomicU64> = OnceLock::new();
let n = N
.get_or_init(|| std::sync::atomic::AtomicU64::new(0))
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let path = dir.join(format!(
"tensor-wasm-audit-test-{}-{n}.log",
std::process::id(),
));
let _ = std::fs::remove_file(&path);
let sink = FileJsonSink::open(&path).expect("opens");
sink.emit(&sample_record());
sink.emit(&sample_record());
let body = std::fs::read_to_string(&path).expect("reads");
let lines: Vec<&str> = body.lines().collect();
assert_eq!(lines.len(), 2);
for line in lines {
let v: serde_json::Value = serde_json::from_str(line).expect("each line is JSON");
assert_eq!(v["action"], "create_function");
}
let _ = std::fs::remove_file(&path);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn file_sink_offloads_write_under_runtime() {
let dir = std::env::temp_dir();
static N: OnceLock<std::sync::atomic::AtomicU64> = OnceLock::new();
let n = N
.get_or_init(|| std::sync::atomic::AtomicU64::new(0))
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let path = dir.join(format!(
"tensor-wasm-audit-async-test-{}-{n}.log",
std::process::id(),
));
let _ = std::fs::remove_file(&path);
let sink = FileJsonSink::open(&path).expect("opens");
sink.emit(&sample_record());
sink.emit(&sample_record());
let mut lines = 0;
for _ in 0..200 {
if let Ok(body) = std::fs::read_to_string(&path) {
lines = body.lines().count();
if lines >= 2 {
break;
}
}
tokio::task::yield_now().await;
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
}
assert_eq!(lines, 2, "both records must reach the file (none dropped)");
let _ = std::fs::remove_file(&path);
}
#[test]
fn audit_config_from_env_falls_back_to_stdout_when_unset() {
let cfg = AuditConfig::stdout();
cfg.sink.emit(&sample_record());
}
#[test]
fn audit_config_disabled_is_noop() {
let cfg = AuditConfig::disabled();
cfg.sink.emit(&sample_record());
}
#[test]
fn extract_subject_from_xfcc_envoy_format() {
let mut headers = axum::http::HeaderMap::new();
headers.insert(
HEADER_XFCC,
axum::http::HeaderValue::from_static(
"Hash=abc123;Subject=\"CN=client-prod,O=Acme\";URI=spiffe://acme.io/client",
),
);
let subj = extract_client_cert_subject(&headers).expect("present");
assert_eq!(subj, "CN=client-prod,O=Acme");
}
#[test]
fn extract_subject_returns_none_when_header_absent() {
let headers = axum::http::HeaderMap::new();
assert!(extract_client_cert_subject(&headers).is_none());
}
#[test]
fn parse_function_id_recognises_canonical_routes() {
let id = Uuid::new_v4();
let s = id.to_string();
assert_eq!(
parse_function_id_from_path(&format!("/functions/{s}")),
Some(id),
);
assert_eq!(
parse_function_id_from_path(&format!("/functions/{s}/invoke")),
Some(id),
);
assert_eq!(
parse_function_id_from_path(&format!("/functions/{s}/invoke-async")),
Some(id),
);
assert_eq!(parse_function_id_from_path("/functions"), None);
assert_eq!(parse_function_id_from_path("/functions/garbage"), None);
assert_eq!(parse_function_id_from_path("/healthz"), None);
}
#[test]
fn extract_subject_returns_none_when_header_has_no_subject() {
let mut headers = axum::http::HeaderMap::new();
headers.insert(
HEADER_XFCC,
axum::http::HeaderValue::from_static("Hash=abc123;URI=spiffe://acme.io/client"),
);
assert!(extract_client_cert_subject(&headers).is_none());
}
#[test]
fn gated_extract_drops_xfcc_when_no_proxies_trusted() {
let mut headers = axum::http::HeaderMap::new();
headers.insert(
HEADER_XFCC,
axum::http::HeaderValue::from_static("Subject=\"CN=evil\""),
);
let trusted = TrustedProxies::empty();
let peer = Some(IpAddr::from([127, 0, 0, 1]));
assert!(extract_client_cert_subject_gated(&headers, peer, &trusted).is_none());
}
#[test]
fn gated_extract_honours_xfcc_from_trusted_peer() {
let mut headers = axum::http::HeaderMap::new();
headers.insert(
HEADER_XFCC,
axum::http::HeaderValue::from_static("Subject=\"CN=client-prod\""),
);
let trusted = TrustedProxies::parse("127.0.0.1");
let peer = Some(IpAddr::from([127, 0, 0, 1]));
let subj = extract_client_cert_subject_gated(&headers, peer, &trusted)
.expect("trusted peer's header is honoured");
assert_eq!(subj, "CN=client-prod");
}
#[test]
fn gated_extract_drops_xfcc_when_peer_unknown() {
let mut headers = axum::http::HeaderMap::new();
headers.insert(
HEADER_XFCC,
axum::http::HeaderValue::from_static("Subject=\"CN=anyone\""),
);
let trusted = TrustedProxies::parse("10.0.0.0/8");
assert!(extract_client_cert_subject_gated(&headers, None, &trusted).is_none());
}
#[test]
fn trusted_proxies_cidr_membership_v4() {
let t = TrustedProxies::parse("10.0.0.0/8");
assert!(t.contains(IpAddr::from([10, 5, 3, 7])));
assert!(t.contains(IpAddr::from([10, 0, 0, 0])));
assert!(t.contains(IpAddr::from([10, 255, 255, 255])));
assert!(!t.contains(IpAddr::from([192, 168, 1, 1])));
assert!(!t.contains(IpAddr::from([11, 0, 0, 0])));
}
#[test]
fn trusted_proxies_cidr_membership_v6() {
let t = TrustedProxies::parse("fd00::/8");
let v6: IpAddr = "fd12::1".parse().unwrap();
assert!(t.contains(v6));
let outside: IpAddr = "2001:db8::1".parse().unwrap();
assert!(!t.contains(outside));
}
#[test]
fn trusted_proxies_bare_ip_normalised_to_host_route() {
let t = TrustedProxies::parse("127.0.0.1, ::1");
assert!(t.contains(IpAddr::from([127, 0, 0, 1])));
assert!(!t.contains(IpAddr::from([127, 0, 0, 2])));
let v6: IpAddr = "::1".parse().unwrap();
assert!(t.contains(v6));
}
#[test]
fn trusted_proxies_empty_input_trusts_nobody() {
let t = TrustedProxies::parse("");
assert!(t.is_empty());
assert!(!t.contains(IpAddr::from([127, 0, 0, 1])));
let t = TrustedProxies::parse(" , , ");
assert!(t.is_empty());
}
#[test]
fn trusted_proxies_malformed_entries_are_skipped() {
let t = TrustedProxies::parse("garbage,127.0.0.1,also-bad/99");
assert!(t.contains(IpAddr::from([127, 0, 0, 1])));
assert!(!t.contains(IpAddr::from([10, 0, 0, 1])));
}
#[test]
fn trusted_proxies_warn_once_dedup() {
let t = TrustedProxies::empty();
let peer = IpAddr::from([192, 168, 1, 1]);
assert!(t.warn_once_untrusted(peer), "first call emits the warn");
assert!(
!t.warn_once_untrusted(peer),
"second call from same peer is suppressed",
);
let other = IpAddr::from([192, 168, 1, 2]);
assert!(t.warn_once_untrusted(other));
}
}