Skip to main content

postrust_graphql/
role.rs

1//! What one role can see.
2//!
3//! Hasura builds a separate GraphQL schema for every role, and the difference
4//! between them is not cosmetic. A role with no `select` permission on a table
5//! does not get a table it is refused access to -- it gets a schema with no
6//! such field to read it by, and naming one is a validation failure rather
7//! than a denial. The same goes one level down: a column outside the
8//! permission is absent from the type, not merely null.
9//!
10//! That shape is what the corpus tests. `artist_select_query_Track_fail.yaml`
11//! expects `field "Track" not found in type: 'query_root'`, which no amount of
12//! filtering at execution time produces.
13//!
14//! # Why this is a filtered cache rather than a flag threaded through the
15//! builders
16//!
17//! A select permission is a view of a table, and a set of them is a view of
18//! the database. Reducing the schema cache to what a role may see and then
19//! building the schema from *that* means the builders need to know nothing
20//! about permissions: a table that was dropped generates no root field, no
21//! relationship pointing at it and no boolean expression naming it, because
22//! the code that would have generated them cannot see it either.
23//!
24//! The alternative -- asking "may this role?" at each of the several dozen
25//! places a name is emitted -- has to be right every time, and is wrong the
26//! moment someone adds a place. This has to be right once.
27//!
28//! # The one thing a cache cannot hold: two column sets
29//!
30//! Hasura grants reading and writing separately, so a role may be allowed to
31//! set a column it is not allowed to see -- ten permissions in the corpus do
32//! -- and may be allowed to write a table it cannot read at all, which eight
33//! more do. A schema cache has one column list per table and no place to say
34//! which half of it is which.
35//!
36//! The union goes in the cache, and the split is made once, where the object
37//! type's fields are built: [`crate::schema::object::TableObjectType::fields`]
38//! is what may be read and `writable_fields` is what may be written. That
39//! keeps the property above -- everything a read is made of (the type, its
40//! boolean expression, its ordering, its column enum, its aggregates) is
41//! derived from `fields` and narrows with it, and the write inputs are built
42//! from the other list and narrowed again by the permission that names them.
43//!
44//! A table with no readable field is the limit of that: it has no GraphQL type
45//! at all, because a type with no fields is not a legal one. Its bulk writes
46//! remain and answer with `affected_rows` alone -- there is no `returning` to
47//! hang rows on -- and everything that would return a row of it (the query
48//! roots, `insert_one`, the `_by_pk` writes, a relationship pointing at it, a
49//! function returning it) is left out with the type.
50//!
51//! What is left over goes on [`SchemaConfig`]: `allow_aggregations`, which is
52//! a fact about a permission rather than about a table.
53//!
54//! [`SchemaConfig`]: crate::schema::SchemaConfig
55
56use crate::names::NameOverrides;
57use postrust_core::schema_cache::{SchemaCache, Table};
58
59/// Reduce a schema cache to what one role may see.
60///
61/// Returns the cache unchanged when the document grants that role nothing to
62/// narrow -- which is the case for `admin`, and for every role when the
63/// document carries no permissions at all.
64pub fn cache_for_role(
65    cache: &SchemaCache,
66    names: &NameOverrides,
67    role: &str,
68    backend: bool,
69) -> SchemaCache {
70    let mut view = cache.clone();
71
72    view.tables.retain(|_, table| {
73        let Some(granted) = names.permissions(&table.schema, &table.name, role) else {
74            // Named nothing about this table. With a permission document in
75            // play that is a statement, not a silence: the role cannot see it.
76            return false;
77        };
78
79        // Which of the four grants apply, decided before the columns because
80        // the columns depend on the answer.
81        //
82        // `backend_only` narrows the insert once more: such a field is
83        // reachable only by a caller that proved it holds the admin secret and
84        // asked for it by name, so for everyone else it is not there at all --
85        // which is the point of the flag. A client must not be able to reach
86        // it by naming a role.
87        table.insertable = table.insertable
88            && granted
89                .insert
90                .as_ref()
91                .is_some_and(|insert| backend || !insert.backend_only);
92        table.updatable = table.updatable && granted.update.is_some();
93        table.deletable = table.deletable && granted.delete.is_some();
94        let writes = table.insertable || table.updatable || table.deletable;
95
96        // Reading is not what makes a table exist here, and that is the whole
97        // of this change. Hasura lets a role write a table it cannot read, and
98        // eight permissions in its corpus do exactly that: `insert_account`
99        // for `user`, `insert_leads` for `sales`, `insert_author` for
100        // `student`. Such a mutation answers `affected_rows` and has no
101        // `returning` to hang rows on, because there is no type to read the
102        // written row out of.
103        if granted.select.is_none() && !writes {
104            return false;
105        }
106
107        // The columns the role may read, together with the ones it may write
108        // without reading. Everything else is not merely unreadable but
109        // absent, which is what makes a permission a statement about the
110        // schema rather than about a request.
111        //
112        // This is the one place both column sets are held at once, and the
113        // reason they can be: a schema cache has a single column list, so the
114        // union goes in it and the narrower read set is applied once, where
115        // the object type's fields are built. Everything a read is made of --
116        // the type, its boolean expression, its ordering, its column enum, its
117        // aggregates -- is derived from those fields and narrows with them.
118        // The write inputs are built from the union instead and narrowed again
119        // by the write permission that names them.
120        let (insertable, updatable) = (table.insertable, table.updatable);
121        table.columns.retain(|name, _| {
122            let read = granted
123                .select
124                .as_ref()
125                .is_some_and(|select| select.columns.allows(name));
126            let written = insertable
127                && granted
128                    .insert
129                    .as_ref()
130                    .is_some_and(|insert| insert.columns.allows(name))
131                || updatable
132                    && granted
133                        .update
134                        .as_ref()
135                        .is_some_and(|update| update.columns.allows(name));
136            read || written
137        });
138
139        // A computed field runs a function over the whole row, so it can
140        // answer questions the column list was written to prevent. Hasura
141        // grants them one at a time and grants none by default -- and grants
142        // none at all to a role that cannot read the table, since there is
143        // nowhere for the answer to appear.
144        //
145        // Named as the client sees it. The map is keyed by the *function*,
146        // and a permission grants the *field* -- `student_total_marks` and
147        // `total_marks` are the same thing under the two names, and comparing
148        // them directly withheld every computed field a schema had renamed.
149        table.computed_columns.retain(|name, _| {
150            let exposed = names
151                .computed_field(&table.schema, &table.name, name)
152                .unwrap_or(name);
153            granted
154                .select
155                .as_ref()
156                .is_some_and(|select| select.computed_fields.iter().any(|f| f == exposed))
157        });
158
159        // A select permission naming no columns at all, with
160        // `allow_aggregations`, is how Hasura grants "how many" without
161        // granting "which": the table is there to be counted and has nothing
162        // to read. It gets an aggregate root and no row type, the same shape a
163        // table this role may only write has.
164        let counts = granted
165            .select
166            .as_ref()
167            .is_some_and(|select| select.allow_aggregations);
168
169        // Nothing to read, nothing to write and nothing to count is nothing at
170        // all: the role is told the table is not there, which is the same
171        // answer in the direction that withholds.
172        !table.columns.is_empty() || !table.computed_columns.is_empty() || writes || counts
173    });
174
175    // A relationship whose other end the role cannot *read* is not a field it
176    // gets a null for -- there is nothing to name it after, because a table
177    // this role may only write has no type. Both ends are checked: the map is
178    // keyed by the source, and the target may have gone even where the source
179    // stayed.
180    let readable: std::collections::HashSet<_> = view
181        .tables
182        .iter()
183        .filter(|(_, table)| {
184            names
185                .permissions(&table.schema, &table.name, role)
186                .is_some_and(|granted| reads_anything(granted, table))
187        })
188        .map(|(key, _)| key.clone())
189        .collect();
190    // A table whose rows are a generated enum is the exception. A column with
191    // a key to one is *typed* as that enum, and the type is not the table's
192    // data: `colors_enum` is published to a role that may not read `colors` at
193    // all, and Hasura publishes it too. The key is what says which column is
194    // typed by which enum, so it has to survive here -- and no field comes of
195    // it, since a relationship to an enum table is never exposed.
196    let enumerated: std::collections::HashSet<_> = names
197        .enum_tables()
198        .into_iter()
199        .map(|(schema, table)| postrust_core::api_request::QualifiedIdentifier::new(schema, table))
200        .collect();
201    view.relationships.retain(|(source, _), links| {
202        if !view.tables.contains_key(source) {
203            return false;
204        }
205        links.retain(|link| {
206            readable.contains(link.foreign_table()) || enumerated.contains(link.foreign_table())
207        });
208        !links.is_empty()
209    });
210
211    view
212}
213
214/// Whether this role can read anything of this table at all.
215///
216/// The question a relationship target has to answer, and the question that
217/// decides whether the table gets a GraphQL type: a role with a write grant
218/// and no select permission keeps the table in the cache, and a type with no
219/// fields is not a legal type. Asked of an already-reduced table, so the
220/// computed fields it still carries are the granted ones.
221pub fn reads_anything(granted: &crate::names::RolePermissions, table: &Table) -> bool {
222    let Some(select) = &granted.select else {
223        return false;
224    };
225    !table.computed_columns.is_empty()
226        || table.columns.keys().any(|name| select.columns.allows(name))
227}
228
229/// Whether any of this role's permissions is reachable only by a backend.
230///
231/// Which decides whether the role needs a second schema at all: without one,
232/// what a backend caller may name is what everyone may name.
233pub fn has_backend_only(names: &NameOverrides, role: &str) -> bool {
234    names.tables_with_permissions(role).any(|granted| {
235        granted
236            .insert
237            .as_ref()
238            .is_some_and(|insert| insert.backend_only)
239    })
240}
241
242/// Who is asking, for the places that have to narrow rows to them.
243///
244/// The two request-scoped facts a permission needs, carried together because
245/// they are always needed together and because the SQL builders they reach are
246/// several calls below the context that holds them. `role: None` is the
247/// unrestricted read: no permission document, or an administrator.
248#[derive(Clone, Copy)]
249pub struct Caller<'a> {
250    pub role: Option<&'a str>,
251    pub session: &'a std::collections::HashMap<String, String>,
252}
253
254impl Caller<'_> {
255    /// Whether any permission applies to this caller at all.
256    pub fn unrestricted(&self) -> bool {
257        match self.role {
258            None => true,
259            Some(role) => role == postrust_auth::hasura::ADMIN_ROLE,
260        }
261    }
262}
263
264/// Why a read could not be narrowed to the caller.
265///
266/// Two different answers with two different codes in Hasura, so they are kept
267/// apart here rather than collapsed into one message.
268#[derive(Debug, Clone, PartialEq, Eq)]
269pub enum Fault {
270    /// The role has no select permission on the table. Unreachable through a
271    /// role's own schema, which has no such field -- so reaching it means
272    /// something was built that should not have been, and the safe answer is
273    /// to refuse rather than to read every row.
274    NoPermission { role: String, table: String },
275    /// A permission names a session variable the caller does not carry.
276    MissingSessionVariable(String),
277}
278
279impl Fault {
280    /// Hasura's `extensions.code` for this.
281    pub fn code(&self) -> &'static str {
282        match self {
283            Self::NoPermission { .. } => "permission-error",
284            Self::MissingSessionVariable(_) => "not-found",
285        }
286    }
287}
288
289impl std::fmt::Display for Fault {
290    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
291        match self {
292            Self::NoPermission { role, table } => write!(
293                f,
294                "role \"{}\" has no permission to read \"{}\"",
295                role, table
296            ),
297            Self::MissingSessionVariable(message) => f.write_str(message),
298        }
299    }
300}
301
302/// Hasura's wording for a written row that a permission refuses.
303///
304/// One message for both verbs, which is Hasura's choice and not a shortcut
305/// taken here: `check constraint of an insert/update permission has failed`.
306pub const CHECK_FAILED: &str = "check constraint of an insert/update permission has failed";
307
308/// Which of the four grants is being asked about.
309#[derive(Clone, Copy, Debug, PartialEq, Eq)]
310pub enum Verb {
311    Select,
312    Insert,
313    Update,
314    Delete,
315}
316
317impl Verb {
318    fn of<'a>(&self, granted: &'a crate::names::RolePermissions) -> Option<Grant<'a>> {
319        match self {
320            Self::Select => granted.select.as_ref().map(Grant::Select),
321            Self::Insert => granted.insert.as_ref().map(Grant::Insert),
322            Self::Update => granted.update.as_ref().map(Grant::Update),
323            Self::Delete => granted.delete.as_ref().map(Grant::Delete),
324        }
325    }
326}
327
328/// One grant, whichever it is.
329enum Grant<'a> {
330    Select(&'a crate::names::SelectPermission),
331    Insert(&'a crate::names::InsertPermission),
332    Update(&'a crate::names::UpdatePermission),
333    Delete(&'a crate::names::DeletePermission),
334}
335
336impl Grant<'_> {
337    /// Which rows this grant applies to, read before the operation.
338    ///
339    /// An insert has none: there are no rows to choose from yet, which is
340    /// exactly why it has a `check` instead.
341    fn filter(&self) -> Option<&serde_json::Value> {
342        match self {
343            Self::Select(p) => Some(&p.filter),
344            Self::Update(p) => Some(&p.filter),
345            Self::Delete(p) => Some(&p.filter),
346            Self::Insert(_) => None,
347        }
348    }
349
350    /// What a written row has to satisfy afterwards.
351    ///
352    /// An update with no `check` of its own is held to its `filter`, which is
353    /// Hasura's rule: a row you may change is a row you may change into
354    /// something you may still change.
355    fn check(&self) -> Option<&serde_json::Value> {
356        match self {
357            Self::Insert(p) => Some(&p.check),
358            Self::Update(p) => match p.check.is_null() {
359                false => Some(&p.check),
360                true => Some(&p.filter),
361            },
362            Self::Select(_) | Self::Delete(_) => None,
363        }
364    }
365
366    /// Columns the server fills in, whatever the request said.
367    fn set(&self) -> Option<&std::collections::HashMap<String, serde_json::Value>> {
368        match self {
369            Self::Insert(p) => Some(&p.set),
370            Self::Update(p) => Some(&p.set),
371            Self::Select(_) | Self::Delete(_) => None,
372        }
373    }
374}
375
376fn grant_for<'a>(
377    caller: &Caller<'_>,
378    names: &'a NameOverrides,
379    schema: &str,
380    table: &str,
381    verb: Verb,
382) -> Result<Option<Grant<'a>>, Fault> {
383    if caller.unrestricted() {
384        return Ok(None);
385    }
386    let role = caller.role.unwrap_or_default();
387    match names
388        .permissions(schema, table, role)
389        .and_then(|granted| verb.of(granted))
390    {
391        Some(grant) => Ok(Some(grant)),
392        // Reaching here means a schema was built naming an operation this role
393        // has no permission for, which should not happen -- and the safe
394        // answer to "which rows" is none of them, not all of them.
395        None => Err(Fault::NoPermission {
396            role: role.to_string(),
397            table: table.to_string(),
398        }),
399    }
400}
401
402/// Which rows a role's permission lets one operation reach.
403///
404/// `Ok(None)` where nothing restricts it: an unrestricted caller, a filter of
405/// `{}`, or an insert, which has no rows to choose from.
406pub fn row_filter(
407    caller: &Caller<'_>,
408    names: &NameOverrides,
409    schema: &str,
410    table: &str,
411    verb: Verb,
412) -> Result<Option<serde_json::Value>, Fault> {
413    let Some(grant) = grant_for(caller, names, schema, table, verb)? else {
414        return Ok(None);
415    };
416    let Some(filter) = grant.filter() else {
417        return Ok(None);
418    };
419    permission_where(filter, caller.session, names, schema, table)
420        .map_err(Fault::MissingSessionVariable)
421}
422
423/// What a written row has to satisfy for the write to stand.
424pub fn write_check(
425    caller: &Caller<'_>,
426    names: &NameOverrides,
427    schema: &str,
428    table: &str,
429    verb: Verb,
430) -> Result<Option<serde_json::Value>, Fault> {
431    let Some(grant) = grant_for(caller, names, schema, table, verb)? else {
432        return Ok(None);
433    };
434    let Some(check) = grant.check() else {
435        return Ok(None);
436    };
437    permission_where(check, caller.session, names, schema, table)
438        .map_err(Fault::MissingSessionVariable)
439}
440
441/// The columns the server fills in itself, with their values.
442///
443/// This is how `author_id` comes from the caller's identity rather than from
444/// the caller: a preset overrides whatever the request said, so a client that
445/// names the column anyway does not get to choose it.
446pub fn presets(
447    caller: &Caller<'_>,
448    names: &NameOverrides,
449    schema: &str,
450    table: &str,
451    verb: Verb,
452) -> Result<Vec<(String, serde_json::Value)>, Fault> {
453    let Some(grant) = grant_for(caller, names, schema, table, verb)? else {
454        return Ok(Vec::new());
455    };
456    let Some(set) = grant.set() else {
457        return Ok(Vec::new());
458    };
459
460    let mut filled = Vec::with_capacity(set.len());
461    for (column, value) in set {
462        // A preset's value is a session variable as often as it is a literal,
463        // and it is resolved the same way a filter's operand is.
464        let resolved = match value {
465            serde_json::Value::String(text) => match session_variable(text) {
466                Some(name) => {
467                    resolve(name, caller.session, false).map_err(Fault::MissingSessionVariable)?
468                }
469                None => value.clone(),
470            },
471            other => other.clone(),
472        };
473        filled.push((column.clone(), resolved));
474    }
475    // Sorted, so the SQL a preset produces does not depend on how a hash map
476    // felt about its keys.
477    filled.sort_by(|a, b| a.0.cmp(&b.0));
478    Ok(filled)
479}
480
481/// The predicate a role's select permission adds to a read of one table.
482pub fn read_predicate(
483    caller: &Caller<'_>,
484    names: &NameOverrides,
485    schema: &str,
486    table: &str,
487) -> Result<Option<serde_json::Value>, Fault> {
488    row_filter(caller, names, schema, table, Verb::Select)
489}
490
491/// How many rows a role's permission lets one read return.
492///
493/// A ceiling rather than a default, and `None` where the permission names one
494/// no more than the configuration does.
495pub fn read_limit(
496    caller: &Caller<'_>,
497    names: &NameOverrides,
498    schema: &str,
499    table: &str,
500) -> Option<i64> {
501    if caller.unrestricted() {
502        return None;
503    }
504    names
505        .permissions(schema, table, caller.role?)
506        .and_then(|granted| granted.select.as_ref())
507        .and_then(|select| select.limit)
508        .map(|limit| limit as i64)
509}
510
511/// Turn a permission's row filter into the boolean expression a `where`
512/// argument would have been.
513///
514/// The two are the same language. Hasura's `filter` is written in the same
515/// shape a client writes `where` in, so there is no second predicate compiler
516/// here and no second set of operators to keep in step -- the filter is
517/// rewritten into a `<table>_bool_exp` and handed to the one that already
518/// exists. Everything it can express, a permission can express, including a
519/// predicate over a relationship.
520///
521/// Three things metadata spells differently, and they are all this does:
522///
523///   * `{"author_id": 1}` is `_eq` written without saying so. Metadata omits
524///     the operator where it is equality; a `where` argument never does.
525///   * `$and`, `$or` and `$not` are the older spellings of `_and`, `_or` and
526///     `_not`, and the corpus uses both -- sometimes in one file.
527///   * `"X-Hasura-User-Id"` is not a string to compare against but the name of
528///     a session variable to compare against the value of. This is the half
529///     that makes a permission about the caller rather than about the table.
530///
531/// `Ok(None)` means the filter restricts nothing, which is what `{}` says and
532/// is a real answer: a role may be granted every row. It is distinct from
533/// having no permission at all, which is settled before this is reached.
534///
535/// An `Err` names a session variable the caller does not carry. That is a
536/// refusal rather than an empty result, because a filter that silently
537/// compares against nothing is a filter that matches nothing for a reason the
538/// client cannot see -- and Hasura says so too, in these words.
539pub fn permission_where(
540    filter: &serde_json::Value,
541    session: &std::collections::HashMap<String, String>,
542    names: &NameOverrides,
543    schema: &str,
544    table: &str,
545) -> Result<Option<serde_json::Value>, String> {
546    let rewritten = rewrite(filter, session, names, schema, table, None)?;
547    Ok(match rewritten {
548        serde_json::Value::Object(map) if map.is_empty() => None,
549        serde_json::Value::Null => None,
550        value => Some(value),
551    })
552}
553
554/// The older spellings, and what they are now.
555fn connective(key: &str) -> Option<&'static str> {
556    match key {
557        "$and" | "_and" => Some("_and"),
558        "$or" | "_or" => Some("_or"),
559        "$not" | "_not" => Some("_not"),
560        _ => None,
561    }
562}
563
564/// Whether an operator takes a list, which decides how a session variable
565/// spells itself.
566fn takes_a_list(operator: &str) -> bool {
567    matches!(operator, "_in" | "_nin" | "_has_keys_any" | "_has_keys_all")
568}
569
570fn rewrite(
571    value: &serde_json::Value,
572    session: &std::collections::HashMap<String, String>,
573    names: &NameOverrides,
574    schema: &str,
575    table: &str,
576    // The operator this value is the operand of, where it is one. Only used to
577    // decide whether a session variable is one value or a list of them.
578    operator: Option<&str>,
579) -> Result<serde_json::Value, String> {
580    match value {
581        // Inside an operator, an object is the operator's *operand* and not a
582        // nested boolean expression: `_st_d_within` takes `{distance, from}`
583        // and `_contains` takes a whole document. Reading their keys as
584        // columns renamed them, wrapped their values in `_eq`, and handed
585        // PostGIS something that was no longer GeoJSON -- `invalid GeoJson
586        // representation`, about a shape this server had taken apart.
587        //
588        // Session variables inside one are still resolved. That is the only
589        // thing a permission's operand needs rewriting for, and `{"from":
590        // "X-Hasura-Geom-Val"}` is how the corpus writes it.
591        serde_json::Value::Object(map) if operator.is_some() => {
592            let mut out = serde_json::Map::with_capacity(map.len());
593            for (key, child) in map {
594                out.insert(
595                    key.clone(),
596                    rewrite(child, session, names, schema, table, operator)?,
597                );
598            }
599            Ok(serde_json::Value::Object(out))
600        }
601
602        serde_json::Value::Object(map) => {
603            let mut out = serde_json::Map::with_capacity(map.len());
604            for (key, child) in map {
605                if let Some(spelling) = connective(key) {
606                    out.insert(
607                        spelling.to_string(),
608                        rewrite(child, session, names, schema, table, None)?,
609                    );
610                    continue;
611                }
612
613                // `$in`, `$gt`, `$is_null`: the whole operator family has an
614                // older spelling, not just the three connectives above, and
615                // the corpus writes both. Everything after the sigil is the
616                // operator this server already knows.
617                let key = match key.strip_prefix('$') {
618                    Some(rest) if !rest.is_empty() => std::borrow::Cow::Owned(format!("_{}", rest)),
619                    _ => std::borrow::Cow::Borrowed(key.as_str()),
620                };
621
622                // A predicate over another table, so what is inside it is
623                // spelled in *that* table's names rather than in this one's.
624                // Everything else here carries the outer table down.
625                if key == "_exists" {
626                    out.insert(
627                        "_exists".to_string(),
628                        rewrite_exists(child, session, names, schema)?,
629                    );
630                    continue;
631                }
632
633                if key.starts_with('_') {
634                    // An operator. Its operand may be a session variable, and
635                    // whether that is one value or several depends on which
636                    // operator it is.
637                    out.insert(
638                        key.to_string(),
639                        rewrite(child, session, names, schema, table, Some(&key))?,
640                    );
641                    continue;
642                }
643
644                // A column, or a relationship. A renamed column is exposed
645                // under the other name and the compiler below reads exposed
646                // names, so the rename is applied here. A relationship is not
647                // in the column map and so is left alone -- along with the
648                // columns inside it, which belong to a table this does not
649                // know the name of. A rename on the far side of a relationship
650                // inside a permission is the one spelling this does not
651                // follow.
652                let exposed = names
653                    .column(schema, table, &key)
654                    .unwrap_or(&key)
655                    .to_string();
656
657                let rewritten = rewrite(child, session, names, schema, table, None)?;
658                // Equality written without saying so: metadata omits the
659                // operator, a boolean expression never does.
660                let wrapped = match &rewritten {
661                    serde_json::Value::Object(_) => rewritten,
662                    scalar => serde_json::json!({ "_eq": scalar }),
663                };
664                out.insert(exposed, wrapped);
665            }
666            Ok(serde_json::Value::Object(out))
667        }
668
669        serde_json::Value::Array(items) => items
670            .iter()
671            .map(|item| rewrite(item, session, names, schema, table, operator))
672            .collect::<Result<Vec<_>, _>>()
673            .map(serde_json::Value::Array),
674
675        serde_json::Value::String(text) => match session_variable(text) {
676            Some(name) => resolve(name, session, operator.is_some_and(takes_a_list)),
677            None => Ok(value.clone()),
678        },
679
680        other => Ok(other.clone()),
681    }
682}
683
684/// The table `_exists` looks in, as `(schema, table)`.
685///
686/// Hasura's metadata writes the name two ways -- `_table: user` and `_table:
687/// {schema: public, name: user}` -- and its own corpus uses the first. An
688/// unqualified name is read in the schema of the table the permission is on,
689/// which is where a permission written without a schema means to look.
690pub fn exists_target(spec: &serde_json::Value, default_schema: &str) -> Option<(String, String)> {
691    match spec.get("_table")? {
692        serde_json::Value::String(name) => Some((default_schema.to_string(), name.clone())),
693        serde_json::Value::Object(qualified) => {
694            let name = qualified.get("name").and_then(|v| v.as_str())?;
695            let schema = qualified
696                .get("schema")
697                .and_then(|v| v.as_str())
698                .unwrap_or(default_schema);
699            Some((schema.to_string(), name.to_string()))
700        }
701        _ => None,
702    }
703}
704
705/// `_exists`, rewritten so that its predicate reads against the table it names.
706///
707/// The `_table` is a name and not a predicate, so it is copied through
708/// untouched: rewriting it would read a table called `x-hasura-anything` as a
709/// session variable, and would wrap it in an `_eq` besides.
710fn rewrite_exists(
711    spec: &serde_json::Value,
712    session: &std::collections::HashMap<String, String>,
713    names: &NameOverrides,
714    default_schema: &str,
715) -> Result<serde_json::Value, String> {
716    let Some(map) = spec.as_object() else {
717        return Err("\"_exists\" takes a table and a predicate over it".to_string());
718    };
719    let Some((schema, table)) = exists_target(spec, default_schema) else {
720        return Err("\"_exists\" needs a \"_table\" to look in".to_string());
721    };
722
723    let mut out = serde_json::Map::with_capacity(2);
724    if let Some(named) = map.get("_table") {
725        out.insert("_table".to_string(), named.clone());
726    }
727    let predicate = map
728        .get("_where")
729        .cloned()
730        .unwrap_or_else(|| serde_json::json!({}));
731    out.insert(
732        "_where".to_string(),
733        rewrite(&predicate, session, names, &schema, &table, None)?,
734    );
735    Ok(serde_json::Value::Object(out))
736}
737
738/// The session variable a string names, if it names one.
739///
740/// Case does not matter: the corpus writes `X-HASURA-USER-ID` and
741/// `X-Hasura-User-Id` for the same variable, sometimes in adjacent lines of
742/// one file.
743fn session_variable(text: &str) -> Option<String> {
744    let lowered = text.to_ascii_lowercase();
745    let bare = lowered.strip_prefix("x-hasura-")?;
746    match bare.is_empty() {
747        true => None,
748        false => Some(bare.replace('-', "_")),
749    }
750}
751
752/// The value a session variable stands for.
753fn resolve(
754    name: String,
755    session: &std::collections::HashMap<String, String>,
756    as_a_list: bool,
757) -> Result<serde_json::Value, String> {
758    let Some(value) = session.get(&name) else {
759        // Hasura's wording, and Hasura's decision to refuse rather than to
760        // compare against nothing.
761        return Err(format!(
762            "missing session variable: \"x-hasura-{}\"",
763            name.replace('_', "-")
764        ));
765    };
766
767    if !as_a_list {
768        return Ok(serde_json::Value::String(value.clone()));
769    }
770
771    // A list arrives as a PostgreSQL array literal, because a header carries
772    // one string and that is the spelling both ends already agree on:
773    // `X-Hasura-Allowed-Ids: {1,2,3}`. Unbraced is read as a single-item list
774    // rather than refused, since a one-element list is the case a caller is
775    // most likely to write bare.
776    let inner = value
777        .strip_prefix('{')
778        .and_then(|rest| rest.strip_suffix('}'));
779    Ok(match inner {
780        None => serde_json::json!([value]),
781        Some("") => serde_json::json!([]),
782        Some(items) => serde_json::Value::Array(
783            items
784                .split(',')
785                .map(|item| serde_json::Value::String(item.trim().trim_matches('"').to_string()))
786                .collect(),
787        ),
788    })
789}
790
791/// Whether a role may ask for aggregates over a table.
792///
793/// Separate from reading its rows because counting rows you cannot see is a
794/// way of seeing them, and Hasura keeps the two apart for that reason. A role
795/// the document says nothing about is not reached here: it has no table.
796pub fn allows_aggregations(names: &NameOverrides, schema: &str, table: &str, role: &str) -> bool {
797    names
798        .permissions(schema, table, role)
799        .and_then(|granted| granted.select.as_ref())
800        .is_some_and(|select| select.allow_aggregations)
801}
802
803#[cfg(test)]
804mod tests {
805    use super::*;
806    use postrust_core::schema_cache::{Column, Table};
807    use postrust_core::QualifiedIdentifier;
808    use std::collections::{HashMap, HashSet};
809
810    fn column(name: &str, position: i32) -> Column {
811        Column {
812            name: name.to_string(),
813            description: None,
814            nullable: false,
815            data_type: "text".to_string(),
816            nominal_type: "text".to_string(),
817            max_len: None,
818            default: None,
819            enum_values: vec![],
820            is_pk: name == "id",
821            position,
822            domain_type: None,
823            always_generated: false,
824        }
825    }
826
827    fn table(name: &str, columns: &[&str]) -> Table {
828        Table {
829            schema: "public".to_string(),
830            name: name.to_string(),
831            description: None,
832            is_view: false,
833            insertable: true,
834            updatable: true,
835            deletable: true,
836            pk_cols: vec!["id".to_string()],
837            unique_constraints: Vec::new(),
838            columns: columns
839                .iter()
840                .enumerate()
841                .map(|(at, c)| (c.to_string(), column(c, at as i32 + 1)))
842                .collect(),
843            computed_columns: Default::default(),
844            is_partitioned: false,
845        }
846    }
847
848    fn cache(tables: Vec<Table>) -> SchemaCache {
849        let mut map = HashMap::new();
850        for table in tables {
851            map.insert(table.qualified_identifier(), table);
852        }
853        SchemaCache {
854            tables: map,
855            relationships: HashMap::new(),
856            routines: HashMap::new(),
857            timezones: HashSet::new(),
858            media_handlers: HashMap::new(),
859            pg_version: 150000,
860            representations: Default::default(),
861        }
862    }
863
864    const DOCUMENT: &str = r#"{
865        "tables": {
866            "public.article": {
867                "permissions": {
868                    "user": {
869                        "select": {"columns": ["id", "title"], "filter": {},
870                                   "allow_aggregations": true},
871                        "insert": {"columns": "*", "check": {}}
872                    },
873                    "reader": {
874                        "select": {"columns": "*", "filter": {}}
875                    },
876                    "narrow": {
877                        "select": {"columns": ["id", "title"], "filter": {}},
878                        "insert": {"columns": ["title"], "check": {}}
879                    }
880                }
881            },
882            "public.secret": {
883                "permissions": {
884                    "reader": {"select": {"columns": "*", "filter": {}}}
885                }
886            }
887        }
888    }"#;
889
890    fn names() -> NameOverrides {
891        NameOverrides::parse(DOCUMENT).unwrap()
892    }
893
894    #[test]
895    fn a_table_the_role_was_told_nothing_about_is_not_there() {
896        let view = cache_for_role(
897            &cache(vec![table("article", &["id"]), table("secret", &["id"])]),
898            &names(),
899            "user",
900            false,
901        );
902        assert!(view
903            .tables
904            .contains_key(&QualifiedIdentifier::new("public", "article")));
905        // `secret` names `reader` and not `user`, so for `user` it does not
906        // exist -- which is a different answer from existing and refusing.
907        assert!(!view
908            .tables
909            .contains_key(&QualifiedIdentifier::new("public", "secret")));
910    }
911
912    #[test]
913    fn a_column_outside_the_permission_is_absent_rather_than_null() {
914        // `narrow` reads id and title and writes title. `content` is named by
915        // neither, so it is not in the cache under any heading.
916        let view = cache_for_role(
917            &cache(vec![table("article", &["id", "title", "content"])]),
918            &names(),
919            "narrow",
920            false,
921        );
922        let article = &view.tables[&QualifiedIdentifier::new("public", "article")];
923        assert!(article.has_column("title"));
924        assert!(!article.has_column("content"));
925    }
926
927    #[test]
928    fn a_column_the_role_may_write_and_not_read_is_kept_for_the_write() {
929        // `user` reads id and title and inserts every column. `content` is in
930        // the cache because the insert names it -- the read half is taken out
931        // where the object type's fields are built, not here.
932        let view = cache_for_role(
933            &cache(vec![table("article", &["id", "title", "content"])]),
934            &names(),
935            "user",
936            false,
937        );
938        let article = &view.tables[&QualifiedIdentifier::new("public", "article")];
939        assert!(article.has_column("content"));
940    }
941
942    #[test]
943    fn a_wildcard_keeps_every_column() {
944        let view = cache_for_role(
945            &cache(vec![table("article", &["id", "title", "content"])]),
946            &names(),
947            "reader",
948            false,
949        );
950        let article = &view.tables[&QualifiedIdentifier::new("public", "article")];
951        assert_eq!(article.columns.len(), 3);
952    }
953
954    #[test]
955    fn a_write_the_role_was_not_granted_is_not_a_root_field() {
956        let view = cache_for_role(
957            &cache(vec![table("article", &["id"])]),
958            &names(),
959            "user",
960            false,
961        );
962        let article = &view.tables[&QualifiedIdentifier::new("public", "article")];
963        // Granted insert; told nothing about update or delete.
964        assert!(article.insertable);
965        assert!(!article.updatable);
966        assert!(!article.deletable);
967    }
968
969    #[test]
970    fn a_permission_cannot_grant_a_write_the_database_refuses() {
971        let mut read_only = table("article", &["id"]);
972        read_only.insertable = false;
973        let view = cache_for_role(&cache(vec![read_only]), &names(), "user", false);
974        let article = &view.tables[&QualifiedIdentifier::new("public", "article")];
975        // The document grants `insert` and the database does not. Metadata
976        // above the database cannot widen what is beneath it.
977        assert!(!article.insertable);
978    }
979
980    /// A permission grants the field, and the map is keyed by the function.
981    /// Comparing the two directly withheld every renamed computed field.
982    #[test]
983    fn a_computed_field_is_granted_under_the_name_it_is_exposed_as() {
984        let mut table = table("student_marks", &["id", "name"]);
985        table.computed_columns.insert(
986            "student_total_marks".to_string(),
987            postrust_core::schema_cache::ComputedColumn {
988                function: postrust_core::api_request::QualifiedIdentifier::new(
989                    "public",
990                    "student_total_marks",
991                ),
992                data_type: "integer".to_string(),
993                description: None,
994                row_argument: None,
995                session_argument: None,
996                takes_arguments: false,
997            },
998        );
999        let cache = cache(vec![table]);
1000        let names = NameOverrides::parse(
1001            r#"{"tables": {"public.student_marks": {
1002                 "computed_fields": {"student_total_marks": "total_marks"},
1003                 "permissions": {"tutor": {"select": {
1004                    "columns": ["name"], "computed_fields": ["total_marks"], "filter": {}}}}}}}"#,
1005        )
1006        .unwrap();
1007        let view = cache_for_role(&cache, &names, "tutor", false);
1008        let table = view
1009            .get_table(&postrust_core::api_request::QualifiedIdentifier::new(
1010                "public",
1011                "student_marks",
1012            ))
1013            .expect("the table survives");
1014        assert!(
1015            table.computed_columns.contains_key("student_total_marks"),
1016            "granted under the name the client sees"
1017        );
1018    }
1019
1020    #[test]
1021    fn a_computed_field_is_granted_one_at_a_time() {
1022        let mut article = table("article", &["id"]);
1023        article.computed_columns.insert(
1024            "word_count".to_string(),
1025            postrust_core::schema_cache::ComputedColumn {
1026                function: QualifiedIdentifier::new("public", "word_count"),
1027                data_type: "integer".to_string(),
1028                description: None,
1029                row_argument: None,
1030                session_argument: None,
1031                takes_arguments: false,
1032            },
1033        );
1034        let view = cache_for_role(&cache(vec![article]), &names(), "user", false);
1035        let article = &view.tables[&QualifiedIdentifier::new("public", "article")];
1036        // The permission names no computed fields, and none is the default.
1037        assert!(article.computed_columns.is_empty());
1038    }
1039
1040    #[test]
1041    fn a_table_that_can_only_be_written_keeps_the_write_and_gets_no_type() {
1042        // `writer` may insert and not read. The table stays, with the columns
1043        // the insert may name and nothing to read them back with: nothing this
1044        // role asks can produce a row of it.
1045        let names = NameOverrides::parse(
1046            r#"{"tables": {"public.article": {"permissions":
1047                 {"writer": {"insert": {"columns": "*", "check": {}}}}}}}"#,
1048        )
1049        .unwrap();
1050        let view = cache_for_role(
1051            &cache(vec![table("article", &["id"])]),
1052            &names,
1053            "writer",
1054            false,
1055        );
1056        let article = &view.tables[&QualifiedIdentifier::new("public", "article")];
1057        assert!(article.insertable);
1058        assert!(!article.updatable);
1059        assert!(!article.deletable);
1060        assert!(article.has_column("id"));
1061        let granted = names.permissions("public", "article", "writer").unwrap();
1062        assert!(!reads_anything(granted, article));
1063    }
1064
1065    /// A generated enum is a type, not the table's data. A role granted
1066    /// nothing on the table still sees the enum, and still has a column typed
1067    /// by it -- so the key that says which column is kept.
1068    #[test]
1069    fn a_key_to_an_enum_table_survives_a_role_that_cannot_read_it() {
1070        let users = table("users", &["id", "favorite_color"]);
1071        let colors = table("colors", &["value"]);
1072        let mut base = cache(vec![users, colors]);
1073        base.relationships.insert(
1074            (
1075                postrust_core::api_request::QualifiedIdentifier::new("public", "users"),
1076                "public".to_string(),
1077            ),
1078            vec![postrust_core::schema_cache::Relationship::ForeignKey {
1079                table: postrust_core::api_request::QualifiedIdentifier::new("public", "users"),
1080                foreign_table: postrust_core::api_request::QualifiedIdentifier::new(
1081                    "public", "colors",
1082                ),
1083                is_self: false,
1084                cardinality: postrust_core::schema_cache::Cardinality::M2O {
1085                    constraint: "users_favorite_color_fkey".to_string(),
1086                    columns: vec![("favorite_color".to_string(), "value".to_string())],
1087                },
1088                table_is_view: false,
1089                foreign_table_is_view: false,
1090                constraint_name: "users_favorite_color_fkey".to_string(),
1091            }],
1092        );
1093
1094        let names = NameOverrides::parse(
1095            r#"{"tables": {
1096                 "public.colors": {"enum": true},
1097                 "public.users": {"permissions": {"anonymous":
1098                    {"select": {"columns": "*", "filter": {}}}}}}}"#,
1099        )
1100        .unwrap();
1101        let view = cache_for_role(&base, &names, "anonymous", false);
1102
1103        assert!(
1104            !view
1105                .tables
1106                .contains_key(&postrust_core::api_request::QualifiedIdentifier::new(
1107                    "public", "colors"
1108                )),
1109            "the table itself is not readable"
1110        );
1111        assert_eq!(
1112            view.relationships.len(),
1113            1,
1114            "and the key to it is kept anyway: {:?}",
1115            view.relationships
1116        );
1117    }
1118
1119    #[test]
1120    fn a_relationship_pointing_at_a_table_the_role_may_only_write_is_dropped() {
1121        // There is nothing to name such a field after: the far side has no
1122        // type, and naming one that was never registered is a schema that will
1123        // not build.
1124        let names = NameOverrides::parse(
1125            r#"{"tables": {
1126                 "public.article": {"permissions":
1127                   {"writer": {"select": {"columns": "*", "filter": {}}}}},
1128                 "public.author": {"permissions":
1129                   {"writer": {"insert": {"columns": "*", "check": {}}}}}}}"#,
1130        )
1131        .unwrap();
1132        let mut base = cache(vec![table("article", &["id"]), table("author", &["id"])]);
1133        base.relationships.insert(
1134            (
1135                QualifiedIdentifier::new("public", "article"),
1136                "public".to_string(),
1137            ),
1138            vec![postrust_core::schema_cache::Relationship::ForeignKey {
1139                table: QualifiedIdentifier::new("public", "article"),
1140                foreign_table: QualifiedIdentifier::new("public", "author"),
1141                is_self: false,
1142                cardinality: postrust_core::schema_cache::Cardinality::M2O {
1143                    constraint: "article_author_id_fkey".to_string(),
1144                    columns: vec![("author_id".to_string(), "id".to_string())],
1145                },
1146                table_is_view: false,
1147                foreign_table_is_view: false,
1148                constraint_name: "article_author_id_fkey".to_string(),
1149            }],
1150        );
1151        let view = cache_for_role(&base, &names, "writer", false);
1152        assert!(view
1153            .tables
1154            .contains_key(&QualifiedIdentifier::new("public", "author")));
1155        assert!(view.relationships.is_empty());
1156    }
1157
1158    /// An operator's operand is a value, not a boolean expression. Reading
1159    /// its keys as columns took a shape apart before PostGIS ever saw it.
1160    #[test]
1161    fn an_operand_that_is_an_object_is_carried_through_whole() {
1162        let filter = serde_json::json!({
1163            "geom_col": {"$st_d_within": {
1164                "distance": 1,
1165                "from": {"type": "Polygon", "coordinates": [[[2, 0], [2, 1]]]}
1166            }}
1167        });
1168        let rewritten = permission_where(
1169            &filter,
1170            &HashMap::new(),
1171            &NameOverrides::default(),
1172            "public",
1173            "geom_table",
1174        )
1175        .expect("it rewrites")
1176        .expect("it restricts something");
1177        assert_eq!(
1178            rewritten,
1179            serde_json::json!({
1180                "geom_col": {"_st_d_within": {
1181                    "distance": 1,
1182                    "from": {"type": "Polygon", "coordinates": [[[2, 0], [2, 1]]]}
1183                }}
1184            }),
1185            "only the operator's own spelling changes"
1186        );
1187    }
1188
1189    /// And a session variable inside such an operand is still resolved, which
1190    /// is the one thing it does need rewriting for.
1191    #[test]
1192    fn a_session_variable_inside_an_operand_is_still_resolved() {
1193        let mut session = HashMap::new();
1194        session.insert("geom_val".to_string(), "{\"type\":\"Point\"}".to_string());
1195        let filter = serde_json::json!({
1196            "geom_col": {"$st_d_within": {"distance": 1, "from": "X-Hasura-Geom-Val"}}
1197        });
1198        let rewritten = permission_where(
1199            &filter,
1200            &session,
1201            &NameOverrides::default(),
1202            "public",
1203            "geom_table",
1204        )
1205        .expect("it rewrites")
1206        .expect("it restricts something");
1207        assert_eq!(
1208            rewritten["geom_col"]["_st_d_within"]["from"],
1209            serde_json::json!("{\"type\":\"Point\"}")
1210        );
1211    }
1212
1213    #[test]
1214    fn exists_keeps_its_table_and_rewrites_its_predicate() {
1215        // The table is a name, not a value: it must survive untouched, while
1216        // the predicate beside it gets the session variable and the `_eq` that
1217        // metadata leaves implicit.
1218        let session = session(&[("user_id", "2")]);
1219        let rewritten = compiled(
1220            serde_json::json!({
1221                "_exists": {"_table": "user",
1222                            "_where": {"id": "X-Hasura-User-Id", "is_admin": true}}
1223            }),
1224            &session,
1225        );
1226        assert_eq!(
1227            rewritten,
1228            serde_json::json!({
1229                "_exists": {"_table": "user",
1230                            "_where": {"id": {"_eq": "2"},
1231                                       "is_admin": {"_eq": true}}}
1232            })
1233        );
1234    }
1235
1236    #[test]
1237    fn exists_names_its_table_either_way() {
1238        assert_eq!(
1239            exists_target(&serde_json::json!({"_table": "user"}), "public"),
1240            Some(("public".to_string(), "user".to_string()))
1241        );
1242        assert_eq!(
1243            exists_target(
1244                &serde_json::json!({"_table": {"schema": "auth", "name": "user"}}),
1245                "public"
1246            ),
1247            Some(("auth".to_string(), "user".to_string()))
1248        );
1249        assert_eq!(exists_target(&serde_json::json!({}), "public"), None);
1250    }
1251
1252    fn session(pairs: &[(&str, &str)]) -> HashMap<String, String> {
1253        pairs
1254            .iter()
1255            .map(|(k, v)| (k.to_string(), v.to_string()))
1256            .collect()
1257    }
1258
1259    fn compiled(filter: serde_json::Value, session: &HashMap<String, String>) -> serde_json::Value {
1260        permission_where(
1261            &filter,
1262            session,
1263            &NameOverrides::default(),
1264            "public",
1265            "article",
1266        )
1267        .unwrap()
1268        .unwrap()
1269    }
1270
1271    #[test]
1272    fn equality_written_without_saying_so_becomes_an_operator() {
1273        assert_eq!(
1274            compiled(serde_json::json!({"is_published": true}), &session(&[])),
1275            serde_json::json!({"is_published": {"_eq": true}})
1276        );
1277    }
1278
1279    #[test]
1280    fn the_older_connectives_are_the_newer_ones() {
1281        let filter = serde_json::json!({
1282            "$or": [{"author_id": "X-HASURA-USER-ID"}, {"is_published": true}]
1283        });
1284        assert_eq!(
1285            compiled(filter, &session(&[("user_id", "1")])),
1286            serde_json::json!({"_or": [
1287                {"author_id": {"_eq": "1"}},
1288                {"is_published": {"_eq": true}}
1289            ]})
1290        );
1291    }
1292
1293    #[test]
1294    fn the_older_operator_spellings_are_the_newer_ones_too() {
1295        // Not only the three connectives: the whole family has a `$` spelling
1296        // and the corpus writes both.
1297        assert_eq!(
1298            compiled(
1299                serde_json::json!({"id": {"$in": "X-Hasura-Allowed-Ids"}}),
1300                &session(&[("allowed_ids", "{1,2}")])
1301            ),
1302            serde_json::json!({"id": {"_in": ["1", "2"]}})
1303        );
1304        assert_eq!(
1305            compiled(serde_json::json!({"age": {"$gt": 18}}), &session(&[])),
1306            serde_json::json!({"age": {"_gt": 18}})
1307        );
1308    }
1309
1310    #[test]
1311    fn a_session_variable_is_read_however_it_is_spelled() {
1312        // The corpus writes both, sometimes in one file.
1313        let by_shouting = compiled(
1314            serde_json::json!({"id": {"_eq": "X-HASURA-USER-ID"}}),
1315            &session(&[("user_id", "7")]),
1316        );
1317        let by_title = compiled(
1318            serde_json::json!({"id": {"_eq": "X-Hasura-User-Id"}}),
1319            &session(&[("user_id", "7")]),
1320        );
1321        assert_eq!(by_shouting, by_title);
1322        assert_eq!(by_shouting, serde_json::json!({"id": {"_eq": "7"}}));
1323    }
1324
1325    #[test]
1326    fn a_variable_the_caller_does_not_carry_is_refused() {
1327        let error = permission_where(
1328            &serde_json::json!({"id": "X-Hasura-User-Id"}),
1329            &session(&[]),
1330            &NameOverrides::default(),
1331            "public",
1332            "article",
1333        )
1334        .unwrap_err();
1335        // Refused rather than compared against nothing: a filter that matches
1336        // no rows for a reason the client cannot see is worse than a refusal.
1337        assert!(error.contains("x-hasura-user-id"), "unhelpful: {}", error);
1338    }
1339
1340    #[test]
1341    fn a_list_operator_reads_its_variable_as_a_list() {
1342        assert_eq!(
1343            compiled(
1344                serde_json::json!({"name": {"_in": "X-Hasura-Free-Artists"}}),
1345                &session(&[("free_artists", "{Ann,Bob}")])
1346            ),
1347            serde_json::json!({"name": {"_in": ["Ann", "Bob"]}})
1348        );
1349        // An empty array literal is no items, not one empty item.
1350        assert_eq!(
1351            compiled(
1352                serde_json::json!({"name": {"_in": "X-Hasura-Free-Artists"}}),
1353                &session(&[("free_artists", "{}")])
1354            ),
1355            serde_json::json!({"name": {"_in": []}})
1356        );
1357        // And the same variable under an operator that takes one value stays
1358        // one value.
1359        assert_eq!(
1360            compiled(
1361                serde_json::json!({"name": {"_eq": "X-Hasura-Free-Artists"}}),
1362                &session(&[("free_artists", "{Ann,Bob}")])
1363            ),
1364            serde_json::json!({"name": {"_eq": "{Ann,Bob}"}})
1365        );
1366    }
1367
1368    #[test]
1369    fn a_predicate_over_a_relationship_survives_unchanged() {
1370        // Nothing special to do: a relationship in a permission is the same
1371        // shape a relationship in `where` is, and the compiler below already
1372        // turns one into a correlated EXISTS.
1373        assert_eq!(
1374            compiled(
1375                serde_json::json!({"articles": {"is_published": {"_eq": true}}}),
1376                &session(&[])
1377            ),
1378            serde_json::json!({"articles": {"is_published": {"_eq": true}}})
1379        );
1380    }
1381
1382    #[test]
1383    fn an_empty_filter_restricts_nothing() {
1384        let none = permission_where(
1385            &serde_json::json!({}),
1386            &session(&[]),
1387            &NameOverrides::default(),
1388            "public",
1389            "article",
1390        )
1391        .unwrap();
1392        // A real answer, and not the same as having no permission at all --
1393        // which is settled before this is reached.
1394        assert!(none.is_none());
1395    }
1396
1397    #[test]
1398    fn a_renamed_column_is_named_the_way_the_schema_exposes_it() {
1399        let names =
1400            NameOverrides::parse(r#"{"public.article": {"columns": {"author_id": "writtenBy"}}}"#)
1401                .unwrap();
1402        let compiled = permission_where(
1403            &serde_json::json!({"author_id": "X-Hasura-User-Id"}),
1404            &session(&[("user_id", "1")]),
1405            &names,
1406            "public",
1407            "article",
1408        )
1409        .unwrap()
1410        .unwrap();
1411        // The permission names the column; the compiler below reads the field.
1412        assert_eq!(compiled, serde_json::json!({"writtenBy": {"_eq": "1"}}));
1413    }
1414
1415    const WRITES: &str = r#"{
1416        "tables": {"public.article": {"permissions": {"user": {
1417            "select": {"columns": "*", "filter": {}},
1418            "insert": {"columns": "*", "check": {"is_published": false},
1419                       "set": {"author_id": "X-Hasura-User-Id", "source": "web"},
1420                       "backend_only": true},
1421            "update": {"columns": "*", "filter": {"author_id": "X-Hasura-User-Id"}},
1422            "delete": {"filter": {"author_id": "X-Hasura-User-Id"}}
1423        }}}}
1424    }"#;
1425
1426    fn writer() -> (NameOverrides, HashMap<String, String>) {
1427        (
1428            NameOverrides::parse(WRITES).unwrap(),
1429            session(&[("user_id", "3")]),
1430        )
1431    }
1432
1433    #[test]
1434    fn each_verb_is_asked_about_its_own_grant() {
1435        let (names, sess) = writer();
1436        let caller = Caller {
1437            role: Some("user"),
1438            session: &sess,
1439        };
1440        // An insert has no rows to choose from, which is why it has a check.
1441        assert_eq!(
1442            row_filter(&caller, &names, "public", "article", Verb::Insert).unwrap(),
1443            None
1444        );
1445        for verb in [Verb::Update, Verb::Delete] {
1446            assert_eq!(
1447                row_filter(&caller, &names, "public", "article", verb).unwrap(),
1448                Some(serde_json::json!({"author_id": {"_eq": "3"}}))
1449            );
1450        }
1451    }
1452
1453    #[test]
1454    fn an_update_with_no_check_of_its_own_is_held_to_its_filter() {
1455        // Hasura's rule: a row you may change is a row you may change into
1456        // something you may still change.
1457        let (names, sess) = writer();
1458        let caller = Caller {
1459            role: Some("user"),
1460            session: &sess,
1461        };
1462        assert_eq!(
1463            write_check(&caller, &names, "public", "article", Verb::Update).unwrap(),
1464            Some(serde_json::json!({"author_id": {"_eq": "3"}}))
1465        );
1466        assert_eq!(
1467            write_check(&caller, &names, "public", "article", Verb::Insert).unwrap(),
1468            Some(serde_json::json!({"is_published": {"_eq": false}}))
1469        );
1470    }
1471
1472    #[test]
1473    fn a_preset_resolves_a_session_variable_and_keeps_a_literal() {
1474        let (names, sess) = writer();
1475        let caller = Caller {
1476            role: Some("user"),
1477            session: &sess,
1478        };
1479        assert_eq!(
1480            presets(&caller, &names, "public", "article", Verb::Insert).unwrap(),
1481            vec![
1482                ("author_id".to_string(), serde_json::json!("3")),
1483                ("source".to_string(), serde_json::json!("web")),
1484            ]
1485        );
1486    }
1487
1488    #[test]
1489    fn a_backend_only_insert_is_absent_until_a_backend_asks() {
1490        let (names, _) = writer();
1491        assert!(has_backend_only(&names, "user"));
1492        let of = |backend| {
1493            cache_for_role(
1494                &cache(vec![table("article", &["id"])]),
1495                &names,
1496                "user",
1497                backend,
1498            )
1499            .tables[&QualifiedIdentifier::new("public", "article")]
1500                .insertable
1501        };
1502        assert!(!of(false));
1503        assert!(of(true));
1504    }
1505
1506    #[test]
1507    fn aggregates_are_asked_for_separately_from_rows() {
1508        let names = names();
1509        assert!(allows_aggregations(&names, "public", "article", "user"));
1510        // `reader` may read every column and was not granted aggregates.
1511        assert!(!allows_aggregations(&names, "public", "article", "reader"));
1512        assert!(!allows_aggregations(
1513            &names, "public", "article", "stranger"
1514        ));
1515    }
1516}