|
| 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