Skip to main content

cortex/frontend/
actor.rs

1// Copyright 2015-2025 Deyan Ginev. See the LICENSE
2// file at the top-level directory of this distribution.
3//
4// Licensed under the MIT license <LICENSE-MIT or http://opensource.org/licenses/MIT>.
5// This file may not be copied, modified, or distributed
6// except according to those terms.
7
8//! The [`Actor`] request guard: the authenticated initiator of a mutating request.
9//!
10//! Identity is tokens-first (no OAuth on the critical path). A request carries a rerun token via
11//! the `X-Cortex-Token` header or a `?token=` query parameter; the guard resolves it to an owner
12//! through `config().auth.rerun_tokens`, or fails the request with `401`. Mutating routes take an
13//! `Actor` so the initiator is **threaded into the owner of every write** (attributable actions —
14//! the observability mandate) and so writes are denied by default (an empty token map rejects
15//! everyone, rather than letting anyone wipe results).
16
17use diesel::pg::PgConnection;
18use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
19use rocket::State;
20use rocket::http::Status;
21use rocket::request::{FromRequest, Outcome, Request};
22use rocket::response::{Redirect, Responder};
23
24use crate::backend::DbPool;
25use crate::config::config;
26use crate::models::Session;
27
28/// The authenticated initiator of a mutating request, resolved from a rerun token.
29pub struct Actor {
30  /// The human-readable owner the token maps to (recorded as the `owner` of the resulting action).
31  pub owner: String,
32}
33
34/// Resolves a rerun token to its owner, mirroring the [`Actor`] guard's lookup. For **form-based**
35/// human submissions (a `<form method=post>` token field), where the guard — which only reads the
36/// `X-Cortex-Token` header or `?token=` query — can't see a token in the request body.
37pub fn owner_for_token(token: &str) -> Option<String> {
38  config().auth.rerun_tokens.get(token).cloned()
39}
40
41/// The raw credential carriers on a request, extracted **without any lookup** (cheap, sync): the
42/// bearer token (the `X-Cortex-Token` header or `?token=` query) and the [`ADMIN_COOKIE`] session
43/// cookie. The audit fairing extracts these synchronously so it can resolve them to an owner *off*
44/// the response path (the cookie now needs a DB session lookup — see [`resolve_carriers`]). A token
45/// in a POST **form body** (the un-signed-in human forms) is deliberately not visible here.
46pub struct ActorCarriers {
47  /// A bearer token from the `X-Cortex-Token` header or `?token=` query, if present.
48  pub token: Option<String>,
49  /// The [`ADMIN_COOKIE`] session-id cookie value, if present.
50  pub session_cookie: Option<String>,
51}
52
53/// Extracts the [`ActorCarriers`] from a request (no lookups).
54pub fn actor_carriers(request: &Request<'_>) -> ActorCarriers {
55  ActorCarriers {
56    token: request
57      .headers()
58      .get_one("X-Cortex-Token")
59      .map(str::to_string)
60      .or_else(|| request.query_value::<String>("token").and_then(Result::ok)),
61    session_cookie: request
62      .cookies()
63      .get(ADMIN_COOKIE)
64      .map(|cookie| cookie.value().to_string()),
65  }
66}
67
68/// Resolves [`ActorCarriers`] to an owner: the bearer token against the configured admin tokens,
69/// the session cookie against the `sessions` table (hence the `connection`). The token wins if both
70/// are present (an explicit API credential is the more specific intent). `None` if neither
71/// resolves.
72pub fn resolve_carriers(connection: &mut PgConnection, carriers: &ActorCarriers) -> Option<String> {
73  if let Some(owner) = carriers.token.as_deref().and_then(owner_for_token) {
74    return Some(owner);
75  }
76  carriers
77    .session_cookie
78    .as_deref()
79    .and_then(|id| Session::resolve_owner(connection, id))
80}
81
82#[rocket::async_trait]
83impl<'r> FromRequest<'r> for Actor {
84  type Error = ();
85
86  async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
87    let token = request
88      .headers()
89      .get_one("X-Cortex-Token")
90      .map(str::to_string)
91      .or_else(|| request.query_value::<String>("token").and_then(Result::ok));
92    match token.and_then(|token| config().auth.rerun_tokens.get(&token).cloned()) {
93      Some(owner) => Outcome::Success(Actor { owner }),
94      None => Outcome::Error((Status::Unauthorized, ())),
95    }
96  }
97}
98
99/// Documents the [`Actor`] guard for the generated OpenAPI spec (`frontend::apidoc`): every
100/// endpoint that takes an `Actor` advertises a `CortexToken` **ApiKey** security scheme — the
101/// `X-Cortex-Token` request header — so the docs show which calls are token-gated.
102impl<'r> rocket_okapi::request::OpenApiFromRequest<'r> for Actor {
103  fn from_request_input(
104    _gen: &mut rocket_okapi::r#gen::OpenApiGenerator,
105    _name: String,
106    _required: bool,
107  ) -> rocket_okapi::Result<rocket_okapi::request::RequestHeaderInput> {
108    use rocket_okapi::okapi::openapi3::{SecurityRequirement, SecurityScheme, SecuritySchemeData};
109    let security_scheme = SecurityScheme {
110      description: Some(
111        "A CorTeX rerun token, sent in the `X-Cortex-Token` request header (a `?token=` query \
112         parameter is also accepted). It maps to an owner in `auth.rerun_tokens`; a missing or \
113         unknown token is rejected with `401`."
114          .to_owned(),
115      ),
116      data: SecuritySchemeData::ApiKey {
117        name: "X-Cortex-Token".to_owned(),
118        location: "header".to_owned(),
119      },
120      extensions: Default::default(),
121    };
122    let mut security_req = SecurityRequirement::new();
123    security_req.insert("CortexToken".to_owned(), Vec::new());
124    Ok(rocket_okapi::request::RequestHeaderInput::Security(
125      "CortexToken".to_owned(),
126      security_scheme,
127      security_req,
128    ))
129  }
130}
131
132/// The cookie carrying a signed-in admin's session token (set by the `/admin/login` page).
133pub const ADMIN_COOKIE: &str = "cortex_admin";
134
135/// A signed-in admin's **browser** session — the [`Actor`]'s counterpart for the human admin UI.
136/// The [`ADMIN_COOKIE`] cookie carries a random opaque **session id** (not a credential); this
137/// guard resolves it against the server-side `sessions` table on every request, so sign-out (which
138/// deletes the row) immediately ends the session and a forged cookie is a useless random id.
139/// Established by the admin token *or* a passkey at sign-in. Gated admin screens take an
140/// `AdminSession`; an unauthenticated browser is sent to the sign-in page (handled per-route via
141/// `Option<AdminSession>`).
142pub struct AdminSession {
143  /// The owner the session belongs to (recorded as the actor of admin actions).
144  pub owner: String,
145}
146
147#[rocket::async_trait]
148impl<'r> FromRequest<'r> for AdminSession {
149  type Error = ();
150
151  async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
152    let Some(session_id) = request
153      .cookies()
154      .get(ADMIN_COOKIE)
155      .map(|c| c.value().to_string())
156    else {
157      return Outcome::Error((Status::Unauthorized, ()));
158    };
159    // Resolve the session id against the `sessions` table (the pool is managed state).
160    let owner = match request.guard::<&State<DbPool>>().await {
161      Outcome::Success(pool) => pool
162        .get()
163        .ok()
164        .and_then(|mut connection| Session::resolve_owner(&mut connection, &session_id)),
165      _ => None,
166    };
167    match owner {
168      Some(owner) => Outcome::Success(AdminSession { owner }),
169      None => Outcome::Error((Status::Unauthorized, ())),
170    }
171  }
172}
173
174/// Whether `path` is a safe local path to redirect to — an absolute path that is **not**
175/// protocol-relative (`//host`) or a backslash trick (`/\host`). The open-redirect guard for the
176/// `?next=` return-to-after-login flow.
177fn is_safe_local_path(path: &str) -> bool {
178  path.starts_with('/') && !path.starts_with("//") && !path.starts_with("/\\")
179}
180
181/// Builds the sign-in URL: `/admin/login`, with `?bad=true` when a previous attempt failed and
182/// `&next=<encoded>` when `next` is a safe local path to return to after login (open-redirect
183/// guarded — a non-local `next` is dropped).
184pub fn sign_in_url(bad: bool, next: Option<&str>) -> String {
185  let mut url = String::from("/admin/login");
186  let mut separator = '?';
187  if bad {
188    url.push_str("?bad=true");
189    separator = '&';
190  }
191  if let Some(path) = next.filter(|path| is_safe_local_path(path)) {
192    url.push(separator);
193    url.push_str("next=");
194    url.push_str(&utf8_percent_encode(path, NON_ALPHANUMERIC).to_string());
195  }
196  url
197}
198
199/// Validates a post-login `next` destination: the path if it is a safe local path, else `/admin`.
200pub fn safe_next(next: Option<&str>) -> String {
201  match next {
202    Some(path) if is_safe_local_path(path) => path.to_string(),
203    _ => "/admin".to_string(),
204  }
205}
206
207/// The current request's path + query, captured for the `?next=` return-to-after-login flow on
208/// gated **GET** screens. An infallible request guard (always succeeds).
209pub struct ReturnTo(pub String);
210
211#[rocket::async_trait]
212impl<'r> FromRequest<'r> for ReturnTo {
213  type Error = ();
214
215  async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
216    Outcome::Success(ReturnTo(request.uri().to_string()))
217  }
218}
219
220/// Like [`require_admin`], but for a **GET** screen: an unauthenticated browser is redirected to
221/// the sign-in page with a `?next=` pointing back at `return_to`, so it lands here again after
222/// signing in.
223#[allow(clippy::result_large_err)] // see require_admin.
224pub fn require_admin_to(
225  session: Option<AdminSession>,
226  return_to: &ReturnTo,
227) -> Result<AdminSession, AdminReject> {
228  session.ok_or_else(|| AdminReject::Redirect(Redirect::to(sign_in_url(false, Some(&return_to.0)))))
229}
230
231/// The rejection of an admin-gated **human screen**: either a redirect to the sign-in page (the
232/// browser isn't signed in) or a genuine error status (e.g. `404` unknown resource, `503` pool
233/// exhausted). This lets a gated page keep its real error cases while sending an unauthenticated
234/// browser to sign in — rather than showing it a bare `401`. The **agent APIs are unaffected**:
235/// they keep the token-based [`Actor`] guard, so a machine still gets a clean `401`, not an HTML
236/// redirect.
237// The Redirect variant is intentionally larger than the Status variant — this enum exists precisely
238// to carry *either*, and it is only ever a short-lived error value, never stored en masse.
239#[allow(clippy::large_enum_variant)]
240#[derive(Responder)]
241pub enum AdminReject {
242  /// Not signed in → the sign-in page (`303`).
243  Redirect(Redirect),
244  /// A genuine error reached *after* authorization (unknown resource, pool exhausted, …).
245  Status(Status),
246}
247
248impl From<Status> for AdminReject {
249  fn from(status: Status) -> Self { AdminReject::Status(status) }
250}
251
252/// Requires a signed-in admin for a **human screen**, else a redirect to the sign-in page. The
253/// first line of every admin-gated page handler (which returns `Result<Template, AdminReject>`): a
254/// handler's existing `Status` errors convert through `?` (see [`AdminReject`]'s `From<Status>`),
255/// so it keeps its real `404`/`503` while unauthenticated browsers are bounced to `/admin/login`.
256// The `Err` (AdminReject) carries a Redirect; large by clippy's heuristic but it is a transient
257// one-shot value on the request path, not a hot return — same rationale as the page handlers.
258#[allow(clippy::result_large_err)]
259pub fn require_admin(session: Option<AdminSession>) -> Result<AdminSession, AdminReject> {
260  session.ok_or_else(|| AdminReject::Redirect(Redirect::to("/admin/login")))
261}