1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
//! `POST /api/search/<entity>` — faceted search over the entity's
//! search index. Refuses entities whose read policy depends on row
//! data because facet aggregates would leak counts even when hits
//! were filtered.
use crate::{json_error, parse_json, RouterContext};
use pylon_http::HttpMethod;
pub(crate) fn handle(
ctx: &RouterContext,
method: HttpMethod,
url: &str,
body: &str,
_auth_token: Option<&str>,
) -> Option<(u16, String)> {
let rest = url.strip_prefix("/api/search/")?;
let entity_name = rest.split('?').next().unwrap_or(rest).trim_end_matches('/');
if entity_name.is_empty() {
return Some((
400,
json_error("MISSING_ENTITY", "search path is /api/search/<Entity>"),
));
}
if method != HttpMethod::Post {
return Some((
405,
json_error(
"METHOD_NOT_ALLOWED",
"search requires POST with a JSON body",
),
));
}
let query_json: serde_json::Value = match parse_json(body) {
Ok(v) => v,
Err((status, message)) => return Some((status, message)),
};
// Refuse search on entities whose read policy depends on per-row
// fields (e.g. `auth.userId == data.ownerId`). Facet counts +
// totals are computed across the full match-set via bitmap
// intersection — exposing aggregates for row-scoped data leaks
// "how many X does tenant Y have" even if individual hits were
// filtered. Probe with `None` to detect row-independence.
let aggregate_safe = matches!(
ctx.policy_engine
.check_entity_read(entity_name, ctx.auth_ctx, None),
pylon_policy::PolicyResult::Allowed
);
if !aggregate_safe {
return Some((
403,
json_error(
"SEARCH_REQUIRES_ROW_INDEPENDENT_POLICY",
&format!(
"Entity {entity_name} has a read policy that depends on row data. \
Faceted search computes aggregates over every match and would \
leak counts for rows you can't read. Make the read policy \
row-independent, or disable search: in the manifest."
),
),
));
}
Some(match ctx.store.search(entity_name, &query_json) {
Ok(mut result) => {
// Belt-and-suspenders per-hit filter. With aggregate_safe
// above, the policy already allows "anyone who passes the
// auth check" — should be a no-op here, but guards against
// future relaxations of the aggregate_safe gate.
if let Some(hits) = result.get_mut("hits").and_then(|v| v.as_array_mut()) {
hits.retain(|hit| {
matches!(
ctx.policy_engine
.check_entity_read(entity_name, ctx.auth_ctx, Some(hit)),
pylon_policy::PolicyResult::Allowed
)
});
}
(200, result.to_string())
}
Err(e) => {
let status = match e.code.as_str() {
"ENTITY_NOT_FOUND" => 404,
"SEARCH_NOT_CONFIGURED" | "INVALID_QUERY" => 400,
_ => 500,
};
(status, json_error(&e.code, &e.message))
}
})
}