Skip to content

Commit 5528f4c

Browse files
committed
test(eql): run the element predicate over Stack Encrypt documents in PostgreSQL
Seals contact lists with the JSON index, derives `element_match` through a data plan over eql-bindings' target table, and queries the column: two contacts each holding half of the predicate are returned by the superset query (`@>` and an ordering over `json_tree_path_query`) and not by `json_tree_element_match`; one contact holding both is returned by both; a folded spelling ties under the element predicate and not under exact containment; a bound below the longest array raises; and `json_tree_subtree` equals the indexed read without `k` under every budget, the sealed-root fallback at `k = 0`. Refs #1440
1 parent 1582d8f commit 5528f4c

2 files changed

Lines changed: 343 additions & 0 deletions

File tree

‎packages/eql/tests/encryption/tests/json_tree.rs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -511,6 +511,10 @@ fn postgres_containment_over_the_encrypted_column_is_jsonb_containment() {
511511
#[path = "json_tree/generated.rs"]
512512
mod generated;
513513

514+
/// The element-scoped predicate, through a data plan, against the column.
515+
#[path = "json_tree/elements.rs"]
516+
mod elements;
517+
514518
/// Needs an EQL v4 installation. Extraction by selector opens to the leaf;
515519
/// the subtree walk by a client-derived token returns exactly the nodes
516520
/// under it and reassembles; the array functions count and list elements;
Lines changed: 339 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,339 @@
1+
//! The element-scoped predicate end to end: documents sealed by Stack
2+
//! Encrypt's JSON index, the `element_match` operand derived through a data
3+
//! plan over eql-bindings' target table (the path the Go guest takes), and
4+
//! `eql_v4.json_tree_element_match` over the stored column.
5+
6+
use super::*;
7+
8+
use eql_bindings::encryption::targets::{self as eql_targets, Target};
9+
use stack_encrypt::dynamic::{
10+
self, record, OpSpec, Operand, TargetDescriptor, TargetError, TargetResolver, ValueFrame,
11+
};
12+
use stack_encrypt::{KeysetCipher, Label, Pending, StackCipher};
13+
14+
/// eql-bindings' table as the engine's resolver, naming each type's leaf
15+
/// and element predicate domains, as a host that answers `element_match`
16+
/// describes it.
17+
struct Eql;
18+
19+
fn describe(target: &Target) -> TargetDescriptor {
20+
let mut descriptor = TargetDescriptor::new(
21+
target.name,
22+
target.family,
23+
target.suffix,
24+
target.plaintext_kind(),
25+
target.sql_domain,
26+
target.indexes.iter().map(|key| (*key).to_owned()).collect(),
27+
target.query.map(str::to_owned),
28+
target.query_sql_domain.map(str::to_owned),
29+
target.producible,
30+
target.reason.map(str::to_owned),
31+
);
32+
if let Some(domain) = target.leaf_query_sql_domain() {
33+
descriptor = descriptor.with_leaf_query_sql_domain(domain);
34+
}
35+
if let Some(domain) = target.elements_query_sql_domain() {
36+
descriptor = descriptor.with_elements_query_sql_domain(domain);
37+
}
38+
descriptor
39+
}
40+
41+
impl TargetResolver for Eql {
42+
fn targets(&self) -> Vec<TargetDescriptor> {
43+
eql_targets::targets().iter().map(describe).collect()
44+
}
45+
fn encrypt<'a, K: 'static>(
46+
&self,
47+
name: &str,
48+
options: Option<&dynamic::TargetOptions>,
49+
keyset: &'a KeysetCipher<'_, K>,
50+
label: &Label,
51+
plaintext: ValueFrame,
52+
) -> Result<Pending<'a, Vec<u8>, K>, TargetError> {
53+
eql_targets::encrypt_with(name, options, keyset, label, plaintext)
54+
.map_err(|error| TargetError::Other(Box::new(error)))
55+
}
56+
fn decrypt<'a, K: 'static>(
57+
&self,
58+
name: &str,
59+
cipher: &'a StackCipher<K>,
60+
label: &Label,
61+
stored: &[u8],
62+
) -> Result<Pending<'a, ValueFrame, K>, TargetError> {
63+
eql_targets::decrypt(name, cipher, label, stored)
64+
.map_err(|error| TargetError::Other(Box::new(error)))
65+
}
66+
fn query<'a, K: 'static>(
67+
&self,
68+
name: &str,
69+
index: &stack_encrypt::target::IndexSpec,
70+
keyset: &'a KeysetCipher<'_, K>,
71+
label: &Label,
72+
plaintext: ValueFrame,
73+
) -> Result<Pending<'a, Vec<u8>, K>, TargetError> {
74+
eql_targets::query(name, index, keyset, label, plaintext)
75+
.map_err(|error| TargetError::Other(Box::new(error)))
76+
}
77+
}
78+
79+
const RELATIONSHIP: &str = "$.extra.emergency_contacts[*].relationship";
80+
const CALLS: &str = "$.extra.emergency_contacts[*].previous_call_count";
81+
82+
/// The documents' index options: each contact's relationship and call
83+
/// count carry ordering terms.
84+
fn contact_options() -> JsonOptions {
85+
JsonOptions::default()
86+
.ordering([RELATIONSHIP, CALLS])
87+
.unwrap()
88+
}
89+
90+
/// `profile` under `users/profile`, the column the documents are sealed
91+
/// under, naming `JsonSearch` with [`contact_options`].
92+
fn contact_plan() -> record::Plan {
93+
let wire = json!({
94+
"profile": {
95+
"context": ["users", "profile"],
96+
"type": "json",
97+
"null": "sql",
98+
"target": {"name": "JsonSearch", "options": {"ordered": [RELATIONSHIP, CALLS]}}
99+
}
100+
});
101+
record::plan_with(&dynamic::frame::serde::to_vec(&wire).unwrap(), &Eql).unwrap()
102+
}
103+
104+
fn frame_of(value: &Value) -> ValueFrame {
105+
ValueFrame::from_bytes(&dynamic::frame::serde::to_vec(value).unwrap()).unwrap()
106+
}
107+
108+
/// The operand of `operator` on `profile` for `input`, as the SQL binds
109+
/// it: an EQL operand's JSON text, or a list of tokens.
110+
fn derive(
111+
runtime: &tokio::runtime::Runtime,
112+
keyset: &KeysetCipher<'_, common::Observed>,
113+
plan: &record::Plan,
114+
operator: &str,
115+
input: Value,
116+
) -> Operand {
117+
let term = wait(
118+
runtime,
119+
dynamic::query_term(
120+
keyset,
121+
plan,
122+
"profile",
123+
OpSpec::parse(operator).unwrap(),
124+
frame_of(&input),
125+
&Eql,
126+
)
127+
.unwrap(),
128+
)
129+
.unwrap();
130+
term.operand().clone()
131+
}
132+
133+
fn eql_text(operand: &Operand) -> String {
134+
match operand {
135+
Operand::One(bytes) => String::from_utf8(bytes.clone()).unwrap(),
136+
other => panic!("one EQL operand, got {other:?}"),
137+
}
138+
}
139+
140+
/// Needs an EQL v4 installation. The customer's predicate, a Sibling with
141+
/// more than five calls in one contact: the element predicate answers it,
142+
/// where `@>` and an ordering over `json_tree_path_query` answer "some
143+
/// contact is a Sibling and some has more than five calls". A row with a
144+
/// contact past the bound fails the query. `json_tree_subtree` is the
145+
/// indexed read without `k`, and `k` pairs a contact's two fields.
146+
#[test]
147+
#[ignore = "requires EQL_TEST_DATABASE_URL pointing to an EQL v4 database"]
148+
fn postgres_an_element_predicate_holds_of_one_element() {
149+
let runtime = tokio::runtime::Builder::new_current_thread()
150+
.enable_all()
151+
.build()
152+
.unwrap();
153+
let (cipher, _) = wait(&runtime, common::cipher());
154+
let keyset = cipher.default_keyset();
155+
let plan = contact_plan();
156+
let mut db = connect();
157+
let contacts = |list: Value| json!({"name": "x", "extra": {"emergency_contacts": list}});
158+
let documents = [
159+
// Each contact holds one half of the predicate.
160+
contacts(json!([
161+
{"relationship": "Sibling", "previous_call_count": 3},
162+
{"relationship": "Friend", "previous_call_count": 7}
163+
])),
164+
// One contact holds both.
165+
contacts(json!([
166+
{"relationship": "Friend", "previous_call_count": 9},
167+
{"relationship": "Sibling", "previous_call_count": 7}
168+
])),
169+
// Neither.
170+
contacts(json!([{"relationship": "Friend", "previous_call_count": 9}])),
171+
// No contacts.
172+
json!({"name": "y"}),
173+
// Folded equality: "sibling" ties with "Sibling".
174+
contacts(json!([{"relationship": "sibling", "previous_call_count": 6}])),
175+
];
176+
store(
177+
&mut db,
178+
&runtime,
179+
&cipher,
180+
"json_tree_elements",
181+
&contact_options(),
182+
&documents,
183+
);
184+
let ids = |db: &mut postgres::Client, sql: &str, params: &[&(dyn postgres::types::ToSql + Sync)]| -> Vec<i32> {
185+
db.query(sql, params)
186+
.unwrap_or_else(|error| panic!("{sql}: {error}"))
187+
.iter()
188+
.map(|row| row.get(0))
189+
.collect()
190+
};
191+
192+
let predicate = |bound: u32| {
193+
eql_text(&derive(
194+
&runtime,
195+
&keyset,
196+
&plan,
197+
"element_match",
198+
json!({"path": "$.extra.emergency_contacts[*]", "wildcard_bound": bound, "predicates": [
199+
{"member": "relationship", "op": "eq", "json": "\"Sibling\""},
200+
{"member": "previous_call_count", "op": "gt", "json": "5"}
201+
]}),
202+
))
203+
};
204+
let element_scoped = "SELECT id FROM json_tree_elements \
205+
WHERE eql_v4.json_tree_element_match(doc, $1::text::jsonb::eql_v4.query_json_elements) ORDER BY id";
206+
assert_eq!(
207+
ids(&mut db, element_scoped, &[&predicate(4)]),
208+
[2, 5],
209+
"one contact a Sibling with more than five calls, the folded spelling included"
210+
);
211+
212+
// The superset: containment on the relationship, an ordering over every
213+
// call count.
214+
let needle = eql_text(&derive(
215+
&runtime,
216+
&keyset,
217+
&plan,
218+
"eq_at",
219+
json!({"path": RELATIONSHIP, "json": "\"Sibling\""}),
220+
));
221+
let Operand::Text(calls_selector) = derive(&runtime, &keyset, &plan, "selector", json!({"path": CALLS}))
222+
else {
223+
panic!("a selector is text")
224+
};
225+
let five = eql_text(&derive(
226+
&runtime,
227+
&keyset,
228+
&plan,
229+
"gt_at",
230+
json!({"path": CALLS, "json": "5"}),
231+
));
232+
let superset = "SELECT id FROM json_tree_elements \
233+
WHERE doc @> $1::text::jsonb::eql_v4.query_json \
234+
AND EXISTS (SELECT 1 FROM eql_v4.json_tree_path_query(doc, $2::text) AS e \
235+
WHERE e > $3::text::jsonb::eql_v4.query_json_leaf) ORDER BY id";
236+
assert_eq!(
237+
ids(&mut db, superset, &[&needle, &calls_selector, &five]),
238+
[1, 2],
239+
"some contact a Sibling and some over five: row 1's halves answer it, and \
240+
containment is exact, so the folded spelling does not"
241+
);
242+
243+
// A bound below the longest array: the probe names the element at it.
244+
let error = db.query(element_scoped, &[&predicate(1)]).unwrap_err();
245+
assert_eq!(
246+
error.code(),
247+
Some(&postgres::error::SqlState::INVALID_PARAMETER_VALUE),
248+
"{error}"
249+
);
250+
let message = error.as_db_error().map(|db| db.message()).unwrap_or_default();
251+
assert!(
252+
message.contains("longer than the bound"),
253+
"the message says why: {message}"
254+
);
255+
// At exactly the longest array's length, it answers.
256+
assert_eq!(ids(&mut db, element_scoped, &[&predicate(2)]), [2, 5]);
257+
258+
// `json_tree_subtree` is the indexed read without `k`, and the `k`-th
259+
// token of each field's read names a field of one contact.
260+
let tokens = |path: &str| match derive(
261+
&runtime,
262+
&keyset,
263+
&plan,
264+
"subtree",
265+
json!({"path": path, "wildcard_bound": 2}),
266+
) {
267+
Operand::TextList(tokens) => tokens,
268+
other => panic!("{other:?}"),
269+
};
270+
let (relationships, counts) = (tokens(RELATIONSHIP), tokens(CALLS));
271+
for budget in [0, 1, 64] {
272+
for id in 1..=5 {
273+
let plain: Vec<String> = db
274+
.query(
275+
"SELECT r::jsonb::text FROM json_tree_elements, eql_v4.json_tree_subtree(doc, $1::text[], $2::int) AS r WHERE id = $3",
276+
&[&relationships, &budget, &id],
277+
)
278+
.unwrap()
279+
.iter()
280+
.map(|row| row.get(0))
281+
.collect();
282+
let indexed: Vec<String> = db
283+
.query(
284+
"SELECT r.entry::jsonb::text FROM json_tree_elements, eql_v4.json_tree_subtree_indexed(doc, $1::text[], $2::int) AS r WHERE id = $3",
285+
&[&relationships, &budget, &id],
286+
)
287+
.unwrap()
288+
.iter()
289+
.map(|row| row.get(0))
290+
.collect();
291+
assert_eq!(plain, indexed, "row {id}, budget {budget}");
292+
}
293+
}
294+
let paired: Vec<(i32, i32)> = db
295+
.query(
296+
"SELECT r.k, c.k FROM json_tree_elements,
297+
eql_v4.json_tree_subtree_indexed(doc, $1::text[], 0) AS r
298+
JOIN eql_v4.json_tree_subtree_indexed(doc, $2::text[], 0) AS c ON c.k = r.k
299+
WHERE id = 2 ORDER BY r.k",
300+
&[&relationships, &counts],
301+
)
302+
.unwrap()
303+
.iter()
304+
.map(|row| (row.get(0), row.get(1)))
305+
.collect();
306+
assert_eq!(paired, [(1, 1), (2, 2)], "one pair per contact, by k");
307+
let fallback: Vec<i32> = db
308+
.query(
309+
"SELECT r.k FROM json_tree_elements, eql_v4.json_tree_subtree_indexed(doc, $1::text[], 1) AS r WHERE id = 2",
310+
&[&relationships],
311+
)
312+
.unwrap()
313+
.iter()
314+
.map(|row| row.get(0))
315+
.collect();
316+
assert_eq!(fallback, [0], "past the budget, the sealed root alone, at k = 0");
317+
318+
// A member whose full path the field does not order is refused before
319+
// anything is derived.
320+
let error = dynamic::check_query(
321+
&plan,
322+
"profile",
323+
OpSpec::ElementMatch,
324+
frame_of(&json!({"path": "$.extra.emergency_contacts[*]", "predicates": [
325+
{"member": "name", "op": "eq", "json": "\"x\""}
326+
]})),
327+
&Eql,
328+
)
329+
.unwrap_err();
330+
assert!(
331+
matches!(
332+
error,
333+
dynamic::Error::Cipher(stack_encrypt::Error::Term(
334+
stack_encrypt::sem::TermError::JsonPathNotOrdered
335+
))
336+
),
337+
"{error:?}"
338+
);
339+
}

0 commit comments

Comments
 (0)