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, Path};
10use topcoat::context::{Cx, try_context};
11
12use crate::{
13    DeclarationErrorKind, TenancyFault,
14    toasty_compat::model::{self, ModelPath},
15};
16
17mod tenant_id;
18
19pub use tenant_id::{TenantColumn, TenantId};
20
21/// One tenant a user may act for: its id, and the name the tenant switcher
22/// shows. A [`PanelUser`](crate::auth::PanelUser) lists its memberships in
23/// [`tenants`](crate::auth::PanelUser::tenants).
24#[derive(Debug, Clone, PartialEq, Eq, Hash)]
25pub struct Membership {
26    pub tenant: TenantId,
27    pub name: String,
28}
29
30impl Membership {
31    /// The membership of `tenant`, shown as `name`.
32    pub fn new(tenant: TenantId, name: impl Into<String>) -> Self {
33        Self {
34            tenant,
35            name: name.into(),
36        }
37    }
38}
39
40/// Request-scoped tenant identifier.
41///
42/// App code may put it on the `Cx` with `cx.with(Tenant(id))`, and server code
43/// and tests may carry it as a request extension; either wins over the signed-in
44/// user's tenant. No request header sets it. A `Tenant` registered on the router
45/// with `.with(Tenant(id))` reaches every request, so every user acts for that
46/// tenant whatever their memberships.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
48pub struct Tenant(pub TenantId);
49
50/// The tenant a test claims its request acts for, as a request extension: what
51/// `tablo::testing::TestClient::tenant` sends. Unlike a [`Tenant`] override it is no seam
52/// around membership: a signed-in user acts for it only while a member, as after switching to it
53/// from the tenant menu, and otherwise for the tenant they would act for without it. A request
54/// with nobody signed in, such as one to a panel with auth disabled, acts for it.
55#[doc(hidden)]
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
57pub struct ClaimedTenant(pub TenantId);
58
59/// The tenant the request acts for, if any.
60///
61/// Checks a `Tenant` request extension first (a server-set override — the
62/// deliberate seam app middleware and `Router::handle` tests use), then a
63/// `Tenant` scoped value app code put on the `Cx`, then the signed-in user's
64/// [`membership`](crate::auth::membership). No request header is consulted.
65pub fn tenant_id(cx: &Cx) -> Option<TenantId> {
66    if let Some(parts) = try_context::<http::request::Parts>(cx)
67        && let Some(t) = parts.extensions.get::<Tenant>()
68    {
69        return Some(t.0);
70    }
71    if let Some(t) = try_context::<Tenant>(cx) {
72        return Some(t.0);
73    }
74    let TenantSource(session) = try_context::<TenantSource>(cx)?;
75    session(cx)
76}
77
78/// Finds the tenant the request's session acts for; the first panel mounted on a
79/// router installs it.
80pub(crate) struct TenantSource(pub(crate) fn(&Cx) -> Option<TenantId>);
81
82/// Requires a tenant, returning an error if missing (for tenant-owned resources).
83///
84/// # Errors
85///
86/// 403 when the request acts for no tenant.
87pub fn require_tenant(cx: &Cx) -> Result<TenantId, topcoat::Error> {
88    tenant_id(cx).ok_or_else(|| topcoat::router::error::forbidden().into())
89}
90
91/// How a resource's rows belong to a tenant.
92///
93/// A resource declares one with [`ResourceDef::tenancy`](crate::ResourceDef::tenancy). A
94/// scoped tenancy names, by lens, the [`TenantId`] column each row is filtered on:
95///
96/// ```rust
97/// # use tablo_core::{NoForm, Resource, ResourceDef, Tenancy, TenantId, lens};
98/// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
99/// # struct Post { #[key] #[auto] id: uuid::Uuid, tenant_id: TenantId }
100/// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
101/// # struct Comment {
102/// #     #[key] #[auto] id: uuid::Uuid,
103/// #     post_id: uuid::Uuid,
104/// #     #[belongs_to(key = post_id, references = id)]
105/// #     post: toasty::Deferred<Post>,
106/// # }
107/// # struct PostResource;
108/// # impl Resource for PostResource { type Model = Post; type Form = NoForm<Post>; }
109/// # struct CommentResource;
110/// # impl Resource for CommentResource { type Model = Comment; type Form = NoForm<Comment>; }
111/// let posts: ResourceDef<PostResource> =
112///     ResourceDef::new().tenancy(Tenancy::column(lens!(Post.tenant_id)));
113///
114/// let comments: ResourceDef<CommentResource> =
115///     ResourceDef::new().tenancy(Tenancy::via(Comment::fields().post().tenant_id()));
116/// ```
117///
118/// A scoped resource answers 403 to a request with no tenant, in every handler,
119/// instead of serving unscoped rows or writing rows with no tenant.
120pub struct Tenancy<M> {
121    scope: Scope,
122    _model: std::marker::PhantomData<fn() -> M>,
123}
124
125/// The model field a [`Tenancy::column`] stamps and filters on.
126#[derive(Debug, Clone, PartialEq, Eq)]
127pub(crate) struct TenantField {
128    /// The field's index in its model.
129    pub(crate) index: usize,
130    /// The field's name, which is also its form key.
131    pub(crate) name: String,
132}
133
134/// `<lens> = tenant` for one tenant.
135type TenantFilter = Box<dyn Fn(TenantId) -> Expr<bool> + Send + Sync>;
136
137enum Scope {
138    /// Unscoped: [`Tenancy::none`] when `declared`, else the default a resource that never called
139    /// [`ResourceDef::tenancy`](crate::ResourceDef::tenancy) holds.
140    None { declared: bool },
141    /// The model's own column, resolved when declared: its field, or `None`
142    /// when the lens names none.
143    Column {
144        filter: TenantFilter,
145        field: Option<TenantField>,
146    },
147    Via {
148        filter: TenantFilter,
149        /// Whether the lens is one field of the model: a `via` over its own
150        /// column stamps nothing, so the mount refuses it in favor of
151        /// [`Tenancy::column`](Self::column).
152        single: bool,
153        /// The model field the lens's first step names: the relation whose
154        /// foreign key the form must write through a relationship field.
155        hop: Option<usize>,
156    },
157}
158
159impl<M: toasty::schema::Model + 'static> Tenancy<M> {
160    fn scoped(scope: Scope) -> Self {
161        Self {
162            scope,
163            _model: std::marker::PhantomData,
164        }
165    }
166
167    /// Rows belong to no tenant: the resource is served as its
168    /// [`query`](crate::ResourceDef::query) narrows it, to every request.
169    ///
170    /// A resource that declares no tenancy is served the same way, unless its model has a
171    /// [`TenantId`] column or the panel acts for tenants, offering a tenant registration or
172    /// settings page or mounting a resource over tenant-owned rows: mounting the panel refuses
173    /// that resource until it declares one, so it declares `Tenancy::none()` to serve every tenant
174    /// on purpose.
175    pub fn none() -> Self {
176        Self::scoped(Scope::None { declared: true })
177    }
178
179    /// The tenancy of a resource that never declared one.
180    pub(crate) fn undeclared() -> Self {
181        Self::scoped(Scope::None { declared: false })
182    }
183
184    /// Rows carry their tenant in one [`TenantId`] column of their own model,
185    /// `TenantId` or `Option<TenantId>`.
186    ///
187    /// The framework filters every loader on it and stamps the request's
188    /// tenant into it on create, so the record form must not claim it.
189    /// Mounting the panel refuses a lens that is not a single field of the
190    /// model.
191    pub fn column<T>(lens: impl Into<Path<M, T>>) -> Self
192    where
193        T: TenantColumn,
194        M: Send + Sync,
195    {
196        let lens = lens.into();
197        let field = model::field::<M>(&ModelPath::of(&lens))
198            .ok()
199            .map(|field| TenantField {
200                index: field.index,
201                name: field.name,
202            });
203        Self::scoped(Scope::Column {
204            filter: Box::new(move |tenant| lens.clone().eq(T::from_tenant(tenant))),
205            field,
206        })
207    }
208
209    /// Rows inherit their tenant through a relation: `lens` reaches the parent's [`TenantId`]
210    /// column, as `Comment::fields().post().tenant_id()` does.
211    ///
212    /// The framework filters every loader on it. It stamps nothing on create:
213    /// a row's tenant is its parent's. The lens starts at a `belongs_to`
214    /// relation, and mounting the panel requires the form to write that
215    /// relation's foreign key through a relationship field over a tenant-scoped
216    /// resource, whose key the framework re-checks against that resource's
217    /// tenant-scoped query inside the write.
218    pub fn via<T>(lens: impl Into<Path<M, T>>) -> Self
219    where
220        T: TenantColumn,
221        M: Send + Sync,
222    {
223        let lens = lens.into();
224        let path = ModelPath::of(&lens);
225        let hop = path.steps().first().copied();
226        let single = model::field::<M>(&path).is_ok();
227        Self::scoped(Scope::Via {
228            filter: Box::new(move |tenant| lens.clone().eq(T::from_tenant(tenant))),
229            single,
230            hop,
231        })
232    }
233
234    /// Whether the rows belong to a tenant, so every handler requires one.
235    pub fn is_scoped(&self) -> bool {
236        !matches!(self.scope, Scope::None { .. })
237    }
238
239    /// Whether the resource never declared its tenancy.
240    pub(crate) fn is_undeclared(&self) -> bool {
241        matches!(self.scope, Scope::None { declared: false })
242    }
243
244    /// `<lens> = tenant`, or `None` for an unscoped resource.
245    pub(crate) fn filter(&self, tenant: TenantId) -> Option<Expr<bool>> {
246        match &self.scope {
247            Scope::None { .. } => None,
248            Scope::Column { filter, .. } | Scope::Via { filter, .. } => Some(filter(tenant)),
249        }
250    }
251
252    /// Whether a [`via`](Self::via) tenancy names one field of the model,
253    /// which [`Tenancy::column`](Self::column) owns. `None` for any other
254    /// tenancy.
255    pub(crate) fn via_is_single(&self) -> Option<bool> {
256        match &self.scope {
257            Scope::Via { single, .. } => Some(*single),
258            Scope::None { .. } | Scope::Column { .. } => None,
259        }
260    }
261
262    /// The model field a [`via`](Self::via) lens steps through first.
263    pub(crate) fn via_hop(&self) -> Option<usize> {
264        match &self.scope {
265            Scope::Via { hop, .. } => *hop,
266            Scope::None { .. } | Scope::Column { .. } => None,
267        }
268    }
269
270    /// The model's own tenant column for a [`column`](Self::column) tenancy,
271    /// or the mistake of a lens that names none.
272    pub(crate) fn column_field(&self) -> Option<Result<&TenantField, DeclarationErrorKind>> {
273        match &self.scope {
274            Scope::Column { field, .. } => {
275                Some(field.as_ref().ok_or(DeclarationErrorKind::Tenancy {
276                    fault: TenancyFault::ColumnNotAField,
277                }))
278            }
279            Scope::None { .. } | Scope::Via { .. } => None,
280        }
281    }
282}
283
284#[cfg(test)]
285mod tests;