Skip to main content

watchword/core/service/
cloudflare.rs

1//! Cloudflare Access sign-in on [`Auth`] (feature `cloudflare`).
2//!
3//! Access vouches for an email, which finds an existing user; users are never
4//! created. Signing in mints an ordinary session, so Access is consulted only
5//! when signing in. A pending user who signs in becomes active, and a revoked
6//! user is refused.
7
8#[cfg(test)]
9use std::string::String;
10#[cfg(test)]
11use std::vec::Vec;
12
13use http::HeaderMap;
14
15use super::{Actor, AuditEvent, Auth, AuthBuilder, AuthEvent, Result, require_admin};
16use crate::store::{Session, User};
17use crate::types::{Access, AuthError, CloudflareConfig};
18
19impl AuthBuilder {
20    /// The Cloudflare Access settings to run with while the store has none,
21    /// such as settings from the app's environment or its earlier storage.
22    /// `None` and settings that are not `enabled` both turn Access sign-in
23    /// off. The default is `None`.
24    ///
25    /// Settings saved with [`Auth::set_cloudflare`] are kept in the store
26    /// and win over these from then on, across restarts.
27    #[cfg_attr(watchword_docsrs, doc(cfg(feature = "cloudflare")))]
28    pub fn cloudflare(mut self, config: Option<CloudflareConfig>) -> Self {
29        self.cloudflare = config;
30        self
31    }
32}
33
34impl Auth {
35    /// The Cloudflare Access settings: those kept in the store, or those
36    /// given to [`AuthBuilder::cloudflare`] while the store has none, or
37    /// `None` when neither has any. They are what an admin's settings page
38    /// shows, enabled or not.
39    ///
40    /// The store is read once, the first time the settings are needed, which
41    /// is also when they are logged. To have them logged at startup, call
42    /// this there.
43    ///
44    /// Errors only when the store fails.
45    #[cfg_attr(watchword_docsrs, doc(cfg(feature = "cloudflare")))]
46    pub async fn cloudflare(&self) -> Result<Option<CloudflareConfig>> {
47        let provider = &self.inner.cloudflare;
48        provider.load(self.store()).await?;
49        Ok(provider.settings())
50    }
51
52    /// Stores the Cloudflare Access settings, under the store's `cloudflare`
53    /// setting, and applies them, such as when an admin saves them on the
54    /// settings page. Returns them as stored, with the team domain and the
55    /// audience trimmed.
56    ///
57    /// Settings that are not `enabled` turn Access sign-in off. Settings
58    /// that differ from the current ones are logged, with a warning when
59    /// they are unsafe or let no one sign in; the same settings again change
60    /// nothing.
61    ///
62    /// Sends [`AuditEvent::CloudflareChanged`] when the settings differ from
63    /// the current ones, naming an admin impersonating `actor` through
64    /// `session`.
65    ///
66    /// Admins only: [`AuthError::Forbidden`] unless `actor` is an active
67    /// admin. An app moving settings it kept itself onto watchword passes
68    /// them to [`AuthBuilder::cloudflare`], where they apply until an admin
69    /// saves settings here.
70    #[cfg_attr(watchword_docsrs, doc(cfg(feature = "cloudflare")))]
71    pub async fn set_cloudflare(
72        &self,
73        actor: &User,
74        session: &Session,
75        config: CloudflareConfig,
76    ) -> Result<CloudflareConfig> {
77        require_admin(actor)?;
78        let (before, config) = self.inner.cloudflare.save(self.store(), config).await?;
79        tracing::info!(user = %actor.id, "Cloudflare Access settings saved");
80
81        if before.as_ref() != Some(&config) {
82            self.audit(AuditEvent::CloudflareChanged {
83                actor: Actor::of(actor, session),
84                before,
85                after: config.clone(),
86            });
87        }
88
89        Ok(config)
90    }
91
92    /// Whether the request carries Cloudflare Access credentials that sign in
93    /// an existing, not revoked user, as
94    /// [`sign_in_cloudflare`](Auth::sign_in_cloudflare) would. This is
95    /// [`AuthConfig::cloudflare`](crate::AuthConfig::cloudflare).
96    ///
97    /// When the request carries Access credentials that do not, the reason
98    /// is logged as a warning, at most once a minute.
99    #[cfg_attr(watchword_docsrs, doc(cfg(feature = "cloudflare")))]
100    pub async fn cloudflare_available(&self, headers: &HeaderMap) -> Result<bool> {
101        Ok(self.cloudflare_user(headers).await?.is_some())
102    }
103
104    /// Signs in the user whose email the request's Cloudflare Access
105    /// credentials vouch for, whatever their [`Method`](crate::Method).
106    ///
107    /// A pending user becomes active, because Access has vouched for their
108    /// email. Anything else that is not an active user, including a revoked
109    /// user, an unknown email and Access being disabled, is
110    /// [`AuthError::Unauthorized`], with the reason logged as for
111    /// [`cloudflare_available`](Auth::cloudflare_available).
112    #[cfg_attr(watchword_docsrs, doc(cfg(feature = "cloudflare")))]
113    pub async fn sign_in_cloudflare(&self, headers: &HeaderMap) -> Result<(User, Session)> {
114        let mut user = self
115            .cloudflare_user(headers)
116            .await?
117            .ok_or(AuthError::Unauthorized)?;
118
119        if user.access == Access::Pending {
120            // Only a user who is still pending is activated, so a revocation
121            // since the read above is not undone.
122            let activated = self.store().activate(user.id).await?;
123
124            user = self
125                .store()
126                .user(user.id)
127                .await?
128                .ok_or(AuthError::Unauthorized)?;
129
130            if activated {
131                tracing::info!(user = %user.id, "Cloudflare Access activated a pending user");
132                self.emit(AuthEvent::UserChanged(user.info()));
133
134                self.audit(AuditEvent::Activated {
135                    actor: Actor::System,
136                    user: user.info(),
137                });
138            }
139        }
140
141        if user.access != Access::Active {
142            return Err(AuthError::Unauthorized.into());
143        }
144
145        let session = self.create_session(user.id).await?;
146        Ok((user, session))
147    }
148
149    /// The user the request's Access credentials sign in: an existing user
150    /// with the vouched-for email who is not revoked.
151    async fn cloudflare_user(&self, headers: &HeaderMap) -> Result<Option<User>> {
152        let provider = &self.inner.cloudflare;
153        provider.load(self.store()).await?;
154
155        let Some(email) = provider.email(headers).await else {
156            return Ok(None);
157        };
158
159        let Some(user) = self.store().user_by_email(&email).await? else {
160            provider.warn(format!(
161                "Cloudflare Access sign-in failed: no user has the email {email}"
162            ));
163            return Ok(None);
164        };
165
166        if user.access == Access::Revoked {
167            provider.warn(format!(
168                "Cloudflare Access sign-in failed: the user with the email {email} has had their access revoked"
169            ));
170            return Ok(None);
171        }
172
173        Ok(Some(user))
174    }
175
176    /// The Cloudflare Access warnings logged within the last minute.
177    #[cfg(test)]
178    pub(crate) fn cloudflare_warnings(&self) -> Vec<String> {
179        self.inner.cloudflare.warnings()
180    }
181}
182
183#[cfg(test)]
184mod tests;