use axum::{
extract::{Path, State},
http::HeaderMap,
Json,
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use uuid::Uuid;
use crate::{
error::{ApiError, ApiResult},
middleware::{resolve_org_context, AuthUser},
models::{workspace_environment::WorkspaceEnvVariable, CloudWorkspace},
AppState,
};
async fn require_workspace(
state: &AppState,
user_id: Uuid,
headers: &HeaderMap,
workspace_id: Uuid,
) -> ApiResult<CloudWorkspace> {
let org_ctx = resolve_org_context(state, user_id, headers, None)
.await
.map_err(|_| ApiError::InvalidRequest("Organization not found".to_string()))?;
let workspace = CloudWorkspace::find_by_id(state.db.pool(), workspace_id)
.await?
.ok_or_else(|| ApiError::InvalidRequest("Workspace not found".to_string()))?;
if workspace.org_id != org_ctx.org_id {
return Err(ApiError::InvalidRequest(
"Workspace does not belong to this organization".to_string(),
));
}
Ok(workspace)
}
#[derive(Debug, Serialize)]
pub struct EncryptionStatusResponse {
pub enabled: bool,
pub algorithm: String,
pub key_id: Option<String>,
pub last_rotated: Option<chrono::DateTime<chrono::Utc>>,
#[serde(rename = "masterKeySet")]
pub master_key_set: bool,
#[serde(rename = "workspaceKeySet")]
pub workspace_key_set: bool,
}
pub async fn get_status(
State(state): State<AppState>,
AuthUser(user_id): AuthUser,
headers: HeaderMap,
Path(workspace_id): Path<Uuid>,
) -> ApiResult<Json<EncryptionStatusResponse>> {
let ws = require_workspace(&state, user_id, &headers, workspace_id).await?;
let master_key_set =
std::env::var("BYOK_ENCRYPTION_KEY").map(|v| !v.is_empty()).unwrap_or(false);
Ok(Json(EncryptionStatusResponse {
enabled: ws.encryption_enabled,
algorithm: ws.encryption_algorithm.clone(),
key_id: ws.encryption_key_rotated_at.map(|ts| format!("k-{}", ts.timestamp())),
last_rotated: ws.encryption_key_rotated_at,
master_key_set,
workspace_key_set: ws.encryption_enabled && master_key_set,
}))
}
pub async fn get_config(
State(state): State<AppState>,
AuthUser(user_id): AuthUser,
headers: HeaderMap,
Path(workspace_id): Path<Uuid>,
) -> ApiResult<Json<Value>> {
let ws = require_workspace(&state, user_id, &headers, workspace_id).await?;
let mut cfg = ws.encryption_config.clone();
if let Some(obj) = cfg.as_object_mut() {
obj.insert("enabled".to_string(), json!(ws.encryption_enabled));
obj.entry("algorithm".to_string())
.or_insert_with(|| json!(ws.encryption_algorithm));
}
Ok(Json(cfg))
}
pub async fn put_config(
State(state): State<AppState>,
AuthUser(user_id): AuthUser,
headers: HeaderMap,
Path(workspace_id): Path<Uuid>,
Json(mut config): Json<Value>,
) -> ApiResult<Json<Value>> {
require_workspace(&state, user_id, &headers, workspace_id).await?;
let obj = config
.as_object_mut()
.ok_or_else(|| ApiError::InvalidRequest("Config must be a JSON object".to_string()))?;
obj.remove("enabled");
obj.remove("algorithm");
let updated = CloudWorkspace::set_encryption_config(state.db.pool(), workspace_id, &config)
.await?
.ok_or_else(|| ApiError::InvalidRequest("Workspace not found".to_string()))?;
Ok(Json(json!({
"message": "Encryption config updated",
"config": updated.encryption_config,
})))
}
pub async fn enable(
State(state): State<AppState>,
AuthUser(user_id): AuthUser,
headers: HeaderMap,
Path(workspace_id): Path<Uuid>,
) -> ApiResult<Json<Value>> {
require_workspace(&state, user_id, &headers, workspace_id).await?;
CloudWorkspace::set_encryption_enabled(state.db.pool(), workspace_id, true)
.await?
.ok_or_else(|| ApiError::InvalidRequest("Workspace not found".to_string()))?;
Ok(Json(json!({ "message": "Encryption enabled" })))
}
pub async fn disable(
State(state): State<AppState>,
AuthUser(user_id): AuthUser,
headers: HeaderMap,
Path(workspace_id): Path<Uuid>,
) -> ApiResult<Json<Value>> {
require_workspace(&state, user_id, &headers, workspace_id).await?;
CloudWorkspace::set_encryption_enabled(state.db.pool(), workspace_id, false)
.await?
.ok_or_else(|| ApiError::InvalidRequest("Workspace not found".to_string()))?;
Ok(Json(json!({ "message": "Encryption disabled" })))
}
const DEFAULT_SENSITIVE_NAME_PATTERNS: &[&str] = &[
"password",
"passwd",
"pwd",
"secret",
"token",
"api_key",
"apikey",
"bearer",
"auth",
"private",
"credential",
"oauth",
"jwt",
"ssh_key",
"access_key",
];
const SUSPICIOUS_VALUE_PREFIXES: &[&str] = &[
"sk_", "sk-", "AKIA", "ghp_", "glpat-", "xoxb-", "xoxp-", "Bearer ", "eyJ",
];
#[derive(Debug, Serialize, Deserialize)]
pub struct SecurityCheck {
pub name: String,
pub passed: bool,
pub message: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct SecurityCheckResult {
pub passed: bool,
pub checks: Vec<SecurityCheck>,
#[serde(rename = "isSecure")]
pub is_secure: bool,
pub warnings: Vec<String>,
pub errors: Vec<String>,
pub recommendations: Vec<String>,
}
pub async fn security_check(
State(state): State<AppState>,
AuthUser(user_id): AuthUser,
headers: HeaderMap,
Path(workspace_id): Path<Uuid>,
) -> ApiResult<Json<SecurityCheckResult>> {
use crate::models::workspace_environment::WorkspaceEnvironment;
let ws = require_workspace(&state, user_id, &headers, workspace_id).await?;
let pool = state.db.pool();
let envs = WorkspaceEnvironment::list_by_workspace(pool, workspace_id).await?;
let mut all_vars: Vec<(String, WorkspaceEnvVariable)> = Vec::new();
for env in &envs {
let vars = WorkspaceEnvVariable::list_by_environment(pool, env.id).await?;
for v in vars {
all_vars.push((env.name.clone(), v));
}
}
let mut warnings = Vec::new();
let mut errors = Vec::new();
let mut recommendations = Vec::new();
let enc_enabled = ws.encryption_enabled;
if !enc_enabled {
recommendations.push(
"Enable workspace encryption so sensitive variables are marked clearly.".to_string(),
);
}
let master_key_set =
std::env::var("BYOK_ENCRYPTION_KEY").map(|v| !v.is_empty()).unwrap_or(false);
if enc_enabled && !master_key_set {
errors.push(
"Workspace encryption is enabled but the server has no BYOK master key configured."
.to_string(),
);
}
let mut suspicious_name_count = 0;
for (env_name, v) in &all_vars {
let lower = v.name.to_lowercase();
let matched_name = DEFAULT_SENSITIVE_NAME_PATTERNS.iter().any(|p| lower.contains(p));
if matched_name && !v.is_secret {
suspicious_name_count += 1;
warnings.push(format!(
"{}.{} looks sensitive but is not marked as secret",
env_name, v.name
));
}
}
let mut suspicious_value_count = 0;
for (env_name, v) in &all_vars {
if v.is_secret {
continue;
}
if SUSPICIOUS_VALUE_PREFIXES.iter().any(|pfx| v.value.starts_with(pfx)) {
suspicious_value_count += 1;
warnings.push(format!(
"{}.{} value matches a known secret pattern but is stored in plaintext",
env_name, v.name
));
}
}
if suspicious_name_count > 0 || suspicious_value_count > 0 {
recommendations.push(
"Mark matching variables `encrypted: true` to hide them in the UI and audit logs."
.to_string(),
);
}
let checks = vec![
SecurityCheck {
name: "workspace_encryption_enabled".to_string(),
passed: enc_enabled,
message: Some(if enc_enabled {
"Workspace encryption is on".into()
} else {
"Workspace encryption is off".into()
}),
},
SecurityCheck {
name: "byok_master_key_configured".to_string(),
passed: master_key_set,
message: Some(if master_key_set {
"Server BYOK master key is configured".into()
} else {
"Server BYOK master key is NOT configured".into()
}),
},
SecurityCheck {
name: "no_sensitive_named_plaintext_vars".to_string(),
passed: suspicious_name_count == 0,
message: Some(format!(
"{suspicious_name_count} sensitively-named variable(s) are stored in plaintext"
)),
},
SecurityCheck {
name: "no_suspicious_valued_plaintext_vars".to_string(),
passed: suspicious_value_count == 0,
message: Some(format!(
"{suspicious_value_count} variable value(s) match known secret patterns but are plaintext"
)),
},
];
let passed = errors.is_empty() && warnings.is_empty();
Ok(Json(SecurityCheckResult {
passed,
checks,
is_secure: passed,
warnings,
errors,
recommendations,
}))
}