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}