Skip to main content

rustlavel_rbac/
plugin.rs

1//! Attaching the package to an application, the way every optional package
2//! attaches itself:
3//!
4//! ```ignore
5//! App::new()
6//!     .plugin(Rbac::from_config(db.clone(), &config)?)
7//! ```
8//!
9//! One explicit line in `main.rs`, no auto-discovery. It puts a [`Permissions`]
10//! into the application context, which is where [`Can`](crate::Can) and
11//! `req.can(...)` look for it — and the only reason those two work without
12//! being handed a store.
13//!
14//! The migrations are *not* registered from here, because migrations are not
15//! runtime state: they belong in the registry the CLI generates, next to the
16//! application's own. Add them with `registry.extend(rustlavel_rbac::migrations())`.
17
18use crate::store::Permissions;
19use rustlavel_core::{Config, Result};
20use rustlavel_db::Database;
21use rustlavel_http::plugin::{Plugin, Setup};
22use std::time::Duration;
23
24/// Registers the [`Permissions`] store into application state.
25///
26/// Adds no routes. There is no first-party admin UI, deliberately: a screen
27/// that hands out permissions is an application's own decision — about who may
28/// open it above all — and shipping one that is enabled by adding a plugin
29/// would be shipping a privilege-escalation route with an on switch.
30pub struct Rbac {
31    store: Permissions,
32}
33
34impl Rbac {
35    /// The defaults: the conventional tables, `super-admin`, a 30-second cache.
36    pub fn new(db: Database) -> Self {
37        Rbac { store: Permissions::new(db) }
38    }
39
40    /// Read `rbac.super_role` (or `rbac.super_roles`) and `rbac.cache_ttl_ms`.
41    ///
42    /// In `.env` that is `RBAC_SUPER_ROLE=owner` and `RBAC_CACHE_TTL_MS=5000`,
43    /// through whatever mapping the application's configuration uses.
44    pub fn from_config(db: Database, config: &Config) -> Result<Self> {
45        Ok(Rbac { store: Permissions::from_config(db, config)? })
46    }
47
48    /// Use a store you built yourself — with different tables, say.
49    pub fn with_store(store: Permissions) -> Self {
50        Rbac { store }
51    }
52
53    pub fn super_role(mut self, name: impl Into<String>) -> Self {
54        self.store = self.store.super_role(name);
55        self
56    }
57
58    pub fn cache_ttl(mut self, ttl: Duration) -> Self {
59        self.store = self.store.cache_ttl(ttl);
60        self
61    }
62
63    /// The store, for wiring it into anything else that needs one.
64    pub fn store(&self) -> &Permissions {
65        &self.store
66    }
67}
68
69impl Plugin for Rbac {
70    fn name(&self) -> &'static str {
71        "rbac"
72    }
73
74    fn register(self: Box<Self>, setup: &mut Setup<'_>) {
75        // Cloning shares the cache: every request gets a handle to the same
76        // map, which is the whole point of caching at all.
77        setup.state(self.store.clone());
78    }
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84    use crate::grants::Grants;
85    use crate::guard::RbacExt;
86    use crate::store::DEFAULT_SUPER_ROLE;
87    use rustlavel_core::Context;
88    use rustlavel_db::DatabaseConfig;
89    use rustlavel_http::router::Router;
90    use rustlavel_http::{Request, TestClient};
91
92    fn offline() -> Database {
93        Database::lazy(
94            DatabaseConfig::from_url("postgres://nobody:nothing@127.0.0.1:1/none").unwrap(),
95        )
96        .unwrap()
97    }
98
99    /// Register the plugin the way an application would.
100    fn registered(plugin: Rbac) -> TestClient {
101        let mut router = Router::new();
102        let config = Config::with_defaults();
103        let mut context = Some(Context::builder());
104
105        router.get("/who", |request: Request| async move {
106            let store = request.permissions()?;
107            let list = store.permissions_for(41).await?;
108            Ok::<_, rustlavel_core::Error>(list.join(","))
109        });
110
111        let mut setup = Setup { router: &mut router, config: &config, context: &mut context };
112        Box::new(plugin).register(&mut setup);
113
114        TestClient::new(router).with_context(context.expect("the builder survives setup").build())
115    }
116
117    #[tokio::test]
118    async fn the_plugin_puts_a_store_where_handlers_can_find_it() {
119        let plugin = Rbac::new(offline());
120        // Prime through the plugin's own store: the clone the plugin registers
121        // shares the cache, so this is visible from inside the handler.
122        plugin.store().prime(
123            41,
124            Grants {
125                granted: ["posts.publish".to_string()].into_iter().collect(),
126                ..Grants::default()
127            },
128        );
129
130        registered(plugin).get("/who").await.assert_ok().assert_see("posts.publish");
131    }
132
133    #[tokio::test]
134    async fn without_the_plugin_a_handler_gets_the_configuration_error() {
135        let mut router = Router::new();
136        router.get("/who", |request: Request| async move {
137            let store = request.permissions()?;
138            Ok::<_, rustlavel_core::Error>(store.permissions_for(41).await?.join(","))
139        });
140
141        TestClient::new(router).get("/who").await.assert_status(500);
142    }
143
144    #[test]
145    fn the_plugin_is_named_for_route_list() {
146        assert_eq!(Rbac::new(offline()).name(), "rbac");
147    }
148
149    #[test]
150    fn configuration_reaches_the_store_through_the_plugin() {
151        let config = Config::new();
152        config.set("rbac.super_role", "owner");
153        config.set("rbac.cache_ttl_ms", 1_500);
154
155        let plugin = Rbac::from_config(offline(), &config).unwrap();
156
157        assert!(plugin.store().super_role_names().contains("owner"));
158        assert!(!plugin.store().super_role_names().contains(DEFAULT_SUPER_ROLE));
159    }
160
161    #[test]
162    fn the_builders_override_the_defaults() {
163        let plugin = Rbac::new(offline()).super_role("root").cache_ttl(Duration::from_secs(1));
164
165        assert_eq!(plugin.store().super_role_names().iter().collect::<Vec<_>>(), ["root"]);
166    }
167}