use axum::http::request::Parts;
use crate::core::{Filter, ModelSchema, Op, SearchClause, SelectQuery, SqlValue, WhereExpr};
pub type QuerySetScopeFn = fn(&Parts) -> Vec<Filter>;
pub struct QuerySetScope {
pub table: &'static str,
pub hook: QuerySetScopeFn,
}
inventory::collect!(QuerySetScope);
#[must_use]
pub fn for_table(table: &str) -> Vec<&'static QuerySetScope> {
inventory::iter::<QuerySetScope>
.into_iter()
.filter(|h| h.table == table)
.collect()
}
pub(crate) enum ViewHookRows {
All,
Only(Filter),
TooMany,
}
pub(crate) async fn view_hook_rows(
pool: &crate::sql::Pool,
(parts, hooks): (&Parts, super::Hooks),
model: &'static ModelSchema,
(where_clause, search): (WhereExpr, Option<SearchClause>),
(cap, binds): (usize, usize),
) -> Result<ViewHookRows, crate::sql::ExecError> {
if !hooks.any(model.table, "view") {
return Ok(ViewHookRows::All);
}
let Some(pk_field) = model.primary_key() else {
return Ok(ViewHookRows::TooMany);
};
let fields: Vec<_> = model.scalar_fields().collect();
let rows = crate::sql::select_rows_as_json(
pool,
&SelectQuery {
where_clause,
search,
limit: Some(i64::try_from(cap).unwrap_or(i64::MAX).saturating_add(1)),
..SelectQuery::new(model)
},
&fields,
)
.await?;
if rows.len() > cap {
warn_scan_cap(model.table, cap);
return Ok(ViewHookRows::TooMany);
}
let (mut admitted, mut denied) = (Vec::new(), Vec::new());
for row in &rows {
let Some(pk) = row_pk(row, pk_field) else {
return Ok(ViewHookRows::TooMany);
};
if hooks.allows(model.table, "view", parts, Some(row)) {
admitted.push(pk);
} else {
denied.push(pk);
}
}
Ok(fold(model.table, pk_field, (admitted, denied), binds))
}
fn fold(
table: &'static str,
pk_field: &'static crate::core::FieldSchema,
(admitted, denied): (Vec<SqlValue>, Vec<SqlValue>),
binds: usize,
) -> ViewHookRows {
if denied.len().min(admitted.len()) > binds {
warn_scan_cap(table, binds);
return ViewHookRows::TooMany;
}
let filter = |op, value| Filter {
column: pk_field.column,
op,
value,
};
if denied.is_empty() {
ViewHookRows::All
} else if admitted.is_empty() {
ViewHookRows::Only(filter(Op::IsNull, SqlValue::Bool(true)))
} else if denied.len() <= admitted.len() {
ViewHookRows::Only(filter(Op::NotIn, SqlValue::List(denied)))
} else {
ViewHookRows::Only(filter(Op::In, SqlValue::List(admitted)))
}
}
pub(crate) fn row_pk(
row: &serde_json::Value,
pk_field: &'static crate::core::FieldSchema,
) -> Option<SqlValue> {
crate::admin::render::read_value_as_string_json(row, pk_field)
.and_then(|raw| crate::forms::parse_pk_string(pk_field, &raw).ok())
}
pub(crate) fn warn_scan_cap(table: &'static str, cap: usize) {
static WARNED: std::sync::Mutex<Vec<&'static str>> = std::sync::Mutex::new(Vec::new());
let mut warned = WARNED
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if !warned.contains(&table) {
warned.push(table);
tracing::warn!(
target: "rustango::object_permissions",
table,
cap,
"the \"view\" hook matches more rows than the scan cap: totals are hidden. \
Use register_queryset_scope! or raise the scan cap"
);
}
}
#[macro_export]
macro_rules! register_queryset_scope {
($table:expr, $hook:expr $(,)?) => {
$crate::inventory::submit! {
$crate::object_permissions::QuerySetScope {
table: $table,
hook: {
const _HOOK: $crate::object_permissions::QuerySetScopeFn = $hook;
_HOOK
},
}
}
};
}
#[deprecated(since = "0.62.0", note = "use `register_queryset_scope!`")]
#[macro_export]
macro_rules! register_admin_queryset {
($table:expr, $hook:expr $(,)?) => {
$crate::register_queryset_scope!($table, $hook);
};
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::Model as _;
fn ids(r: std::ops::Range<i64>) -> Vec<SqlValue> {
r.map(SqlValue::I64).collect()
}
#[test]
fn a_list_past_the_bind_budget_withholds_totals() {
let pk = crate::contenttypes::ContentType::SCHEMA
.primary_key()
.unwrap();
let t = "op_unit_fold";
assert!(matches!(
fold(t, pk, (ids(0..5), ids(5..8)), 2),
ViewHookRows::TooMany
));
let fits = fold(t, pk, (ids(0..5), ids(5..8)), 3);
assert!(matches!(
fits,
ViewHookRows::Only(Filter { op: Op::NotIn, .. })
));
let shorter = fold(t, pk, (ids(0..2), ids(2..8)), 3);
assert!(matches!(
shorter,
ViewHookRows::Only(Filter { op: Op::In, .. })
));
assert!(matches!(
fold(t, pk, (vec![], ids(0..8)), 0),
ViewHookRows::Only(Filter { op: Op::IsNull, .. })
));
assert!(matches!(
fold(t, pk, (ids(0..8), vec![]), 0),
ViewHookRows::All
));
}
}