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;