Skip to main content

prospero_api/auth/
mod.rs

1//! Inbound API authentication (#2, ADR-0010).
2//!
3//! A `route_layer` middleware resolves a [`Principal`] from the request's
4//! credential, checks it against the route's [`required_access`], writes one
5//! audit log line per mutation, and runs the handler inside
6//! `prospero_core::actor::scope` so directly emitted events carry the token name.
7
8use std::fmt;
9use std::sync::Arc;
10
11use axum::extract::{MatchedPath, Request, State};
12use axum::http::{HeaderMap, Method, header};
13use axum::middleware::Next;
14use axum::response::{IntoResponse, Response};
15use prospero_core::Scope;
16use prospero_core::auth::TokenSet;
17use rand::RngCore as _;
18
19use crate::error::ApiError;
20
21pub mod handlers;
22pub mod session;
23
24/// HMAC key for dashboard session cookies. Never logged.
25pub struct SessionKey(Vec<u8>);
26
27impl SessionKey {
28    /// Minimum key length in bytes.
29    pub const MIN_LEN: usize = 32;
30
31    /// A process-local random key (standalone default; sessions reset on restart).
32    pub fn random() -> SessionKey {
33        let mut bytes = vec![0u8; Self::MIN_LEN];
34        rand::thread_rng().fill_bytes(&mut bytes);
35        SessionKey(bytes)
36    }
37
38    /// A key from configured bytes.
39    pub fn from_bytes(bytes: Vec<u8>) -> Result<SessionKey, String> {
40        if bytes.len() < Self::MIN_LEN {
41            return Err(format!(
42                "session key must be at least {} bytes (got {})",
43                Self::MIN_LEN,
44                bytes.len()
45            ));
46        }
47        Ok(SessionKey(bytes))
48    }
49
50    pub(crate) fn as_bytes(&self) -> &[u8] {
51        &self.0
52    }
53}
54
55impl fmt::Debug for SessionKey {
56    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57        f.write_str("SessionKey(<redacted>)")
58    }
59}
60
61/// Authentication configuration shared by the middleware and session handlers.
62#[derive(Debug)]
63pub struct AuthState {
64    tokens: Option<TokenSet>,
65    session_key: SessionKey,
66    cookie_secure: bool,
67}
68
69impl AuthState {
70    /// No tokens configured: every request is admitted (loopback-only or
71    /// `--insecure-no-auth`, enforced by prosperod at startup).
72    pub fn disabled() -> AuthState {
73        AuthState {
74            tokens: None,
75            session_key: SessionKey::random(),
76            cookie_secure: false,
77        }
78    }
79
80    /// Tokens configured: every non-open route requires a credential.
81    pub fn enabled(tokens: TokenSet, session_key: SessionKey, cookie_secure: bool) -> AuthState {
82        AuthState {
83            tokens: Some(tokens),
84            session_key,
85            cookie_secure,
86        }
87    }
88
89    /// Whether tokens are configured.
90    pub fn is_enabled(&self) -> bool {
91        self.tokens.is_some()
92    }
93
94    pub(crate) fn tokens(&self) -> Option<&TokenSet> {
95        self.tokens.as_ref()
96    }
97
98    pub(crate) fn session_key(&self) -> &SessionKey {
99        &self.session_key
100    }
101
102    pub(crate) fn cookie_secure(&self) -> bool {
103        self.cookie_secure
104    }
105}
106
107/// How the principal authenticated.
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub enum Via {
110    /// `Authorization: Bearer`.
111    Bearer,
112    /// `prospero_session` cookie.
113    Cookie,
114}
115
116/// The authenticated caller, inserted as a request extension.
117#[derive(Debug, Clone, PartialEq, Eq)]
118pub struct Principal {
119    /// Tokens-file name.
120    pub token_name: String,
121    /// Scope from the *current* tokens file.
122    pub scope: Scope,
123    /// Credential kind.
124    pub via: Via,
125    /// Cookie expiry (unix seconds); `None` for bearer.
126    pub expires_unix: Option<i64>,
127}
128
129/// A route's requirement.
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131pub enum Access {
132    /// No credential needed.
133    Open,
134    /// At least this scope.
135    Requires(Scope),
136}
137
138/// The scope table, keyed on axum's matched route pattern. Unlisted routes
139/// fail closed to `admin`.
140pub fn required_access(method: &Method, route: &str) -> Access {
141    use Access::{Open, Requires};
142    use Scope::{Admin, Operate, Read};
143    match (method.as_str(), route) {
144        // #222: the OpenAPI document describes the API's shape, not its data,
145        // and a client needs it to know how to authenticate in the first place.
146        // It is the same information the published guide already carries.
147        (_, "/healthz" | "/readyz" | "/" | "/assets/{*path}" | "/api/session"
148            | "/api/openapi.json") => Open,
149        // #220: a webhook trigger authenticates with an HMAC signature over the
150        // request body, checked against the one automation's own key. That
151        // signature *is* the credential, and it authorizes firing exactly that
152        // automation — so the route carries no scope. It is `Open` only in the
153        // sense that this table does not gate it; the handler rejects any
154        // request whose signature does not verify.
155        ("POST", "/api/automations/{id}/trigger") => Open,
156        (
157            "GET" | "HEAD",
158            "/api/metrics"
159            | "/api/capabilities"
160            | "/api/fleet"
161            // #219: the fleet-wide event stream is a read of the same fleet.
162            | "/api/fleet/stream"
163            | "/api/usage"
164            | "/api/workspaces"
165            | "/api/workspaces/{workspace}/agents"
166            | "/api/agents/{id}"
167            | "/api/agents/{id}/events"
168            | "/api/agents/{id}/stream"
169            // #220: reading automations and their run history is a read of
170            // fleet configuration; neither response carries a signing key.
171            | "/api/automations"
172            | "/api/automations/{id}/runs",
173        ) => Requires(Read),
174        (
175            "POST",
176            "/api/workspaces/{workspace}/agents"
177            | "/api/agents/{id}/kill"
178            | "/api/agents/{id}/respawn"
179            | "/api/agents/{id}/input"
180            | "/api/agents/{id}/end-input"
181            // #220: firing an automation by hand spawns an agent, which is
182            // exactly what `operate` covers.
183            | "/api/automations/{id}/run",
184        )
185        | ("DELETE", "/api/agents/{id}") => Requires(Operate),
186        // #218: MCP is a *driving* surface — its tools spawn, steer and kill —
187        // so the whole endpoint sits at the scope those actions need rather
188        // than a second, per-tool authorization model inside the handler.
189        (_, "/mcp") => Requires(Operate),
190        // #220: creating an automation mints a credential and can grant an
191        // unattended permission posture; editing or deleting one changes what
192        // the fleet does with nobody watching. All three are `admin`, the same
193        // bar #238 set for choosing `Unattended` on a manual spawn.
194        ("POST", "/api/automations")
195        | ("DELETE", "/api/automations/{id}")
196        | ("PUT", "/api/automations/{id}/enabled") => Requires(Admin),
197        ("POST", "/api/workspaces")
198        | ("DELETE", "/api/workspaces/{name}")
199        | ("PUT", "/api/workspaces/{name}/config") => Requires(Admin),
200        _ => Requires(Admin),
201    }
202}
203
204/// Header by which a client says which person it is acting for (#251).
205pub const ON_BEHALF_OF_HEADER: &str = "x-prospero-on-behalf-of";
206
207/// Read and validate the claimed subject from a request's headers.
208///
209/// `Ok(None)` means the client asserted nothing, which is the overwhelmingly
210/// common case and must behave exactly as before. `Err` is a client mistake
211/// worth a `400`: silently dropping a malformed value would leave the caller
212/// believing its audit trail records something it does not.
213pub(crate) fn subject_header(headers: &HeaderMap) -> Result<Option<String>, String> {
214    let Some(raw) = headers.get(ON_BEHALF_OF_HEADER) else {
215        return Ok(None);
216    };
217    let text = raw
218        .to_str()
219        .map_err(|_| "on-behalf-of must be valid UTF-8 text".to_string())?;
220    prospero_core::actor::validate_subject(text).map(|s| Some(s.to_string()))
221}
222
223/// The `Authorization: Bearer` token, if present.
224pub(crate) fn bearer_token(headers: &HeaderMap) -> Option<&str> {
225    headers
226        .get(header::AUTHORIZATION)?
227        .to_str()
228        .ok()?
229        .strip_prefix("Bearer ")
230        .map(str::trim)
231}
232
233/// Resolve the caller. A present-but-invalid bearer token is a failure; it never
234/// falls back to another credential.
235pub(crate) fn resolve_principal(auth: &AuthState, headers: &HeaderMap) -> Option<Principal> {
236    let tokens = auth.tokens()?;
237    if let Some(token) = bearer_token(headers) {
238        return tokens.authenticate(token).map(|e| Principal {
239            token_name: e.name.clone(),
240            scope: e.scope,
241            via: Via::Bearer,
242            expires_unix: None,
243        });
244    }
245    let value = session::cookie_value(headers)?;
246    let now = chrono::Utc::now().timestamp();
247    session::verify(auth.session_key(), tokens, value, now).map(|(e, expires)| Principal {
248        token_name: e.name.clone(),
249        scope: e.scope,
250        via: Via::Cookie,
251        expires_unix: Some(expires),
252    })
253}
254
255/// The auth middleware (installed with `route_layer`, so `MatchedPath` is set).
256pub async fn middleware(
257    State(auth): State<Arc<AuthState>>,
258    mut req: Request,
259    next: Next,
260) -> Response {
261    // Resolve the claimed subject before anything else: it is orthogonal to
262    // authentication (it applies with auth disabled and on open routes), and a
263    // malformed one is the client's mistake either way.
264    let subject = match subject_header(req.headers()) {
265        Ok(subject) => subject,
266        Err(why) => {
267            tracing::debug!(target: "prospero_auth", %why, "rejected: bad on-behalf-of header");
268            return ApiError::BadRequest(why).into_response();
269        }
270    };
271    if !auth.is_enabled() {
272        return prospero_core::actor::scope_with_subject(None, subject, next.run(req)).await;
273    }
274    let route = req
275        .extensions()
276        .get::<MatchedPath>()
277        .map(|m| m.as_str().to_owned())
278        .unwrap_or_default();
279    let need = match required_access(req.method(), &route) {
280        Access::Open => {
281            return prospero_core::actor::scope_with_subject(None, subject, next.run(req)).await;
282        }
283        Access::Requires(scope) => scope,
284    };
285    let Some(principal) = resolve_principal(&auth, req.headers()) else {
286        tracing::debug!(target: "prospero_auth", %route, "rejected: missing or invalid credential");
287        return ApiError::Unauthorized.into_response();
288    };
289    if principal.scope < need {
290        tracing::debug!(
291            target: "prospero_auth", %route, token = %principal.token_name,
292            have = principal.scope.as_str(), need = need.as_str(), "rejected: insufficient scope"
293        );
294        return ApiError::Forbidden(format!("requires scope {}", need.as_str())).into_response();
295    }
296    if principal.via == Via::Cookie
297        && !matches!(*req.method(), Method::GET | Method::HEAD)
298        && !session::same_origin(req.headers())
299    {
300        tracing::debug!(target: "prospero_auth", %route, token = %principal.token_name, "rejected: cross-origin cookie mutation");
301        return ApiError::Forbidden("cross-origin request".into()).into_response();
302    }
303    if !matches!(*req.method(), Method::GET | Method::HEAD) {
304        // The audit line carries both identities, and names the claimed one as
305        // claimed: the token is what prosperod authenticated, the subject is
306        // only what that token asserted.
307        tracing::info!(
308            target: "prospero_audit", actor = %principal.token_name,
309            asserted_on_behalf_of = subject.as_deref().unwrap_or("-"),
310            method = %req.method(), %route, "api mutation"
311        );
312    }
313    let actor = principal.token_name.clone();
314    req.extensions_mut().insert(principal);
315    prospero_core::actor::scope_with_subject(Some(actor), subject, next.run(req)).await
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321
322    #[test]
323    fn unknown_routes_fail_closed_to_admin() {
324        assert_eq!(
325            required_access(&Method::GET, "/api/something-new"),
326            Access::Requires(Scope::Admin)
327        );
328        assert_eq!(required_access(&Method::GET, "/healthz"), Access::Open);
329    }
330
331    /// #220: the automation surface sits at three different scopes, and
332    /// getting any of them wrong is a real privilege bug — creating an
333    /// automation mints a credential, so it must not be reachable at
334    /// `operate`, and the signed-webhook route must not demand a token it
335    /// was never meant to need.
336    #[test]
337    fn automation_routes_sit_at_their_intended_scopes() {
338        use Access::{Open, Requires};
339        use Scope::{Admin, Operate, Read};
340
341        assert_eq!(
342            required_access(&Method::GET, "/api/automations"),
343            Requires(Read)
344        );
345        assert_eq!(
346            required_access(&Method::GET, "/api/automations/{id}/runs"),
347            Requires(Read)
348        );
349        assert_eq!(
350            required_access(&Method::POST, "/api/automations/{id}/run"),
351            Requires(Operate)
352        );
353        assert_eq!(
354            required_access(&Method::POST, "/api/automations"),
355            Requires(Admin)
356        );
357        assert_eq!(
358            required_access(&Method::DELETE, "/api/automations/{id}"),
359            Requires(Admin)
360        );
361        assert_eq!(
362            required_access(&Method::PUT, "/api/automations/{id}/enabled"),
363            Requires(Admin)
364        );
365        // The HMAC signature is this route's credential.
366        assert_eq!(
367            required_access(&Method::POST, "/api/automations/{id}/trigger"),
368            Open
369        );
370        // …and only for POST. Nothing else on that path is open.
371        assert_eq!(
372            required_access(&Method::GET, "/api/automations/{id}/trigger"),
373            Requires(Admin)
374        );
375    }
376
377    #[test]
378    fn session_key_minimum_and_redaction() {
379        assert!(SessionKey::from_bytes(vec![7; 31]).is_err());
380        let k = SessionKey::from_bytes(vec![7; 32]).unwrap();
381        assert_eq!(format!("{k:?}"), "SessionKey(<redacted>)");
382    }
383}