Skip to main content

rustlavel_flags/
guard.rs

1//! The `WhenActive` middleware, and asking about a flag from inside a handler.
2//!
3//! Shaped like `rustlavel_rbac::Can`, because it is a neighbouring
4//! conversation: that middleware decides whether this user *may* see a route,
5//! this one decides whether the route is *there* for them yet.
6//!
7//! ```ignore
8//! router.group("/checkout", |r| {
9//!     r.middleware(Authenticate::from_config(&config));
10//!     r.middleware(WhenActive::new("new-checkout"));
11//!     r.post("", place_order);
12//! });
13//!
14//! router.get("/search", |req: Request| async move {
15//!     // Or ask inside the handler, when the flag changes the page rather
16//!     // than removing it.
17//!     let template = if req.flag("beta-search").await? { "search.beta" } else { "search" };
18//!     Ok(Response::html(render(template)))
19//! });
20//! ```
21
22use crate::flags::{Flags, ScopedFlags, missing_registry};
23use crate::scope::Scope;
24use rustlavel_auth::guard::AuthExt;
25use rustlavel_core::{Error, Result};
26use rustlavel_http::handler::BoxFuture;
27use rustlavel_http::middleware::{Middleware, Next};
28use rustlavel_http::response::IntoResponse;
29use rustlavel_http::{Request, Response};
30
31/// A route that is not there yet.
32///
33/// # Why 404 and not 403
34///
35/// A `403` is an admission. It says "there is something at this address, and
36/// you are not it" — which tells anyone who tries the URL that the feature
37/// exists, roughly what it is called, and that it is worth coming back for.
38/// Half-built features get found this way: somebody diffs the routes between
39/// two deploys, walks the ones that answer `403`, and reads the names.
40///
41/// A route behind a flag that is off has not shipped. The honest answer, and
42/// the same one the router gives for a path nobody ever wrote, is `404` — and
43/// it is byte-for-byte the router's own 404 here, because a body that differed
44/// would give the whole thing away again.
45///
46/// This is concealment, not authorization. It keeps a URL from being
47/// *interesting*; it does not keep anybody out. If the feature behind the flag
48/// must not be reachable by the people outside the rollout — because it costs
49/// money, or exposes somebody else's data — put `rustlavel_rbac::Can` in front
50/// of it as well. A flag is a release tool.
51///
52/// # What it does to the request
53///
54/// On the way through, the [`ScopedFlags`] it resolved is attached to the
55/// request, so a handler that asks the same or another flag reuses the memo
56/// instead of running the resolvers again.
57#[derive(Debug, Clone)]
58pub struct WhenActive {
59    flag: String,
60}
61
62impl WhenActive {
63    /// Guard the routes below with one flag.
64    pub fn new(flag: impl Into<String>) -> Self {
65        WhenActive { flag: flag.into() }
66    }
67
68    /// The flag this guard is checking.
69    pub fn flag(&self) -> &str {
70        &self.flag
71    }
72}
73
74impl Middleware for WhenActive {
75    fn handle(&self, request: Request, next: Next) -> BoxFuture<Response> {
76        let flag = self.flag.clone();
77
78        Box::pin(async move {
79            let mut request = request;
80
81            let scoped = match request.scoped_flags() {
82                Ok(scoped) => scoped,
83                Err(error) => return error.into_response(),
84            };
85
86            match scoped.active(&flag).await {
87                Ok(true) => {
88                    // Hand the memo to the handler: it very often wants the
89                    // same answer again, and this way it is free.
90                    request.extend(scoped);
91                    next.run(request).await
92                }
93                Ok(false) => Response::not_found(),
94                // Not a 404. A flag that could not be resolved is not a flag
95                // that is off, and answering "no such route" would hide a
96                // broken store behind a page that merely looks unfinished —
97                // for as long as it takes somebody to notice the feature never
98                // arrived. The cost is that a 500 admits something is here,
99                // which is the leak this middleware otherwise exists to close;
100                // we take that trade, because the alternative is an outage
101                // nobody can see.
102                Err(error) => Error::msg(format!(
103                    "checking the `{flag}` feature flag failed: {error}"
104                ))
105                .into_response(),
106            }
107        })
108    }
109}
110
111/// `req.flag(...)` — asking about a flag from inside a handler.
112///
113/// The companion to [`WhenActive`], for when the flag changes the page rather
114/// than removing it: a new panel, a different template, an extra field on a
115/// form.
116///
117/// Every method returns `Result`, and that is deliberate. "I could not find
118/// out" is a third answer, distinct from on and from off, and collapsing it
119/// into `false` would turn a store outage into a silent, global rollback that
120/// looks exactly like the flag being off on purpose.
121pub trait FlagsExt {
122    /// The registry from application state.
123    fn flags(&self) -> Result<Flags>;
124
125    /// The scope this request is checked as.
126    ///
127    /// The logged-in `rustlavel_auth::Identity` if there is one — attached by
128    /// the `auth` middleware, or read from the session if only the session
129    /// middleware is installed — and [`Scope::none`] otherwise. A guest is not
130    /// a scope of their own: they are everybody, and giving them one would mean
131    /// a percentage rollout that reshuffles on every visit.
132    fn flag_scope(&self) -> Scope;
133
134    /// A [`ScopedFlags`] for this request, reusing the one [`WhenActive`]
135    /// attached if there is one.
136    ///
137    /// Take it once and keep it when a handler checks several flags. Calling
138    /// [`FlagsExt::flag`] repeatedly on a request that did not come through
139    /// `WhenActive` runs the resolver once per call — the memo lives in the
140    /// view, and a fresh view remembers nothing.
141    fn scoped_flags(&self) -> Result<ScopedFlags>;
142
143    /// Is this flag on for whoever is making this request?
144    fn flag(&self, flag: &str) -> impl Future<Output = Result<bool>> + Send;
145
146    /// The negation, for the reading that comes out shorter.
147    fn flag_off(&self, flag: &str) -> impl Future<Output = Result<bool>> + Send;
148}
149
150impl FlagsExt for Request {
151    fn flags(&self) -> Result<Flags> {
152        self.state::<Flags>().cloned().ok_or_else(missing_registry)
153    }
154
155    fn flag_scope(&self) -> Scope {
156        if let Some(identity) = self.identity() {
157            return Scope::user(identity.id());
158        }
159
160        match self.try_auth().and_then(|guard| guard.user_id()) {
161            Some(id) => Scope::user(id),
162            None => Scope::none(),
163        }
164    }
165
166    fn scoped_flags(&self) -> Result<ScopedFlags> {
167        if let Some(scoped) = self.extension::<ScopedFlags>() {
168            return Ok(scoped.clone());
169        }
170
171        Ok(self.flags()?.for_scope(self.flag_scope()))
172    }
173
174    async fn flag(&self, flag: &str) -> Result<bool> {
175        self.scoped_flags()?.active(flag).await
176    }
177
178    async fn flag_off(&self, flag: &str) -> Result<bool> {
179        Ok(!self.flag(flag).await?)
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use crate::store::MemoryStore;
187    use rustlavel_auth::key::AppKey;
188    use rustlavel_auth::store::MemoryStore as SessionStore;
189    use rustlavel_auth::SessionManager;
190    use rustlavel_core::Context;
191    use rustlavel_http::{Method, Router, TestClient, TestResponse};
192    use std::sync::Arc;
193    use std::sync::atomic::{AtomicUsize, Ordering};
194
195    /// `new-checkout` is on for user ids ending in 7; `beta-search` is off.
196    fn flags() -> Flags {
197        Flags::new()
198            .store(MemoryStore::new())
199            .define("new-checkout", |scope: Scope| async move { scope.id().ends_with('7') })
200            .define("beta-search", |_| async { false })
201    }
202
203    /// The routes, with or without a registry in application state.
204    fn client(flags: Option<Flags>) -> TestClient {
205        let mut router = Router::new();
206        router.middleware(SessionManager::new(&AppKey::from_bytes([9u8; 32]), SessionStore::new()));
207
208        router.get("/login-as/{id}", |request: Request| async move {
209            let id = request.param("id").unwrap_or_default().to_string();
210            request.auth().login_using_id(id);
211            "logged in"
212        });
213
214        router.group("/checkout", |r| {
215            r.middleware(WhenActive::new("new-checkout"));
216            r.get("", |_request: Request| async { "the new checkout" });
217        });
218
219        router.get("/who", |request: Request| async move {
220            let scope = request.flag_scope();
221            let on = request.flag("new-checkout").await?;
222            Ok::<_, Error>(format!("{scope}|new-checkout={on}"))
223        });
224
225        let mut builder = Context::builder();
226        if let Some(flags) = flags {
227            builder = builder.state(flags);
228        }
229
230        TestClient::new(router).with_context(builder.build())
231    }
232
233    fn cookie_of(response: &TestResponse) -> String {
234        response
235            .header("set-cookie")
236            .expect("the session middleware sets a cookie")
237            .split(';')
238            .next()
239            .unwrap()
240            .to_string()
241    }
242
243    async fn logged_in(client: &TestClient, id: &str) -> String {
244        cookie_of(&client.get(&format!("/login-as/{id}")).await.assert_ok())
245    }
246
247    async fn get_as(client: &TestClient, cookie: &str, path: &str) -> TestResponse {
248        client.send(Request::new(Method::Get, path).with_header("cookie", cookie)).await
249    }
250
251    #[tokio::test]
252    async fn a_route_behind_a_flag_that_is_on_is_reachable() {
253        let client = client(Some(flags()));
254        let cookie = logged_in(&client, "17").await;
255
256        get_as(&client, &cookie, "/checkout").await.assert_ok().assert_see("the new checkout");
257    }
258
259    #[tokio::test]
260    async fn a_route_behind_a_flag_that_is_off_answers_404() {
261        let client = client(Some(flags()));
262        let cookie = logged_in(&client, "18").await;
263
264        get_as(&client, &cookie, "/checkout").await.assert_not_found();
265    }
266
267    #[tokio::test]
268    async fn the_404_is_indistinguishable_from_a_route_that_never_existed() {
269        // The point of the middleware. If these two differed by so much as a
270        // header or a byte of body, the guard would be announcing the feature
271        // to anybody who compared them.
272        let client = client(Some(flags()));
273        let cookie = logged_in(&client, "18").await;
274
275        let flagged = get_as(&client, &cookie, "/checkout").await;
276        let imaginary = get_as(&client, &cookie, "/no-such-route").await;
277
278        assert_eq!(flagged.status(), imaginary.status());
279        assert_eq!(flagged.body(), imaginary.body());
280        assert_eq!(flagged.header("content-type"), imaginary.header("content-type"));
281    }
282
283    #[tokio::test]
284    async fn a_guest_falls_back_to_the_global_scope() {
285        let client = client(Some(flags()));
286
287        // Not logged in, so the resolver sees `Scope::none()`, whose id is
288        // empty and does not end in 7.
289        client.get("/checkout").await.assert_not_found();
290        client.get("/who").await.assert_ok().assert_see("global|new-checkout=false");
291    }
292
293    #[tokio::test]
294    async fn an_override_reopens_a_route_the_resolver_had_closed() {
295        // What an operator does when one customer needs the feature today.
296        let flags = flags();
297        let client = client(Some(flags.clone()));
298        let cookie = logged_in(&client, "18").await;
299
300        get_as(&client, &cookie, "/checkout").await.assert_not_found();
301
302        flags.activate_for("new-checkout", &Scope::user("18")).await.unwrap();
303
304        get_as(&client, &cookie, "/checkout").await.assert_ok().assert_see("the new checkout");
305    }
306
307    #[tokio::test]
308    async fn the_incident_switch_closes_a_route_the_resolver_had_opened() {
309        let flags = flags().force_off(["new-checkout"]);
310        let client = client(Some(flags.clone()));
311        let cookie = logged_in(&client, "17").await;
312
313        // On for user 17 by the resolver, and on for them by an override too.
314        flags.activate_for("new-checkout", &Scope::user("17")).await.unwrap();
315
316        get_as(&client, &cookie, "/checkout").await.assert_not_found();
317    }
318
319    #[tokio::test]
320    async fn a_handler_can_ask_for_itself_and_sees_the_logged_in_user() {
321        let client = client(Some(flags()));
322        let cookie = logged_in(&client, "17").await;
323
324        get_as(&client, &cookie, "/who").await.assert_ok().assert_see("user:17|new-checkout=true");
325    }
326
327    #[tokio::test]
328    async fn a_missing_registry_is_a_configuration_error_and_not_a_404() {
329        // 500, not 404: nobody decided this route was hidden, the application
330        // was assembled wrongly. A 404 here would look exactly like a flag that
331        // is off, and the mistake would ship.
332        let client = client(None);
333        let cookie = logged_in(&client, "17").await;
334
335        get_as(&client, &cookie, "/checkout").await.assert_status(500);
336        get_as(&client, &cookie, "/who").await.assert_status(500);
337    }
338
339    #[tokio::test]
340    async fn the_guard_hands_its_answers_to_the_handler() {
341        // The resolver runs once for the whole request, not once for the
342        // middleware and again for each question the handler asks.
343        let calls = Arc::new(AtomicUsize::new(0));
344        let counter = Arc::clone(&calls);
345
346        let flags = Flags::new().define("counted", move |_| {
347            let counter = Arc::clone(&counter);
348            async move {
349                counter.fetch_add(1, Ordering::SeqCst);
350                true
351            }
352        });
353
354        let mut router = Router::new();
355        router.group("/page", |r| {
356            r.middleware(WhenActive::new("counted"));
357            r.get("", |request: Request| async move {
358                for _ in 0..5 {
359                    assert!(request.flag("counted").await?);
360                }
361                Ok::<_, Error>("drawn")
362            });
363        });
364
365        let client =
366            TestClient::new(router).with_context(Context::builder().state(flags).build());
367
368        client.get("/page").await.assert_ok().assert_see("drawn");
369        assert_eq!(calls.load(Ordering::SeqCst), 1);
370    }
371
372    #[tokio::test]
373    async fn a_handler_holding_the_view_itself_asks_once() {
374        let calls = Arc::new(AtomicUsize::new(0));
375        let counter = Arc::clone(&calls);
376
377        let flags = Flags::new().define("counted", move |_| {
378            let counter = Arc::clone(&counter);
379            async move {
380                counter.fetch_add(1, Ordering::SeqCst);
381                true
382            }
383        });
384
385        let mut router = Router::new();
386        router.get("/page", |request: Request| async move {
387            let view = request.scoped_flags()?;
388            let values = view.values(["counted", "counted"]).await?;
389            Ok::<_, Error>(format!("{}", values["counted"]))
390        });
391
392        let client =
393            TestClient::new(router).with_context(Context::builder().state(flags).build());
394
395        client.get("/page").await.assert_ok().assert_see("true");
396        assert_eq!(calls.load(Ordering::SeqCst), 1);
397    }
398
399    #[tokio::test]
400    async fn a_resolver_that_fails_gives_500_rather_than_hiding_the_route() {
401        let flags =
402            Flags::new().define("flaky", |_| async { Result::<bool>::Err(Error::msg("nope")) });
403
404        let mut router = Router::new();
405        router.group("/page", |r| {
406            r.middleware(WhenActive::new("flaky"));
407            r.get("", |_request: Request| async { "drawn" });
408        });
409
410        let client =
411            TestClient::new(router).with_context(Context::builder().state(flags).build());
412
413        client.get("/page").await.assert_status(500);
414    }
415
416    #[tokio::test]
417    async fn flag_off_is_the_negation() {
418        let mut router = Router::new();
419        router.get("/page", |request: Request| async move {
420            let off = request.flag_off("beta-search").await?;
421            Ok::<_, Error>(format!("off={off}"))
422        });
423
424        let client =
425            TestClient::new(router).with_context(Context::builder().state(flags()).build());
426
427        client.get("/page").await.assert_ok().assert_see("off=true");
428    }
429
430    #[test]
431    fn the_guard_names_the_flag_it_checks() {
432        assert_eq!(WhenActive::new("new-checkout").flag(), "new-checkout");
433    }
434
435    #[test]
436    fn a_request_with_no_session_at_all_is_the_global_scope() {
437        assert_eq!(Request::new(Method::Get, "/").flag_scope(), Scope::none());
438    }
439}