1use crate::serve::audit;
10use crate::serve::error::ServeError;
11use crate::serve::rbac::{self, Role};
12use crate::serve::state::ServerState;
13use axum::extract::{ConnectInfo, MatchedPath, Request, State};
14use axum::middleware::Next;
15use axum::response::Response;
16use std::net::SocketAddr;
17use subtle::ConstantTimeEq;
18
19pub(crate) fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
24 a.ct_eq(b).into()
25}
26
27pub fn authorize_header(header: Option<&str>, expected: &str) -> Result<(), ServeError> {
29 let value = header.ok_or(ServeError::Unauthorized)?;
30 let token = value
31 .strip_prefix("Bearer ")
32 .ok_or(ServeError::Unauthorized)?;
33 if constant_time_eq(token.as_bytes(), expected.as_bytes()) {
34 Ok(())
35 } else {
36 Err(ServeError::Unauthorized)
37 }
38}
39
40fn bearer_token(headers: &axum::http::HeaderMap) -> Option<&str> {
42 headers
43 .get(axum::http::header::AUTHORIZATION)?
44 .to_str()
45 .ok()?
46 .strip_prefix("Bearer ")
47}
48
49fn extract_run_id(path: &str) -> Option<String> {
52 let rest = path.strip_prefix("/v1/runs/")?;
53 let id = rest.split('/').next()?;
54 (!id.is_empty()).then(|| id.to_string())
55}
56
57pub async fn require_auth(
67 State(state): State<ServerState>,
68 mut req: Request,
69 next: Next,
70) -> Result<Response, ServeError> {
71 if req.method() == axum::http::Method::OPTIONS {
72 return Ok(next.run(req).await);
73 }
74
75 let bearer = bearer_token(req.headers());
76 let mut ctx = state
77 .auth_mode()
78 .resolve(bearer)
79 .ok_or(ServeError::Unauthorized)?;
80 ctx.source_ip = req
83 .extensions()
84 .get::<ConnectInfo<SocketAddr>>()
85 .map(|c| c.0.ip().to_string());
86
87 let method = req.method().clone();
88 let matched = req
89 .extensions()
90 .get::<MatchedPath>()
91 .map(|m| m.as_str().to_string());
92
93 let allowed = match matched.as_deref() {
96 Some(mp) => match rbac::required_permission(&method, mp) {
97 Some(perm) => ctx.role.grants(perm),
98 None => ctx.role == Role::Admin,
99 },
100 None => ctx.role == Role::Admin,
101 };
102
103 if !allowed {
104 let action = matched
105 .as_deref()
106 .map(|mp| rbac::audit_action(&method, mp))
107 .unwrap_or("unknown");
108 let run_id = extract_run_id(req.uri().path());
109 tracing::warn!(
110 principal = %ctx.principal, role = ctx.role.as_str(), action,
111 "RBAC denied a control-plane action"
112 );
113 audit::write(&state, &ctx, action, run_id, None, "denied").await;
114 return Err(ServeError::Forbidden(format!(
115 "principal '{}' (role {}) is not permitted to perform this action",
116 ctx.principal,
117 ctx.role.as_str()
118 )));
119 }
120
121 req.extensions_mut().insert(ctx);
122 Ok(next.run(req).await)
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128
129 #[test]
130 fn constant_time_eq_matches_only_identical() {
131 assert!(constant_time_eq(b"abc123", b"abc123"));
132 assert!(!constant_time_eq(b"abc123", b"abc124"));
133 assert!(!constant_time_eq(b"abc", b"abc123")); }
135
136 #[test]
137 fn authorize_accepts_correct_bearer() {
138 assert!(authorize_header(Some("Bearer s3cret"), "s3cret").is_ok());
139 }
140
141 #[test]
142 fn authorize_rejects_wrong_or_missing() {
143 assert!(authorize_header(Some("Bearer nope"), "s3cret").is_err());
144 assert!(authorize_header(None, "s3cret").is_err());
145 assert!(authorize_header(Some("s3cret"), "s3cret").is_err()); assert!(authorize_header(Some("Basic s3cret"), "s3cret").is_err());
147 }
148}