From 5ecd8d6a434f97da94daef2f59166086fbaf5a6b Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Fri, 9 Jan 2026 17:04:06 +0000 Subject: feat: implement state event authorization per GRASP-01 spec Add comprehensive authorization checks to ensure state events are only accepted from maintainers of accepted repository announcements. This implements the core GRASP-01 requirement that pushes must match the latest state announcement "respecting the maintainer set." Changes: 1. StatePolicy authorization (src/nostr/policy/state.rs): - Check authorization BEFORE git data validation (fail-fast) - Reject if no announcement exists for repository - Reject if author not in maintainer set - Use existing helpers: fetch_repository_data() and pubkey_authorised_for_repo_owners() - Structured logging for all rejections 2. Purgatory invalidation (src/nostr/builder.rs): - New method: check_purgatory_state_events_for_identifier() - Called when announcements accepted (Accept and AcceptMaintainer) - Re-evaluates state events in purgatory for the identifier - Processes newly-authorized events (releases from purgatory) - Keeps unauthorized events for natural expiry (30 min) - Enables retroactive authorization when announcements arrive late 3. Purgatory sync authorization (src/git/sync.rs): - Check authorization BEFORE processing git data - Remove unauthorized events from purgatory (permanent rejection) - Prevents processing even if git data arrives first - Structured logging for monitoring 4. Rejected events tracking (src/sync/rejected_index.rs): - Add support for tracking rejected state events - New methods: add_state(), contains_state() - Separate metrics for state rejections - Enables sync to avoid re-fetching rejected states 5. Sync metrics (src/sync/metrics.rs, src/sync/mod.rs): - Add state-specific metrics (hot cache, cold index) - Track rejected states separately from announcements - Support monitoring of authorization rejections 6. Comprehensive tests (tests/state_authorization.rs): - test_reject_state_without_announcement - test_reject_state_from_unauthorized_author - test_accept_state_from_announcement_author - test_accept_state_from_maintainer Security Impact: - Before: State events could be published by anyone - After: Only maintainers can publish state events - Defense-in-depth: Authorization checked at 3 points: 1. On arrival (StatePolicy) 2. On announcement acceptance (purgatory re-evaluation) 3. On git data arrival (purgatory sync) All tests pass: - 248 unit tests - 51 NIP-34 announcement tests - 4 new state authorization tests - 9 rejected index tests Closes: State authorization requirement from GRASP-01 spec --- src/sync/metrics.rs | 103 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) (limited to 'src/sync/metrics.rs') diff --git a/src/sync/metrics.rs b/src/sync/metrics.rs index a175210..7d6d42d 100644 --- a/src/sync/metrics.rs +++ b/src/sync/metrics.rs @@ -56,6 +56,22 @@ pub struct SyncMetrics { rejected_announcements_cold_index_expired_total: IntCounter, /// Total invalidations (maintainer announcements invalidated) rejected_announcements_invalidated_total: IntCounter, + + // === Rejected States Index Metrics === + /// Current number of state events in hot cache + rejected_states_hot_cache_current: IntGauge, + /// Total hot cache hits (state events re-processed from cache) + rejected_states_hot_cache_hits_total: IntCounter, + /// Total hot cache misses (state events not in cache) + rejected_states_hot_cache_misses_total: IntCounter, + /// Total expired state events removed from hot cache + rejected_states_hot_cache_expired_total: IntCounter, + /// Current number of state event entries in cold index + rejected_states_cold_index_current: IntGauge, + /// Total state event cold index entries expired and removed + rejected_states_cold_index_expired_total: IntCounter, + /// Total state event invalidations + rejected_states_invalidated_total: IntCounter, } impl SyncMetrics { @@ -172,6 +188,49 @@ impl SyncMetrics { ))?; registry.register(Box::new(rejected_announcements_invalidated_total.clone()))?; + // Rejected states metrics + let rejected_states_hot_cache_current = IntGauge::with_opts(Opts::new( + "ngit_sync_rejected_states_hot_cache_current", + "Current number of state events in hot cache (full events, 2 min expiry)", + ))?; + registry.register(Box::new(rejected_states_hot_cache_current.clone()))?; + + let rejected_states_hot_cache_hits_total = IntCounter::with_opts(Opts::new( + "ngit_sync_rejected_states_hot_cache_hits_total", + "Total hot cache hits (state events re-processed from cache)", + ))?; + registry.register(Box::new(rejected_states_hot_cache_hits_total.clone()))?; + + let rejected_states_hot_cache_misses_total = IntCounter::with_opts(Opts::new( + "ngit_sync_rejected_states_hot_cache_misses_total", + "Total hot cache misses (state events not in cache when invalidated)", + ))?; + registry.register(Box::new(rejected_states_hot_cache_misses_total.clone()))?; + + let rejected_states_hot_cache_expired_total = IntCounter::with_opts(Opts::new( + "ngit_sync_rejected_states_hot_cache_expired_total", + "Total expired state events removed from hot cache", + ))?; + registry.register(Box::new(rejected_states_hot_cache_expired_total.clone()))?; + + let rejected_states_cold_index_current = IntGauge::with_opts(Opts::new( + "ngit_sync_rejected_states_cold_index_current", + "Current number of state event entries in cold index (metadata only, 7 day expiry)", + ))?; + registry.register(Box::new(rejected_states_cold_index_current.clone()))?; + + let rejected_states_cold_index_expired_total = IntCounter::with_opts(Opts::new( + "ngit_sync_rejected_states_cold_index_expired_total", + "Total state event cold index entries expired and removed", + ))?; + registry.register(Box::new(rejected_states_cold_index_expired_total.clone()))?; + + let rejected_states_invalidated_total = IntCounter::with_opts(Opts::new( + "ngit_sync_rejected_states_invalidated_total", + "Total state event invalidations (when announcements accepted)", + ))?; + registry.register(Box::new(rejected_states_invalidated_total.clone()))?; + Ok(Self { relay_connected, connection_attempts_total, @@ -188,6 +247,13 @@ impl SyncMetrics { rejected_announcements_cold_index_current, rejected_announcements_cold_index_expired_total, rejected_announcements_invalidated_total, + rejected_states_hot_cache_current, + rejected_states_hot_cache_hits_total, + rejected_states_hot_cache_misses_total, + rejected_states_hot_cache_expired_total, + rejected_states_cold_index_current, + rejected_states_cold_index_expired_total, + rejected_states_invalidated_total, }) } @@ -396,6 +462,43 @@ impl SyncMetrics { pub fn record_invalidation(&self, count: usize) { self.rejected_announcements_invalidated_total.inc_by(count as u64); } + + // === Rejected States Recording Methods === + + /// Update state events hot cache current size gauge. + pub fn update_states_hot_cache_size(&self, size: usize) { + self.rejected_states_hot_cache_current.set(size as i64); + } + + /// Record state event hot cache hit (event re-processed from cache). + pub fn record_states_hot_cache_hit(&self) { + self.rejected_states_hot_cache_hits_total.inc(); + } + + /// Record state event hot cache miss (event not in cache when invalidated). + pub fn record_states_hot_cache_miss(&self) { + self.rejected_states_hot_cache_misses_total.inc(); + } + + /// Record state event hot cache expired entries. + pub fn record_states_hot_cache_expired(&self, count: usize) { + self.rejected_states_hot_cache_expired_total.inc_by(count as u64); + } + + /// Update state events cold index current size gauge. + pub fn update_states_cold_index_size(&self, size: usize) { + self.rejected_states_cold_index_current.set(size as i64); + } + + /// Record state event cold index expired entries. + pub fn record_states_cold_index_expired(&self, count: usize) { + self.rejected_states_cold_index_expired_total.inc_by(count as u64); + } + + /// Record state event invalidation. + pub fn record_states_invalidation(&self, count: usize) { + self.rejected_states_invalidated_total.inc_by(count as u64); + } } #[cfg(test)] -- cgit v1.2.3