Skip to main content

tablo_core/
tenancy.rs

1//! Scopes resources to the request tenant.
2//!
3//! [`tenant_id`] answers the request's tenant: the signed-in user's
4//! [`membership`](crate::membership) unless a server-set `Tenant` request extension or a `Tenant`
5//! scoped value overrides it, so app middleware and `Router::handle` tests can set it
6//! deliberately. No request header supplies a tenant: learning another tenant's UUID does not make
7//! anyone that tenant.
8
9use toasty::stmt::{Expr, IntoExpr, Path};
10use topcoat::context::{Cx, try_app_context, try_request_context};
11
12use crate::DeclarationErrorKind;
13
14/// One tenant a user may act for: its id, and the name the tenant switcher
15/// shows. A [`PanelUser`](crate::auth::PanelUser) lists its memberships in
16/// [`tenants`](crate::auth::PanelUser::tenants).
17#[derive(Debug, Clone, PartialEq, Eq, Hash)]
18pub struct Membership {
19    pub tenant: uuid::Uuid,
20    pub name: String,
21}
22
23impl Membership {
24    /// The membership of `tenant`, shown as `name`.
25    pub fn new(tenant: uuid::Uuid, name: impl Into<String>) -> Self {
26        Self {
27            tenant,
28            name: name.into(),
29        }
30    }
31}
32
33/// Request-scoped tenant identifier.
34///
35/// App code may put it on the `Cx` with `cx.with(Tenant(id))`, and server code
36/// and tests may carry it as a request extension; either wins over the signed-in
37/// user's tenant.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
39pub struct Tenant(pub uuid::Uuid);
40
41/// The tenant the request acts for, if any.
42///
43/// Checks a `Tenant` request extension first (a server-set override — the
44/// deliberate seam app middleware and `Router::handle` tests use), then a
45/// `Tenant` scoped value app code put on the `Cx`, then the signed-in user's
46/// [`membership`](crate::membership). No request header is consulted.
47pub fn tenant_id(cx: &Cx) -> Option<uuid::Uuid> {
48    if let Some(parts) = try_request_context::<http::request::Parts>(cx)
49        && let Some(t) = parts.extensions.get::<Tenant>()
50    {
51        return Some(t.0);
52    }
53    if let Some(t) = try_request_context::<Tenant>(cx) {
54        return Some(t.0);
55    }
56    let TenantSource(session) = try_app_context::<TenantSource>(cx)?;
57    session(cx)
58}
59
60/// Finds the tenant the request's session acts for; the first panel mounted on a router installs
61/// it in the app context.
62pub(crate) struct TenantSource(pub(crate) fn(&Cx) -> Option<uuid::Uuid>);
63
64/// Requires a tenant, returning an error if missing (for tenancy-gated resources).
65///
66/// # Errors
67///
68/// 403 when the request acts for no tenant.
69pub fn require_tenant(cx: &Cx) -> Result<uuid::Uuid, topcoat::Error> {
70    tenant_id(cx).ok_or_else(|| topcoat::router::error::forbidden().into())
71}
72
73/// How a resource's rows belong to a tenant.
74///
75/// A resource declares one with [`ResourceDef::tenancy`](crate::ResourceDef::tenancy). A
76/// scoped tenancy names, by lens, the tenant UUID each row is filtered on:
77///
78/// ```text
79/// ResourceDef::new().tenancy(Tenancy::column(Post::fields().tenant_id()))
80///
81/// ResourceDef::new().tenancy(Tenancy::via(Comment::fields().post().tenant_id()))
82/// ```
83///
84/// A scoped resource answers 403 to a request with no tenant, in every handler,
85/// instead of serving unscoped rows or writing rows with no tenant.
86pub struct Tenancy<M> {
87    scope: Scope,
88    _model: std::marker::PhantomData<fn() -> M>,
89}
90
91/// A resource's own tenant column: the field the create stamps.
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub(crate) struct TenantColumn {
94    /// The field's index in its model.
95    pub(crate) index: usize,
96    /// The field's name, which is also its form key.
97    pub(crate) name: String,
98}
99
100/// `<lens> = tenant` for one tenant.
101type TenantFilter = Box<dyn Fn(uuid::Uuid) -> Expr<bool> + Send + Sync>;
102
103enum Scope {
104    None,
105    /// The model's own column, resolved when declared: its field, or `None`
106    /// when the lens names none.
107    Column {
108        filter: TenantFilter,
109        field: Option<TenantColumn>,
110    },
111    Via {
112        filter: TenantFilter,
113        /// Whether the lens is one field of the model: a `via` over its own
114        /// column stamps nothing, so the mount refuses it in favor of
115        /// [`Tenancy::column`](Self::column).
116        single: bool,
117        /// The model field the lens's first step names: the relation whose
118        /// foreign key the form must write through a relationship field.
119        hop: Option<usize>,
120    },
121}
122
123impl<M: toasty::schema::Model + 'static> Tenancy<M> {
124    fn scoped(scope: Scope) -> Self {
125        Self {
126            scope,
127            _model: std::marker::PhantomData,
128        }
129    }
130
131    /// Rows belong to no tenant: the resource is served as
132    /// [`query`](crate::resource::Resource::query) states it, to every
133    /// request. The default.
134    pub fn none() -> Self {
135        Self::scoped(Scope::None)
136    }
137
138    /// Rows carry their tenant in one UUID column of their own model, `Uuid`
139    /// or `Option<Uuid>`.
140    ///
141    /// The framework filters every loader on it and stamps the request's
142    /// tenant into it on create, so the record form must not claim it.
143    /// Mounting the panel refuses a lens that is not a single field of the
144    /// model.
145    pub fn column<T>(lens: impl Into<Path<M, T>>) -> Self
146    where
147        T: Send + Sync + 'static,
148        M: Send + Sync,
149        uuid::Uuid: IntoExpr<T>,
150    {
151        let lens = lens.into();
152        let field = crate::schema::lens_field(lens.clone(), &M::schema())
153            .ok()
154            .map(|field| TenantColumn {
155                index: field.id.index,
156                name: field.name.app_unwrap().to_string(),
157            });
158        Self::scoped(Scope::Column {
159            filter: Box::new(move |tenant| lens.clone().eq(tenant)),
160            field,
161        })
162    }
163
164    /// Rows inherit their tenant through a relation: `lens` reaches the parent's
165    /// tenant column, as `Comment::fields().post().tenant_id()` does.
166    ///
167    /// The framework filters every loader on it. It stamps nothing on create:
168    /// a row's tenant is its parent's. The lens starts at a `belongs_to`
169    /// relation, and mounting the panel requires the form to write that
170    /// relation's foreign key through a relationship field over a tenant-scoped
171    /// resource, whose key the framework re-checks against that resource's
172    /// tenant-scoped query inside the write.
173    pub fn via<T>(lens: impl Into<Path<M, T>>) -> Self
174    where
175        T: Send + Sync + 'static,
176        M: Send + Sync,
177        uuid::Uuid: IntoExpr<T>,
178    {
179        let lens = lens.into();
180        let hop = toasty_core::stmt::Path::from(lens.clone())
181            .projection
182            .as_slice()
183            .first()
184            .copied();
185        let single = crate::schema::lens_field(lens.clone(), &M::schema()).is_ok();
186        Self::scoped(Scope::Via {
187            filter: Box::new(move |tenant| lens.clone().eq(tenant)),
188            single,
189            hop,
190        })
191    }
192
193    /// Whether the rows belong to a tenant, so every handler requires one.
194    pub fn is_scoped(&self) -> bool {
195        !matches!(self.scope, Scope::None)
196    }
197
198    /// `<lens> = tenant`, or `None` for an unscoped resource.
199    pub(crate) fn filter(&self, tenant: uuid::Uuid) -> Option<Expr<bool>> {
200        match &self.scope {
201            Scope::None => None,
202            Scope::Column { filter, .. } | Scope::Via { filter, .. } => Some(filter(tenant)),
203        }
204    }
205
206    /// Whether a [`via`](Self::via) tenancy names one field of the model,
207    /// which [`Tenancy::column`](Self::column) owns. `None` for any other
208    /// tenancy.
209    pub(crate) fn via_is_single(&self) -> Option<bool> {
210        match &self.scope {
211            Scope::Via { single, .. } => Some(*single),
212            Scope::None | Scope::Column { .. } => None,
213        }
214    }
215
216    /// The model field a [`via`](Self::via) lens steps through first.
217    pub(crate) fn via_hop(&self) -> Option<usize> {
218        match &self.scope {
219            Scope::Via { hop, .. } => *hop,
220            Scope::None | Scope::Column { .. } => None,
221        }
222    }
223
224    /// The model's own tenant column for a [`column`](Self::column) tenancy,
225    /// or the mistake of a lens that names none.
226    pub(crate) fn column_field(&self) -> Option<Result<&TenantColumn, DeclarationErrorKind>> {
227        match &self.scope {
228            Scope::Column { field, .. } => Some(
229                field
230                    .as_ref()
231                    .ok_or(DeclarationErrorKind::TenancyColumnNotAField),
232            ),
233            Scope::None | Scope::Via { .. } => None,
234        }
235    }
236}
237
238impl<M: toasty::schema::Model + 'static> Default for Tenancy<M> {
239    fn default() -> Self {
240        Self::none()
241    }
242}
243
244#[cfg(test)]
245mod tests;