Skip to main content

mockforge_registry_server/handlers/
workspace_encryption.rs

1//! Workspace application-layer encryption: status, config, enable/disable, security-check.
2//!
3//! The self-hosted surface also exposes `export`/`import` to local filesystem paths; those
4//! don't translate to multi-tenant cloud (filesystem concept) and are intentionally omitted.
5//! Key material itself lives in the BYOK infrastructure in `handlers::settings` — this module
6//! just controls the *policy* (should we encrypt? which patterns count as sensitive?).
7
8use axum::{
9    extract::{Path, State},
10    http::HeaderMap,
11    Json,
12};
13use serde::{Deserialize, Serialize};
14use serde_json::{json, Value};
15use uuid::Uuid;
16
17use crate::{
18    error::{ApiError, ApiResult},
19    middleware::{resolve_org_context, AuthUser},
20    models::{workspace_environment::WorkspaceEnvVariable, CloudWorkspace},
21    AppState,
22};
23
24async fn require_workspace(
25    state: &AppState,
26    user_id: Uuid,
27    headers: &HeaderMap,
28    workspace_id: Uuid,
29) -> ApiResult<CloudWorkspace> {
30    let org_ctx = resolve_org_context(state, user_id, headers, None)
31        .await
32        .map_err(|_| ApiError::InvalidRequest("Organization not found".to_string()))?;
33    let workspace = CloudWorkspace::find_by_id(state.db.pool(), workspace_id)
34        .await?
35        .ok_or_else(|| ApiError::InvalidRequest("Workspace not found".to_string()))?;
36    if workspace.org_id != org_ctx.org_id {
37        return Err(ApiError::InvalidRequest(
38            "Workspace does not belong to this organization".to_string(),
39        ));
40    }
41    Ok(workspace)
42}
43
44/// Matches the `EncryptionStatus` TS interface.
45#[derive(Debug, Serialize)]
46pub struct EncryptionStatusResponse {
47    pub enabled: bool,
48    pub algorithm: String,
49    pub key_id: Option<String>,
50    pub last_rotated: Option<chrono::DateTime<chrono::Utc>>,
51    #[serde(rename = "masterKeySet")]
52    pub master_key_set: bool,
53    #[serde(rename = "workspaceKeySet")]
54    pub workspace_key_set: bool,
55}
56
57/// GET /api/v1/workspaces/{workspace_id}/encryption/status
58pub async fn get_status(
59    State(state): State<AppState>,
60    AuthUser(user_id): AuthUser,
61    headers: HeaderMap,
62    Path(workspace_id): Path<Uuid>,
63) -> ApiResult<Json<EncryptionStatusResponse>> {
64    let ws = require_workspace(&state, user_id, &headers, workspace_id).await?;
65    let master_key_set =
66        std::env::var("BYOK_ENCRYPTION_KEY").map(|v| !v.is_empty()).unwrap_or(false);
67    Ok(Json(EncryptionStatusResponse {
68        enabled: ws.encryption_enabled,
69        algorithm: ws.encryption_algorithm.clone(),
70        key_id: ws.encryption_key_rotated_at.map(|ts| format!("k-{}", ts.timestamp())),
71        last_rotated: ws.encryption_key_rotated_at,
72        master_key_set,
73        workspace_key_set: ws.encryption_enabled && master_key_set,
74    }))
75}
76
77/// GET /api/v1/workspaces/{workspace_id}/encryption/config
78pub async fn get_config(
79    State(state): State<AppState>,
80    AuthUser(user_id): AuthUser,
81    headers: HeaderMap,
82    Path(workspace_id): Path<Uuid>,
83) -> ApiResult<Json<Value>> {
84    let ws = require_workspace(&state, user_id, &headers, workspace_id).await?;
85    // Always echo `enabled` alongside the stored blob so the UI can read both in one call.
86    let mut cfg = ws.encryption_config.clone();
87    if let Some(obj) = cfg.as_object_mut() {
88        obj.insert("enabled".to_string(), json!(ws.encryption_enabled));
89        obj.entry("algorithm".to_string())
90            .or_insert_with(|| json!(ws.encryption_algorithm));
91    }
92    Ok(Json(cfg))
93}
94
95/// PUT /api/v1/workspaces/{workspace_id}/encryption/config
96pub async fn put_config(
97    State(state): State<AppState>,
98    AuthUser(user_id): AuthUser,
99    headers: HeaderMap,
100    Path(workspace_id): Path<Uuid>,
101    Json(mut config): Json<Value>,
102) -> ApiResult<Json<Value>> {
103    require_workspace(&state, user_id, &headers, workspace_id).await?;
104
105    // Callers sometimes send `enabled`/`algorithm` inside the config blob; keep the flag
106    // column authoritative and strip those from the JSONB so we don't store them twice.
107    let obj = config
108        .as_object_mut()
109        .ok_or_else(|| ApiError::InvalidRequest("Config must be a JSON object".to_string()))?;
110    obj.remove("enabled");
111    obj.remove("algorithm");
112
113    let updated = CloudWorkspace::set_encryption_config(state.db.pool(), workspace_id, &config)
114        .await?
115        .ok_or_else(|| ApiError::InvalidRequest("Workspace not found".to_string()))?;
116
117    Ok(Json(json!({
118        "message": "Encryption config updated",
119        "config": updated.encryption_config,
120    })))
121}
122
123/// POST /api/v1/workspaces/{workspace_id}/encryption/enable
124pub async fn enable(
125    State(state): State<AppState>,
126    AuthUser(user_id): AuthUser,
127    headers: HeaderMap,
128    Path(workspace_id): Path<Uuid>,
129) -> ApiResult<Json<Value>> {
130    require_workspace(&state, user_id, &headers, workspace_id).await?;
131    CloudWorkspace::set_encryption_enabled(state.db.pool(), workspace_id, true)
132        .await?
133        .ok_or_else(|| ApiError::InvalidRequest("Workspace not found".to_string()))?;
134    Ok(Json(json!({ "message": "Encryption enabled" })))
135}
136
137/// POST /api/v1/workspaces/{workspace_id}/encryption/disable
138pub async fn disable(
139    State(state): State<AppState>,
140    AuthUser(user_id): AuthUser,
141    headers: HeaderMap,
142    Path(workspace_id): Path<Uuid>,
143) -> ApiResult<Json<Value>> {
144    require_workspace(&state, user_id, &headers, workspace_id).await?;
145    CloudWorkspace::set_encryption_enabled(state.db.pool(), workspace_id, false)
146        .await?
147        .ok_or_else(|| ApiError::InvalidRequest("Workspace not found".to_string()))?;
148    Ok(Json(json!({ "message": "Encryption disabled" })))
149}
150
151// ---------- Security check ----------
152
153/// Low-noise pattern list. Matched case-insensitively against variable names.
154const DEFAULT_SENSITIVE_NAME_PATTERNS: &[&str] = &[
155    "password",
156    "passwd",
157    "pwd",
158    "secret",
159    "token",
160    "api_key",
161    "apikey",
162    "bearer",
163    "auth",
164    "private",
165    "credential",
166    "oauth",
167    "jwt",
168    "ssh_key",
169    "access_key",
170];
171
172/// Low-noise pattern list. Matched case-insensitively against variable *values* to detect
173/// likely-secrets stored in plaintext without `is_secret = true`.
174const SUSPICIOUS_VALUE_PREFIXES: &[&str] = &[
175    "sk_", "sk-", "AKIA", "ghp_", "glpat-", "xoxb-", "xoxp-", "Bearer ", "eyJ",
176];
177
178#[derive(Debug, Serialize, Deserialize)]
179pub struct SecurityCheck {
180    pub name: String,
181    pub passed: bool,
182    pub message: Option<String>,
183}
184
185#[derive(Debug, Serialize)]
186pub struct SecurityCheckResult {
187    pub passed: bool,
188    pub checks: Vec<SecurityCheck>,
189    #[serde(rename = "isSecure")]
190    pub is_secure: bool,
191    pub warnings: Vec<String>,
192    pub errors: Vec<String>,
193    pub recommendations: Vec<String>,
194}
195
196/// POST /api/v1/workspaces/{workspace_id}/encryption/security-check
197///
198/// Scans every environment variable in the workspace for patterns that look sensitive
199/// but are stored with `is_secret = false`. Reports a per-check result + human-readable
200/// warnings/recommendations the UI can surface.
201pub async fn security_check(
202    State(state): State<AppState>,
203    AuthUser(user_id): AuthUser,
204    headers: HeaderMap,
205    Path(workspace_id): Path<Uuid>,
206) -> ApiResult<Json<SecurityCheckResult>> {
207    use crate::models::workspace_environment::WorkspaceEnvironment;
208
209    let ws = require_workspace(&state, user_id, &headers, workspace_id).await?;
210    let pool = state.db.pool();
211
212    // Every variable in every environment in this workspace.
213    let envs = WorkspaceEnvironment::list_by_workspace(pool, workspace_id).await?;
214    let mut all_vars: Vec<(String, WorkspaceEnvVariable)> = Vec::new();
215    for env in &envs {
216        let vars = WorkspaceEnvVariable::list_by_environment(pool, env.id).await?;
217        for v in vars {
218            all_vars.push((env.name.clone(), v));
219        }
220    }
221
222    let mut warnings = Vec::new();
223    let mut errors = Vec::new();
224    let mut recommendations = Vec::new();
225
226    // Check 1: encryption enabled on the workspace.
227    let enc_enabled = ws.encryption_enabled;
228    if !enc_enabled {
229        recommendations.push(
230            "Enable workspace encryption so sensitive variables are marked clearly.".to_string(),
231        );
232    }
233
234    // Check 2: master key configured on the server.
235    let master_key_set =
236        std::env::var("BYOK_ENCRYPTION_KEY").map(|v| !v.is_empty()).unwrap_or(false);
237    if enc_enabled && !master_key_set {
238        errors.push(
239            "Workspace encryption is enabled but the server has no BYOK master key configured."
240                .to_string(),
241        );
242    }
243
244    // Check 3: suspicious names stored without is_secret.
245    let mut suspicious_name_count = 0;
246    for (env_name, v) in &all_vars {
247        let lower = v.name.to_lowercase();
248        let matched_name = DEFAULT_SENSITIVE_NAME_PATTERNS.iter().any(|p| lower.contains(p));
249        if matched_name && !v.is_secret {
250            suspicious_name_count += 1;
251            warnings.push(format!(
252                "{}.{} looks sensitive but is not marked as secret",
253                env_name, v.name
254            ));
255        }
256    }
257
258    // Check 4: plaintext values that look like real secrets.
259    let mut suspicious_value_count = 0;
260    for (env_name, v) in &all_vars {
261        if v.is_secret {
262            continue;
263        }
264        if SUSPICIOUS_VALUE_PREFIXES.iter().any(|pfx| v.value.starts_with(pfx)) {
265            suspicious_value_count += 1;
266            warnings.push(format!(
267                "{}.{} value matches a known secret pattern but is stored in plaintext",
268                env_name, v.name
269            ));
270        }
271    }
272
273    if suspicious_name_count > 0 || suspicious_value_count > 0 {
274        recommendations.push(
275            "Mark matching variables `encrypted: true` to hide them in the UI and audit logs."
276                .to_string(),
277        );
278    }
279
280    let checks = vec![
281        SecurityCheck {
282            name: "workspace_encryption_enabled".to_string(),
283            passed: enc_enabled,
284            message: Some(if enc_enabled {
285                "Workspace encryption is on".into()
286            } else {
287                "Workspace encryption is off".into()
288            }),
289        },
290        SecurityCheck {
291            name: "byok_master_key_configured".to_string(),
292            passed: master_key_set,
293            message: Some(if master_key_set {
294                "Server BYOK master key is configured".into()
295            } else {
296                "Server BYOK master key is NOT configured".into()
297            }),
298        },
299        SecurityCheck {
300            name: "no_sensitive_named_plaintext_vars".to_string(),
301            passed: suspicious_name_count == 0,
302            message: Some(format!(
303                "{suspicious_name_count} sensitively-named variable(s) are stored in plaintext"
304            )),
305        },
306        SecurityCheck {
307            name: "no_suspicious_valued_plaintext_vars".to_string(),
308            passed: suspicious_value_count == 0,
309            message: Some(format!(
310                "{suspicious_value_count} variable value(s) match known secret patterns but are plaintext"
311            )),
312        },
313    ];
314
315    let passed = errors.is_empty() && warnings.is_empty();
316    Ok(Json(SecurityCheckResult {
317        passed,
318        checks,
319        is_secure: passed,
320        warnings,
321        errors,
322        recommendations,
323    }))
324}