Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
name: Build and test cedar-sql

on:
pull_request:
push:
branches: [main]

permissions:
contents: read

jobs:
build_and_test:
name: Build and test
runs-on: ubuntu-latest
services:
postgres:
image: postgres:18
env:
POSTGRES_HOST_AUTH_METHOD: trust
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 5s
--health-timeout 5s
--health-retries 10
env:
CEDAR_SQL_PG_URL: postgres://postgres@localhost:5432/postgres
steps:
- name: Checkout cedar-sql
uses: actions/checkout@v7
with:
path: ./cedar-sql
- name: Checkout cedar (luxas/cedar-woodpecker)
uses: actions/checkout@v7
with:
repository: luxas/cedar-woodpecker
path: ./cedar
- name: Prepare Rust build
run: rustup update stable && rustup default stable && rustup component add rustfmt clippy
- name: cargo fmt
working-directory: ./cedar-sql
run: cargo fmt --all --check
- name: cargo build
working-directory: ./cedar-sql
run: RUSTFLAGS="-D warnings" cargo build --all-features
- name: cargo clippy
working-directory: ./cedar-sql
run: cargo clippy --all-targets --all-features -- -D warnings
- name: cargo test
working-directory: ./cedar-sql
run: cargo test --all-features -- --nocapture
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,6 @@ target
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/

# The crate is patched to a sibling checkout of cedar; no lockfile is tracked, as in cedar-spec.
Cargo.lock
37 changes: 37 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
[package]
name = "cedar-sql"
edition = "2024"
version = "0.1.0"
license = "Apache-2.0"
publish = false
description = "An experimental Cedar evaluator that compiles policy evaluation and entity fetching into SQL"
repository = "https://github.com/luxas/cedar-sql"

[dependencies]
cedar-policy = { version = "4.12.0", features = ["tpe"] }
cedar-policy-core = { version = "4.12.0", features = ["tpe"] }
indexmap = "2"
postgres = { version = "0.19", features = ["with-serde_json-1"] }
serde_json = "1"
smol_str = "0.3"
thiserror = "2"

# The `testing` feature provisions a Postgres for the test suites and the
# differential tests: it connects to `CEDAR_SQL_PG_URL` when set and otherwise
# starts an embedded server once per process.
postgresql_embedded = { version = "0.21", features = ["blocking"], optional = true }
libc = { version = "0.2", optional = true }

[features]
default = []
testing = ["dep:postgresql_embedded", "dep:libc"]

[dev-dependencies]
cedar-sql = { path = ".", features = ["testing"] }

# A checkout of `luxas/cedar-woodpecker` (a fork of `cedar-policy/cedar`) is
# expected next to this repository, as in `cedar-spec`.
[patch.crates-io]
cedar-policy = { path = "../cedar/cedar-policy" }
cedar-policy-core = { path = "../cedar/cedar-policy-core" }
cedar-policy-formatter = { path = "../cedar/cedar-policy-formatter" }
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -724,6 +724,34 @@ Other operators are handled accordingly.

The evaluator should eventually pass all Cedar evaluator test using the DRT framework in the `cedar-spec` repo, as well as the `cedar-integration-tests` bundle.

## Status and phases

The implementation is built in phases, one branch and plan document each (`docs/plans/N-slug.md` here, with the
differential tests and their `docs/plans/cedar-sql-N-slug.md` in the [`cedar-sql-spec`] fork of `cedar-spec`):

1. **init** — the crate skeleton, the Postgres provisioning for tests, CI.
1. **schema** — Idea 1: the schema annotations, the database configuration, DDL, and loading Cedar entities as rows.
1. **is-authorized** — Idea 2 for a concrete request over the core operators, and the first differential test.
1. **values** — sets, records, `like`, tags, and the remaining operators.
1. **query** — unknown `principal` and/or `resource`, returning one row per candidate.
1. **corpus** — the `cedar-integration-tests` corpus and hardening.
1. **extensions** — the Cedar extension types.
1. **sqlite** — SQLite and Turso.
1. **benchmarks**.

Deviations from the text above that were decided during implementation:

- Sets and records are stored as canonical JSONB (sets deduplicated and sorted, records and entity references
wrapped) rather than `[]<type>` arrays, so that JSONB equality is Cedar equality for every nested shape.
- The hierarchy table may hold the transitive closure instead of the direct edges (the recursive CTE tolerates both).

The crate expects a checkout of [`cedar-woodpecker`] (a fork of `cedar`) next to this repository, as `cedar-spec`
does. Tests need a Postgres: set `CEDAR_SQL_PG_URL`, or leave it unset to have an embedded Postgres downloaded and
started on first use.

[`cedar-sql-spec`]: https://github.com/luxas/cedar-sql-spec
[`cedar-woodpecker`]: https://github.com/luxas/cedar-woodpecker

## Benchmarks

- Compare how fast it is for single checks vs "cross-product of principal x resource"
Expand Down
40 changes: 40 additions & 0 deletions docs/plans/1-init.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Plan 1 — init: the crate skeleton, Postgres for tests, CI

## Goal

`cedar-sql` compiles Cedar evaluation to SQL (`README.md`). This branch turns the empty repository into a
buildable crate with the module layout the later phases fill in, provisions a Postgres for the test suites
and the differential tests, and sets up CI, so that every later branch lands as code plus tests.

## Design

- **Crate** (`Cargo.toml`, `src/lib.rs`): depends on `cedar-policy` and `cedar-policy-core` with `tpe`,
patched to the `../cedar` checkout as `cedar-spec` does; modules `ident`, `config`, `annotations`, `ddl`,
`dialect`, `load`, `plan`, `compile`, `authorizer`, `backend`, `testing`, `error` — the last four have code,
the rest document what their phase adds.
- **Error** (`src/error.rs`): `Error::Unsupported(&'static str)` names a construct the compiler does not
handle yet (the differential tests skip these), `Database`, `Decode`, `Provision`.
- **Backend** (`src/backend/`): `trait Backend { execute_batch, query, begin, rollback }` over a
`SqlValue {Null, Bool, Long, Text, Json}` row model; `PgBackend` on the synchronous `postgres` client,
decoding `bool`, `int8`, `int4`, `text`, `jsonb`.
- **Testing** (`src/testing.rs`, feature `testing`): `SharedPostgres::get()` provisions one server per process
behind a `OnceLock`: the one at `CEDAR_SQL_PG_URL` when set, otherwise `postgresql_embedded` (Postgres 18,
temporary data directory, install directory overridable with `CEDAR_SQL_PG_INSTALL_DIR`, stopped through an
`atexit` hook since a `static` is never dropped). Tests run `BEGIN … ROLLBACK`, so the database stays empty.
- **CI** (`.github/workflows/ci.yml`): a `postgres:18` service, `cargo fmt/build/clippy/test` with
`-D warnings`, the `cedar-woodpecker` fork checked out next to the crate.
- **README**: a "Status and phases" section listing the phases and the two decided deviations.

## Files

`Cargo.toml`, `src/lib.rs`, `src/error.rs`, `src/backend/{mod,postgres}.rs`, `src/testing.rs`, the module
stubs, `tests/smoke_pg.rs`, `.github/workflows/ci.yml`, `README.md`, `docs/plans/1-init.md`.

## Verification

`cargo build --all-features`, `cargo clippy --all-targets --all-features -- -D warnings`,
`cargo fmt --all --check`, `cargo test --all-features` (with `CEDAR_SQL_PG_URL` set, or the embedded server).

## History

New; the first of the `cedar-sql` branches planned on 2026-09-13.
7 changes: 7 additions & 0 deletions src/annotations.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
//! Reading the `@sql_*` schema annotations.
//!
//! Plan 2 adds the entity-type annotations (`@sql_table`, `@sql_tags_table`,
//! `@sql_primary_key`, `@sql_custom_config`, `@sql_entity_id_column`) and the
//! attribute annotations (`@sql_column`, `@sql_unique`, `@sql_custom_attrs`),
//! read from the schema fragment since annotations do not survive into the
//! validator schema.
4 changes: 4 additions & 0 deletions src/authorizer.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
//! The public entry points.
//!
//! Plan 3 adds `SqlAuthorizer` with `compile_request` and `is_authorized`;
//! Plan 5 adds `query` for partial requests.
39 changes: 39 additions & 0 deletions src/backend/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
//! Executing SQL on a database.

pub mod postgres;

use crate::Result;

/// A value read from a result row.
#[derive(Debug, Clone, PartialEq)]
pub enum SqlValue {
/// SQL `NULL`.
Null,
/// A boolean.
Bool(bool),
/// A 64-bit integer.
Long(i64),
/// A string.
Text(String),
/// A JSON document.
Json(serde_json::Value),
}

/// One result row.
pub type Row = Vec<SqlValue>;

/// A database connection this crate can run its statements on.
pub trait Backend {
/// Runs one or more statements, separated by `;`, without parameters.
fn execute_batch(&mut self, sql: &str) -> Result<()>;
/// Runs one query and returns its rows.
fn query(&mut self, sql: &str) -> Result<Vec<Row>>;
/// Starts a transaction.
fn begin(&mut self) -> Result<()> {
self.execute_batch("BEGIN")
}
/// Discards the current transaction.
fn rollback(&mut self) -> Result<()> {
self.execute_batch("ROLLBACK")
}
}
80 changes: 80 additions & 0 deletions src/backend/postgres.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
//! The Postgres backend.

use postgres::types::Type;
use postgres::{Client, NoTls};

use super::{Backend, Row, SqlValue};
use crate::{Error, Result};

/// A synchronous Postgres connection.
pub struct PgBackend {
client: Client,
}

impl PgBackend {
/// Connects to `url` (a `postgres://` URL or a key-value connection string).
pub fn connect(url: &str) -> Result<Self> {
Ok(Self {
client: Client::connect(url, NoTls)?,
})
}

/// The underlying client.
pub fn client_mut(&mut self) -> &mut Client {
&mut self.client
}
}

impl Backend for PgBackend {
fn execute_batch(&mut self, sql: &str) -> Result<()> {
Ok(self.client.batch_execute(sql)?)
}

fn query(&mut self, sql: &str) -> Result<Vec<Row>> {
let rows = self.client.query(sql, &[])?;
rows.iter()
.map(|row| {
row.columns()
.iter()
.enumerate()
.map(|(i, column)| decode(row, i, column.type_()))
.collect()
})
.collect()
}
}

fn decode(row: &postgres::Row, i: usize, ty: &Type) -> Result<SqlValue> {
let decode_err = |e: postgres::Error| Error::Decode {
column: i,
message: e.to_string(),
};
Ok(match *ty {
Type::BOOL => row
.try_get::<_, Option<bool>>(i)
.map_err(decode_err)?
.map_or(SqlValue::Null, SqlValue::Bool),
Type::INT8 => row
.try_get::<_, Option<i64>>(i)
.map_err(decode_err)?
.map_or(SqlValue::Null, SqlValue::Long),
Type::INT4 => row
.try_get::<_, Option<i32>>(i)
.map_err(decode_err)?
.map_or(SqlValue::Null, |v| SqlValue::Long(v.into())),
Type::TEXT | Type::VARCHAR => row
.try_get::<_, Option<String>>(i)
.map_err(decode_err)?
.map_or(SqlValue::Null, SqlValue::Text),
Type::JSONB | Type::JSON => row
.try_get::<_, Option<serde_json::Value>>(i)
.map_err(decode_err)?
.map_or(SqlValue::Null, SqlValue::Json),
ref other => {
return Err(Error::Decode {
column: i,
message: format!("unsupported column type {other}"),
});
}
})
}
5 changes: 5 additions & 0 deletions src/compile.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
//! Compiling a TPE residual into a SQL expression.
//!
//! 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.
5 changes: 5 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
//! The database, table and column model a Cedar schema maps to.
//!
//! Plan 2 adds `DatabaseConfiguration`, `TableConfiguration`,
//! `ColumnConfiguration` and `SQLType` as described in the README, together
//! with the globally unique interface-column and hierarchy-table naming rule.
5 changes: 5 additions & 0 deletions src/ddl.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
//! Rendering a [`crate::config::DatabaseConfiguration`] as DDL.
//!
//! Plan 2 adds `create_tables` and `drop_tables`: the entity tables, the tags
//! tables, the hierarchy table, and the foreign keys as trailing `ALTER TABLE`
//! statements so table order does not matter.
4 changes: 4 additions & 0 deletions src/dialect.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
//! The SQL dialect differences.
//!
//! Plan 2 adds the `Dialect` trait with the Postgres implementation; Plan 8
//! adds SQLite (Turso).
28 changes: 28 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
//! The crate's error type.

/// A `Result` with this crate's [`Error`].
pub type Result<T> = std::result::Result<T, Error>;

/// Why an operation of this crate failed.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
/// A construct this crate does not compile (yet). The differential tests
/// treat this as a benign skip, so the string names the construct.
#[error("unsupported: {0}")]
Unsupported(&'static str),
/// The database returned an error.
#[error("database error: {0}")]
Database(#[from] postgres::Error),
/// A row's column had a type or value this crate cannot decode.
#[error("cannot decode column {column}: {message}")]
Decode {
/// The column's index in the row.
column: usize,
/// What went wrong.
message: String,
},
/// The test-only database provisioning failed (feature `testing`).
#[error("cannot provision a Postgres for testing: {0}")]
Provision(String),
}
5 changes: 5 additions & 0 deletions src/ident.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
//! SQL identifiers.
//!
//! Plan 2 adds `SQLIdentifier`: a validated identifier of at most 63 bytes whose
//! `Display` emits the double-quoted form with `"` doubled, plus
//! `quoted_literal` for single-quoted string literals.
Loading