watchword/core/guard.rs
1use std::string::String;
2use std::time::Duration;
3
4use jiff::Timestamp;
5use tokio::sync::broadcast::error::RecvError;
6use tokio::sync::{Mutex, watch};
7use tokio::task::JoinHandle;
8use tokio::time::{Instant, MissedTickBehavior};
9
10use super::{Auth, AuthEvent};
11use crate::store::{Session, User};
12use crate::types::{Access, UserId, UserInfo};
13
14/// How often a guard re-checks its session in the store, to notice a session
15/// ended by another process.
16const RECHECK: Duration = Duration::from_secs(5 * 60);
17
18impl Auth {
19 /// Watches `session`, signed in as `user` (as loaded for the request,
20 /// such as the `SignedIn` extractor's fields), for a WebSocket's
21 /// `select!` loop:
22 ///
23 /// ```no_run
24 /// # async fn ws(auth: watchword::Auth, user: watchword::store::User, session: watchword::store::Session) {
25 /// let guard = auth.guard(&user, &session);
26 ///
27 /// loop {
28 /// tokio::select! {
29 /// _ = guard.ended() => break,
30 /// info = guard.changed() => {
31 /// // The user's role, email, login or credential changed.
32 /// }
33 /// // ... the socket's own messages
34 /// }
35 /// }
36 /// # }
37 /// ```
38 ///
39 /// The guard follows [`AuthEvent`]s, sleeps until the session expires,
40 /// and re-checks the session in the store on broadcast lag and every 5
41 /// minutes, so a session deleted by another process still ends the
42 /// socket. It runs a task on the current Tokio runtime until it is
43 /// dropped or the session ends.
44 pub fn guard(&self, user: &User, session: &Session) -> SessionGuard {
45 let (ended_tx, ended) = watch::channel(false);
46 let (changed_tx, changed) = watch::channel(user.info());
47 let current = changed.clone();
48
49 let watcher = Watcher {
50 auth: self.clone(),
51 // Subscribe now, so nothing sent after this call is missed.
52 events: self.events(),
53 session: session.id.clone(),
54 user: user.id,
55 impersonator: session.impersonator,
56 expires_at: session.expires_at,
57 ended: ended_tx,
58 changed: changed_tx,
59 };
60
61 SessionGuard {
62 ended: Mutex::new(ended),
63 changed: Mutex::new(changed),
64 current,
65 task: tokio::spawn(watcher.run()),
66 }
67 }
68}
69
70/// Resolves when a WebSocket's session ends; see [`Auth::guard`].
71///
72/// [`ended`](SessionGuard::ended) and [`changed`](SessionGuard::changed)
73/// take `&self` and are cancel-safe, so they can share one `tokio::select!`.
74///
75/// The guard also keeps the session's user as last seen, which
76/// [`user`](SessionGuard::user) and [`watch_user`](SessionGuard::watch_user)
77/// read without a store lookup, so a socket can gate its own requests on the
78/// user's role:
79///
80/// ```no_run
81/// # async fn ws(auth: watchword::Auth, user: watchword::store::User, session: watchword::store::Session) {
82/// let guard = auth.guard(&user, &session);
83///
84/// // Cloneable, for the app's request handler.
85/// let user = guard.watch_user();
86///
87/// if user.borrow().is_admin() {
88/// // ... answer an admin-only request.
89/// }
90/// # }
91/// ```
92///
93/// That role is current as of the last event or store re-check (on lag and
94/// every 5 minutes), so it may lag a change made by another process. Where
95/// a stale role must not let a request through, use [`Auth::session`], or
96/// `musliweb05::Connection::require_admin` on a musli-web socket, which read
97/// the store.
98pub struct SessionGuard {
99 ended: Mutex<watch::Receiver<bool>>,
100 changed: Mutex<watch::Receiver<UserInfo>>,
101 current: watch::Receiver<UserInfo>,
102 task: JoinHandle<()>,
103}
104
105impl SessionGuard {
106 /// Resolves once the session has ended: signed out, signed out
107 /// everywhere, revoked, the credential changed elsewhere, the user
108 /// deleted, or the session expired; for an impersonated session also
109 /// when its admin is revoked, demoted or deleted. Once ended, it
110 /// resolves at once.
111 pub async fn ended(&self) {
112 let mut ended = self.ended.lock().await;
113 // An error means the watcher is gone, which it only is once ended.
114 let _ = ended.wait_for(|ended| *ended).await;
115 }
116
117 /// Resolves with the user whenever their login, email, role, access or
118 /// credential changes. Changes made while nobody waits are coalesced
119 /// into the latest one. Never resolves once the session has ended.
120 pub async fn changed(&self) -> UserInfo {
121 let mut changed = self.changed.lock().await;
122
123 if changed.changed().await.is_err() {
124 return std::future::pending().await;
125 }
126
127 changed.borrow_and_update().clone()
128 }
129
130 /// The session's user as last seen: as loaded when the guard was made,
131 /// then as each change [`changed`](SessionGuard::changed) reports left
132 /// it. Once the session has ended it is the user as they were last seen
133 /// before. For an impersonated session it is the impersonated user,
134 /// without the [`impersonated_by`](UserInfo::impersonated_by) marker.
135 pub fn user(&self) -> UserInfo {
136 self.current.borrow().clone()
137 }
138
139 /// A receiver of the session's user, as [`user`](SessionGuard::user)
140 /// gives it, for code outside the socket loop, such as the app's request
141 /// handler. It is cheap to clone, `borrow()` reads the latest value, and
142 /// it keeps working after the guard is dropped, holding the last value.
143 /// It is separate from what [`changed`](SessionGuard::changed) tracks:
144 /// reading or waiting on it does not consume a change there.
145 pub fn watch_user(&self) -> watch::Receiver<UserInfo> {
146 self.current.clone()
147 }
148}
149
150impl Drop for SessionGuard {
151 fn drop(&mut self) {
152 self.task.abort();
153 }
154}
155
156struct Watcher {
157 auth: Auth,
158 events: tokio::sync::broadcast::Receiver<AuthEvent>,
159 session: String,
160 user: UserId,
161 impersonator: Option<UserId>,
162 expires_at: Timestamp,
163 ended: watch::Sender<bool>,
164 changed: watch::Sender<UserInfo>,
165}
166
167impl Watcher {
168 async fn run(mut self) {
169 let expiry = tokio::time::sleep(until(self.expires_at));
170 tokio::pin!(expiry);
171
172 let mut recheck = tokio::time::interval_at(Instant::now() + RECHECK, RECHECK);
173 recheck.set_missed_tick_behavior(MissedTickBehavior::Delay);
174
175 // Catches anything that happened between the request loading the
176 // session and the guard subscribing.
177 let mut live = self.recheck().await;
178
179 while live {
180 live = tokio::select! {
181 _ = &mut expiry => false,
182 _ = recheck.tick() => self.recheck().await,
183 event = self.events.recv() => match event {
184 Ok(event) => self.apply(event),
185 Err(RecvError::Lagged(_)) => self.recheck().await,
186 // The service holds the sender, and this task the service.
187 Err(RecvError::Closed) => false,
188 },
189 };
190 }
191
192 let _ = self.ended.send(true);
193 }
194
195 /// Whether the session is still live after `event`.
196 fn apply(&self, event: AuthEvent) -> bool {
197 match event {
198 AuthEvent::SessionEnded(id) => id != self.session,
199 AuthEvent::UserSessionsEnded { user, keep } => {
200 user != self.user || keep.as_deref() == Some(self.session.as_str())
201 }
202 AuthEvent::UserDeleted(user) => user != self.user && Some(user) != self.impersonator,
203 // An impersonated session ends with its admin's standing.
204 AuthEvent::UserChanged(info) if Some(info.id) == self.impersonator => {
205 info.access == Access::Active && info.is_admin()
206 }
207 AuthEvent::UserChanged(info) if info.id == self.user => {
208 if info.access == Access::Revoked {
209 return false;
210 }
211
212 self.change(info);
213 true
214 }
215 AuthEvent::UserChanged(_) | AuthEvent::UserCreated(_) => true,
216 }
217 }
218
219 /// Asks the store whether the session is live, passing on a change to
220 /// the user that an event may have been lost for.
221 async fn recheck(&self) -> bool {
222 match self.auth.session(&self.session).await {
223 Ok(Some((user, _))) => {
224 self.change(user.info());
225 true
226 }
227 Ok(None) => false,
228 Err(error) => {
229 // Keep the socket; the next check may succeed.
230 tracing::warn!(error = ?error, "auth: re-checking a socket's session failed");
231 true
232 }
233 }
234 }
235
236 fn change(&self, info: UserInfo) {
237 self.changed.send_if_modified(|current| {
238 if *current == info {
239 return false;
240 }
241
242 *current = info;
243 true
244 });
245 }
246}
247
248/// How long until `at`, or zero if it has passed.
249fn until(at: Timestamp) -> Duration {
250 let left = at.duration_since(Timestamp::now());
251 Duration::try_from(left).unwrap_or(Duration::ZERO)
252}