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