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;