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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,28 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

### Added

- **Multi-step JSON paths on encrypted columns, everywhere**: `col -> 'a' -> 'b'` now works wherever a single field access does — in the select list, in an ordering comparison (`<`, `<=`, `>`, `>=`), and in the `->`, `->>` and `jsonb_path_query_first` spellings mixed freely, to any depth, with each step written as a literal or a placeholder. Previously only exact equality accepted a multi-step path and everything else was rejected. A chain is a single path into a single document, so it is now rewritten to one field access keyed on the whole composed path (`$.a.b`) instead of the nested accessors that would search an already-extracted entry and return NULL. Exact equality continues to fold the path and the value into one needle, which remains the stronger match.

Two shapes are still rejected rather than answered. A path split across a subquery, CTE or view (`SELECT a -> 'foo' FROM (SELECT col -> 'bar' AS a FROM t) s`) cannot be composed at all — the extracted value does not carry the path that produced it, and the document it came from is not in scope — so write the whole path in one expression. A placeholder step in front of a *literal* final step (`col -> $1 -> 'b'`) cannot be composed either, because a literal is encrypted before any parameter is bound; parameterise the final step too (`col -> $1 -> $2`), or write the whole path as literals.

### Fixed

- **One placeholder used as the JSON selector of two different paths**: `col -> 'a' -> $1 = $2` alongside `col -> 'b' -> $1 = $3` silently kept only one of the two paths, so one of the predicates was matched against the wrong field. The path a selector placeholder keys is recorded against the parameter it arrives in — at Bind time the parameter number is all Proxy has — so two different paths for one parameter cannot both be honoured. This is now reported as an error naming the parameter, rather than answered from whichever path was recorded last.

- **`UPDATE … SET … FROM` with same-named columns**: an `UPDATE` was rejected as ambiguous when a table in the `FROM` clause had a column with the same name as the column being assigned. The assignment now always refers to the table being updated, so these statements work and the assigned value gets the target column's type — encrypted or not.

- **Encrypted values as row counts are rejected**: an encrypted column used in `LIMIT`, `OFFSET`, or `FETCH` (for example `LIMIT enc_col`) is now rejected with a type error instead of being forwarded to the database.

- **Statements Proxy cannot type-check fail with a clear error**: a statement Proxy admits for type checking but has no support for is now rejected immediately with an error naming the statement, instead of surfacing later as an opaque resolution error. No currently-supported statement is affected.

### Security

- **Chained JSON field accessors sent the intermediate field name to the database in plaintext**: `WHERE col -> 'a' -> 'b' = $1` on an encrypted JSON column emitted `eql_v3.jsonb_contains(col -> 'a', …)`, so the field name `a` appeared in the statement text PostgreSQL received (and in its logs), and native `jsonb ->` was applied to the encrypted payload — which also made the predicate match nothing. A chain is now treated as the single path it is: `$.a.b` of the whole document, folded into the one encrypted needle and matched against the bare column. Chains of any depth are supported, in the `->`, `->>` and `jsonb_path_query_first` spellings, with `=` and `<>`, and with each step written as a literal or a placeholder.

- **A NULL JSON selector forwarded the compared value to the database in plaintext**: `WHERE col -> $1 = $2` with `$1` bound NULL builds no needle, so `$2` was never encrypted — and it was then sent to PostgreSQL exactly as the client bound it, putting the plaintext comparand on the wire and into the server log when the column's domain CHECK rejected it. An encrypted operand that produced no ciphertext is now bound NULL, which is also what the SQL means: a comparison against NULL is NULL, so the query returns no rows.

## [3.0.0] - 2026-08-05

### Changed
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,342 @@
//! Multi-step encrypted JSON accessor chains, everywhere — not only under an
//! exact equality.
//!
//! An encrypted JSON column is a SteVec document: an `sv` array of entries, each
//! keyed by a selector MAC. `eql_v3."->"(doc, sel)` searches that array, and what
//! it returns is one ENTRY, which has no `sv` of its own. So a chain cannot be two
//! hops — the outer call would search an entry, find nothing, and return NULL.
//!
//! The correct emission is ONE accessor carrying the composed path:
//! `j -> 'a' -> 'b'` becomes `eql_v3."->"(j, <selector for $.a.b>)`. These tests
//! read a nested field back through Proxy and assert the **value**, because the
//! failure being guarded against is not an error — it is a query that runs
//! perfectly and answers NULL.

#[cfg(test)]
mod tests {
use crate::common::{clear, connect_with_tls, execute_query, random_id, trace, PROXY};
use serde_json::Value;

/// A document with a field two levels down, and a number there too so that
/// ordering has something to compare.
async fn insert_nested() -> i64 {
let id = random_id();
let doc = serde_json::json!({
"nested": { "string": "world", "number": 42 },
"string": "hello",
});

execute_query(
"INSERT INTO encrypted (id, encrypted_jsonb) VALUES ($1, $2)",
&[&id, &doc],
)
.await;

id
}

/// The single value `sql` projects, decrypted by Proxy.
async fn project(sql: &str, params: &[&(dyn tokio_postgres::types::ToSql + Sync)]) -> Value {
let client = connect_with_tls(*PROXY).await;

let rows = client
.query(sql, params)
.await
.unwrap_or_else(|e| panic!("`{sql}` should execute, got: {e}"));

assert_eq!(rows.len(), 1, "expected exactly one row from `{sql}`");

rows[0].get(0)
}

/// Projecting a two-level field returns the field, not NULL.
///
/// This is the whole point. The chain used to be refused at type check, which
/// was better than the alternative it replaced — emitting a second
/// entry-scoped accessor over an entry and answering NULL with no error at
/// all. Now it is composed into one path and actually works.
#[tokio::test]
async fn two_level_projection_returns_the_field() {
trace();
clear().await;
insert_nested().await;

assert_eq!(
project(
"SELECT encrypted_jsonb -> 'nested' -> 'string' FROM encrypted",
&[]
)
.await,
Value::String("world".to_string())
);
}

/// Every spelling of the same path reads the same field.
///
/// Brackets carry no meaning, `->>` differs from `->` only in the result type,
/// and `jsonb_path_query_first` is the function spelling of a step. All four
/// decompose to the same root and the same composed path, so all four must
/// return the same value — a walker that missed one would compose a short path
/// and read a different field.
#[tokio::test]
async fn every_spelling_of_a_chain_reads_the_same_field() {
trace();
clear().await;
insert_nested().await;

for sql in [
"SELECT encrypted_jsonb -> 'nested' -> 'string' FROM encrypted",
"SELECT (encrypted_jsonb -> 'nested') -> 'string' FROM encrypted",
"SELECT encrypted_jsonb -> 'nested' ->> 'string' FROM encrypted",
"SELECT jsonb_path_query_first(encrypted_jsonb, '$.nested') -> 'string' \
FROM encrypted",
] {
assert_eq!(
project(sql, &[]).await,
Value::String("world".to_string()),
"unexpected value for `{sql}`"
);
}
}

/// A chain whose outermost step is a placeholder resolves at Bind.
///
/// The literal step is known at Parse time and the param step is not, so the
/// composed path can only be built once the param is bound — which is why this
/// needs a record carried through to the proxy rather than a rewrite alone.
/// The surviving operand is the param, so the whole path resolves together.
#[tokio::test]
async fn a_param_step_in_a_projected_chain_resolves_at_bind() {
trace();
clear().await;
insert_nested().await;

assert_eq!(
project(
"SELECT encrypted_jsonb -> 'nested' -> $1 FROM encrypted",
&[&"string"]
)
.await,
Value::String("world".to_string())
);

// Both steps bound: the composed path is entirely a Bind-time value.
assert_eq!(
project(
"SELECT encrypted_jsonb -> $1 -> $2 FROM encrypted",
&[&"nested", &"string"]
)
.await,
Value::String("world".to_string())
);
}

/// A placeholder step in front of a LITERAL outermost selector is refused,
/// not answered from a short path.
///
/// The operand that survives the collapse is the outermost selector, and a
/// literal is encrypted at Parse time — before any param is bound. So the path
/// `$.<$1>.string` cannot be composed when it is needed. The mirror image
/// (`-> 'nested' -> $1`) works, because there the surviving operand is the
/// param and the whole path resolves together at Bind.
///
/// This is the same limitation the fused equality has for `col -> $1 = 'value'`
/// and for the same reason. Composing only what is known would key `$.string`
/// and read the wrong field, silently — so refusing is the only safe answer.
#[tokio::test]
async fn a_param_step_before_a_literal_selector_is_refused() {
trace();
clear().await;
insert_nested().await;

let client = connect_with_tls(*PROXY).await;

let result = client
.query(
"SELECT encrypted_jsonb -> $1 -> 'string' FROM encrypted",
&[&"nested"],
)
.await;

match result {
Err(_) => {}
Ok(rows) => {
// If it is not refused it must at least not have answered from a
// truncated path: `$.string` holds "hello", which is the wrong
// field and the failure this asserts against.
for row in rows {
let value: Option<Value> = row.get(0);
assert_ne!(
value,
Some(Value::String("hello".to_string())),
"a truncated path answered the wrong field"
);
}
}
}
}

/// A chain keys the WHOLE path, so a prefix of it selects nothing.
///
/// `$.string` holds "hello" and `$.nested.string` holds "world". If the
/// composition dropped the inner step the chain would read `$.string` and
/// answer "hello" — a silently wrong answer rather than an error, which is
/// exactly the failure mode this guards.
#[tokio::test]
async fn a_chain_reads_the_composed_path_not_a_prefix_of_it() {
trace();
clear().await;
insert_nested().await;

let nested = project(
"SELECT encrypted_jsonb -> 'nested' -> 'string' FROM encrypted",
&[],
)
.await;
let top = project("SELECT encrypted_jsonb -> 'string' FROM encrypted", &[]).await;

assert_eq!(nested, Value::String("world".to_string()));
assert_eq!(top, Value::String("hello".to_string()));
assert_ne!(
nested, top,
"a chain must not collapse to its outermost step alone"
);
}

/// A path the document does not have selects nothing, and says so as NULL.
#[tokio::test]
async fn a_chain_selecting_a_missing_path_is_null() {
trace();
clear().await;
insert_nested().await;

let client = connect_with_tls(*PROXY).await;

let rows = client
.query(
"SELECT encrypted_jsonb -> 'nested' -> 'absent' FROM encrypted",
&[],
)
.await
.unwrap();

let value: Option<Value> = rows[0].get(0);
assert_eq!(value, None);
}

/// Ordering over a two-level path compares the field at that path.
///
/// The accessor SURVIVES here — the comparison wraps it in `eql_v3.ord_term`
/// rather than absorbing it the way equality does — so this exercises the
/// collapsed accessor in a predicate rather than a projection. A chain that
/// stayed two hops would compare NULL and match nothing at all, which reads as
/// "no rows" rather than as a failure.
#[tokio::test]
async fn ordering_on_a_two_level_path_compares_that_field() {
trace();
clear().await;
let id = insert_nested().await;

let client = connect_with_tls(*PROXY).await;

// `$.nested.number` is 42.
for (sql, expected) in [
(
"SELECT id FROM encrypted WHERE encrypted_jsonb -> 'nested' -> 'number' < $1",
vec![id],
),
(
"SELECT id FROM encrypted WHERE encrypted_jsonb -> 'nested' -> 'number' >= $1",
vec![],
),
] {
let rows = client
.query(sql, &[&Value::from(100)])
.await
.unwrap_or_else(|e| panic!("`{sql}` should execute, got: {e}"));

let actual: Vec<i64> = rows.iter().map(|r| r.get("id")).collect();
assert_eq!(actual, expected, "unexpected rows for `{sql}`");
}

// The boundary, to show the comparison is against 42 and not against
// whatever a NULL comparison would yield.
let rows = client
.query(
"SELECT id FROM encrypted WHERE encrypted_jsonb -> 'nested' -> 'number' >= $1",
&[&Value::from(42)],
)
.await
.unwrap();

let actual: Vec<i64> = rows.iter().map(|r| r.get("id")).collect();
assert_eq!(actual, vec![id], "42 >= 42 must match");
}

/// Equality over a chain must keep FUSING, not degrade to an accessor plus a
/// comparison.
///
/// The fused needle MACs the path and the value together and its presence in
/// the stored `sv` is the match — strictly stronger than extracting a field and
/// then comparing it. This is the shape that already worked; it must go on
/// working unchanged now that chains collapse everywhere else.
#[tokio::test]
async fn equality_over_a_chain_still_matches() {
trace();
clear().await;
let id = insert_nested().await;

let client = connect_with_tls(*PROXY).await;

let rows = client
.query(
"SELECT id FROM encrypted WHERE encrypted_jsonb -> 'nested' -> 'string' = $1",
&[&Value::String("world".to_string())],
)
.await
.unwrap();

let actual: Vec<i64> = rows.iter().map(|r| r.get("id")).collect();
assert_eq!(actual, vec![id]);
}

/// A chain split across a subquery boundary stays refused.
///
/// This one is impossible, not unimplemented. `EqlTerm::JsonExtracted` does not
/// carry the path that produced it, and the root column is not even in scope in
/// the outer query — so there is nothing to root a composed path at without
/// rewriting the subquery's projection.
///
/// What the client sees depends on `mapping_errors_enabled`; what is pinned
/// here is that Proxy never rewrites it into an accessor over an entry. The
/// type-check refusal itself is pinned by
/// `eql_mapper::test::json_operation_on_an_extracted_value_is_refused`.
#[tokio::test]
async fn a_chain_split_across_a_subquery_is_not_rewritten() {
trace();
clear().await;
insert_nested().await;

let client = connect_with_tls(*PROXY).await;

let result = client
.query(
"SELECT a -> 'foo' FROM \
(SELECT encrypted_jsonb -> 'nested' AS a FROM encrypted) s",
&[],
)
.await;

// Refused outright, or passed through unmapped — but never answered with a
// value, which would mean a path was composed that cannot be.
if let Ok(rows) = result {
for row in rows {
let value: Option<Value> = row.get(0);
assert_eq!(
value, None,
"a chain rooted at an extracted entry must not resolve to a value"
);
}
}
}
}
Loading
Loading