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>,
pub session_cookie: Option<&'a str>,
pub session_anchor: Option<&'a boatramp_core::cose::TokenPublicKey>,
pub signed_context: Option<&'a str>,
pub context_anchor: Option<&'a boatramp_core::cose::TokenPublicKey>,
}
#[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,
sources,
read,
write,
}) => {
let mut facts = Vec::new();
if let Some(value) = resolve_from_sources(sources, &inputs).await {
facts.push(boatramp_handlers::ScopeFact {
axis: boatramp_core::tenancy::ScopeAxis::Tenant,
value,
});
}
if let Some(value) = resolve_session_fact(&inputs) {
facts.push(boatramp_handlers::ScopeFact {
axis: boatramp_core::tenancy::ScopeAxis::Session,
value,
});
}
let read = cap(*read, posture.allow_cross_tenant);
let write = normalize_write(cap(*write, posture.allow_cross_tenant));
Ok(Some(HostTenancy::from_facts(
column.clone(),
facts,
read,
write,
)))
}
Some(Tenancy::Target { .. }) => Err(TenancyUndeclared),
}
}
pub(crate) struct ResolvedTarget {
pub value: String,
pub write: Vec<String>,
pub context: std::collections::BTreeMap<String, String>,
}
#[allow(clippy::too_many_arguments)] pub(crate) fn resolve_target_via(
via: &[boatramp_core::tenancy::TargetSource],
route_public: &str,
route_write: &[String],
schema: &boatramp_core::tenancy::TenancySchema,
domain_context: Option<&str>,
capability: Option<&str>,
capability_anchor: Option<&boatramp_core::cose::TokenPublicKey>,
handle_slug: Option<&str>,
audience: &str,
now_unix: u64,
) -> Option<ResolvedTarget> {
use boatramp_core::tenancy::TargetSource;
for source in via {
match source {
TargetSource::Domain => {
if let Some(ctx) = domain_context.filter(|c| !c.is_empty()) {
return Some(ResolvedTarget {
value: ctx.to_string(),
write: route_write.to_vec(),
context: std::collections::BTreeMap::new(),
});
}
}
TargetSource::Capability => {
if let (Some(token), Some(anchor)) = (capability, capability_anchor) {
if let Ok(grant) =
boatramp_core::cose::verify_capability(token, anchor, now_unix, audience)
{
if grant.public == route_public {
return Some(ResolvedTarget {
value: grant.tenant,
write: route_write.to_vec(),
context: grant.context,
});
}
}
}
}
TargetSource::Handle => {
if let Some(ctx) = handle_slug
.filter(|s| !s.is_empty())
.filter(|_| schema.subset_is_world_public(route_public))
.and_then(|slug| schema.resolve_handle(slug))
{
return Some(ResolvedTarget {
value: ctx.to_string(),
write: Vec::new(), context: std::collections::BTreeMap::new(),
});
}
}
_ => {}
}
}
None
}
pub(crate) fn resolve_inherited_tenancy(
decision: Option<&Tenancy>,
imports_db: bool,
posture: TenantPosture,
inherited: Vec<boatramp_handlers::ScopeFact>,
) -> 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::from_facts(
column.clone(),
inherited,
read,
write,
)))
}
Some(Tenancy::Target { .. }) => Err(TenancyUndeclared),
}
}
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_from_sources(
sources: &[TenantSource],
inputs: &TenantSourceInputs<'_>,
) -> Option<boatramp_core::sql::SqlValue> {
for source in sources {
if let Some(value) = resolve_value(source, inputs).await {
return Some(value);
}
}
None
}
fn resolve_session_fact(inputs: &TenantSourceInputs<'_>) -> Option<boatramp_core::sql::SqlValue> {
let cookie = inputs.session_cookie?;
let anchor = inputs.session_anchor?;
let sid = boatramp_core::cose::verify_session(cookie, anchor, boatramp_core::time::now_unix())
.ok()?;
Some(boatramp_core::sql::SqlValue::Text(sid))
}
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 => {
let (env, anchor) = (inputs.signed_context?, inputs.context_anchor?);
let tenant =
boatramp_core::cose::verify_context(env, anchor, boatramp_core::time::now_unix())
.ok()?;
Some(boatramp_core::sql::SqlValue::Text(tenant))
}
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,
}
}
#[tokio::test]
async fn a_valid_session_cookie_resolves_the_session_fact() {
use boatramp_core::cose::{mint_session, LocalSigner, Signer, TokenAlg};
use boatramp_handlers::ScopeFact;
let signer = LocalSigner::generate(TokenAlg::Es256);
let anchor = signer.public_key();
let cookie = mint_session("sid-xyz", 3600, boatramp_core::time::now_unix(), &signer)
.await
.unwrap();
let decision = Tenancy::Scoped {
column: "tenant_id".into(),
sources: vec![TenantSource::Domain],
read: AccessMode::Own,
write: AccessMode::Own,
};
let inputs = TenantSourceInputs {
domain_context: Some("acme"),
session_cookie: Some(&cookie),
session_anchor: Some(&anchor),
..Default::default()
};
let ht = resolve_host_tenancy(Some(&decision), true, posture(true, false), inputs)
.await
.unwrap()
.unwrap();
let facts: Vec<&ScopeFact> = ht.facts().iter().collect();
assert!(
facts
.iter()
.any(|f| f.axis == boatramp_core::tenancy::ScopeAxis::Tenant
&& f.value == SqlValue::Text("acme".into())),
"the domain source resolved the tenant fact"
);
assert!(
facts
.iter()
.any(|f| f.axis == boatramp_core::tenancy::ScopeAxis::Session
&& f.value == SqlValue::Text("sid-xyz".into())),
"the valid cookie resolved the session fact"
);
let stranger = LocalSigner::generate(TokenAlg::Es256);
let forged = mint_session("sid-EVIL", 3600, boatramp_core::time::now_unix(), &stranger)
.await
.unwrap();
let inputs = TenantSourceInputs {
domain_context: Some("acme"),
session_cookie: Some(&forged),
session_anchor: Some(&anchor),
..Default::default()
};
let ht = resolve_host_tenancy(Some(&decision), true, posture(true, false), inputs)
.await
.unwrap()
.unwrap();
assert!(
!ht.facts()
.iter()
.any(|f| f.axis == boatramp_core::tenancy::ScopeAxis::Session),
"a cookie not signed by the fleet anchor resolves no session fact"
);
}
#[tokio::test]
async fn a_valid_signed_context_resolves_the_own_tenant_on_the_async_lane() {
use boatramp_core::cose::{mint_context, LocalSigner, Signer, TokenAlg};
use boatramp_core::tenancy::ScopeAxis;
let signer = LocalSigner::generate(TokenAlg::Es256);
let anchor = signer.public_key();
let envelope = mint_context("acme", 3600, boatramp_core::time::now_unix(), &signer)
.await
.unwrap();
let decision = Tenancy::Scoped {
column: "tenant_id".into(),
sources: vec![TenantSource::SignedContext],
read: AccessMode::Own,
write: AccessMode::Own,
};
let inputs = TenantSourceInputs {
signed_context: Some(&envelope),
context_anchor: Some(&anchor),
..Default::default()
};
let ht = resolve_host_tenancy(Some(&decision), true, posture(true, false), inputs)
.await
.unwrap()
.unwrap();
assert!(
ht.facts()
.iter()
.any(|f| f.axis == ScopeAxis::Tenant && f.value == SqlValue::Text("acme".into())),
"a valid signed context resolves the producer's tenant as the consumer's own fact"
);
let stranger = LocalSigner::generate(TokenAlg::Es256);
let forged = mint_context("evil", 3600, boatramp_core::time::now_unix(), &stranger)
.await
.unwrap();
let inputs = TenantSourceInputs {
signed_context: Some(&forged),
context_anchor: Some(&anchor),
..Default::default()
};
let ht = resolve_host_tenancy(Some(&decision), true, posture(true, false), inputs)
.await
.unwrap()
.unwrap();
assert!(
!ht.facts().iter().any(|f| f.axis == ScopeAxis::Tenant),
"an envelope not signed by the fleet anchor resolves no own tenant"
);
let inputs = TenantSourceInputs {
context_anchor: Some(&anchor),
..Default::default()
};
let ht = resolve_host_tenancy(Some(&decision), true, posture(true, false), inputs)
.await
.unwrap()
.unwrap();
assert!(
!ht.facts().iter().any(|f| f.axis == ScopeAxis::Tenant),
"no envelope ⇒ no own tenant (the async lane fails an own op closed)"
);
}
#[test]
fn inherited_invoke_uses_the_caller_value_with_the_callee_grant() {
let decision = Tenancy::Scoped {
column: "tenant_id".into(),
sources: vec![TenantSource::None], read: AccessMode::OwnOrNull,
write: AccessMode::All,
};
let ht = resolve_inherited_tenancy(
Some(&decision),
true,
posture(true, false),
vec![boatramp_handlers::ScopeFact {
axis: boatramp_core::tenancy::ScopeAxis::Tenant,
value: SqlValue::Text("caller-tenant".into()),
}],
)
.unwrap()
.unwrap();
let read = ht
.orm_scope(boatramp_handlers::TenantAxis::Read)
.unwrap()
.unwrap();
assert_eq!(read.value, Some(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), Vec::new())
.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(),
sources: vec![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, Some(SqlValue::Text("acme-store".into())));
}
#[tokio::test]
async fn via_resolves_first_applicable_source() {
use boatramp_core::cose::{mint_capability, LocalSigner, Signer, TokenAlg};
use boatramp_core::tenancy::{
PublicCmp, PublicLiteral, PublicPredicate, PublicSubset, PublicTerm, TargetSource,
TenancySchema,
};
let now = 1000;
let signer = LocalSigner::generate(TokenAlg::Es256);
let anchor = signer.public_key();
let write = vec!["title".to_string()];
let mk = |world_public: bool| PublicSubset {
predicate: PublicPredicate {
terms: vec![PublicTerm::Cmp {
column: "published".into(),
op: PublicCmp::Eq,
value: PublicLiteral::Bool(true),
}],
},
world_public,
listable: world_public,
};
let mut schema = TenancySchema::default();
schema.public_subsets.insert("products".into(), mk(true));
schema.public_subsets.insert("reviews".into(), mk(false));
schema.handles.insert("acme".into(), "tenant_B".into());
let cap = mint_capability(
"tenant_B",
"shop",
"products",
&Default::default(),
300,
now,
&signer,
)
.await
.unwrap();
let call = |via: &[TargetSource],
public: &str,
dom: Option<&str>,
capa: Option<&str>,
hnd: Option<&str>,
aud: &str| {
resolve_target_via(
via,
public,
&write,
&schema,
dom,
capa,
Some(&anchor),
hnd,
aud,
now,
)
};
let r = call(
&[TargetSource::Domain, TargetSource::Capability],
"products",
Some("acme-store"),
Some(cap.as_str()),
None,
"shop",
)
.unwrap();
assert_eq!(r.value, "acme-store");
assert_eq!(r.write, write);
let r = call(
&[TargetSource::Capability],
"products",
None,
Some(cap.as_str()),
None,
"shop",
)
.unwrap();
assert_eq!(r.value, "tenant_B");
assert_eq!(r.write, write);
assert!(call(
&[TargetSource::Capability],
"products",
None,
Some(cap.as_str()),
None,
"other"
)
.is_none());
assert!(call(
&[TargetSource::Capability],
"reviews",
None,
Some(cap.as_str()),
None,
"shop"
)
.is_none());
assert!(call(
&[TargetSource::Capability],
"products",
None,
Some("not-a-capability"),
None,
"shop"
)
.is_none());
let r = call(
&[TargetSource::Handle],
"products",
None,
None,
Some("acme"),
"shop",
)
.unwrap();
assert_eq!(r.value, "tenant_B");
assert!(
r.write.is_empty(),
"G1: a handle source is always read-only"
);
assert!(call(
&[TargetSource::Handle],
"products",
None,
None,
Some("ghost"),
"shop"
)
.is_none());
assert!(call(
&[TargetSource::Handle],
"reviews",
None,
None,
Some("acme"),
"shop"
)
.is_none());
assert!(call(
&[TargetSource::Handle],
"products",
None,
None,
None,
"shop"
)
.is_none());
assert!(call(
&[],
"products",
Some("acme-store"),
Some(cap.as_str()),
Some("acme"),
"shop"
)
.is_none());
}
#[tokio::test]
async fn all_is_capped_to_own_unless_the_posture_opens_it() {
let decision = Tenancy::Scoped {
column: "tenant_id".into(),
sources: vec![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(),
sources: vec![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(),
sources: vec![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());
}
#[tokio::test]
#[ignore = "run via the test-target-plain-wasm CI job on the host toolchain (static-musl libsql segfault)"]
async fn plain_wasm_target_confines_orm_and_raw_sql_to_b_public_on_a_real_engine() {
use boatramp_core::orm::{Expr, Select, SelectItem};
use boatramp_core::sql::{Dialect, SqlBackends, SqlValue};
use boatramp_core::tenancy::{
AccessMode, PublicCmp, PublicLiteral, PublicPredicate, PublicSubset, PublicTerm,
TableScope, TenancySchema,
};
use boatramp_handlers::{HostTenancy, TenantAxis};
use std::collections::BTreeMap;
let dir =
std::env::temp_dir().join(format!("boatramp-target-plainwasm-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let backends = boatramp_storage::LibsqlSqlBackends::local(&dir);
let db = backends.database("default", "shop", "").await.unwrap();
{
let mut tx = db.begin().await.unwrap();
tx.execute(
"CREATE TABLE products (id TEXT PRIMARY KEY, tenant_id TEXT, name TEXT, \
published INTEGER, deleted_at TEXT)",
&[],
)
.await
.unwrap();
for (id, tenant, name, published, deleted) in [
("b1", "tenant_B", "B public", 1i64, None),
("b2", "tenant_B", "B draft", 0, None),
("b3", "tenant_B", "B removed", 1, Some("2020-01-01")),
("a1", "tenant_A", "A public", 1, None),
] {
tx.execute(
"INSERT INTO products (id, tenant_id, name, published, deleted_at) \
VALUES (?1, ?2, ?3, ?4, ?5)",
&[
SqlValue::Text(id.into()),
SqlValue::Text(tenant.into()),
SqlValue::Text(name.into()),
SqlValue::Integer(published),
deleted.map_or(SqlValue::Null, |d: &str| SqlValue::Text(d.into())),
],
)
.await
.unwrap();
}
tx.commit().await.unwrap();
}
let mut schema = TenancySchema {
default_tenant_key: "tenant_id".into(),
tables: BTreeMap::from([("products".into(), TableScope::Tenant)]),
..Default::default()
};
schema.public_subsets.insert(
"products".into(),
PublicSubset {
predicate: PublicPredicate {
terms: vec![
PublicTerm::Cmp {
column: "published".into(),
op: PublicCmp::Eq,
value: PublicLiteral::Int(1),
},
PublicTerm::Null {
column: "deleted_at".into(),
negated: false,
},
],
},
world_public: true,
listable: true,
},
);
let ht = HostTenancy::target(
SqlValue::Text("tenant_B".into()),
AccessMode::Own,
&schema,
"products",
&[],
true,
);
let mut q = Select {
columns: vec![SelectItem {
expr: Expr::col("name"),
alias: None,
}],
..Select::from("products")
};
q.force_scope(&ht.orm_scope(TenantAxis::Read).unwrap().unwrap())
.unwrap();
let (sql, params) = q.compile(Dialect::Sqlite).unwrap();
let mut tx = db.begin().await.unwrap();
let orm_rows = run_text_rows(tx.as_mut(), &sql, ¶ms).await;
assert_eq!(
orm_rows,
vec!["B public".to_string()],
"orm target read returns ONLY B's published, non-deleted row: {sql}"
);
tx.commit().await.unwrap();
for (label, guest_sql) in [
("plain", "SELECT name FROM products"),
(
"or-escape",
"SELECT name FROM products WHERE 1 = 1 OR published = 0",
),
(
"self-join",
"SELECT p.name FROM products p JOIN products q ON q.id = p.id",
),
(
"subquery",
"SELECT name FROM products WHERE id IN (SELECT id FROM products)",
),
] {
let rewritten = ht
.rewrite_target_read(guest_sql, Dialect::Sqlite)
.unwrap_or_else(|e| panic!("{label}: rewrite refused: {}", e.reason()));
let mut tx = db.begin().await.unwrap();
let raw_rows = run_text_rows(tx.as_mut(), &rewritten, &[]).await;
assert_eq!(
raw_rows,
vec!["B public".to_string()],
"raw-SQL target read [{label}] returns ONLY B's published, non-deleted row: {rewritten}"
);
tx.commit().await.unwrap();
}
assert!(
ht.rewrite_target_read("SELECT name FROM orders", Dialect::Sqlite)
.is_err(),
"a target read of an undeclared table must be refused"
);
assert!(
ht.rewrite_target_read(
"WITH products AS (SELECT * FROM products WHERE tenant_id = 'tenant_A') \
SELECT name FROM products",
Dialect::Sqlite,
)
.is_err(),
"a self-named CTE must be refused, not passed through unconfined"
);
println!(
"PLAIN-WASM TARGET ISOLATION OK: a target route's orm AND raw-sql reads each return only \
tenant B's published+non-deleted rows (never tenant A's, never B's draft/removed) on a \
real libsql engine — the raw-SQL path is AST-rewritten so multi-table joins, subqueries, \
and OR-escapes are all confined, and an undeclared table is refused"
);
}
#[tokio::test]
#[ignore = "run via the test-target-plain-wasm CI job on the host toolchain (static-musl libsql segfault)"]
async fn plain_wasm_target_writes_confine_to_b_public_subset_on_a_real_engine() {
use boatramp_core::orm::{Assignment, CmpOp, Delete, Expr, Insert, Predicate, RowValues};
use boatramp_core::sql::{Dialect, SqlBackends, SqlValue};
use boatramp_core::tenancy::{
AccessMode, PublicCmp, PublicLiteral, PublicPredicate, PublicSubset, PublicTerm,
TableScope, TenancySchema,
};
use boatramp_handlers::{HostTenancy, TenantAxis};
use std::collections::BTreeMap;
let dir = std::env::temp_dir().join(format!(
"boatramp-target-plainwasm-write-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
let backends = boatramp_storage::LibsqlSqlBackends::local(&dir);
let db = backends.database("default", "shop", "").await.unwrap();
{
let mut tx = db.begin().await.unwrap();
tx.execute(
"CREATE TABLE products (id TEXT PRIMARY KEY, tenant_id TEXT, name TEXT, \
published INTEGER, deleted_at TEXT)",
&[],
)
.await
.unwrap();
for (id, tenant, name, published, deleted) in [
("b1", "tenant_B", "B public", 1i64, None),
("b2", "tenant_B", "B draft", 0, None),
("a1", "tenant_A", "A public", 1, None),
] {
tx.execute(
"INSERT INTO products (id, tenant_id, name, published, deleted_at) \
VALUES (?1, ?2, ?3, ?4, ?5)",
&[
SqlValue::Text(id.into()),
SqlValue::Text(tenant.into()),
SqlValue::Text(name.into()),
SqlValue::Integer(published),
deleted.map_or(SqlValue::Null, |d: &str| SqlValue::Text(d.into())),
],
)
.await
.unwrap();
}
tx.commit().await.unwrap();
}
let mut schema = TenancySchema {
default_tenant_key: "tenant_id".into(),
tables: BTreeMap::from([("products".into(), TableScope::Tenant)]),
..Default::default()
};
schema.public_subsets.insert(
"products".into(),
PublicSubset {
predicate: PublicPredicate {
terms: vec![
PublicTerm::Cmp {
column: "published".into(),
op: PublicCmp::Eq,
value: PublicLiteral::Int(1),
},
PublicTerm::Null {
column: "deleted_at".into(),
negated: false,
},
],
},
world_public: true,
listable: true,
},
);
let ht = HostTenancy::target(
SqlValue::Text("tenant_B".into()),
AccessMode::Own,
&schema,
"products",
&["name".to_string()],
true,
);
let write = ht.orm_scope(TenantAxis::Write).unwrap().unwrap();
let read = ht.orm_scope(TenantAxis::Read).unwrap().unwrap();
let mut ins = Insert {
table: "products".into(),
rows: vec![RowValues {
cells: vec![
Assignment {
column: "id".into(),
value: Expr::val(SqlValue::Text("new1".into())),
},
Assignment {
column: "name".into(),
value: Expr::val(SqlValue::Text("guest wrote".into())),
},
],
}],
conflict: None,
scope: None,
returning: vec![],
from_select: None,
};
assert!(
ins.force_scope(Some(&write), Some(&read)).is_err(),
"a target INSERT setting a non-allowlisted column (id) must be refused"
);
let mut ins = Insert {
table: "products".into(),
rows: vec![RowValues {
cells: vec![Assignment {
column: "name".into(),
value: Expr::val(SqlValue::Text("guest wrote".into())),
}],
}],
conflict: None,
scope: None,
returning: vec![],
from_select: None,
};
ins.force_scope(Some(&write), Some(&read)).unwrap();
let (sql, params) = ins.compile(Dialect::Sqlite).unwrap();
let mut tx = db.begin().await.unwrap();
tx.execute(&sql, ¶ms).await.unwrap();
tx.commit().await.unwrap();
let mut tx = db.begin().await.unwrap();
let rows = tx
.query(
"SELECT tenant_id, published, deleted_at FROM products WHERE name = 'guest wrote'",
&[],
)
.await
.unwrap();
tx.commit().await.unwrap();
assert_eq!(rows.rows.len(), 1, "exactly one inserted row");
let row = &rows.rows[0];
assert_eq!(
row[0],
SqlValue::Text("tenant_B".into()),
"tenant forced to B"
);
assert!(
matches!(row[1], SqlValue::Integer(1)),
"published forced to 1 (public)"
);
assert_eq!(row[2], SqlValue::Null, "deleted_at forced NULL (public)");
for (id, expected) in [("b2", 0u64), ("a1", 0), ("b1", 1)] {
let mut upd = boatramp_core::orm::Update {
table: "products".into(),
set: vec![Assignment {
column: "name".into(),
value: Expr::val(SqlValue::Text("RENAMED".into())),
}],
filter: Predicate::Cmp {
left: Expr::col("id"),
op: CmpOp::Eq,
right: Expr::val(SqlValue::Text(id.into())),
},
scope: None,
returning: vec![],
};
upd.force_scope(&write).unwrap();
let (sql, params) = upd.compile(Dialect::Sqlite).unwrap();
let mut tx = db.begin().await.unwrap();
let n = tx.execute(&sql, ¶ms).await.unwrap();
tx.commit().await.unwrap();
assert_eq!(
n, expected,
"UPDATE of {id}: expected {expected} rows affected (confined to B's public subset)"
);
}
let mut tx = db.begin().await.unwrap();
let names = run_text_rows(
tx.as_mut(),
"SELECT name FROM products WHERE id IN ('b1','b2','a1') ORDER BY id",
&[],
)
.await;
tx.commit().await.unwrap();
assert_eq!(
names,
vec![
"A public".to_string(),
"B draft".to_string(),
"RENAMED".to_string()
],
"only B's public row (b1) was renamed; B's draft + tenant A untouched"
);
let mut flip = boatramp_core::orm::Update {
table: "products".into(),
set: vec![Assignment {
column: "published".into(),
value: Expr::val(SqlValue::Integer(0)),
}],
filter: Predicate::Cmp {
left: Expr::col("id"),
op: CmpOp::Eq,
right: Expr::val(SqlValue::Text("b1".into())),
},
scope: None,
returning: vec![],
};
assert!(
flip.force_scope(&write).is_err(),
"a target UPDATE may not set a visibility column (published)"
);
let mut del = Delete {
table: "products".into(),
filter: Predicate::Cmp {
left: Expr::col("id"),
op: CmpOp::Eq,
right: Expr::val(SqlValue::Text("b1".into())),
},
scope: None,
returning: vec![],
};
assert!(
del.force_scope(&write).is_err(),
"a target DELETE is refused"
);
let ro = HostTenancy::target(
SqlValue::Text("tenant_B".into()),
AccessMode::Own,
&schema,
"products",
&[],
true,
);
assert!(
ro.orm_scope(TenantAxis::Write).is_err(),
"a read-only target route denies the write axis outright"
);
println!(
"PLAIN-WASM TARGET WRITE ISOLATION OK: a target route's orm writes land only in tenant \
B's public subset (INSERT force-stamps tenant=B + visibility; UPDATE confined to B's \
public rows, only the allowlisted column settable), and a DELETE / visibility-flip / \
read-only write are all refused, on a real libsql engine"
);
}
#[tokio::test]
#[ignore = "run via the test-target-plain-wasm CI job on the host toolchain (static-musl libsql segfault)"]
async fn plain_wasm_target_attach_reference_derives_tenant_from_a_reachable_parent_on_a_real_engine(
) {
use boatramp_core::orm::{compile_attach_reference, Assignment, AttachReference, Expr};
use boatramp_core::sql::{Dialect, SqlBackends, SqlValue};
use boatramp_core::tenancy::{
AccessMode, PublicCmp, PublicLiteral, PublicPredicate, PublicSubset, PublicTerm,
TableScope, TenancySchema,
};
use boatramp_handlers::{HostTenancy, TenantAxis};
use std::collections::BTreeMap;
let dir =
std::env::temp_dir().join(format!("boatramp-target-attachref-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let backends = boatramp_storage::LibsqlSqlBackends::local(&dir);
let db = backends.database("default", "shop", "").await.unwrap();
{
let mut tx = db.begin().await.unwrap();
tx.execute(
"CREATE TABLE products (id TEXT PRIMARY KEY, tenant_id TEXT, published INTEGER)",
&[],
)
.await
.unwrap();
tx.execute(
"CREATE TABLE favorites (id INTEGER PRIMARY KEY AUTOINCREMENT, tenant_id TEXT, \
product_id TEXT, note TEXT, visible INTEGER)",
&[],
)
.await
.unwrap();
for (id, tenant, published) in [
("b_pub", "tenant_B", 1i64),
("b_draft", "tenant_B", 0),
("a_pub", "tenant_A", 1),
] {
tx.execute(
"INSERT INTO products (id, tenant_id, published) VALUES (?1, ?2, ?3)",
&[
SqlValue::Text(id.into()),
SqlValue::Text(tenant.into()),
SqlValue::Integer(published),
],
)
.await
.unwrap();
}
tx.commit().await.unwrap();
}
let world = |col: &str, v: i64| PublicSubset {
predicate: PublicPredicate {
terms: vec![PublicTerm::Cmp {
column: col.into(),
op: PublicCmp::Eq,
value: PublicLiteral::Int(v),
}],
},
world_public: true,
listable: true,
};
let mut schema = TenancySchema {
default_tenant_key: "tenant_id".into(),
tables: BTreeMap::from([
("products".into(), TableScope::Tenant),
("favorites".into(), TableScope::Tenant),
]),
..Default::default()
};
schema
.public_subsets
.insert("products".into(), world("published", 1));
schema
.public_subsets
.insert("favorites".into(), world("visible", 1));
let ht = HostTenancy::target(
SqlValue::Text("tenant_B".into()),
AccessMode::Own,
&schema,
"products",
&["note".to_string(), "product_id".to_string()],
true,
);
let write = ht.orm_scope(TenantAxis::Write).unwrap().unwrap();
let attach = |product: &str| AttachReference {
child: "favorites".into(),
parent: "products".into(),
ref_column: "id".into(),
ref_value: SqlValue::Text(product.into()),
set: vec![
Assignment {
column: "product_id".into(),
value: Expr::val(SqlValue::Text(product.into())),
},
Assignment {
column: "note".into(),
value: Expr::val(SqlValue::Text("fav".into())),
},
],
};
for (product, expected) in [("b_pub", 1u64), ("b_draft", 0), ("a_pub", 0)] {
let (sql, params) =
compile_attach_reference(&write, &attach(product), Dialect::Sqlite).unwrap();
let mut tx = db.begin().await.unwrap();
let n = tx.execute(&sql, ¶ms).await.unwrap();
tx.commit().await.unwrap();
assert_eq!(
n, expected,
"attach_reference to {product}: expected {expected} inserts (reachable-parent gate)"
);
}
let mut tx = db.begin().await.unwrap();
let rows = tx
.query("SELECT tenant_id, product_id, visible FROM favorites", &[])
.await
.unwrap();
tx.commit().await.unwrap();
assert_eq!(
rows.rows.len(),
1,
"exactly one favorite inserted (only b_pub reachable)"
);
let row = &rows.rows[0];
assert_eq!(
row[0],
SqlValue::Text("tenant_B".into()),
"derived tenant = B (the parent's)"
);
assert_eq!(
row[1],
SqlValue::Text("b_pub".into()),
"references B's public product"
);
assert!(
matches!(row[2], SqlValue::Integer(1)),
"child visibility forced public"
);
println!(
"PLAIN-WASM TARGET ATTACH-REFERENCE ISOLATION OK: attach_reference stamped the child's \
tenant from B's reachable public parent (never tenant A, never B's draft) and was a \
fail-closed no-op for every unreachable parent, on a real libsql engine"
);
}
#[tokio::test]
#[ignore = "run via the test-target-plain-wasm CI job on the host toolchain (static-musl libsql segfault)"]
async fn plain_wasm_target_capability_confines_to_tenant_b_without_a_subset_on_a_real_engine() {
use boatramp_core::orm::{Expr, Select, SelectItem};
use boatramp_core::sql::{Dialect, SqlBackends, SqlValue};
use boatramp_core::tenancy::{AccessMode, TableScope, TenancySchema};
use boatramp_handlers::{HostTenancy, TenantAxis};
use std::collections::BTreeMap;
let dir =
std::env::temp_dir().join(format!("boatramp-target-capability-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let backends = boatramp_storage::LibsqlSqlBackends::local(&dir);
let db = backends.database("default", "shop", "").await.unwrap();
{
let mut tx = db.begin().await.unwrap();
tx.execute(
"CREATE TABLE invoices (id TEXT PRIMARY KEY, tenant_id TEXT, client_id TEXT, amount INTEGER)",
&[],
)
.await
.unwrap();
for (id, tenant, client, amount) in [
("b1", "tenant_B", "cli_1", 10i64),
("b2", "tenant_B", "cli_2", 20), ("a1", "tenant_A", "cli_9", 99),
] {
tx.execute(
"INSERT INTO invoices (id, tenant_id, client_id, amount) VALUES (?1, ?2, ?3, ?4)",
&[
SqlValue::Text(id.into()),
SqlValue::Text(tenant.into()),
SqlValue::Text(client.into()),
SqlValue::Integer(amount),
],
)
.await
.unwrap();
}
tx.commit().await.unwrap();
}
let schema = TenancySchema {
default_tenant_key: "tenant_id".into(),
tables: BTreeMap::from([("invoices".into(), TableScope::Tenant)]),
..Default::default()
};
let ht = HostTenancy::target(
SqlValue::Text("tenant_B".into()),
AccessMode::Own,
&schema,
"client-invoices", &[],
false, );
let mut q = Select {
columns: vec![SelectItem {
expr: Expr::col("id"),
alias: None,
}],
..Select::from("invoices")
};
q.force_scope(&ht.orm_scope(TenantAxis::Read).unwrap().unwrap())
.unwrap();
let (sql, params) = q.compile(Dialect::Sqlite).unwrap();
let mut tx = db.begin().await.unwrap();
let orm_rows = run_text_rows(tx.as_mut(), &sql, ¶ms).await;
assert_eq!(
orm_rows,
vec!["b1".to_string(), "b2".to_string()],
"capability orm read = ALL of tenant B (across clients), never A: {sql}"
);
tx.commit().await.unwrap();
assert!(
!sql.contains("published") && !sql.contains("client_id"),
"no visibility/per-client predicate injected — confinement is tenant-only: {sql}"
);
let rewritten = ht
.rewrite_target_read("SELECT id FROM invoices", Dialect::Sqlite)
.unwrap();
assert!(
rewritten.contains("invoices.tenant_id = 'tenant_B'")
&& !rewritten.contains("published"),
"raw-sql capability read confines tenant-only: {rewritten}"
);
let mut tx = db.begin().await.unwrap();
let raw_rows = run_text_rows(tx.as_mut(), &rewritten, &[]).await;
assert_eq!(
raw_rows,
vec!["b1".to_string(), "b2".to_string()],
"raw-sql capability read = tenant B only: {rewritten}"
);
tx.commit().await.unwrap();
println!(
"PLAIN-WASM CAPABILITY TARGET ISOLATION OK: a via:[capability]-only field with no declared \
public subset confines its orm AND raw-sql reads to tenant B alone (all of B's rows, \
never tenant A's; the per-client filter stays in-guest), on a real libsql engine"
);
}
#[tokio::test]
#[ignore = "run via the test-target-plain-wasm CI job on the host toolchain (static-musl libsql segfault)"]
async fn plain_wasm_target_capability_context_roundtrips_sub_on_a_real_engine() {
use boatramp_core::cose::{mint_capability, LocalSigner, Signer, TokenAlg};
use boatramp_core::sql::{Dialect, SqlBackends, SqlValue};
use boatramp_core::tenancy::{AccessMode, TableScope, TargetSource, TenancySchema};
use boatramp_handlers::HostTenancy;
use std::collections::BTreeMap;
let dir =
std::env::temp_dir().join(format!("boatramp-target-capctx-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let backends = boatramp_storage::LibsqlSqlBackends::local(&dir);
let db = backends.database("default", "shop", "").await.unwrap();
{
let mut tx = db.begin().await.unwrap();
tx.execute(
"CREATE TABLE invoices (id TEXT PRIMARY KEY, tenant_id TEXT, client_id TEXT, amount INTEGER)",
&[],
)
.await
.unwrap();
for (id, tenant, client, amount) in [
("b1", "tenant_B", "cli_1", 10i64),
("b2", "tenant_B", "cli_2", 20),
("a1", "tenant_A", "cli_1", 99), ] {
tx.execute(
"INSERT INTO invoices (id, tenant_id, client_id, amount) VALUES (?1, ?2, ?3, ?4)",
&[
SqlValue::Text(id.into()),
SqlValue::Text(tenant.into()),
SqlValue::Text(client.into()),
SqlValue::Integer(amount),
],
)
.await
.unwrap();
}
tx.commit().await.unwrap();
}
let signer = LocalSigner::generate(TokenAlg::Es256);
let anchor = signer.public_key();
let app_context = BTreeMap::from([("sub".to_string(), "cli_1".to_string())]);
let cap = mint_capability(
"tenant_B",
"shop",
"client-invoices",
&app_context,
300,
1000,
&signer,
)
.await
.unwrap();
let schema = TenancySchema {
default_tenant_key: "tenant_id".into(),
tables: BTreeMap::from([("invoices".into(), TableScope::Tenant)]),
..Default::default()
};
let rt = resolve_target_via(
&[TargetSource::Capability],
"client-invoices", &[], &schema,
None,
Some(cap.as_str()),
Some(&anchor),
None,
"shop",
1000,
)
.expect("the capability resolves at its own project");
assert_eq!(rt.value, "tenant_B", "redeem resolves tenant B");
assert_eq!(
rt.context.get("sub").map(String::as_str),
Some("cli_1"),
"the opaque sub is surfaced from the verified capability"
);
assert!(
resolve_target_via(
&[TargetSource::Capability],
"client-invoices",
&[],
&schema,
None,
Some(cap.as_str()),
Some(&anchor),
None,
"other-project",
1000,
)
.is_none(),
"a capability minted for `shop` must not resolve at `other-project`"
);
let ht = HostTenancy::target(
SqlValue::Text(rt.value.clone()),
AccessMode::Own,
&schema,
"client-invoices",
&rt.write,
false, )
.with_target_context(rt.context.clone());
let ctx = ht.target_context();
assert_eq!(ctx.get("sub").map(String::as_str), Some("cli_1"));
assert!(
!ctx.values().any(|v| v == "tenant_B") && !ctx.contains_key("tenant_id"),
"the read-back exposes ONLY the app-context, never the host-forced tenant B: {ctx:?}"
);
let unfiltered = ht
.rewrite_target_read("SELECT id FROM invoices", Dialect::Sqlite)
.unwrap();
let mut tx = db.begin().await.unwrap();
assert_eq!(
run_text_rows(tx.as_mut(), &unfiltered, &[]).await,
vec!["b1".to_string(), "b2".to_string()],
"host confines to tenant B across clients: {unfiltered}"
);
tx.commit().await.unwrap();
let sub = ctx.get("sub").unwrap();
let filtered = ht
.rewrite_target_read(
&format!("SELECT id FROM invoices WHERE client_id = '{sub}'"),
Dialect::Sqlite,
)
.unwrap();
assert!(
filtered.contains("invoices.tenant_id = 'tenant_B'") && filtered.contains("client_id"),
"both the host tenant floor and the guest client filter are present: {filtered}"
);
let mut tx = db.begin().await.unwrap();
assert_eq!(
run_text_rows(tx.as_mut(), &filtered, &[]).await,
vec!["b1".to_string()],
"the in-guest sub filter narrows to cli_1 WITHIN B (b1), never tenant A's a1: {filtered}"
);
tx.commit().await.unwrap();
println!(
"PLAIN-WASM CAPABILITY CONTEXT ROUNDTRIP OK: a capability carrying an opaque sub redeems to \
tenant B (inert cross-project), surfaces sub via target-context (never leaking B), the host \
confines the read to tenant B, and the guest's in-guest client_id = sub filter narrows to \
the one client within B (never tenant A's same-client_id row), on a real libsql engine"
);
}
async fn run_text_rows(
tx: &mut dyn boatramp_core::sql::SqlTransaction,
sql: &str,
params: &[boatramp_core::sql::SqlValue],
) -> Vec<String> {
let rows = tx.query(sql, params).await.expect("query runs");
let mut out: Vec<String> = rows
.rows
.into_iter()
.flatten()
.filter_map(|v| match v {
boatramp_core::sql::SqlValue::Text(s) => Some(s),
_ => None,
})
.collect();
out.sort();
out
}
}