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

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, key.clone()).await?;
265
266        // Explicitly check that the approving user has Admin permission
267        // This provides clear error messages and fails fast before modifying the database
268        let permission = database.current_permission().await?;
269        if !permission.can_admin() {
270            return Err(SyncError::InsufficientPermission {
271                request_id: request_id.to_string(),
272                required_permission: "Admin".to_string(),
273                actual_permission: permission,
274            }
275            .into());
276        }
277
278        // Create transaction - this will use the provided signing key
279        let tx = database.new_transaction().await?;
280
281        // Get settings store and update auth configuration
282        let settings_store = tx.get_settings()?;
283
284        // Create the auth key for the requesting device
285        // Keys are stored by pubkey, with name as optional metadata
286        let auth_key = AuthKey::active(
287            Some(&request.requesting_key_name), // name metadata
288            request.requested_permission,
289        );
290
291        // Add the new key to auth settings using SettingsStore API
292        // Store by pubkey (this provides proper upsert behavior and validation)
293        settings_store
294            .set_auth_key(&request.requesting_pubkey, auth_key)
295            .await?;
296
297        // Commit will validate that the user's key has Admin permission
298        // If this fails, it means the user lacks the necessary permission
299        tx.commit().await?;
300
301        // Update request status to approved
302        let approver_id = key.identity().display_id();
303        let approval_time = self
304            .instance
305            .upgrade()
306            .ok_or(SyncError::InstanceDropped)?
307            .clock()
308            .now_rfc3339();
309        manager
310            .update_status(
311                request_id,
312                RequestStatus::Approved {
313                    approved_by: approver_id.to_string(),
314                    approval_time,
315                },
316            )
317            .await?;
318        sync_op.commit().await?;
319
320        info!(
321            request_id = %request_id,
322            tree_id = %request.tree_id,
323            approved_by = %approver_id,
324            "Bootstrap request approved and key added to database using user-provided key"
325        );
326
327        Ok(())
328    }
329
330    /// Reject a bootstrap request using a `DatabaseKey` with Admin permission validation.
331    ///
332    /// This variant allows rejection using keys that are not stored in the backend,
333    /// such as user keys managed in memory. It validates that the rejecting user has
334    /// Admin permission on the target database before allowing the rejection.
335    ///
336    /// # Arguments
337    /// * `request_id` - The unique identifier of the request to reject
338    /// * `key` - The `DatabaseKey` to use for permission validation and audit trail
339    ///
340    /// # Returns
341    /// Result indicating success or failure of the rejection operation.
342    ///
343    /// # Errors
344    /// Returns `SyncError::InsufficientPermission` if the rejecting key does not have
345    /// Admin permission on the target database.
346    pub async fn reject_bootstrap_request_with_key(
347        &self,
348        request_id: &str,
349        key: &DatabaseKey,
350    ) -> Result<()> {
351        // Load the request from sync database
352        let sync_op = self.sync_tree.new_transaction().await?;
353        let manager = BootstrapRequestManager::new(&sync_op);
354
355        let request = manager
356            .get_request(request_id)
357            .await?
358            .ok_or_else(|| SyncError::RequestNotFound(request_id.to_string()))?;
359
360        // Validate request is still pending
361        if !matches!(request.status, RequestStatus::Pending) {
362            return Err(SyncError::InvalidRequestState {
363                request_id: request_id.to_string(),
364                current_status: format!("{:?}", request.status),
365                expected_status: "Pending".to_string(),
366            }
367            .into());
368        }
369
370        // Load the existing database with the user's signing key to validate permissions
371        let database = Database::open(self.instance()?, &request.tree_id, key.clone()).await?;
372
373        // Check that the rejecting user has Admin permission
374        let permission = database.current_permission().await?;
375        if !permission.can_admin() {
376            return Err(SyncError::InsufficientPermission {
377                request_id: request_id.to_string(),
378                required_permission: "Admin".to_string(),
379                actual_permission: permission,
380            }
381            .into());
382        }
383
384        // User has Admin permission, proceed with rejection
385        let rejecter_id = key.identity().display_id();
386        let rejection_time = self
387            .instance
388            .upgrade()
389            .ok_or(SyncError::InstanceDropped)?
390            .clock()
391            .now_rfc3339();
392        manager
393            .update_status(
394                request_id,
395                RequestStatus::Rejected {
396                    rejected_by: rejecter_id.to_string(),
397                    rejection_time,
398                },
399            )
400            .await?;
401        sync_op.commit().await?;
402
403        info!(
404            request_id = %request_id,
405            tree_id = %request.tree_id,
406            rejected_by = %rejecter_id,
407            "Bootstrap request rejected by user with Admin permission"
408        );
409
410        Ok(())
411    }
412}