use crate::serve::audit;
use crate::serve::error::ServeError;
use crate::serve::rbac::{self, Role};
use crate::serve::state::ServerState;
use axum::extract::{ConnectInfo, MatchedPath, Request, State};
use axum::middleware::Next;
use axum::response::Response;
use std::net::SocketAddr;
use subtle::ConstantTimeEq;
pub(crate) fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
a.ct_eq(b).into()
}
pub fn authorize_header(header: Option<&str>, expected: &str) -> Result<(), ServeError> {
let value = header.ok_or(ServeError::Unauthorized)?;
let token = value
.strip_prefix("Bearer ")
.ok_or(ServeError::Unauthorized)?;
if constant_time_eq(token.as_bytes(), expected.as_bytes()) {
Ok(())
} else {
Err(ServeError::Unauthorized)
}
}
fn bearer_token(headers: &axum::http::HeaderMap) -> Option<&str> {
headers
.get(axum::http::header::AUTHORIZATION)?
.to_str()
.ok()?
.strip_prefix("Bearer ")
}
fn extract_run_id(path: &str) -> Option<String> {
let rest = path.strip_prefix("/v1/runs/")?;
let id = rest.split('/').next()?;
(!id.is_empty()).then(|| id.to_string())
}
pub async fn require_auth(
State(state): State<ServerState>,
mut req: Request,
next: Next,
) -> Result<Response, ServeError> {
if req.method() == axum::http::Method::OPTIONS {
return Ok(next.run(req).await);
}
let bearer = bearer_token(req.headers());
let mut ctx = state
.auth_mode()
.resolve(bearer)
.ok_or(ServeError::Unauthorized)?;
ctx.source_ip = req
.extensions()
.get::<ConnectInfo<SocketAddr>>()
.map(|c| c.0.ip().to_string());
let method = req.method().clone();
let matched = req
.extensions()
.get::<MatchedPath>()
.map(|m| m.as_str().to_string());
let allowed = match matched.as_deref() {
Some(mp) => match rbac::required_permission(&method, mp) {
Some(perm) => ctx.role.grants(perm),
None => ctx.role == Role::Admin,
},
None => ctx.role == Role::Admin,
};
if !allowed {
let action = matched
.as_deref()
.map(|mp| rbac::audit_action(&method, mp))
.unwrap_or("unknown");
let run_id = extract_run_id(req.uri().path());
tracing::warn!(
principal = %ctx.principal, role = ctx.role.as_str(), action,
"RBAC denied a control-plane action"
);
audit::write(&state, &ctx, action, run_id, None, "denied").await;
return Err(ServeError::Forbidden(format!(
"principal '{}' (role {}) is not permitted to perform this action",
ctx.principal,
ctx.role.as_str()
)));
}
req.extensions_mut().insert(ctx);
Ok(next.run(req).await)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn constant_time_eq_matches_only_identical() {
assert!(constant_time_eq(b"abc123", b"abc123"));
assert!(!constant_time_eq(b"abc123", b"abc124"));
assert!(!constant_time_eq(b"abc", b"abc123")); }
#[test]
fn authorize_accepts_correct_bearer() {
assert!(authorize_header(Some("Bearer s3cret"), "s3cret").is_ok());
}
#[test]
fn authorize_rejects_wrong_or_missing() {
assert!(authorize_header(Some("Bearer nope"), "s3cret").is_err());
assert!(authorize_header(None, "s3cret").is_err());
assert!(authorize_header(Some("s3cret"), "s3cret").is_err()); assert!(authorize_header(Some("Basic s3cret"), "s3cret").is_err());
}
}