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