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