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

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