Skip to main content

tablo_core/
policy.rs

1//! Authorizes resource abilities, denying by default.
2//!
3//! Combines [`Allow`], [`Deny`], [`ReadOnly`], [`when`], [`Abilities`] and closures with
4//! `and`/`or`.
5//!
6//! ```rust
7//! # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
8//! # struct Post { #[key] #[auto] id: uuid::Uuid, locked: bool }
9//! # use tablo_core::{Ability, Policy, when};
10//! # use topcoat::context::Cx;
11//! # fn not_suspended(cx: &Cx) -> bool { true }
12//! fn post_policy() -> impl Policy<Post> {
13//!     // No write to a locked post: an update, a delete, a restore or a record action.
14//!     when(not_suspended).and(|_cx: &Cx, ability: Ability<'_, Post>| {
15//!         ability.written_record().is_none_or(|post| !post.locked)
16//!     })
17//! }
18//! ```
19//!
20//! A closure ending in `_ => true` allows every ability it does not name, the ones a resource
21//! gains later included. Match every variant so that a new one fails to compile, read
22//! [`Ability::written_record`] to refuse writes by the record, or build with [`Abilities`], which
23//! denies what it does not declare.
24//!
25//! [`can`](crate::can) asks a resource's policy from app code, and [`can_list`](crate::can_list)
26//! answers whether the current request may open a resource's list at all.
27
28use derive_where::derive_where;
29use topcoat::context::Cx;
30
31mod abilities;
32
33pub use abilities::Abilities;
34
35/// One thing a policy is asked to allow; record abilities are asked once per
36/// loaded row, and a record that cannot be viewed cannot be written by guessing
37/// its key.
38#[derive(Debug)]
39#[derive_where(Clone, Copy)]
40pub enum Ability<'a, M> {
41    /// Read the resource's rows: asked by the list and its live refresh, the sidebar entry, the
42    /// export, related tables, relationship options and relation columns, and the global search.
43    /// All but the global search show every row of the resource's query, asking no record
44    /// ability of it: a
45    /// policy cannot run in the database, so the rows a user must not see belong out of
46    /// [`ResourceDef::query`](crate::ResourceDef::query).
47    ViewAny,
48    /// Open one record: asked by the detail page, the edit page and POST, deletes, record pages
49    /// and each global search result, which leaves out a record it refuses. Every write asks it
50    /// of the records it loads, and a list row it refuses shows without its actions or checkbox.
51    View(&'a M),
52    /// Open the create form and submit it: asked by the create page and POST, and the Create
53    /// button.
54    Create,
55    /// Edit one record: asked by the edit page and POST, and the row's Edit action.
56    Update(&'a M),
57    /// Delete at all: asked by the delete and bulk-delete POSTs before any record loads, the
58    /// Delete action and the bulk column.
59    DeleteAny,
60    /// Delete one record: asked of each record a delete removes, and by the row's Delete action
61    /// and checkbox.
62    Delete(&'a M),
63    /// Restore trashed records at all, on a resource that
64    /// [soft-deletes](crate::ResourceDef::soft_delete): asked by the restore POSTs and the
65    /// Restore buttons.
66    RestoreAny,
67    /// Restore one trashed record: asked of each record a restore brings back, and by its Restore
68    /// button and checkbox.
69    Restore(&'a M),
70    /// Delete trashed records for good at all, on a resource that
71    /// [soft-deletes](crate::ResourceDef::soft_delete). Allowed only with
72    /// [`DeleteAny`](Self::DeleteAny). Asked by the force-delete POSTs and the Delete
73    /// permanently buttons.
74    ForceDeleteAny,
75    /// Delete one trashed record for good. Allowed only with [`Delete`](Self::Delete) of it, so a
76    /// policy refusing a record's delete refuses its force delete too. Asked of each trashed
77    /// record a force delete removes, and by its button and checkbox.
78    ForceDelete(&'a M),
79    /// Run the custom [`Action`](crate::Action) whose [`NAME`](crate::Action::NAME) is `action`
80    /// at all, before [`Run`](Self::Run) is asked of each record: asked by the action's POSTs,
81    /// before any record loads, its buttons, and the bulk column for a bulk action.
82    RunAny {
83        /// The action's [`NAME`](crate::Action::NAME).
84        action: &'static str,
85    },
86    /// Run the custom [`Action`](crate::Action) whose [`NAME`](crate::Action::NAME) is `action`
87    /// on one record: asked of each record the action runs on, and by its button on the record's
88    /// row and pages and, for a bulk action, the record's checkbox.
89    Run {
90        /// The action's [`NAME`](crate::Action::NAME).
91        action: &'static str,
92        /// The record it runs on.
93        record: &'a M,
94    },
95    /// Run the [`HeaderAction`](crate::HeaderAction) whose `NAME` is `action` from the list's
96    /// header. It acts on no record, so nothing else of the policy is asked but `ViewAny`.
97    ///
98    /// It is not [`RunAny`](Self::RunAny): a policy that grants every record action with
99    /// `RunAny { .. } => true` grants no header action by it.
100    RunHeader {
101        /// The action's [`NAME`](crate::HeaderAction::NAME).
102        action: &'static str,
103    },
104}
105
106impl<'a, M> Ability<'a, M> {
107    /// The record the ability names, if any.
108    pub fn record(self) -> Option<&'a M> {
109        match self {
110            Self::View(record)
111            | Self::Update(record)
112            | Self::Delete(record)
113            | Self::Restore(record)
114            | Self::ForceDelete(record)
115            | Self::Run { record, .. } => Some(record),
116            Self::ViewAny
117            | Self::Create
118            | Self::DeleteAny
119            | Self::RestoreAny
120            | Self::ForceDeleteAny
121            | Self::RunAny { .. }
122            | Self::RunHeader { .. } => None,
123        }
124    }
125
126    /// Whether the ability only reads.
127    pub fn is_read(self) -> bool {
128        matches!(self, Self::ViewAny | Self::View(_))
129    }
130
131    /// The record the ability writes: an update, a delete, a restore, a force delete or a
132    /// record action's run on it. `None` for a read and for an ability over no existing record:
133    /// `Create`, the `_any` abilities and `RunHeader`.
134    ///
135    /// A policy refusing every write to some records reads it, and so also refuses the record
136    /// actions and soft-delete writes a resource gains later. A header action writes no record
137    /// it is handed, so such a policy allows it unless it matches `RunHeader` too:
138    ///
139    /// ```rust
140    /// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
141    /// # struct User { #[key] #[auto] id: uuid::Uuid, name: String }
142    /// # use tablo_core::Ability;
143    /// # use topcoat::context::Cx;
144    /// fn keep_root(_cx: &Cx, ability: Ability<'_, User>) -> bool {
145    ///     ability
146    ///         .written_record()
147    ///         .is_none_or(|user| user.name != "root")
148    /// }
149    /// ```
150    pub fn written_record(self) -> Option<&'a M> {
151        match self {
152            Self::Update(record)
153            | Self::Delete(record)
154            | Self::Restore(record)
155            | Self::ForceDelete(record)
156            | Self::Run { record, .. } => Some(record),
157            Self::ViewAny
158            | Self::View(_)
159            | Self::Create
160            | Self::DeleteAny
161            | Self::RestoreAny
162            | Self::ForceDeleteAny
163            | Self::RunAny { .. }
164            | Self::RunHeader { .. } => None,
165        }
166    }
167}
168
169/// Decides which [`Ability`]s the current user has over a resource's records.
170///
171/// Every handler asks the same abilities that decide which buttons render, so a hidden button is
172/// also a refused request, answered 403:
173///
174/// - a record is viewed before it is written: the edit, delete and action handlers ask
175///   [`View`](Ability::View) together with `Update`, `Delete` or `Run`;
176/// - a write is checked against the stored row: the update, delete and action handlers load the
177///   record inside the write's transaction and ask about that row, not the submitted key. A bulk
178///   delete or bulk action fails as a whole when any selected record is refused `View`, and skips
179///   the records refused `Delete` or `Run`;
180/// - an action's [`can_run`](crate::Action::can_run) reads the record's state and sees no policy,
181///   so a panel that mounts the resource with [`ReadOnly`] offers and runs none of its actions;
182/// - a relationship choice asks the related resource's policy for `ViewAny` and `View`.
183///
184/// A closure `|cx: &Cx, ability: Ability<'_, M>| -> bool` is a policy, and the
185/// building blocks combine with `and` and `or`.
186/// [`ResourceDef::policy_fn`](crate::ResourceDef::policy_fn) takes a closure without naming its
187/// argument types.
188pub trait Policy<M>: Send + Sync + 'static {
189    /// Whether the current user may do `ability`.
190    fn allows(&self, cx: &Cx, ability: Ability<'_, M>) -> bool;
191}
192
193macro_rules! combinators {
194    ($($ty:ident $(<$($param:ident),+>)?),+ $(,)?) => {$(
195        impl$(<$($param),+>)? $ty$(<$($param),+>)? {
196            /// Allows what both `self` and `other` allow.
197            pub fn and<P>(self, other: P) -> And<Self, P> {
198                And(self, other)
199            }
200
201            /// Allows what either `self` or `other` allows.
202            pub fn or<P>(self, other: P) -> Or<Self, P> {
203                Or(self, other)
204            }
205        }
206    )+};
207}
208
209combinators!(Allow, Deny, ReadOnly, When<F>, And<A, B>, Or<A, B>, Abilities<M>);
210
211impl<M, F> Policy<M> for F
212where
213    F: Fn(&Cx, Ability<'_, M>) -> bool + Send + Sync + 'static,
214{
215    fn allows(&self, cx: &Cx, ability: Ability<'_, M>) -> bool {
216        self(cx, ability)
217    }
218}
219
220/// Allows every ability.
221#[derive(Debug, Clone, Copy, Default)]
222pub struct Allow;
223
224impl<M> Policy<M> for Allow {
225    fn allows(&self, _cx: &Cx, _ability: Ability<'_, M>) -> bool {
226        true
227    }
228}
229
230/// Allows nothing: the default policy.
231#[derive(Debug, Clone, Copy, Default)]
232pub struct Deny;
233
234impl<M> Policy<M> for Deny {
235    fn allows(&self, _cx: &Cx, _ability: Ability<'_, M>) -> bool {
236        false
237    }
238}
239
240/// Allows listing and viewing, and no write: no create, update, delete or custom action.
241#[derive(Debug, Clone, Copy, Default)]
242pub struct ReadOnly;
243
244impl<M> Policy<M> for ReadOnly {
245    fn allows(&self, _cx: &Cx, ability: Ability<'_, M>) -> bool {
246        ability.is_read()
247    }
248}
249
250/// Allows every ability while `predicate` holds for the request.
251///
252/// ```rust
253/// # #[derive(Debug, Clone, toasty::Model)]
254/// # struct Staff { #[key] #[auto] id: uuid::Uuid, editor: bool }
255/// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
256/// # struct Post { #[key] #[auto] id: uuid::Uuid }
257/// # use tablo_core::{PanelUser, Policy, ReadOnly, auth, when};
258/// # use topcoat::context::Cx;
259/// # impl PanelUser for Staff {
260/// #     fn user_id(&self) -> String { String::new() }
261/// #     fn display_name(&self) -> &str { "" }
262/// #     fn can_access_panel(&self, _panel: &str) -> bool { true }
263/// # }
264/// fn editor(cx: &Cx) -> bool {
265///     auth::user::<Staff>(cx).is_some_and(|staff| staff.editor)
266/// }
267///
268/// fn post_policy() -> impl Policy<Post> {
269///     ReadOnly.or(when(editor))
270/// }
271/// ```
272pub fn when<F>(predicate: F) -> When<F>
273where
274    F: Fn(&Cx) -> bool + Send + Sync + 'static,
275{
276    When(predicate)
277}
278
279/// The policy [`when`] returns.
280#[derive(Debug, Clone, Copy)]
281pub struct When<F>(F);
282
283impl<M, F> Policy<M> for When<F>
284where
285    F: Fn(&Cx) -> bool + Send + Sync + 'static,
286{
287    fn allows(&self, cx: &Cx, _ability: Ability<'_, M>) -> bool {
288        (self.0)(cx)
289    }
290}
291
292/// The policy `and` returns: allows what both sides allow.
293#[derive(Debug, Clone, Copy)]
294pub struct And<A, B>(A, B);
295
296impl<M, A: Policy<M>, B: Policy<M>> Policy<M> for And<A, B> {
297    fn allows(&self, cx: &Cx, ability: Ability<'_, M>) -> bool {
298        self.0.allows(cx, ability) && self.1.allows(cx, ability)
299    }
300}
301
302/// The policy `or` returns: allows what either side allows.
303#[derive(Debug, Clone, Copy)]
304pub struct Or<A, B>(A, B);
305
306impl<M, A: Policy<M>, B: Policy<M>> Policy<M> for Or<A, B> {
307    fn allows(&self, cx: &Cx, ability: Ability<'_, M>) -> bool {
308        self.0.allows(cx, ability) || self.1.allows(cx, ability)
309    }
310}
311
312#[cfg(test)]
313mod tests;