Development Documentation (main branch) - For stable release docs, see docs.rs/eidetica
Skip to main content

eidetica/sync/
bootstrap_request_manager.rs

1//! Bootstrap request management for the sync module.
2//!
3//! This module handles storing and managing bootstrap requests that require manual approval.
4//! Bootstrap requests are stored in the sync database as an Instance-level concern.
5
6use serde::{Deserialize, Serialize};
7use tracing::{debug, info};
8
9use super::peer_types::Address;
10use crate::{
11    Error, Result, Transaction,
12    auth::{Permission, crypto::PublicKey},
13    crdt::Doc,
14    entry::ID,
15    store::{StoreError, Table},
16};
17
18/// Private constant for bootstrap request subtree name
19pub(super) const BOOTSTRAP_REQUESTS_SUBTREE: &str = "bootstrap_requests";
20
21/// Internal bootstrap request manager for the sync module.
22///
23/// This struct manages all bootstrap request operations for the sync module,
24/// operating on a Transaction to stage changes.
25pub(super) struct BootstrapRequestManager<'a> {
26    txn: &'a Transaction,
27}
28
29/// A bootstrap request awaiting manual approval
30#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
31pub struct BootstrapRequest {
32    /// The tree ID being requested for access
33    pub tree_id: ID,
34    /// Public key of the requesting device
35    pub requesting_pubkey: PublicKey,
36    /// Key name identifier for the requesting key
37    pub requesting_key_name: String,
38    /// Permission level being requested
39    pub requested_permission: Permission,
40    /// When the request was made (ISO 8601 timestamp)
41    pub timestamp: String,
42    /// Current status of the request
43    pub status: RequestStatus,
44    /// Address of the requesting peer (for future notifications)
45    pub peer_address: Address,
46    /// Free-form context supplied by the requester for the approver to inspect
47    /// when deciding whether to grant access. Carried verbatim from the request.
48    #[serde(default)]
49    pub metadata: Option<Doc>,
50}
51
52/// Status of a bootstrap request
53#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
54pub enum RequestStatus {
55    /// Request is pending approval
56    Pending,
57    /// Request has been approved
58    Approved {
59        /// Who approved the request
60        approved_by: String,
61        /// When it was approved
62        approval_time: String,
63    },
64    /// Request has been rejected
65    Rejected {
66        /// Who rejected the request
67        rejected_by: String,
68        /// When it was rejected
69        rejection_time: String,
70    },
71}
72
73impl<'a> BootstrapRequestManager<'a> {
74    /// Create a new BootstrapRequestManager that operates on the given Transaction.
75    pub(super) fn new(txn: &'a Transaction) -> Self {
76        Self { txn }
77    }
78
79    /// Store a new bootstrap request in the sync database.
80    ///
81    /// # Arguments
82    /// * `request` - The bootstrap request to store
83    ///
84    /// # Returns
85    /// The generated UUID for the request.
86    pub(super) async fn store_request(&self, request: BootstrapRequest) -> Result<String> {
87        let requests = self
88            .txn
89            .get_store::<Table<BootstrapRequest>>(BOOTSTRAP_REQUESTS_SUBTREE)
90            .await?;
91
92        debug!(tree_id = %request.tree_id, "Storing bootstrap request");
93
94        // Insert request and get generated UUID
95        let request_id = requests.insert(request.clone()).await?;
96
97        info!(request_id = %request_id, tree_id = %request.tree_id, "Successfully stored bootstrap request");
98        Ok(request_id)
99    }
100
101    /// Get a specific bootstrap request by ID.
102    ///
103    /// # Arguments
104    /// * `request_id` - The ID of the request to retrieve
105    ///
106    /// # Returns
107    /// The bootstrap request if found, None otherwise.
108    pub(super) async fn get_request(&self, request_id: &str) -> Result<Option<BootstrapRequest>> {
109        let requests = self
110            .txn
111            .get_store::<Table<BootstrapRequest>>(BOOTSTRAP_REQUESTS_SUBTREE)
112            .await?;
113
114        match requests.get(request_id).await {
115            Ok(request) => Ok(Some(request)),
116            Err(Error::Store(ref e)) if matches!(**e, StoreError::KeyNotFound { .. }) => Ok(None),
117            Err(e) => Err(e),
118        }
119    }
120
121    /// Internal method to filter bootstrap requests by status.
122    async fn filter_requests(
123        &self,
124        status_filter: &RequestStatus,
125    ) -> Result<Vec<(String, BootstrapRequest)>> {
126        let requests = self
127            .txn
128            .get_store::<Table<BootstrapRequest>>(BOOTSTRAP_REQUESTS_SUBTREE)
129            .await?;
130
131        let results = requests
132            .search(|request| {
133                std::mem::discriminant(status_filter) == std::mem::discriminant(&request.status)
134            })
135            .await?;
136
137        Ok(results)
138    }
139
140    /// Get all pending bootstrap requests.
141    ///
142    /// # Returns
143    /// A vector of (request_id, bootstrap_request) pairs for pending requests.
144    pub(super) async fn pending_requests(&self) -> Result<Vec<(String, BootstrapRequest)>> {
145        self.filter_requests(&RequestStatus::Pending).await
146    }
147
148    /// Get all approved bootstrap requests.
149    ///
150    /// # Returns
151    /// A vector of (request_id, bootstrap_request) pairs for approved requests.
152    pub(super) async fn approved_requests(&self) -> Result<Vec<(String, BootstrapRequest)>> {
153        self.filter_requests(&RequestStatus::Approved {
154            approved_by: String::new(),
155            approval_time: String::new(),
156        })
157        .await
158    }
159
160    /// Get all rejected bootstrap requests.
161    ///
162    /// # Returns
163    /// A vector of (request_id, bootstrap_request) pairs for rejected requests.
164    pub(super) async fn rejected_requests(&self) -> Result<Vec<(String, BootstrapRequest)>> {
165        self.filter_requests(&RequestStatus::Rejected {
166            rejected_by: String::new(),
167            rejection_time: String::new(),
168        })
169        .await
170    }
171
172    /// Update the status of a bootstrap request.
173    ///
174    /// # Arguments
175    /// * `request_id` - The ID of the request to update
176    /// * `new_status` - The new status to set
177    ///
178    /// # Returns
179    /// A Result indicating success or an error.
180    pub(super) async fn update_status(
181        &self,
182        request_id: &str,
183        new_status: RequestStatus,
184    ) -> Result<()> {
185        let requests = self
186            .txn
187            .get_store::<Table<BootstrapRequest>>(BOOTSTRAP_REQUESTS_SUBTREE)
188            .await?;
189
190        // Get the existing request
191        let mut request = requests.get(request_id).await?;
192
193        // Update the status
194        request.status = new_status;
195
196        // Store the updated request
197        requests.set(request_id, request).await?;
198
199        debug!(request_id = %request_id, "Updated bootstrap request status");
200        Ok(())
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use crate::{
208        Clock, Database, Instance, auth::types::Permission, backend::database::InMemory,
209        clock::FixedClock, crdt::Doc,
210    };
211    use std::sync::Arc;
212
213    async fn create_test_sync_tree() -> (Instance, Database, Arc<FixedClock>) {
214        let clock = Arc::new(FixedClock::default());
215        let (instance, mut user) = Instance::create_backend_with_clock(
216            Box::new(InMemory::new()),
217            clock.clone(),
218            crate::NewUser::passwordless("test"),
219        )
220        .await
221        .expect("Failed to create test instance");
222
223        let mut sync_settings = Doc::new();
224        sync_settings.set("name", "_sync");
225        sync_settings.set("type", "sync_settings");
226
227        let (database, _) = user
228            .new_database()
229            .settings(sync_settings)
230            .build()
231            .await
232            .unwrap();
233
234        (instance, database, clock)
235    }
236
237    fn create_test_request(clock: &FixedClock) -> BootstrapRequest {
238        BootstrapRequest {
239            // Use a valid, prefixed ID so parsing validates correctly
240            tree_id: ID::from_bytes("test_tree_id"),
241            requesting_pubkey: PublicKey::random(),
242            requesting_key_name: "laptop_key".to_string(),
243            requested_permission: Permission::Write(5),
244            timestamp: clock.now_rfc3339(),
245            status: RequestStatus::Pending,
246            peer_address: Address {
247                transport_type: "http".to_string(),
248                address: "127.0.0.1:8080".to_string(),
249            },
250            metadata: None,
251        }
252    }
253
254    #[tokio::test]
255    async fn test_store_and_get_request() {
256        let (_instance, sync_tree, clock) = create_test_sync_tree().await;
257        let txn = sync_tree.new_transaction().await.unwrap();
258        let manager = BootstrapRequestManager::new(&txn);
259
260        let request = create_test_request(&clock);
261
262        // Store the request and get the generated UUID
263        let request_id = manager.store_request(request.clone()).await.unwrap();
264
265        // Retrieve the request
266        let retrieved = manager.get_request(&request_id).await.unwrap().unwrap();
267        assert_eq!(retrieved.tree_id, request.tree_id);
268        assert_eq!(retrieved.requesting_pubkey, request.requesting_pubkey);
269        assert_eq!(retrieved.requesting_key_name, request.requesting_key_name);
270        assert_eq!(retrieved.requested_permission, request.requested_permission);
271        assert_eq!(retrieved.status, request.status);
272        assert_eq!(retrieved.peer_address, request.peer_address);
273    }
274
275    #[tokio::test]
276    async fn test_list_requests() {
277        let (_instance, sync_tree, clock) = create_test_sync_tree().await;
278        let txn = sync_tree.new_transaction().await.unwrap();
279        let manager = BootstrapRequestManager::new(&txn);
280
281        // Store multiple requests
282        let request1 = create_test_request(&clock);
283
284        let mut request2 = create_test_request(&clock);
285        request2.status = RequestStatus::Approved {
286            approved_by: "admin".to_string(),
287            approval_time: clock.now_rfc3339(),
288        };
289
290        manager.store_request(request1).await.unwrap();
291        manager.store_request(request2).await.unwrap();
292
293        // Get pending requests
294        let pending_requests = manager.pending_requests().await.unwrap();
295        assert_eq!(pending_requests.len(), 1);
296
297        // Get approved requests
298        let approved_requests = manager.approved_requests().await.unwrap();
299        assert_eq!(approved_requests.len(), 1);
300
301        // Verify statuses
302        assert!(matches!(
303            pending_requests[0].1.status,
304            RequestStatus::Pending
305        ));
306        assert!(matches!(
307            approved_requests[0].1.status,
308            RequestStatus::Approved { .. }
309        ));
310    }
311
312    #[tokio::test]
313    async fn test_update_status() {
314        let (_instance, sync_tree, clock) = create_test_sync_tree().await;
315        let txn = sync_tree.new_transaction().await.unwrap();
316        let manager = BootstrapRequestManager::new(&txn);
317
318        let request = create_test_request(&clock);
319
320        // Store the request and get the generated UUID
321        let request_id = manager.store_request(request).await.unwrap();
322
323        // Update status to approved
324        let new_status = RequestStatus::Approved {
325            approved_by: "admin".to_string(),
326            approval_time: clock.now_rfc3339(),
327        };
328        manager
329            .update_status(&request_id, new_status.clone())
330            .await
331            .unwrap();
332
333        // Verify status was updated
334        let updated_request = manager.get_request(&request_id).await.unwrap().unwrap();
335        assert_eq!(updated_request.status, new_status);
336    }
337
338    #[tokio::test]
339    async fn test_get_nonexistent_request() {
340        let (_instance, sync_tree, _clock) = create_test_sync_tree().await;
341        let txn = sync_tree.new_transaction().await.unwrap();
342        let manager = BootstrapRequestManager::new(&txn);
343
344        let result = manager.get_request("nonexistent").await.unwrap();
345        assert!(result.is_none());
346    }
347}