mockforge_registry_server/handlers/
workspace_encryption.rs1use 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#[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
57pub 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
77pub 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 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
95pub 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 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
123pub 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
137pub 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
151const 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
172const 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
196pub 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 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 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 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 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 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}