Skip to main content

faucet_cli/serve/
auth.rs

1//! Bearer-token authentication + RBAC authorization for `/v1/*` (#205).
2//! Constant-time comparison via `subtle`; the `Authorization` header is the only
3//! accepted credential. The bearer token resolves (via
4//! [`AuthMode::resolve`](crate::serve::config::AuthMode::resolve)) to an
5//! [`AuthContext`](crate::serve::rbac::AuthContext), the request's matched route declares the required
6//! [`Permission`](crate::serve::rbac::Permission), and a role that lacks it is
7//! denied (`403`) with an audit record.
8
9use 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
19/// Timing-safe byte-slice equality. Differing lengths return `false` after a
20/// constant-time length check (subtle short-circuits unequal lengths — this
21/// leaks only the token *length*, never its content); equal-length inputs are
22/// compared byte-by-byte with no early exit on the first mismatch.
23pub(crate) fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
24    a.ct_eq(b).into()
25}
26
27/// Validate a raw `Authorization` header value against the expected token.
28pub 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
40/// Extract the raw bearer token from an `Authorization: Bearer <token>` header.
41fn 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
49/// Best-effort `run_id` from a `/v1/runs/{id}[/…]` path (for denial audit
50/// attribution). `None` for non-run routes.
51fn 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
57/// Axum middleware enforcing bearer auth + RBAC on `/v1/*`. Under `--no-auth`
58/// every request resolves to an implicit `anonymous` admin (all permitted), so
59/// the authz path is uniform. CORS preflight (`OPTIONS`) is allowed through so
60/// browsers (which omit `Authorization` on preflight) work behind a CORS policy.
61///
62/// On success the resolved [`AuthContext`](crate::serve::rbac::AuthContext) is
63/// inserted into the request extensions for handlers (and the audit writer). A
64/// principal whose role lacks the route's required permission gets a `403` and a
65/// `denied` audit record.
66pub 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    // Best-effort source IP (present when the server is served with connect-info;
81    // absent when a handler is called directly in tests).
82    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    // A mapped route requires its permission; an unmapped `/v1` route is
94    // admin-only (fail closed for any endpoint added without an explicit entry).
95    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")); // length differs
134    }
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()); // no "Bearer " prefix
146        assert!(authorize_header(Some("Basic s3cret"), "s3cret").is_err());
147    }
148}