diff --git a/Makefile b/Makefile index c88e745..7a6fb27 100644 --- a/Makefile +++ b/Makefile @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -.PHONY: pre-commit fmt fmt-check clippy test build help console-lint console-fmt console-fmt-check +.PHONY: pre-commit fmt fmt-check clippy test build docker-build-operator docker-build-console-web docker-build-all help console-lint console-fmt console-fmt-check # 默认目标 help: @@ -25,6 +25,9 @@ help: @echo " make clippy - 运行 clippy 检查" @echo " make test - 运行 Rust 测试" @echo " make build - 构建项目" + @echo " make docker-build-operator - 构建 operator 镜像 (IMAGE_REPO?=rustfs/operator IMAGE_TAG?=dev)" + @echo " make docker-build-console-web - 构建 console-web 前端镜像 (CONSOLE_WEB_IMAGE_REPO?=rustfs/console-web CONSOLE_WEB_IMAGE_TAG?=dev)" + @echo " make docker-build-all - 构建 operator + console-web 两个镜像" @echo " make console-lint - 前端 ESLint 检查 (console-web)" @echo " make console-fmt - 前端 Prettier 自动格式化 (console-web)" @echo " make console-fmt-check - 前端 Prettier 格式检查 (console-web)" @@ -64,3 +67,16 @@ console-fmt-check: # 构建 build: cargo build --release + +# 构建 Docker 镜像(operator:含 controller + console API;console-web:前端静态资源) +IMAGE_REPO ?= rustfs/operator +IMAGE_TAG ?= dev +docker-build-operator: + docker build -t $(IMAGE_REPO):$(IMAGE_TAG) . + +CONSOLE_WEB_IMAGE_REPO ?= rustfs/console-web +CONSOLE_WEB_IMAGE_TAG ?= dev +docker-build-console-web: + docker build -t $(CONSOLE_WEB_IMAGE_REPO):$(CONSOLE_WEB_IMAGE_TAG) -f console-web/Dockerfile console-web + +docker-build-all: docker-build-operator docker-build-console-web diff --git a/console-web/app/(dashboard)/tenants/[namespace]/[name]/tenant-detail-client.tsx b/console-web/app/(dashboard)/tenants/[namespace]/[name]/tenant-detail-client.tsx index 1ebccce..3854471 100644 --- a/console-web/app/(dashboard)/tenants/[namespace]/[name]/tenant-detail-client.tsx +++ b/console-web/app/(dashboard)/tenants/[namespace]/[name]/tenant-detail-client.tsx @@ -29,10 +29,12 @@ import type { PodListItem, EventItem, AddPoolRequest, + EncryptionInfoResponse, + UpdateEncryptionRequest, } from "@/types/api" import { ApiError } from "@/lib/api-client" -type Tab = "overview" | "edit" | "pools" | "pods" | "events" +type Tab = "overview" | "edit" | "pools" | "pods" | "events" | "encryption" | "security" interface TenantDetailClientProps { namespace: string @@ -52,6 +54,10 @@ function normalizeTab(value?: string | null): Tab { return "pods" case "events": return "events" + case "encryption": + return "encryption" + case "security": + return "security" default: return "overview" } @@ -90,6 +96,43 @@ export function TenantDetailClient({ namespace, name, initialTab, initialYamlEdi const [isYamlEditable, setIsYamlEditable] = useState(!!initialYamlEditable) const [editLoading, setEditLoading] = useState(false) + // Encryption tab state + const [encLoaded, setEncLoaded] = useState(false) + const [encLoading, setEncLoading] = useState(false) + const [encSaving, setEncSaving] = useState(false) + const [encEnabled, setEncEnabled] = useState(false) + const [encBackend, setEncBackend] = useState<"local" | "vault">("local") + const [encVault, setEncVault] = useState({ + endpoint: "", + engine: "", + namespace: "", + prefix: "", + authType: "token", + tlsSkipVerify: false, + customCertificates: false, + }) + const [encAppRole, setEncAppRole] = useState({ + engine: "", + retrySeconds: "", + }) + const [encLocal, setEncLocal] = useState({ + keyDirectory: "", + masterKeyId: "", + }) + const [encKmsSecretName, setEncKmsSecretName] = useState("") + const [encPingSeconds, setEncPingSeconds] = useState("") + + // Security tab state + const [secCtxLoaded, setSecCtxLoaded] = useState(false) + const [secCtxLoading, setSecCtxLoading] = useState(false) + const [secCtxSaving, setSecCtxSaving] = useState(false) + const [secCtx, setSecCtx] = useState({ + runAsUser: "", + runAsGroup: "", + fsGroup: "", + runAsNonRoot: true, + }) + const loadTenant = async () => { const [detailResult, poolResult, podResult, eventResult] = await Promise.allSettled([ api.getTenant(namespace, name), @@ -146,7 +189,7 @@ export function TenantDetailClient({ namespace, name, initialTab, initialYamlEdi setTenantYamlSnapshot("") setTenantYamlLoaded(false) setIsYamlEditable(!!initialYamlEditable) - }, [namespace, name]) + }, [namespace, name, initialYamlEditable]) useEffect(() => { setTab(normalizeTab(initialTab)) @@ -158,6 +201,54 @@ export function TenantDetailClient({ namespace, name, initialTab, initialYamlEdi loadTenantYaml() }, [tab, tenantYamlLoaded, tenantYamlLoading]) // eslint-disable-line react-hooks/exhaustive-deps -- only lazy-load once per tenant + useEffect(() => { + if (tab !== "encryption" || encLoaded || encLoading) return + loadEncryption() + }, [tab, encLoaded, encLoading]) // eslint-disable-line react-hooks/exhaustive-deps -- only lazy-load once per tenant + + useEffect(() => { + if (tab !== "security" || secCtxLoaded || secCtxLoading) return + loadSecurityContext() + }, [tab, secCtxLoaded, secCtxLoading]) // eslint-disable-line react-hooks/exhaustive-deps -- only lazy-load once per tenant + + const loadSecurityContext = async () => { + setSecCtxLoading(true) + try { + const data = await api.getSecurityContext(namespace, name) + setSecCtx({ + runAsUser: data.runAsUser?.toString() ?? "", + runAsGroup: data.runAsGroup?.toString() ?? "", + fsGroup: data.fsGroup?.toString() ?? "", + runAsNonRoot: data.runAsNonRoot ?? true, + }) + } catch (e) { + const err = e as ApiError + toast.error(err.message || t("Failed to load security context")) + } finally { + setSecCtxLoaded(true) + setSecCtxLoading(false) + } + } + + const handleSaveSecurityContext = async (e: React.FormEvent) => { + e.preventDefault() + setSecCtxSaving(true) + try { + await api.updateSecurityContext(namespace, name, { + runAsUser: secCtx.runAsUser ? parseInt(secCtx.runAsUser, 10) : undefined, + runAsGroup: secCtx.runAsGroup ? parseInt(secCtx.runAsGroup, 10) : undefined, + fsGroup: secCtx.fsGroup ? parseInt(secCtx.fsGroup, 10) : undefined, + runAsNonRoot: secCtx.runAsNonRoot, + }) + toast.success(t("SecurityContext updated")) + } catch (e) { + const err = e as ApiError + toast.error(err.message || t("Update failed")) + } finally { + setSecCtxSaving(false) + } + } + const handleDeleteTenant = async () => { if (!confirm(t("Delete this tenant? This cannot be undone."))) return setDeleting(true) @@ -276,6 +367,92 @@ export function TenantDetailClient({ namespace, name, initialTab, initialYamlEdi } } + const loadEncryption = async () => { + setEncLoading(true) + try { + const data = await api.getEncryption(namespace, name) + setEncEnabled(data.enabled) + setEncBackend((data.backend === "vault" ? "vault" : "local") as "local" | "vault") + if (data.vault) { + setEncVault({ + endpoint: data.vault.endpoint || "", + engine: data.vault.engine || "", + namespace: data.vault.namespace || "", + prefix: data.vault.prefix || "", + authType: data.vault.authType || "token", + tlsSkipVerify: data.vault.tlsSkipVerify || false, + customCertificates: data.vault.customCertificates || false, + }) + if (data.vault.appRole) { + setEncAppRole({ + engine: data.vault.appRole.engine || "", + retrySeconds: data.vault.appRole.retrySeconds?.toString() || "", + }) + } + } + if (data.local) { + setEncLocal({ + keyDirectory: data.local.keyDirectory || "", + masterKeyId: data.local.masterKeyId || "", + }) + } + setEncKmsSecretName(data.kmsSecretName || "") + setEncPingSeconds(data.pingSeconds?.toString() || "") + } catch (e) { + const err = e as ApiError + toast.error(err.message || t("Failed to load encryption config")) + } finally { + setEncLoaded(true) + setEncLoading(false) + } + } + + const handleSaveEncryption = async (e: React.FormEvent) => { + e.preventDefault() + if (encEnabled && encBackend === "vault" && !encVault.endpoint.trim()) { + toast.warning(t("Vault endpoint is required")) + return + } + setEncSaving(true) + try { + const body: UpdateEncryptionRequest = { + enabled: encEnabled, + backend: encBackend, + kmsSecretName: encKmsSecretName || undefined, + pingSeconds: encPingSeconds ? parseInt(encPingSeconds, 10) : undefined, + } + if (encBackend === "vault") { + body.vault = { + endpoint: encVault.endpoint, + engine: encVault.engine || undefined, + namespace: encVault.namespace || undefined, + prefix: encVault.prefix || undefined, + authType: encVault.authType || undefined, + tlsSkipVerify: encVault.tlsSkipVerify || undefined, + customCertificates: encVault.customCertificates || undefined, + } + if (encVault.authType === "approle") { + body.vault.appRole = { + engine: encAppRole.engine || undefined, + retrySeconds: encAppRole.retrySeconds ? parseInt(encAppRole.retrySeconds, 10) : undefined, + } + } + } else { + body.local = { + keyDirectory: encLocal.keyDirectory || undefined, + masterKeyId: encLocal.masterKeyId || undefined, + } + } + const res = await api.updateEncryption(namespace, name, body) + toast.success(res.message || t("Encryption config updated")) + } catch (e) { + const err = e as ApiError + toast.error(err.message || t("Update failed")) + } finally { + setEncSaving(false) + } + } + const handleCopyYaml = async () => { try { await navigator.clipboard.writeText(tenantYaml) @@ -298,6 +475,8 @@ export function TenantDetailClient({ namespace, name, initialTab, initialYamlEdi { id: "pools", labelKey: "Pools" }, { id: "pods", labelKey: "Pods" }, { id: "events", labelKey: "Events" }, + { id: "encryption", labelKey: "Encryption" }, + { id: "security", labelKey: "Security" }, { id: "edit", labelKey: "YAML" }, ] @@ -674,6 +853,359 @@ export function TenantDetailClient({ namespace, name, initialTab, initialYamlEdi )} + {tab === "encryption" && ( + + + {t("Encryption")} + + {t("Configure server-side encryption (SSE) with a KMS backend. RustFS supports Local and Vault.")} + + + + {encLoading ? ( +
+ + {t("Loading encryption config...")} +
+ ) : ( +
+ {/* Enable / Disable toggle */} +
+ + setEncEnabled(e.target.checked)} + className="h-4 w-4 rounded border-border" + /> +
+ + {encEnabled && ( +
+ {/* Backend selector */} +
+ +
+ + +
+
+ + {/* Vault configuration */} + {encBackend === "vault" && ( +
+

{t("Vault Configuration")}

+
+
+ + setEncVault((v) => ({ ...v, endpoint: e.target.value }))} + /> +
+
+ + setEncVault((v) => ({ ...v, engine: e.target.value }))} + /> +
+
+ + setEncVault((v) => ({ ...v, namespace: e.target.value }))} + /> +
+
+ + setEncVault((v) => ({ ...v, prefix: e.target.value }))} + /> +
+
+
+
+ + setEncVault((v) => ({ ...v, tlsSkipVerify: e.target.checked }))} + className="h-4 w-4 rounded border-border" + /> +
+
+ + setEncVault((v) => ({ ...v, customCertificates: e.target.checked }))} + className="h-4 w-4 rounded border-border" + /> +
+
+ + {/* Auth type selector */} +
+ +
+ + +
+
+ + {/* AppRole section */} + {encVault.authType === "approle" && ( +
+
+
App Role
+ + {t("Not yet implemented in backend")} + +
+
+
+ + setEncAppRole((a) => ({ ...a, engine: e.target.value }))} + /> +
+
+ + setEncAppRole((a) => ({ ...a, retrySeconds: e.target.value }))} + /> +
+
+

+ {t( + "AppRole ID and Secret are stored in the KMS Secret (keys: vault-approle-id, vault-approle-secret).", + )} +

+
+ )} +
+ )} + + {/* Local configuration */} + {encBackend === "local" && ( +
+

{t("Local KMS Configuration")}

+
+
+ + setEncLocal((l) => ({ ...l, keyDirectory: e.target.value }))} + /> +
+
+ + setEncLocal((l) => ({ ...l, masterKeyId: e.target.value }))} + /> +
+
+
+ )} + + {/* Status — Ping is mainly useful for remote backends (Vault) */} + {encBackend === "vault" && ( +
+

{t("Status")}

+
+
+ + setEncPingSeconds(e.target.value)} + /> +

+ {t("Health check interval for KMS connectivity.")} +

+
+
+
+ )} + + {/* KMS Secret name */} +
+ + setEncKmsSecretName(e.target.value)} + /> +

+ {encBackend === "vault" + ? encVault.authType === "approle" + ? t("Secret must contain 'vault-approle-id' and 'vault-approle-secret'.") + + (encVault.customCertificates + ? " " + t("Plus TLS certs: vault-ca-cert, vault-client-cert, vault-client-key.") + : "") + : t("Secret must contain key 'vault-token'.") + + (encVault.customCertificates + ? " " + t("Plus TLS certs: vault-ca-cert, vault-client-cert, vault-client-key.") + : "") + : t("Not required for Local backend.")} +

+
+
+ )} + + {/* Save button */} +
+ + +
+
+ )} +
+
+ )} + + {tab === "security" && ( + + + {t("SecurityContext")} + + {t( + "Override Pod SecurityContext for RustFS pods (runAsUser, runAsGroup, fsGroup). Changes apply after Pods are recreated.", + )} + + + + {secCtxLoading ? ( +
+ + {t("Loading...")} +
+ ) : ( +
+
+
+ + setSecCtx((s) => ({ ...s, runAsUser: e.target.value }))} + /> +
+
+ + setSecCtx((s) => ({ ...s, runAsGroup: e.target.value }))} + /> +
+
+ + setSecCtx((s) => ({ ...s, fsGroup: e.target.value }))} + /> +
+
+ + setSecCtx((s) => ({ ...s, runAsNonRoot: e.target.checked }))} + className="h-4 w-4 rounded border-border" + /> +
+
+
+ + +
+
+ )} +
+
+ )} + {tab === "events" && (
diff --git a/console-web/app/(dashboard)/tenants/new/page.tsx b/console-web/app/(dashboard)/tenants/new/page.tsx index 1bea3af..3940e3f 100644 --- a/console-web/app/(dashboard)/tenants/new/page.tsx +++ b/console-web/app/(dashboard)/tenants/new/page.tsx @@ -73,6 +73,12 @@ export default function TenantCreatePage() { const [pools, setPools] = useState([{ ...defaultPool }]) const [image, setImage] = useState("") const [credsSecret, setCredsSecret] = useState("") + const [securityContext, setSecurityContext] = useState({ + runAsUser: "", + runAsGroup: "", + fsGroup: "", + runAsNonRoot: true, + }) const [yamlContent, setYamlContent] = useState(defaultTenantYaml) const [loading, setLoading] = useState(false) @@ -144,6 +150,21 @@ export default function TenantCreatePage() { } }) + const specSc = asRecord(spec?.securityContext ?? spec?.security_context) + const security_context = specSc + ? { + runAsUser: asPositiveInt(specSc.runAsUser ?? specSc.run_as_user), + runAsGroup: asPositiveInt(specSc.runAsGroup ?? specSc.run_as_group), + fsGroup: asPositiveInt(specSc.fsGroup ?? specSc.fs_group), + runAsNonRoot: + typeof specSc.runAsNonRoot === "boolean" + ? specSc.runAsNonRoot + : typeof specSc.run_as_non_root === "boolean" + ? specSc.run_as_non_root + : true, + } + : undefined + return { name: parsedName, namespace: parsedNamespace, @@ -151,6 +172,7 @@ export default function TenantCreatePage() { image: asString(spec?.image), mount_path: asString(spec?.mountPath ?? spec?.mount_path), creds_secret: asString(spec?.credsSecret ?? spec?.creds_secret), + security_context, } } @@ -182,6 +204,12 @@ export default function TenantCreatePage() { })), image: image.trim() || undefined, creds_secret: credsSecret.trim() || undefined, + security_context: { + runAsUser: securityContext.runAsUser ? parseInt(securityContext.runAsUser, 10) : undefined, + runAsGroup: securityContext.runAsGroup ? parseInt(securityContext.runAsGroup, 10) : undefined, + fsGroup: securityContext.fsGroup ? parseInt(securityContext.fsGroup, 10) : undefined, + runAsNonRoot: securityContext.runAsNonRoot, + }, } } @@ -221,18 +249,10 @@ export default function TenantCreatePage() {
- -
@@ -287,6 +307,58 @@ export default function TenantCreatePage() {
+ + + {t("SecurityContext")} + + {t("Override Pod SecurityContext for RustFS pods (default: 10001/10001/10001). Optional.")} + + + +
+
+ + setSecurityContext((s) => ({ ...s, runAsUser: e.target.value }))} + /> +
+
+ + setSecurityContext((s) => ({ ...s, runAsGroup: e.target.value }))} + /> +
+
+ + setSecurityContext((s) => ({ ...s, fsGroup: e.target.value }))} + /> +
+
+ + setSecurityContext((s) => ({ ...s, runAsNonRoot: e.target.checked }))} + className="h-4 w-4 rounded border-border" + /> +
+
+
+
+
diff --git a/console-web/app/(dashboard)/tenants/page.tsx b/console-web/app/(dashboard)/tenants/page.tsx index 9bd4fca..0d6b9e9 100644 --- a/console-web/app/(dashboard)/tenants/page.tsx +++ b/console-web/app/(dashboard)/tenants/page.tsx @@ -144,8 +144,7 @@ export default function TenantsListPage() { const currentSeq = ++loadSeq.current try { - const res = - namespace === ALL_NAMESPACES ? await api.listTenants() : await api.listTenantsByNamespace(namespace) + const res = namespace === ALL_NAMESPACES ? await api.listTenants() : await api.listTenantsByNamespace(namespace) if (currentSeq !== loadSeq.current) return setTenants(res.tenants) @@ -159,7 +158,10 @@ export default function TenantsListPage() { res.tenants.map(async (tnt) => { const key = makeTenantKey(tnt.namespace, tnt.name) try { - const [detailRes, poolRes] = await Promise.all([api.getTenant(tnt.namespace, tnt.name), api.listPools(tnt.namespace, tnt.name)]) + const [detailRes, poolRes] = await Promise.all([ + api.getTenant(tnt.namespace, tnt.name), + api.listPools(tnt.namespace, tnt.name), + ]) const replicas = poolRes.pools.reduce((sum, pool) => sum + pool.replicas, 0) const capacityBytes = poolRes.pools.reduce((sum, pool) => { const oneVolume = parseSizeToBytes(pool.volume_size) @@ -400,7 +402,9 @@ export default function TenantsListPage() { {tenant.namespace} - + {tenant.state} @@ -421,7 +425,9 @@ export default function TenantsListPage() { - router.push(routes.tenantDetail(tenant.namespace, tenant.name))}> + router.push(routes.tenantDetail(tenant.namespace, tenant.name))} + > {t("View Details")} diff --git a/console-web/i18n/locales/zh-CN.json b/console-web/i18n/locales/zh-CN.json index 7ac8343..b314098 100755 --- a/console-web/i18n/locales/zh-CN.json +++ b/console-web/i18n/locales/zh-CN.json @@ -151,6 +151,45 @@ "YAML content is required": "YAML 内容不能为空", "Tenant YAML updated": "租户 YAML 已更新", "Failed to load tenant YAML": "加载租户 YAML 失败", + "Encryption": "加密", + "Configure server-side encryption (SSE) with a KMS backend. RustFS supports Local and Vault.": "配置服务端加密 (SSE) 的 KMS 后端。RustFS 支持 Local 和 Vault。", + "Enable Encryption": "启用加密", + "KMS Backend": "KMS 后端", + "Vault Configuration": "Vault 配置", + "Endpoint": "Endpoint", + "Engine": "引擎路径", + "Prefix": "前缀", + "Skip TLS Verification": "跳过 TLS 验证", + "Local KMS Configuration": "本地 KMS 配置", + "Key Directory": "密钥目录", + "Master Key ID": "主密钥 ID", + "KMS Secret Name": "KMS Secret 名称", + "Secret containing vault-token and TLS certs": "包含 vault-token 和 TLS 证书的 Secret", + "Secret must contain key 'vault-token'. Optional: 'vault-ca-cert', 'vault-client-cert', 'vault-client-key'.": "Secret 必须包含 'vault-token' 键。可选:'vault-ca-cert'、'vault-client-cert'、'vault-client-key'。", + "Not required for Local backend.": "本地后端不需要 Secret。", + "Auth Type": "认证方式", + "Not yet implemented in backend": "后端尚未实现", + "Retry (Seconds)": "重试间隔(秒)", + "AppRole ID and Secret are stored in the KMS Secret (keys: vault-approle-id, vault-approle-secret).": "AppRole ID 和 Secret 存储在 KMS Secret 中(键名:vault-approle-id、vault-approle-secret)。", + "Secret must contain 'vault-approle-id' and 'vault-approle-secret'. Optional: TLS certs.": "Secret 必须包含 'vault-approle-id' 和 'vault-approle-secret'。可选:TLS 证书。", + "Ping (Seconds)": "Ping 间隔(秒)", + "Health check interval for KMS connectivity.": "KMS 连通性健康检查间隔。", + "Security": "安全", + "SecurityContext": "SecurityContext", + "Override Pod SecurityContext for RustFS pods (default: 10001/10001/10001).": "覆盖 RustFS Pod 的 SecurityContext(默认:10001/10001/10001)。", + "Override Pod SecurityContext for RustFS pods (runAsUser, runAsGroup, fsGroup). Changes apply after Pods are recreated.": "覆盖 RustFS Pod 的 SecurityContext(runAsUser、runAsGroup、fsGroup)。修改后需等 Pod 重建后生效。", + "SecurityContext updated": "SecurityContext 已更新", + "Failed to load security context": "加载 SecurityContext 失败", + "Do not run as Root": "禁止以 Root 运行", + "Custom Certificates": "自定义证书", + "Vault endpoint is required": "Vault 端点地址不能为空", + "Secret must contain 'vault-approle-id' and 'vault-approle-secret'.": "Secret 必须包含 'vault-approle-id' 和 'vault-approle-secret'。", + "Secret must contain key 'vault-token'.": "Secret 必须包含键 'vault-token'。", + "Plus TLS certs: vault-ca-cert, vault-client-cert, vault-client-key.": "还需包含 TLS 证书:vault-ca-cert、vault-client-cert、vault-client-key。", + "Failed to load encryption config": "加载加密配置失败", + "Encryption config updated": "加密配置已更新", + "Loading encryption config...": "正在加载加密配置...", + "Reload": "重新加载", "Update tenant image, mount path or credentials secret.": "更新租户镜像、挂载路径或凭证 Secret。", "Save": "保存", "Saving...": "保存中...", diff --git a/console-web/lib/api.ts b/console-web/lib/api.ts index b7ebab8..1b11379 100644 --- a/console-web/lib/api.ts +++ b/console-web/lib/api.ts @@ -18,6 +18,12 @@ import type { NamespaceListResponse, ClusterResourcesResponse, TenantYamlPayload, + EncryptionInfoResponse, + UpdateEncryptionRequest, + EncryptionUpdateResponse, + SecurityContextInfo, + UpdateSecurityContextRequest, + SecurityContextUpdateResponse, } from "@/types/api" const ns = (namespace: string) => `/namespaces/${encodeURIComponent(namespace)}` @@ -80,7 +86,6 @@ export async function updateTenantYaml( ): Promise { return apiClient.put(tenantYaml(namespace, name), body) } - // ----- Pools ----- export async function listPools(namespace: string, tenantName: string): Promise { return apiClient.get(`${pools(namespace, tenantName)}`) @@ -132,6 +137,36 @@ export async function getPodLogs( return apiClient.getText(`${pod(namespace, tenantName, podName)}/logs${q ? `?${q}` : ""}`) } +// ----- Encryption ----- +const encryption = (namespace: string, name: string) => `${tenant(namespace, name)}/encryption` + +export async function getEncryption(namespace: string, name: string): Promise { + return apiClient.get(encryption(namespace, name)) +} + +export async function updateEncryption( + namespace: string, + name: string, + body: UpdateEncryptionRequest, +): Promise { + return apiClient.put(encryption(namespace, name), body) +} + +// ----- Security Context ----- +const securityContext = (namespace: string, name: string) => `${tenant(namespace, name)}/security-context` + +export async function getSecurityContext(namespace: string, name: string): Promise { + return apiClient.get(securityContext(namespace, name)) +} + +export async function updateSecurityContext( + namespace: string, + name: string, + body: UpdateSecurityContextRequest, +): Promise { + return apiClient.put(securityContext(namespace, name), body) +} + // ----- Events ----- export async function listTenantEvents(namespace: string, tenantName: string): Promise { return apiClient.get(events(namespace, tenantName)) diff --git a/console-web/pnpm-lock.yaml b/console-web/pnpm-lock.yaml index e28a418..96cf463 100755 --- a/console-web/pnpm-lock.yaml +++ b/console-web/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: tailwind-merge: specifier: ^3.4.0 version: 3.4.0 + yaml: + specifier: ^2.8.2 + version: 2.8.2 devDependencies: '@tailwindcss/postcss': specifier: ^4 @@ -3787,6 +3790,11 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yaml@2.8.2: + resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==} + engines: {node: '>= 14.6'} + hasBin: true + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} @@ -7838,6 +7846,8 @@ snapshots: yallist@3.1.1: {} + yaml@2.8.2: {} + yargs-parser@21.1.1: {} yargs@17.7.2: diff --git a/console-web/types/api.ts b/console-web/types/api.ts index 025ec6a..431191c 100644 --- a/console-web/types/api.ts +++ b/console-web/types/api.ts @@ -50,6 +50,13 @@ export interface CreatePoolRequest { storage_class?: string } +export interface CreateSecurityContextRequest { + runAsUser?: number + runAsGroup?: number + fsGroup?: number + runAsNonRoot?: boolean +} + export interface CreateTenantRequest { name: string namespace: string @@ -57,6 +64,7 @@ export interface CreateTenantRequest { image?: string mount_path?: string creds_secret?: string + security_context?: CreateSecurityContextRequest } export interface UpdateTenantRequest { @@ -235,6 +243,86 @@ export interface EventListResponse { events: EventItem[] } +// ----- Encryption ----- +export interface AppRoleInfo { + engine: string | null + retrySeconds: number | null +} + +export interface VaultInfo { + endpoint: string + engine: string | null + namespace: string | null + prefix: string | null + authType: string | null + appRole: AppRoleInfo | null + tlsSkipVerify: boolean | null + customCertificates: boolean | null +} + +export interface LocalKmsInfo { + keyDirectory: string | null + masterKeyId: string | null +} + +export interface SecurityContextInfo { + runAsUser: number | null + runAsGroup: number | null + fsGroup: number | null + runAsNonRoot: boolean | null +} + +export interface EncryptionInfoResponse { + enabled: boolean + backend: string + vault: VaultInfo | null + local: LocalKmsInfo | null + kmsSecretName: string | null + pingSeconds: number | null + securityContext: SecurityContextInfo | null +} + +export interface UpdateEncryptionRequest { + enabled: boolean + backend?: string + vault?: { + endpoint: string + engine?: string + namespace?: string + prefix?: string + authType?: string + appRole?: { + engine?: string + retrySeconds?: number + } + tlsSkipVerify?: boolean + customCertificates?: boolean + } + local?: { + keyDirectory?: string + masterKeyId?: string + } + kmsSecretName?: string + pingSeconds?: number +} + +export interface EncryptionUpdateResponse { + success: boolean + message: string +} + +export interface UpdateSecurityContextRequest { + runAsUser?: number + runAsGroup?: number + fsGroup?: number + runAsNonRoot?: boolean +} + +export interface SecurityContextUpdateResponse { + success: boolean + message: string +} + // ----- Cluster ----- export interface NodeInfo { name: string diff --git a/deploy/rustfs-operator/crds/tenant-crd.yaml b/deploy/rustfs-operator/crds/tenant-crd.yaml index 979ef94..d31c7db 100644 --- a/deploy/rustfs-operator/crds/tenant-crd.yaml +++ b/deploy/rustfs-operator/crds/tenant-crd.yaml @@ -45,6 +45,135 @@ spec: required: - name type: object + encryption: + description: |- + Encryption / KMS configuration for server-side encryption. + When enabled, the operator injects KMS environment variables and mounts + secrets into RustFS pods so the in-process `rustfs-kms` library is configured. + nullable: true + properties: + backend: + default: local + description: 'KMS backend type: `local` or `vault`.' + enum: + - local + - vault + type: string + enabled: + default: false + description: Enable server-side encryption. When `false`, all other fields are ignored. + type: boolean + kmsSecret: + description: |- + Reference to a Secret containing sensitive KMS credentials + (Vault token or AppRole credentials, TLS certificates). + nullable: true + properties: + name: + description: 'Name of the referent. This field is effectively required, but due to backwards compatibility is allowed to be empty. Instances of this type with an empty value here are almost certainly wrong. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names' + type: string + required: + - name + type: object + local: + description: 'Local file-based settings (optional when `backend: local`).' + nullable: true + properties: + keyDirectory: + description: 'Directory for key files inside the container (default: `/data/kms-keys`).' + nullable: true + type: string + masterKeyId: + description: 'Master key identifier (default: `default-master-key`).' + nullable: true + type: string + type: object + pingSeconds: + description: |- + Interval in seconds for KMS health-check pings (default: disabled). + When set, the operator stores the value; the in-process KMS library + picks it up from `RUSTFS_KMS_PING_SECONDS`. + format: int32 + nullable: true + type: integer + securityContext: + description: |- + Override Pod SecurityContext when encryption is enabled. + If not set, the default RustFS Pod SecurityContext is used + (runAsUser/runAsGroup/fsGroup = 10001). + nullable: true + properties: + fsGroup: + description: GID applied to all volumes mounted in the Pod. + format: int64 + nullable: true + type: integer + runAsGroup: + description: GID to run the container process as. + format: int64 + nullable: true + type: integer + runAsNonRoot: + description: 'Enforce non-root execution (default: true).' + nullable: true + type: boolean + runAsUser: + description: UID to run the container process as. + format: int64 + nullable: true + type: integer + type: object + vault: + description: 'Vault-specific settings (required when `backend: vault`).' + nullable: true + properties: + appRole: + description: |- + AppRole authentication settings. Only used when `authType: approle`. + The actual `role_id` and `secret_id` values live in the KMS Secret + under keys `vault-approle-id` and `vault-approle-secret`. + nullable: true + properties: + engine: + description: Engine mount path for AppRole auth (e.g. `approle`). + nullable: true + type: string + retrySeconds: + description: 'Retry interval in seconds for AppRole login attempts (default: 10).' + format: int32 + nullable: true + type: integer + type: object + authType: + default: token + description: |- + Authentication method: `token` (default, implemented) or `approle` + (type defined in rustfs-kms but backend not yet functional). + nullable: true + type: string + endpoint: + description: Vault server endpoint (e.g. `https://vault.example.com:8200`). + type: string + engine: + description: 'Vault KV2 engine mount path (default: `kv`).' + nullable: true + type: string + namespace: + description: Vault namespace (Enterprise feature). + nullable: true + type: string + prefix: + description: Key prefix inside the engine. + nullable: true + type: string + tlsSkipVerify: + description: 'Enable TLS verification for Vault connection (default: true).' + nullable: true + type: boolean + required: + - endpoint + type: object + type: object env: items: description: EnvVar represents an environment variable present in a Container. diff --git a/src/console/handlers/encryption.rs b/src/console/handlers/encryption.rs new file mode 100644 index 0000000..52d1d0e --- /dev/null +++ b/src/console/handlers/encryption.rs @@ -0,0 +1,219 @@ +// Copyright 2025 RustFS Team +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use crate::console::{ + error::{self, Error, Result}, + models::encryption::*, + state::Claims, +}; +use crate::types::v1alpha1::encryption::{ + EncryptionConfig, KmsBackendType, LocalKmsConfig, VaultAppRoleConfig, VaultAuthType, + VaultKmsConfig, +}; +use crate::types::v1alpha1::tenant::Tenant; +use axum::{Extension, Json, extract::Path}; +use k8s_openapi::api::core::v1 as corev1; +use kube::api::{Patch, PatchParams}; +use kube::{Api, Client}; + +/// GET /namespaces/:namespace/tenants/:name/encryption +pub async fn get_encryption( + Path((namespace, name)): Path<(String, String)>, + Extension(claims): Extension, +) -> Result> { + let client = create_client(&claims).await?; + let api: Api = Api::namespaced(client, &namespace); + + let tenant = api + .get(&name) + .await + .map_err(|e| error::map_kube_error(e, format!("Tenant '{}'", name)))?; + + let enc_resp = + match tenant.spec.encryption { + Some(ref enc) => EncryptionInfoResponse { + enabled: enc.enabled, + backend: enc.backend.to_string(), + vault: enc.vault.as_ref().map(|v| VaultInfo { + endpoint: v.endpoint.clone(), + engine: v.engine.clone(), + namespace: v.namespace.clone(), + prefix: v.prefix.clone(), + auth_type: v.auth_type.as_ref().map(|a| a.to_string()), + app_role: v.app_role.as_ref().map(|ar| AppRoleInfo { + engine: ar.engine.clone(), + retry_seconds: ar.retry_seconds, + }), + tls_skip_verify: v.tls_skip_verify, + custom_certificates: v.custom_certificates, + }), + local: enc.local.as_ref().map(|l| LocalInfo { + key_directory: l.key_directory.clone(), + master_key_id: l.master_key_id.clone(), + }), + kms_secret_name: enc.kms_secret.as_ref().map(|s| s.name.clone()), + ping_seconds: enc.ping_seconds, + security_context: tenant.spec.security_context.as_ref().map(|sc| { + SecurityContextInfo { + run_as_user: sc.run_as_user, + run_as_group: sc.run_as_group, + fs_group: sc.fs_group, + run_as_non_root: sc.run_as_non_root, + } + }), + }, + None => EncryptionInfoResponse { + enabled: false, + backend: "local".to_string(), + vault: None, + local: None, + kms_secret_name: None, + ping_seconds: None, + security_context: tenant.spec.security_context.as_ref().map(|sc| { + SecurityContextInfo { + run_as_user: sc.run_as_user, + run_as_group: sc.run_as_group, + fs_group: sc.fs_group, + run_as_non_root: sc.run_as_non_root, + } + }), + }, + }; + + Ok(Json(enc_resp)) +} + +/// PUT /namespaces/:namespace/tenants/:name/encryption +pub async fn update_encryption( + Path((namespace, name)): Path<(String, String)>, + Extension(claims): Extension, + Json(body): Json, +) -> Result> { + let client = create_client(&claims).await?; + let api: Api = Api::namespaced(client, &namespace); + + let _tenant = api + .get(&name) + .await + .map_err(|e| error::map_kube_error(e, format!("Tenant '{}'", name)))?; + + let encryption = if body.enabled { + let backend = match body.backend.as_deref() { + Some("vault") => KmsBackendType::Vault, + _ => KmsBackendType::Local, + }; + + // Validate Vault config when backend is Vault (fail fast with 400 instead of invalid spec) + if backend == KmsBackendType::Vault { + let vault_ok = body + .vault + .as_ref() + .map(|v| !v.endpoint.is_empty()) + .unwrap_or(false); + if !vault_ok { + return Err(Error::BadRequest { + message: "Vault backend requires vault.endpoint to be non-empty".to_string(), + }); + } + let secret_ok = body + .kms_secret_name + .as_ref() + .map(|s| !s.is_empty()) + .unwrap_or(false); + if !secret_ok { + return Err(Error::BadRequest { + message: "Vault backend requires kmsSecretName".to_string(), + }); + } + } + + let vault = if backend == KmsBackendType::Vault { + body.vault.map(|v| VaultKmsConfig { + endpoint: v.endpoint, + engine: v.engine, + namespace: v.namespace, + prefix: v.prefix, + auth_type: v.auth_type.map(|s| match s.as_str() { + "approle" => VaultAuthType::Approle, + _ => VaultAuthType::Token, + }), + app_role: v.app_role.map(|ar| VaultAppRoleConfig { + engine: ar.engine, + retry_seconds: ar.retry_seconds, + }), + tls_skip_verify: v.tls_skip_verify, + custom_certificates: v.custom_certificates, + }) + } else { + None + }; + + let local = if backend == KmsBackendType::Local { + body.local.map(|l| LocalKmsConfig { + key_directory: l.key_directory, + master_key_id: l.master_key_id, + }) + } else { + None + }; + + let kms_secret = body + .kms_secret_name + .filter(|s| !s.is_empty()) + .map(|s| corev1::LocalObjectReference { name: s }); + + Some(EncryptionConfig { + enabled: true, + backend, + vault, + local, + kms_secret, + ping_seconds: body.ping_seconds, + }) + } else { + Some(EncryptionConfig { + enabled: false, + ..Default::default() + }) + }; + + let patch = serde_json::json!({ "spec": { "encryption": encryption } }); + + api.patch(&name, &PatchParams::default(), &Patch::Merge(&patch)) + .await + .map_err(|e| error::map_kube_error(e, format!("Tenant '{}'", name)))?; + + Ok(Json(EncryptionUpdateResponse { + success: true, + message: if body.enabled { + "Encryption configuration updated".to_string() + } else { + "Encryption disabled".to_string() + }, + })) +} + +async fn create_client(claims: &Claims) -> Result { + let mut config = kube::Config::infer() + .await + .map_err(|e| Error::InternalServer { + message: format!("Failed to load kubeconfig: {}", e), + })?; + + config.auth_info.token = Some(claims.k8s_token.clone().into()); + + Client::try_from(config).map_err(|e| Error::InternalServer { + message: format!("Failed to create K8s client: {}", e), + }) +} diff --git a/src/console/handlers/mod.rs b/src/console/handlers/mod.rs index db4cc76..5c45bd7 100755 --- a/src/console/handlers/mod.rs +++ b/src/console/handlers/mod.rs @@ -14,7 +14,9 @@ pub mod auth; pub mod cluster; +pub mod encryption; pub mod events; pub mod pods; pub mod pools; +pub mod security_context; pub mod tenants; diff --git a/src/console/handlers/security_context.rs b/src/console/handlers/security_context.rs new file mode 100644 index 0000000..e03020b --- /dev/null +++ b/src/console/handlers/security_context.rs @@ -0,0 +1,112 @@ +// Copyright 2025 RustFS Team +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use crate::console::{ + error::{self, Error, Result}, + models::encryption::{SecurityContextInfo, UpdateSecurityContextRequest}, + state::Claims, +}; +use crate::types::v1alpha1::encryption::PodSecurityContextOverride; +use crate::types::v1alpha1::tenant::Tenant; +use axum::{Extension, Json, extract::Path}; +use kube::api::{Patch, PatchParams}; +use kube::{Api, Client}; + +/// GET /namespaces/:namespace/tenants/:name/security-context +pub async fn get_security_context( + Path((namespace, name)): Path<(String, String)>, + Extension(claims): Extension, +) -> Result> { + let client = create_client(&claims).await?; + let api: Api = Api::namespaced(client, &namespace); + + let tenant = api + .get(&name) + .await + .map_err(|e| error::map_kube_error(e, format!("Tenant '{}'", name)))?; + + let info = tenant.spec.security_context.as_ref().map_or_else( + || SecurityContextInfo { + run_as_user: None, + run_as_group: None, + fs_group: None, + run_as_non_root: None, + }, + |sc| SecurityContextInfo { + run_as_user: sc.run_as_user, + run_as_group: sc.run_as_group, + fs_group: sc.fs_group, + run_as_non_root: sc.run_as_non_root, + }, + ); + + Ok(Json(info)) +} + +/// PUT /namespaces/:namespace/tenants/:name/security-context +pub async fn update_security_context( + Path((namespace, name)): Path<(String, String)>, + Extension(claims): Extension, + Json(body): Json, +) -> Result> { + let client = create_client(&claims).await?; + let api: Api = Api::namespaced(client, &namespace); + + let _tenant = api + .get(&name) + .await + .map_err(|e| error::map_kube_error(e, format!("Tenant '{}'", name)))?; + + let security_context = PodSecurityContextOverride { + run_as_user: body.run_as_user, + run_as_group: body.run_as_group, + fs_group: body.fs_group, + run_as_non_root: body.run_as_non_root, + }; + + let patch = serde_json::json!({ + "spec": { + "securityContext": serde_json::to_value(&security_context).map_err(|e| Error::Json { source: e })? + } + }); + + api.patch(&name, &PatchParams::default(), &Patch::Merge(&patch)) + .await + .map_err(|e| error::map_kube_error(e, format!("Tenant '{}'", name)))?; + + Ok(Json(SecurityContextUpdateResponse { + success: true, + message: "SecurityContext updated".to_string(), + })) +} + +#[derive(Debug, serde::Serialize)] +pub struct SecurityContextUpdateResponse { + pub success: bool, + pub message: String, +} + +async fn create_client(claims: &Claims) -> Result { + let mut config = kube::Config::infer() + .await + .map_err(|e| Error::InternalServer { + message: format!("Failed to load kubeconfig: {}", e), + })?; + + config.auth_info.token = Some(claims.k8s_token.clone().into()); + + Client::try_from(config).map_err(|e| Error::InternalServer { + message: format!("Failed to create K8s client: {}", e), + }) +} diff --git a/src/console/handlers/tenants.rs b/src/console/handlers/tenants.rs index 4cd4449..fcf4880 100755 --- a/src/console/handlers/tenants.rs +++ b/src/console/handlers/tenants.rs @@ -17,7 +17,10 @@ use crate::console::{ models::tenant::*, state::Claims, }; -use crate::types::v1alpha1::{persistence::PersistenceConfig, pool::Pool, tenant::Tenant}; +use crate::types::v1alpha1::{ + encryption::PodSecurityContextOverride, persistence::PersistenceConfig, pool::Pool, + tenant::Tenant, +}; use axum::{Extension, Json, extract::Path}; use k8s_openapi::api::core::v1 as corev1; use kube::{Api, Client, ResourceExt, api::ListParams}; @@ -256,6 +259,16 @@ pub async fn create_tenant( }) .collect(); + let security_context = req + .security_context + .as_ref() + .map(|sc| PodSecurityContextOverride { + run_as_user: sc.run_as_user, + run_as_group: sc.run_as_group, + fs_group: sc.fs_group, + run_as_non_root: sc.run_as_non_root, + }); + let tenant = Tenant { metadata: k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta { name: Some(req.name.clone()), @@ -269,6 +282,7 @@ pub async fn create_tenant( creds_secret: req .creds_secret .map(|name| corev1::LocalObjectReference { name }), + security_context, ..Default::default() }, status: None, diff --git a/src/console/models/encryption.rs b/src/console/models/encryption.rs new file mode 100644 index 0000000..6a5c7a2 --- /dev/null +++ b/src/console/models/encryption.rs @@ -0,0 +1,125 @@ +// Copyright 2025 RustFS Team +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +/// GET response – current encryption configuration for a Tenant. +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct EncryptionInfoResponse { + pub enabled: bool, + pub backend: String, + pub vault: Option, + pub local: Option, + pub kms_secret_name: Option, + pub ping_seconds: Option, + pub security_context: Option, +} + +/// Vault configuration (non-sensitive fields only). +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct VaultInfo { + pub endpoint: String, + pub engine: Option, + pub namespace: Option, + pub prefix: Option, + pub auth_type: Option, + pub app_role: Option, + pub tls_skip_verify: Option, + pub custom_certificates: Option, +} + +/// AppRole non-sensitive fields. +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct AppRoleInfo { + pub engine: Option, + pub retry_seconds: Option, +} + +/// Local KMS configuration. +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct LocalInfo { + pub key_directory: Option, + pub master_key_id: Option, +} + +/// SecurityContext information (lives at TenantSpec level, shown alongside encryption). +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct SecurityContextInfo { + pub run_as_user: Option, + pub run_as_group: Option, + pub fs_group: Option, + pub run_as_non_root: Option, +} + +/// PUT request – update encryption configuration. +/// SecurityContext is managed separately via the Security tab (PUT .../security-context). +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct UpdateEncryptionRequest { + pub enabled: bool, + pub backend: Option, + pub vault: Option, + pub local: Option, + pub kms_secret_name: Option, + pub ping_seconds: Option, +} + +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct UpdateVaultRequest { + pub endpoint: String, + pub engine: Option, + pub namespace: Option, + pub prefix: Option, + pub auth_type: Option, + pub app_role: Option, + pub tls_skip_verify: Option, + pub custom_certificates: Option, +} + +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct UpdateAppRoleRequest { + pub engine: Option, + pub retry_seconds: Option, +} + +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct UpdateLocalRequest { + pub key_directory: Option, + pub master_key_id: Option, +} + +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct UpdateSecurityContextRequest { + pub run_as_user: Option, + pub run_as_group: Option, + pub fs_group: Option, + pub run_as_non_root: Option, +} + +/// Generic success response. +#[derive(Debug, Serialize, ToSchema)] +pub struct EncryptionUpdateResponse { + pub success: bool, + pub message: String, +} diff --git a/src/console/models/mod.rs b/src/console/models/mod.rs index 3523721..1ac0dc0 100755 --- a/src/console/models/mod.rs +++ b/src/console/models/mod.rs @@ -14,6 +14,7 @@ pub mod auth; pub mod cluster; +pub mod encryption; pub mod event; pub mod pod; pub mod pool; diff --git a/src/console/models/tenant.rs b/src/console/models/tenant.rs index 6501753..2971d7a 100755 --- a/src/console/models/tenant.rs +++ b/src/console/models/tenant.rs @@ -68,6 +68,16 @@ pub struct ServicePort { pub target_port: String, } +/// SecurityContext for create/update (Pod runAsUser, runAsGroup, fsGroup, runAsNonRoot). +#[derive(Debug, Deserialize, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct CreateSecurityContextRequest { + pub run_as_user: Option, + pub run_as_group: Option, + pub fs_group: Option, + pub run_as_non_root: Option, +} + /// 创建 Tenant 请求 #[derive(Debug, Deserialize, ToSchema)] pub struct CreateTenantRequest { @@ -77,6 +87,8 @@ pub struct CreateTenantRequest { pub image: Option, pub mount_path: Option, pub creds_secret: Option, + /// Optional Pod SecurityContext override (runAsUser, runAsGroup, fsGroup, runAsNonRoot). + pub security_context: Option, } /// 创建 Pool 请求 diff --git a/src/console/routes/mod.rs b/src/console/routes/mod.rs index a94da36..5b95b76 100755 --- a/src/console/routes/mod.rs +++ b/src/console/routes/mod.rs @@ -56,6 +56,22 @@ pub fn tenant_routes() -> Router { "/namespaces/:namespace/tenants/:name/yaml", put(handlers::tenants::put_tenant_yaml), ) + .route( + "/namespaces/:namespace/tenants/:name/encryption", + get(handlers::encryption::get_encryption), + ) + .route( + "/namespaces/:namespace/tenants/:name/encryption", + put(handlers::encryption::update_encryption), + ) + .route( + "/namespaces/:namespace/tenants/:name/security-context", + get(handlers::security_context::get_security_context), + ) + .route( + "/namespaces/:namespace/tenants/:name/security-context", + put(handlers::security_context::update_security_context), + ) } /// Pool 管理路由 diff --git a/src/context.rs b/src/context.rs index 4f36cb5..238c39f 100755 --- a/src/context.rs +++ b/src/context.rs @@ -62,6 +62,15 @@ pub enum Error { length: usize, }, + #[snafu(display("KMS secret '{}' not found", name))] + KmsSecretNotFound { name: String }, + + #[snafu(display("KMS secret '{}' missing required key '{}'", secret_name, key))] + KmsSecretMissingKey { secret_name: String, key: String }, + + #[snafu(display("KMS configuration invalid: {}", message))] + KmsConfigInvalid { message: String }, + #[snafu(transparent)] Serde { source: serde_json::Error }, } @@ -291,6 +300,93 @@ impl Context { Ok(()) } + /// Validates encryption configuration and the KMS Secret. + /// + /// Checks: + /// 1. Vault endpoint is non-empty when backend is Vault. + /// 2. KMS Secret exists and contains the correct keys for the auth type. + pub async fn validate_kms_secret(&self, tenant: &Tenant) -> Result<(), Error> { + use crate::types::v1alpha1::encryption::{KmsBackendType, VaultAuthType}; + + let Some(ref enc) = tenant.spec.encryption else { + return Ok(()); + }; + if !enc.enabled { + return Ok(()); + } + + // Validate Vault endpoint is non-empty and kms_secret is required for Vault + if enc.backend == KmsBackendType::Vault { + let endpoint_empty = enc + .vault + .as_ref() + .map(|v| v.endpoint.is_empty()) + .unwrap_or(true); + if endpoint_empty { + return Err(Error::KmsConfigInvalid { + message: "Vault endpoint must not be empty".to_string(), + }); + } + // Vault backend requires credentials (token or AppRole) from a Secret + let secret_missing = enc + .kms_secret + .as_ref() + .map(|s| s.name.is_empty()) + .unwrap_or(true); + if secret_missing { + return Err(Error::KmsConfigInvalid { + message: "Vault backend requires kmsSecret with vault-token or vault-approle-id/vault-approle-secret".to_string(), + }); + } + } + + let Some(ref secret_ref) = enc.kms_secret else { + return Ok(()); + }; + if secret_ref.name.is_empty() { + return Ok(()); + } + + let secret: Secret = self + .get(&secret_ref.name, &tenant.namespace()?) + .await + .map_err(|_| Error::KmsSecretNotFound { + name: secret_ref.name.clone(), + })?; + + if enc.backend == KmsBackendType::Vault { + let is_approle = enc.vault.as_ref().and_then(|v| v.auth_type.as_ref()) + == Some(&VaultAuthType::Approle); + + if is_approle { + for key in ["vault-approle-id", "vault-approle-secret"] { + let has_key = secret.data.as_ref().is_some_and(|d| d.contains_key(key)); + if !has_key { + return KmsSecretMissingKeySnafu { + secret_name: secret_ref.name.clone(), + key: key.to_string(), + } + .fail(); + } + } + } else { + let has_token = secret + .data + .as_ref() + .is_some_and(|d| d.contains_key("vault-token")); + if !has_token { + return KmsSecretMissingKeySnafu { + secret_name: secret_ref.name.clone(), + key: "vault-token".to_string(), + } + .fail(); + } + } + } + + Ok(()) + } + /// Gets the status of a StatefulSet including rollout progress /// /// # Returns diff --git a/src/reconcile.rs b/src/reconcile.rs index 77a00e6..eb09bf5 100755 --- a/src/reconcile.rs +++ b/src/reconcile.rs @@ -66,6 +66,39 @@ pub async fn reconcile_rustfs(tenant: Arc, ctx: Arc) -> Result< return Err(e.into()); } + // Validate KMS Secret if encryption is configured + if let Some(ref enc) = latest_tenant.spec.encryption + && enc.enabled + && enc.kms_secret.is_some() + && let Err(e) = ctx.validate_kms_secret(&latest_tenant).await + { + let _ = ctx + .record( + &latest_tenant, + EventType::Warning, + "KmsSecretValidationFailed", + &format!("Failed to validate KMS secret: {}", e), + ) + .await; + return Err(e.into()); + } + + // Warn if Local backend has a kmsSecret configured (not used for Local) + if let Some(ref enc) = latest_tenant.spec.encryption + && enc.enabled + && enc.backend == crate::types::v1alpha1::encryption::KmsBackendType::Local + && enc.kms_secret.as_ref().is_some_and(|s| !s.name.is_empty()) + { + let _ = ctx + .record( + &latest_tenant, + EventType::Warning, + "KmsConfigWarning", + "Local KMS backend does not use kmsSecret; the Secret reference will be ignored", + ) + .await; + } + // 0. Optional: unblock StatefulSet pods stuck terminating when their node is down. // This is inspired by Longhorn's "Pod Deletion Policy When Node is Down". if let Some(policy) = latest_tenant @@ -554,14 +587,15 @@ pub fn error_policy(_object: Arc, error: &Error, _ctx: Arc) -> // - Transient errors (API, network): Shorter intervals for quick recovery match error { Error::Context { source } => match source { - // Credential validation errors - require user intervention + // Credential / KMS validation errors - require user intervention // Use 60-second requeue to reduce event/log spam while user fixes the issue context::Error::CredentialSecretNotFound { .. } | context::Error::CredentialSecretMissingKey { .. } | context::Error::CredentialSecretInvalidEncoding { .. } - | context::Error::CredentialSecretTooShort { .. } => { - Action::requeue(Duration::from_secs(60)) - } + | context::Error::CredentialSecretTooShort { .. } + | context::Error::KmsSecretNotFound { .. } + | context::Error::KmsSecretMissingKey { .. } + | context::Error::KmsConfigInvalid { .. } => Action::requeue(Duration::from_secs(60)), // Kubernetes API errors - might be transient (network, API server issues) // Use shorter requeue for faster recovery diff --git a/src/types/v1alpha1.rs b/src/types/v1alpha1.rs index d6966aa..1bef3c0 100755 --- a/src/types/v1alpha1.rs +++ b/src/types/v1alpha1.rs @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +pub mod encryption; pub mod k8s; pub mod logging; pub mod persistence; diff --git a/src/types/v1alpha1/encryption.rs b/src/types/v1alpha1/encryption.rs new file mode 100644 index 0000000..e5e8ba0 --- /dev/null +++ b/src/types/v1alpha1/encryption.rs @@ -0,0 +1,239 @@ +// Copyright 2025 RustFS Team +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use k8s_openapi::api::core::v1 as corev1; +use k8s_openapi::schemars::JsonSchema; +use kube::KubeSchema; +use serde::{Deserialize, Serialize}; + +/// KMS backend type for server-side encryption. +/// +/// RustFS supports two backends: +/// - `Local`: File-based key storage on disk (development / single-node) +/// - `Vault`: HashiCorp Vault KV2 engine (production) +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema, Default, PartialEq)] +#[serde(rename_all = "lowercase")] +#[schemars(rename_all = "lowercase")] +pub enum KmsBackendType { + #[default] + Local, + Vault, +} + +impl std::fmt::Display for KmsBackendType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + KmsBackendType::Local => write!(f, "local"), + KmsBackendType::Vault => write!(f, "vault"), + } + } +} + +/// Vault authentication method. +/// +/// `Token` is the default and fully implemented in rustfs-kms. +/// `Approle` type exists in rustfs-kms but the backend is not yet functional. +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema, Default, PartialEq)] +#[serde(rename_all = "lowercase")] +#[schemars(rename_all = "lowercase")] +pub enum VaultAuthType { + #[default] + Token, + Approle, +} + +impl std::fmt::Display for VaultAuthType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + VaultAuthType::Token => write!(f, "token"), + VaultAuthType::Approle => write!(f, "approle"), + } + } +} + +/// Vault-specific KMS configuration. +/// +/// Maps to `VaultConfig` in the `rustfs-kms` crate. +/// Sensitive fields (token, TLS keys) are stored in the Secret referenced +/// by `EncryptionConfig::kms_secret`. +#[derive(Deserialize, Serialize, Clone, Debug, KubeSchema, Default)] +#[serde(rename_all = "camelCase")] +pub struct VaultKmsConfig { + /// Vault server endpoint (e.g. `https://vault.example.com:8200`). + pub endpoint: String, + + /// Vault KV2 engine mount path (default: `kv`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub engine: Option, + + /// Vault namespace (Enterprise feature). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub namespace: Option, + + /// Key prefix inside the engine. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prefix: Option, + + /// Authentication method. Defaults to `token` when not set. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auth_type: Option, + + /// AppRole authentication settings. Only used when `authType: approle`. + /// The actual `role_id` and `secret_id` values live in the KMS Secret + /// under keys `vault-approle-id` and `vault-approle-secret`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub app_role: Option, + + /// Skip TLS certificate verification for Vault connection. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tls_skip_verify: Option, + + /// Enable custom TLS certificates for the Vault connection. + /// When `true`, the operator mounts TLS certificate files from the KMS Secret + /// and configures the corresponding environment variables. + /// The Secret must contain: `vault-ca-cert`, `vault-client-cert`, `vault-client-key`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub custom_certificates: Option, +} + +/// Vault AppRole authentication settings. +/// +/// Sensitive credentials (`role_id`, `secret_id`) are NOT stored here. +/// They must be placed in the KMS Secret referenced by `EncryptionConfig::kms_secret` +/// under keys `vault-approle-id` and `vault-approle-secret`. +/// +/// NOTE: The rustfs-kms `VaultAuthMethod::AppRole` type exists, but the +/// Vault backend does **not** implement it yet. These fields are provided +/// so the CRD/UI is ready when the backend adds support. +#[derive(Deserialize, Serialize, Clone, Debug, KubeSchema, Default)] +#[serde(rename_all = "camelCase")] +pub struct VaultAppRoleConfig { + /// Engine mount path for AppRole auth (e.g. `approle`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub engine: Option, + + /// Retry interval in seconds for AppRole login attempts (default: 10). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub retry_seconds: Option, +} + +/// Local file-based KMS configuration. +/// +/// Maps to `LocalConfig` in the `rustfs-kms` crate. +/// Keys are stored as JSON files in the specified directory. +#[derive(Deserialize, Serialize, Clone, Debug, KubeSchema, Default)] +#[serde(rename_all = "camelCase")] +pub struct LocalKmsConfig { + /// Directory for key files inside the container (default: `/data/kms-keys`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub key_directory: Option, + + /// Master key identifier (default: `default-master-key`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub master_key_id: Option, +} + +/// Encryption / KMS configuration for a Tenant. +/// +/// When enabled, the operator injects KMS environment variables and mounts +/// the referenced Secret into all RustFS pods so that the in-process +/// `rustfs-kms` library picks them up on startup. +/// +/// Example YAML: +/// ```yaml +/// spec: +/// encryption: +/// enabled: true +/// backend: vault +/// vault: +/// endpoint: "https://vault.example.com:8200" +/// engine: "kv" +/// namespace: "tenant1" +/// prefix: "rustfs" +/// customCertificates: true +/// kmsSecret: +/// name: "my-tenant-kms-secret" +/// ``` +/// +/// The referenced Secret must contain backend-specific keys: +/// +/// **Vault backend (Token auth):** +/// - `vault-token` (required): Vault authentication token +/// +/// **Vault backend (AppRole auth):** +/// - `vault-approle-id` (required): AppRole role ID +/// - `vault-approle-secret` (required): AppRole secret ID +/// +/// **Vault TLS (when `customCertificates: true`):** +/// - `vault-ca-cert`: PEM-encoded CA certificate +/// - `vault-client-cert`: PEM-encoded client certificate for mTLS +/// - `vault-client-key`: PEM-encoded client private key for mTLS +/// +/// **Local backend:** +/// No secret keys required (keys are stored on disk). +#[derive(Deserialize, Serialize, Clone, Debug, KubeSchema, Default)] +#[serde(rename_all = "camelCase")] +pub struct EncryptionConfig { + /// Enable server-side encryption. When `false`, all other fields are ignored. + #[serde(default)] + pub enabled: bool, + + /// KMS backend type: `local` or `vault`. + #[serde(default)] + pub backend: KmsBackendType, + + /// Vault-specific settings (required when `backend: vault`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub vault: Option, + + /// Local file-based settings (optional when `backend: local`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub local: Option, + + /// Reference to a Secret containing sensitive KMS credentials + /// (Vault token or AppRole credentials, TLS certificates). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub kms_secret: Option, + + /// Interval in seconds for KMS health-check pings (default: disabled). + /// When set, the operator stores the value; the in-process KMS library + /// picks it up from `RUSTFS_KMS_PING_SECONDS`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ping_seconds: Option, +} + +/// Pod SecurityContext overrides. +/// +/// Since RustFS KMS runs in-process (no separate sidecar like MinIO KES), +/// these values override the default Pod SecurityContext +/// (runAsUser/runAsGroup/fsGroup = 10001) for all RustFS pods in the Tenant. +#[derive(Deserialize, Serialize, Clone, Debug, KubeSchema, Default)] +#[serde(rename_all = "camelCase")] +pub struct PodSecurityContextOverride { + /// UID to run the container process as. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub run_as_user: Option, + + /// GID to run the container process as. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub run_as_group: Option, + + /// GID applied to all volumes mounted in the Pod. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fs_group: Option, + + /// Enforce non-root execution (default: true). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub run_as_non_root: Option, +} diff --git a/src/types/v1alpha1/tenant.rs b/src/types/v1alpha1/tenant.rs index 981c15d..3ea8ffb 100755 --- a/src/types/v1alpha1/tenant.rs +++ b/src/types/v1alpha1/tenant.rs @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +use crate::types::v1alpha1::encryption::{EncryptionConfig, PodSecurityContextOverride}; use crate::types::v1alpha1::k8s; use crate::types::v1alpha1::logging::LoggingConfig; use crate::types::v1alpha1::pool::Pool; @@ -140,6 +141,17 @@ pub struct TenantSpec { /// For production use, always configure credentials via Secret or environment variables. #[serde(default, skip_serializing_if = "Option::is_none")] pub creds_secret: Option, + + /// Encryption / KMS configuration for server-side encryption. + /// When enabled, the operator injects KMS environment variables and mounts + /// secrets into RustFS pods so the in-process `rustfs-kms` library is configured. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub encryption: Option, + + /// Override the default Pod SecurityContext (runAsUser/runAsGroup/fsGroup = 10001). + /// Applies to all RustFS pods in this Tenant. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub security_context: Option, } impl Tenant { diff --git a/src/types/v1alpha1/tenant/workloads.rs b/src/types/v1alpha1/tenant/workloads.rs index f26f963..a5db7dd 100755 --- a/src/types/v1alpha1/tenant/workloads.rs +++ b/src/types/v1alpha1/tenant/workloads.rs @@ -14,6 +14,7 @@ use super::Tenant; use crate::types; +use crate::types::v1alpha1::encryption::{KmsBackendType, VaultAuthType}; use crate::types::v1alpha1::pool::Pool; use k8s_openapi::api::apps::v1; use k8s_openapi::api::core::v1 as corev1; @@ -24,6 +25,9 @@ const DEFAULT_RUN_AS_USER: i64 = 10001; const DEFAULT_RUN_AS_GROUP: i64 = 10001; const DEFAULT_FS_GROUP: i64 = 10001; +const KMS_CERT_VOLUME_NAME: &str = "kms-tls"; +const KMS_CERT_MOUNT_PATH: &str = "/etc/rustfs/kms/tls"; + fn volume_claim_template_name(shard: i32) -> String { format!("{VOLUME_CLAIM_TEMPLATE_PREFIX}-{shard}") } @@ -215,6 +219,245 @@ impl Tenant { }) } + /// Build KMS-related environment variables, pod volumes and container volume mounts + /// based on `spec.encryption`. + /// + /// Returns `(env_vars, pod_volumes, volume_mounts)`. + fn configure_kms( + &self, + ) -> ( + Vec, + Vec, + Vec, + ) { + let Some(ref enc) = self.spec.encryption else { + return (vec![], vec![], vec![]); + }; + if !enc.enabled { + return (vec![], vec![], vec![]); + } + + let mut env = Vec::new(); + let mut volumes = Vec::new(); + let mut mounts = Vec::new(); + + env.push(corev1::EnvVar { + name: "RUSTFS_KMS_BACKEND".to_owned(), + value: Some(enc.backend.to_string()), + ..Default::default() + }); + env.push(corev1::EnvVar { + name: "RUSTFS_KMS_AUTO_START".to_owned(), + value: Some("true".to_owned()), + ..Default::default() + }); + + match enc.backend { + KmsBackendType::Vault => { + if let Some(ref vault) = enc.vault { + env.push(corev1::EnvVar { + name: "RUSTFS_KMS_VAULT_ENDPOINT".to_owned(), + value: Some(vault.endpoint.clone()), + ..Default::default() + }); + if let Some(ref engine) = vault.engine { + env.push(corev1::EnvVar { + name: "RUSTFS_KMS_VAULT_ENGINE".to_owned(), + value: Some(engine.clone()), + ..Default::default() + }); + } + if let Some(ref ns) = vault.namespace { + env.push(corev1::EnvVar { + name: "RUSTFS_KMS_VAULT_NAMESPACE".to_owned(), + value: Some(ns.clone()), + ..Default::default() + }); + } + if let Some(ref prefix) = vault.prefix { + env.push(corev1::EnvVar { + name: "RUSTFS_KMS_VAULT_PREFIX".to_owned(), + value: Some(prefix.clone()), + ..Default::default() + }); + } + if vault.tls_skip_verify == Some(true) { + env.push(corev1::EnvVar { + name: "RUSTFS_KMS_VAULT_TLS_SKIP_VERIFY".to_owned(), + value: Some("true".to_owned()), + ..Default::default() + }); + } + } + + let auth_type = enc + .vault + .as_ref() + .and_then(|v| v.auth_type.as_ref()) + .cloned() + .unwrap_or_default(); + let is_approle = auth_type == VaultAuthType::Approle; + + if is_approle { + env.push(corev1::EnvVar { + name: "RUSTFS_KMS_VAULT_AUTH_TYPE".to_owned(), + value: Some("approle".to_owned()), + ..Default::default() + }); + + if let Some(ar) = enc.vault.as_ref().and_then(|v| v.app_role.as_ref()) { + if let Some(ref engine) = ar.engine { + env.push(corev1::EnvVar { + name: "RUSTFS_KMS_VAULT_APPROLE_ENGINE".to_owned(), + value: Some(engine.clone()), + ..Default::default() + }); + } + if let Some(retry) = ar.retry_seconds { + env.push(corev1::EnvVar { + name: "RUSTFS_KMS_VAULT_APPROLE_RETRY_SECONDS".to_owned(), + value: Some(retry.to_string()), + ..Default::default() + }); + } + } + } + + if let Some(ref secret_ref) = enc.kms_secret + && !secret_ref.name.is_empty() + { + if is_approle { + env.push(corev1::EnvVar { + name: "RUSTFS_KMS_VAULT_APPROLE_ID".to_owned(), + value_from: Some(corev1::EnvVarSource { + secret_key_ref: Some(corev1::SecretKeySelector { + name: secret_ref.name.clone(), + key: "vault-approle-id".to_string(), + optional: Some(false), + }), + ..Default::default() + }), + ..Default::default() + }); + env.push(corev1::EnvVar { + name: "RUSTFS_KMS_VAULT_APPROLE_SECRET".to_owned(), + value_from: Some(corev1::EnvVarSource { + secret_key_ref: Some(corev1::SecretKeySelector { + name: secret_ref.name.clone(), + key: "vault-approle-secret".to_string(), + optional: Some(false), + }), + ..Default::default() + }), + ..Default::default() + }); + } else { + env.push(corev1::EnvVar { + name: "RUSTFS_KMS_VAULT_TOKEN".to_owned(), + value_from: Some(corev1::EnvVarSource { + secret_key_ref: Some(corev1::SecretKeySelector { + name: secret_ref.name.clone(), + key: "vault-token".to_string(), + optional: Some(false), + }), + ..Default::default() + }), + ..Default::default() + }); + } + + // Only mount TLS certificates when explicitly enabled + let custom_certs = enc + .vault + .as_ref() + .and_then(|v| v.custom_certificates) + .unwrap_or(false); + + if custom_certs { + volumes.push(corev1::Volume { + name: KMS_CERT_VOLUME_NAME.to_string(), + secret: Some(corev1::SecretVolumeSource { + secret_name: Some(secret_ref.name.clone()), + items: Some(vec![ + corev1::KeyToPath { + key: "vault-ca-cert".to_string(), + path: "ca.crt".to_string(), + ..Default::default() + }, + corev1::KeyToPath { + key: "vault-client-cert".to_string(), + path: "client.crt".to_string(), + ..Default::default() + }, + corev1::KeyToPath { + key: "vault-client-key".to_string(), + path: "client.key".to_string(), + ..Default::default() + }, + ]), + optional: Some(true), + ..Default::default() + }), + ..Default::default() + }); + mounts.push(corev1::VolumeMount { + name: KMS_CERT_VOLUME_NAME.to_string(), + mount_path: KMS_CERT_MOUNT_PATH.to_string(), + read_only: Some(true), + ..Default::default() + }); + + env.push(corev1::EnvVar { + name: "RUSTFS_KMS_VAULT_TLS_CA".to_owned(), + value: Some(format!("{KMS_CERT_MOUNT_PATH}/ca.crt")), + ..Default::default() + }); + env.push(corev1::EnvVar { + name: "RUSTFS_KMS_VAULT_TLS_CERT".to_owned(), + value: Some(format!("{KMS_CERT_MOUNT_PATH}/client.crt")), + ..Default::default() + }); + env.push(corev1::EnvVar { + name: "RUSTFS_KMS_VAULT_TLS_KEY".to_owned(), + value: Some(format!("{KMS_CERT_MOUNT_PATH}/client.key")), + ..Default::default() + }); + } + } + } + KmsBackendType::Local => { + let local_cfg = enc.local.as_ref(); + let key_dir = local_cfg + .and_then(|l| l.key_directory.as_deref()) + .unwrap_or("/data/kms-keys"); + let master_key_id = local_cfg + .and_then(|l| l.master_key_id.as_deref()) + .unwrap_or("default-master-key"); + + env.push(corev1::EnvVar { + name: "RUSTFS_KMS_LOCAL_KEY_DIR".to_owned(), + value: Some(key_dir.to_string()), + ..Default::default() + }); + env.push(corev1::EnvVar { + name: "RUSTFS_KMS_LOCAL_MASTER_KEY_ID".to_owned(), + value: Some(master_key_id.to_string()), + ..Default::default() + }); + } + } + + if let Some(ping) = enc.ping_seconds { + env.push(corev1::EnvVar { + name: "RUSTFS_KMS_PING_SECONDS".to_owned(), + value: Some(ping.to_string()), + ..Default::default() + }); + } + + (env, volumes, mounts) + } + pub fn new_statefulset(&self, pool: &Pool) -> Result { let labels = self.pool_labels(pool); let selector_labels = self.pool_selector_labels(pool); @@ -306,18 +549,33 @@ impl Tenant { // Configure logging based on tenant.spec.logging // Default: stdout (cloud-native best practice) - let (pod_volumes, mut log_volume_mounts) = self.configure_logging()?; + let (mut pod_volumes, mut log_volume_mounts) = self.configure_logging()?; // Merge log volume mounts with data volume mounts volume_mounts.append(&mut log_volume_mounts); - // Enforce non-root execution and make mounted volumes writable by RustFS user - // This aligns with Pod Security Standards (restricted tier) + // Configure KMS / encryption environment variables and volumes + let (kms_env, mut kms_volumes, mut kms_mounts) = self.configure_kms(); + env_vars.extend(kms_env); + pod_volumes.append(&mut kms_volumes); + volume_mounts.append(&mut kms_mounts); + + // Enforce non-root execution and make mounted volumes writable by RustFS user. + // If spec.securityContext overrides are set, use those values instead. + let sc = self.spec.security_context.as_ref(); + let pod_security_context = Some(corev1::PodSecurityContext { - run_as_user: Some(DEFAULT_RUN_AS_USER), - run_as_group: Some(DEFAULT_RUN_AS_GROUP), - fs_group: Some(DEFAULT_FS_GROUP), + run_as_user: Some( + sc.and_then(|s| s.run_as_user) + .unwrap_or(DEFAULT_RUN_AS_USER), + ), + run_as_group: Some( + sc.and_then(|s| s.run_as_group) + .unwrap_or(DEFAULT_RUN_AS_GROUP), + ), + fs_group: Some(sc.and_then(|s| s.fs_group).unwrap_or(DEFAULT_FS_GROUP)), fs_group_change_policy: Some("OnRootMismatch".to_string()), + run_as_non_root: sc.and_then(|s| s.run_as_non_root), ..Default::default() }); @@ -531,6 +789,13 @@ impl Tenant { return Ok(true); } + // Check pod security context (runAsUser, runAsGroup, fsGroup, runAsNonRoot) + if serde_json::to_value(&existing_pod_spec.security_context)? + != serde_json::to_value(&desired_pod_spec.security_context)? + { + return Ok(true); + } + // Compare container specs if existing_pod_spec.containers.is_empty() || desired_pod_spec.containers.is_empty() { return Err(types::error::Error::InternalError {