Skip to main content

cratefield_core/ports/
auth.rs

1//! Who is making this request (issue #153).
2//!
3//! A module that serves a row belonging to somebody has to know who is
4//! asking. Nothing in the harness could answer that: the verifier lives
5//! in `cratefield-auth-client`, whose extractor needs `AuthState` in the
6//! router state, and a module's state is an `Arc<ModuleContext>`. So a
7//! module could check an admin token or nothing at all.
8//!
9//! This is the port that closes it. A deployment wires one; a module
10//! declares [`Port::Auth`](super::Port::Auth) and gets it, and a module
11//! that declares it cannot be composed into a deployment that has none —
12//! which is the guarantee worth having. A venture declaring a table whose
13//! access is `owner` composes a module that requires this port, so the
14//! deployment refuses to boot rather than serving that table to everyone.
15//!
16//! # An unverified credential is not anonymity
17//!
18//! [`Auth::identify`] answers `Ok(Caller::Anonymous)` only when the
19//! request carried no credential at all. A credential that was presented
20//! and did not verify is an error, always, even for a route that would
21//! have served an anonymous caller.
22//!
23//! The alternative — folding a bad token into "anonymous" — is a hole
24//! with no floor. An expired token reading a `public-read` table would
25//! succeed, so nothing would tell the caller their session had ended;
26//! and the first handler written to fall back to anonymous on error
27//! would hand anonymous access to anybody presenting a forged token. The
28//! distinction has to survive as far as the handler, so it is in the
29//! type.
30
31use std::sync::Arc;
32
33use async_trait::async_trait;
34use http::HeaderMap;
35
36/// The person a verified credential speaks for.
37#[derive(Debug, Clone, PartialEq, Eq)]
38#[non_exhaustive]
39pub struct Subject {
40    /// The stable subject identifier — the value a table's privacy
41    /// declaration names as its subject column matches against.
42    pub id: String,
43    /// The session behind the credential. A deployment that needs
44    /// instant revocation asks the auth service about this id.
45    pub session: String,
46    /// Present only when the credential carried a verified address.
47    pub email: Option<String>,
48}
49
50impl Subject {
51    /// A subject with only an id, for a verifier that carries no more.
52    ///
53    /// Built rather than written as a literal because the struct is
54    /// `#[non_exhaustive]`: an adapter outside this crate cannot name
55    /// every field, which is what lets a later field be added without
56    /// breaking every adapter. The builders are the way in.
57    #[must_use]
58    pub fn new(id: impl Into<String>) -> Self {
59        Self {
60            id: id.into(),
61            session: String::new(),
62            email: None,
63        }
64    }
65
66    /// The session behind the credential.
67    #[must_use]
68    pub fn session(mut self, session: impl Into<String>) -> Self {
69        self.session = session.into();
70        self
71    }
72
73    /// A **verified** address. A verifier that holds an unverified one
74    /// passes `None`: an unverified address is a string the user typed,
75    /// and a table matching on it would be matching on a claim its own
76    /// subject controls.
77    #[must_use]
78    pub fn email(mut self, email: Option<String>) -> Self {
79        self.email = email;
80        self
81    }
82}
83
84/// Who the request is from.
85#[derive(Debug, Clone, PartialEq, Eq)]
86#[non_exhaustive]
87pub enum Caller {
88    /// No credential was presented. Not "a credential that failed" — see
89    /// the module docs for why those must not be the same answer.
90    Anonymous,
91    /// A credential was presented and verified.
92    Subject(Subject),
93}
94
95impl Caller {
96    /// The subject, when there is one.
97    #[must_use]
98    pub fn subject(&self) -> Option<&Subject> {
99        match self {
100            Self::Subject(subject) => Some(subject),
101            Self::Anonymous => None,
102        }
103    }
104
105    /// The subject's id, when there is one.
106    #[must_use]
107    pub fn id(&self) -> Option<&str> {
108        self.subject().map(|subject| subject.id.as_str())
109    }
110}
111
112/// Why a caller could not be identified.
113///
114/// Two cases and they are not the same refusal: one is the caller's
115/// problem and one is the deployment's, so one is a 401 and the other a
116/// 503. Collapsing them would tell a user to sign in again while the auth
117/// service is down, and they would, and it would not help.
118#[derive(Debug, Clone, PartialEq, Eq)]
119#[non_exhaustive]
120pub enum AuthError {
121    /// A credential was presented and did not verify — malformed,
122    /// expired, wrong audience, unknown key. One variant on purpose: each
123    /// answer a caller can tell apart is a hint about how to get closer.
124    NotVerified,
125    /// The verifier could not be reached or could not answer.
126    ///
127    /// The message is an adapter's, so it can carry a connection URL
128    /// with credentials in it, or an address. `Display` runs it through
129    /// [`crate::logging::scrub_text`] for the reason `DbError` does
130    /// (#135): "this never reaches a caller" is a promise about every
131    /// call site rather than about this type, and four credential leaks
132    /// in this codebase had exactly that shape. `Debug` keeps the raw
133    /// string, and the logging formatters scrub `{:?}` too.
134    Unavailable(String),
135}
136
137impl std::fmt::Display for AuthError {
138    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
139        match self {
140            Self::NotVerified => f.write_str("the credential did not verify"),
141            Self::Unavailable(detail) => write!(
142                f,
143                "the verifier could not answer: {}",
144                crate::logging::scrub_text(detail)
145            ),
146        }
147    }
148}
149
150impl std::error::Error for AuthError {}
151
152/// Turns a request's headers into the caller they speak for.
153///
154/// Headers rather than an extractor so the port is object-safe and can be
155/// exercised without an HTTP stack; the extractor is built on top.
156#[async_trait]
157pub trait Auth: Send + Sync {
158    /// Identifies the caller.
159    ///
160    /// # Errors
161    ///
162    /// [`AuthError::NotVerified`] when a credential was presented and did
163    /// not verify, and [`AuthError::Unavailable`] when the verifier could
164    /// not answer. A request with no credential is
165    /// `Ok(Caller::Anonymous)`, never an error.
166    async fn identify(&self, headers: &HeaderMap) -> Result<Caller, AuthError>;
167}
168
169#[async_trait]
170impl<T: Auth + ?Sized> Auth for Arc<T> {
171    async fn identify(&self, headers: &HeaderMap) -> Result<Caller, AuthError> {
172        (**self).identify(headers).await
173    }
174}
175
176/// The verifier a deployment gets when it asked for one from the
177/// environment and the environment did not have it.
178///
179/// A **provided port that verifies nothing**, the same shape
180/// `push_from_env` uses for a push router with no transports configured:
181/// on Workers the `Env` exists per request, so a runtime cannot know at
182/// compose time whether the issuer is set, and refusing to provide the
183/// port would make every such deployment fail to boot — including the
184/// ones whose tables are all public and never need it.
185///
186/// What it answers is chosen so nothing reads as working:
187///
188/// - **no credential** is [`Caller::Anonymous`], so a `public-read` table
189///   still serves and a deployment that only publishes is unaffected;
190/// - **a credential** is [`AuthError::Unavailable`], never `NotVerified`
191///   — the token may be perfectly good and nothing here can tell, so
192///   saying it did not verify would be a claim this has not established.
193///
194/// A table whose access needs a caller therefore answers 401 to an
195/// anonymous request and 503 to a signed-in one, and the boot warning
196/// naming the missing variables is in the log either way.
197pub struct Unconfigured {
198    /// What is missing, for the log and for the 503's detail.
199    why: String,
200}
201
202impl Unconfigured {
203    /// A verifier that cannot verify, and says why.
204    #[must_use]
205    pub fn new(why: impl Into<String>) -> Self {
206        Self { why: why.into() }
207    }
208}
209
210#[async_trait]
211impl Auth for Unconfigured {
212    async fn identify(&self, headers: &HeaderMap) -> Result<Caller, AuthError> {
213        if headers.get(http::header::AUTHORIZATION).is_none() {
214            return Ok(Caller::Anonymous);
215        }
216        Err(AuthError::Unavailable(self.why.clone()))
217    }
218}