use crate::names::NameOverrides;
use postrust_core::schema_cache::{SchemaCache, Table};
pub fn cache_for_role(
cache: &SchemaCache,
names: &NameOverrides,
role: &str,
backend: bool,
) -> SchemaCache {
let mut view = cache.clone();
view.tables.retain(|_, table| {
let Some(granted) = names.permissions(&table.schema, &table.name, role) else {
return false;
};
table.insertable = table.insertable
&& granted
.insert
.as_ref()
.is_some_and(|insert| backend || !insert.backend_only);
table.updatable = table.updatable && granted.update.is_some();
table.deletable = table.deletable && granted.delete.is_some();
let writes = table.insertable || table.updatable || table.deletable;
if granted.select.is_none() && !writes {
return false;
}
let (insertable, updatable) = (table.insertable, table.updatable);
table.columns.retain(|name, _| {
let read = granted
.select
.as_ref()
.is_some_and(|select| select.columns.allows(name));
let written = insertable
&& granted
.insert
.as_ref()
.is_some_and(|insert| insert.columns.allows(name))
|| updatable
&& granted
.update
.as_ref()
.is_some_and(|update| update.columns.allows(name));
read || written
});
table.computed_columns.retain(|name, _| {
let exposed = names
.computed_field(&table.schema, &table.name, name)
.unwrap_or(name);
granted
.select
.as_ref()
.is_some_and(|select| select.computed_fields.iter().any(|f| f == exposed))
});
let counts = granted
.select
.as_ref()
.is_some_and(|select| select.allow_aggregations);
!table.columns.is_empty() || !table.computed_columns.is_empty() || writes || counts
});
let readable: std::collections::HashSet<_> = view
.tables
.iter()
.filter(|(_, table)| {
names
.permissions(&table.schema, &table.name, role)
.is_some_and(|granted| reads_anything(granted, table))
})
.map(|(key, _)| key.clone())
.collect();
let enumerated: std::collections::HashSet<_> = names
.enum_tables()
.into_iter()
.map(|(schema, table)| postrust_core::api_request::QualifiedIdentifier::new(schema, table))
.collect();
view.relationships.retain(|(source, _), links| {
if !view.tables.contains_key(source) {
return false;
}
links.retain(|link| {
readable.contains(link.foreign_table()) || enumerated.contains(link.foreign_table())
});
!links.is_empty()
});
view
}
pub fn reads_anything(granted: &crate::names::RolePermissions, table: &Table) -> bool {
let Some(select) = &granted.select else {
return false;
};
!table.computed_columns.is_empty()
|| table.columns.keys().any(|name| select.columns.allows(name))
}
pub fn has_backend_only(names: &NameOverrides, role: &str) -> bool {
names.tables_with_permissions(role).any(|granted| {
granted
.insert
.as_ref()
.is_some_and(|insert| insert.backend_only)
})
}
#[derive(Clone, Copy)]
pub struct Caller<'a> {
pub role: Option<&'a str>,
pub session: &'a std::collections::HashMap<String, String>,
}
impl Caller<'_> {
pub fn unrestricted(&self) -> bool {
match self.role {
None => true,
Some(role) => role == postrust_auth::hasura::ADMIN_ROLE,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Fault {
NoPermission { role: String, table: String },
MissingSessionVariable(String),
}
impl Fault {
pub fn code(&self) -> &'static str {
match self {
Self::NoPermission { .. } => "permission-error",
Self::MissingSessionVariable(_) => "not-found",
}
}
}
impl std::fmt::Display for Fault {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NoPermission { role, table } => write!(
f,
"role \"{}\" has no permission to read \"{}\"",
role, table
),
Self::MissingSessionVariable(message) => f.write_str(message),
}
}
}
pub const CHECK_FAILED: &str = "check constraint of an insert/update permission has failed";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Verb {
Select,
Insert,
Update,
Delete,
}
impl Verb {
fn of<'a>(&self, granted: &'a crate::names::RolePermissions) -> Option<Grant<'a>> {
match self {
Self::Select => granted.select.as_ref().map(Grant::Select),
Self::Insert => granted.insert.as_ref().map(Grant::Insert),
Self::Update => granted.update.as_ref().map(Grant::Update),
Self::Delete => granted.delete.as_ref().map(Grant::Delete),
}
}
}
enum Grant<'a> {
Select(&'a crate::names::SelectPermission),
Insert(&'a crate::names::InsertPermission),
Update(&'a crate::names::UpdatePermission),
Delete(&'a crate::names::DeletePermission),
}
impl Grant<'_> {
fn filter(&self) -> Option<&serde_json::Value> {
match self {
Self::Select(p) => Some(&p.filter),
Self::Update(p) => Some(&p.filter),
Self::Delete(p) => Some(&p.filter),
Self::Insert(_) => None,
}
}
fn check(&self) -> Option<&serde_json::Value> {
match self {
Self::Insert(p) => Some(&p.check),
Self::Update(p) => match p.check.is_null() {
false => Some(&p.check),
true => Some(&p.filter),
},
Self::Select(_) | Self::Delete(_) => None,
}
}
fn set(&self) -> Option<&std::collections::HashMap<String, serde_json::Value>> {
match self {
Self::Insert(p) => Some(&p.set),
Self::Update(p) => Some(&p.set),
Self::Select(_) | Self::Delete(_) => None,
}
}
}
fn grant_for<'a>(
caller: &Caller<'_>,
names: &'a NameOverrides,
schema: &str,
table: &str,
verb: Verb,
) -> Result<Option<Grant<'a>>, Fault> {
if caller.unrestricted() {
return Ok(None);
}
let role = caller.role.unwrap_or_default();
match names
.permissions(schema, table, role)
.and_then(|granted| verb.of(granted))
{
Some(grant) => Ok(Some(grant)),
None => Err(Fault::NoPermission {
role: role.to_string(),
table: table.to_string(),
}),
}
}
pub fn row_filter(
caller: &Caller<'_>,
names: &NameOverrides,
schema: &str,
table: &str,
verb: Verb,
) -> Result<Option<serde_json::Value>, Fault> {
let Some(grant) = grant_for(caller, names, schema, table, verb)? else {
return Ok(None);
};
let Some(filter) = grant.filter() else {
return Ok(None);
};
permission_where(filter, caller.session, names, schema, table)
.map_err(Fault::MissingSessionVariable)
}
pub fn write_check(
caller: &Caller<'_>,
names: &NameOverrides,
schema: &str,
table: &str,
verb: Verb,
) -> Result<Option<serde_json::Value>, Fault> {
let Some(grant) = grant_for(caller, names, schema, table, verb)? else {
return Ok(None);
};
let Some(check) = grant.check() else {
return Ok(None);
};
permission_where(check, caller.session, names, schema, table)
.map_err(Fault::MissingSessionVariable)
}
pub fn presets(
caller: &Caller<'_>,
names: &NameOverrides,
schema: &str,
table: &str,
verb: Verb,
) -> Result<Vec<(String, serde_json::Value)>, Fault> {
let Some(grant) = grant_for(caller, names, schema, table, verb)? else {
return Ok(Vec::new());
};
let Some(set) = grant.set() else {
return Ok(Vec::new());
};
let mut filled = Vec::with_capacity(set.len());
for (column, value) in set {
let resolved = match value {
serde_json::Value::String(text) => match session_variable(text) {
Some(name) => {
resolve(name, caller.session, false).map_err(Fault::MissingSessionVariable)?
}
None => value.clone(),
},
other => other.clone(),
};
filled.push((column.clone(), resolved));
}
filled.sort_by(|a, b| a.0.cmp(&b.0));
Ok(filled)
}
pub fn read_predicate(
caller: &Caller<'_>,
names: &NameOverrides,
schema: &str,
table: &str,
) -> Result<Option<serde_json::Value>, Fault> {
row_filter(caller, names, schema, table, Verb::Select)
}
pub fn read_limit(
caller: &Caller<'_>,
names: &NameOverrides,
schema: &str,
table: &str,
) -> Option<i64> {
if caller.unrestricted() {
return None;
}
names
.permissions(schema, table, caller.role?)
.and_then(|granted| granted.select.as_ref())
.and_then(|select| select.limit)
.map(|limit| limit as i64)
}
pub fn permission_where(
filter: &serde_json::Value,
session: &std::collections::HashMap<String, String>,
names: &NameOverrides,
schema: &str,
table: &str,
) -> Result<Option<serde_json::Value>, String> {
let rewritten = rewrite(filter, session, names, schema, table, None)?;
Ok(match rewritten {
serde_json::Value::Object(map) if map.is_empty() => None,
serde_json::Value::Null => None,
value => Some(value),
})
}
fn connective(key: &str) -> Option<&'static str> {
match key {
"$and" | "_and" => Some("_and"),
"$or" | "_or" => Some("_or"),
"$not" | "_not" => Some("_not"),
_ => None,
}
}
fn takes_a_list(operator: &str) -> bool {
matches!(operator, "_in" | "_nin" | "_has_keys_any" | "_has_keys_all")
}
fn rewrite(
value: &serde_json::Value,
session: &std::collections::HashMap<String, String>,
names: &NameOverrides,
schema: &str,
table: &str,
operator: Option<&str>,
) -> Result<serde_json::Value, String> {
match value {
serde_json::Value::Object(map) if operator.is_some() => {
let mut out = serde_json::Map::with_capacity(map.len());
for (key, child) in map {
out.insert(
key.clone(),
rewrite(child, session, names, schema, table, operator)?,
);
}
Ok(serde_json::Value::Object(out))
}
serde_json::Value::Object(map) => {
let mut out = serde_json::Map::with_capacity(map.len());
for (key, child) in map {
if let Some(spelling) = connective(key) {
out.insert(
spelling.to_string(),
rewrite(child, session, names, schema, table, None)?,
);
continue;
}
let key = match key.strip_prefix('$') {
Some(rest) if !rest.is_empty() => std::borrow::Cow::Owned(format!("_{}", rest)),
_ => std::borrow::Cow::Borrowed(key.as_str()),
};
if key == "_exists" {
out.insert(
"_exists".to_string(),
rewrite_exists(child, session, names, schema)?,
);
continue;
}
if key.starts_with('_') {
out.insert(
key.to_string(),
rewrite(child, session, names, schema, table, Some(&key))?,
);
continue;
}
let exposed = names
.column(schema, table, &key)
.unwrap_or(&key)
.to_string();
let rewritten = rewrite(child, session, names, schema, table, None)?;
let wrapped = match &rewritten {
serde_json::Value::Object(_) => rewritten,
scalar => serde_json::json!({ "_eq": scalar }),
};
out.insert(exposed, wrapped);
}
Ok(serde_json::Value::Object(out))
}
serde_json::Value::Array(items) => items
.iter()
.map(|item| rewrite(item, session, names, schema, table, operator))
.collect::<Result<Vec<_>, _>>()
.map(serde_json::Value::Array),
serde_json::Value::String(text) => match session_variable(text) {
Some(name) => resolve(name, session, operator.is_some_and(takes_a_list)),
None => Ok(value.clone()),
},
other => Ok(other.clone()),
}
}
pub fn exists_target(spec: &serde_json::Value, default_schema: &str) -> Option<(String, String)> {
match spec.get("_table")? {
serde_json::Value::String(name) => Some((default_schema.to_string(), name.clone())),
serde_json::Value::Object(qualified) => {
let name = qualified.get("name").and_then(|v| v.as_str())?;
let schema = qualified
.get("schema")
.and_then(|v| v.as_str())
.unwrap_or(default_schema);
Some((schema.to_string(), name.to_string()))
}
_ => None,
}
}
fn rewrite_exists(
spec: &serde_json::Value,
session: &std::collections::HashMap<String, String>,
names: &NameOverrides,
default_schema: &str,
) -> Result<serde_json::Value, String> {
let Some(map) = spec.as_object() else {
return Err("\"_exists\" takes a table and a predicate over it".to_string());
};
let Some((schema, table)) = exists_target(spec, default_schema) else {
return Err("\"_exists\" needs a \"_table\" to look in".to_string());
};
let mut out = serde_json::Map::with_capacity(2);
if let Some(named) = map.get("_table") {
out.insert("_table".to_string(), named.clone());
}
let predicate = map
.get("_where")
.cloned()
.unwrap_or_else(|| serde_json::json!({}));
out.insert(
"_where".to_string(),
rewrite(&predicate, session, names, &schema, &table, None)?,
);
Ok(serde_json::Value::Object(out))
}
fn session_variable(text: &str) -> Option<String> {
let lowered = text.to_ascii_lowercase();
let bare = lowered.strip_prefix("x-hasura-")?;
match bare.is_empty() {
true => None,
false => Some(bare.replace('-', "_")),
}
}
fn resolve(
name: String,
session: &std::collections::HashMap<String, String>,
as_a_list: bool,
) -> Result<serde_json::Value, String> {
let Some(value) = session.get(&name) else {
return Err(format!(
"missing session variable: \"x-hasura-{}\"",
name.replace('_', "-")
));
};
if !as_a_list {
return Ok(serde_json::Value::String(value.clone()));
}
let inner = value
.strip_prefix('{')
.and_then(|rest| rest.strip_suffix('}'));
Ok(match inner {
None => serde_json::json!([value]),
Some("") => serde_json::json!([]),
Some(items) => serde_json::Value::Array(
items
.split(',')
.map(|item| serde_json::Value::String(item.trim().trim_matches('"').to_string()))
.collect(),
),
})
}
pub fn allows_aggregations(names: &NameOverrides, schema: &str, table: &str, role: &str) -> bool {
names
.permissions(schema, table, role)
.and_then(|granted| granted.select.as_ref())
.is_some_and(|select| select.allow_aggregations)
}
#[cfg(test)]
mod tests {
use super::*;
use postrust_core::schema_cache::{Column, Table};
use postrust_core::QualifiedIdentifier;
use std::collections::{HashMap, HashSet};
fn column(name: &str, position: i32) -> Column {
Column {
name: name.to_string(),
description: None,
nullable: false,
data_type: "text".to_string(),
nominal_type: "text".to_string(),
max_len: None,
default: None,
enum_values: vec![],
is_pk: name == "id",
position,
domain_type: None,
always_generated: false,
}
}
fn table(name: &str, columns: &[&str]) -> Table {
Table {
schema: "public".to_string(),
name: name.to_string(),
description: None,
is_view: false,
insertable: true,
updatable: true,
deletable: true,
pk_cols: vec!["id".to_string()],
unique_constraints: Vec::new(),
columns: columns
.iter()
.enumerate()
.map(|(at, c)| (c.to_string(), column(c, at as i32 + 1)))
.collect(),
computed_columns: Default::default(),
is_partitioned: false,
}
}
fn cache(tables: Vec<Table>) -> SchemaCache {
let mut map = HashMap::new();
for table in tables {
map.insert(table.qualified_identifier(), table);
}
SchemaCache {
tables: map,
relationships: HashMap::new(),
routines: HashMap::new(),
timezones: HashSet::new(),
media_handlers: HashMap::new(),
pg_version: 150000,
representations: Default::default(),
}
}
const DOCUMENT: &str = r#"{
"tables": {
"public.article": {
"permissions": {
"user": {
"select": {"columns": ["id", "title"], "filter": {},
"allow_aggregations": true},
"insert": {"columns": "*", "check": {}}
},
"reader": {
"select": {"columns": "*", "filter": {}}
},
"narrow": {
"select": {"columns": ["id", "title"], "filter": {}},
"insert": {"columns": ["title"], "check": {}}
}
}
},
"public.secret": {
"permissions": {
"reader": {"select": {"columns": "*", "filter": {}}}
}
}
}
}"#;
fn names() -> NameOverrides {
NameOverrides::parse(DOCUMENT).unwrap()
}
#[test]
fn a_table_the_role_was_told_nothing_about_is_not_there() {
let view = cache_for_role(
&cache(vec![table("article", &["id"]), table("secret", &["id"])]),
&names(),
"user",
false,
);
assert!(view
.tables
.contains_key(&QualifiedIdentifier::new("public", "article")));
assert!(!view
.tables
.contains_key(&QualifiedIdentifier::new("public", "secret")));
}
#[test]
fn a_column_outside_the_permission_is_absent_rather_than_null() {
let view = cache_for_role(
&cache(vec![table("article", &["id", "title", "content"])]),
&names(),
"narrow",
false,
);
let article = &view.tables[&QualifiedIdentifier::new("public", "article")];
assert!(article.has_column("title"));
assert!(!article.has_column("content"));
}
#[test]
fn a_column_the_role_may_write_and_not_read_is_kept_for_the_write() {
let view = cache_for_role(
&cache(vec![table("article", &["id", "title", "content"])]),
&names(),
"user",
false,
);
let article = &view.tables[&QualifiedIdentifier::new("public", "article")];
assert!(article.has_column("content"));
}
#[test]
fn a_wildcard_keeps_every_column() {
let view = cache_for_role(
&cache(vec![table("article", &["id", "title", "content"])]),
&names(),
"reader",
false,
);
let article = &view.tables[&QualifiedIdentifier::new("public", "article")];
assert_eq!(article.columns.len(), 3);
}
#[test]
fn a_write_the_role_was_not_granted_is_not_a_root_field() {
let view = cache_for_role(
&cache(vec![table("article", &["id"])]),
&names(),
"user",
false,
);
let article = &view.tables[&QualifiedIdentifier::new("public", "article")];
assert!(article.insertable);
assert!(!article.updatable);
assert!(!article.deletable);
}
#[test]
fn a_permission_cannot_grant_a_write_the_database_refuses() {
let mut read_only = table("article", &["id"]);
read_only.insertable = false;
let view = cache_for_role(&cache(vec![read_only]), &names(), "user", false);
let article = &view.tables[&QualifiedIdentifier::new("public", "article")];
assert!(!article.insertable);
}
#[test]
fn a_computed_field_is_granted_under_the_name_it_is_exposed_as() {
let mut table = table("student_marks", &["id", "name"]);
table.computed_columns.insert(
"student_total_marks".to_string(),
postrust_core::schema_cache::ComputedColumn {
function: postrust_core::api_request::QualifiedIdentifier::new(
"public",
"student_total_marks",
),
data_type: "integer".to_string(),
description: None,
row_argument: None,
session_argument: None,
takes_arguments: false,
},
);
let cache = cache(vec![table]);
let names = NameOverrides::parse(
r#"{"tables": {"public.student_marks": {
"computed_fields": {"student_total_marks": "total_marks"},
"permissions": {"tutor": {"select": {
"columns": ["name"], "computed_fields": ["total_marks"], "filter": {}}}}}}}"#,
)
.unwrap();
let view = cache_for_role(&cache, &names, "tutor", false);
let table = view
.get_table(&postrust_core::api_request::QualifiedIdentifier::new(
"public",
"student_marks",
))
.expect("the table survives");
assert!(
table.computed_columns.contains_key("student_total_marks"),
"granted under the name the client sees"
);
}
#[test]
fn a_computed_field_is_granted_one_at_a_time() {
let mut article = table("article", &["id"]);
article.computed_columns.insert(
"word_count".to_string(),
postrust_core::schema_cache::ComputedColumn {
function: QualifiedIdentifier::new("public", "word_count"),
data_type: "integer".to_string(),
description: None,
row_argument: None,
session_argument: None,
takes_arguments: false,
},
);
let view = cache_for_role(&cache(vec![article]), &names(), "user", false);
let article = &view.tables[&QualifiedIdentifier::new("public", "article")];
assert!(article.computed_columns.is_empty());
}
#[test]
fn a_table_that_can_only_be_written_keeps_the_write_and_gets_no_type() {
let names = NameOverrides::parse(
r#"{"tables": {"public.article": {"permissions":
{"writer": {"insert": {"columns": "*", "check": {}}}}}}}"#,
)
.unwrap();
let view = cache_for_role(
&cache(vec![table("article", &["id"])]),
&names,
"writer",
false,
);
let article = &view.tables[&QualifiedIdentifier::new("public", "article")];
assert!(article.insertable);
assert!(!article.updatable);
assert!(!article.deletable);
assert!(article.has_column("id"));
let granted = names.permissions("public", "article", "writer").unwrap();
assert!(!reads_anything(granted, article));
}
#[test]
fn a_key_to_an_enum_table_survives_a_role_that_cannot_read_it() {
let users = table("users", &["id", "favorite_color"]);
let colors = table("colors", &["value"]);
let mut base = cache(vec![users, colors]);
base.relationships.insert(
(
postrust_core::api_request::QualifiedIdentifier::new("public", "users"),
"public".to_string(),
),
vec![postrust_core::schema_cache::Relationship::ForeignKey {
table: postrust_core::api_request::QualifiedIdentifier::new("public", "users"),
foreign_table: postrust_core::api_request::QualifiedIdentifier::new(
"public", "colors",
),
is_self: false,
cardinality: postrust_core::schema_cache::Cardinality::M2O {
constraint: "users_favorite_color_fkey".to_string(),
columns: vec![("favorite_color".to_string(), "value".to_string())],
},
table_is_view: false,
foreign_table_is_view: false,
constraint_name: "users_favorite_color_fkey".to_string(),
}],
);
let names = NameOverrides::parse(
r#"{"tables": {
"public.colors": {"enum": true},
"public.users": {"permissions": {"anonymous":
{"select": {"columns": "*", "filter": {}}}}}}}"#,
)
.unwrap();
let view = cache_for_role(&base, &names, "anonymous", false);
assert!(
!view
.tables
.contains_key(&postrust_core::api_request::QualifiedIdentifier::new(
"public", "colors"
)),
"the table itself is not readable"
);
assert_eq!(
view.relationships.len(),
1,
"and the key to it is kept anyway: {:?}",
view.relationships
);
}
#[test]
fn a_relationship_pointing_at_a_table_the_role_may_only_write_is_dropped() {
let names = NameOverrides::parse(
r#"{"tables": {
"public.article": {"permissions":
{"writer": {"select": {"columns": "*", "filter": {}}}}},
"public.author": {"permissions":
{"writer": {"insert": {"columns": "*", "check": {}}}}}}}"#,
)
.unwrap();
let mut base = cache(vec![table("article", &["id"]), table("author", &["id"])]);
base.relationships.insert(
(
QualifiedIdentifier::new("public", "article"),
"public".to_string(),
),
vec![postrust_core::schema_cache::Relationship::ForeignKey {
table: QualifiedIdentifier::new("public", "article"),
foreign_table: QualifiedIdentifier::new("public", "author"),
is_self: false,
cardinality: postrust_core::schema_cache::Cardinality::M2O {
constraint: "article_author_id_fkey".to_string(),
columns: vec![("author_id".to_string(), "id".to_string())],
},
table_is_view: false,
foreign_table_is_view: false,
constraint_name: "article_author_id_fkey".to_string(),
}],
);
let view = cache_for_role(&base, &names, "writer", false);
assert!(view
.tables
.contains_key(&QualifiedIdentifier::new("public", "author")));
assert!(view.relationships.is_empty());
}
#[test]
fn an_operand_that_is_an_object_is_carried_through_whole() {
let filter = serde_json::json!({
"geom_col": {"$st_d_within": {
"distance": 1,
"from": {"type": "Polygon", "coordinates": [[[2, 0], [2, 1]]]}
}}
});
let rewritten = permission_where(
&filter,
&HashMap::new(),
&NameOverrides::default(),
"public",
"geom_table",
)
.expect("it rewrites")
.expect("it restricts something");
assert_eq!(
rewritten,
serde_json::json!({
"geom_col": {"_st_d_within": {
"distance": 1,
"from": {"type": "Polygon", "coordinates": [[[2, 0], [2, 1]]]}
}}
}),
"only the operator's own spelling changes"
);
}
#[test]
fn a_session_variable_inside_an_operand_is_still_resolved() {
let mut session = HashMap::new();
session.insert("geom_val".to_string(), "{\"type\":\"Point\"}".to_string());
let filter = serde_json::json!({
"geom_col": {"$st_d_within": {"distance": 1, "from": "X-Hasura-Geom-Val"}}
});
let rewritten = permission_where(
&filter,
&session,
&NameOverrides::default(),
"public",
"geom_table",
)
.expect("it rewrites")
.expect("it restricts something");
assert_eq!(
rewritten["geom_col"]["_st_d_within"]["from"],
serde_json::json!("{\"type\":\"Point\"}")
);
}
#[test]
fn exists_keeps_its_table_and_rewrites_its_predicate() {
let session = session(&[("user_id", "2")]);
let rewritten = compiled(
serde_json::json!({
"_exists": {"_table": "user",
"_where": {"id": "X-Hasura-User-Id", "is_admin": true}}
}),
&session,
);
assert_eq!(
rewritten,
serde_json::json!({
"_exists": {"_table": "user",
"_where": {"id": {"_eq": "2"},
"is_admin": {"_eq": true}}}
})
);
}
#[test]
fn exists_names_its_table_either_way() {
assert_eq!(
exists_target(&serde_json::json!({"_table": "user"}), "public"),
Some(("public".to_string(), "user".to_string()))
);
assert_eq!(
exists_target(
&serde_json::json!({"_table": {"schema": "auth", "name": "user"}}),
"public"
),
Some(("auth".to_string(), "user".to_string()))
);
assert_eq!(exists_target(&serde_json::json!({}), "public"), None);
}
fn session(pairs: &[(&str, &str)]) -> HashMap<String, String> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
fn compiled(filter: serde_json::Value, session: &HashMap<String, String>) -> serde_json::Value {
permission_where(
&filter,
session,
&NameOverrides::default(),
"public",
"article",
)
.unwrap()
.unwrap()
}
#[test]
fn equality_written_without_saying_so_becomes_an_operator() {
assert_eq!(
compiled(serde_json::json!({"is_published": true}), &session(&[])),
serde_json::json!({"is_published": {"_eq": true}})
);
}
#[test]
fn the_older_connectives_are_the_newer_ones() {
let filter = serde_json::json!({
"$or": [{"author_id": "X-HASURA-USER-ID"}, {"is_published": true}]
});
assert_eq!(
compiled(filter, &session(&[("user_id", "1")])),
serde_json::json!({"_or": [
{"author_id": {"_eq": "1"}},
{"is_published": {"_eq": true}}
]})
);
}
#[test]
fn the_older_operator_spellings_are_the_newer_ones_too() {
assert_eq!(
compiled(
serde_json::json!({"id": {"$in": "X-Hasura-Allowed-Ids"}}),
&session(&[("allowed_ids", "{1,2}")])
),
serde_json::json!({"id": {"_in": ["1", "2"]}})
);
assert_eq!(
compiled(serde_json::json!({"age": {"$gt": 18}}), &session(&[])),
serde_json::json!({"age": {"_gt": 18}})
);
}
#[test]
fn a_session_variable_is_read_however_it_is_spelled() {
let by_shouting = compiled(
serde_json::json!({"id": {"_eq": "X-HASURA-USER-ID"}}),
&session(&[("user_id", "7")]),
);
let by_title = compiled(
serde_json::json!({"id": {"_eq": "X-Hasura-User-Id"}}),
&session(&[("user_id", "7")]),
);
assert_eq!(by_shouting, by_title);
assert_eq!(by_shouting, serde_json::json!({"id": {"_eq": "7"}}));
}
#[test]
fn a_variable_the_caller_does_not_carry_is_refused() {
let error = permission_where(
&serde_json::json!({"id": "X-Hasura-User-Id"}),
&session(&[]),
&NameOverrides::default(),
"public",
"article",
)
.unwrap_err();
assert!(error.contains("x-hasura-user-id"), "unhelpful: {}", error);
}
#[test]
fn a_list_operator_reads_its_variable_as_a_list() {
assert_eq!(
compiled(
serde_json::json!({"name": {"_in": "X-Hasura-Free-Artists"}}),
&session(&[("free_artists", "{Ann,Bob}")])
),
serde_json::json!({"name": {"_in": ["Ann", "Bob"]}})
);
assert_eq!(
compiled(
serde_json::json!({"name": {"_in": "X-Hasura-Free-Artists"}}),
&session(&[("free_artists", "{}")])
),
serde_json::json!({"name": {"_in": []}})
);
assert_eq!(
compiled(
serde_json::json!({"name": {"_eq": "X-Hasura-Free-Artists"}}),
&session(&[("free_artists", "{Ann,Bob}")])
),
serde_json::json!({"name": {"_eq": "{Ann,Bob}"}})
);
}
#[test]
fn a_predicate_over_a_relationship_survives_unchanged() {
assert_eq!(
compiled(
serde_json::json!({"articles": {"is_published": {"_eq": true}}}),
&session(&[])
),
serde_json::json!({"articles": {"is_published": {"_eq": true}}})
);
}
#[test]
fn an_empty_filter_restricts_nothing() {
let none = permission_where(
&serde_json::json!({}),
&session(&[]),
&NameOverrides::default(),
"public",
"article",
)
.unwrap();
assert!(none.is_none());
}
#[test]
fn a_renamed_column_is_named_the_way_the_schema_exposes_it() {
let names =
NameOverrides::parse(r#"{"public.article": {"columns": {"author_id": "writtenBy"}}}"#)
.unwrap();
let compiled = permission_where(
&serde_json::json!({"author_id": "X-Hasura-User-Id"}),
&session(&[("user_id", "1")]),
&names,
"public",
"article",
)
.unwrap()
.unwrap();
assert_eq!(compiled, serde_json::json!({"writtenBy": {"_eq": "1"}}));
}
const WRITES: &str = r#"{
"tables": {"public.article": {"permissions": {"user": {
"select": {"columns": "*", "filter": {}},
"insert": {"columns": "*", "check": {"is_published": false},
"set": {"author_id": "X-Hasura-User-Id", "source": "web"},
"backend_only": true},
"update": {"columns": "*", "filter": {"author_id": "X-Hasura-User-Id"}},
"delete": {"filter": {"author_id": "X-Hasura-User-Id"}}
}}}}
}"#;
fn writer() -> (NameOverrides, HashMap<String, String>) {
(
NameOverrides::parse(WRITES).unwrap(),
session(&[("user_id", "3")]),
)
}
#[test]
fn each_verb_is_asked_about_its_own_grant() {
let (names, sess) = writer();
let caller = Caller {
role: Some("user"),
session: &sess,
};
assert_eq!(
row_filter(&caller, &names, "public", "article", Verb::Insert).unwrap(),
None
);
for verb in [Verb::Update, Verb::Delete] {
assert_eq!(
row_filter(&caller, &names, "public", "article", verb).unwrap(),
Some(serde_json::json!({"author_id": {"_eq": "3"}}))
);
}
}
#[test]
fn an_update_with_no_check_of_its_own_is_held_to_its_filter() {
let (names, sess) = writer();
let caller = Caller {
role: Some("user"),
session: &sess,
};
assert_eq!(
write_check(&caller, &names, "public", "article", Verb::Update).unwrap(),
Some(serde_json::json!({"author_id": {"_eq": "3"}}))
);
assert_eq!(
write_check(&caller, &names, "public", "article", Verb::Insert).unwrap(),
Some(serde_json::json!({"is_published": {"_eq": false}}))
);
}
#[test]
fn a_preset_resolves_a_session_variable_and_keeps_a_literal() {
let (names, sess) = writer();
let caller = Caller {
role: Some("user"),
session: &sess,
};
assert_eq!(
presets(&caller, &names, "public", "article", Verb::Insert).unwrap(),
vec![
("author_id".to_string(), serde_json::json!("3")),
("source".to_string(), serde_json::json!("web")),
]
);
}
#[test]
fn a_backend_only_insert_is_absent_until_a_backend_asks() {
let (names, _) = writer();
assert!(has_backend_only(&names, "user"));
let of = |backend| {
cache_for_role(
&cache(vec![table("article", &["id"])]),
&names,
"user",
backend,
)
.tables[&QualifiedIdentifier::new("public", "article")]
.insertable
};
assert!(!of(false));
assert!(of(true));
}
#[test]
fn aggregates_are_asked_for_separately_from_rows() {
let names = names();
assert!(allows_aggregations(&names, "public", "article", "user"));
assert!(!allows_aggregations(&names, "public", "article", "reader"));
assert!(!allows_aggregations(
&names, "public", "article", "stranger"
));
}
}