use std::collections::{BTreeMap, BTreeSet};
use serde::de::{self, Deserializer, MapAccess, SeqAccess, Visitor};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
#[derive(Default)]
pub enum TenantSource {
Token {
#[serde(default = "default_tid_claim")]
claim: String,
},
Domain,
SignedContext,
#[default]
None,
}
fn default_tid_claim() -> String {
"tid".to_string()
}
fn default_sources() -> Vec<TenantSource> {
vec![TenantSource::None]
}
fn de_sources<'de, D>(deserializer: D) -> Result<Vec<TenantSource>, D::Error>
where
D: Deserializer<'de>,
{
struct SourcesVisitor;
impl<'de> Visitor<'de> for SourcesVisitor {
type Value = Vec<TenantSource>;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("a TenantSource map or a list of TenantSource maps")
}
fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
let mut out = Vec::new();
while let Some(s) = seq.next_element::<TenantSource>()? {
out.push(s);
}
Ok(out)
}
fn visit_map<A: MapAccess<'de>>(self, map: A) -> Result<Self::Value, A::Error> {
let s = TenantSource::deserialize(de::value::MapAccessDeserializer::new(map))?;
Ok(vec![s])
}
}
deserializer.deserialize_any(SourcesVisitor)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum TargetSource {
Domain,
Handle,
Capability,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default)]
#[serde(tag = "scope", rename_all = "snake_case")]
#[non_exhaustive]
pub enum TenancyClass {
#[default]
Own,
Target {
via: Vec<TargetSource>,
public: String,
#[serde(default)]
write: Vec<String>,
},
}
impl TenancyClass {
pub fn is_target(&self) -> bool {
matches!(self, Self::Target { .. })
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum PublicTerm {
Cmp {
column: String,
op: PublicCmp,
value: PublicLiteral,
},
Null { column: String, negated: bool },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PublicCmp {
Eq,
Ne,
Lt,
Le,
Gt,
Ge,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PublicLiteral {
Bool(bool),
Int(i64),
Text(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct PublicPredicate {
pub terms: Vec<PublicTerm>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(default, deny_unknown_fields)]
pub struct PublicSubset {
pub predicate: PublicPredicate,
pub world_public: bool,
pub listable: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ScopeAxis {
Tenant,
Session,
TargetTenant,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AccessMode {
None,
Null,
Own,
OwnOrNull,
All,
}
impl AccessMode {
pub fn is_cross_tenant(self) -> bool {
matches!(self, Self::All)
}
pub fn needs_own_value(self) -> bool {
matches!(self, Self::Own | Self::OwnOrNull)
}
pub fn within(self, ceiling: Self) -> bool {
use AccessMode::*;
match self {
None => true, Null => matches!(ceiling, Null | OwnOrNull | All),
Own => matches!(ceiling, Own | OwnOrNull | All),
OwnOrNull => matches!(ceiling, OwnOrNull | All),
All => matches!(ceiling, All),
}
}
}
fn default_own() -> AccessMode {
AccessMode::Own
}
pub fn de_opt_tenancy<'de, D>(deserializer: D) -> Result<Option<Tenancy>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::Deserialize;
match Option::<ron::Value>::deserialize(deserializer)? {
Some(value) => value
.into_rust::<Tenancy>()
.map(Some)
.map_err(serde::de::Error::custom),
None => Ok(None),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "mode", rename_all = "snake_case")]
pub enum Tenancy {
Disabled,
Scoped {
column: String,
#[serde(
default = "default_sources",
alias = "source",
deserialize_with = "de_sources"
)]
sources: Vec<TenantSource>,
#[serde(default = "default_own")]
read: AccessMode,
#[serde(default = "default_own")]
write: AccessMode,
},
Target {
via: Vec<TargetSource>,
public: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
write: Vec<String>,
},
}
impl Tenancy {
pub fn is_scoped(&self) -> bool {
matches!(self, Self::Scoped { .. } | Self::Target { .. })
}
pub fn is_target(&self) -> bool {
matches!(self, Self::Target { .. })
}
pub fn narrows_within(&self, ceiling: &Self) -> bool {
use Tenancy::*;
match (self, ceiling) {
(_, Disabled) => true,
(Disabled, _) => false,
(
Scoped {
column: c,
read: r,
write: w,
..
},
Scoped {
column: cc,
read: rc,
write: wc,
..
},
) => c == cc && r.within(*rc) && w.within(*wc),
(Target { .. }, Target { .. }) => true,
_ => false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
#[non_exhaustive]
pub enum TableScope {
Tenant,
TenantKeyed { key: String },
Unscoped,
TenantOrSession,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ResolvedScope {
Column(String),
Unscoped,
TenantOrSession {
tenant: String,
session: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct TenancySchema {
pub default_tenant_key: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_key: Option<String>,
pub tables: BTreeMap<String, TableScope>,
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
pub target_eligible_fields: BTreeSet<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub public_subsets: BTreeMap<String, PublicSubset>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub handles: BTreeMap<String, String>,
}
impl Default for TenancySchema {
fn default() -> Self {
Self {
default_tenant_key: "tenant_id".to_string(),
session_key: None,
tables: BTreeMap::new(),
target_eligible_fields: BTreeSet::new(),
public_subsets: BTreeMap::new(),
handles: BTreeMap::new(),
}
}
}
impl TenancySchema {
pub fn deny_all() -> Self {
Self {
default_tenant_key: "tenant_id".to_string(),
session_key: None,
tables: BTreeMap::new(),
target_eligible_fields: BTreeSet::new(),
public_subsets: BTreeMap::new(),
handles: BTreeMap::new(),
}
}
pub fn target_field_eligible(&self, field: &str) -> bool {
self.target_eligible_fields.contains(field)
}
pub fn subset_is_world_public(&self, subset: &str) -> bool {
self.public_subsets
.get(subset)
.is_some_and(|s| s.world_public)
}
pub fn resolve_handle(&self, slug: &str) -> Option<&str> {
self.handles.get(slug).map(String::as_str)
}
pub fn public_subset(&self, table: &str) -> Option<&PublicSubset> {
self.public_subsets.get(table)
}
pub fn validate(&self) -> Result<(), String> {
for (table, subset) in &self.public_subsets {
if subset.predicate.terms.is_empty() {
return Err(format!(
"public subset for table `{table}` has an empty predicate (would match every \
row, defeating the target-read confinement) — declare at least one visibility \
term (e.g. `published = true`)"
));
}
}
Ok(())
}
pub fn resolve(&self, table: &str) -> Option<ResolvedScope> {
match self.tables.get(table)? {
TableScope::Tenant => Some(ResolvedScope::Column(self.default_tenant_key.clone())),
TableScope::TenantKeyed { key } => Some(ResolvedScope::Column(key.clone())),
TableScope::Unscoped => Some(ResolvedScope::Unscoped),
TableScope::TenantOrSession => Some(ResolvedScope::TenantOrSession {
tenant: self.default_tenant_key.clone(),
session: self.session_key.clone()?,
}),
}
}
pub fn table_key_map(&self) -> BTreeMap<String, ResolvedScope> {
self.tables
.iter()
.filter_map(|(table, scope)| {
let resolved = match scope {
TableScope::Tenant => ResolvedScope::Column(self.default_tenant_key.clone()),
TableScope::TenantKeyed { key } => ResolvedScope::Column(key.clone()),
TableScope::Unscoped => ResolvedScope::Unscoped,
TableScope::TenantOrSession => ResolvedScope::TenantOrSession {
tenant: self.default_tenant_key.clone(),
session: self.session_key.clone()?, },
};
Some((table.clone(), resolved))
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn schema_resolves_per_table_key_and_denies_undeclared() {
let schema: TenancySchema = serde_json::from_str(
r#"{"default_tenant_key":"tenant_id","tables":{
"orders":{"kind":"tenant"},
"tenant":{"kind":"tenant_keyed","key":"id"},
"countries":{"kind":"unscoped"}}}"#,
)
.unwrap();
assert_eq!(
schema.resolve("orders"),
Some(ResolvedScope::Column("tenant_id".into()))
);
assert_eq!(
schema.resolve("tenant"),
Some(ResolvedScope::Column("id".into())) );
assert_eq!(schema.resolve("countries"), Some(ResolvedScope::Unscoped));
assert_eq!(schema.resolve("secrets_table"), None); }
#[test]
fn schema_default_is_tenant_id_no_tables() {
let s = TenancySchema::default();
assert_eq!(s.default_tenant_key, "tenant_id");
assert!(s.tables.is_empty());
assert_eq!(s.resolve("orders"), None);
}
#[test]
fn table_scope_roundtrips_through_json() {
for ts in [
TableScope::Tenant,
TableScope::TenantKeyed { key: "id".into() },
TableScope::Unscoped,
TableScope::TenantOrSession,
] {
let j = serde_json::to_string(&ts).unwrap();
assert_eq!(ts, serde_json::from_str::<TableScope>(&j).unwrap());
}
}
#[test]
fn tenant_or_session_needs_a_session_key_else_denies() {
let s = TenancySchema {
default_tenant_key: "tenant_id".into(),
session_key: Some("session_id".into()),
tables: BTreeMap::from([("carts".into(), TableScope::TenantOrSession)]),
..Default::default()
};
assert_eq!(
s.resolve("carts"),
Some(ResolvedScope::TenantOrSession {
tenant: "tenant_id".into(),
session: "session_id".into(),
})
);
let s = TenancySchema {
default_tenant_key: "tenant_id".into(),
session_key: None,
tables: BTreeMap::from([("carts".into(), TableScope::TenantOrSession)]),
..Default::default()
};
assert_eq!(s.resolve("carts"), None);
assert!(
!s.table_key_map().contains_key("carts"),
"a TenantOrSession table without a session_key must be omitted (denied), not scoped"
);
}
#[test]
fn scoped_defaults_are_own_own_none_source() {
let t: Tenancy = serde_json::from_str(r#"{"mode":"scoped","column":"tenant_id"}"#).unwrap();
assert_eq!(
t,
Tenancy::Scoped {
column: "tenant_id".into(),
sources: vec![TenantSource::None],
read: AccessMode::Own,
write: AccessMode::Own,
}
);
assert!(t.is_scoped());
}
#[test]
fn sources_accept_both_the_legacy_singular_and_the_stage2_list() {
let legacy: Tenancy = serde_json::from_str(
r#"{"mode":"scoped","column":"tenant_id","source":{"kind":"domain"}}"#,
)
.unwrap();
let Tenancy::Scoped { sources, .. } = &legacy else {
panic!("scoped")
};
assert_eq!(sources, &vec![TenantSource::Domain]);
let listed: Tenancy = serde_json::from_str(
r#"{"mode":"scoped","column":"tenant_id","sources":[{"kind":"token"},{"kind":"domain"}]}"#,
)
.unwrap();
let Tenancy::Scoped { sources, .. } = &listed else {
panic!("scoped")
};
assert_eq!(
sources,
&vec![
TenantSource::Token {
claim: "tid".into()
},
TenantSource::Domain
]
);
}
#[test]
fn disabled_is_an_explicit_decision() {
let t: Tenancy = serde_json::from_str(r#"{"mode":"disabled"}"#).unwrap();
assert_eq!(t, Tenancy::Disabled);
assert!(!t.is_scoped());
}
#[test]
fn token_source_defaults_the_claim_to_tid() {
let t: Tenancy = serde_json::from_str(
r#"{"mode":"scoped","column":"tenant_id","source":{"kind":"token"},"read":"own_or_null","write":"own"}"#,
)
.unwrap();
let Tenancy::Scoped { sources, read, .. } = t else {
panic!("scoped")
};
assert_eq!(
sources,
vec![TenantSource::Token {
claim: "tid".into()
}]
);
assert_eq!(read, AccessMode::OwnOrNull);
}
#[test]
fn access_mode_cross_tenant_and_own_value_flags() {
assert!(AccessMode::All.is_cross_tenant());
assert!(!AccessMode::Own.is_cross_tenant());
assert!(AccessMode::Own.needs_own_value());
assert!(AccessMode::OwnOrNull.needs_own_value());
assert!(!AccessMode::Null.needs_own_value());
assert!(!AccessMode::All.needs_own_value());
assert!(!AccessMode::None.needs_own_value());
}
#[test]
fn roundtrips_through_json() {
let t = Tenancy::Scoped {
column: "org_id".into(),
sources: vec![TenantSource::Domain],
read: AccessMode::OwnOrNull,
write: AccessMode::Own,
};
let s = serde_json::to_string(&t).unwrap();
assert_eq!(t, serde_json::from_str::<Tenancy>(&s).unwrap());
}
#[test]
fn tenancy_class_default_is_own_and_target_flag() {
assert_eq!(TenancyClass::default(), TenancyClass::Own);
assert!(!TenancyClass::Own.is_target());
let tgt = TenancyClass::Target {
via: vec![TargetSource::Domain, TargetSource::Handle],
public: "storefront".into(),
write: vec![],
};
assert!(tgt.is_target());
assert_eq!(
tgt,
serde_json::from_str(&serde_json::to_string(&tgt).unwrap()).unwrap()
);
}
#[test]
fn target_schema_facts_roundtrip_and_gate_deny_by_default() {
let mut schema = TenancySchema {
default_tenant_key: "tenant_id".into(),
tables: BTreeMap::from([("products".into(), TableScope::Tenant)]),
..Default::default()
};
schema
.target_eligible_fields
.insert("publicProducts".into());
schema.public_subsets.insert(
"products".into(),
PublicSubset {
predicate: PublicPredicate {
terms: vec![
PublicTerm::Cmp {
column: "published".into(),
op: PublicCmp::Eq,
value: PublicLiteral::Bool(true),
},
PublicTerm::Null {
column: "deleted_at".into(),
negated: false,
},
],
},
world_public: true,
listable: true,
},
);
assert!(schema.target_field_eligible("publicProducts"));
assert!(!schema.target_field_eligible("secretOrders"));
assert!(schema.public_subset("products").unwrap().world_public);
assert!(schema.public_subset("orders").is_none());
let s = serde_json::to_string(&schema).unwrap();
assert_eq!(schema, serde_json::from_str::<TenancySchema>(&s).unwrap());
}
#[test]
fn validate_rejects_an_empty_public_predicate() {
let mut schema = TenancySchema::default();
schema.public_subsets.insert(
"products".into(),
PublicSubset {
predicate: PublicPredicate {
terms: vec![PublicTerm::Cmp {
column: "published".into(),
op: PublicCmp::Eq,
value: PublicLiteral::Bool(true),
}],
},
world_public: true,
listable: true,
},
);
assert!(schema.validate().is_ok());
schema.public_subsets.insert(
"orders".into(),
PublicSubset {
predicate: PublicPredicate { terms: vec![] },
world_public: true,
listable: false,
},
);
let err = schema.validate().unwrap_err();
assert!(
err.contains("orders") && err.contains("empty predicate"),
"got: {err}"
);
}
#[test]
fn a_pre_stage5_schema_deserializes_with_empty_target_facts() {
let legacy = r#"{"default_tenant_key":"tenant_id","tables":{"notes":{"kind":"tenant"}}}"#;
let schema: TenancySchema = serde_json::from_str(legacy).unwrap();
assert!(schema.target_eligible_fields.is_empty());
assert!(schema.public_subsets.is_empty());
assert!(!schema.target_field_eligible("anything"));
}
#[test]
fn access_mode_subset_lattice() {
use AccessMode::*;
for c in [None, Null, Own, OwnOrNull, All] {
assert!(None.within(c));
}
assert!(!Own.within(Null));
assert!(!Null.within(Own));
assert!(Own.within(Own) && Own.within(OwnOrNull) && Own.within(All));
assert!(Null.within(Null) && Null.within(OwnOrNull) && Null.within(All));
assert!(OwnOrNull.within(OwnOrNull) && OwnOrNull.within(All));
assert!(!OwnOrNull.within(Own) && !OwnOrNull.within(Null));
assert!(All.within(All));
assert!(!All.within(OwnOrNull) && !All.within(Own));
}
fn scoped(read: AccessMode, write: AccessMode) -> Tenancy {
Tenancy::Scoped {
column: "tenant_id".into(),
sources: vec![TenantSource::Token {
claim: "tid".into(),
}],
read,
write,
}
}
#[test]
fn tenancy_narrows_within_ceiling() {
use AccessMode::*;
assert!(scoped(Own, Own).narrows_within(&scoped(All, All)));
assert!(scoped(All, Own).narrows_within(&scoped(All, All)));
assert!(scoped(All, All).narrows_within(&scoped(All, All)));
assert!(!scoped(All, Own).narrows_within(&scoped(Own, Own)));
assert!(!scoped(Own, All).narrows_within(&scoped(Own, Own)));
assert!(!Tenancy::Scoped {
column: "org_id".into(),
sources: vec![TenantSource::None],
read: Own,
write: Own,
}
.narrows_within(&scoped(All, All)));
}
#[test]
fn tenancy_disabled_widening_is_refused() {
use AccessMode::*;
assert!(scoped(Own, Own).narrows_within(&Tenancy::Disabled));
assert!(Tenancy::Disabled.narrows_within(&Tenancy::Disabled));
assert!(!Tenancy::Disabled.narrows_within(&scoped(Own, Own)));
let target = Tenancy::Target {
via: vec![TargetSource::Domain],
public: "storefront".into(),
write: vec![],
};
assert!(!target.narrows_within(&scoped(All, All)));
assert!(!scoped(Own, Own).narrows_within(&target));
assert!(target.narrows_within(&target));
}
#[test]
fn de_opt_tenancy_bridges_ron_and_json() {
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct W {
#[serde(default, deserialize_with = "de_opt_tenancy")]
tenancy: Option<Tenancy>,
}
let ron_opts = ron::Options::default()
.with_default_extension(ron::extensions::Extensions::IMPLICIT_SOME);
let scoped_ron: W = ron_opts
.from_str(
r#"(tenancy: (mode: "scoped", column: "tenant_id",
sources: [(kind: "token", claim: "tid"), (kind: "signed_context")],
read: "all", write: "own"))"#,
)
.expect("scoped RON parses via the bridge");
let scoped_json: W = serde_json::from_str(
r#"{"tenancy":{"mode":"scoped","column":"tenant_id",
"sources":[{"kind":"token","claim":"tid"},{"kind":"signed_context"}],
"read":"all","write":"own"}}"#,
)
.expect("scoped JSON parses via the bridge");
assert_eq!(scoped_ron.tenancy, scoped_json.tenancy);
match scoped_ron.tenancy.unwrap() {
Tenancy::Scoped {
column,
sources,
read,
write,
} => {
assert_eq!(column, "tenant_id");
assert_eq!(
sources,
vec![
TenantSource::Token {
claim: "tid".into()
},
TenantSource::SignedContext
]
);
assert_eq!(read, AccessMode::All);
assert_eq!(write, AccessMode::Own);
}
other => panic!("expected scoped, got {other:?}"),
}
let target_ron: W = ron_opts
.from_str(
r#"(tenancy: (mode: "target", via: ["domain"], public: "storefront",
write: ["status"]))"#,
)
.expect("target RON parses");
match target_ron.tenancy.unwrap() {
Tenancy::Target { via, public, write } => {
assert_eq!(via, vec![TargetSource::Domain]);
assert_eq!(public, "storefront");
assert_eq!(write, vec!["status".to_string()]);
}
other => panic!("expected target, got {other:?}"),
}
let disabled: W = ron_opts
.from_str(r#"(tenancy: (mode: "disabled"))"#)
.expect("disabled RON parses");
assert_eq!(disabled.tenancy, Some(Tenancy::Disabled));
let absent: W = ron_opts.from_str(r#"()"#).expect("absent parses");
assert_eq!(absent.tenancy, None);
}
}