use std::collections::HashMap;
use serde_json::Value;
use crate::Plugin;
use pylon_auth::AuthContext;
#[derive(Debug, Clone)]
pub struct TenantScopeConfig {
pub field: String,
}
impl Default for TenantScopeConfig {
fn default() -> Self {
Self {
field: "tenantId".into(),
}
}
}
pub struct TenantScopePlugin {
scopes: HashMap<String, TenantScopeConfig>,
}
impl TenantScopePlugin {
pub fn new() -> Self {
Self {
scopes: HashMap::new(),
}
}
pub fn from_manifest(manifest: &pylon_kernel::AppManifest) -> Self {
let mut plugin = Self::new();
for entity in &manifest.entities {
for field in &entity.fields {
if field.name == "tenantId" || field.name == "tenant_id" {
plugin.scopes.insert(
entity.name.clone(),
TenantScopeConfig {
field: field.name.clone(),
},
);
break;
}
}
}
plugin
}
pub fn scope(&mut self, entity: impl Into<String>) -> &mut Self {
self.scopes
.insert(entity.into(), TenantScopeConfig::default());
self
}
pub fn scope_with_field(
&mut self,
entity: impl Into<String>,
field: impl Into<String>,
) -> &mut Self {
self.scopes.insert(
entity.into(),
TenantScopeConfig {
field: field.into(),
},
);
self
}
pub fn is_scoped(&self, entity: &str) -> bool {
self.scopes.contains_key(entity)
}
pub fn field_for(&self, entity: &str) -> Option<&str> {
self.scopes.get(entity).map(|c| c.field.as_str())
}
pub fn stamp_insert(
&self,
entity: &str,
data: &mut Value,
auth: &AuthContext,
) -> Result<(), TenantError> {
let Some(field) = self.field_for(entity) else {
return Ok(());
};
let tenant_id = match auth_tenant_id(auth) {
Some(t) => t,
None => return Err(TenantError::MissingTenant),
};
if let Some(obj) = data.as_object_mut() {
let needs_set = obj
.get(field)
.map(|v| v.is_null() || v.as_str().map_or(false, str::is_empty))
.unwrap_or(true);
if needs_set {
obj.insert(field.into(), Value::String(tenant_id.into()));
}
}
Ok(())
}
pub fn check_write(
&self,
entity: &str,
existing: &Value,
auth: &AuthContext,
) -> Result<(), TenantError> {
let Some(field) = self.field_for(entity) else {
return Ok(());
};
let tenant = auth_tenant_id(auth).ok_or(TenantError::MissingTenant)?;
let row_tenant = existing
.as_object()
.and_then(|o| o.get(field))
.and_then(|v| v.as_str())
.unwrap_or("");
if row_tenant.is_empty() {
return Err(TenantError::WrongTenant);
}
if row_tenant != tenant {
return Err(TenantError::WrongTenant);
}
Ok(())
}
}
impl Default for TenantScopePlugin {
fn default() -> Self {
Self::new()
}
}
impl Plugin for TenantScopePlugin {
fn name(&self) -> &str {
"tenant_scope"
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TenantError {
MissingTenant,
WrongTenant,
}
fn auth_tenant_id(auth: &AuthContext) -> Option<&str> {
auth.tenant_id()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn auth_with_tenant(user: &str, tenant: &str) -> AuthContext {
AuthContext::user(user.into()).with_tenant(tenant.into())
}
#[test]
fn unscoped_entity_passes_through() {
let p = TenantScopePlugin::new();
let mut data = json!({"name": "x"});
p.stamp_insert("Doc", &mut data, &auth_with_tenant("u1", "t1"))
.unwrap();
assert_eq!(data["name"], "x");
assert!(data.get("tenantId").is_none());
}
#[test]
fn stamps_tenant_on_scoped_insert() {
let mut p = TenantScopePlugin::new();
p.scope("Doc");
let mut data = json!({"title": "hi"});
p.stamp_insert("Doc", &mut data, &auth_with_tenant("u1", "tA"))
.unwrap();
assert_eq!(data["tenantId"], "tA");
}
#[test]
fn does_not_overwrite_provided_tenant() {
let mut p = TenantScopePlugin::new();
p.scope("Doc");
let mut data = json!({"tenantId": "explicit"});
p.stamp_insert("Doc", &mut data, &auth_with_tenant("u1", "tA"))
.unwrap();
assert_eq!(data["tenantId"], "explicit");
}
#[test]
fn rejects_insert_without_tenant() {
let mut p = TenantScopePlugin::new();
p.scope("Doc");
let mut data = json!({});
let err = p
.stamp_insert("Doc", &mut data, &AuthContext::user("u1".into()))
.unwrap_err();
assert_eq!(err, TenantError::MissingTenant);
}
#[test]
fn allows_write_to_own_tenant_row() {
let mut p = TenantScopePlugin::new();
p.scope("Doc");
let row = json!({"tenantId": "tA", "title": "x"});
p.check_write("Doc", &row, &auth_with_tenant("u1", "tA"))
.unwrap();
}
#[test]
fn rejects_write_to_other_tenant_row() {
let mut p = TenantScopePlugin::new();
p.scope("Doc");
let row = json!({"tenantId": "tB", "title": "x"});
let err = p
.check_write("Doc", &row, &auth_with_tenant("u1", "tA"))
.unwrap_err();
assert_eq!(err, TenantError::WrongTenant);
}
#[test]
fn rejects_write_to_legacy_unscoped_row() {
let mut p = TenantScopePlugin::new();
p.scope("Doc");
let row = json!({"title": "x"}); let err = p
.check_write("Doc", &row, &auth_with_tenant("u1", "tA"))
.unwrap_err();
assert_eq!(err, TenantError::WrongTenant);
}
#[test]
fn custom_field_name_used() {
let mut p = TenantScopePlugin::new();
p.scope_with_field("Doc", "orgId");
let mut data = json!({});
p.stamp_insert("Doc", &mut data, &auth_with_tenant("u1", "tA"))
.unwrap();
assert_eq!(data["orgId"], "tA");
assert!(data.get("tenantId").is_none());
}
}