Skip to main content

tablo_core/schema/
relationship.rs

1//! Relationship option loading — bounded, memoized, policy-checked.
2//!
3//! Every relationship choice field over one source shares a single bounded load per `(request,
4//! tenant)` and fails closed instead of leaking labels.
5
6use toasty::stmt::{Expr, List, OrderByExpr, Query};
7use topcoat::{Result, context::Cx};
8
9use crate::{BoxFuture, policy::Ability};
10
11/// Describes the source a relationship option loader reads.
12///
13/// Every [`Resource`](crate::Resource) is one, answering from its def as the context's panel
14/// mounted it.
15///
16/// Every relationship choice over one source shares a single load per request and tenant,
17/// bounded to [`MAX_RELATIONSHIP_OPTIONS`] rows. A load the policy refuses fails closed: the
18/// choice offers nothing rather than leak a label.
19pub trait OptionSource: Sized + Send + Sync + 'static {
20    /// The model whose rows become options.
21    type Model: toasty::schema::Model + toasty::stmt::IntoExpr<Self::Model> + Send + Sync + 'static;
22
23    /// States the tenant-scoped seed query every option load starts from and reports an unscopable
24    /// source as misdeclared.
25    fn scoped_query(cx: &Cx) -> Result<Query<List<Self::Model>>>;
26
27    /// Whether the current user may see the rows `ability` names; refusing `ViewAny` fails the
28    /// whole load closed. Defaults to refusing everything.
29    fn allows(_cx: &Cx, _ability: Ability<'_, Self::Model>) -> bool {
30        false
31    }
32
33    /// Declares whether the source's rows are tenant-owned and fails a tenantless request closed.
34    ///
35    /// A [`Resource`](crate::Resource) answers from its def's
36    /// [`tenancy`](crate::ResourceDef::tenancy). An implementor that is not one declares no def and
37    /// no tenant lens the framework could read, so it states the boolean and scopes
38    /// [`scoped_query`](Self::scoped_query) itself.
39    fn requires_tenant(_cx: &Cx) -> bool {
40        false
41    }
42
43    /// States the related source's search predicate for `term`, or `None` when it declares no
44    /// searchable column.
45    fn search_expr(_cx: &Cx, _term: &str) -> Option<Expr<bool>> {
46        None
47    }
48
49    /// States the related source's declared default ordering.
50    fn order_by(_cx: &Cx) -> Option<OrderByExpr> {
51        None
52    }
53
54    /// Labels `record` wherever a relationship choice offers it or a
55    /// [`RelationColumn::of`](crate::RelationColumn::of) shows it.
56    ///
57    /// A [`Resource`](crate::Resource) answers with its def's
58    /// [`record_title`](crate::ResourceDef::record_title), else its label and the record's key.
59    /// The record is loaded without its relations.
60    fn label(cx: &Cx, record: &Self::Model) -> String;
61
62    /// Whether the context's panel can load from the source: a resource only when the panel
63    /// mounts it.
64    #[doc(hidden)]
65    fn available(_cx: &Cx) -> bool {
66        true
67    }
68
69    /// The root fields of the source's records the request may not view: for a resource, the
70    /// ones the panel's [`field_access`](crate::ResourceDef::field_access) hides from the request.
71    #[doc(hidden)]
72    fn hidden_fields(_cx: &Cx) -> Vec<String> {
73        Vec::new()
74    }
75}
76
77/// Distinguishes a policy denial and an over-cap table from a retryable load failure so validation
78/// answers correctly.
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub(crate) enum OptionLoadError {
81    Denied,
82    LoadFailed,
83    Overflow,
84    Misdeclared,
85}
86
87pub(crate) type RelationshipLoader = std::sync::Arc<
88    dyn Fn(&Cx) -> BoxFuture<'static, Result<Vec<(String, String)>, OptionLoadError>> + Send + Sync,
89>;
90
91pub(crate) type RelationshipSearchLoader = std::sync::Arc<
92    dyn Fn(
93            &Cx,
94            String,
95            Option<Expr<bool>>,
96        ) -> BoxFuture<'static, Result<Vec<(String, String)>, OptionLoadError>>
97        + Send
98        + Sync,
99>;
100
101pub(crate) type RelationshipChecker = std::sync::Arc<
102    dyn for<'a> Fn(
103            &'a Cx,
104            String,
105            Option<Expr<bool>>,
106            &'a mut dyn toasty::Executor,
107        ) -> BoxFuture<'a, Result<RelatedCheck, OptionLoadError>>
108        + Send
109        + Sync,
110>;
111
112/// Refuses the option load closed when `ViewAny` is refused or a tenant-owned source gets a
113/// tenantless request.
114pub(crate) fn ensure_option_access<R>(cx: &Cx) -> Result<(), OptionLoadError>
115where
116    R: OptionSource,
117{
118    if !R::allows(cx, Ability::ViewAny) {
119        return Err(OptionLoadError::Denied);
120    }
121    if R::requires_tenant(cx) && crate::tenancy::tenant_id(cx).is_none() {
122        return Err(OptionLoadError::Denied);
123    }
124    Ok(())
125}
126
127/// Starts every option loader from the source's tenant-scoped seed query and reports an unscopable
128/// source as misdeclared.
129fn option_query<R>(cx: &Cx) -> Result<Query<List<R::Model>>, OptionLoadError>
130where
131    R: OptionSource,
132{
133    R::scoped_query(cx).map_err(|error| {
134        tracing::error!(
135            resource = std::any::type_name::<R>(),
136            error = %error,
137            "relationship option load cannot scope the related resource"
138        );
139        OptionLoadError::Misdeclared
140    })
141}
142
143/// Caps the options a relationship choice field loads instead of scanning a large table per select.
144pub const MAX_RELATIONSHIP_OPTIONS: usize = 200;
145
146/// Loads option records for one related resource, memoized per request, and checks the cap on the
147/// raw fetch before filtering rows through `View`.
148#[topcoat::context::memoize(as_ref)]
149pub(crate) async fn related_records<R>(
150    cx: &Cx,
151    _tenant: Option<uuid::Uuid>,
152) -> Result<Vec<R::Model>, OptionLoadError>
153where
154    R: OptionSource,
155{
156    ensure_option_access::<R>(cx)?;
157    let mut query = option_query::<R>(cx)?;
158    if let Some(ord) = R::order_by(cx) {
159        query = query.order_by(ord);
160    }
161    bounded_options::<R>(
162        cx,
163        query,
164        "relationship option load failed",
165        "relationship option table overflows the cap",
166    )
167    .await
168}
169
170/// Fetches one row past the cap, refuses a set over the cap with `Overflow`, and drops rows the
171/// caller cannot view.
172async fn bounded_options<R>(
173    cx: &Cx,
174    query: Query<List<R::Model>>,
175    failed: &str,
176    overflow: &str,
177) -> Result<Vec<R::Model>, OptionLoadError>
178where
179    R: OptionSource,
180{
181    let mut db = crate::db::db(cx).map_err(|_| OptionLoadError::Misdeclared)?;
182    let mut records = query
183        .limit(MAX_RELATIONSHIP_OPTIONS + 1)
184        .exec(&mut db)
185        .await
186        .map_err(|e| {
187            tracing::warn!(
188                resource = std::any::type_name::<R>(),
189                error = %e,
190                "{failed}"
191            );
192            OptionLoadError::LoadFailed
193        })?;
194    if records.len() > MAX_RELATIONSHIP_OPTIONS {
195        tracing::warn!(
196            resource = std::any::type_name::<R>(),
197            max = MAX_RELATIONSHIP_OPTIONS,
198            "{overflow}"
199        );
200        return Err(OptionLoadError::Overflow);
201    }
202    records.retain(|record| R::allows(cx, Ability::View(record)));
203    Ok(records)
204}
205
206/// Longest accepted search term, in chars; a relationship's option search and a table's `?q=`
207/// search share it.
208pub(crate) const MAX_QUERY_TERM: usize = 128;
209
210/// Clamps a search term to [`MAX_QUERY_TERM`] chars, so a multibyte term truncates on a char
211/// boundary.
212pub(crate) fn clamp_query_term(term: &str) -> String {
213    term.trim().chars().take(MAX_QUERY_TERM).collect()
214}
215
216/// Searches the related table's declared searchable columns, among the rows `scope` selects, in
217/// one bounded round-trip and fails past the cap with `Overflow`.
218pub(crate) async fn related_records_search<R>(
219    cx: &Cx,
220    q: String,
221    scope: Option<Expr<bool>>,
222) -> Result<Vec<R::Model>, OptionLoadError>
223where
224    R: OptionSource,
225{
226    ensure_option_access::<R>(cx)?;
227    let term = clamp_query_term(&q);
228    let mut query = option_query::<R>(cx)?;
229    if let Some(scope) = scope {
230        query = query.filter(scope);
231    }
232    if !term.is_empty()
233        && let Some(expr) = R::search_expr(cx, &term)
234    {
235        query = query.filter(crate::toasty_compat::case_insensitive_search(
236            &crate::db::db(cx).map_err(|_| OptionLoadError::Misdeclared)?,
237            expr,
238        ));
239    }
240    if let Some(ord) = R::order_by(cx) {
241        query = query.order_by(ord);
242    }
243    bounded_options::<R>(
244        cx,
245        query,
246        "relationship option search failed",
247        "relationship option search overflows the cap",
248    )
249    .await
250}
251
252/// Reports the outcome of the targeted existence check for overflowed selects.
253#[derive(Debug, Clone, PartialEq, Eq)]
254pub(crate) enum RelatedCheck {
255    FoundViewable,
256    FoundHidden,
257    NotFound,
258}
259
260/// Checks one submitted key through the given executor against the tenant-scoped query, the rows
261/// `scope` selects, and `View`.
262pub(crate) async fn related_record_check<R>(
263    cx: &Cx,
264    value: String,
265    scope: Option<Expr<bool>>,
266    ex: &mut dyn toasty::Executor,
267) -> Result<RelatedCheck, OptionLoadError>
268where
269    R: OptionSource,
270{
271    ensure_option_access::<R>(cx)?;
272    let trimmed = value.trim();
273    let Some(expr) = crate::toasty_compat::pk::pk_eq_expr::<R::Model>(trimmed) else {
274        return Ok(RelatedCheck::NotFound);
275    };
276    let mut query = option_query::<R>(cx)?.filter(expr);
277    if let Some(scope) = scope {
278        query = query.filter(scope);
279    }
280    let row = query.first().exec(ex).await.map_err(|e| {
281        tracing::warn!(
282            resource = std::any::type_name::<R>(),
283            error = %e,
284            "relationship option check failed"
285        );
286        OptionLoadError::LoadFailed
287    })?;
288    match row {
289        None => Ok(RelatedCheck::NotFound),
290        Some(record) => {
291            if R::allows(cx, Ability::View(&record)) {
292                Ok(RelatedCheck::FoundViewable)
293            } else {
294                Ok(RelatedCheck::FoundHidden)
295            }
296        }
297    }
298}
299
300/// The keys among `keys` naming a row `R`'s tenant-scoped query holds and its policy lets the
301/// user view, read in one query: what a relationship filter admits past the cap. Memoized per
302/// request, so the list and its filter menu ask once.
303#[topcoat::context::memoize(as_ref)]
304pub(crate) async fn viewable_keys<R>(
305    cx: &Cx,
306    _tenant: Option<uuid::Uuid>,
307    keys: &[String],
308) -> Result<std::collections::HashSet<String>, OptionLoadError>
309where
310    R: OptionSource,
311{
312    ensure_option_access::<R>(cx)?;
313    let Some(any) = keys
314        .iter()
315        .filter_map(|key| crate::toasty_compat::pk::pk_eq_expr::<R::Model>(key))
316        .reduce(|any, one| any.or(one))
317    else {
318        return Ok(std::collections::HashSet::new());
319    };
320    let rows = option_query::<R>(cx)?
321        .filter(any)
322        .exec(&mut crate::db::db(cx).map_err(|_| OptionLoadError::Misdeclared)?)
323        .await
324        .map_err(|e| {
325            tracing::warn!(
326                resource = std::any::type_name::<R>(),
327                error = %e,
328                "relationship filter check failed"
329            );
330            OptionLoadError::LoadFailed
331        })?;
332    Ok(rows
333        .iter()
334        .filter(|record| R::allows(cx, Ability::View(record)))
335        .map(crate::toasty_compat::pk::pk_text)
336        .collect())
337}
338
339#[cfg(test)]
340mod tests;