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

eidetica/sync/
bootstrap.rs

1//! Bootstrap sync operations and request management.
2
3use tracing::info;
4
5use super::{
6    Address, BootstrapRequest, DatabaseTicket, RequestStatus, Sync, SyncError,
7    bootstrap_request_manager::BootstrapRequestManager,
8};
9use crate::{
10    Database, Result,
11    auth::{Permission, crypto::PrivateKey, types::AuthKey},
12    crdt::Doc,
13    database::DatabaseKey,
14    entry::ID,
15};
16
17impl Sync {
18    // === Bootstrap Sync Methods ===
19    //
20    // Bootstrap sync allows a device to request access to a database it doesn't
21    // have permission to yet. The device sends its public key and requested
22    // permission level to the peer, creating a pending bootstrap request that
23    // an administrator can approve or reject.
24    //
25    // Use `sync_with_peer_for_bootstrap_with_key()` with User API managed keys.
26
27    /// Internal helper for bootstrap sync operations.
28    ///
29    /// This method contains the common logic for bootstrap scenarios where the local
30    /// device doesn't have access to the target tree yet and needs to request
31    /// permission during the initial sync.
32    ///
33    /// # Arguments
34    /// * `address` - The transport address of the peer to sync with
35    /// * `tree_id` - The ID of the tree to sync
36    /// * `requesting_key` - The private key to sign the request with and request access for
37    /// * `requesting_key_name` - The name/ID of the requesting key
38    /// * `requested_permission` - The permission level being requested
39    ///
40    /// # Returns
41    /// A Result indicating success or failure.
42    ///
43    /// # Errors
44    /// * `SyncError::InvalidPublicKey` if the public key is empty or malformed
45    /// * `SyncError::InvalidKeyName` if the key name is empty
46    async fn sync_with_peer_for_bootstrap_internal(
47        &self,
48        address: &Address,
49        tree_id: &ID,
50        requesting_key: &PrivateKey,
51        requesting_key_name: &str,
52        requested_permission: Permission,
53        metadata: Option<Doc>,
54    ) -> Result<()> {
55        // Validate key name is not empty
56        if requesting_key_name.is_empty() {
57            return Err(SyncError::InvalidKeyName {
58                reason: "Key name cannot be empty".to_string(),
59            }
60            .into());
61        }
62
63        // Connect to peer if not already connected
64        let peer_pubkey = self.connect_to_peer(address).await?;
65
66        // Store the address for this peer
67        self.add_peer_address(&peer_pubkey, address.clone()).await?;
68
69        // Sync tree with authentication
70        self.sync_tree_with_peer_auth(
71            &peer_pubkey,
72            tree_id,
73            Some(requesting_key),
74            Some(requesting_key_name),
75            Some(requested_permission),
76            metadata,
77        )
78        .await?;
79
80        Ok(())
81    }
82
83    /// Sync with a peer for bootstrap using a user-provided public key.
84    ///
85    /// This method is specifically designed for bootstrap scenarios where the local
86    /// device doesn't have access to the target tree yet and needs to request
87    /// permission during the initial sync. The public key is provided directly
88    /// rather than looked up from backend storage, making it compatible with
89    /// User API managed keys.
90    ///
91    /// # Arguments
92    /// * `address` - The transport address of the peer to sync with
93    /// * `tree_id` - The ID of the tree to sync
94    /// * `requesting_key` - The private key to sign the request with and request access for
95    /// * `requesting_key_name` - The name/ID of the requesting key for audit trail
96    /// * `requested_permission` - The permission level being requested
97    ///
98    /// # Returns
99    /// A Result indicating success or failure.
100    ///
101    /// # Example
102    /// ```rust,ignore
103    /// // With User API managed keys:
104    /// let signing_key = user.get_signing_key(user_key_id)?;
105    /// sync.sync_with_peer_for_bootstrap_with_key(
106    ///     &Address::http("127.0.0.1:8080"),
107    ///     &tree_id,
108    ///     &signing_key,
109    ///     user_key_id,
110    ///     Permission::Write(5),
111    /// ).await?;
112    /// ```
113    pub async fn sync_with_peer_for_bootstrap_with_key(
114        &self,
115        address: &Address,
116        tree_id: &ID,
117        requesting_key: &PrivateKey,
118        requesting_key_name: &str,
119        requested_permission: Permission,
120    ) -> Result<()> {
121        // Delegate to internal method. This lower-level entry point carries no
122        // approver metadata; use `bootstrap_with_ticket` to attach it.
123        self.sync_with_peer_for_bootstrap_internal(
124            address,
125            tree_id,
126            requesting_key,
127            requesting_key_name,
128            requested_permission,
129            None,
130        )
131        .await
132    }
133
134    /// Bootstrap with a peer using a [`DatabaseTicket`].
135    ///
136    /// Races bounded handshakes against every address hint, then performs the
137    /// bootstrap exchange once through the first usable route.
138    ///
139    /// # Arguments
140    /// * `ticket` - A ticket containing the database ID and address hints.
141    /// * `requesting_key` - The private key to sign the request with and request access for.
142    /// * `requesting_key_name` - The name/ID of the requesting key.
143    /// * `requested_permission` - The permission level being requested.
144    /// * `metadata` - Optional free-form context the requester attaches for the
145    ///   approver to inspect, surfaced verbatim on the stored [`BootstrapRequest`].
146    ///
147    /// # Errors
148    /// Returns [`SyncError::InvalidAddress`] if the ticket has no address hints.
149    /// Returns the last sync error if no address succeeded.
150    pub async fn bootstrap_with_ticket(
151        &self,
152        ticket: &DatabaseTicket,
153        requesting_key: &PrivateKey,
154        requesting_key_name: &str,
155        requested_permission: Permission,
156        metadata: Option<Doc>,
157    ) -> Result<()> {
158        let database_id = ticket.database_id().clone();
159        let signing_key = requesting_key.clone();
160        let key_name = requesting_key_name.to_string();
161        let (address, peer_pubkey) = self.select_address(ticket.addresses(), None).await?;
162        self.add_peer_address(&peer_pubkey, address.clone()).await?;
163        self.sync_tree_with_peer_auth_at(
164            &address,
165            &peer_pubkey,
166            &database_id,
167            Some(&signing_key),
168            Some(&key_name),
169            Some(requested_permission),
170            metadata,
171        )
172        .await
173    }
174
175    // === Bootstrap Request Management Methods ===
176
177    /// Get all pending bootstrap requests.
178    ///
179    /// # Returns
180    /// A vector of (request_id, bootstrap_request) pairs for pending requests.
181    pub async fn pending_bootstrap_requests(&self) -> Result<Vec<(String, BootstrapRequest)>> {
182        let txn = self.sync_tree.new_transaction().await?;
183        let manager = BootstrapRequestManager::new(&txn);
184        manager.pending_requests().await
185    }
186
187    /// Get all approved bootstrap requests.
188    ///
189    /// # Returns
190    /// A vector of (request_id, bootstrap_request) pairs for approved requests.
191    pub async fn approved_bootstrap_requests(&self) -> Result<Vec<(String, BootstrapRequest)>> {
192        let txn = self.sync_tree.new_transaction().await?;
193        let manager = BootstrapRequestManager::new(&txn);
194        manager.approved_requests().await
195    }
196
197    /// Get all rejected bootstrap requests.
198    ///
199    /// # Returns
200    /// A vector of (request_id, bootstrap_request) pairs for rejected requests.
201    pub async fn rejected_bootstrap_requests(&self) -> Result<Vec<(String, BootstrapRequest)>> {
202        let txn = self.sync_tree.new_transaction().await?;
203        let manager = BootstrapRequestManager::new(&txn);
204        manager.rejected_requests().await
205    }
206
207    /// Get a specific bootstrap request by ID.
208    ///
209    /// # Arguments
210    /// * `request_id` - The unique identifier of the request
211    ///
212    /// # Returns
213    /// A tuple of (request_id, bootstrap_request) if found, None otherwise.
214    pub async fn get_bootstrap_request(
215        &self,
216        request_id: &str,
217    ) -> Result<Option<(String, BootstrapRequest)>> {
218        let txn = self.sync_tree.new_transaction().await?;
219        let manager = BootstrapRequestManager::new(&txn);
220
221        match manager.get_request(request_id).await? {
222            Some(request) => Ok(Some((request_id.to_string(), request))),
223            None => Ok(None),
224        }
225    }
226
227    /// Approve a bootstrap request using a `DatabaseKey`.
228    ///
229    /// This variant allows approval using keys that are not stored in the backend,
230    /// such as user keys managed in memory.
231    ///
232    /// # Arguments
233    /// * `request_id` - The unique identifier of the request to approve
234    /// * `key` - The `DatabaseKey` to use for the transaction and audit trail
235    ///
236    /// # Returns
237    /// Result indicating success or failure of the approval operation.
238    ///
239    /// # Errors
240    /// Returns `SyncError::InsufficientPermission` if the approving key does not have
241    /// Admin permission on the target database.
242    pub async fn approve_bootstrap_request_with_key(
243        &self,
244        request_id: &str,
245        key: &DatabaseKey,
246    ) -> Result<()> {
247        let instance = self.instance()?;
248        let lock = instance.tree_lock(self.sync_tree.root_id());
249        let guard = lock.lock_owned().await;
250
251        // Load the request from sync database
252        let sync_op = self.sync_tree.new_transaction().await?;
253        let manager = BootstrapRequestManager::new(&sync_op);
254
255        let request = manager
256            .get_request(request_id)
257            .await?
258            .ok_or_else(|| SyncError::RequestNotFound(request_id.to_string()))?;
259
260        // Validate request is still pending
261        if !matches!(request.status, RequestStatus::Pending) {
262            return Err(SyncError::InvalidRequestState {
263                request_id: request_id.to_string(),
264                current_status: format!("{:?}", request.status),
265                expected_status: "Pending".to_string(),
266            }
267            .into());
268        }
269
270        // Load the existing database with the user's signing key
271        let database = Database::open(&self.instance()?, &request.tree_id)
272            .await?
273            .with_key(key.clone());
274
275        // Explicitly check that the approving user has Admin permission
276        // This provides clear error messages and fails fast before modifying the database
277        let permission = database.current_permission().await?;
278        if !permission.can_admin() {
279            return Err(SyncError::InsufficientPermission {
280                request_id: request_id.to_string(),
281                required_permission: "Admin".to_string(),
282                actual_permission: permission,
283            }
284            .into());
285        }
286
287        // Create transaction - this will use the provided signing key
288        let tx = database.new_transaction().await?;
289
290        // Get settings store and update auth configuration
291        let settings_store = tx.get_settings()?;
292
293        // Create the auth key for the requesting device
294        // Keys are stored by pubkey, with name as optional metadata
295        let auth_key = AuthKey::active(
296            Some(&request.requesting_key_name), // name metadata
297            request.requested_permission,
298        );
299
300        // Add the new key to auth settings using SettingsStore API
301        // Store by pubkey (this provides proper upsert behavior and validation)
302        settings_store
303            .set_auth_key(&request.requesting_pubkey, auth_key)
304            .await?;
305
306        // Commit will validate that the user's key has Admin permission
307        // If this fails, it means the user lacks the necessary permission
308        tx.commit().await?;
309
310        // Update request status to approved
311        let approver_id = key.identity().display_id();
312        let approval_time = self
313            .instance
314            .upgrade()
315            .ok_or(SyncError::InstanceDropped)?
316            .clock()
317            .now_rfc3339();
318        manager
319            .update_status(
320                request_id,
321                RequestStatus::Approved {
322                    approved_by: approver_id.to_string(),
323                    approval_time,
324                },
325            )
326            .await?;
327        sync_op.commit_under_tree_lock(guard).await?;
328
329        info!(
330            request_id = %request_id,
331            tree_id = %request.tree_id,
332            approved_by = %approver_id,
333            "Bootstrap request approved and key added to database using user-provided key"
334        );
335
336        Ok(())
337    }
338
339    /// Reject a bootstrap request using a `DatabaseKey` with Admin permission validation.
340    ///
341    /// This variant allows rejection using keys that are not stored in the backend,
342    /// such as user keys managed in memory. It validates that the rejecting user has
343    /// Admin permission on the target database before allowing the rejection.
344    ///
345    /// # Arguments
346    /// * `request_id` - The unique identifier of the request to reject
347    /// * `key` - The `DatabaseKey` to use for permission validation and audit trail
348    ///
349    /// # Returns
350    /// Result indicating success or failure of the rejection operation.
351    ///
352    /// # Errors
353    /// Returns `SyncError::InsufficientPermission` if the rejecting key does not have
354    /// Admin permission on the target database.
355    pub async fn reject_bootstrap_request_with_key(
356        &self,
357        request_id: &str,
358        key: &DatabaseKey,
359    ) -> Result<()> {
360        let instance = self.instance()?;
361        let lock = instance.tree_lock(self.sync_tree.root_id());
362        let guard = lock.lock_owned().await;
363
364        // Load the request from sync database
365        let sync_op = self.sync_tree.new_transaction().await?;
366        let manager = BootstrapRequestManager::new(&sync_op);
367
368        let request = manager
369            .get_request(request_id)
370            .await?
371            .ok_or_else(|| SyncError::RequestNotFound(request_id.to_string()))?;
372
373        // Validate request is still pending
374        if !matches!(request.status, RequestStatus::Pending) {
375            return Err(SyncError::InvalidRequestState {
376                request_id: request_id.to_string(),
377                current_status: format!("{:?}", request.status),
378                expected_status: "Pending".to_string(),
379            }
380            .into());
381        }
382
383        // Load the existing database with the user's signing key to validate permissions
384        let database = Database::open(&self.instance()?, &request.tree_id)
385            .await?
386            .with_key(key.clone());
387
388        // Check that the rejecting user has Admin permission
389        let permission = database.current_permission().await?;
390        if !permission.can_admin() {
391            return Err(SyncError::InsufficientPermission {
392                request_id: request_id.to_string(),
393                required_permission: "Admin".to_string(),
394                actual_permission: permission,
395            }
396            .into());
397        }
398
399        // User has Admin permission, proceed with rejection
400        let rejecter_id = key.identity().display_id();
401        let rejection_time = self
402            .instance
403            .upgrade()
404            .ok_or(SyncError::InstanceDropped)?
405            .clock()
406            .now_rfc3339();
407        manager
408            .update_status(
409                request_id,
410                RequestStatus::Rejected {
411                    rejected_by: rejecter_id.to_string(),
412                    rejection_time,
413                },
414            )
415            .await?;
416        sync_op.commit_under_tree_lock(guard).await?;
417
418        info!(
419            request_id = %request_id,
420            tree_id = %request.tree_id,
421            rejected_by = %rejecter_id,
422            "Bootstrap request rejected by user with Admin permission"
423        );
424
425        Ok(())
426    }
427}