Skip to main content

pylon_plugin/builtin/
tenant_scope.rs

1//! Row-level multi-tenancy via automatic `tenantId` injection.
2//!
3//! Pairs with `OrganizationsPlugin`: orgs answer "who belongs to what",
4//! `TenantScopePlugin` answers "which rows belong to what" by stamping every
5//! insert with the active tenant id.
6//!
7//! How it works:
8//! 1. Configure which entities are tenant-scoped, plus the column name
9//!    (default `tenantId`). Untouched entities behave normally.
10//! 2. Before insert, the plugin sets `data.tenantId = auth.tenant_id` if
11//!    the field is missing or empty.
12//! 3. Before update/delete, the plugin checks the existing row's tenant
13//!    matches the caller's tenant — cross-tenant writes are rejected by
14//!    returning an `Err` from the hook (the runtime translates this to a
15//!    403 response).
16//!
17//! This plugin does NOT enforce reads. Use `pylon-policy` expressions for
18//! that — they have access to `auth.tenantId` and can scope `query` and
19//! `lookup` calls. The asymmetry is intentional: writes need the tenant id
20//! anyway (to stamp the row), so enforcing them here is free; reads need
21//! the user-defined policy expression engine because filtering rules can
22//! get arbitrarily complex.
23
24use std::collections::HashMap;
25
26use serde_json::Value;
27
28use crate::Plugin;
29use pylon_auth::AuthContext;
30
31/// Per-entity tenant scoping configuration.
32#[derive(Debug, Clone)]
33pub struct TenantScopeConfig {
34    pub field: String,
35}
36
37impl Default for TenantScopeConfig {
38    fn default() -> Self {
39        Self {
40            field: "tenantId".into(),
41        }
42    }
43}
44
45pub struct TenantScopePlugin {
46    /// entity → which field carries the tenant id
47    scopes: HashMap<String, TenantScopeConfig>,
48}
49
50impl TenantScopePlugin {
51    pub fn new() -> Self {
52        Self {
53            scopes: HashMap::new(),
54        }
55    }
56
57    /// Auto-configure from a manifest: any entity that declares a `tenantId`
58    /// (or `tenant_id`) field is marked tenant-scoped using that field.
59    ///
60    /// This is how the server registers the plugin by default. Adding a
61    /// `tenantId` field to an entity is the signal — no separate config
62    /// step required. Apps that use a different field name (e.g. `orgId`)
63    /// can still call [`scope_with_field`] after this to customize.
64    pub fn from_manifest(manifest: &pylon_kernel::AppManifest) -> Self {
65        let mut plugin = Self::new();
66        for entity in &manifest.entities {
67            for field in &entity.fields {
68                if field.name == "tenantId" || field.name == "tenant_id" {
69                    plugin.scopes.insert(
70                        entity.name.clone(),
71                        TenantScopeConfig {
72                            field: field.name.clone(),
73                        },
74                    );
75                    break;
76                }
77            }
78        }
79        plugin
80    }
81
82    /// Mark `entity` as tenant-scoped using the default `tenantId` field.
83    pub fn scope(&mut self, entity: impl Into<String>) -> &mut Self {
84        self.scopes
85            .insert(entity.into(), TenantScopeConfig::default());
86        self
87    }
88
89    /// Mark `entity` as tenant-scoped using a custom field name.
90    pub fn scope_with_field(
91        &mut self,
92        entity: impl Into<String>,
93        field: impl Into<String>,
94    ) -> &mut Self {
95        self.scopes.insert(
96            entity.into(),
97            TenantScopeConfig {
98                field: field.into(),
99            },
100        );
101        self
102    }
103
104    pub fn is_scoped(&self, entity: &str) -> bool {
105        self.scopes.contains_key(entity)
106    }
107
108    pub fn field_for(&self, entity: &str) -> Option<&str> {
109        self.scopes.get(entity).map(|c| c.field.as_str())
110    }
111
112    /// Stamp `tenantId` onto a row that's about to be inserted.
113    /// Returns `Err` if the entity is scoped but the caller has no tenant.
114    pub fn stamp_insert(
115        &self,
116        entity: &str,
117        data: &mut Value,
118        auth: &AuthContext,
119    ) -> Result<(), TenantError> {
120        let Some(field) = self.field_for(entity) else {
121            return Ok(());
122        };
123        let tenant_id = match auth_tenant_id(auth) {
124            Some(t) => t,
125            None => return Err(TenantError::MissingTenant),
126        };
127        if let Some(obj) = data.as_object_mut() {
128            // Only inject when caller didn't provide one — let admin tooling
129            // override by being explicit.
130            let needs_set = obj
131                .get(field)
132                .map(|v| v.is_null() || v.as_str().map_or(false, str::is_empty))
133                .unwrap_or(true);
134            if needs_set {
135                obj.insert(field.into(), Value::String(tenant_id.into()));
136            }
137        }
138        Ok(())
139    }
140
141    /// Verify that an existing row belongs to the caller's tenant before
142    /// allowing a mutation.
143    pub fn check_write(
144        &self,
145        entity: &str,
146        existing: &Value,
147        auth: &AuthContext,
148    ) -> Result<(), TenantError> {
149        let Some(field) = self.field_for(entity) else {
150            return Ok(());
151        };
152        let tenant = auth_tenant_id(auth).ok_or(TenantError::MissingTenant)?;
153        let row_tenant = existing
154            .as_object()
155            .and_then(|o| o.get(field))
156            .and_then(|v| v.as_str())
157            .unwrap_or("");
158        if row_tenant.is_empty() {
159            // Unscoped legacy row — refuse to mutate from a tenant context to
160            // avoid accidentally claiming someone else's data.
161            return Err(TenantError::WrongTenant);
162        }
163        if row_tenant != tenant {
164            return Err(TenantError::WrongTenant);
165        }
166        Ok(())
167    }
168}
169
170impl Default for TenantScopePlugin {
171    fn default() -> Self {
172        Self::new()
173    }
174}
175
176impl Plugin for TenantScopePlugin {
177    fn name(&self) -> &str {
178        "tenant_scope"
179    }
180}
181
182#[derive(Debug, Clone, PartialEq, Eq)]
183pub enum TenantError {
184    MissingTenant,
185    WrongTenant,
186}
187
188/// Extract the active tenant id from auth context.
189///
190/// Looks for a session attribute called `tenant_id` (set by the app when the
191/// user picks an org) — falling back to None. Apps that don't use the
192/// attribute can subclass this resolution by setting `auth.tenant_id` via
193/// AuthContext::with_tenant.
194fn auth_tenant_id(auth: &AuthContext) -> Option<&str> {
195    auth.tenant_id()
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201    use serde_json::json;
202
203    fn auth_with_tenant(user: &str, tenant: &str) -> AuthContext {
204        AuthContext::user(user.into()).with_tenant(tenant.into())
205    }
206
207    #[test]
208    fn unscoped_entity_passes_through() {
209        let p = TenantScopePlugin::new();
210        let mut data = json!({"name": "x"});
211        p.stamp_insert("Doc", &mut data, &auth_with_tenant("u1", "t1"))
212            .unwrap();
213        assert_eq!(data["name"], "x");
214        assert!(data.get("tenantId").is_none());
215    }
216
217    #[test]
218    fn stamps_tenant_on_scoped_insert() {
219        let mut p = TenantScopePlugin::new();
220        p.scope("Doc");
221        let mut data = json!({"title": "hi"});
222        p.stamp_insert("Doc", &mut data, &auth_with_tenant("u1", "tA"))
223            .unwrap();
224        assert_eq!(data["tenantId"], "tA");
225    }
226
227    #[test]
228    fn does_not_overwrite_provided_tenant() {
229        let mut p = TenantScopePlugin::new();
230        p.scope("Doc");
231        let mut data = json!({"tenantId": "explicit"});
232        p.stamp_insert("Doc", &mut data, &auth_with_tenant("u1", "tA"))
233            .unwrap();
234        assert_eq!(data["tenantId"], "explicit");
235    }
236
237    #[test]
238    fn rejects_insert_without_tenant() {
239        let mut p = TenantScopePlugin::new();
240        p.scope("Doc");
241        let mut data = json!({});
242        let err = p
243            .stamp_insert("Doc", &mut data, &AuthContext::user("u1".into()))
244            .unwrap_err();
245        assert_eq!(err, TenantError::MissingTenant);
246    }
247
248    #[test]
249    fn allows_write_to_own_tenant_row() {
250        let mut p = TenantScopePlugin::new();
251        p.scope("Doc");
252        let row = json!({"tenantId": "tA", "title": "x"});
253        p.check_write("Doc", &row, &auth_with_tenant("u1", "tA"))
254            .unwrap();
255    }
256
257    #[test]
258    fn rejects_write_to_other_tenant_row() {
259        let mut p = TenantScopePlugin::new();
260        p.scope("Doc");
261        let row = json!({"tenantId": "tB", "title": "x"});
262        let err = p
263            .check_write("Doc", &row, &auth_with_tenant("u1", "tA"))
264            .unwrap_err();
265        assert_eq!(err, TenantError::WrongTenant);
266    }
267
268    #[test]
269    fn rejects_write_to_legacy_unscoped_row() {
270        let mut p = TenantScopePlugin::new();
271        p.scope("Doc");
272        let row = json!({"title": "x"}); // no tenantId
273        let err = p
274            .check_write("Doc", &row, &auth_with_tenant("u1", "tA"))
275            .unwrap_err();
276        assert_eq!(err, TenantError::WrongTenant);
277    }
278
279    #[test]
280    fn custom_field_name_used() {
281        let mut p = TenantScopePlugin::new();
282        p.scope_with_field("Doc", "orgId");
283        let mut data = json!({});
284        p.stamp_insert("Doc", &mut data, &auth_with_tenant("u1", "tA"))
285            .unwrap();
286        assert_eq!(data["orgId"], "tA");
287        assert!(data.get("tenantId").is_none());
288    }
289}