mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
test(search): track p-gated FTS skip drift
Companion to author_only_kinds_are_storage_level_unsearchable (a3f407ceextended the hardcoded skip-set;46ba39e4added the AUTHOR_ONLY drift tripwire). Closes the parallel drift surface for P_GATED_KINDS: today the schema NULL-tsvector CASE happens to cover every persistent p-gated kind, but a new entry in P_GATED_KINDS without a matching schema migration would silently reduce search privacy to L2 (the filter-level #p gate) alone. Move P_GATED_KINDS from a private const in crates/buzz-relay/src/handlers/req.rs to a pub const in crates/buzz-core/src/kind.rs, mirroring AUTHOR_ONLY_KINDS's shape and placement. The relay handler keeps its identical usage; buzz-search's integration test now imports the canonical const and iterates it. Why move rather than re-export: P_GATED_KINDS is a privacy-classification constant about kinds, not a relay implementation detail. AUTHOR_ONLY_KINDS already lives in buzz-core::kind for exactly this reason. Adding buzz-relay as a dev-dependency of buzz-search would create a crate cycle (buzz-relay depends on buzz-search). The new tripwire skips ephemeral kinds via buzz_core::kind::is_ephemeral: ephemeral events (20000-29999) are never stored, so the storage-layer search defense does not apply to them by category. Co-authored-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
parent
0bd34eac8f
commit
1b53c2ffcb
@@ -110,6 +110,29 @@ pub const KIND_EVENT_REMINDER: u32 = 30300;
|
||||
/// a compile-time bitset or sorted array with binary search for hot-path use.
|
||||
pub const AUTHOR_ONLY_KINDS: &[u32] = &[KIND_EVENT_REMINDER];
|
||||
|
||||
/// Kinds whose stored events have `#p`-bound read access — readable only by
|
||||
/// subscribers whose pubkey appears in the event's `#p` tag.
|
||||
///
|
||||
/// The relay enforces this at the filter layer (`p_gated_filters_authorized`):
|
||||
/// a REQ that can match any kind in this set is closed unless the filter's
|
||||
/// `#p` values exactly equal the authenticated reader's pubkey. For stored
|
||||
/// (non-ephemeral) kinds in this set, the storage layer additionally writes a
|
||||
/// NULL `search_tsv` so the event is unsearchable through NIP-50 FTS
|
||||
/// (`schema/schema.sql` and `migrations/0001_initial_schema.sql` — drift
|
||||
/// caught by `p_gated_persistent_kinds_have_storage_null_tsvector` in
|
||||
/// `crates/buzz-search/tests/fts_integration.rs`).
|
||||
///
|
||||
/// Ephemeral kinds (20000–29999, e.g. [`KIND_AGENT_OBSERVER_FRAME`]) are
|
||||
/// included for filter-layer enforcement but are never stored, so the
|
||||
/// storage-layer search defense does not apply to them.
|
||||
pub const P_GATED_KINDS: &[u32] = &[
|
||||
KIND_AGENT_OBSERVER_FRAME,
|
||||
KIND_MEMBER_ADDED_NOTIFICATION,
|
||||
KIND_MEMBER_REMOVED_NOTIFICATION,
|
||||
KIND_GIFT_WRAP,
|
||||
KIND_DM_VISIBILITY,
|
||||
];
|
||||
|
||||
/// NIP-AP: Agent Persona (parameterized replaceable, owner-authored).
|
||||
///
|
||||
/// Persona definition event published by the workspace owner. Addressed by
|
||||
|
||||
@@ -6,10 +6,7 @@ use std::sync::Arc;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use buzz_core::filter::filters_match;
|
||||
use buzz_core::kind::{
|
||||
AUTHOR_ONLY_KINDS, KIND_AGENT_ENGRAM, KIND_AGENT_OBSERVER_FRAME, KIND_DM_VISIBILITY,
|
||||
KIND_GIFT_WRAP, KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION,
|
||||
};
|
||||
use buzz_core::kind::{AUTHOR_ONLY_KINDS, KIND_AGENT_ENGRAM, KIND_DM_VISIBILITY, P_GATED_KINDS};
|
||||
use buzz_core::tenant::TenantContext;
|
||||
use buzz_db::EventQuery;
|
||||
use buzz_pubsub::EventTopic;
|
||||
@@ -24,13 +21,6 @@ use crate::state::AppState;
|
||||
|
||||
const MAX_HISTORICAL_LIMIT: i64 = 2_000;
|
||||
const MAX_SUBSCRIPTIONS: usize = 1024;
|
||||
const P_GATED_KINDS: [u32; 5] = [
|
||||
KIND_AGENT_OBSERVER_FRAME,
|
||||
KIND_MEMBER_ADDED_NOTIFICATION,
|
||||
KIND_MEMBER_REMOVED_NOTIFICATION,
|
||||
KIND_GIFT_WRAP,
|
||||
KIND_DM_VISIBILITY,
|
||||
];
|
||||
|
||||
/// Handle a REQ message: register the subscription, deliver historical events, then send EOSE.
|
||||
pub async fn handle_req(
|
||||
|
||||
@@ -7,7 +7,10 @@
|
||||
//! parallel-safe.
|
||||
|
||||
use buzz_core::{
|
||||
kind::{AUTHOR_ONLY_KINDS, KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION},
|
||||
kind::{
|
||||
AUTHOR_ONLY_KINDS, KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION,
|
||||
P_GATED_KINDS,
|
||||
},
|
||||
CommunityId,
|
||||
};
|
||||
use buzz_search::{ChannelScope, SearchQuery, SearchService};
|
||||
@@ -1031,3 +1034,110 @@ async fn author_only_kinds_are_storage_level_unsearchable() {
|
||||
|
||||
teardown(pool, &schema).await;
|
||||
}
|
||||
|
||||
/// Tripwire: every Rust-side `P_GATED_KINDS` entry that is *persistent* (not
|
||||
/// in the ephemeral 20000–29999 range) MUST be excluded from `search_tsv` at
|
||||
/// the storage layer.
|
||||
///
|
||||
/// L2 (the filter-level `#p` gate in `p_gated_filters_authorized`) prevents
|
||||
/// reachable leaks today, but it is Rust logic — a future bug or new exempt
|
||||
/// search entry point could surface tokenized content from these kinds. The
|
||||
/// L1 NULL tsvector is the unbreakable backstop: `@@` mathematically cannot
|
||||
/// match NULL. This test catches the drift where someone adds a persistent
|
||||
/// kind to `P_GATED_KINDS` without the matching `schema/schema.sql` +
|
||||
/// `migrations/0001_initial_schema.sql` skip-set update.
|
||||
///
|
||||
/// Ephemeral kinds (20000–29999) are skipped: they are never stored, so the
|
||||
/// storage-layer defense does not apply to them regardless of the schema
|
||||
/// CASE. `p_gated_filters_authorized` remains their sole defense by design.
|
||||
///
|
||||
/// Companion to `author_only_kinds_are_storage_level_unsearchable`: that test
|
||||
/// covers `AUTHOR_ONLY_KINDS` drift; this one covers `P_GATED_KINDS`
|
||||
/// persistent-subset drift. Together they tripwire both Rust-side privacy
|
||||
/// constants against the schema literal.
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn p_gated_persistent_kinds_have_storage_null_tsvector() {
|
||||
let (pool, schema) = setup().await;
|
||||
|
||||
let c = mk_community(&pool, "p-gated-tripwire.example").await;
|
||||
let token = "pgated_tripwire_marker_qwerty";
|
||||
|
||||
insert_event(
|
||||
&pool,
|
||||
c,
|
||||
rand_bytes32(),
|
||||
rand_bytes32(),
|
||||
9,
|
||||
&format!("public control — {token}"),
|
||||
None,
|
||||
1_700_000_000,
|
||||
)
|
||||
.await;
|
||||
|
||||
let persistent: Vec<u32> = P_GATED_KINDS
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&k| !buzz_core::kind::is_ephemeral(k))
|
||||
.collect();
|
||||
assert!(
|
||||
!persistent.is_empty(),
|
||||
"P_GATED_KINDS must include at least one persistent kind for this \
|
||||
test to be meaningful; got {P_GATED_KINDS:?}",
|
||||
);
|
||||
|
||||
for (i, &kind) in persistent.iter().enumerate() {
|
||||
insert_event(
|
||||
&pool,
|
||||
c,
|
||||
rand_bytes32(),
|
||||
rand_bytes32(),
|
||||
kind as i32,
|
||||
&format!("p-gated kind:{kind} — {token}"),
|
||||
None,
|
||||
1_700_000_100 + i as i64,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let svc = SearchService::new(pool.clone());
|
||||
let result = svc
|
||||
.search(&SearchQuery {
|
||||
community: c,
|
||||
q: token.into(),
|
||||
channel_scope: ChannelScope::Any,
|
||||
kinds: None,
|
||||
authors: None,
|
||||
since: None,
|
||||
until: None,
|
||||
page: 1,
|
||||
per_page: 100,
|
||||
})
|
||||
.await
|
||||
.expect("search ok");
|
||||
|
||||
let kinds: Vec<i32> = result.hits.iter().map(|h| h.kind).collect();
|
||||
assert!(
|
||||
kinds.contains(&9),
|
||||
"kind:9 control row MUST be searchable, got kinds={kinds:?}",
|
||||
);
|
||||
|
||||
for &kind in &persistent {
|
||||
assert!(
|
||||
!kinds.contains(&(kind as i32)),
|
||||
"P_GATED persistent kind:{kind} MUST NOT be searchable — \
|
||||
schema NULL tsvector skip-set is missing this kind. Defense \
|
||||
reduces to L2 (filter-level `#p` gate) alone. \
|
||||
P_GATED_KINDS={P_GATED_KINDS:?}, hits={kinds:?}",
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
result.hits.len(),
|
||||
1,
|
||||
"expected exactly 1 hit (the kind:9 control), got {} (kinds={kinds:?})",
|
||||
result.hits.len(),
|
||||
);
|
||||
|
||||
teardown(pool, &schema).await;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user