pylon_plugin/builtin/
tenant_scope.rs1use std::collections::HashMap;
25
26use serde_json::Value;
27
28use crate::Plugin;
29use pylon_auth::AuthContext;
30
31#[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 scopes: HashMap<String, TenantScopeConfig>,
48}
49
50impl TenantScopePlugin {
51 pub fn new() -> Self {
52 Self {
53 scopes: HashMap::new(),
54 }
55 }
56
57 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 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 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 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 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 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 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
188fn 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"}); 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}