From d4855c16a606805ce10bf8144d40ae3500c97f54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20K=C3=A4ldstr=C3=B6m?= Date: Sun, 13 Sep 2026 22:33:21 +0300 Subject: [PATCH 1/3] =?UTF-8?q?Plan=203:=20is-authorized=20=E2=80=94=20con?= =?UTF-8?q?crete=20authorization=20through=20one=20query?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01JSRPugoPu8zLyykBCW6QJh --- docs/plans/3-is-authorized.md | 58 +++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 docs/plans/3-is-authorized.md 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`. From 462a6820da40451ddb5b3b5ffb185e3a074fcd49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20K=C3=A4ldstr=C3=B6m?= Date: Sun, 13 Sep 2026 22:33:21 +0300 Subject: [PATCH 2/3] Compile TPE residuals into one query and authorize concrete requests `SqlAuthorizer` validates the policies, partially evaluates them with the request and the action entities known, compiles the residuals with the three-valued encoding (NULL is an error; CASE forms for the boolean operators, guarded arithmetic, tags as correlated subqueries, `in` on the closed hierarchy table or a recursive ancestors CTE) over one CTE per root with a LEFT JOIN per attribute path, and rebuilds the decision, the determining policies and the erroring policies from the row. Database errors now carry the server's message. Tests compare every case with `cedar_policy::Authorizer`. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01JSRPugoPu8zLyykBCW6QJh --- src/authorizer.rs | 263 ++++++++- src/compile.rs | 881 ++++++++++++++++++++++++++++++- src/error.rs | 29 +- tests/authorize_pg.rs | 571 ++++++++++++++++++++ tests/entities/kitchen_sink.json | 4 + tests/load_pg.rs | 31 +- 6 files changed, 1769 insertions(+), 10 deletions(-) create mode 100644 tests/authorize_pg.rs 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 From ab9d81eddc2a94df3aa538c4847ef3a5683267b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20K=C3=A4ldstr=C3=B6m?= Date: Sun, 13 Sep 2026 23:05:43 +0300 Subject: [PATCH 3/3] Follow-ups for branch 3: is-authorized (review) Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01JSRPugoPu8zLyykBCW6QJh --- docs/followups/3-is-authorized.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 docs/followups/3-is-authorized.md 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).