Skip to main content

tablo_core/policy/
abilities.rs

1//! [`Abilities`]: a policy declared one ability at a time, denying every ability it does not
2//! declare.
3
4use topcoat::context::Cx;
5
6use super::{Ability, Policy};
7
8/// A check on the request alone.
9type RequestCheck = Box<dyn Fn(&Cx) -> bool + Send + Sync>;
10
11/// A check on the request and one record.
12type RecordCheck<M> = Box<dyn Fn(&Cx, &M) -> bool + Send + Sync>;
13
14/// The two checks of an ability asked at all and then of each record, such as `DeleteAny` and
15/// `Delete`.
16struct Pair<M> {
17    any: Option<RequestCheck>,
18    record: Option<RecordCheck<M>>,
19}
20
21impl<M> Default for Pair<M> {
22    fn default() -> Self {
23        Self {
24            any: None,
25            record: None,
26        }
27    }
28}
29
30impl<M> Pair<M> {
31    /// The `any` check, else allowed when a record check is declared: declaring who may delete
32    /// a record lets the request delete at all.
33    fn any_allows(&self, cx: &Cx) -> bool {
34        match &self.any {
35            Some(check) => check(cx),
36            None => self.record.is_some(),
37        }
38    }
39
40    fn record_allows(&self, cx: &Cx, record: &M) -> bool {
41        self.record.as_ref().is_some_and(|check| check(cx, record))
42    }
43}
44
45/// A policy that answers each [`Ability`] from the check declared for it, and denies every
46/// ability it declares none for.
47///
48/// A closure policy that ends in `_ => true` allows whatever it does not name, such as the
49/// restore a soft delete adds or an action declared later. `Abilities` allows only what it
50/// names:
51///
52/// ```rust
53/// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
54/// # struct Post { #[key] #[auto] id: uuid::Uuid, locked: bool }
55/// # use tablo_core::Abilities;
56/// # use topcoat::context::Cx;
57/// # fn is_editor(_cx: &Cx) -> bool { true }
58/// let policy = Abilities::<Post>::new()
59///     .view_any(is_editor)
60///     .view(|_cx, _post| true)
61///     .create(is_editor)
62///     .update(|cx, post| is_editor(cx) && !post.locked);
63/// // No delete, restore or action: each is refused until the policy declares it.
64/// # let _ = policy;
65/// ```
66///
67/// A write's record check also allows the write at all: [`delete`](Self::delete) allows
68/// [`DeleteAny`](Ability::DeleteAny), [`restore`](Self::restore) allows
69/// [`RestoreAny`](Ability::RestoreAny), [`force_delete`](Self::force_delete) allows
70/// [`ForceDeleteAny`](Ability::ForceDeleteAny), and [`run`](Self::run) allows
71/// [`RunAny`](Ability::RunAny) for its action, since the panel asks the record's check before
72/// each of these writes. The `_any` methods narrow it; declared alone, they allow no record.
73/// [`view`](Self::view) does not allow [`ViewAny`](Ability::ViewAny): the list asks `ViewAny`
74/// alone and shows every row the resource's query returns, so [`view_any`](Self::view_any) is
75/// declared on its own.
76///
77/// Declaring an ability twice replaces its earlier check; combine the checks in one closure.
78///
79/// The model's type is named on `new`, `Abilities::<Post>::new()`: a closure is checked before
80/// the chain learns the model from where the policy goes.
81pub struct Abilities<M> {
82    view_any: Option<RequestCheck>,
83    view: Option<RecordCheck<M>>,
84    create: Option<RequestCheck>,
85    update: Option<RecordCheck<M>>,
86    delete: Pair<M>,
87    restore: Pair<M>,
88    force_delete: Pair<M>,
89    /// Each record action's checks, by its `NAME`.
90    actions: Vec<(&'static str, Pair<M>)>,
91    /// Each header action's check, by its `NAME`.
92    header_actions: Vec<(&'static str, RequestCheck)>,
93}
94
95impl<M> Default for Abilities<M> {
96    fn default() -> Self {
97        Self {
98            view_any: None,
99            view: None,
100            create: None,
101            update: None,
102            delete: Pair::default(),
103            restore: Pair::default(),
104            force_delete: Pair::default(),
105            actions: Vec::new(),
106            header_actions: Vec::new(),
107        }
108    }
109}
110
111impl<M> Abilities<M> {
112    /// A policy that denies every ability.
113    pub fn new() -> Self {
114        Self::default()
115    }
116
117    /// Who may see the resource at all: [`ViewAny`](Ability::ViewAny), which opens the list
118    /// with every row of the resource's query, the sidebar entry, the export, related tables,
119    /// relationship options and header actions. Refused until declared, whether or not
120    /// [`view`](Self::view) is.
121    #[must_use]
122    pub fn view_any(mut self, check: impl Fn(&Cx) -> bool + Send + Sync + 'static) -> Self {
123        self.view_any = Some(Box::new(check));
124        self
125    }
126
127    /// Who may see a record: [`View`](Ability::View), asked by its detail page, its edit form,
128    /// each write to it, the export and relationship options. The list shows a row without it,
129    /// and asks it only to offer the row's actions.
130    #[must_use]
131    pub fn view(mut self, check: impl Fn(&Cx, &M) -> bool + Send + Sync + 'static) -> Self {
132        self.view = Some(Box::new(check));
133        self
134    }
135
136    /// Who may create a record: [`Create`](Ability::Create).
137    #[must_use]
138    pub fn create(mut self, check: impl Fn(&Cx) -> bool + Send + Sync + 'static) -> Self {
139        self.create = Some(Box::new(check));
140        self
141    }
142
143    /// Who may edit a record: [`Update`](Ability::Update).
144    #[must_use]
145    pub fn update(mut self, check: impl Fn(&Cx, &M) -> bool + Send + Sync + 'static) -> Self {
146        self.update = Some(Box::new(check));
147        self
148    }
149
150    /// Who may delete at all: [`DeleteAny`](Ability::DeleteAny). Defaults to whether
151    /// [`delete`](Self::delete) is declared.
152    #[must_use]
153    pub fn delete_any(mut self, check: impl Fn(&Cx) -> bool + Send + Sync + 'static) -> Self {
154        self.delete.any = Some(Box::new(check));
155        self
156    }
157
158    /// Who may delete a record: [`Delete`](Ability::Delete).
159    #[must_use]
160    pub fn delete(mut self, check: impl Fn(&Cx, &M) -> bool + Send + Sync + 'static) -> Self {
161        self.delete.record = Some(Box::new(check));
162        self
163    }
164
165    /// Who may restore trashed records at all: [`RestoreAny`](Ability::RestoreAny). Defaults to
166    /// whether [`restore`](Self::restore) is declared.
167    #[must_use]
168    pub fn restore_any(mut self, check: impl Fn(&Cx) -> bool + Send + Sync + 'static) -> Self {
169        self.restore.any = Some(Box::new(check));
170        self
171    }
172
173    /// Who may restore a trashed record: [`Restore`](Ability::Restore).
174    #[must_use]
175    pub fn restore(mut self, check: impl Fn(&Cx, &M) -> bool + Send + Sync + 'static) -> Self {
176        self.restore.record = Some(Box::new(check));
177        self
178    }
179
180    /// Who may delete trashed records for good at all:
181    /// [`ForceDeleteAny`](Ability::ForceDeleteAny), which the panel also asks `DeleteAny` of.
182    /// Defaults to whether [`force_delete`](Self::force_delete) is declared.
183    #[must_use]
184    pub fn force_delete_any(mut self, check: impl Fn(&Cx) -> bool + Send + Sync + 'static) -> Self {
185        self.force_delete.any = Some(Box::new(check));
186        self
187    }
188
189    /// Who may delete a trashed record for good: [`ForceDelete`](Ability::ForceDelete), which
190    /// the panel also asks [`Delete`](Ability::Delete) of.
191    #[must_use]
192    pub fn force_delete(mut self, check: impl Fn(&Cx, &M) -> bool + Send + Sync + 'static) -> Self {
193        self.force_delete.record = Some(Box::new(check));
194        self
195    }
196
197    /// The checks of the record action `name`, added empty when none is declared yet.
198    fn action_mut(&mut self, name: &'static str) -> &mut Pair<M> {
199        let index = match self.actions.iter().position(|(action, _)| *action == name) {
200            Some(index) => index,
201            None => {
202                self.actions.push((name, Pair::default()));
203                self.actions.len() - 1
204            }
205        };
206        &mut self.actions[index].1
207    }
208
209    /// Declares the record action `name`'s check on the request.
210    pub(crate) fn set_action_any(&mut self, name: &'static str, check: RequestCheck) {
211        self.action_mut(name).any = Some(check);
212    }
213
214    /// Declares the record action `name`'s check on each record.
215    pub(crate) fn set_action_record(&mut self, name: &'static str, check: RecordCheck<M>) {
216        self.action_mut(name).record = Some(check);
217    }
218
219    /// Declares the header action `name`'s check, replacing an earlier one.
220    pub(crate) fn set_header_action(&mut self, name: &'static str, check: RequestCheck) {
221        self.header_actions.retain(|(action, _)| *action != name);
222        self.header_actions.push((name, check));
223    }
224
225    fn action(&self, name: &str) -> Option<&Pair<M>> {
226        self.actions
227            .iter()
228            .find(|(action, _)| *action == name)
229            .map(|(_, pair)| pair)
230    }
231}
232
233impl<M> std::fmt::Debug for Abilities<M> {
234    /// Lists the declared abilities by method name; their checks are closures.
235    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
236        let declared = [
237            ("view_any", self.view_any.is_some()),
238            ("view", self.view.is_some()),
239            ("create", self.create.is_some()),
240            ("update", self.update.is_some()),
241            ("delete_any", self.delete.any.is_some()),
242            ("delete", self.delete.record.is_some()),
243            ("restore_any", self.restore.any.is_some()),
244            ("restore", self.restore.record.is_some()),
245            ("force_delete_any", self.force_delete.any.is_some()),
246            ("force_delete", self.force_delete.record.is_some()),
247        ];
248        f.debug_struct("Abilities")
249            .field(
250                "declared",
251                &declared
252                    .iter()
253                    .filter_map(|(name, set)| set.then_some(*name))
254                    .collect::<Vec<_>>(),
255            )
256            .field(
257                "actions",
258                &self
259                    .actions
260                    .iter()
261                    .map(|(name, _)| name)
262                    .collect::<Vec<_>>(),
263            )
264            .field(
265                "header_actions",
266                &self
267                    .header_actions
268                    .iter()
269                    .map(|(name, _)| name)
270                    .collect::<Vec<_>>(),
271            )
272            .finish()
273    }
274}
275
276impl<M: 'static> Policy<M> for Abilities<M> {
277    fn allows(&self, cx: &Cx, ability: Ability<'_, M>) -> bool {
278        // No catch-all arm: a new ability fails to compile here until it is given a check.
279        match ability {
280            Ability::ViewAny => self.view_any.as_ref().is_some_and(|check| check(cx)),
281            Ability::View(record) => self.view.as_ref().is_some_and(|check| check(cx, record)),
282            Ability::Create => self.create.as_ref().is_some_and(|check| check(cx)),
283            Ability::Update(record) => self.update.as_ref().is_some_and(|check| check(cx, record)),
284            Ability::DeleteAny => self.delete.any_allows(cx),
285            Ability::Delete(record) => self.delete.record_allows(cx, record),
286            Ability::RestoreAny => self.restore.any_allows(cx),
287            Ability::Restore(record) => self.restore.record_allows(cx, record),
288            Ability::ForceDeleteAny => self.force_delete.any_allows(cx),
289            Ability::ForceDelete(record) => self.force_delete.record_allows(cx, record),
290            Ability::RunAny { action } => self.action(action).is_some_and(|p| p.any_allows(cx)),
291            Ability::Run { action, record } => self
292                .action(action)
293                .is_some_and(|pair| pair.record_allows(cx, record)),
294            Ability::RunHeader { action } => self
295                .header_actions
296                .iter()
297                .find(|(name, _)| *name == action)
298                .is_some_and(|(_, check)| check(cx)),
299        }
300    }
301}