Skip to main content

zvault_server/routes/
sys.rs

1//! System routes: `/v1/sys/*`
2//!
3//! Handles vault initialization, seal/unseal lifecycle, and health checks.
4//! These endpoints are the first to come online and the last to go down.
5
6use std::sync::Arc;
7
8use axum::extract::State;
9use axum::http::StatusCode;
10use axum::response::IntoResponse;
11use axum::routing::{get, post};
12use axum::{Json, Router};
13use serde::{Deserialize, Serialize};
14
15use crate::error::AppError;
16use crate::state::AppState;
17use zvault_core::token::CreateTokenParams;
18
19/// Build the `/v1/sys` router.
20pub fn router() -> Router<Arc<AppState>> {
21    Router::new()
22        .route("/init", post(init))
23        .route("/unseal", post(unseal))
24        .route("/seal", post(seal))
25        .route("/seal-status", get(seal_status))
26        .route("/health", get(health))
27        .route("/audit-log", get(audit_log))
28        .route("/license", get(license_status))
29        .route("/backup", get(backup))
30        .route("/restore", post(restore))
31}
32
33// ── Request / Response types ─────────────────────────────────────────
34
35/// Request body for `POST /v1/sys/init`.
36#[derive(Debug, Deserialize)]
37pub struct InitRequest {
38    /// Number of unseal key shares to generate (1-10).
39    pub shares: u8,
40    /// Minimum shares required to unseal (2..=shares).
41    pub threshold: u8,
42}
43
44/// Response body for `POST /v1/sys/init`.
45#[derive(Debug, Serialize)]
46pub struct InitResponse {
47    /// Base64-encoded unseal key shares (shown once).
48    pub unseal_shares: Vec<String>,
49    /// Root token for initial authentication.
50    pub root_token: String,
51}
52
53/// Request body for `POST /v1/sys/unseal`.
54#[derive(Debug, Deserialize)]
55pub struct UnsealRequest {
56    /// Base64-encoded unseal key share.
57    pub share: String,
58}
59
60/// Response body for `POST /v1/sys/unseal`.
61#[derive(Debug, Serialize)]
62pub struct UnsealResponse {
63    /// Whether the vault is still sealed.
64    pub sealed: bool,
65    /// Threshold required.
66    pub threshold: u8,
67    /// Shares submitted so far.
68    pub progress: u8,
69}
70
71/// Response body for `GET /v1/sys/seal-status` and `GET /v1/sys/health`.
72#[derive(Debug, Serialize)]
73pub struct SealStatusResponse {
74    /// Whether the vault has been initialized.
75    pub initialized: bool,
76    /// Whether the vault is currently sealed.
77    pub sealed: bool,
78    /// Threshold of shares required.
79    pub threshold: u8,
80    /// Total number of shares.
81    pub shares: u8,
82    /// Shares submitted in current unseal attempt.
83    pub progress: u8,
84}
85
86// ── Handlers ─────────────────────────────────────────────────────────
87
88/// Initialize a new vault.
89///
90/// Generates a root key, splits the unseal key into Shamir shares, and
91/// returns the shares + root token. The vault is left sealed.
92async fn init(
93    State(state): State<Arc<AppState>>,
94    Json(body): Json<InitRequest>,
95) -> Result<(StatusCode, Json<InitResponse>), AppError> {
96    let result = state.seal_manager.init(body.shares, body.threshold).await?;
97
98    // The vault is sealed after init. We need to temporarily unseal it to
99    // persist the root token in the TokenStore (which goes through the barrier).
100    // We have all shares at this point, so we can reconstruct the unseal key.
101    for share in &result.unseal_shares {
102        let progress = state.seal_manager.submit_unseal_share(share).await?;
103        if progress.is_none() {
104            break; // Unsealed
105        }
106    }
107
108    // Store the root token in the TokenStore so auth middleware can find it.
109    state
110        .token_store
111        .create_with_token(
112            &result.root_token,
113            CreateTokenParams {
114                policies: vec!["root".to_owned()],
115                ttl: None,
116                max_ttl: None,
117                renewable: false,
118                parent_hash: None,
119                metadata: std::collections::HashMap::new(),
120                display_name: "root".to_owned(),
121            },
122        )
123        .await
124        .map_err(|e| AppError::Internal(format!("failed to store root token: {e}")))?;
125
126    // Re-seal the vault. The operator must unseal it using the shares.
127    state.seal_manager.seal().await?;
128
129    Ok((
130        StatusCode::OK,
131        Json(InitResponse {
132            unseal_shares: result.unseal_shares,
133            root_token: result.root_token,
134        }),
135    ))
136}
137
138/// Submit an unseal key share.
139///
140/// Returns progress if more shares are needed, or unseals the vault when
141/// the threshold is reached.
142async fn unseal(
143    State(state): State<Arc<AppState>>,
144    Json(body): Json<UnsealRequest>,
145) -> Result<Json<UnsealResponse>, AppError> {
146    let progress = state.seal_manager.submit_unseal_share(&body.share).await?;
147
148    match progress {
149        Some(p) => Ok(Json(UnsealResponse {
150            sealed: true,
151            threshold: p.threshold,
152            progress: p.submitted,
153        })),
154        None => Ok(Json(UnsealResponse {
155            sealed: false,
156            threshold: 0,
157            progress: 0,
158        })),
159    }
160}
161
162/// Seal the vault, zeroizing all key material from memory.
163async fn seal(State(state): State<Arc<AppState>>) -> Result<StatusCode, AppError> {
164    state.seal_manager.seal().await?;
165    Ok(StatusCode::NO_CONTENT)
166}
167
168/// Get the current seal status.
169async fn seal_status(
170    State(state): State<Arc<AppState>>,
171) -> Result<Json<SealStatusResponse>, AppError> {
172    let status = state.seal_manager.status().await?;
173    Ok(Json(SealStatusResponse {
174        initialized: status.initialized,
175        sealed: status.sealed,
176        threshold: status.threshold,
177        shares: status.shares,
178        progress: status.progress,
179    }))
180}
181
182/// Health check endpoint. No auth required.
183///
184/// Returns 200 if unsealed, 503 if sealed, 501 if not initialized.
185async fn health(State(state): State<Arc<AppState>>) -> impl IntoResponse {
186    let status = state.seal_manager.status().await;
187
188    match status {
189        Ok(s) if !s.initialized => {
190            let body = SealStatusResponse {
191                initialized: false,
192                sealed: true,
193                threshold: 0,
194                shares: 0,
195                progress: 0,
196            };
197            (StatusCode::NOT_IMPLEMENTED, Json(body))
198        }
199        Ok(s) if s.sealed => {
200            let body = SealStatusResponse {
201                initialized: s.initialized,
202                sealed: true,
203                threshold: s.threshold,
204                shares: s.shares,
205                progress: s.progress,
206            };
207            (StatusCode::SERVICE_UNAVAILABLE, Json(body))
208        }
209        Ok(s) => {
210            let body = SealStatusResponse {
211                initialized: s.initialized,
212                sealed: s.sealed,
213                threshold: s.threshold,
214                shares: s.shares,
215                progress: s.progress,
216            };
217            (StatusCode::OK, Json(body))
218        }
219        Err(_) => {
220            let body = SealStatusResponse {
221                initialized: false,
222                sealed: true,
223                threshold: 0,
224                shares: 0,
225                progress: 0,
226            };
227            (StatusCode::INTERNAL_SERVER_ERROR, Json(body))
228        }
229    }
230}
231
232// ── Audit log read endpoint ──────────────────────────────────────────
233
234/// Query parameters for `GET /v1/sys/audit-log`.
235#[derive(Debug, Deserialize)]
236pub struct AuditLogQuery {
237    /// Maximum number of entries to return (default: 100, max: 1000).
238    pub limit: Option<usize>,
239}
240
241/// Response body for `GET /v1/sys/audit-log`.
242#[derive(Debug, Serialize)]
243pub struct AuditLogResponse {
244    /// Audit log entries (most recent first).
245    pub entries: Vec<serde_json::Value>,
246    /// Total number of entries returned.
247    pub count: usize,
248}
249
250/// Read recent audit log entries from the file backend.
251///
252/// Returns the most recent entries in reverse chronological order.
253/// No auth required on this endpoint since it's under `/v1/sys` which
254/// is not behind the auth middleware — but the audit file only contains
255/// HMAC'd sensitive fields, so no secrets are exposed.
256async fn audit_log(
257    State(state): State<Arc<AppState>>,
258    axum::extract::Query(query): axum::extract::Query<AuditLogQuery>,
259) -> Result<Json<AuditLogResponse>, AppError> {
260    let limit = query.limit.unwrap_or(100).min(1000);
261
262    let Some(ref audit_path) = state.audit_file_path else {
263        return Ok(Json(AuditLogResponse {
264            entries: Vec::new(),
265            count: 0,
266        }));
267    };
268
269    // Read the audit file. If it doesn't exist yet, return empty.
270    let content = match tokio::fs::read_to_string(audit_path).await {
271        Ok(c) => c,
272        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
273            return Ok(Json(AuditLogResponse {
274                entries: Vec::new(),
275                count: 0,
276            }));
277        }
278        Err(e) => {
279            return Err(AppError::Internal(format!(
280                "failed to read audit log: {e}"
281            )));
282        }
283    };
284
285    // Parse JSON-lines, take the last `limit` entries, reverse for most-recent-first.
286    let mut entries: Vec<serde_json::Value> = content
287        .lines()
288        .filter(|line| !line.trim().is_empty())
289        .filter_map(|line| serde_json::from_str(line).ok())
290        .collect();
291
292    // Most recent last in file → reverse to get most recent first.
293    entries.reverse();
294    entries.truncate(limit);
295
296    let count = entries.len();
297    Ok(Json(AuditLogResponse { entries, count }))
298}
299
300// ── License status endpoint ──────────────────────────────────────────
301
302/// Response body for `GET /v1/sys/license`.
303#[derive(Debug, Serialize)]
304pub struct LicenseResponse {
305    /// Current license tier.
306    pub tier: String,
307    /// Whether a license is installed.
308    pub licensed: bool,
309    /// License ID (if any).
310    pub license_id: Option<String>,
311    /// Licensee email (if any).
312    pub email: Option<String>,
313    /// Expiration date (if any).
314    pub expires_at: Option<String>,
315}
316
317/// Get the current license status.
318///
319/// No auth required — returns only tier info, no secrets.
320async fn license_status(
321    State(_state): State<Arc<AppState>>,
322) -> Json<LicenseResponse> {
323    // The license is a CLI-side concept stored in ~/.zvault/license.key.
324    // The server doesn't have direct access to it, so we return a basic
325    // "server edition" response. The dashboard can also check the CLI
326    // license separately.
327    Json(LicenseResponse {
328        tier: "community".to_owned(),
329        licensed: false,
330        license_id: None,
331        email: None,
332        expires_at: None,
333    })
334}
335
336// ── Backup / Restore endpoints ───────────────────────────────────────
337
338/// Response body for `GET /v1/sys/backup`.
339///
340/// Returns all barrier data as a base64-encoded JSON snapshot.
341/// The data is already encrypted by the barrier — this is a raw dump
342/// of ciphertext, safe to store externally.
343#[derive(Debug, Serialize)]
344pub struct BackupResponse {
345    /// Base64-encoded snapshot of all barrier entries.
346    pub snapshot: String,
347    /// Number of entries in the backup.
348    pub entry_count: usize,
349    /// ISO 8601 timestamp of when the backup was taken.
350    pub created_at: String,
351    /// ZVault version that created the backup.
352    pub version: String,
353}
354
355/// Internal representation of a single backup entry.
356#[derive(Debug, Serialize, Deserialize)]
357struct BackupEntry {
358    key: String,
359    /// Base64-encoded ciphertext value.
360    value: String,
361}
362
363/// `GET /v1/sys/backup` — Export all barrier data as an encrypted snapshot.
364///
365/// No auth middleware on `/v1/sys`, but the data is ciphertext — useless
366/// without the unseal key. Still, this should be protected in production
367/// (e.g., via network policy or reverse proxy auth).
368async fn backup(
369    State(state): State<Arc<AppState>>,
370) -> Result<Json<BackupResponse>, AppError> {
371    // List all keys in the barrier.
372    let keys = state.barrier.list("").await?;
373
374    let mut entries = Vec::with_capacity(keys.len());
375    for key in &keys {
376        if let Ok(Some(data)) = state.barrier.get_raw(key).await {
377            entries.push(BackupEntry {
378                key: key.clone(),
379                value: base64::Engine::encode(
380                    &base64::engine::general_purpose::STANDARD,
381                    &data,
382                ),
383            });
384        }
385    }
386
387    let entry_count = entries.len();
388    let snapshot_json = serde_json::to_vec(&entries)
389        .map_err(|e| AppError::Internal(format!("backup serialization failed: {e}")))?;
390
391    let snapshot = base64::Engine::encode(
392        &base64::engine::general_purpose::STANDARD,
393        &snapshot_json,
394    );
395
396    let created_at = chrono::Utc::now().to_rfc3339();
397
398    Ok(Json(BackupResponse {
399        snapshot,
400        entry_count,
401        created_at,
402        version: env!("CARGO_PKG_VERSION").to_owned(),
403    }))
404}
405
406/// Request body for `POST /v1/sys/restore`.
407#[derive(Debug, Deserialize)]
408pub struct RestoreRequest {
409    /// Base64-encoded snapshot (from a previous backup).
410    pub snapshot: String,
411}
412
413/// Response body for `POST /v1/sys/restore`.
414#[derive(Debug, Serialize)]
415pub struct RestoreResponse {
416    /// Number of entries restored.
417    pub entry_count: usize,
418    /// Whether the restore was successful.
419    pub success: bool,
420}
421
422/// `POST /v1/sys/restore` — Restore barrier data from an encrypted snapshot.
423///
424/// Overwrites existing data. The vault should be sealed after restore
425/// and re-unsealed to pick up the restored state.
426async fn restore(
427    State(state): State<Arc<AppState>>,
428    Json(body): Json<RestoreRequest>,
429) -> Result<Json<RestoreResponse>, AppError> {
430    let snapshot_bytes = base64::Engine::decode(
431        &base64::engine::general_purpose::STANDARD,
432        &body.snapshot,
433    )
434    .map_err(|_| AppError::BadRequest("invalid base64 snapshot".to_owned()))?;
435
436    let entries: Vec<BackupEntry> = serde_json::from_slice(&snapshot_bytes)
437        .map_err(|e| AppError::BadRequest(format!("invalid snapshot format: {e}")))?;
438
439    let entry_count = entries.len();
440
441    for entry in &entries {
442        let value = base64::Engine::decode(
443            &base64::engine::general_purpose::STANDARD,
444            &entry.value,
445        )
446        .map_err(|_| {
447            AppError::BadRequest(format!("invalid base64 value for key: {}", entry.key))
448        })?;
449
450        state.barrier.put_raw(&entry.key, &value).await?;
451    }
452
453    Ok(Json(RestoreResponse {
454        entry_count,
455        success: true,
456    }))
457}