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
25 changes: 25 additions & 0 deletions .changeset/eql-wrapper-operators-search-path.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
'@cipherstash/eql': patch
'stash': patch
---

Fix: the `_ord_ore` comparison functions (`eql_v3.lt`, `lte`, `gt`, `gte`, `eq`,
`neq`), and the operators on `_ord_ore` columns when named with their schema
(`col OPERATOR(public.<) $1`), no longer give wrong answers when `public` is not
on the `search_path`. These functions are kept free of a pinned
`search_path` so PostgreSQL can inline them into index scans, which means they
look up their comparison operator through the caller's `search_path`. The ORE
comparison operators live in `public`, so a caller without it, such as a
`SECURITY DEFINER` function with `SET search_path = ''` (the hardening Supabase
recommends), got PostgreSQL's built-in record comparison instead. That compares
the ciphertext bytes, so equality and ordering were silently wrong rather than
raising an error. Every generated comparison function now names its operator
with its schema (`OPERATOR(public.<)` for ORE, `OPERATOR(pg_catalog.<)` for the
HMAC, bloom filter and OPE terms), so the result no longer depends on the
`search_path`, and the functions still inline. A bare `col < $1` still needs
`public` on the `search_path`, because the operators on encrypted columns are
created in `public`; without it, call the function (`eql_v3.lt(col, $1)`) or
name the operator's schema. Reinstall or upgrade EQL to pick up the fix.

The `stash-postgres` skill now explains how to query encrypted columns from a
function or role whose `search_path` leaves out `public`.
37 changes: 25 additions & 12 deletions packages/eql/crates/eql-codegen/src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ pub enum FnEntry {
ctor: String, // e.g. hmac_256 (called as {{ internal_schema }}.{{ ctor }})
},
Wrapper {
op: String, // SQL operator used in the body, e.g. =
op: String, // schema-qualified body operator, e.g. OPERATOR(pg_catalog.=)
function_name: String, // e.g. eq
args: [SqlParam; 2],
call_a: String, // e.g. eql_v3.eq_term(a) (embeds extract_arg cast logic)
Expand Down Expand Up @@ -258,17 +258,13 @@ pub fn extractor_entry(term: Term) -> FnEntry {

/// Build an inlinable comparison-wrapper entry for a supported operator.
/// `dom` is the schema-qualified domain name; `op` is the already-resolved
/// operator (the caller iterates `OPERATORS`, so no symbol re-lookup is needed).
pub fn wrapper_entry(
dom: &str,
op: &Operator,
arg_a: &str,
arg_b: &str,
extractor: &str,
) -> FnEntry {
/// operator (the caller iterates `OPERATORS`, so no symbol re-lookup is needed);
/// `term` is the term serving `op`, which supplies both the extractor and the
/// schema the body's operator is named in.
pub fn wrapper_entry(dom: &str, op: &Operator, arg_a: &str, arg_b: &str, term: Term) -> FnEntry {
let extractor = term.extractor();
FnEntry::Wrapper {
// Body operator: `symbol` for all but `@@`, whose body uses bloom `@>`.
op: op.body_operator().to_string(),
op: qualified_body_operator(op, term),
// Supported path → the public wrapper name. Differs from `function_name`
// (the blocker name) only for `@@`, whose wrapper is `eql_v3.matches`.
function_name: op.wrapper_function_name().to_string(),
Expand All @@ -288,6 +284,23 @@ pub fn wrapper_entry(
}
}

/// The wrapper body's operator, schema-qualified as `OPERATOR(<schema>.<sym>)`.
///
/// `sym` is `op.body_operator()` (the symbol itself for all but `@@`, whose
/// body uses bloom `@>`); `<schema>` is `term.operator_schema()`. A wrapper is
/// unpinned `LANGUAGE sql` so it can inline, which means its body is parsed
/// under the caller's `search_path` — a bare operator would resolve to whatever
/// that path finds. See [`Term::operator_schema`] for the ORE failure this
/// closes. Qualifying keeps the wrapper inlinable: the inlined
/// expression carries the same operator OID either way.
pub fn qualified_body_operator(op: &Operator, term: Term) -> String {
format!(
"OPERATOR({}.{})",
term.operator_schema(),
op.body_operator()
)
}

/// Build an unsupported-operator entry. Every such entry shares one uniform
/// `RAISE EXCEPTION` body; only signature facts vary. `op` is the
/// already-resolved operator (no symbol re-lookup needed).
Expand Down Expand Up @@ -337,7 +350,7 @@ pub fn operator_entry(op: &Operator, leftarg: &str, rightarg: &str, supported: b
};
// A supported operator is backed by a public comparison wrapper (SCHEMA); a
// blocked one by an internal blocker (INTERNAL_SCHEMA). `supported` tracks
// wrapper emission exactly: `is_supported(op) ⟹ extractor_for_operator is
// wrapper emission exactly: `is_supported(op) ⟹ term_for_operator is
// Some`, so `render_functions_file` always emits a wrapper for a supported
// operator and a blocker otherwise.
let function_schema = if supported { SCHEMA } else { INTERNAL_SCHEMA };
Expand Down
151 changes: 116 additions & 35 deletions packages/eql/crates/eql-codegen/src/generate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -160,14 +160,14 @@ pub fn render_functions_file(family_name: &str, domain: &Domain) -> String {
entries.push(extractor_entry(term));
}
for op in OPERATORS {
let extractor = Term::extractor_for_operator(domain.terms, op.symbol.as_str());
let term = Term::term_for_operator(domain.terms, op.symbol.as_str());
for sig in op.signatures {
let rendered = sig.render(&dom);
// A `blocker_only` overload (the `@@` jsonpath predicate) always falls
// through to the blocker, even when the domain supports the symbol.
if is_supported(op.symbol.as_str()) && !sig.blocker_only {
if let Some(ex) = extractor {
entries.push(wrapper_entry(&dom, op, &rendered.left, &rendered.right, ex));
if let Some(t) = term {
entries.push(wrapper_entry(&dom, op, &rendered.left, &rendered.right, t));
continue;
}
}
Expand Down Expand Up @@ -275,7 +275,7 @@ pub fn render_query_functions_file(family_name: &str, domain: &Domain) -> String
}
// Comparison wrappers: (storage, query) and its (query, storage) commutator,
// for supported operators only (a query operand is never sent for a blocked
// operator). `is_supported(op) ⟹ extractor_for_operator is Some`.
// operator). `is_supported(op) ⟹ term_for_operator is Some`.
//
// Query twins deliberately emit NO blockers (unlike the storage surface). A
// query operand only ever appears as the RHS of `col <op> operand`, and for
Expand All @@ -289,21 +289,21 @@ pub fn render_query_functions_file(family_name: &str, domain: &Domain) -> String
if !supported.contains(&op.symbol.as_str()) {
continue;
}
let extractor = Term::extractor_for_operator(domain.terms, op.symbol.as_str())
.expect("a supported operator resolves an extractor");
let term = Term::term_for_operator(domain.terms, op.symbol.as_str())
.expect("a supported operator resolves a term");
entries.push(wrapper_entry(
&query_dom,
op,
&storage_dom,
&query_dom,
extractor,
term,
));
entries.push(wrapper_entry(
&query_dom,
op,
&query_dom,
&storage_dom,
extractor,
term,
));
}

Expand Down Expand Up @@ -533,22 +533,6 @@ fn json_entry_cross_blocked_operators(spec: &DomainFamily) -> Vec<&'static str>
.collect()
}

/// The extractor this surface uses on BOTH operands: [`JSON_ENTRY_TERM`]'s own
/// (`ord_term` → `ope_cllw`), asked of the `Term` rather than spelled out, so an
/// extractor rename stays a single catalog-side change.
///
/// Used for every operator rather than resolving per-operator through
/// `Term::extractor_for_operator`. That generic rule picks the first capable term
/// in the operand's list, so on a dual-term `[Hm, Ope]` operand (text's `_ord`)
/// it routes `=`/`<>` through `eq_term` — the per-value HMAC a SteVec scalar leaf
/// never carries. That rule is right for a scalar column and wrong for this seam:
/// `json_entry` serves exactly one term, so the extractor is a property of the
/// SEAM, not of the operator. Since [`json_entry_cross_operators`] already limits
/// the emitted set to what this term provides, the pairing is total.
fn json_entry_extractor() -> &'static str {
JSON_ENTRY_TERM.extractor()
}

/// Body for a family's json_entry_<T>_functions.sql: comparison
/// WRAPPERS binding the fixed `public.eql_v3_json_entry` leaf to the family's
/// `_ord` / `_ord_ope` query operands, for every operator json_entry can serve
Expand Down Expand Up @@ -576,7 +560,17 @@ pub fn render_json_entry_cross_functions(

let family_name = spec.name;
let json_entry = json_entry_type();
let extractor = json_entry_extractor();
// The seam's own term on BOTH operands, for every operator — not resolved
// per operator through `Term::term_for_operator`. That generic rule picks the
// first capable term in the operand's list, so on a dual-term `[Hm, Ope]`
// operand (text's `_ord`) it would route `=`/`<>` through `eq_term`, the
// per-value HMAC a SteVec scalar leaf never carries. `json_entry` serves
// exactly one term, so the term is a property of the SEAM, not of the
// operator; [`json_entry_cross_operators`] already limits the emitted set to
// what it provides, so the pairing is total. Passing the `Term` (not an
// extractor name) lets `wrapper_entry` ask the catalog for both the
// extractor and the schema of the body's operator.
let term = JSON_ENTRY_TERM;
let supported = json_entry_cross_operators(spec);
let blocked = json_entry_cross_blocked_operators(spec);
let mut requires = vec![
Expand Down Expand Up @@ -607,14 +601,14 @@ pub fn render_json_entry_cross_functions(
op,
&json_entry,
&query_dom,
extractor,
term,
));
entries.push(wrapper_entry(
&json_entry,
op,
&query_dom,
&json_entry,
extractor,
term,
));
} else if blocked.contains(&symbol) {
// Both directions, so neither can fall through to `jsonb = jsonb`.
Expand Down Expand Up @@ -1152,6 +1146,94 @@ mod tests {

use std::fs;

/// Every comparison-wrapper body — scalar, query-twin and json_entry
/// surfaces, every catalog family — names its operator schema-qualified.
///
/// Wrappers are unpinned `LANGUAGE sql` (to stay inlinable), so their body
/// is parsed under the CALLER's `search_path`; a bare operator resolves to
/// whatever that path finds. For the ORE term, whose operators live in
/// `public`, a caller without `public` on the path got pg_catalog's RECORD
/// comparison — byte-wise ciphertext order, no error. A bare
/// operator token anywhere in a wrapper body fails here.
#[test]
fn every_wrapper_body_schema_qualifies_its_operators() {
let mut bodies = 0;
for family in CATALOG {
for (path, sql) in render_type(family, std::path::Path::new("out")) {
for line in sql
.lines()
.filter(|l| l.starts_with("AS $$ SELECT eql_v3."))
{
bodies += 1;
let bare: Vec<&str> = line
.split_whitespace()
.filter(|tok| tok.chars().all(|c| "<>=@!~#&|-+*/%^".contains(c)))
.collect();
assert!(
bare.is_empty(),
"{}: wrapper body uses unqualified operator(s) {bare:?}: {line}",
path.display()
);
// The schema each term's operators actually live in, spelled
// out here rather than read from `Term::operator_schema`, so a
// wrong mapping there fails this test for every family. The
// ORE term compares a composite type whose operators are in
// `public`; naming pg_catalog there reaches the built-in
// RECORD comparison, the byte-order bug this guards.
let want = if line.contains("eql_v3.ord_term_ore(") {
" OPERATOR(public."
} else {
" OPERATOR(pg_catalog."
};
assert!(
line.contains(want),
"{}: wrapper body does not name its operator as{want}…: {line}",
path.display()
);
let other = if want.contains("public") {
" OPERATOR(pg_catalog."
} else {
" OPERATOR(public."
};
assert!(
!line.contains(other),
"{}: wrapper body names an operator in the wrong schema: {line}",
path.display()
);
}
}
}
// Wrapper bodies open with an `eql_v3.<extractor>(a)` call (extractor
// bodies call an `eql_v3_internal` constructor and are skipped). Guards
// the filter itself: a template change that stopped emitting single-line
// `AS $$ SELECT eql_v3.` bodies would otherwise pass vacuously.
assert!(bodies > 500, "only {bodies} wrapper bodies inspected");
}

/// The `_ord_ore` wrappers compare `eql_v3_internal.ore_block_256` values,
/// a composite type: only `OPERATOR(public.<op>)` reaches the ORE
/// comparator. Pins the exact body per operator, on both the storage and
/// query-twin surfaces (the catalog-wide schema check is
/// `every_wrapper_body_schema_qualifies_its_operators`).
#[test]
fn ord_ore_wrappers_name_the_public_ore_operators() {
let s = spec("integer");
let d = domain(s, "ord_ore");
let storage = render_functions_file("integer", d);
let query = render_query_functions_file("integer", d);
for op in ["=", "<>", "<", "<=", ">", ">="] {
let body = format!(
"SELECT eql_v3.ord_term_ore(a) OPERATOR(public.{op}) eql_v3.ord_term_ore(b) $$;"
);
assert!(storage.contains(&body), "storage wrapper for {op}: {body}");
assert!(query.contains(&body), "query wrapper for {op}: {body}");
assert!(
!storage.contains(&format!("eql_v3.ord_term_ore(a) {op} ")),
"bare {op} on ORE terms"
);
}
}

#[test]
fn arg_b_name_is_selector_only_for_path_operators() {
assert_eq!(arg_b_name("->"), "selector");
Expand Down Expand Up @@ -1548,8 +1630,9 @@ mod tests {
"CREATE FUNCTION eql_v3.matches(a public.eql_v3_text_match, b public.eql_v3_text_match)"
));
assert!(fns.contains(
"SELECT eql_v3.match_term(a) @> eql_v3.match_term(b) \
AND (cardinality(eql_v3.match_term(b)) > 0 OR cardinality(eql_v3.match_term(a)) = 0)"
"SELECT eql_v3.match_term(a) OPERATOR(pg_catalog.@>) eql_v3.match_term(b) \
AND (pg_catalog.cardinality(eql_v3.match_term(b)) OPERATOR(pg_catalog.>) 0 \
OR pg_catalog.cardinality(eql_v3.match_term(a)) OPERATOR(pg_catalog.=) 0)"
));
// The guarded match wrapper is NOT STRICT: a STRICT SQL function with a
// non-strict body (the guard's AND/OR) would stop inlining, losing the
Expand Down Expand Up @@ -2076,8 +2159,6 @@ mod tests {
"{excluded} must never reach the extract surface"
);
}
// And the extractor is the term's own, not a spelled-out literal.
assert_eq!(json_entry_extractor(), Term::Ope.extractor());
}

#[test]
Expand Down Expand Up @@ -2193,7 +2274,7 @@ mod tests {
assert!(sql.contains(
"CREATE FUNCTION eql_v3.lt(a public.eql_v3_json_entry, b eql_v3.query_integer_ord)"
));
assert!(sql.contains("SELECT eql_v3.ord_term(a) < eql_v3.ord_term(b)"));
assert!(sql.contains("SELECT eql_v3.ord_term(a) OPERATOR(pg_catalog.<) eql_v3.ord_term(b)"));
assert!(sql.contains(
"CREATE FUNCTION eql_v3.lt(a public.eql_v3_json_entry, b eql_v3.query_integer_ord_ope)"
));
Expand All @@ -2212,7 +2293,7 @@ mod tests {
"equality must not be a public wrapper on the extract surface"
);
assert!(
!sql.contains("SELECT eql_v3.ord_term(a) = eql_v3.ord_term(b)"),
!sql.contains("SELECT eql_v3.ord_term(a) OPERATOR(pg_catalog.=) eql_v3.ord_term(b)"),
"op-equality is gone — equality is document containment, not an extract op"
);
// The dropped `_eq` operand never appears, and no hmac equality route
Expand Down Expand Up @@ -2299,7 +2380,7 @@ mod tests {
assert!(sql.contains(
"CREATE FUNCTION eql_v3.lt(a public.eql_v3_json_entry, b eql_v3.query_text_ord)"
));
assert!(sql.contains("SELECT eql_v3.ord_term(a) < eql_v3.ord_term(b)"));
assert!(sql.contains("SELECT eql_v3.ord_term(a) OPERATOR(pg_catalog.<) eql_v3.ord_term(b)"));
assert!(sql.contains(
"CREATE FUNCTION eql_v3.gte(a public.eql_v3_json_entry, b eql_v3.query_text_ord)"
));
Expand All @@ -2314,7 +2395,7 @@ mod tests {
);
}
assert!(
!sql.contains("SELECT eql_v3.ord_term(a) = eql_v3.ord_term(b)"),
!sql.contains("SELECT eql_v3.ord_term(a) OPERATOR(pg_catalog.=) eql_v3.ord_term(b)"),
"op-equality on text is a FALSE POSITIVE (orderize_string collates)"
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@
--! @return boolean
CREATE FUNCTION {{ schema }}.{{ e.function_name }}({{ e.args[0].name }} {{ e.args[0].ty }}, {{ e.args[1].name }} {{ e.args[1].ty }})
RETURNS boolean LANGUAGE sql IMMUTABLE{% if not e.empty_bloom_guard %} STRICT{% endif %} PARALLEL SAFE
AS $$ SELECT {{ e.call_a }} {{ e.op }} {{ e.call_b }}{% if e.empty_bloom_guard %} AND (cardinality({{ e.call_b }}) > 0 OR cardinality({{ e.call_a }}) = 0){% endif %} $$;
AS $$ SELECT {{ e.call_a }} {{ e.op }} {{ e.call_b }}{% if e.empty_bloom_guard %} AND (pg_catalog.cardinality({{ e.call_b }}) OPERATOR(pg_catalog.>) 0 OR pg_catalog.cardinality({{ e.call_a }}) OPERATOR(pg_catalog.=) 0){% endif %} $$;
Loading
Loading