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()
}
#[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)
}
}
fn default_own() -> AccessMode {
AccessMode::Own
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "mode", rename_all = "snake_case")]
pub enum Tenancy {
Disabled,
Scoped {
column: String,
#[serde(default)]
source: TenantSource,
#[serde(default = "default_own")]
read: AccessMode,
#[serde(default = "default_own")]
write: AccessMode,
},
}
impl Tenancy {
pub fn is_scoped(&self) -> bool {
matches!(self, Self::Scoped { .. })
}
}
#[cfg(test)]
mod tests {
use super::*;
#[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(),
source: TenantSource::None,
read: AccessMode::Own,
write: AccessMode::Own,
}
);
assert!(t.is_scoped());
}
#[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 { source, read, .. } = t else {
panic!("scoped")
};
assert_eq!(
source,
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(),
source: 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());
}
}