use super::schema::{Column, DbSchema, Table};
use boatramp_core::sql::SqlValue;
use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Clone, Default)]
pub(crate) struct Claims(BTreeMap<String, SqlValue>);
impl Claims {
pub(crate) fn new(map: BTreeMap<String, SqlValue>) -> Self {
Self(map)
}
pub(crate) fn get(&self, name: &str) -> Option<&SqlValue> {
self.0.get(name)
}
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum RowValue {
Claim(String),
Literal(SqlValue),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RowOp {
Eq,
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct RowTerm {
pub column: String,
pub op: RowOp,
pub value: RowValue,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub(crate) struct RowPredicate {
pub terms: Vec<RowTerm>,
}
#[derive(Debug, Clone, Default)]
pub(crate) struct TablePolicy {
pub columns: BTreeSet<String>,
pub rows: Option<RowPredicate>,
pub resolvers: BTreeMap<String, String>,
}
impl TablePolicy {
pub(crate) fn columns<I, S>(columns: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
Self {
columns: columns.into_iter().map(Into::into).collect(),
rows: None,
resolvers: BTreeMap::new(),
}
}
pub(crate) fn with_rows(mut self, rows: RowPredicate) -> Self {
self.rows = Some(rows);
self
}
pub(crate) fn with_resolver(
mut self,
field: impl Into<String>,
function: impl Into<String>,
) -> Self {
self.resolvers.insert(field.into(), function.into());
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub(crate) enum PolicyError {
#[error("access requires the `{0}` claim, which the request does not carry")]
MissingClaim(String),
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct ResolvedTerm {
pub column: String,
pub op: RowOp,
pub value: SqlValue,
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct ResolvedFilter {
pub terms: Vec<ResolvedTerm>,
}
#[derive(Debug, Clone, Default)]
pub(crate) struct DataPolicy {
tables: BTreeMap<String, TablePolicy>,
}
impl DataPolicy {
pub(crate) fn new() -> Self {
Self::default()
}
pub(crate) fn with_table(mut self, name: impl Into<String>, policy: TablePolicy) -> Self {
self.tables.insert(name.into(), policy);
self
}
pub(crate) fn table(&self, name: &str) -> Option<&TablePolicy> {
self.tables.get(name)
}
pub(crate) fn is_table_exposed(&self, table: &str) -> bool {
self.tables.contains_key(table)
}
pub(crate) fn is_column_exposed(&self, table: &str, column: &str) -> bool {
self.tables
.get(table)
.is_some_and(|t| t.columns.contains(column))
}
pub(crate) fn delegated(&self, table: &str, field: &str) -> Option<&str> {
self.tables
.get(table)
.and_then(|t| t.resolvers.get(field))
.map(String::as_str)
}
pub(crate) fn project_schema(&self, schema: &DbSchema) -> DbSchema {
let tables = schema
.tables
.iter()
.filter_map(|t| self.project_table(t))
.collect();
DbSchema { tables }
}
fn project_table(&self, table: &Table) -> Option<Table> {
let policy = self.tables.get(&table.name)?;
let columns: Vec<Column> = table
.columns
.iter()
.filter(|c| policy.columns.contains(&c.name))
.cloned()
.collect();
if columns.is_empty() {
return None; }
let exposed = |name: &String| policy.columns.contains(name);
let primary_key = if !table.primary_key.is_empty() && table.primary_key.iter().all(exposed)
{
table.primary_key.clone()
} else {
Vec::new()
};
let foreign_keys = table
.foreign_keys
.iter()
.filter(|fk| fk.columns.iter().all(exposed))
.cloned()
.collect();
Some(Table {
name: table.name.clone(),
columns,
primary_key,
foreign_keys,
})
}
pub(crate) fn row_filter(
&self,
table: &str,
claims: &Claims,
) -> Result<Option<ResolvedFilter>, PolicyError> {
let Some(predicate) = self.tables.get(table).and_then(|t| t.rows.as_ref()) else {
return Ok(None);
};
let mut terms = Vec::with_capacity(predicate.terms.len());
for term in &predicate.terms {
let value = match &term.value {
RowValue::Literal(v) => v.clone(),
RowValue::Claim(name) => claims
.get(name)
.cloned()
.ok_or_else(|| PolicyError::MissingClaim(name.clone()))?,
};
terms.push(ResolvedTerm {
column: term.column.clone(),
op: term.op,
value,
});
}
Ok(Some(ResolvedFilter { terms }))
}
}
#[cfg(test)]
mod tests {
use super::super::schema::{Column, DbSchema, ForeignKey, ScalarType, Table};
use super::*;
fn schema() -> DbSchema {
DbSchema {
tables: vec![
Table {
name: "users".into(),
columns: vec![
Column {
name: "id".into(),
ty: ScalarType::Id,
nullable: false,
},
Column {
name: "name".into(),
ty: ScalarType::String,
nullable: true,
},
Column {
name: "tenant_id".into(),
ty: ScalarType::String,
nullable: false,
},
Column {
name: "secret".into(),
ty: ScalarType::String,
nullable: true,
},
],
primary_key: vec!["id".into()],
foreign_keys: vec![],
},
Table {
name: "audit".into(),
columns: vec![Column {
name: "id".into(),
ty: ScalarType::Id,
nullable: false,
}],
primary_key: vec!["id".into()],
foreign_keys: vec![ForeignKey {
columns: vec!["id".into()],
ref_table: "users".into(),
ref_columns: vec!["id".into()],
}],
},
],
}
}
fn policy() -> DataPolicy {
DataPolicy::new().with_table(
"users",
TablePolicy::columns(["id", "name"]).with_rows(RowPredicate {
terms: vec![RowTerm {
column: "tenant_id".into(),
op: RowOp::Eq,
value: RowValue::Claim("tenant".into()),
}],
}),
)
}
#[test]
fn deny_by_default_hides_unlisted_tables_and_columns() {
let p = policy();
assert!(p.is_table_exposed("users"));
assert!(!p.is_table_exposed("audit"));
assert!(p.is_column_exposed("users", "name"));
assert!(!p.is_column_exposed("users", "secret"));
assert!(!p.is_column_exposed("audit", "id"));
}
#[test]
fn project_schema_drops_unexposed_tables_and_columns() {
let projected = policy().project_schema(&schema());
assert_eq!(projected.tables.len(), 1);
let users = projected.table("users").unwrap();
let cols: Vec<&str> = users.columns.iter().map(|c| c.name.as_str()).collect();
assert_eq!(cols, vec!["id", "name"]); assert_eq!(users.primary_key, vec!["id".to_string()]);
}
#[test]
fn row_filter_binds_a_claim() {
let claims = Claims::new(BTreeMap::from([(
"tenant".to_string(),
SqlValue::Text("acme".into()),
)]));
let filter = policy().row_filter("users", &claims).unwrap().unwrap();
assert_eq!(
filter.terms,
vec![ResolvedTerm {
column: "tenant_id".into(),
op: RowOp::Eq,
value: SqlValue::Text("acme".into()),
}]
);
}
#[test]
fn row_filter_is_fail_closed_on_a_missing_claim() {
let err = policy()
.row_filter("users", &Claims::default())
.unwrap_err();
assert_eq!(err, PolicyError::MissingClaim("tenant".into()));
}
#[test]
fn a_table_with_no_predicate_resolves_to_no_filter() {
let p = DataPolicy::new().with_table("users", TablePolicy::columns(["id"]));
assert_eq!(p.row_filter("users", &Claims::default()).unwrap(), None);
}
}