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}