Skip to main content

backbone_core/
permission.rs

1//! Permission checks on routes: the one layer generic routes, generated transitions and
2//! hand-written verbs share.
3//!
4//! A route requires a permission, written `<resource>:<action>`. [`guarded`] wraps a route so a
5//! request without it is refused before the route's extractors or handler run:
6//!
7//! - no `AuthContext` on the request: `401`, violation `authentication_required`;
8//! - an `AuthContext` that lacks the permission: `403`, violation `permission_required` with
9//!   `params.permission` naming it ([`permission_refusal`]).
10//!
11//! The check is [`AuthContext::require`](backbone_auth::middleware::AuthContext::require), which
12//! reads `AuthContext.permissions`. With no grant resolver installed that is whatever the host's own
13//! authentication put there; installing `backbone_auth::resolve_grants` makes it the identity
14//! module's data. Nothing is allowed by default in either case.
15//!
16//! A gate that has already admitted a request on a narrower rule (a self-service lane) attaches
17//! [`PermissionWaiver`](backbone_auth::PermissionWaiver) to the request, and the layer lets it pass.
18//!
19//! ```rust,ignore
20//! use axum::routing::post;
21//! use backbone_core::http::guarded;
22//!
23//! // in `ticket_handler.ext.rs`: a custom verb, wrapped so `schema doctor` finds it guarded
24//! let route = guarded(post(escalate), "ticket:escalate");
25//! ```
26
27use std::sync::Arc;
28
29use axum::{
30    extract::{Request, State},
31    http::StatusCode,
32    middleware::{from_fn_with_state, Next},
33    response::{IntoResponse, Response},
34    routing::MethodRouter,
35    Json,
36};
37use backbone_auth::middleware::AuthContext;
38use backbone_auth::{PermissionRequired, PermissionWaiver, PERMISSION_REQUIRED};
39
40use crate::http::ApiResponse;
41use crate::violation::Violation;
42
43/// What one route needs: a permission, and the legacy spellings it also accepts for one release.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct Needs {
46    /// The canonical `<resource>:<action>`.
47    pub permission: String,
48    /// Legacy spellings that also grant it (the module-wide `<module>:<action>`).
49    pub alternates: Vec<String>,
50}
51
52/// The `403` a missing permission answers with: the violation `permission_required`, and
53/// `params.permission` naming what the route requires.
54pub fn permission_refusal<T>(refused: &PermissionRequired) -> (StatusCode, Json<ApiResponse<T>>) {
55    let message = refused.to_string();
56    let violation = Violation::new("", PERMISSION_REQUIRED, message.clone())
57        .with_param("permission", refused.permission.clone());
58    (
59        StatusCode::FORBIDDEN,
60        Json(ApiResponse::rejected(message, vec![violation])),
61    )
62}
63
64/// The `401` a request with no authenticated caller answers with.
65pub fn unauthenticated_refusal<T>() -> (StatusCode, Json<ApiResponse<T>>) {
66    let message = "Authentication required";
67    let violation = Violation::new("", "authentication_required", message);
68    (
69        StatusCode::UNAUTHORIZED,
70        Json(ApiResponse::rejected(message, vec![violation])),
71    )
72}
73
74/// Check `needs` against the request, answering the refusal or `None` to proceed.
75fn refusal(req: &Request, needs: &[Needs]) -> Option<Response> {
76    if req.extensions().get::<PermissionWaiver>().is_some() {
77        return None;
78    }
79    let Some(ctx) = req.extensions().get::<AuthContext>() else {
80        return Some(unauthenticated_refusal::<()>().into_response());
81    };
82    for need in needs {
83        let alternates: Vec<&str> = need.alternates.iter().map(String::as_str).collect();
84        if let Err(refused) = ctx.require_with(&need.permission, &alternates) {
85            tracing::debug!(target: "backbone::auth", user = %ctx.user_id, permission = %refused.permission, "permission refused");
86            return Some(permission_refusal::<()>(&refused).into_response());
87        }
88    }
89    None
90}
91
92async fn enforce(State(needs): State<Arc<Vec<Needs>>>, req: Request, next: Next) -> Response {
93    match refusal(&req, &needs) {
94        Some(refused) => refused,
95        None => next.run(req).await,
96    }
97}
98
99/// Wrap `route` so a caller must hold `permission` (a canonical `<resource>:<action>`).
100pub fn guarded<S>(route: MethodRouter<S>, permission: impl Into<String>) -> MethodRouter<S>
101where
102    S: Clone + Send + Sync + 'static,
103{
104    guarded_needs(
105        route,
106        vec![Needs {
107            permission: permission.into(),
108            alternates: Vec::new(),
109        }],
110    )
111}
112
113/// Wrap `route` so a caller must hold every one of `permissions` (a route that does two things, as
114/// an upsert creates and updates).
115pub fn guarded_all<S>(route: MethodRouter<S>, permissions: &[&str]) -> MethodRouter<S>
116where
117    S: Clone + Send + Sync + 'static,
118{
119    guarded_needs(
120        route,
121        permissions
122            .iter()
123            .map(|p| Needs {
124                permission: (*p).to_string(),
125                alternates: Vec::new(),
126            })
127            .collect(),
128    )
129}
130
131/// [`guarded`] with each permission's accepted legacy spellings, which is what a generated route
132/// builds from [`CrudPermissions`].
133pub fn guarded_needs<S>(route: MethodRouter<S>, needs: Vec<Needs>) -> MethodRouter<S>
134where
135    S: Clone + Send + Sync + 'static,
136{
137    route.route_layer(from_fn_with_state(Arc::new(needs), enforce))
138}
139
140/// Which permission of an entity a generic route needs.
141#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
142pub enum Gate {
143    /// List, get, count, aggregate, history, trash views.
144    Read,
145    /// Create, bulk create.
146    Create,
147    /// Put, patch, bulk update, bulk preview.
148    Update,
149    /// Soft delete, bulk soft delete.
150    Delete,
151    /// Restore, restore bulk, restore all.
152    Restore,
153    /// Hard delete from the trash, bulk hard delete, empty the trash.
154    Purge,
155    /// Upsert: creates or updates, so it needs both.
156    CreateUpdate,
157}
158
159impl Gate {
160    /// The action half of the permission this gate names by default.
161    pub fn action(&self) -> &'static str {
162        match self {
163            Gate::Read => "read",
164            Gate::Create => "create",
165            Gate::Update => "update",
166            Gate::Delete => "delete",
167            Gate::Restore => "restore",
168            Gate::Purge => "purge",
169            Gate::CreateUpdate => "create",
170        }
171    }
172}
173
174/// The permissions of one entity's generic routes: `<entity>:read` for reads, and so on, with any a
175/// schema's `requires:` renames.
176#[derive(Debug, Clone, PartialEq, Eq)]
177pub struct CrudPermissions {
178    entity: String,
179    module: Option<String>,
180    overrides: Vec<(Gate, String)>,
181    public: Vec<Gate>,
182}
183
184impl CrudPermissions {
185    /// The permissions of `entity` (its snake_case name, `payroll_entry`).
186    pub fn new(entity: impl Into<String>) -> Self {
187        Self {
188            entity: entity.into(),
189            module: None,
190            overrides: Vec::new(),
191            public: Vec::new(),
192        }
193    }
194
195    /// Also accept the module-wide spelling (`<module>:<action>`, legacy) for the default
196    /// permissions, so an existing grant on a whole module keeps working for one release.
197    pub fn in_module(mut self, module: impl Into<String>) -> Self {
198        self.module = Some(module.into());
199        self
200    }
201
202    /// Require `permission` for `gate` instead of `<entity>:<action>`.
203    pub fn requiring(mut self, gate: Gate, permission: impl Into<String>) -> Self {
204        self.overrides.retain(|(g, _)| *g != gate);
205        self.overrides.push((gate, permission.into()));
206        self
207    }
208
209    /// Declare the routes of `gate` public: they require no permission at all, and no
210    /// `AuthContext`, so an unauthenticated request is served where the service mounted the routes
211    /// outside its authentication layer (see the generated `public_routes()`). The schema's
212    /// `public:` declares it per entity; the tenant or company fence still applies, since it lives
213    /// in the database connection and not in this layer.
214    pub fn public(mut self, gate: Gate) -> Self {
215        let gate = if gate == Gate::CreateUpdate {
216            Gate::Create
217        } else {
218            gate
219        };
220        if !self.public.contains(&gate) {
221            self.public.push(gate);
222        }
223        self
224    }
225
226    /// Whether the routes of `gate` are public. An upsert is public only when both the create and
227    /// the update it performs are.
228    pub fn is_public(&self, gate: Gate) -> bool {
229        match gate {
230            Gate::CreateUpdate => {
231                self.public.contains(&Gate::Create) && self.public.contains(&Gate::Update)
232            }
233            g => self.public.contains(&g),
234        }
235    }
236
237    /// The canonical permission `gate` requires.
238    pub fn permission(&self, gate: Gate) -> String {
239        let gate = if gate == Gate::CreateUpdate {
240            Gate::Create
241        } else {
242            gate
243        };
244        self.overrides
245            .iter()
246            .find(|(g, _)| *g == gate)
247            .map(|(_, p)| p.clone())
248            .unwrap_or_else(|| format!("{}:{}", self.entity, gate.action()))
249    }
250
251    fn one(&self, gate: Gate) -> Needs {
252        let overridden = self.overrides.iter().any(|(g, _)| *g == gate);
253        let alternates = match (&self.module, overridden) {
254            (Some(module), false) => vec![format!("{module}:{}", gate.action())],
255            _ => Vec::new(),
256        };
257        Needs {
258            permission: self.permission(gate),
259            alternates,
260        }
261    }
262
263    /// What a route of `gate` needs: nothing for a public gate, and for an upsert only the half
264    /// that is not public.
265    pub fn needs(&self, gate: Gate) -> Vec<Needs> {
266        match gate {
267            Gate::CreateUpdate => [Gate::Create, Gate::Update]
268                .into_iter()
269                .filter(|g| !self.public.contains(g))
270                .map(|g| self.one(g))
271                .collect(),
272            g if self.public.contains(&g) => Vec::new(),
273            g => vec![self.one(g)],
274        }
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281    use axum::{body::Body, http::Request as HttpRequest, routing::get};
282    use tower::ServiceExt;
283
284    fn app(route: MethodRouter<()>) -> axum::Router {
285        axum::Router::new().route("/", route)
286    }
287
288    async fn call(
289        app: axum::Router,
290        ctx: Option<AuthContext>,
291        waiver: bool,
292    ) -> (StatusCode, serde_json::Value) {
293        let mut req = HttpRequest::builder().uri("/").body(Body::empty()).unwrap();
294        if let Some(c) = ctx {
295            req.extensions_mut().insert(c);
296        }
297        if waiver {
298            req.extensions_mut().insert(PermissionWaiver);
299        }
300        let res = app.oneshot(req).await.unwrap();
301        let status = res.status();
302        let bytes = axum::body::to_bytes(res.into_body(), 1 << 20)
303            .await
304            .unwrap();
305        (
306            status,
307            serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null),
308        )
309    }
310
311    fn holding(perms: &[&str]) -> AuthContext {
312        AuthContext {
313            user_id: "u".into(),
314            roles: vec![],
315            permissions: perms.iter().map(|s| s.to_string()).collect(),
316        }
317    }
318
319    #[tokio::test]
320    async fn a_holder_passes_and_a_caller_without_it_is_403_with_the_permission_named() {
321        let route = || guarded(get(|| async { "ok" }), "ticket:read");
322        let (status, _) = call(app(route()), Some(holding(&["ticket:read"])), false).await;
323        assert_eq!(status, StatusCode::OK);
324
325        let (status, body) = call(app(route()), Some(holding(&[])), false).await;
326        assert_eq!(
327            status,
328            StatusCode::FORBIDDEN,
329            "no permission, no resolver: fail closed"
330        );
331        assert_eq!(body["violations"][0]["code"], "permission_required");
332        assert_eq!(body["violations"][0]["params"]["permission"], "ticket:read");
333        assert_eq!(body["success"], false);
334
335        let (status, body) = call(app(route()), Some(holding(&["ticket:update"])), false).await;
336        assert_eq!(status, StatusCode::FORBIDDEN);
337        assert_eq!(body["violations"][0]["params"]["permission"], "ticket:read");
338    }
339
340    #[tokio::test]
341    async fn a_request_with_no_auth_context_is_401() {
342        let (status, body) = call(
343            app(guarded(get(|| async { "ok" }), "ticket:read")),
344            None,
345            false,
346        )
347        .await;
348        assert_eq!(status, StatusCode::UNAUTHORIZED);
349        assert_eq!(body["violations"][0]["code"], "authentication_required");
350    }
351
352    #[tokio::test]
353    async fn wildcards_grant_and_the_waiver_admits_a_request_a_narrower_rule_already_admitted() {
354        let route = || guarded(get(|| async { "ok" }), "ticket:read");
355        for held in ["ticket:*", "*:read", "*:*"] {
356            let (status, _) = call(app(route()), Some(holding(&[held])), false).await;
357            assert_eq!(status, StatusCode::OK, "{held}");
358        }
359        let (status, _) = call(app(route()), Some(holding(&[])), true).await;
360        assert_eq!(status, StatusCode::OK, "waived");
361        let (status, _) = call(app(route()), None, true).await;
362        assert_eq!(
363            status,
364            StatusCode::OK,
365            "a waiver stands for the whole check"
366        );
367    }
368
369    #[tokio::test]
370    async fn the_check_runs_before_the_handler() {
371        async fn must_not_run() -> &'static str {
372            panic!("the handler must not run for a refused caller")
373        }
374        let route = guarded(get(must_not_run), "ticket:read");
375        let (status, _) = call(app(route), Some(holding(&[])), false).await;
376        assert_eq!(status, StatusCode::FORBIDDEN);
377    }
378
379    #[tokio::test]
380    async fn guarded_all_needs_every_permission() {
381        let route = || guarded_all(get(|| async { "ok" }), &["ticket:create", "ticket:update"]);
382        let (status, body) = call(app(route()), Some(holding(&["ticket:create"])), false).await;
383        assert_eq!(status, StatusCode::FORBIDDEN);
384        assert_eq!(
385            body["violations"][0]["params"]["permission"],
386            "ticket:update"
387        );
388        let (status, _) = call(
389            app(route()),
390            Some(holding(&["ticket:create", "ticket:update"])),
391            false,
392        )
393        .await;
394        assert_eq!(status, StatusCode::OK);
395    }
396
397    #[test]
398    fn crud_permissions_default_to_entity_colon_action_and_honour_a_rename() {
399        let p = CrudPermissions::new("payroll_entry").requiring(Gate::Read, "payroll:view");
400        assert_eq!(p.permission(Gate::Read), "payroll:view");
401        assert_eq!(p.permission(Gate::Purge), "payroll_entry:purge");
402        assert_eq!(p.permission(Gate::CreateUpdate), "payroll_entry:create");
403        assert_eq!(
404            p.needs(Gate::CreateUpdate)
405                .iter()
406                .map(|n| n.permission.as_str())
407                .collect::<Vec<_>>(),
408            vec!["payroll_entry:create", "payroll_entry:update"]
409        );
410        let p = CrudPermissions::new("salary_slip").in_module("payroll");
411        assert_eq!(
412            p.needs(Gate::Read)[0].alternates,
413            vec!["payroll:read".to_string()]
414        );
415        let p = p.requiring(Gate::Read, "payroll:view");
416        assert!(
417            p.needs(Gate::Read)[0].alternates.is_empty(),
418            "a renamed permission has no legacy alternates"
419        );
420    }
421
422    #[test]
423    fn a_public_gate_needs_nothing_and_an_upsert_needs_only_its_non_public_half() {
424        let p = CrudPermissions::new("label").public(Gate::Read);
425        assert!(p.is_public(Gate::Read) && !p.is_public(Gate::Create));
426        assert!(p.needs(Gate::Read).is_empty());
427        assert_eq!(p.needs(Gate::Create)[0].permission, "label:create");
428
429        let p = CrudPermissions::new("label").public(Gate::Create);
430        assert!(!p.is_public(Gate::CreateUpdate), "an upsert also updates");
431        assert_eq!(
432            p.needs(Gate::CreateUpdate)
433                .iter()
434                .map(|n| n.permission.as_str())
435                .collect::<Vec<_>>(),
436            vec!["label:update"]
437        );
438        let p = p.public(Gate::Update);
439        assert!(p.is_public(Gate::CreateUpdate));
440        assert!(p.needs(Gate::CreateUpdate).is_empty());
441    }
442}