use boatramp_core::config::HandlerGraphqlTokenClaims;
use boatramp_core::tenancy::{AccessMode, Tenancy, TenantSource};
use boatramp_handlers::HostTenancy;
#[derive(Default, Clone, Copy)]
pub(crate) struct TenantSourceInputs<'a> {
pub bearer: Option<&'a str>,
pub domain_context: Option<&'a str>,
pub token_cfg: Option<&'a HandlerGraphqlTokenClaims>,
}
#[derive(Clone, Copy)]
pub(crate) struct TenantPosture {
pub require_declaration: bool,
pub allow_cross_tenant: bool,
}
#[derive(Debug, Clone)]
pub(crate) struct TenancyUndeclared;
impl std::fmt::Display for TenancyUndeclared {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(
"tenancy: this function imports sql/orm but declares no tenancy decision; the \
multi-tenant posture requires an explicit `tenancy` (disabled or scoped)",
)
}
}
impl std::error::Error for TenancyUndeclared {}
pub(crate) async fn resolve_host_tenancy(
decision: Option<&Tenancy>,
imports_db: bool,
posture: TenantPosture,
inputs: TenantSourceInputs<'_>,
) -> Result<Option<HostTenancy>, TenancyUndeclared> {
match decision {
None => {
if imports_db && posture.require_declaration {
Err(TenancyUndeclared)
} else {
Ok(None)
}
}
Some(Tenancy::Disabled) => Ok(None),
Some(Tenancy::Scoped {
column,
source,
read,
write,
}) => {
let value = resolve_value(source, &inputs).await;
let read = cap(*read, posture.allow_cross_tenant);
let write = normalize_write(cap(*write, posture.allow_cross_tenant));
Ok(Some(HostTenancy::new(column.clone(), value, read, write)))
}
}
}
pub(crate) fn resolve_inherited_tenancy(
decision: Option<&Tenancy>,
imports_db: bool,
posture: TenantPosture,
inherited: Option<boatramp_core::sql::SqlValue>,
) -> Result<Option<HostTenancy>, TenancyUndeclared> {
match decision {
None => {
if imports_db && posture.require_declaration {
Err(TenancyUndeclared)
} else {
Ok(None)
}
}
Some(Tenancy::Disabled) => Ok(None),
Some(Tenancy::Scoped {
column,
read,
write,
..
}) => {
let read = cap(*read, posture.allow_cross_tenant);
let write = normalize_write(cap(*write, posture.allow_cross_tenant));
Ok(Some(HostTenancy::new(
column.clone(),
inherited,
read,
write,
)))
}
}
}
fn cap(mode: AccessMode, allow_cross_tenant: bool) -> AccessMode {
if mode == AccessMode::All && !allow_cross_tenant {
AccessMode::Own
} else {
mode
}
}
fn normalize_write(mode: AccessMode) -> AccessMode {
if mode == AccessMode::OwnOrNull {
AccessMode::Own
} else {
mode
}
}
async fn resolve_value(
source: &TenantSource,
inputs: &TenantSourceInputs<'_>,
) -> Option<boatramp_core::sql::SqlValue> {
match source {
TenantSource::Token { claim } => {
#[cfg(feature = "oidc")]
{
let (cfg, bearer) = (inputs.token_cfg?, inputs.bearer?);
let claims = crate::graphql_data::token::verified_claims(cfg, bearer).await?;
claims.get(claim).and_then(scalar_to_sql)
}
#[cfg(not(feature = "oidc"))]
{
let _ = (
claim,
inputs.bearer,
inputs.token_cfg,
inputs.domain_context,
);
None
}
}
TenantSource::Domain => inputs
.domain_context
.filter(|c| !c.is_empty())
.map(|c| boatramp_core::sql::SqlValue::Text(c.to_string())),
TenantSource::SignedContext => None,
TenantSource::None => None,
}
}
#[cfg(feature = "oidc")]
fn scalar_to_sql(v: &serde_json::Value) -> Option<boatramp_core::sql::SqlValue> {
use boatramp_core::sql::SqlValue;
match v {
serde_json::Value::String(s) => Some(SqlValue::Text(s.clone())),
serde_json::Value::Bool(b) => Some(SqlValue::Boolean(*b)),
serde_json::Value::Number(n) if n.is_i64() => Some(SqlValue::Integer(n.as_i64().unwrap())),
serde_json::Value::Number(n) => Some(SqlValue::Text(n.to_string())),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use boatramp_core::sql::SqlValue;
fn posture(require: bool, cross: bool) -> TenantPosture {
TenantPosture {
require_declaration: require,
allow_cross_tenant: cross,
}
}
#[test]
fn inherited_invoke_uses_the_caller_value_with_the_callee_grant() {
let decision = Tenancy::Scoped {
column: "tenant_id".into(),
source: TenantSource::None, read: AccessMode::OwnOrNull,
write: AccessMode::All,
};
let ht = resolve_inherited_tenancy(
Some(&decision),
true,
posture(true, false),
Some(SqlValue::Text("caller-tenant".into())),
)
.unwrap()
.unwrap();
let read = ht
.orm_scope(boatramp_handlers::TenantAxis::Read)
.unwrap()
.unwrap();
assert_eq!(read.value, SqlValue::Text("caller-tenant".into()));
assert_eq!(read.mode, boatramp_core::orm::ScopeMode::OwnOrNull);
let write = ht
.orm_scope(boatramp_handlers::TenantAxis::Write)
.unwrap()
.unwrap();
assert_eq!(write.mode, boatramp_core::orm::ScopeMode::Own);
let ht = resolve_inherited_tenancy(Some(&decision), true, posture(true, false), None)
.unwrap()
.unwrap();
assert!(ht.orm_scope(boatramp_handlers::TenantAxis::Read).is_err());
}
#[tokio::test]
async fn undeclared_db_importer_is_refused_only_under_the_strict_posture() {
assert!(resolve_host_tenancy(
None,
true,
posture(true, false),
TenantSourceInputs::default()
)
.await
.is_err());
assert!(matches!(
resolve_host_tenancy(
None,
false,
posture(true, false),
TenantSourceInputs::default()
)
.await,
Ok(None)
));
assert!(matches!(
resolve_host_tenancy(
None,
true,
posture(false, true),
TenantSourceInputs::default()
)
.await,
Ok(None)
));
}
#[tokio::test]
async fn disabled_is_plain() {
let out = resolve_host_tenancy(
Some(&Tenancy::Disabled),
true,
posture(true, false),
TenantSourceInputs::default(),
)
.await
.unwrap();
assert!(out.is_none());
}
#[tokio::test]
async fn domain_source_binds_the_context_tag() {
let decision = Tenancy::Scoped {
column: "tenant_id".into(),
source: TenantSource::Domain,
read: AccessMode::Own,
write: AccessMode::Own,
};
let inputs = TenantSourceInputs {
domain_context: Some("acme-store"),
..Default::default()
};
let ht = resolve_host_tenancy(Some(&decision), true, posture(true, false), inputs)
.await
.unwrap()
.unwrap();
let scope = ht
.orm_scope(boatramp_handlers::TenantAxis::Read)
.unwrap()
.unwrap();
assert_eq!(scope.value, SqlValue::Text("acme-store".into()));
}
#[tokio::test]
async fn all_is_capped_to_own_unless_the_posture_opens_it() {
let decision = Tenancy::Scoped {
column: "tenant_id".into(),
source: TenantSource::Domain,
read: AccessMode::All,
write: AccessMode::All,
};
let inputs = TenantSourceInputs {
domain_context: Some("acme"),
..Default::default()
};
let ht = resolve_host_tenancy(Some(&decision), true, posture(true, false), inputs)
.await
.unwrap()
.unwrap();
assert!(ht
.orm_scope(boatramp_handlers::TenantAxis::Read)
.unwrap()
.is_some());
let ht = resolve_host_tenancy(Some(&decision), true, posture(true, true), inputs)
.await
.unwrap()
.unwrap();
assert!(ht
.orm_scope(boatramp_handlers::TenantAxis::Read)
.unwrap()
.is_none());
}
#[tokio::test]
async fn own_plus_null_write_degrades_to_own_never_the_shared_baseline() {
let decision = Tenancy::Scoped {
column: "tenant_id".into(),
source: TenantSource::Domain,
read: AccessMode::OwnOrNull,
write: AccessMode::OwnOrNull,
};
let inputs = TenantSourceInputs {
domain_context: Some("acme"),
..Default::default()
};
let ht = resolve_host_tenancy(Some(&decision), true, posture(true, false), inputs)
.await
.unwrap()
.unwrap();
let read = ht
.orm_scope(boatramp_handlers::TenantAxis::Read)
.unwrap()
.unwrap();
let write = ht
.orm_scope(boatramp_handlers::TenantAxis::Write)
.unwrap()
.unwrap();
assert_eq!(read.mode, boatramp_core::orm::ScopeMode::OwnOrNull);
assert_eq!(write.mode, boatramp_core::orm::ScopeMode::Own);
}
#[tokio::test]
async fn own_source_without_inputs_resolves_to_no_value_then_fails_closed() {
let decision = Tenancy::Scoped {
column: "tenant_id".into(),
source: TenantSource::Domain,
read: AccessMode::Own,
write: AccessMode::Own,
};
let ht = resolve_host_tenancy(
Some(&decision),
true,
posture(true, false),
TenantSourceInputs::default(),
)
.await
.unwrap()
.unwrap();
assert!(ht.orm_scope(boatramp_handlers::TenantAxis::Read).is_err());
}
}