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}