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 or filter that does not say what it reads.
187pub(crate) fn check<R: Resource>(
188    cx: &Cx,
189    declared: &Mounted<R>,
190    errors: &mut Vec<DeclarationError>,
191) {
192    let model = model_rules(
193        cx,
194        <R::Model as toasty::schema::Model>::id(),
195        &declared.field_rules,
196    );
197    let key_fields = pk::key_names::<R::Model>();
198    let mut seen = Vec::new();
199    for rule in &declared.field_rules {
200        let site = |field: &str| Site::FieldAccess(field.to_string());
201        let fault = |fault| DeclarationErrorKind::FieldAccess { fault };
202        let field = match &rule.field {
203            Ok(field) => field,
204            Err((placeholder, error)) => {
205                errors.push(DeclarationError::of::<R>(site(placeholder), error.clone()));
206                continue;
207            }
208        };
209        if seen.contains(&field) {
210            errors.push(DeclarationError::of::<R>(
211                site(field),
212                fault(FieldAccessFault::Duplicate),
213            ));
214        }
215        seen.push(field);
216        if rule.inverse {
217            errors.push(DeclarationError::of::<R>(
218                site(field),
219                fault(FieldAccessFault::Inverse),
220            ));
221            continue;
222        }
223        let trash = declared.soft_delete.as_ref();
224        if trash.is_some_and(|trash| trash.field().is_ok_and(|(_, name)| name == field)) {
225            errors.push(DeclarationError::of::<R>(
226                site(field),
227                fault(FieldAccessFault::SoftDelete),
228            ));
229        }
230        if rule.restricts_view() && key_fields.contains(field) {
231            errors.push(DeclarationError::of::<R>(
232                site(field),
233                fault(FieldAccessFault::RecordKey),
234            ));
235        }
236    }
237    // A rule only over edits leaves the field shown to every user: a title or a watch over it
238    // tells no one what they may not see. The restrictions are the model's, every mounted
239    // resource's, since a title or a watch reads the record whichever resource shows it.
240    let hiding = model.iter().filter(|rule| rule.restricts_view());
241    let title_hidden = declared.record_title_field().is_some_and(|title| {
242        hiding
243            .clone()
244            .any(|rule| rule.covers.iter().any(|covered| covered == title))
245    });
246    if title_hidden {
247        errors.push(DeclarationError::of::<R>(
248            Site::RecordTitle,
249            DeclarationErrorKind::FieldAccess {
250                fault: FieldAccessFault::RecordTitle,
251            },
252        ));
253    }
254    let watchers = declared.form.watchers();
255    // The field's own controls may watch one another, as an embedded enum's watch its variant:
256    // only a watch driving a control the user still sees tells what it holds.
257    let watched = hiding.clone().any(|rule| {
258        let keys = FieldMask {
259            hidden: rule.covers.clone(),
260            locked: Vec::new(),
261        }
262        .form(&declared.fields);
263        watchers.iter().any(|(watched, driven)| {
264            keys.hides(watched) && driven.as_ref().is_none_or(|driven| !keys.hides(driven))
265        })
266    });
267    if watched {
268        errors.push(DeclarationError::of::<R>(
269            Site::Form,
270            DeclarationErrorKind::FieldAccess {
271                fault: FieldAccessFault::Watched,
272            },
273        ));
274    }
275    if model.is_empty() {
276        return;
277    }
278    let undeclared = |what, name: &str| DeclarationErrorKind::FieldAccess {
279        fault: FieldAccessFault::UndeclaredReads {
280            what,
281            name: name.to_string(),
282        },
283    };
284    let mut faults = Vec::new();
285    for column in declared.table.declared_columns() {
286        if column.reads().is_none() {
287            faults.push((Site::Table, undeclared("column", column.name())));
288        }
289    }
290    for filter in declared.table.declared_filters() {
291        if filter.reads().is_none() {
292            faults.push((Site::Table, undeclared("filter", filter.name())));
293        }
294    }
295    for column in declared.detail.undeclared_reads() {
296        faults.push((Site::Detail, undeclared("column", column)));
297    }
298    errors.extend(
299        faults
300            .into_iter()
301            .map(|(site, kind)| DeclarationError::of::<R>(site, kind)),
302    );
303}
304
305/// What `declared`'s table and detail page show of the records of a model a mounted resource
306/// restricts a field of, under no field access a panel can mask.
307pub(crate) fn check_columns<R: Resource>(
308    cx: &Cx,
309    declared: &Mounted<R>,
310    errors: &mut Vec<DeclarationError>,
311) {
312    if !restricts_any(cx) {
313        return;
314    }
315    let table: Vec<_> = declared.table.declared_columns().iter().collect();
316    check_page::<R>(cx, Site::Table, &table, errors);
317    check_page::<R>(cx, Site::Detail, &declared.detail.columns(), errors);
318}
319
320/// What `columns`, the columns of one page, show under no field access a panel can mask. A column
321/// reading related records no mounted resource masks is refused over a restricted model its own
322/// includes load; a column reading its row is refused on a page whose columns load one, since its
323/// closure may read the related record through any of them.
324fn check_page<R: Resource>(
325    cx: &Cx,
326    site: Site,
327    columns: &[&BoxColumn<R::Model>],
328    errors: &mut Vec<DeclarationError>,
329) {
330    let loads: Vec<Option<Vec<(model::ModelId, String)>>> = columns
331        .iter()
332        .map(|column| model::included_models::<R::Model>(&column.includes().into_vec()))
333        .collect();
334    let unresolved = loads.iter().any(Option::is_none);
335    let page_restricted = loads
336        .iter()
337        .flatten()
338        .flatten()
339        .find(|(model, _)| is_restricted_model(cx, *model))
340        .map(|(_, name)| name.clone());
341    for (column, own) in columns.iter().zip(&loads) {
342        let name = column.name().to_string();
343        let fault = match column.related() {
344            Related::None => continue,
345            Related::Source(source) if is_mounted(cx, source) => continue,
346            Related::Row => match (unresolved, &page_restricted) {
347                (true, _) => FieldAccessFault::UnresolvedRelation { name },
348                (false, Some(model)) => FieldAccessFault::UnmaskedRelation {
349                    name,
350                    model: model.clone(),
351                },
352                (false, None) => continue,
353            },
354            Related::Source(_) | Related::Unmasked => match own {
355                None => FieldAccessFault::UnresolvedRelation { name },
356                Some(models) => {
357                    match models
358                        .iter()
359                        .find(|(model, _)| is_restricted_model(cx, *model))
360                    {
361                        Some((_, model)) => FieldAccessFault::UnmaskedRelation {
362                            name,
363                            model: model.clone(),
364                        },
365                        None => continue,
366                    }
367                }
368            },
369        };
370        errors.push(DeclarationError::of::<R>(
371            site.clone(),
372            DeclarationErrorKind::FieldAccess { fault },
373        ));
374    }
375}
376
377/// Whether a mounted resource over `model` restricts who views its `field`.
378pub(crate) fn restricts_field(cx: &Cx, model: model::ModelId, field: &str) -> bool {
379    model_rules(cx, model, &[])
380        .iter()
381        .any(|rule| rule.restricts_view() && rule.covers.iter().any(|covered| covered == field))
382}
383
384/// What `schema`'s relationship choices show that no mount may: records under a label a panel
385/// cannot mask, and a dependent choice narrowed by a field a mounted resource restricts.
386pub(crate) fn check_choices<T: 'static, F>(
387    cx: &Cx,
388    schema: &Schema<F>,
389    site: Site,
390    errors: &mut Vec<DeclarationError>,
391) {
392    let mounted = |source| is_mounted(cx, source);
393    let restricted = |model: model::ModelId| is_restricted_model(cx, model);
394    for field in schema.fields() {
395        let Some(choice) = field.as_choice() else {
396            continue;
397        };
398        let name = field.name();
399        if let Some(model) = choice.unmasked_model(mounted, restricted) {
400            errors.push(DeclarationError::of::<T>(
401                site.clone(),
402                DeclarationErrorKind::FieldAccess {
403                    fault: FieldAccessFault::UnmaskedRelation {
404                        name: name.to_string(),
405                        model: model.to_string(),
406                    },
407                },
408            ));
409        }
410        if let Some(column) = choice.narrowed_by(|model, column| restricts_field(cx, model, column))
411        {
412            errors.push(DeclarationError::of::<T>(
413                site.clone(),
414                DeclarationErrorKind::FieldAccess {
415                    fault: FieldAccessFault::NarrowedBy {
416                        field: column.to_string(),
417                    },
418                },
419            ));
420        }
421    }
422}
423
424/// What `declared`'s filters show or read that no mount may: records under a label a panel cannot
425/// mask, and records a relation the filter's reads reach holds, of a model a mounted resource
426/// restricts a field of.
427pub(crate) fn check_filters<R: Resource>(
428    cx: &Cx,
429    declared: &Mounted<R>,
430    errors: &mut Vec<DeclarationError>,
431) {
432    if !restricts_any(cx) {
433        return;
434    }
435    let mounted = |source| is_mounted(cx, source);
436    let restricted = |model: model::ModelId| is_restricted_model(cx, model);
437    for filter in declared.table.declared_filters() {
438        let name = filter.name().to_string();
439        let fault = if let Some(model) = filter.unmasked_model(&mounted, &restricted) {
440            Some(FieldAccessFault::UnmaskedRelation {
441                name,
442                model: model.to_string(),
443            })
444        } else {
445            match filter.reads() {
446                None => None,
447                Some(reads) => match model::read_relations::<R::Model>(reads.fields()) {
448                    Some(models) if !reads.is_unresolved() => models
449                        .into_iter()
450                        .find(|(model, _)| is_restricted_model(cx, *model))
451                        .map(|(_, model)| FieldAccessFault::UnmaskedRelation { name, model }),
452                    _ => Some(FieldAccessFault::UnresolvedRelation { name }),
453                },
454            }
455        };
456        if let Some(fault) = fault {
457            errors.push(DeclarationError::of::<R>(
458                Site::Table,
459                DeclarationErrorKind::FieldAccess { fault },
460            ));
461        }
462    }
463}