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}
182
183impl CrudPermissions {
184    /// The permissions of `entity` (its snake_case name, `payroll_entry`).
185    pub fn new(entity: impl Into<String>) -> Self {
186        Self {
187            entity: entity.into(),
188            module: None,
189            overrides: Vec::new(),
190        }
191    }
192
193    /// Also accept the module-wide spelling (`<module>:<action>`, legacy) for the default
194    /// permissions, so an existing grant on a whole module keeps working for one release.
195    pub fn in_module(mut self, module: impl Into<String>) -> Self {
196        self.module = Some(module.into());
197        self
198    }
199
200    /// Require `permission` for `gate` instead of `<entity>:<action>`.
201    pub fn requiring(mut self, gate: Gate, permission: impl Into<String>) -> Self {
202        self.overrides.retain(|(g, _)| *g != gate);
203        self.overrides.push((gate, permission.into()));
204        self
205    }
206
207    /// The canonical permission `gate` requires.
208    pub fn permission(&self, gate: Gate) -> String {
209        let gate = if gate == Gate::CreateUpdate {
210            Gate::Create
211        } else {
212            gate
213        };
214        self.overrides
215            .iter()
216            .find(|(g, _)| *g == gate)
217            .map(|(_, p)| p.clone())
218            .unwrap_or_else(|| format!("{}:{}", self.entity, gate.action()))
219    }
220
221    fn one(&self, gate: Gate) -> Needs {
222        let overridden = self.overrides.iter().any(|(g, _)| *g == gate);
223        let alternates = match (&self.module, overridden) {
224            (Some(module), false) => vec![format!("{module}:{}", gate.action())],
225            _ => Vec::new(),
226        };
227        Needs {
228            permission: self.permission(gate),
229            alternates,
230        }
231    }
232
233    /// What a route of `gate` needs.
234    pub fn needs(&self, gate: Gate) -> Vec<Needs> {
235        match gate {
236            Gate::CreateUpdate => vec![self.one(Gate::Create), self.one(Gate::Update)],
237            g => vec![self.one(g)],
238        }
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245    use axum::{body::Body, http::Request as HttpRequest, routing::get};
246    use tower::ServiceExt;
247
248    fn app(route: MethodRouter<()>) -> axum::Router {
249        axum::Router::new().route("/", route)
250    }
251
252    async fn call(
253        app: axum::Router,
254        ctx: Option<AuthContext>,
255        waiver: bool,
256    ) -> (StatusCode, serde_json::Value) {
257        let mut req = HttpRequest::builder().uri("/").body(Body::empty()).unwrap();
258        if let Some(c) = ctx {
259            req.extensions_mut().insert(c);
260        }
261        if waiver {
262            req.extensions_mut().insert(PermissionWaiver);
263        }
264        let res = app.oneshot(req).await.unwrap();
265        let status = res.status();
266        let bytes = axum::body::to_bytes(res.into_body(), 1 << 20)
267            .await
268            .unwrap();
269        (
270            status,
271            serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null),
272        )
273    }
274
275    fn holding(perms: &[&str]) -> AuthContext {
276        AuthContext {
277            user_id: "u".into(),
278            roles: vec![],
279            permissions: perms.iter().map(|s| s.to_string()).collect(),
280        }
281    }
282
283    #[tokio::test]
284    async fn a_holder_passes_and_a_caller_without_it_is_403_with_the_permission_named() {
285        let route = || guarded(get(|| async { "ok" }), "ticket:read");
286        let (status, _) = call(app(route()), Some(holding(&["ticket:read"])), false).await;
287        assert_eq!(status, StatusCode::OK);
288
289        let (status, body) = call(app(route()), Some(holding(&[])), false).await;
290        assert_eq!(
291            status,
292            StatusCode::FORBIDDEN,
293            "no permission, no resolver: fail closed"
294        );
295        assert_eq!(body["violations"][0]["code"], "permission_required");
296        assert_eq!(body["violations"][0]["params"]["permission"], "ticket:read");
297        assert_eq!(body["success"], false);
298
299        let (status, body) = call(app(route()), Some(holding(&["ticket:update"])), false).await;
300        assert_eq!(status, StatusCode::FORBIDDEN);
301        assert_eq!(body["violations"][0]["params"]["permission"], "ticket:read");
302    }
303
304    #[tokio::test]
305    async fn a_request_with_no_auth_context_is_401() {
306        let (status, body) = call(
307            app(guarded(get(|| async { "ok" }), "ticket:read")),
308            None,
309            false,
310        )
311        .await;
312        assert_eq!(status, StatusCode::UNAUTHORIZED);
313        assert_eq!(body["violations"][0]["code"], "authentication_required");
314    }
315
316    #[tokio::test]
317    async fn wildcards_grant_and_the_waiver_admits_a_request_a_narrower_rule_already_admitted() {
318        let route = || guarded(get(|| async { "ok" }), "ticket:read");
319        for held in ["ticket:*", "*:read", "*:*"] {
320            let (status, _) = call(app(route()), Some(holding(&[held])), false).await;
321            assert_eq!(status, StatusCode::OK, "{held}");
322        }
323        let (status, _) = call(app(route()), Some(holding(&[])), true).await;
324        assert_eq!(status, StatusCode::OK, "waived");
325        let (status, _) = call(app(route()), None, true).await;
326        assert_eq!(
327            status,
328            StatusCode::OK,
329            "a waiver stands for the whole check"
330        );
331    }
332
333    #[tokio::test]
334    async fn the_check_runs_before_the_handler() {
335        async fn must_not_run() -> &'static str {
336            panic!("the handler must not run for a refused caller")
337        }
338        let route = guarded(get(must_not_run), "ticket:read");
339        let (status, _) = call(app(route), Some(holding(&[])), false).await;
340        assert_eq!(status, StatusCode::FORBIDDEN);
341    }
342
343    #[tokio::test]
344    async fn guarded_all_needs_every_permission() {
345        let route = || guarded_all(get(|| async { "ok" }), &["ticket:create", "ticket:update"]);
346        let (status, body) = call(app(route()), Some(holding(&["ticket:create"])), false).await;
347        assert_eq!(status, StatusCode::FORBIDDEN);
348        assert_eq!(
349            body["violations"][0]["params"]["permission"],
350            "ticket:update"
351        );
352        let (status, _) = call(
353            app(route()),
354            Some(holding(&["ticket:create", "ticket:update"])),
355            false,
356        )
357        .await;
358        assert_eq!(status, StatusCode::OK);
359    }
360
361    #[test]
362    fn crud_permissions_default_to_entity_colon_action_and_honour_a_rename() {
363        let p = CrudPermissions::new("payroll_entry").requiring(Gate::Read, "payroll:view");
364        assert_eq!(p.permission(Gate::Read), "payroll:view");
365        assert_eq!(p.permission(Gate::Purge), "payroll_entry:purge");
366        assert_eq!(p.permission(Gate::CreateUpdate), "payroll_entry:create");
367        assert_eq!(
368            p.needs(Gate::CreateUpdate)
369                .iter()
370                .map(|n| n.permission.as_str())
371                .collect::<Vec<_>>(),
372            vec!["payroll_entry:create", "payroll_entry:update"]
373        );
374        let p = CrudPermissions::new("salary_slip").in_module("payroll");
375        assert_eq!(
376            p.needs(Gate::Read)[0].alternates,
377            vec!["payroll:read".to_string()]
378        );
379        let p = p.requiring(Gate::Read, "payroll:view");
380        assert!(
381            p.needs(Gate::Read)[0].alternates.is_empty(),
382            "a renamed permission has no legacy alternates"
383        );
384    }
385}