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    /// Tries every address hint in the ticket concurrently. Succeeds if at
137    /// least one address connects and syncs; returns the last error if all
138    /// fail.
139    ///
140    /// # Arguments
141    /// * `ticket` - A ticket containing the database ID and address hints.
142    /// * `requesting_key` - The private key to sign the request with and request access for.
143    /// * `requesting_key_name` - The name/ID of the requesting key.
144    /// * `requested_permission` - The permission level being requested.
145    /// * `metadata` - Optional free-form context the requester attaches for the
146    ///   approver to inspect, surfaced verbatim on the stored [`BootstrapRequest`].
147    ///
148    /// # Errors
149    /// Returns [`SyncError::InvalidAddress`] if the ticket has no address hints.
150    /// Returns the last sync error if no address succeeded.
151    pub async fn bootstrap_with_ticket(
152        &self,
153        ticket: &DatabaseTicket,
154        requesting_key: &PrivateKey,
155        requesting_key_name: &str,
156        requested_permission: Permission,
157        metadata: Option<Doc>,
158    ) -> Result<()> {
159        let database_id = ticket.database_id().clone();
160        let signing_key = requesting_key.clone();
161        let key_name = requesting_key_name.to_string();
162        self.try_addresses_concurrently(ticket.addresses(), |sync, addr| {
163            let db_id = database_id.clone();
164            let signing_key = signing_key.clone();
165            let key_name = key_name.clone();
166            let metadata = metadata.clone();
167            async move {
168                sync.sync_with_peer_for_bootstrap_internal(
169                    &addr,
170                    &db_id,
171                    &signing_key,
172                    &key_name,
173                    requested_permission,
174                    metadata,
175                )
176                .await
177            }
178        })
179        .await
180    }
181
182    // === Bootstrap Request Management Methods ===
183
184    /// Get all pending bootstrap requests.
185    ///
186    /// # Returns
187    /// A vector of (request_id, bootstrap_request) pairs for pending requests.
188    pub async fn pending_bootstrap_requests(&self) -> Result<Vec<(String, BootstrapRequest)>> {
189        let txn = self.sync_tree.new_transaction().await?;
190        let manager = BootstrapRequestManager::new(&txn);
191        manager.pending_requests().await
192    }
193
194    /// Get all approved bootstrap requests.
195    ///
196    /// # Returns
197    /// A vector of (request_id, bootstrap_request) pairs for approved requests.
198    pub async fn approved_bootstrap_requests(&self) -> Result<Vec<(String, BootstrapRequest)>> {
199        let txn = self.sync_tree.new_transaction().await?;
200        let manager = BootstrapRequestManager::new(&txn);
201        manager.approved_requests().await
202    }
203
204    /// Get all rejected bootstrap requests.
205    ///
206    /// # Returns
207    /// A vector of (request_id, bootstrap_request) pairs for rejected requests.
208    pub async fn rejected_bootstrap_requests(&self) -> Result<Vec<(String, BootstrapRequest)>> {
209        let txn = self.sync_tree.new_transaction().await?;
210        let manager = BootstrapRequestManager::new(&txn);
211        manager.rejected_requests().await
212    }
213
214    /// Get a specific bootstrap request by ID.
215    ///
216    /// # Arguments
217    /// * `request_id` - The unique identifier of the request
218    ///
219    /// # Returns
220    /// A tuple of (request_id, bootstrap_request) if found, None otherwise.
221    pub async fn get_bootstrap_request(
222        &self,
223        request_id: &str,
224    ) -> Result<Option<(String, BootstrapRequest)>> {
225        let txn = self.sync_tree.new_transaction().await?;
226        let manager = BootstrapRequestManager::new(&txn);
227
228        match manager.get_request(request_id).await? {
229            Some(request) => Ok(Some((request_id.to_string(), request))),
230            None => Ok(None),
231        }
232    }
233
234    /// Approve a bootstrap request using a `DatabaseKey`.
235    ///
236    /// This variant allows approval using keys that are not stored in the backend,
237    /// such as user keys managed in memory.
238    ///
239    /// # Arguments
240    /// * `request_id` - The unique identifier of the request to approve
241    /// * `key` - The `DatabaseKey` to use for the transaction and audit trail
242    ///
243    /// # Returns
244    /// Result indicating success or failure of the approval operation.
245    ///
246    /// # Errors
247    /// Returns `SyncError::InsufficientPermission` if the approving key does not have
248    /// Admin permission on the target database.
249    pub async fn approve_bootstrap_request_with_key(
250        &self,
251        request_id: &str,
252        key: &DatabaseKey,
253    ) -> Result<()> {
254        // Load the request from sync database
255        let sync_op = self.sync_tree.new_transaction().await?;
256        let manager = BootstrapRequestManager::new(&sync_op);
257
258        let request = manager
259            .get_request(request_id)
260            .await?
261            .ok_or_else(|| SyncError::RequestNotFound(request_id.to_string()))?;
262
263        // Validate request is still pending
264        if !matches!(request.status, RequestStatus::Pending) {
265            return Err(SyncError::InvalidRequestState {
266                request_id: request_id.to_string(),
267                current_status: format!("{:?}", request.status),
268                expected_status: "Pending".to_string(),
269            }
270            .into());
271        }
272
273        // Load the existing database with the user's signing key
274        let database = Database::open(&self.instance()?, &request.tree_id)
275            .await?
276            .with_key(key.clone());
277
278        // Explicitly check that the approving user has Admin permission
279        // This provides clear error messages and fails fast before modifying the database
280        let permission = database.current_permission().await?;
281        if !permission.can_admin() {
282            return Err(SyncError::InsufficientPermission {
283                request_id: request_id.to_string(),
284                required_permission: "Admin".to_string(),
285                actual_permission: permission,
286            }
287            .into());
288        }
289
290        // Create transaction - this will use the provided signing key
291        let tx = database.new_transaction().await?;
292
293        // Get settings store and update auth configuration
294        let settings_store = tx.get_settings()?;
295
296        // Create the auth key for the requesting device
297        // Keys are stored by pubkey, with name as optional metadata
298        let auth_key = AuthKey::active(
299            Some(&request.requesting_key_name), // name metadata
300            request.requested_permission,
301        );
302
303        // Add the new key to auth settings using SettingsStore API
304        // Store by pubkey (this provides proper upsert behavior and validation)
305        settings_store
306            .set_auth_key(&request.requesting_pubkey, auth_key)
307            .await?;
308
309        // Commit will validate that the user's key has Admin permission
310        // If this fails, it means the user lacks the necessary permission
311        tx.commit().await?;
312
313        // Update request status to approved
314        let approver_id = key.identity().display_id();
315        let approval_time = self
316            .instance
317            .upgrade()
318            .ok_or(SyncError::InstanceDropped)?
319            .clock()
320            .now_rfc3339();
321        manager
322            .update_status(
323                request_id,
324                RequestStatus::Approved {
325                    approved_by: approver_id.to_string(),
326                    approval_time,
327                },
328            )
329            .await?;
330        sync_op.commit().await?;
331
332        info!(
333            request_id = %request_id,
334            tree_id = %request.tree_id,
335            approved_by = %approver_id,
336            "Bootstrap request approved and key added to database using user-provided key"
337        );
338
339        Ok(())
340    }
341
342    /// Reject a bootstrap request using a `DatabaseKey` with Admin permission validation.
343    ///
344    /// This variant allows rejection using keys that are not stored in the backend,
345    /// such as user keys managed in memory. It validates that the rejecting user has
346    /// Admin permission on the target database before allowing the rejection.
347    ///
348    /// # Arguments
349    /// * `request_id` - The unique identifier of the request to reject
350    /// * `key` - The `DatabaseKey` to use for permission validation and audit trail
351    ///
352    /// # Returns
353    /// Result indicating success or failure of the rejection operation.
354    ///
355    /// # Errors
356    /// Returns `SyncError::InsufficientPermission` if the rejecting key does not have
357    /// Admin permission on the target database.
358    pub async fn reject_bootstrap_request_with_key(
359        &self,
360        request_id: &str,
361        key: &DatabaseKey,
362    ) -> Result<()> {
363        // Load the request from sync database
364        let sync_op = self.sync_tree.new_transaction().await?;
365        let manager = BootstrapRequestManager::new(&sync_op);
366
367        let request = manager
368            .get_request(request_id)
369            .await?
370            .ok_or_else(|| SyncError::RequestNotFound(request_id.to_string()))?;
371
372        // Validate request is still pending
373        if !matches!(request.status, RequestStatus::Pending) {
374            return Err(SyncError::InvalidRequestState {
375                request_id: request_id.to_string(),
376                current_status: format!("{:?}", request.status),
377                expected_status: "Pending".to_string(),
378            }
379            .into());
380        }
381
382        // Load the existing database with the user's signing key to validate permissions
383        let database = Database::open(&self.instance()?, &request.tree_id)
384            .await?
385            .with_key(key.clone());
386
387        // Check that the rejecting user has Admin permission
388        let permission = database.current_permission().await?;
389        if !permission.can_admin() {
390            return Err(SyncError::InsufficientPermission {
391                request_id: request_id.to_string(),
392                required_permission: "Admin".to_string(),
393                actual_permission: permission,
394            }
395            .into());
396        }
397
398        // User has Admin permission, proceed with rejection
399        let rejecter_id = key.identity().display_id();
400        let rejection_time = self
401            .instance
402            .upgrade()
403            .ok_or(SyncError::InstanceDropped)?
404            .clock()
405            .now_rfc3339();
406        manager
407            .update_status(
408                request_id,
409                RequestStatus::Rejected {
410                    rejected_by: rejecter_id.to_string(),
411                    rejection_time,
412                },
413            )
414            .await?;
415        sync_op.commit().await?;
416
417        info!(
418            request_id = %request_id,
419            tree_id = %request.tree_id,
420            rejected_by = %rejecter_id,
421            "Bootstrap request rejected by user with Admin permission"
422        );
423
424        Ok(())
425    }
426}