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