Skip to main content

tablo_core/resource/
field_access.rs

1//! [`FieldAccess`]: who may view, and who may change, one field of a resource's records; the
2//! [`FieldMask`] a request reads them through; and the mount checks that keep every page under it.
3
4use std::sync::Arc;
5
6use topcoat::context::Cx;
7
8use super::{Mounted, Resource, is_mounted, is_restricted_model, model_rules, restricts_any};
9use crate::{
10    DeclarationError, DeclarationErrorKind, FieldAccessFault, Site,
11    column::{BoxColumn, Related},
12    form::FormField,
13    schema::{FormMask, Schema},
14    toasty_compat::{model, pk},
15};
16
17/// A request's rule over a field: whether it may.
18type Rule = Arc<dyn Fn(&Cx) -> bool + Send + Sync>;
19
20/// Who may view one field of a model's records, and who may change it, which
21/// [`ResourceDef::field_access`](crate::ResourceDef::field_access) declares: every resource the
22/// panel mounts over the model obeys the rule.
23///
24/// A request the [`view`](Self::view) rule refuses sees no column, filter, grouping, detail entry
25/// or relation column reading the field, no export column, and no control for it: an edit keeps
26/// the stored value, and a create takes the control's default, else the blank answer. When the
27/// field has no default and the record form requires it, or its rules refuse a blank answer, as
28/// an `accepted` checkbox does, that request may not create. One the
29/// [`edit`](Self::edit) rule refuses sees the control disabled. Either answers 400 to a submission
30/// posting the field.
31///
32/// ```rust
33/// # use topcoat::context::Cx;
34/// # fn is_hr(_cx: &Cx) -> bool { true }
35/// # fn is_payroll(_cx: &Cx) -> bool { true }
36/// use tablo_core::FieldAccess;
37///
38/// FieldAccess::new().view(is_hr).edit(is_payroll);
39/// ```
40#[derive(Clone, Default)]
41pub struct FieldAccess {
42    view: Option<Rule>,
43    edit: Option<Rule>,
44}
45
46impl FieldAccess {
47    /// Every request views and changes the field, until [`view`](Self::view) or
48    /// [`edit`](Self::edit) narrows it.
49    pub fn new() -> Self {
50        Self::default()
51    }
52
53    /// Let only the requests `rule` allows view the field.
54    #[must_use]
55    pub fn view(mut self, rule: impl Fn(&Cx) -> bool + Send + Sync + 'static) -> Self {
56        self.view = Some(Arc::new(rule));
57        self
58    }
59
60    /// Let only the requests `rule` allows change the field, among the ones that view it.
61    #[must_use]
62    pub fn edit(mut self, rule: impl Fn(&Cx) -> bool + Send + Sync + 'static) -> Self {
63        self.edit = Some(Arc::new(rule));
64        self
65    }
66
67    /// Whether `cx` views the field.
68    fn views(&self, cx: &Cx) -> bool {
69        self.view.as_ref().is_none_or(|rule| rule(cx))
70    }
71
72    /// Whether `cx` changes the field.
73    fn edits(&self, cx: &Cx) -> bool {
74        self.views(cx) && self.edit.as_ref().is_none_or(|rule| rule(cx))
75    }
76}
77
78impl std::fmt::Debug for FieldAccess {
79    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80        f.debug_struct("FieldAccess")
81            .field("view", &self.view.is_some())
82            .field("edit", &self.edit.is_some())
83            .finish()
84    }
85}
86
87/// One declared [`FieldAccess`], resolved against the model.
88#[derive(Debug, Clone)]
89pub(crate) struct FieldRule {
90    /// The field, or why the lens names none; a placeholder spelling the steps then.
91    field: Result<String, (String, DeclarationErrorKind)>,
92    /// The field and the fields a `belongs_to` pairs it with: its relation, or its foreign keys.
93    covers: Vec<String>,
94    /// Whether the field is the inverse side of a relation, which no mask reaches.
95    inverse: bool,
96    access: FieldAccess,
97}
98
99impl FieldRule {
100    /// `access` over the root field `path` names.
101    pub(crate) fn new<M, T>(path: &toasty::stmt::Path<M, T>, access: FieldAccess) -> Self
102    where
103        M: toasty::schema::Model,
104    {
105        let path = model::ModelPath::of(path);
106        let field =
107            model::field::<M>(&path).map_err(|error| (format!("{:?}", path.steps()), error));
108        let covers = match &field {
109            Ok(field) => paired::<M>(field),
110            Err(_) => Vec::new(),
111        };
112        let inverse = field
113            .as_ref()
114            .is_ok_and(|field| model::is_inverse::<M>(&field.name));
115        Self {
116            field: field.map(|field| field.name),
117            covers,
118            inverse,
119            access,
120        }
121    }
122
123    /// Whether the rule restricts who views the field, not only who changes it.
124    pub(crate) fn restricts_view(&self) -> bool {
125        self.access.view.is_some()
126    }
127}
128
129/// `field` and the fields a `belongs_to` pairs it with: a relation's foreign keys, or the
130/// relations whose foreign key it is. Hiding a key hides the relation that shows its record.
131fn paired<M: toasty::schema::Model>(field: &model::FieldInfo) -> Vec<String> {
132    let mut covers = vec![field.name.clone()];
133    for relation in model::belongs_tos::<M>() {
134        let pairs = relation.name == field.name || relation.foreign_keys.contains(&field.name);
135        if pairs {
136            covers.push(relation.name);
137            covers.extend(relation.foreign_keys);
138        }
139    }
140    covers.sort();
141    covers.dedup();
142    covers
143}
144
145/// The root fields one request may not view, and the ones it may view but not change.
146#[derive(Debug, Clone, Default)]
147pub(crate) struct FieldMask {
148    hidden: Vec<String>,
149    locked: Vec<String>,
150}
151
152impl FieldMask {
153    /// What `rules` mask for `cx`.
154    pub(crate) fn of(cx: &Cx, rules: &[FieldRule]) -> Self {
155        let mut mask = Self::default();
156        for rule in rules {
157            if !rule.access.views(cx) {
158                mask.hidden.extend(rule.covers.iter().cloned());
159            } else if !rule.access.edits(cx) {
160                mask.locked.extend(rule.covers.iter().cloned());
161            }
162        }
163        mask
164    }
165
166    /// The fields the request may not view.
167    pub(crate) fn hidden(&self) -> &[String] {
168        &self.hidden
169    }
170
171    /// The keys of `fields` it hides, and the ones it locks.
172    pub(crate) fn form<K>(&self, fields: &[FormField<K>]) -> FormMask {
173        let keys = |names: &[String]| -> Vec<String> {
174            fields
175                .iter()
176                .filter(|field| names.iter().any(|name| name == field.name))
177                .flat_map(|field| field.keys.iter().cloned())
178                .collect()
179        };
180        FormMask::new(keys(&self.hidden), keys(&self.locked))
181    }
182}
183
184/// What is wrong with `declared`'s field access: a lens naming no field, two declarations over one
185/// field, a rule over a primary key, a record title or a form watch reading a field a mounted
186/// resource over the model restricts, and a column, a row color or a row link that does not say
187/// what it reads.
188pub(crate) fn check<R: Resource>(
189    cx: &Cx,
190    declared: &Mounted<R>,
191    errors: &mut Vec<DeclarationError>,
192) {
193    let model = model_rules(
194        cx,
195        <R::Model as toasty::schema::Model>::id(),
196        &declared.field_rules,
197    );
198    let key_fields = pk::key_names::<R::Model>();
199    let mut seen = Vec::new();
200    for rule in &declared.field_rules {
201        let site = |field: &str| Site::FieldAccess(field.to_string());
202        let fault = |fault| DeclarationErrorKind::FieldAccess { fault };
203        let field = match &rule.field {
204            Ok(field) => field,
205            Err((placeholder, error)) => {
206                errors.push(DeclarationError::of::<R>(site(placeholder), error.clone()));
207                continue;
208            }
209        };
210        if seen.contains(&field) {
211            errors.push(DeclarationError::of::<R>(
212                site(field),
213                fault(FieldAccessFault::Duplicate),
214            ));
215        }
216        seen.push(field);
217        if rule.inverse {
218            errors.push(DeclarationError::of::<R>(
219                site(field),
220                fault(FieldAccessFault::Inverse),
221            ));
222            continue;
223        }
224        let trash = declared.soft_delete.as_ref();
225        if trash.is_some_and(|trash| trash.field().is_ok_and(|(_, name)| name == field)) {
226            errors.push(DeclarationError::of::<R>(
227                site(field),
228                fault(FieldAccessFault::SoftDelete),
229            ));
230        }
231        if rule.restricts_view() && key_fields.contains(field) {
232            errors.push(DeclarationError::of::<R>(
233                site(field),
234                fault(FieldAccessFault::RecordKey),
235            ));
236        }
237    }
238    // A rule only over edits leaves the field shown to every user: a title or a watch over it
239    // tells no one what they may not see. The restrictions are the model's, every mounted
240    // resource's, since a title or a watch reads the record whichever resource shows it.
241    let hiding = model.iter().filter(|rule| rule.restricts_view());
242    let title_hidden = declared.record_title_field().is_some_and(|title| {
243        hiding
244            .clone()
245            .any(|rule| rule.covers.iter().any(|covered| covered == title))
246    });
247    if title_hidden {
248        errors.push(DeclarationError::of::<R>(
249            Site::RecordTitle,
250            DeclarationErrorKind::FieldAccess {
251                fault: FieldAccessFault::RecordTitle,
252            },
253        ));
254    }
255    let watchers = declared.form.watchers();
256    // The field's own controls may watch one another, as an embedded enum's watch its variant:
257    // only a watch driving a control the user still sees tells what it holds.
258    let watched = hiding.clone().any(|rule| {
259        let keys = FieldMask {
260            hidden: rule.covers.clone(),
261            locked: Vec::new(),
262        }
263        .form(&declared.fields);
264        watchers.iter().any(|(watched, driven)| {
265            keys.hides(watched) && driven.as_ref().is_none_or(|driven| !keys.hides(driven))
266        })
267    });
268    if watched {
269        errors.push(DeclarationError::of::<R>(
270            Site::Form,
271            DeclarationErrorKind::FieldAccess {
272                fault: FieldAccessFault::Watched,
273            },
274        ));
275    }
276    if model.is_empty() {
277        return;
278    }
279    let undeclared = |what, name: &str| DeclarationErrorKind::FieldAccess {
280        fault: FieldAccessFault::UndeclaredReads {
281            what,
282            name: name.to_string(),
283        },
284    };
285    let mut faults = Vec::new();
286    for column in declared.table.declared_columns() {
287        if column.reads().is_none() {
288            faults.push((Site::Table, undeclared("column", column.name())));
289        }
290    }
291    if declared.table.row_color_reads_undeclared() {
292        faults.push((Site::Table, undeclared("row color", "row_color")));
293    }
294    if declared.table.row_link_reads_undeclared() {
295        faults.push((Site::Table, undeclared("row link", "row_link")));
296    }
297    for column in declared.detail.undeclared_reads() {
298        faults.push((Site::Detail, undeclared("column", column)));
299    }
300    errors.extend(
301        faults
302            .into_iter()
303            .map(|(site, kind)| DeclarationError::of::<R>(site, kind)),
304    );
305}
306
307/// What `declared`'s table and detail page show of the records of a model a mounted resource
308/// restricts a field of, under no field access a panel can mask.
309pub(crate) fn check_columns<R: Resource>(
310    cx: &Cx,
311    declared: &Mounted<R>,
312    errors: &mut Vec<DeclarationError>,
313) {
314    if !restricts_any(cx) {
315        return;
316    }
317    let table: Vec<_> = declared.table.declared_columns().iter().collect();
318    let mut row_readers = Vec::new();
319    if declared.table.row_color_reads_row() {
320        row_readers.push("row color");
321    }
322    if declared.table.row_link_reads_row() {
323        row_readers.push("row link");
324    }
325    check_page::<R>(cx, Site::Table, &table, &row_readers, errors);
326    check_page::<R>(cx, Site::Detail, &declared.detail.columns(), &[], errors);
327}
328
329/// What `columns`, the columns of one page, show under no field access a panel can mask. A column
330/// reading related records no mounted resource masks is refused over a restricted model its own
331/// includes load; a column reading its row, and `row_readers`, the page's other closures over its
332/// rows, are refused on a page whose columns load one, since a closure may read the related record
333/// through any of them.
334fn check_page<R: Resource>(
335    cx: &Cx,
336    site: Site,
337    columns: &[&BoxColumn<R::Model>],
338    row_readers: &[&str],
339    errors: &mut Vec<DeclarationError>,
340) {
341    let loads: Vec<Option<Vec<(model::ModelId, String)>>> = columns
342        .iter()
343        .map(|column| model::included_models::<R::Model>(&column.includes().into_vec()))
344        .collect();
345    let unresolved = loads.iter().any(Option::is_none);
346    let page_restricted = loads
347        .iter()
348        .flatten()
349        .flatten()
350        .find(|(model, _)| is_restricted_model(cx, *model))
351        .map(|(_, name)| name.clone());
352    let reads_row = |name: String| match (unresolved, &page_restricted) {
353        (true, _) => Some(FieldAccessFault::UnresolvedRelation { name }),
354        (false, Some(model)) => Some(FieldAccessFault::UnmaskedRelation {
355            name,
356            model: model.clone(),
357        }),
358        (false, None) => None,
359    };
360    let push = |errors: &mut Vec<DeclarationError>, fault| {
361        errors.push(DeclarationError::of::<R>(
362            site.clone(),
363            DeclarationErrorKind::FieldAccess { fault },
364        ));
365    };
366    for fault in row_readers
367        .iter()
368        .filter_map(|name| reads_row(name.to_string()))
369    {
370        push(errors, fault);
371    }
372    for (column, own) in columns.iter().zip(&loads) {
373        let name = column.name().to_string();
374        let fault = match column.internals().related() {
375            Related::None => continue,
376            Related::Source(source) if is_mounted(cx, source) => continue,
377            Related::Row => match reads_row(name) {
378                Some(fault) => fault,
379                None => continue,
380            },
381            Related::Source(_) | Related::Unmasked => match own {
382                None => FieldAccessFault::UnresolvedRelation { name },
383                Some(models) => {
384                    match models
385                        .iter()
386                        .find(|(model, _)| is_restricted_model(cx, *model))
387                    {
388                        Some((_, model)) => FieldAccessFault::UnmaskedRelation {
389                            name,
390                            model: model.clone(),
391                        },
392                        None => continue,
393                    }
394                }
395            },
396        };
397        push(errors, fault);
398    }
399}
400
401/// Whether a mounted resource over `model` restricts who views its `field`.
402pub(crate) fn restricts_field(cx: &Cx, model: model::ModelId, field: &str) -> bool {
403    model_rules(cx, model, &[])
404        .iter()
405        .any(|rule| rule.restricts_view() && rule.covers.iter().any(|covered| covered == field))
406}
407
408/// What `schema`'s relationship choices show that no mount may: records under a label a panel
409/// cannot mask, and a dependent choice narrowed by a field a mounted resource restricts.
410pub(crate) fn check_choices<T: 'static, F>(
411    cx: &Cx,
412    schema: &Schema<F>,
413    site: Site,
414    errors: &mut Vec<DeclarationError>,
415) {
416    let mounted = |source| is_mounted(cx, source);
417    let restricted = |model: model::ModelId| is_restricted_model(cx, model);
418    for field in schema.fields() {
419        let Some(choice) = field.as_choice() else {
420            continue;
421        };
422        let name = field.name();
423        if let Some(model) = choice.unmasked_model(mounted, restricted) {
424            errors.push(DeclarationError::of::<T>(
425                site.clone(),
426                DeclarationErrorKind::FieldAccess {
427                    fault: FieldAccessFault::UnmaskedRelation {
428                        name: name.to_string(),
429                        model: model.to_string(),
430                    },
431                },
432            ));
433        }
434        if let Some(column) = choice.narrowed_by(|model, column| restricts_field(cx, model, column))
435        {
436            errors.push(DeclarationError::of::<T>(
437                site.clone(),
438                DeclarationErrorKind::FieldAccess {
439                    fault: FieldAccessFault::NarrowedBy {
440                        field: column.to_string(),
441                    },
442                },
443            ));
444        }
445    }
446}
447
448/// What `declared`'s filters, tabs and summaries show or read that no mount may: records under a
449/// label a panel cannot mask, records a relation their reads reach holds, of a model a mounted
450/// resource restricts a field of, and a predicate that does not say what it reads.
451pub(crate) fn check_filters<R: Resource>(
452    cx: &Cx,
453    declared: &Mounted<R>,
454    errors: &mut Vec<DeclarationError>,
455) {
456    if !restricts_any(cx) {
457        return;
458    }
459    let mounted = |source| is_mounted(cx, source);
460    let restricted = |model: model::ModelId| is_restricted_model(cx, model);
461    // A filter, a tab and a summary narrow or count the records, so a predicate that does not say
462    // what it reads could tell a field of any model a mounted resource restricts, not only this
463    // resource's own.
464    for filter in declared.table.declared_filters() {
465        let name = filter.name().to_string();
466        let fault = if let Some(model) = filter.internals().unmasked_model(&mounted, &restricted) {
467            Some(FieldAccessFault::UnmaskedRelation {
468                name,
469                model: model.to_string(),
470            })
471        } else {
472            match filter.reads() {
473                None => Some(FieldAccessFault::UndeclaredReads {
474                    what: "filter",
475                    name,
476                }),
477                reads => relation_fault(cx, name, reads),
478            }
479        };
480        errors.extend(fault.map(|fault| {
481            DeclarationError::of::<R>(Site::Table, DeclarationErrorKind::FieldAccess { fault })
482        }));
483    }
484    for tab in declared.table.declared_tabs() {
485        let name = tab.key().to_string();
486        let fault = relation_fault(cx, name, Some(tab.reads()));
487        errors.extend(fault.map(|fault| {
488            DeclarationError::of::<R>(Site::Table, DeclarationErrorKind::FieldAccess { fault })
489        }));
490    }
491    for summary in declared.table.declared_summaries() {
492        let fault = relation_fault(cx, summary.name(), Some(summary.reads()));
493        errors.extend(fault.map(|fault| {
494            DeclarationError::of::<R>(Site::Table, DeclarationErrorKind::FieldAccess { fault })
495        }));
496    }
497}
498
499/// The relation a filter's, a tab's or a summary's `reads`, named `name`, reach that no mount may
500/// read: one whose model a mounted resource restricts a field of, or one the mount cannot resolve.
501fn relation_fault<M: toasty::schema::Model>(
502    cx: &Cx,
503    name: String,
504    reads: Option<crate::Reads<M>>,
505) -> Option<FieldAccessFault> {
506    let reads = reads?;
507    match model::read_relations::<M>(reads.fields()) {
508        Some(models) if !reads.is_unresolved() => models
509            .into_iter()
510            .find(|(model, _)| is_restricted_model(cx, *model))
511            .map(|(_, model)| FieldAccessFault::UnmaskedRelation { name, model }),
512        _ => Some(FieldAccessFault::UnresolvedRelation { name }),
513    }
514}