upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
path: root/src/git/authorization.rs
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2025-11-28 10:31:46 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2025-11-28 10:31:46 +0000
commit744094c61d6e65892bcdb5a29b90b845ce87559f (patch)
tree61c53f0ab93901b2b3d5378f7d13c3ac2b6dea98 /src/git/authorization.rs
parent4da51a8adb94f2979c0a911157f26596c1ee2cb5 (diff)
fix maintainer recursion
Diffstat (limited to 'src/git/authorization.rs')
-rw-r--r--src/git/authorization.rs367
1 files changed, 364 insertions, 3 deletions
diff --git a/src/git/authorization.rs b/src/git/authorization.rs
index 16498c1..1be3de9 100644
--- a/src/git/authorization.rs
+++ b/src/git/authorization.rs
@@ -9,7 +9,7 @@
9//! 9//!
10//! ## Authorization Flow (Efficient Single-Query Approach) 10//! ## Authorization Flow (Efficient Single-Query Approach)
11//! 11//!
12//! 1. Fetch announcement and state events for the repository from the relay 12//! 1. Fetch announcement and state events for the repository from the relay database
13//! 2. Collect all authorized publishers: announcement authors + listed maintainers 13//! 2. Collect all authorized publishers: announcement authors + listed maintainers
14//! 3. Find the latest state event authored by any authorized publisher 14//! 3. Find the latest state event authored by any authorized publisher
15//! 4. Validate that the pushed refs match the state event 15//! 4. Validate that the pushed refs match the state event
@@ -20,16 +20,377 @@
20//! same identifier: 20//! same identifier:
21//! - They are the author of that announcement, OR 21//! - They are the author of that announcement, OR
22//! - They are listed in the "maintainers" tag of that announcement 22//! - They are listed in the "maintainers" tag of that announcement
23//!
24//! ## Shared Helper Functions
25//!
26//! This module provides helper functions that can be used by both:
27//! - Git push authorization in handlers.rs
28//! - HEAD updates triggered by state events in builder.rs (event policy)
23 29
24use anyhow::{anyhow, Result}; 30use anyhow::{anyhow, Result};
25use nostr_sdk::{Alphabet, Event, Filter, Kind, PublicKey, SingleLetterTag, Timestamp, ToBech32}; 31use nostr_relay_builder::prelude::*;
26use std::collections::HashSet; 32use nostr_sdk::ToBech32;
33use std::collections::{HashMap, HashSet};
34use std::sync::Arc;
27use tracing::debug; 35use tracing::debug;
28 36
29use crate::nostr::events::{ 37use crate::nostr::events::{
30 RepositoryAnnouncement, RepositoryState, KIND_REPOSITORY_ANNOUNCEMENT, KIND_REPOSITORY_STATE, 38 RepositoryAnnouncement, RepositoryState, KIND_REPOSITORY_ANNOUNCEMENT, KIND_REPOSITORY_STATE,
31}; 39};
32 40
41/// Repository data fetched from the database
42///
43/// Contains all announcements and states for a given identifier,
44/// fetched with a single filter query.
45#[derive(Debug)]
46pub struct RepositoryData {
47 /// All repository announcements with this identifier
48 pub announcements: Vec<RepositoryAnnouncement>,
49 /// All repository state events with this identifier
50 pub states: Vec<RepositoryState>,
51}
52
53/// Fetch all repository data (announcements + states) for a given identifier
54///
55/// This performs a single database query to fetch both announcement and state events,
56/// which is more efficient than separate queries.
57pub async fn fetch_repository_data(
58 database: &Arc<MemoryDatabase>,
59 identifier: &str,
60) -> Result<RepositoryData> {
61 let filter = Filter::new()
62 .kinds([
63 Kind::from(KIND_REPOSITORY_ANNOUNCEMENT),
64 Kind::from(KIND_REPOSITORY_STATE),
65 ])
66 .custom_tag(
67 SingleLetterTag::lowercase(Alphabet::D),
68 identifier.to_string(),
69 );
70
71 let events: Vec<Event> = database
72 .query(filter)
73 .await
74 .map_err(|e| anyhow!("Database query failed: {}", e))?
75 .into_iter()
76 .collect();
77
78 debug!(
79 "Fetched {} events for identifier {} from database",
80 events.len(),
81 identifier
82 );
83
84 // Separate into announcements and states
85 let mut announcements = Vec::new();
86 let mut states = Vec::new();
87
88 for event in events {
89 if event.kind == Kind::from(KIND_REPOSITORY_ANNOUNCEMENT) {
90 if let Ok(announcement) = RepositoryAnnouncement::from_event(event) {
91 announcements.push(announcement);
92 }
93 } else if event.kind == Kind::from(KIND_REPOSITORY_STATE) {
94 if let Ok(state) = RepositoryState::from_event(event) {
95 states.push(state);
96 }
97 }
98 }
99
100 debug!(
101 "Parsed {} announcements and {} states for identifier {}",
102 announcements.len(),
103 states.len(),
104 identifier
105 );
106
107 Ok(RepositoryData {
108 announcements,
109 states,
110 })
111}
112
113/// Collect authorized maintainers grouped by owner from a set of announcements
114///
115/// For each announcement, returns a map from owner pubkey to authorized maintainers:
116/// - The owner is always included in their own list
117/// - All pubkeys listed in the "maintainers" tag are also included
118/// - **Recursively**: if a maintainer also has an announcement for the same identifier,
119/// their maintainers are included too (transitive closure)
120///
121/// This allows looking up who can publish state events for a specific owner's
122/// version of the repository.
123///
124/// ## Example
125///
126/// If Alice's announcement lists Bob as maintainer, and Bob's announcement (for the
127/// same identifier) lists Charlie as maintainer, then Alice's authorized set will
128/// be {Alice, Bob, Charlie}.
129pub fn collect_authorized_maintainers(
130 announcements: &[RepositoryAnnouncement],
131) -> HashMap<String, Vec<String>> {
132 let mut by_owner: HashMap<String, Vec<String>> = HashMap::new();
133
134 for announcement in announcements {
135 let owner = announcement.event.pubkey.to_hex();
136 let identifier = &announcement.identifier;
137
138 // Use recursive helper to get all maintainers
139 let mut checked: HashSet<String> = HashSet::new();
140 get_maintainers_recursive(announcements, &owner, identifier, &mut checked);
141
142 by_owner.insert(owner, checked.into_iter().collect());
143 }
144
145 debug!(
146 "Collected maintainers for {} owners from {} announcements (with recursive expansion)",
147 by_owner.len(),
148 announcements.len()
149 );
150
151 by_owner
152}
153
154/// Recursively find all maintainers starting from a pubkey
155///
156/// This follows the pattern from ngit-relay's GetMaintainers function:
157/// - If pubkey already checked, return early (cycle prevention)
158/// - Mark pubkey as checked
159/// - Find the announcement for this pubkey+identifier
160/// - Recursively call for each maintainer listed in that announcement
161/// - The `checked` set accumulates all visited pubkeys
162fn get_maintainers_recursive(
163 announcements: &[RepositoryAnnouncement],
164 pubkey: &str,
165 identifier: &str,
166 checked: &mut HashSet<String>,
167) {
168 // Check if this pubkey has already been processed
169 if checked.contains(pubkey) {
170 return; // Already checked - avoid cycles
171 }
172 checked.insert(pubkey.to_string()); // Mark as checked
173
174 // Find the announcement event for this pubkey+identifier
175 let announcement = announcements.iter().find(|a| {
176 a.event.pubkey.to_hex() == pubkey && a.identifier == identifier
177 });
178
179 let Some(announcement) = announcement else {
180 return; // No announcement found for this pubkey
181 };
182
183 // Recursively find maintainers for each listed maintainer
184 for maintainer_pubkey in &announcement.maintainers {
185 get_maintainers_recursive(announcements, maintainer_pubkey, identifier, checked);
186 }
187}
188
189/// Collect all authorized maintainers as a flat set from all announcements
190///
191/// This is a convenience function that flattens the per-owner maintainer lists
192/// into a single set. Use this when you don't need owner-specific authorization.
193pub fn collect_all_authorized_maintainers(
194 announcements: &[RepositoryAnnouncement],
195) -> HashSet<String> {
196 let by_owner = collect_authorized_maintainers(announcements);
197 let mut all_authorized = HashSet::new();
198
199 for maintainers in by_owner.values() {
200 for maintainer in maintainers {
201 all_authorized.insert(maintainer.clone());
202 }
203 }
204
205 debug!(
206 "Collected {} total authorized maintainers from {} owners",
207 all_authorized.len(),
208 by_owner.len()
209 );
210
211 all_authorized
212}
213
214/// Find the latest state event authored by an authorized maintainer
215///
216/// Returns the state with the highest created_at timestamp among those
217/// authored by pubkeys in the authorized set.
218pub fn find_latest_authorized_state<'a>(
219 states: &'a [RepositoryState],
220 authorized_pubkeys: &HashSet<String>,
221) -> Option<&'a RepositoryState> {
222 states
223 .iter()
224 .filter(|s| {
225 let pubkey_hex = s.event.pubkey.to_hex();
226 authorized_pubkeys.contains(&pubkey_hex)
227 })
228 .max_by_key(|s| s.event.created_at)
229}
230
231/// Find the latest authorized state for a specific announcement context
232///
233/// This is similar to `find_latest_authorized_state` but considers only
234/// the maintainers authorized for a specific announcement (owner + maintainers),
235/// not the global set across all announcements.
236pub fn find_latest_state_for_announcement<'a>(
237 states: &'a [RepositoryState],
238 announcement: &RepositoryAnnouncement,
239) -> Option<&'a RepositoryState> {
240 // Build the authorized set for this specific announcement
241 let mut authorized = HashSet::new();
242 authorized.insert(announcement.event.pubkey.to_hex());
243 for maintainer in &announcement.maintainers {
244 authorized.insert(maintainer.clone());
245 }
246
247 find_latest_authorized_state(states, &authorized)
248}
249
250/// Check if a state event is the latest for its identifier among given authorized authors
251///
252/// A state is considered "latest" if no other state in the provided list
253/// from an authorized author has a newer timestamp.
254pub fn is_latest_state(
255 state: &RepositoryState,
256 all_states: &[RepositoryState],
257 authorized_pubkeys: &HashSet<String>,
258) -> bool {
259 for other in all_states {
260 // Skip self
261 if other.event.id == state.event.id {
262 continue;
263 }
264 // Only compare against authorized authors
265 if !authorized_pubkeys.contains(&other.event.pubkey.to_hex()) {
266 continue;
267 }
268 // If any authorized state is newer, this is not the latest
269 if other.event.created_at > state.event.created_at {
270 return false;
271 }
272 }
273 true
274}
275
276/// Get the authorization result for a repository from the database
277///
278/// This is the main entry point for authorization that queries the database directly.
279/// It:
280/// 1. Fetches all announcements and states for the identifier with a single query
281/// 2. Collects all authorized maintainers from announcements
282/// 3. Finds the latest state event from an authorized maintainer
283///
284/// Returns an `AuthorizationResult` that indicates whether a push is authorized.
285pub async fn get_authorization_from_db(
286 database: &Arc<MemoryDatabase>,
287 identifier: &str,
288) -> Result<AuthorizationResult> {
289 // Fetch all repository data with a single query
290 let repo_data = fetch_repository_data(database, identifier).await?;
291
292 if repo_data.announcements.is_empty() {
293 return Ok(AuthorizationResult::denied(
294 "No repository announcement found",
295 ));
296 }
297
298 // Collect all authorized maintainers (flattened across all owners)
299 let authorized = collect_all_authorized_maintainers(&repo_data.announcements);
300
301 if authorized.is_empty() {
302 return Ok(AuthorizationResult::denied(
303 "No authorized maintainers found",
304 ));
305 }
306
307 debug!(
308 "Found {} authorized maintainers for repository {}",
309 authorized.len(),
310 identifier
311 );
312
313 // Find the latest authorized state
314 match find_latest_authorized_state(&repo_data.states, &authorized) {
315 Some(state) => Ok(AuthorizationResult::authorized(
316 state.clone(),
317 authorized.into_iter().collect(),
318 )),
319 None => Ok(AuthorizationResult::denied(
320 "No state event found from authorized publishers",
321 )),
322 }
323}
324
325/// Get the authorization result for a repository scoped to a specific owner
326///
327/// Unlike `get_authorization_from_db`, this function scopes the authorization
328/// to a specific owner's announcement. This is the correct approach for Git push
329/// authorization where the URL path specifies the owner.
330///
331/// A push to `alice/my-repo` should only consider authorization from alice's
332/// announcement, not bob's announcement for the same identifier.
333///
334/// It:
335/// 1. Fetches all announcements and states for the identifier
336/// 2. Collects authorized maintainers from all announcements (grouped by owner)
337/// 3. Looks up the authorized set for the specific owner
338/// 4. Finds the latest state event from an authorized maintainer
339///
340/// Returns an `AuthorizationResult` that indicates whether a push is authorized.
341pub async fn get_authorization_for_owner(
342 database: &Arc<MemoryDatabase>,
343 identifier: &str,
344 owner_pubkey: &str,
345) -> Result<AuthorizationResult> {
346 // Fetch all repository data with a single query
347 let repo_data = fetch_repository_data(database, identifier).await?;
348
349 if repo_data.announcements.is_empty() {
350 return Ok(AuthorizationResult::denied(
351 "No repository announcement found",
352 ));
353 }
354
355 // Collect authorized maintainers grouped by owner from all announcements
356 let by_owner = collect_authorized_maintainers(&repo_data.announcements);
357
358 // Look up the authorized set for this specific owner
359 let authorized: HashSet<String> = match by_owner.get(owner_pubkey) {
360 Some(maintainers) => maintainers.iter().cloned().collect(),
361 None => {
362 return Ok(AuthorizationResult::denied(format!(
363 "No repository announcement found for owner {}",
364 owner_pubkey
365 )));
366 }
367 };
368
369 if authorized.is_empty() {
370 return Ok(AuthorizationResult::denied(
371 "No authorized maintainers found",
372 ));
373 }
374
375 debug!(
376 "Found {} authorized maintainers for repository {} (owner: {})",
377 authorized.len(),
378 identifier,
379 owner_pubkey
380 );
381
382 // Find the latest authorized state from owner's maintainer set
383 match find_latest_authorized_state(&repo_data.states, &authorized) {
384 Some(state) => Ok(AuthorizationResult::authorized(
385 state.clone(),
386 authorized.into_iter().collect(),
387 )),
388 None => Ok(AuthorizationResult::denied(
389 "No state event found from authorized publishers",
390 )),
391 }
392}
393
33/// Result of authorization check 394/// Result of authorization check
34#[derive(Debug)] 395#[derive(Debug)]
35pub struct AuthorizationResult { 396pub struct AuthorizationResult {