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}