diff --git a/docs/followups/3-is-authorized.md b/docs/followups/3-is-authorized.md new file mode 100644 index 0000000..60f72f0 --- /dev/null +++ b/docs/followups/3-is-authorized.md @@ -0,0 +1,28 @@ +# Follow-ups for branch 3 — is-authorized (review) + +## PR description + +Idea 2 for a concrete request: partial evaluation with the request and the action entities known, the +residuals compiled into one query with the three-valued encoding, the decision rebuilt as `cedar-policy` +does (`docs/plans/3-is-authorized.md`). Tested against `cedar_policy::Authorizer` case by case, and by the +`sql-is-authorized-drt` target in `cedar-sql-spec`. + +## Review findings + +The review (against the committed branch, with some forty probe policies) found no semantic divergence in +the SQL encoding. Its findings, fixed in branch 5 since branch 4 rewrote the compiler in between: + +- A request without a context is one with an unknown context in `cedar-policy`; it was treated as empty. +- Entity references inside JSON-stored sets and records are compared by id under their static type; the + loader now checks stored contents against the schema (`load::conforms`). +- The quoting assumed `standard_conforming_strings`; connections now set it. +- `hasTag` on an entity type without tags (which the validator types as `false`) was unsupported. +- The query text grew exponentially with nested `&&`/`||`; branch 4 binds shared operands to `LATERAL` + nodes. +- On the `cedar-sql-spec` side: loader errors other than NUL strings and `Request`/`Tpe` errors after + validation are bugs, not skips; `enable_extensions: false` made typed generation abort inputs at the + extension-function arms; the recursive hierarchy mode and `like` were not fuzzed. + +## Suggested follow-ups + +- Probes worth keeping as tests were added to `tests/authorize_pg.rs` and `tests/hierarchy_pg.rs` (branch 5). diff --git a/docs/plans/3-is-authorized.md b/docs/plans/3-is-authorized.md new file mode 100644 index 0000000..31e9213 --- /dev/null +++ b/docs/plans/3-is-authorized.md @@ -0,0 +1,58 @@ +# Plan 3 — is-authorized: concrete authorization through one query + +## Goal + +Idea 2 of the README for a concrete request: typed partial evaluation with the request known and only the +action entities in the store, the residual policies compiled into one query over the tables of Plan 2, and +the decision with its determining and erroring policies rebuilt as `cedar-policy`'s authorizer computes them. +This branch covers the core operators; sets, records beyond `has`/`get`, tags on computed entities and the +remaining constructs are `Unsupported` until Plan 4, which the differential test in `cedar-sql-spec` treats +as benign skips. + +## Design + +- **Residuals** (`src/authorizer.rs`): `concrete_request` turns a `Request` into a `PartialRequest` with both + ids and the context known; `action_entities` turns the action entities into `PartialEntities`; + `cedar_policy_core::tpe::is_authorized` then folds everything that does not touch entity data. `SqlAuthorizer:: + new` validates the policies strictly (every residual node is typed, which the compiler relies on). +- **The encoding** (`src/compile.rs`): `NULL` is a Cedar error. `&&`, `||` and `if` are `CASE` forms that keep + short-circuiting (`false && error` is `false`, `error && false` an error, an untaken branch is not evaluated); + `+ - *` compute in `numeric` and range-check so no bigint overflow reaches Postgres (which would abort the + statement); unary `-` guards `i64::MIN`; `==` on the same representation is `=`, on different entity types or + representations it is `false` unless an operand is `NULL`; `<`/`<=` on integers; `like` translates the pattern + to `LIKE … ESCAPE '\'` (`*` to `%`, `%`/`_`/`\` escaped); `is` is a constant from the static type; `hasTag` + is a guarded `EXISTS` and `getTag` a scalar subquery on the tags table (no row is an error); `in` is + `(same type AND same id) OR EXISTS` on the hierarchy table when it holds the closure, else on a per-anchor + recursive ancestors CTE (`UNION`, so cycles terminate); `in` on a literal set is the disjunction. Record + attributes read the canonical JSON (`-> 'r' -> 'k'`, cast per static type; `has` is `?`). +- **Roots and CTEs**: a `Compiled` value carries its representation and, for entities fetched from a row, an + `Anchor` (root plus attribute path). Roots are the unknown request variables and the entity literals that are + dereferenced; a root's CTE selects the id column as `"$id"`, one `LEFT JOIN` per entity-typed attribute path + under it (joined on the target's entity id column, so at most one row each), and one `"$v:"` column per + referenced attribute path. `getAttr` is that column (`NULL` for a missing row or an absent attribute, both + Cedar errors); `hasAttr` is `IS NOT NULL` guarded by the parent's value, so a missing entity is `false` and an + errored parent an error; an attribute the schema does not declare is `false`. Generated names longer than 63 + bytes are shortened with a hash (`ident::shortened`), since Postgres would truncate them silently. +- **The query**: `WITH [RECURSIVE] , SELECT , p0 … pN FROM (SELECT 1 AS base) + CROSS JOIN LEFT JOIN ON TRUE`, so a concrete request yields exactly one row. +- **Decision** (`CompiledAuthorization::response`): satisfied permits and forbids are the ones TPE found `true` + plus the residual columns that are `TRUE`; `errors` are TPE's plus the `NULL` columns; `Allow` iff some permit + and no forbid; the reason is the satisfied forbids if any, else the satisfied permits. + +## Files + +`src/{compile,authorizer,error}.rs`, `tests/authorize_pg.rs`, `tests/entities/kitchen_sink.json` (a dangling +reference), `tests/load_pg.rs`, `docs/plans/3-is-authorized.md`. + +## Verification + +`cargo test --all-features` with `CEDAR_SQL_PG_URL` set: `tests/authorize_pg.rs` authorizes every case through +SQL and through `cedar_policy::Authorizer` and requires the same decision, reason set and error set — attribute +chains over existing, absent and dangling references, literal roots, records, `like`, `is`, arithmetic at the +overflow boundary, short-circuiting with an erroring operand on each side, `in` on both hierarchy modes, tags, +and the decision rules (forbid overriding, erroring permits and forbids, folded policies next to residual ones). +Then `cargo clippy --all-targets --all-features -- -D warnings` and `cargo fmt --all --check`. + +## History + +New; the third `cedar-sql` branch. The differential fuzz target is `cedar-sql-spec` branch `cedar-sql-3-is-authorized`. diff --git a/src/authorizer.rs b/src/authorizer.rs index 33dc3c3..7374cd6 100644 --- a/src/authorizer.rs +++ b/src/authorizer.rs @@ -1,4 +1,261 @@ -//! The public entry points. +//! The public entry points: authorizing a request against the database. //! -//! Plan 3 adds `SqlAuthorizer` with `compile_request` and `is_authorized`; -//! Plan 5 adds `query` for partial requests. +//! [`SqlAuthorizer::is_authorized`] runs typed partial evaluation with the +//! request (all of it known) and the action entities as the only known +//! entities, so that every policy either folds to `true`, `false` or an error, +//! or leaves a residual that references entity data; the residuals become one +//! query (see [`crate::compile`]) whose row yields each residual policy's +//! three-valued outcome. The decision, the determining policies and the +//! erroring policies are then computed as `cedar-policy`'s authorizer does: +//! `Allow` iff some permit is satisfied and no forbid is, the determining +//! policies are the satisfied forbids if any else the satisfied permits, and +//! the erroring policies are reported but do not affect the decision. + +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::Arc; + +use cedar_policy::{ + Decision, Entities, PolicyId, PolicySet, Request, Schema, ValidationMode, Validator, +}; +use cedar_policy_core::ast::{Context, Effect, Entity, EntityUIDEntry}; +use cedar_policy_core::tpe; +use cedar_policy_core::tpe::entities::{PartialEntities, PartialEntity}; +use cedar_policy_core::tpe::request::{PartialEntityUID, PartialRequest}; + +use crate::backend::{Backend, Row, SqlValue}; +use crate::compile::Compiler; +use crate::config::DatabaseConfiguration; +use crate::dialect::Dialect; +use crate::{Error, Result}; + +/// An authorization response. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Response { + /// The decision. + pub decision: Decision, + /// The determining policies. + pub reason: BTreeSet, + /// The policies whose evaluation errored. + pub errors: BTreeSet, +} + +/// The outcome of partial evaluation and compilation for one request. +#[derive(Clone, Debug)] +pub struct CompiledAuthorization { + /// The query, when some policies are residual; `None` when partial + /// evaluation decided every policy. + pub sql: Option, + /// The residual policies, in the order of the query's policy columns. + pub residuals: Vec<(PolicyId, Effect)>, + /// The unknown request variables whose ids the query selects first. + pub unknown_roots: usize, + true_permits: BTreeSet, + true_forbids: BTreeSet, + errors: BTreeSet, +} + +impl CompiledAuthorization { + /// The response for one result row (`None` when there is no query). + pub fn response(&self, row: Option<&Row>) -> Result { + let mut permits = self.true_permits.clone(); + let mut forbids = self.true_forbids.clone(); + let mut errors = self.errors.clone(); + if let Some(row) = row { + if row.len() != self.unknown_roots + self.residuals.len() { + return Err(Error::Query(format!( + "expected {} columns, got {}", + self.unknown_roots + self.residuals.len(), + row.len() + ))); + } + for ((id, effect), value) in self.residuals.iter().zip(&row[self.unknown_roots..]) { + match value { + SqlValue::Bool(true) => { + match effect { + Effect::Permit => permits.insert(id.clone()), + Effect::Forbid => forbids.insert(id.clone()), + }; + } + SqlValue::Bool(false) => {} + SqlValue::Null => { + errors.insert(id.clone()); + } + other => { + return Err(Error::Query(format!( + "policy {id} yielded {other:?}, not a boolean" + ))); + } + } + } + } else if !self.residuals.is_empty() { + return Err(Error::Query( + "a row is needed for the residual policies".into(), + )); + } + let decision = if !forbids.is_empty() || permits.is_empty() { + Decision::Deny + } else { + Decision::Allow + }; + let reason = if forbids.is_empty() { permits } else { forbids }; + Ok(Response { + decision, + reason, + errors, + }) + } +} + +/// Authorizes requests for one schema, database configuration and policy set. +pub struct SqlAuthorizer<'a> { + schema: &'a Schema, + config: &'a DatabaseConfiguration, + dialect: &'a dyn Dialect, + policies: PolicySet, +} + +impl<'a> SqlAuthorizer<'a> { + /// Validates `policies` strictly against `schema`, which compilation + /// relies on (every residual is typed). + pub fn new( + schema: &'a Schema, + config: &'a DatabaseConfiguration, + dialect: &'a dyn Dialect, + policies: &PolicySet, + ) -> Result { + let result = Validator::new(schema.clone()).validate(policies, ValidationMode::Strict); + if !result.validation_passed() { + return Err(Error::Validation( + result + .validation_errors() + .map(ToString::to_string) + .collect::>() + .join("; "), + )); + } + Ok(Self { + schema, + config, + dialect, + policies: policies.clone(), + }) + } + + /// Partially evaluates the policies for `request` over `entities` (the + /// action entities, typically) and compiles the residuals. + pub fn compile( + &self, + request: &PartialRequest, + entities: &PartialEntities, + ) -> Result { + let schema = self.schema.as_ref(); + let response = tpe::is_authorized(self.policies.as_ref(), request, entities, schema) + .map_err(|e| Error::Tpe(e.to_string()))?; + let ids = |policies: Box + '_>| { + policies + .map(|p| PolicyId::new(p.get_policy_id())) + .collect::>() + }; + let true_permits = ids(Box::new(response.true_permits())); + let true_forbids = ids(Box::new(response.true_forbids())); + let mut errors = ids(Box::new(response.error_permits())); + errors.extend(ids(Box::new(response.error_forbids()))); + let mut residuals: Vec<_> = response + .residual_permits() + .chain(response.residual_forbids()) + .collect(); + residuals.sort_by_key(|p| p.get_policy_id().to_string()); + if residuals.is_empty() { + return Ok(CompiledAuthorization { + sql: None, + residuals: Vec::new(), + unknown_roots: 0, + true_permits, + true_forbids, + errors, + }); + } + let mut compiler = Compiler::new(self.config, schema, self.dialect, request); + let mut columns = Vec::new(); + let mut order = Vec::new(); + for (i, policy) in residuals.iter().enumerate() { + let condition = compiler.condition(&policy.get_residual())?; + columns.push((format!("p{i}"), condition)); + order.push((PolicyId::new(policy.get_policy_id()), policy.get_effect())); + } + let sql = compiler.render(&columns)?; + Ok(CompiledAuthorization { + sql: Some(sql), + residuals: order, + unknown_roots: compiler.unknown_roots().len(), + true_permits, + true_forbids, + errors, + }) + } + + /// Authorizes a concrete `request`, with the entity data in the database + /// behind `db` and the action entities in `actions`. + pub fn is_authorized( + &self, + db: &mut dyn Backend, + request: &Request, + actions: &Entities, + ) -> Result { + let request = concrete_request(request, self.schema)?; + let entities = action_entities(actions, self.schema)?; + let compiled = self.compile(&request, &entities)?; + let Some(sql) = &compiled.sql else { + return compiled.response(None); + }; + let rows = db.query(sql)?; + let [row] = rows.as_slice() else { + return Err(Error::Query(format!( + "expected exactly one row, got {}", + rows.len() + ))); + }; + compiled.response(Some(row)) + } +} + +/// The partial request with everything known. +pub fn concrete_request(request: &Request, schema: &Schema) -> Result { + let core = request.as_ref(); + let known = |entry: &EntityUIDEntry, what: &str| match entry { + EntityUIDEntry::Known { euid, .. } => Ok(PartialEntityUID { + ty: euid.entity_type().clone(), + eid: Some(euid.eid().clone()), + }), + EntityUIDEntry::Unknown { .. } => Err(Error::Request(format!("the {what} is unknown"))), + }; + let principal = known(core.principal(), "principal")?; + let resource = known(core.resource(), "resource")?; + let action = match core.action() { + EntityUIDEntry::Known { euid, .. } => euid.as_ref().clone(), + EntityUIDEntry::Unknown { .. } => { + return Err(Error::Request("the action is unknown".into())); + } + }; + let context = match core.context() { + Some(Context::Value(values)) => Some(values.clone()), + Some(Context::RestrictedResidual(_)) => { + return Err(Error::Unsupported("a partially unknown context")); + } + None => Some(Arc::new(BTreeMap::new())), + }; + PartialRequest::new(principal, action, resource, context, schema.as_ref()) + .map_err(|e| Error::Request(e.to_string())) +} + +/// The action entities of `entities` as known partial entities. +pub fn action_entities(entities: &Entities, schema: &Schema) -> Result { + let actions = entities + .as_ref() + .iter() + .filter(|e| e.uid().entity_type().is_action()) + .map(|e: &Entity| PartialEntity::try_from(e.clone()).map_err(|e| Error::Tpe(e.to_string()))) + .collect::>>()?; + PartialEntities::from_entities(actions.into_iter(), schema.as_ref()) + .map_err(|e| Error::Tpe(e.to_string())) +} diff --git a/src/compile.rs b/src/compile.rs index edef36a..985eae7 100644 --- a/src/compile.rs +++ b/src/compile.rs @@ -1,5 +1,878 @@ -//! Compiling a TPE residual into a SQL expression. +//! Compiling TPE residuals into one SQL query. //! -//! Plan 3 adds the three-valued encoding (`NULL` is an error) for the core -//! operators; Plan 4 completes it with sets, records, `like`, tags and -//! memoization. +//! Every residual node becomes a SQL expression with three-valued semantics: +//! `NULL` is a Cedar evaluation error, so `&&`, `||` and `if` are `CASE` +//! forms that keep Cedar's short-circuiting (`false && error` is `false`, +//! `error && false` is an error), arithmetic is guarded so that no bigint +//! overflow reaches Postgres (which would abort the statement), and every +//! `EXISTS` is guarded by a `NULL` check of its inputs, since `EXISTS` never +//! yields `NULL` by itself. +//! +//! Entity data is fetched by CTEs: one per *root* — an unknown request +//! variable, or an entity literal that is dereferenced — with one `LEFT JOIN` +//! per entity-typed attribute path under that root (`principal.a.b`), so that +//! each root row yields exactly one row with a column per referenced attribute +//! path. A referenced attribute that is absent, or whose entity row is +//! missing, reads as `NULL`, which is exactly Cedar's error for `getAttr`; +//! `hasAttr` is `IS NOT NULL` guarded by the parent value, so that a missing +//! entity gives `false` and an errored parent gives an error. +//! +//! [`Compiler::render`] assembles the query: the CTEs, one boolean column per +//! policy, and the `FROM (SELECT 1) CROSS JOIN LEFT JOIN +//! ON TRUE` skeleton, which yields exactly one row for a +//! concrete request and one row per candidate entity otherwise. + +use std::fmt::Write as _; + +use cedar_policy_core::ast::{ + BinaryOp, EntityType, EntityUID, Literal, PatternElem, UnaryOp, Value, ValueKind, Var, +}; +use cedar_policy_core::tpe::request::PartialRequest; +use cedar_policy_core::tpe::residual::{Residual, ResidualKind}; +use cedar_policy_core::validator::ValidatorSchema; +use cedar_policy_core::validator::types::{EntityKind, Type}; +use indexmap::{IndexMap, IndexSet}; +use smol_str::SmolStr; + +use crate::config::{ + DatabaseConfiguration, HIERARCHY_COLUMNS, SQLType, TAGS_ENTITY_ID_COLUMN, TAGS_TAG_COLUMN, + TAGS_VALUE_COLUMN, +}; +use crate::dialect::Dialect; +use crate::ident::{SQLIdentifier, quoted_literal, shortened}; +use crate::load::canonical_json; +use crate::{Error, Result}; + +/// The SQL representation of a compiled value. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Repr { + /// A `BOOLEAN`. + Bool, + /// A `BIGINT`. + Long, + /// A `TEXT`. + Text, + /// A `TEXT` entity id of the given (statically known) entity type. + Entity(EntityType), + /// A `JSONB` in the canonical encoding (a set or a record). + Json, +} + +impl Repr { + /// The representation of values of a validator type. + pub fn of(ty: &Type) -> Result { + Ok(match ty { + Type::Bool(_) => Repr::Bool, + Type::Long => Repr::Long, + Type::String => Repr::Text, + Type::Entity(EntityKind::Entity(lub)) => match lub.get_single_entity() { + Some(ety) => Repr::Entity(ety.clone()), + None => return Err(Error::Unsupported("values of a union entity type")), + }, + Type::Entity(EntityKind::AnyEntity) => { + return Err(Error::Unsupported("values of an unspecified entity type")); + } + Type::Set { .. } | Type::Record { .. } => Repr::Json, + Type::ExtensionType { .. } => return Err(Error::Unsupported("extension types")), + Type::Never => return Err(Error::Unsupported("values of the never type")), + }) + } + + fn sql_type(&self) -> &'static str { + match self { + Repr::Bool => "boolean", + Repr::Long => "bigint", + Repr::Text | Repr::Entity(_) => "text", + Repr::Json => "jsonb", + } + } +} + +/// A root: the source of an entity value that rows are fetched for. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum RootKey { + /// The unknown `principal`. + Principal, + /// The unknown `resource`. + Resource, + /// An entity literal that is dereferenced. + Literal(EntityUID), +} + +/// An entity value that is a row-anchored attribute chain: `root.a.b`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Anchor { + root: RootKey, + path: Vec, +} + +/// A compiled residual. +#[derive(Clone, Debug)] +pub struct Compiled { + /// The SQL expression. + pub sql: String, + /// Its representation. + pub repr: Repr, + /// When the value is an entity fetched by a root CTE, where it comes from. + pub anchor: Option, +} + +/// What one root's CTE must fetch. +#[derive(Debug)] +struct Root { + ety: EntityType, + /// Entity-typed attribute paths that are joined, with the joined type. + joins: IndexMap, EntityType>, + /// Attribute paths whose values the CTE exports. + values: IndexSet>, +} + +/// The per-anchor ancestors CTE, when the hierarchy table is not closed. +#[derive(Debug)] +struct AncestorsCte { + anchor: Anchor, + ety: EntityType, +} + +/// Compiles residuals against one request, collecting the CTEs they need. +pub struct Compiler<'a> { + config: &'a DatabaseConfiguration, + schema: &'a ValidatorSchema, + dialect: &'a dyn Dialect, + request: &'a PartialRequest, + roots: IndexMap, + ancestors: Vec, +} + +impl<'a> Compiler<'a> { + /// A compiler for `request`. + pub fn new( + config: &'a DatabaseConfiguration, + schema: &'a ValidatorSchema, + dialect: &'a dyn Dialect, + request: &'a PartialRequest, + ) -> Self { + Self { + config, + schema, + dialect, + request, + roots: IndexMap::new(), + ancestors: Vec::new(), + } + } + + /// Compiles a residual to a boolean SQL expression. + pub fn condition(&mut self, r: &Residual) -> Result { + let compiled = self.expr(r)?; + match compiled.repr { + Repr::Bool => Ok(compiled.sql), + _ => Err(Error::Unsupported("a policy condition that is not boolean")), + } + } + + /// Compiles a residual. + pub fn expr(&mut self, r: &Residual) -> Result { + let repr = Repr::of(r.ty())?; + let kind = match r { + Residual::Concrete { value, .. } => return self.value(value, repr), + Residual::Error(_) => { + return Ok(Compiled { + sql: format!("NULL::{}", repr.sql_type()), + repr, + anchor: None, + }); + } + Residual::Partial { kind, .. } => kind, + }; + let sql = match kind { + ResidualKind::Var(Var::Principal) => return self.var(RootKey::Principal, repr), + ResidualKind::Var(Var::Resource) => return self.var(RootKey::Resource, repr), + ResidualKind::Var(Var::Action | Var::Context) => { + return Err(Error::Unsupported("an unknown action or context")); + } + ResidualKind::And { left, right } => { + let a = self.bool(left)?; + let b = self.bool(right)?; + format!("(CASE WHEN {a} IS NULL THEN NULL WHEN {a} THEN {b} ELSE FALSE END)") + } + ResidualKind::Or { left, right } => { + let a = self.bool(left)?; + let b = self.bool(right)?; + format!("(CASE WHEN {a} IS NULL THEN NULL WHEN {a} THEN TRUE ELSE {b} END)") + } + ResidualKind::If { + test_expr, + then_expr, + else_expr, + } => { + let c = self.bool(test_expr)?; + let t = self.expr(then_expr)?; + let e = self.expr(else_expr)?; + if t.repr != repr || e.repr != repr { + return Err(Error::Unsupported( + "an if whose branches differ in representation", + )); + } + format!( + "(CASE WHEN {c} IS NULL THEN NULL WHEN {c} THEN {} ELSE {} END)", + t.sql, e.sql + ) + } + ResidualKind::UnaryApp { op, arg } => { + let a = self.expr(arg)?; + match op { + UnaryOp::Not => format!("(NOT {})", a.sql), + UnaryOp::Neg => format!( + "(CASE WHEN {a} = {min} THEN NULL ELSE -{a} END)", + a = a.sql, + min = self.dialect.bigint_literal(i64::MIN) + ), + UnaryOp::IsEmpty => return Err(Error::Unsupported("isEmpty")), + } + } + ResidualKind::BinaryApp { op, arg1, arg2 } => { + return self.binary(*op, arg1, arg2, repr); + } + ResidualKind::GetAttr { expr, attr } => return self.get_attr(expr, attr, repr), + ResidualKind::HasAttr { expr, attr } => self.has_attr(expr, attr)?, + ResidualKind::Like { expr, pattern } => { + let a = self.expr(expr)?; + let mut like = String::new(); + for elem in pattern.iter() { + match elem { + PatternElem::Wildcard => like.push('%'), + PatternElem::Char(c @ ('%' | '_' | '\\')) => { + like.push('\\'); + like.push(*c); + } + PatternElem::Char(c) => like.push(*c), + } + } + format!("({} LIKE {} ESCAPE '\\')", a.sql, quoted_literal(&like)?) + } + ResidualKind::Is { expr, entity_type } => { + let a = self.expr(expr)?; + let Repr::Entity(ety) = &a.repr else { + return Err(Error::Unsupported("is on a non-entity value")); + }; + let outcome = if ety == entity_type { "TRUE" } else { "FALSE" }; + format!("(CASE WHEN {} IS NULL THEN NULL ELSE {outcome} END)", a.sql) + } + ResidualKind::ExtensionFunctionApp { .. } => { + return Err(Error::Unsupported("extension functions")); + } + ResidualKind::Set(_) => return Err(Error::Unsupported("set expressions")), + ResidualKind::Record(_) => return Err(Error::Unsupported("record expressions")), + }; + Ok(Compiled { + sql, + repr, + anchor: None, + }) + } + + fn bool(&mut self, r: &Residual) -> Result { + let c = self.expr(r)?; + match c.repr { + Repr::Bool => Ok(c.sql), + _ => Err(Error::Unsupported( + "a non-boolean operand of a boolean operator", + )), + } + } + + fn value(&mut self, value: &Value, repr: Repr) -> Result { + let sql = match (value.value_kind(), &repr) { + (ValueKind::Lit(Literal::Bool(b)), Repr::Bool) => { + if *b { "TRUE" } else { "FALSE" }.to_owned() + } + (ValueKind::Lit(Literal::Long(n)), Repr::Long) => self.dialect.bigint_literal(*n), + (ValueKind::Lit(Literal::String(s)), Repr::Text) => quoted_literal(s)?, + (ValueKind::Lit(Literal::EntityUID(uid)), Repr::Entity(ety)) => { + if uid.entity_type() != ety { + return Err(Error::Unsupported( + "an entity literal typed as another entity type", + )); + } + return Ok(Compiled { + sql: quoted_literal(uid.eid().as_ref())?, + repr, + anchor: Some(Anchor { + root: RootKey::Literal(uid.as_ref().clone()), + path: Vec::new(), + }), + }); + } + (ValueKind::Set(_) | ValueKind::Record(_), Repr::Json) => { + let json = canonical_json(value)?; + self.dialect + .json_literal("ed_literal(&json.to_string())?) + } + (ValueKind::ExtensionValue(_), _) => { + return Err(Error::Unsupported("extension values")); + } + _ => { + return Err(Error::Unsupported( + "a value whose type disagrees with its representation", + )); + } + }; + Ok(Compiled { + sql, + repr, + anchor: None, + }) + } + + fn var(&mut self, root: RootKey, repr: Repr) -> Result { + let Repr::Entity(ety) = &repr else { + return Err(Error::Unsupported( + "a request variable that is not an entity", + )); + }; + let expected = match root { + RootKey::Principal => self.request.principal_type(), + RootKey::Resource => self.request.resource_type(), + RootKey::Literal(_) => unreachable!("only variables are passed"), + }; + if expected != ety { + return Err(Error::Unsupported( + "a request variable typed as another entity type", + )); + } + self.ensure_root(&root, ety); + Ok(Compiled { + sql: format!("{}.{}", root_name(&root), ID_COLUMN), + repr, + anchor: Some(Anchor { + root, + path: Vec::new(), + }), + }) + } + + fn ensure_root(&mut self, root: &RootKey, ety: &EntityType) { + self.roots.entry(root.clone()).or_insert_with(|| Root { + ety: ety.clone(), + joins: IndexMap::new(), + values: IndexSet::new(), + }); + } + + /// The column of `anchor`'s attribute `attr`, registering the joins and + /// the exported value. + fn anchored_attr( + &mut self, + anchor: &Anchor, + ety: &EntityType, + attr: &SmolStr, + ) -> Result { + if self.config.column_for(ety, attr).is_none() { + return Err(Error::Unsupported("an attribute without a column")); + } + let root_type = self.root_type(anchor); + self.ensure_root(&anchor.root, &root_type); + let root = self.roots.get_mut(&anchor.root).expect("just ensured"); + if !anchor.path.is_empty() { + root.joins.entry(anchor.path.clone()).or_insert(ety.clone()); + } + let mut path = anchor.path.clone(); + path.push(attr.clone()); + root.values.insert(path.clone()); + Ok(format!( + "{}.{}", + root_name(&anchor.root), + value_column(&path) + )) + } + + fn root_type(&self, anchor: &Anchor) -> EntityType { + match &anchor.root { + RootKey::Principal => self.request.principal_type().clone(), + RootKey::Resource => self.request.resource_type().clone(), + RootKey::Literal(uid) => uid.entity_type().clone(), + } + } + + fn get_attr(&mut self, expr: &Residual, attr: &SmolStr, repr: Repr) -> Result { + let inner = self.expr(expr)?; + match &inner.repr { + Repr::Entity(ety) => { + let Some(anchor) = &inner.anchor else { + return Err(Error::Unsupported( + "attribute access on a computed entity value", + )); + }; + let sql = self.anchored_attr(anchor, ety, attr)?; + let anchor = match &repr { + Repr::Entity(_) => { + let mut path = anchor.path.clone(); + path.push(attr.clone()); + Some(Anchor { + root: anchor.root.clone(), + path, + }) + } + _ => None, + }; + Ok(Compiled { sql, repr, anchor }) + } + Repr::Json => { + let field = format!("({} -> 'r' -> {})", inner.sql, quoted_literal(attr)?); + let sql = json_to_repr(&field, &repr); + Ok(Compiled { + sql, + repr, + anchor: None, + }) + } + _ => Err(Error::Unsupported( + "attribute access on a non-entity, non-record value", + )), + } + } + + fn has_attr(&mut self, expr: &Residual, attr: &SmolStr) -> Result { + let inner = self.expr(expr)?; + match &inner.repr { + Repr::Entity(ety) => { + let Some(anchor) = &inner.anchor else { + return Err(Error::Unsupported("has on a computed entity value")); + }; + if self + .schema + .get_entity_type(ety) + .is_none_or(|vet| vet.attr(attr).is_none()) + { + // The attribute is not declared, so no entity of the type has it. + return Ok(format!( + "(CASE WHEN {} IS NULL THEN NULL ELSE FALSE END)", + inner.sql + )); + } + let column = self.anchored_attr(anchor, ety, attr)?; + Ok(format!( + "(CASE WHEN {} IS NULL THEN NULL ELSE {column} IS NOT NULL END)", + inner.sql + )) + } + Repr::Json => Ok(format!( + "(({} -> 'r') ? {})", + inner.sql, + quoted_literal(attr)? + )), + _ => Err(Error::Unsupported("has on a non-entity, non-record value")), + } + } + + fn binary( + &mut self, + op: BinaryOp, + arg1: &Residual, + arg2: &Residual, + repr: Repr, + ) -> Result { + let a = self.expr(arg1)?; + let b = self.expr(arg2)?; + let sql = match op { + BinaryOp::Eq => match (&a.repr, &b.repr) { + (Repr::Bool, Repr::Bool) | (Repr::Long, Repr::Long) | (Repr::Text, Repr::Text) => { + format!("({} = {})", a.sql, b.sql) + } + (Repr::Entity(t1), Repr::Entity(t2)) if t1 == t2 => { + format!("({} = {})", a.sql, b.sql) + } + (Repr::Json, Repr::Json) => { + return Err(Error::Unsupported("equality of sets or records")); + } + // Values of different types are never equal, but an error stays one. + _ => format!( + "(CASE WHEN {} IS NULL OR {} IS NULL THEN NULL ELSE FALSE END)", + a.sql, b.sql + ), + }, + BinaryOp::Less | BinaryOp::LessEq => { + if a.repr != Repr::Long || b.repr != Repr::Long { + return Err(Error::Unsupported("comparison of non-integers")); + } + let cmp = if op == BinaryOp::Less { "<" } else { "<=" }; + format!("({} {cmp} {})", a.sql, b.sql) + } + BinaryOp::Add | BinaryOp::Sub | BinaryOp::Mul => { + if a.repr != Repr::Long || b.repr != Repr::Long { + return Err(Error::Unsupported("arithmetic on non-integers")); + } + let sym = match op { + BinaryOp::Add => "+", + BinaryOp::Sub => "-", + _ => "*", + }; + // Computed in `numeric`, which cannot overflow, then range-checked. + let r = format!("({}::numeric {sym} {}::numeric)", a.sql, b.sql); + format!( + "(CASE WHEN {r} BETWEEN {min} AND {max} THEN {r}::bigint ELSE NULL END)", + min = i64::MIN, + max = i64::MAX + ) + } + BinaryOp::In => return self.in_op(&a, arg2, &b, repr), + BinaryOp::HasTag | BinaryOp::GetTag => { + let Repr::Entity(ety) = &a.repr else { + return Err(Error::Unsupported("tags of a non-entity value")); + }; + if b.repr != Repr::Text { + return Err(Error::Unsupported("a non-string tag")); + } + let Some((_, table)) = self.config.table_for(ety) else { + return Err(Error::Unsupported("tags of an entity type without a table")); + }; + let Some(tags) = &table.tags else { + return Err(Error::Unsupported("tags of an entity type without tags")); + }; + let lookup = format!( + "FROM {} AS t WHERE t.\"{TAGS_ENTITY_ID_COLUMN}\" = {} AND t.\"{TAGS_TAG_COLUMN}\" = {}", + tags.table, a.sql, b.sql + ); + if op == BinaryOp::HasTag { + format!( + "(CASE WHEN {} IS NULL OR {} IS NULL THEN NULL ELSE EXISTS (SELECT 1 {lookup}) END)", + a.sql, b.sql + ) + } else { + let agrees = matches!( + (&tags.value.ty, &repr), + (SQLType::Text, Repr::Text | Repr::Entity(_)) + | (SQLType::Bool, Repr::Bool) + | (SQLType::BigInt, Repr::Long) + | (SQLType::Jsonb, Repr::Json) + ); + if !agrees { + return Err(Error::Unsupported( + "a tag value whose column type disagrees with its type", + )); + } + // No row (missing entity or tag), or a NULL input: NULL, an error. + format!("(SELECT t.\"{TAGS_VALUE_COLUMN}\" {lookup})") + } + } + BinaryOp::Contains | BinaryOp::ContainsAll | BinaryOp::ContainsAny => { + return Err(Error::Unsupported("set operations")); + } + }; + Ok(Compiled { + sql, + repr, + anchor: None, + }) + } + + fn in_op( + &mut self, + a: &Compiled, + arg2: &Residual, + b: &Compiled, + repr: Repr, + ) -> Result { + let Repr::Entity(t1) = &a.repr else { + return Err(Error::Unsupported("in on a non-entity value")); + }; + let t1 = t1.clone(); + let sql = match &b.repr { + Repr::Entity(t2) => { + let check = self.ancestor_check(a, &t1, &b.sql, t2)?; + format!( + "(CASE WHEN {} IS NULL OR {} IS NULL THEN NULL ELSE {check} END)", + a.sql, b.sql + ) + } + Repr::Json => { + // Only a literal set of entities, whose elements need no guard. + let Residual::Concrete { value, .. } = arg2 else { + return Err(Error::Unsupported("in with a computed set")); + }; + let ValueKind::Set(set) = value.value_kind() else { + return Err(Error::Unsupported("in with a non-set")); + }; + let mut checks = Vec::new(); + for element in set.iter() { + let ValueKind::Lit(Literal::EntityUID(uid)) = element.value_kind() else { + return Err(Error::Unsupported("in with a set of non-entities")); + }; + let id = quoted_literal(uid.eid().as_ref())?; + checks.push(self.ancestor_check(a, &t1, &id, uid.entity_type())?); + } + if checks.is_empty() { + format!("(CASE WHEN {} IS NULL THEN NULL ELSE FALSE END)", a.sql) + } else { + format!( + "(CASE WHEN {} IS NULL THEN NULL ELSE ({}) END)", + a.sql, + checks.join(" OR ") + ) + } + } + _ => { + return Err(Error::Unsupported( + "in with a non-entity, non-set right side", + )); + } + }; + Ok(Compiled { + sql, + repr, + anchor: None, + }) + } + + /// `a in b` for non-NULL `a` (of type `t1`) and `b` (of type `t2`). + fn ancestor_check( + &mut self, + a: &Compiled, + t1: &EntityType, + b: &str, + t2: &EntityType, + ) -> Result { + let same = if t1 == t2 { + format!("{} = {b}", a.sql) + } else { + "FALSE".to_owned() + }; + let ancestor = if self.config.hierarchy_closed { + format!( + "EXISTS (SELECT 1 FROM {} AS h WHERE h.\"{}\" = {} AND h.\"{}\" = {} AND h.\"{}\" = {} AND h.\"{}\" = {b})", + self.config.entity_hierarchy_table, + HIERARCHY_COLUMNS[0], + quoted_literal(&t1.to_string())?, + HIERARCHY_COLUMNS[1], + a.sql, + HIERARCHY_COLUMNS[2], + quoted_literal(&t2.to_string())?, + HIERARCHY_COLUMNS[3], + ) + } else { + let Some(anchor) = &a.anchor else { + return Err(Error::Unsupported( + "in on a computed entity value over a hierarchy table without its closure", + )); + }; + let cte = self.ensure_ancestors(anchor, t1)?; + format!( + "EXISTS (SELECT 1 FROM {cte} AS anc WHERE anc.{ID_COLUMN} = {}.{ID_COLUMN} AND anc.\"{}\" = {} AND anc.\"{}\" = {b})", + root_name(&anchor.root), + HIERARCHY_COLUMNS[2], + quoted_literal(&t2.to_string())?, + HIERARCHY_COLUMNS[3], + ) + }; + Ok(format!("({same} OR {ancestor})")) + } + + fn ensure_ancestors(&mut self, anchor: &Anchor, ety: &EntityType) -> Result { + let root_type = self.root_type(anchor); + self.ensure_root(&anchor.root, &root_type); + if !anchor.path.is_empty() { + // The chain's value is exported by the parent's alias, whose join + // `anchored_attr` registered when the chain was compiled. + let root = self.roots.get_mut(&anchor.root).expect("just ensured"); + root.values.insert(anchor.path.clone()); + } + if !self.ancestors.iter().any(|c| c.anchor == *anchor) { + self.ancestors.push(AncestorsCte { + anchor: anchor.clone(), + ety: ety.clone(), + }); + } + ancestors_name(anchor) + } + + /// Renders the query: `columns` are `(alias, expression)` pairs selected + /// after the root ids of the unknown variables. + pub fn render(&self, columns: &[(String, String)]) -> Result { + let mut ctes = Vec::new(); + for (key, root) in &self.roots { + ctes.push(self.render_root(key, root)?); + } + for cte in &self.ancestors { + ctes.push(self.render_ancestors(cte)?); + } + let mut sql = String::new(); + if !ctes.is_empty() { + let recursive = if self.ancestors.is_empty() { + "" + } else { + "RECURSIVE " + }; + let _ = writeln!(sql, "WITH {recursive}{}", ctes.join(",\n")); + } + let mut selected = Vec::new(); + for key in [RootKey::Principal, RootKey::Resource] { + if self.roots.contains_key(&key) { + selected.push(format!( + "{}.{ID_COLUMN} AS {}", + root_name(&key), + root_name(&key) + )); + } + } + for (alias, expr) in columns { + selected.push(format!("{expr} AS {}", SQLIdentifier::new(alias.as_str())?)); + } + let _ = write!( + sql, + "SELECT {}\nFROM (SELECT 1 AS base) AS base", + selected.join(",\n ") + ); + for key in self.roots.keys() { + match key { + RootKey::Principal | RootKey::Resource => { + let _ = write!(sql, "\nCROSS JOIN {}", root_name(key)); + } + RootKey::Literal(_) => { + let _ = write!(sql, "\nLEFT JOIN {} ON TRUE", root_name(key)); + } + } + } + Ok(sql) + } + + fn render_root(&self, key: &RootKey, root: &Root) -> Result { + let name = root_name(key); + let Some((table_name, table)) = self.config.table_for(&root.ety) else { + return Err(Error::Unsupported("an entity type without a table")); + }; + let mut select = vec![format!("{name}.{} AS {ID_COLUMN}", table.entity_id_column)]; + let mut joins = String::new(); + let mut ordered: Vec<(&Vec, &EntityType)> = root.joins.iter().collect(); + ordered.sort_by_key(|(path, _)| path.len()); + for (path, ety) in ordered { + let (attr, prefix) = path.split_last().expect("join paths are non-empty"); + let parent_alias = alias_name(key, prefix)?; + let parent_type = if prefix.is_empty() { + &root.ety + } else { + &root.joins[prefix] + }; + let Some(column) = self.config.column_for(parent_type, attr) else { + return Err(Error::Unsupported("an attribute without a column")); + }; + let Some((joined_table, joined)) = self.config.table_for(ety) else { + return Err(Error::Unsupported("an entity type without a table")); + }; + let alias = alias_name(key, path)?; + let _ = write!( + joins, + "\n LEFT JOIN {joined_table} AS {alias} ON {parent_alias}.{column} = {alias}.{}", + joined.entity_id_column + ); + } + for path in &root.values { + let (attr, prefix) = path.split_last().expect("value paths are non-empty"); + let owner_type = if prefix.is_empty() { + &root.ety + } else { + &root.joins[prefix] + }; + let Some(column) = self.config.column_for(owner_type, attr) else { + return Err(Error::Unsupported("an attribute without a column")); + }; + select.push(format!( + "{}.{column} AS {}", + alias_name(key, prefix)?, + value_column(path) + )); + } + let filter = match key { + RootKey::Literal(uid) => format!( + "\n WHERE {name}.{} = {}", + table.entity_id_column, + quoted_literal(uid.eid().as_ref())? + ), + _ => String::new(), + }; + Ok(format!( + "{name} AS (\n SELECT {}\n FROM {table_name} AS {name}{joins}{filter}\n)", + select.join(",\n ") + )) + } + + fn render_ancestors(&self, cte: &AncestorsCte) -> Result { + let name = ancestors_name(&cte.anchor)?; + let root = root_name(&cte.anchor.root); + let value = if cte.anchor.path.is_empty() { + format!("{root}.{ID_COLUMN}") + } else { + format!("{root}.{}", value_column(&cte.anchor.path)) + }; + let hierarchy = &self.config.entity_hierarchy_table; + let [dt, di, at, ai] = HIERARCHY_COLUMNS; + Ok(format!( + "{name} AS (\n SELECT {root}.{ID_COLUMN} AS {ID_COLUMN}, {} AS \"{at}\", {value} AS \"{ai}\"\n FROM {root}\n WHERE {value} IS NOT NULL\n UNION\n SELECT anc.{ID_COLUMN}, h.\"{at}\", h.\"{ai}\"\n FROM {name} AS anc\n JOIN {hierarchy} AS h ON h.\"{dt}\" = anc.\"{at}\" AND h.\"{di}\" = anc.\"{ai}\"\n)", + quoted_literal(&cte.ety.to_string())? + )) + } + + /// The roots the query selects rows for, in order: the unknown variables. + pub fn unknown_roots(&self) -> Vec { + [RootKey::Principal, RootKey::Resource] + .into_iter() + .filter(|k| self.roots.contains_key(k)) + .collect() + } +} + +/// The column every root CTE exports for its entity id. +const ID_COLUMN: &str = "\"$id\""; + +/// A JSONB value in the canonical encoding, as a native value of `repr`. +fn json_to_repr(json: &str, repr: &Repr) -> String { + match repr { + Repr::Bool => format!("(({json}) #>> '{{}}')::boolean"), + Repr::Long => format!("(({json}) #>> '{{}}')::bigint"), + Repr::Text => format!("(({json}) #>> '{{}}')"), + Repr::Entity(_) => format!("(({json}) -> 'e' ->> 'i')"), + Repr::Json => format!("({json})"), + } +} + +/// The quoted CTE name of a root. +fn root_name(root: &RootKey) -> String { + match root { + RootKey::Principal => "\"principal\"".to_owned(), + RootKey::Resource => "\"resource\"".to_owned(), + RootKey::Literal(uid) => shortened(&uid.to_string()).to_string(), + } +} + +/// The quoted alias of the join for `path` under `root` (the root itself +/// for the empty path). +fn alias_name(root: &RootKey, path: &[SmolStr]) -> Result { + if path.is_empty() { + return Ok(root_name(root)); + } + let raw = match root { + RootKey::Principal => "principal".to_owned(), + RootKey::Resource => "resource".to_owned(), + RootKey::Literal(uid) => uid.to_string(), + }; + Ok(shortened(&format!("{raw}.{}", path.join("."))).to_string()) +} + +/// The quoted CTE column holding the value of an attribute path. +fn value_column(path: &[SmolStr]) -> String { + shortened(&format!("$v:{}", path.join("."))).to_string() +} + +fn ancestors_name(anchor: &Anchor) -> Result { + let raw = match &anchor.root { + RootKey::Principal => "principal".to_owned(), + RootKey::Resource => "resource".to_owned(), + RootKey::Literal(uid) => uid.to_string(), + }; + let path = if anchor.path.is_empty() { + String::new() + } else { + format!(".{}", anchor.path.join(".")) + }; + Ok(shortened(&format!("{raw}{path}$ancestors"))) +} diff --git a/src/error.rs b/src/error.rs index 2db60e2..4efea1f 100644 --- a/src/error.rs +++ b/src/error.rs @@ -27,11 +27,24 @@ pub enum Error { /// What is wrong with it. message: String, }, + /// The policies do not validate against the schema (strict mode), which + /// the compilation requires. + #[error("the policies do not validate: {0}")] + Validation(String), + /// The request is not valid for the schema, or is not concrete enough. + #[error("invalid request: {0}")] + Request(String), + /// Partial evaluation failed. + #[error("partial evaluation failed: {0}")] + Tpe(String), + /// The query returned rows this crate cannot interpret. + #[error("unexpected query result: {0}")] + Query(String), /// The entities cannot be turned into rows of the configured tables. #[error("cannot load entities: {0}")] Load(String), /// The database returned an error. - #[error("database error: {0}")] + #[error("database error: {}", describe(.0))] Database(#[from] postgres::Error), /// A row's column had a type or value this crate cannot decode. #[error("cannot decode column {column}: {message}")] @@ -45,3 +58,17 @@ pub enum Error { #[error("cannot provision a Postgres for testing: {0}")] Provision(String), } + +/// The server's message when there is one (`postgres::Error`'s own `Display` +/// is only "db error"), else the client-side description. +fn describe(error: &postgres::Error) -> String { + match error.as_db_error() { + Some(db) => format!( + "{} ({}){}", + db.message(), + db.code().code(), + db.detail().map(|d| format!(": {d}")).unwrap_or_default() + ), + None => error.to_string(), + } +} diff --git a/tests/authorize_pg.rs b/tests/authorize_pg.rs new file mode 100644 index 0000000..ff57ca5 --- /dev/null +++ b/tests/authorize_pg.rs @@ -0,0 +1,571 @@ +//! Concrete authorization through SQL against `cedar-policy`'s authorizer. +//! +//! Every policy here validates strictly (the compiler requires it), so +//! runtime errors come from entities that are missing from the store, from +//! dangling references, and from arithmetic overflow. + +use std::collections::BTreeSet; +use std::str::FromStr; + +use cedar_policy::{ + Authorizer, Context, Decision, Entities, EntityUid, PolicyId, PolicySet, Request, +}; +use cedar_sql::Error; +use cedar_sql::authorizer::{Response, SqlAuthorizer}; +use cedar_sql::backend::Backend; +use cedar_sql::backend::postgres::PgBackend; +use cedar_sql::config::DatabaseConfiguration; +use cedar_sql::ddl::create_tables; +use cedar_sql::dialect::Postgres; +use cedar_sql::load::entities_to_sql; +use cedar_sql::testing::SharedPostgres; + +struct Fixture { + schema: cedar_policy::Schema, + config: DatabaseConfiguration, + entities: Entities, + db: PgBackend, +} + +impl Fixture { + fn new(closed: bool) -> Self { + let src = std::fs::read_to_string("tests/schemas/kitchen_sink.cedarschema").unwrap(); + let (mut config, schema) = DatabaseConfiguration::from_cedarschema_str(&src).unwrap(); + config.emit_foreign_keys = false; + config.hierarchy_closed = closed; + let json = std::fs::read_to_string("tests/entities/kitchen_sink.json").unwrap(); + let entities = Entities::from_json_str(&json, Some(&schema)).unwrap(); + let mut db = SharedPostgres::get().unwrap().connect().unwrap(); + db.begin().unwrap(); + for statement in create_tables(&config, &Postgres).unwrap() { + db.execute_batch(&statement).unwrap(); + } + let load = entities_to_sql(&entities, &schema, &config, &Postgres).unwrap(); + for statement in &load.statements { + db.execute_batch(statement).unwrap(); + } + Self { + schema, + config, + entities, + db, + } + } + + fn request(&self, principal: &str, resource: &str) -> Request { + Request::new( + EntityUid::from_str(principal).unwrap(), + EntityUid::from_str("Action::\"view\"").unwrap(), + EntityUid::from_str(resource).unwrap(), + Context::from_json_str(r#"{"ip": "1.2.3.4"}"#, None).unwrap(), + Some(&self.schema), + ) + .unwrap() + } + + /// Authorizes through SQL and through `cedar-policy`; they must agree. + fn check(&mut self, policies: &str, principal: &str, resource: &str) -> Response { + let policies = PolicySet::from_str(policies).unwrap(); + let request = self.request(principal, resource); + let sql = SqlAuthorizer::new(&self.schema, &self.config, &Postgres, &policies) + .unwrap_or_else(|e| panic!("{policies}\n{e}")) + .is_authorized(&mut self.db, &request, &self.entities) + .unwrap_or_else(|e| panic!("{policies}\n{e}")); + let expected = Authorizer::new().is_authorized(&request, &policies, &self.entities); + let reason: BTreeSet = expected.diagnostics().reason().cloned().collect(); + let errors: BTreeSet = expected + .diagnostics() + .errors() + .map(|e| match e { + cedar_policy::AuthorizationError::PolicyEvaluationError(e) => e.policy_id().clone(), + }) + .collect(); + assert_eq!( + (sql.decision, &sql.reason, &sql.errors), + (expected.decision(), &reason, &errors), + "{policies}\n{request}" + ); + sql + } + + fn unsupported(&mut self, policies: &str, principal: &str, resource: &str) -> String { + let policies = PolicySet::from_str(policies).unwrap(); + let request = self.request(principal, resource); + match SqlAuthorizer::new(&self.schema, &self.config, &Postgres, &policies) + .unwrap() + .is_authorized(&mut self.db, &request, &self.entities) + { + Err(Error::Unsupported(what)) => what.to_owned(), + other => panic!("expected an unsupported error, got {other:?}"), + } + } +} + +const ALICE: &str = "User::\"alice\""; +const BOB: &str = "User::\"bob\""; +const CAROL: &str = "User::\"carol\""; +const NOBODY: &str = "User::\"nobody\""; +const D1: &str = "Doc::\"d1\""; +const GHOST_NAME: &str = "User::\"ghost\".name == \"x\""; + +fn permit(condition: &str) -> String { + format!("permit(principal, action, resource) when {{ {condition} }};") +} + +fn ids(response: &Response) -> (Vec, Vec) { + ( + response.reason.iter().map(ToString::to_string).collect(), + response.errors.iter().map(ToString::to_string).collect(), + ) +} + +impl Fixture { + /// The single policy errors. + fn errored(&mut self, policies: &str, principal: &str, resource: &str) { + let r = self.check(policies, principal, resource); + assert_eq!( + ids(&r), + (vec![], vec!["policy0".to_owned()]), + "{policies} {principal}" + ); + } + + /// No policy errors. + fn clean(&mut self, policies: &str, principal: &str, resource: &str) -> Response { + let r = self.check(policies, principal, resource); + assert!(r.errors.is_empty(), "{policies} {principal}: {r:?}"); + r + } +} + +#[test] +fn attributes_and_errors() { + let mut fx = Fixture::new(true); + let p = permit("principal.name == \"Alice\""); + assert_eq!(fx.check(&p, ALICE, D1).decision, Decision::Allow); + assert_eq!(fx.check(&p, BOB, D1).decision, Decision::Deny); + // A missing principal row errors on access and is false for `has`. + fx.errored(&p, NOBODY, D1); + fx.clean( + &permit("principal has age && principal.age > 18"), + NOBODY, + D1, + ); + // Optional attributes. + assert_eq!( + fx.check( + &permit("principal has age && principal.age > 18"), + ALICE, + D1 + ) + .decision, + Decision::Allow + ); + fx.clean(&permit("principal has age && principal.age > 18"), BOB, D1); + fx.clean(&permit("principal has age"), BOB, D1); + // Chains: an existing friend, a dangling friend (`has` is false, an access errors). + let friend = + "principal has friend && principal.friend has name && principal.friend.name == \"Bob\""; + assert_eq!( + fx.check(&permit(friend), ALICE, D1).decision, + Decision::Allow + ); + fx.clean(&permit(friend), BOB, D1); + fx.clean( + &permit("principal has friend && principal.friend has name"), + CAROL, + D1, + ); + fx.errored( + &permit("principal has friend && principal.friend.name == \"x\""), + CAROL, + D1, + ); + fx.clean( + &permit("principal has friend && principal.friend has friend && principal.friend.friend has name"), + ALICE, + D1, + ); + // Literal roots, existing and not. + assert_eq!( + fx.check(&permit("User::\"alice\".name == \"Alice\""), BOB, D1) + .decision, + Decision::Allow + ); + fx.errored(&permit(GHOST_NAME), BOB, D1); + // Several policies dereferencing the same literal share its CTE. + let r = fx.check( + &format!( + "{}\n{}", + permit("User::\"alice\".name == \"Alice\""), + permit("User::\"alice\".admin") + ), + BOB, + D1, + ); + assert_eq!( + ids(&r), + (vec!["policy0".to_owned(), "policy1".to_owned()], vec![]) + ); + // The same attribute referenced twice, and a resource attribute. + assert_eq!( + fx.check( + &permit("resource.owner == principal && resource.owner == User::\"alice\""), + ALICE, + D1 + ) + .decision, + Decision::Allow + ); +} + +#[test] +fn records_like_is_arithmetic() { + let mut fx = Fixture::new(true); + assert_eq!( + fx.check(&permit("principal.profile.city == \"Zürich\""), ALICE, D1) + .decision, + Decision::Allow + ); + assert_eq!( + fx.check(&permit("principal.profile has boss"), ALICE, D1) + .decision, + Decision::Allow + ); + assert_eq!( + fx.check(&permit("principal.profile has boss"), BOB, D1) + .decision, + Decision::Deny + ); + assert_eq!( + fx.check( + &permit("principal.profile has boss && principal.profile.boss == User::\"bob\""), + ALICE, + D1 + ) + .decision, + Decision::Allow + ); + fx.errored(&permit("principal.profile.city == \"x\""), NOBODY, D1); + assert_eq!( + fx.check(&permit("principal.name like \"Al*\""), ALICE, D1) + .decision, + Decision::Allow + ); + assert_eq!( + fx.check(&permit("principal.name like \"A_\""), ALICE, D1) + .decision, + Decision::Deny + ); + assert_eq!( + fx.check(&permit("principal.name like \"%\""), ALICE, D1) + .decision, + Decision::Deny + ); + assert_eq!( + fx.check(&permit("principal.name like \"*\\\\*\""), ALICE, D1) + .decision, + Decision::Deny + ); + assert_eq!( + fx.check(&permit("principal.name like \"*e\""), ALICE, D1) + .decision, + Decision::Allow + ); + fx.errored(&permit("principal.name like \"*\""), NOBODY, D1); + assert_eq!( + fx.check( + &permit("principal has friend && principal.friend is User"), + ALICE, + D1 + ) + .decision, + Decision::Allow + ); + assert_eq!( + fx.check( + &permit("principal has age && principal.age * 2 == 60"), + ALICE, + D1 + ) + .decision, + Decision::Allow + ); + assert_eq!( + fx.check( + &permit("principal has age && -(principal.age) == -30"), + ALICE, + D1 + ) + .decision, + Decision::Allow + ); + fx.errored( + &permit("principal has age && principal.age + 9223372036854775807 > 0"), + ALICE, + D1, + ); + fx.errored( + &permit("principal has age && principal.age - 9223372036854775807 - 32 < 0"), + ALICE, + D1, + ); + // 30 - MAX - 31 is exactly i64::MIN, whose negation overflows. + fx.errored( + &permit("principal has age && -(principal.age - 9223372036854775807 - 31) > 0"), + ALICE, + D1, + ); + fx.clean( + &permit("principal has age && -(principal.age - 9223372036854775807 - 30) > 0"), + ALICE, + D1, + ); + fx.errored( + &permit("principal has age && principal.age * 9223372036854775807 > 0"), + ALICE, + D1, + ); + fx.clean( + &permit("principal has age && principal.age * -307445734561825860 < 0"), + ALICE, + D1, + ); + assert_eq!( + fx.check( + &permit("principal has age && principal.age < 31 && principal.age <= 30"), + ALICE, + D1 + ) + .decision, + Decision::Allow + ); + fx.clean( + &permit("if principal has age then principal.age > 18 else false"), + BOB, + D1, + ); + // `false && error` is false; `true && error` is an error; the untaken branch of + // an `if` is never evaluated, but an erroring condition errors. + fx.clean( + &permit(&format!("principal.name == \"Bob\" && {GHOST_NAME}")), + ALICE, + D1, + ); + fx.errored( + &permit(&format!("principal.name == \"Bob\" && {GHOST_NAME}")), + BOB, + D1, + ); + fx.clean( + &permit(&format!("principal.name == \"Bob\" || {GHOST_NAME}")), + BOB, + D1, + ); + fx.errored( + &permit(&format!("principal.name == \"Alice\" || {GHOST_NAME}")), + BOB, + D1, + ); + fx.clean( + &permit(&format!( + "if principal.name == \"Bob\" then true else {GHOST_NAME}" + )), + BOB, + D1, + ); + fx.errored( + &permit(&format!("if {GHOST_NAME} then true else false")), + BOB, + D1, + ); + fx.errored(&permit(&format!("!({GHOST_NAME})")), BOB, D1); + // Different entity types are never equal; a missing operand still errors. + fx.clean( + &permit("principal has friend && principal.friend == resource.owner"), + ALICE, + D1, + ); + assert_eq!( + fx.check( + &permit("principal has friend && principal.friend == resource.owner"), + ALICE, + D1 + ) + .decision, + Decision::Deny + ); + fx.errored(&permit("resource.owner == Doc::\"nope\".owner"), ALICE, D1); + fx.clean(&permit("context.ip == \"1.2.3.4\""), ALICE, D1); + assert_eq!( + fx.unsupported(&permit("principal.groups == [\"a\"]"), ALICE, D1), + "equality of sets or records" + ); +} + +#[test] +fn hierarchy_and_tags() { + for closed in [true, false] { + let mut fx = Fixture::new(closed); + let allow = |fx: &mut Fixture, p: &str, principal: &str| { + assert_eq!( + fx.check(&permit(p), principal, D1).decision, + Decision::Allow, + "{p} {principal} closed={closed}" + ); + }; + let deny = |fx: &mut Fixture, p: &str, principal: &str| { + let r = fx.clean(&permit(p), principal, D1); + assert_eq!( + r.decision, + Decision::Deny, + "{p} {principal} closed={closed}" + ); + }; + allow(&mut fx, "principal in Group::\"admins\"", ALICE); + deny(&mut fx, "principal in Group::\"admins\"", BOB); + allow(&mut fx, "resource in Group::\"admins\"", BOB); + allow( + &mut fx, + "principal in [Group::\"x\", Group::\"admins\"]", + ALICE, + ); + deny(&mut fx, "principal in [Group::\"x\", Group::\"y\"]", ALICE); + deny(&mut fx, "principal in Group::\"admins\"", NOBODY); + allow(&mut fx, "Doc::\"d1\".owner in Group::\"admins\"", BOB); + deny(&mut fx, "Doc::\"d1\".owner in Group::\"nope\"", BOB); + // Carol's friend does not exist, Bob has none, Alice's is not a member. + deny( + &mut fx, + "principal has friend && principal.friend in Group::\"admins\"", + CAROL, + ); + deny( + &mut fx, + "principal has friend && principal.friend in Group::\"admins\"", + BOB, + ); + deny( + &mut fx, + "principal has friend && principal.friend in Group::\"admins\"", + ALICE, + ); + deny( + &mut fx, + "principal has friend && principal.friend in principal", + CAROL, + ); + allow(&mut fx, "resource.owner in principal", ALICE); + deny(&mut fx, "resource.owner in principal", BOB); + allow(&mut fx, "principal in resource.owner", ALICE); + deny(&mut fx, "resource in resource.owner", ALICE); + // A missing entity on the left of `in` is false, an errored operand errors. + deny(&mut fx, "User::\"ghost\" in principal", ALICE); + fx.errored(&permit("Doc::\"nope\".owner in principal"), ALICE, D1); + fx.errored(&permit("principal in Doc::\"nope\".owner"), ALICE, D1); + allow( + &mut fx, + "principal.hasTag(\"k\") && principal.getTag(\"k\") == \"v\"", + ALICE, + ); + deny( + &mut fx, + "principal.hasTag(\"k\") && principal.getTag(\"k\") == \"v\"", + BOB, + ); + deny( + &mut fx, + "principal.hasTag(\"k\") && principal.getTag(\"k\") == \"v\"", + NOBODY, + ); + allow( + &mut fx, + "principal.hasTag(\"it's\") && principal.getTag(\"it's\") == \"q'\"", + ALICE, + ); + allow( + &mut fx, + "resource.hasTag(\"reviewer\") && resource.getTag(\"reviewer\") == User::\"bob\"", + ALICE, + ); + deny(&mut fx, "resource.hasTag(principal.name)", ALICE); + fx.errored(&permit("resource.hasTag(User::\"ghost\".name)"), ALICE, D1); + assert_eq!( + fx.unsupported( + &permit( + "resource.hasTag(\"reviewer\") && resource.getTag(\"reviewer\").name == \"Bob\"" + ), + ALICE, + D1 + ), + "attribute access on a computed entity value" + ); + } +} + +#[test] +fn decisions() { + let mut fx = Fixture::new(true); + let both = format!( + "{}\nforbid(principal, action, resource) when {{ principal.name == \"Alice\" }};", + permit("true") + ); + let r = fx.check(&both, ALICE, D1); + assert_eq!(r.decision, Decision::Deny); + assert_eq!(ids(&r), (vec!["policy1".to_owned()], vec![])); + let r = fx.check(&both, BOB, D1); + assert_eq!(r.decision, Decision::Allow); + assert_eq!(ids(&r), (vec!["policy0".to_owned()], vec![])); + // An erroring policy is reported and ignored for the decision. + let two = format!( + "{}\n{}", + permit(GHOST_NAME), + permit("principal.name == \"Bob\"") + ); + let r = fx.check(&two, BOB, D1); + assert_eq!(r.decision, Decision::Allow); + assert_eq!( + ids(&r), + (vec!["policy1".to_owned()], vec!["policy0".to_owned()]) + ); + let r = fx.check(&two, ALICE, D1); + assert_eq!(r.decision, Decision::Deny); + assert_eq!(ids(&r), (vec![], vec!["policy0".to_owned()])); + // A forbid that errors does not deny. + let forbid_errs = format!( + "{}\nforbid(principal, action, resource) when {{ {GHOST_NAME} }};", + permit("true") + ); + let r = fx.check(&forbid_errs, ALICE, D1); + assert_eq!(r.decision, Decision::Allow); + assert_eq!( + ids(&r), + (vec!["policy0".to_owned()], vec!["policy1".to_owned()]) + ); + // Nothing residual: no query at all. + fx.check( + "permit(principal == User::\"alice\", action, resource);", + ALICE, + D1, + ); + fx.check( + "permit(principal == User::\"alice\", action, resource);", + BOB, + D1, + ); + // A residual policy next to folded ones. + let mixed = format!( + "permit(principal == User::\"alice\", action, resource);\n{}", + permit("principal.name == \"Bob\"") + ); + let r = fx.check(&mixed, ALICE, D1); + assert_eq!(ids(&r), (vec!["policy0".to_owned()], vec![])); + let r = fx.check(&mixed, BOB, D1); + assert_eq!(ids(&r), (vec!["policy1".to_owned()], vec![])); + // Policies that do not validate are rejected up front. + let policies = PolicySet::from_str(&permit("principal.nope == 1")).unwrap(); + assert!(matches!( + SqlAuthorizer::new(&fx.schema, &fx.config, &Postgres, &policies), + Err(Error::Validation(_)) + )); +} diff --git a/tests/entities/kitchen_sink.json b/tests/entities/kitchen_sink.json index 682691a..ddea2e1 100644 --- a/tests/entities/kitchen_sink.json +++ b/tests/entities/kitchen_sink.json @@ -13,6 +13,10 @@ {"uid": {"type": "User", "id": "bob"}, "attrs": {"name": "Bob", "admin": false, "groups": [], "profile": {"city": "", "pets": []}, "friends": []}, "parents": []}, + {"uid": {"type": "User", "id": "carol"}, + "attrs": {"name": "Carol", "admin": false, "groups": ["x"], "profile": {"city": "Oslo", "pets": [7]}, + "friend": {"__entity": {"type": "User", "id": "ghost"}}, "friends": []}, + "parents": []}, {"uid": {"type": "Doc", "id": "d1"}, "attrs": {"owner": {"__entity": {"type": "User", "id": "alice"}}}, "parents": [{"type": "Group", "id": "admins"}], diff --git a/tests/load_pg.rs b/tests/load_pg.rs index 2d7ca7a..41fe01d 100644 --- a/tests/load_pg.rs +++ b/tests/load_pg.rs @@ -34,8 +34,24 @@ fn round_trip() { db.execute_batch(statement) .unwrap_or_else(|e| panic!("{statement}\n{e}")); } - // The deferred foreign keys hold. - db.execute_batch("SET CONSTRAINTS ALL IMMEDIATE").unwrap(); + // The deferred foreign keys hold, except carol's dangling friend. + let violation = db + .execute_batch("SET CONSTRAINTS ALL IMMEDIATE") + .expect_err("carol's friend does not exist"); + assert!( + violation.to_string().contains("User_friend_fkey"), + "{violation}" + ); + db.rollback().unwrap(); + db.begin().unwrap(); + let mut without_fks = config.clone(); + without_fks.emit_foreign_keys = false; + for statement in create_tables(&without_fks, &Postgres).unwrap() { + db.execute_batch(&statement).unwrap(); + } + for statement in &load.statements { + db.execute_batch(statement).unwrap(); + } let users = db .query( @@ -68,6 +84,17 @@ fn round_trip() { SqlValue::Null, SqlValue::Json(json!([])), ], + vec![ + text("carol"), + text("User"), + text("Carol"), + SqlValue::Null, + SqlValue::Bool(false), + SqlValue::Json(json!(["x"])), + SqlValue::Json(json!({"r": {"city": "Oslo", "pets": [7]}})), + text("ghost"), + SqlValue::Json(json!([])), + ], ] ); let hierarchy = db