Skip to content

Commit 88c5450

Browse files
authored
Merge pull request #339 from cipherstash/feature/gin-jsonb-containment-tests
feat(eql-mapper): JSONB containment operator transformation (@> and <@)
2 parents b6a7fb5 + 6d5d45c commit 88c5450

File tree

17 files changed

+806
-112
lines changed

17 files changed

+806
-112
lines changed

docs/reference/searchable-json.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ SELECT eql_v2.add_search_config(
4343
);
4444
```
4545

46+
> **Note:** JSONB literals in INSERT and UPDATE statements work directly without explicit `::jsonb` type casts. The proxy infers the JSONB type from the target column and handles encryption transparently.
47+
4648
### JSON document structure
4749

4850
Examples assume an encrypted JSON document with the following structure:
@@ -591,6 +593,8 @@ SELECT jsonb_array_length(jsonb_path_query(encrypted_jsonb, '$.unknown')) FROM c
591593

592594
## Containment Operators
593595

596+
> **Note:** Containment operators work directly with JSONB literals without requiring explicit `::jsonb` type casts. The examples below use the simplified syntax intentionally.
597+
594598
### `@>` (Contains Operator)
595599

596600
Tests whether the left JSONB value contains the right JSONB value.

mise.toml

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ CS_PROXY__HOST = "host.docker.internal"
3434
# Misc
3535
DOCKER_CLI_HINTS = "false" # Please don't show us What's Next.
3636

37-
CS_EQL_VERSION = "eql-2.1.8"
37+
CS_EQL_VERSION = "eql-2.2.1"
3838

3939

4040
[tools]
@@ -174,6 +174,18 @@ run = """
174174
cargo nextest run --no-fail-fast --nocapture -p cipherstash-proxy-integration
175175
"""
176176

177+
[tasks."test:integration:without_multitenant"]
178+
description = "Runs integration tests excluding multitenant"
179+
run = """
180+
cargo nextest run --no-fail-fast --nocapture -E 'package(cipherstash-proxy-integration) and not test(multitenant)'
181+
"""
182+
183+
[tasks."test:integration:multitenant"]
184+
description = "Runs multitenant integration tests only"
185+
run = """
186+
cargo nextest run --no-fail-fast --nocapture -E 'package(cipherstash-proxy-integration) and test(multitenant)'
187+
"""
188+
177189
[tasks."test:local:mapper"]
178190
alias = 'lm'
179191
description = "Runs test/s"
@@ -311,8 +323,6 @@ echo
311323
mise --env tcp run postgres:setup
312324
mise --env tls run postgres:setup
313325
314-
mise run test:integration:showcase
315-
316326
echo
317327
echo '###############################################'
318328
echo '# Test: Prometheus'
@@ -354,7 +364,7 @@ echo
354364
355365
mise --env tls run proxy:up proxy-tls --extra-args "--detach --wait"
356366
mise --env tls run test:wait_for_postgres_to_quack --port 6432 --max-retries 20 --tls
357-
cargo nextest run --no-fail-fast --nocapture -E 'package(cipherstash-proxy-integration) and not test(multitenant)'
367+
mise --env tls run test:integration:without_multitenant
358368
mise --env tls run proxy:down
359369
360370
echo
@@ -369,7 +379,7 @@ unset CS_DEFAULT_KEYSET_ID
369379
370380
mise --env tls run proxy:up proxy-tls --extra-args "--detach --wait"
371381
mise --env tls run test:wait_for_postgres_to_quack --port 6432 --max-retries 20 --tls
372-
cargo nextest run --no-fail-fast --nocapture -E 'package(cipherstash-proxy-integration) and test(multitenant)'
382+
mise --env tls run test:integration:multitenant
373383
374384
echo "'set CS_DEFAULT_KEYSET_ID = {{default_keyset_id}}'"
375385
export CS_DEFAULT_KEYSET_ID="{{default_keyset_id}}"

packages/cipherstash-proxy-integration/src/common.rs

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@ use rustls::{
88
use serde_json::Value;
99
use std::sync::{Arc, Once};
1010
use tokio_postgres::{types::ToSql, Client, NoTls, Row, SimpleQueryMessage};
11+
use tracing::info;
1112
use tracing_subscriber::{filter::Directive, EnvFilter, FmtSubscriber};
1213

1314
pub const PROXY: u16 = 6432;
14-
pub const PG_LATEST: u16 = 5532;
15-
pub const PG_V17_TLS: u16 = 5617;
15+
pub const PG_PORT: u16 = 5532;
16+
pub const PG_TLS_PORT: u16 = 5617;
1617

1718
pub const TEST_SCHEMA_SQL: &str = include_str!(concat!("../../../tests/sql/schema.sql"));
1819

@@ -52,7 +53,7 @@ pub async fn clear() {
5253
pub async fn reset_schema() {
5354
let port = std::env::var("CS_DATABASE__PORT")
5455
.map(|s| s.parse().unwrap())
55-
.unwrap_or(PG_LATEST);
56+
.unwrap_or(PG_PORT);
5657

5758
let client = connect_with_tls(port).await;
5859
client.simple_query(TEST_SCHEMA_SQL).await.unwrap();
@@ -61,7 +62,7 @@ pub async fn reset_schema() {
6162
pub async fn reset_schema_to(schema: &'static str) {
6263
let port = std::env::var("CS_DATABASE__PORT")
6364
.map(|s| s.parse().unwrap())
64-
.unwrap_or(PG_LATEST);
65+
.unwrap_or(PG_PORT);
6566

6667
let client = connect_with_tls(port).await;
6768
client.simple_query(schema).await.unwrap();
@@ -81,7 +82,7 @@ pub async fn table_exists(table: &str) -> bool {
8182

8283
let port = std::env::var("CS_DATABASE__PORT")
8384
.map(|s| s.parse().unwrap())
84-
.unwrap_or(PG_LATEST);
85+
.unwrap_or(PG_PORT);
8586

8687
let client = connect_with_tls(port).await;
8788
let messages = client.simple_query(&query).await.unwrap();
@@ -209,6 +210,26 @@ where
209210
rows.iter().map(|row| row.get(0)).collect::<Vec<T>>()
210211
}
211212

213+
/// Get database port from environment or use default.
214+
fn get_database_port() -> u16 {
215+
std::env::var("CS_DATABASE__PORT")
216+
.ok()
217+
.and_then(|s| s.parse().ok())
218+
.unwrap_or(PG_PORT)
219+
}
220+
221+
pub async fn query_direct_by<T>(sql: &str, param: &(dyn ToSql + Sync)) -> Vec<T>
222+
where
223+
T: for<'a> tokio_postgres::types::FromSql<'a>,
224+
{
225+
let port = get_database_port();
226+
info!(port);
227+
228+
let client = connect_with_tls(port).await;
229+
let rows = client.query(sql, &[param]).await.unwrap();
230+
rows.iter().map(|row| row.get(0)).collect()
231+
}
232+
212233
pub async fn simple_query<T: std::str::FromStr>(sql: &str) -> Vec<T>
213234
where
214235
<T as std::str::FromStr>::Err: std::fmt::Debug,

packages/cipherstash-proxy-integration/src/insert/insert_with_literal.rs

Lines changed: 6 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,6 @@ mod tests {
99

1010
macro_rules! test_insert_with_literal {
1111
($name: ident, $type: ident, $pg_type: ident) => {
12-
test_insert_with_literal!($name, $type, $pg_type, false);
13-
};
14-
15-
($name: ident, $type: ident, $pg_type: ident, $cast: expr) => {
1612
#[tokio::test]
1713
pub async fn $name() {
1814
trace();
@@ -26,14 +22,8 @@ mod tests {
2622

2723
let expected = vec![encrypted_val.clone()];
2824

29-
let cast_to_type: &str = if $cast {
30-
&format!("::{}", stringify!($pg_type))
31-
} else {
32-
""
33-
};
34-
35-
let insert_sql = format!("INSERT INTO encrypted (id, {encrypted_col}) VALUES ($1, '{encrypted_val}'{cast_to_type})");
36-
let select_sql = format!("SELECT {encrypted_col}{cast_to_type} FROM encrypted WHERE id = $1");
25+
let insert_sql = format!("INSERT INTO encrypted (id, {encrypted_col}) VALUES ($1, '{encrypted_val}')");
26+
let select_sql = format!("SELECT {encrypted_col} FROM encrypted WHERE id = $1");
3727

3828
execute_query(&insert_sql, &[&id]).await;
3929
let actual = query_by::<$type>(&select_sql, &id).await;
@@ -46,10 +36,6 @@ mod tests {
4636

4737
macro_rules! test_insert_simple_query_with_literal {
4838
($name: ident, $type: ident, $pg_type: ident) => {
49-
test_insert_simple_query_with_literal!($name, $type, $pg_type, false);
50-
};
51-
52-
($name: ident, $type: ident, $pg_type: ident, $cast: expr) => {
5339
#[tokio::test]
5440
pub async fn $name() {
5541
trace();
@@ -62,15 +48,8 @@ mod tests {
6248
let encrypted_col = format!("encrypted_{}", stringify!($pg_type));
6349
let encrypted_val = crate::value_for_type!($type, random_limited());
6450

65-
let cast_to_type: &str = if $cast {
66-
&format!("::{}", stringify!($pg_type))
67-
} else {
68-
""
69-
};
70-
71-
let insert_sql = format!("INSERT INTO encrypted (id, {encrypted_col}) VALUES ({id}, '{encrypted_val}'{cast_to_type})");
72-
let select_sql = format!("SELECT {encrypted_col}{cast_to_type} FROM encrypted WHERE id = {id}");
73-
51+
let insert_sql = format!("INSERT INTO encrypted (id, {encrypted_col}) VALUES ({id}, '{encrypted_val}')");
52+
let select_sql = format!("SELECT {encrypted_col} FROM encrypted WHERE id = {id}");
7453

7554
let expected = vec![encrypted_val];
7655

@@ -89,7 +68,7 @@ mod tests {
8968
test_insert_with_literal!(insert_with_literal_bool, bool, bool);
9069
test_insert_with_literal!(insert_with_literal_text, String, text);
9170
test_insert_with_literal!(insert_with_literal_date, NaiveDate, date);
92-
test_insert_with_literal!(insert_with_literal_jsonb, Value, jsonb, true);
71+
test_insert_with_literal!(insert_with_literal_jsonb, Value, jsonb);
9372

9473
test_insert_simple_query_with_literal!(insert_simple_query_with_literal_int2, i16, int2);
9574
test_insert_simple_query_with_literal!(insert_simple_query_with_literal_int4, i32, int4);
@@ -98,12 +77,7 @@ mod tests {
9877
test_insert_simple_query_with_literal!(insert_simple_query_with_literal_bool, bool, bool);
9978
test_insert_simple_query_with_literal!(insert_simple_query_with_literal_text, String, text);
10079
test_insert_simple_query_with_literal!(insert_simple_query_with_literal_date, NaiveDate, date);
101-
test_insert_simple_query_with_literal!(
102-
insert_simple_query_with_literal_jsonb,
103-
Value,
104-
jsonb,
105-
true
106-
);
80+
test_insert_simple_query_with_literal!(insert_simple_query_with_literal_jsonb, Value, jsonb);
10781

10882
// -----------------------------------------------------------------
10983

packages/cipherstash-proxy-integration/src/map_literals.rs

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
#[cfg(test)]
22
mod tests {
3-
use crate::common::{clear, connect_with_tls, random_id, PROXY};
3+
use crate::common::{clear, connect_with_tls, query_direct_by, random_id, trace, PROXY};
44

55
#[tokio::test]
66
async fn map_literal() {
@@ -45,8 +45,14 @@ mod tests {
4545
println!("encrypted: {:?}", rows[0])
4646
}
4747

48+
/// Verify JSONB literal insertion and retrieval without explicit type casts.
49+
///
50+
/// JSONB literals in INSERT and SELECT statements work directly with the proxy
51+
/// without requiring `::jsonb` type annotations. The proxy infers the JSONB type
52+
/// from the target column and handles encryption/decryption transparently.
4853
#[tokio::test]
4954
async fn map_jsonb() {
55+
trace();
5056
clear().await;
5157

5258
let client = connect_with_tls(PROXY).await;
@@ -55,12 +61,12 @@ mod tests {
5561
let encrypted_jsonb = serde_json::json!({"key": "value"});
5662

5763
let sql = format!(
58-
"INSERT INTO encrypted (id, encrypted_jsonb) VALUES ($1, '{encrypted_jsonb}'::jsonb)",
64+
"INSERT INTO encrypted (id, encrypted_jsonb) VALUES ($1, '{encrypted_jsonb}')",
5965
);
6066

6167
client.query(&sql, &[&id]).await.unwrap();
6268

63-
let sql = "SELECT id, encrypted_jsonb::jsonb FROM encrypted WHERE id = $1";
69+
let sql = "SELECT id, encrypted_jsonb FROM encrypted WHERE id = $1";
6470
let rows = client.query(sql, &[&id]).await.unwrap();
6571

6672
assert_eq!(rows.len(), 1);
@@ -74,6 +80,58 @@ mod tests {
7480
}
7581
}
7682

83+
/// Sanity check: verify JSONB is actually encrypted in database
84+
///
85+
/// This test catches silent encryption failures where plaintext is stored.
86+
/// Insert via proxy, query DIRECT from database to verify encryption,
87+
/// then query via proxy to verify decryption round-trip.
88+
#[tokio::test]
89+
async fn jsonb_encryption_sanity_check() {
90+
trace();
91+
clear().await;
92+
93+
let id = random_id();
94+
let plaintext_json = serde_json::json!({"key": "value"});
95+
96+
// Insert through proxy (should encrypt)
97+
let client = connect_with_tls(PROXY).await;
98+
let sql = "INSERT INTO encrypted (id, encrypted_jsonb) VALUES ($1, $2)";
99+
client.query(sql, &[&id, &plaintext_json]).await.unwrap();
100+
101+
// Query DIRECT from database (bypassing proxy, no decryption)
102+
// The stored value should NOT be readable as the original JSON
103+
let sql = "SELECT encrypted_jsonb::text FROM encrypted WHERE id = $1";
104+
let stored: Vec<String> = query_direct_by(sql, &id).await;
105+
106+
assert_eq!(stored.len(), 1, "Expected exactly one row");
107+
let stored_text = &stored[0];
108+
109+
// Verify it's NOT the plaintext JSON (encryption actually happened)
110+
let plaintext_str = plaintext_json.to_string();
111+
assert_ne!(
112+
stored_text, &plaintext_str,
113+
"ENCRYPTION FAILED: Stored value matches plaintext! Data was not encrypted."
114+
);
115+
116+
// Additional verification: the encrypted format should be different structure
117+
if let Ok(stored_json) = serde_json::from_str::<serde_json::Value>(stored_text) {
118+
assert_ne!(
119+
stored_json, plaintext_json,
120+
"ENCRYPTION FAILED: Stored JSON structure matches plaintext!"
121+
);
122+
}
123+
124+
// Round-trip: query through proxy should decrypt back to original
125+
let sql = "SELECT encrypted_jsonb FROM encrypted WHERE id = $1";
126+
let rows = client.query(sql, &[&id]).await.unwrap();
127+
assert_eq!(rows.len(), 1, "Expected exactly one row for round-trip");
128+
let decrypted: serde_json::Value = rows[0].get(0);
129+
assert_eq!(
130+
decrypted, plaintext_json,
131+
"DECRYPTION FAILED: Round-trip value doesn't match original!"
132+
);
133+
}
134+
77135
#[tokio::test]
78136
async fn map_repeated_literals_different_columns_regression() {
79137
clear().await;

0 commit comments

Comments
 (0)