diff --git a/internal/cloud/admin-secrets-readmodel.ts b/internal/cloud/admin-secrets-readmodel.ts index 319f2dc8..9f7ca07e 100644 --- a/internal/cloud/admin-secrets-readmodel.ts +++ b/internal/cloud/admin-secrets-readmodel.ts @@ -10,6 +10,8 @@ import { fileURLToPath } from "node:url"; import { sendJson } from "./server-http-utils.ts"; const CONTRACT_VERSION = "hwlab-admin-secrets-v1"; +const PLAN_PERMISSION = "admin.secrets.plan"; +const MAX_PLAN_BODY_BYTES = 32 * 1024; const DEFAULT_CONFIG_RELATIVE_PATH = "config/hwlab-v03/secrets.yaml"; const DEFAULT_TARGET = Object.freeze({ node: "JD01", @@ -29,12 +31,25 @@ const STATUS_PRIORITY = Object.freeze([ "Ready", "Unknown" ]); +const PLAN_FAILURE_PRIORITY = Object.freeze([ + "rbac_denied", + "leak_blocked", + "store_scope_violation", + "store_unavailable", + "target_collision", + "sync_risk", + "validation_failed" +]); +const KUBERNETES_NAME_RE = /^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/u; +const REF_PATH_RE = /^[A-Za-z0-9._/-]+$/u; +const REF_KEY_RE = /^[A-Za-z0-9._-]+$/u; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); export async function handleAdminSecretsHttp(request, response, url, options = {}) { try { - const auth = await authenticateAdmin(request, options); + const planRoute = request.method === "POST" && url.pathname === "/v1/admin/secrets/external-secrets/plan"; + const auth = await authenticateAdmin(request, options, { requireAdmin: !planRoute }); if (!auth.ok) return sendJson(response, numericStatus(auth.httpStatus) ?? numericStatus(auth.status) ?? numericStatus(auth.statusCode) ?? 401, auth); const readModel = buildAdminSecretsReadModel({ env: options.env ?? process.env }); @@ -46,6 +61,13 @@ export async function handleAdminSecretsHttp(request, response, url, options = { if (request.method === "GET" && url.pathname === "/v1/admin/secrets/external-secrets") { return sendJson(response, 200, externalSecretsListPayload(readModel.payload, url)); } + if (planRoute) { + const permission = adminSecretsPlanPermission(auth.actor); + if (!permission.allowed) return sendJson(response, 403, planPermissionDeniedPayload(readModel.payload, permission)); + const body = await readJsonRequestBody(request, MAX_PLAN_BODY_BYTES); + const plan = buildAdminSecretsPlan(readModel.payload, body, auth); + return sendJson(response, plan.httpStatus ?? 200, plan); + } const detailMatch = url.pathname.match(/^\/v1\/admin\/secrets\/external-secrets\/([^/]+)\/([^/]+)$/u); if (request.method === "GET" && detailMatch) { const detail = externalSecretDetailPayload(readModel.payload, decodeURIComponent(detailMatch[1]), decodeURIComponent(detailMatch[2])); @@ -83,6 +105,13 @@ export function buildAdminSecretsReadModel({ env = process.env } = {}) { secretMaterialStored: false, generatedAt, target, + permissions: { + [PLAN_PERMISSION]: true, + plan: true, + apply: false, + valuesPrinted: false, + secretMaterialStored: false + }, source: { kind: "yaml", path: sourceRelativePath, @@ -142,6 +171,392 @@ export function buildAdminSecretsReadModel({ env = process.env } = {}) { }; } +export function buildAdminSecretsPlan(payload, requestBody = {}, auth = {}) { + const generatedAt = new Date().toISOString(); + const permission = adminSecretsPlanPermission(auth.actor); + if (!permission.allowed) return planPermissionDeniedPayload(payload, permission, generatedAt); + + const leak = findPlanSecretMaterial(requestBody); + if (leak) { + return { + ...basePlanPayload(payload, generatedAt, permission), + ok: false, + status: "failed", + failureKind: "leak_blocked", + checks: [planCheck("leak-block", "Secret material blocked", "failed", "leak_blocked", "Plan request included a field that can carry secret material.")], + risks: [planRisk("leak_blocked", "blocked", "Plan request was rejected before planning because it contained a disallowed secret material field.")], + error: { + code: "SecretLeakBlocked", + message: "ExternalSecret plan requests must not include secret values.", + layer: "admin-secrets-plan", + retryable: false, + field: leak.path, + valuesPrinted: false + }, + httpStatus: 422 + }; + } + + const input = record(requestBody) ?? {}; + const activeTarget = payload.target ?? DEFAULT_TARGET; + const targetInput = record(input.target) ?? {}; + const target = { + node: (text(targetInput.node) || text(activeTarget.node) || DEFAULT_TARGET.node).toUpperCase(), + lane: text(targetInput.lane) || text(activeTarget.lane) || DEFAULT_TARGET.lane, + namespace: text(targetInput.namespace) || text(targetInput.runtimeNamespace) || text(activeTarget.runtimeNamespace) || DEFAULT_TARGET.runtimeNamespace + }; + const mode = normalizePlanMode(input.mode); + const externalSecretName = text(input.externalSecretName) || text(input.name); + const targetSecretName = text(input.targetSecretName) || externalSecretName; + const refreshInterval = text(input.refreshInterval) || "1m"; + const store = normalizePlanStore(input.store, payload, target); + const data = normalizePlanData(input.data); + const consumerRollout = normalizePlanConsumerRollout(input.consumerRollout); + const existingExternalSecret = payload.externalSecrets.find((item) => item.namespace === target.namespace && item.name === externalSecretName) ?? null; + const targetSecretCollision = payload.externalSecrets.find((item) => item.namespace === target.namespace && text(record(item.targetSecret)?.name) === targetSecretName) ?? null; + const affectedConsumers = affectedPlanConsumers(existingExternalSecret ?? targetSecretCollision); + const checks = []; + const risks = []; + const failures = new Set(); + + if (!externalSecretName || !KUBERNETES_NAME_RE.test(externalSecretName) || externalSecretName.length > 63) { + checks.push(planCheck("external-secret-name", "ExternalSecret name", "failed", "validation_failed", "Name must be a Kubernetes DNS label with at most 63 characters.")); + failures.add("validation_failed"); + } else { + checks.push(planCheck("external-secret-name", "ExternalSecret name", "passed", "ok", externalSecretName)); + } + + if (!targetSecretName || !KUBERNETES_NAME_RE.test(targetSecretName) || targetSecretName.length > 63) { + checks.push(planCheck("target-secret-name", "Target Secret name", "failed", "validation_failed", "Target Secret name must be a Kubernetes DNS label with at most 63 characters.")); + failures.add("validation_failed"); + } else { + checks.push(planCheck("target-secret-name", "Target Secret name", "passed", "ok", targetSecretName)); + } + + if (target.node !== text(activeTarget.node).toUpperCase() || target.lane !== text(activeTarget.lane) || target.namespace !== text(activeTarget.runtimeNamespace)) { + checks.push(planCheck("active-target", "Active target", "failed", "validation_failed", "Plan target must match the API active target.")); + failures.add("validation_failed"); + } else { + checks.push(planCheck("active-target", "Active target", "passed", "ok", `${target.node}/${target.lane}/${target.namespace}`)); + } + + const availableStore = findPlanStore(payload, store); + if (store.kind === "SecretStore" && store.namespace !== target.namespace) { + checks.push(planCheck("store-scope", "Store namespace scope", "failed", "store_scope_violation", "Namespace-scoped SecretStore cannot plan for a different namespace.")); + risks.push(planRisk("store_scope_violation", "blocked", "Namespace-scoped Store is limited to the target namespace.")); + failures.add("store_scope_violation"); + } else { + checks.push(planCheck("store-scope", "Store namespace scope", "passed", "ok", store.kind === "ClusterSecretStore" ? `cluster store targets ${target.namespace}` : store.namespace)); + } + if (!availableStore) { + checks.push(planCheck("store-available", "Store availability", "failed", "store_unavailable", `${store.kind}/${store.name} was not found in the active read model.`)); + risks.push(planRisk("store_unavailable", "blocked", "Plan cannot confirm the requested Store from the active target.")); + failures.add("store_unavailable"); + } else { + checks.push(planCheck("store-available", "Store availability", "passed", "ok", `${store.kind}/${store.name}`)); + } + + if (!/^\d+[smhd]$/u.test(refreshInterval)) { + checks.push(planCheck("refresh-interval", "Refresh interval", "failed", "validation_failed", "Refresh interval must use s/m/h/d suffix.")); + failures.add("validation_failed"); + } else { + checks.push(planCheck("refresh-interval", "Refresh interval", "passed", "ok", refreshInterval)); + } + + for (const failure of validatePlanData(data, checks, risks)) failures.add(failure); + + if (mode === "create" && (existingExternalSecret || targetSecretCollision)) { + const collisionName = existingExternalSecret?.name ?? text(record(targetSecretCollision?.targetSecret)?.name) ?? targetSecretName; + checks.push(planCheck("target-collision", "Target collision", "failed", "target_collision", `${target.namespace}/${collisionName} already exists in the read model.`)); + risks.push(planRisk("target_collision", "blocked", "Create plan would collide with an existing ExternalSecret or target Secret.")); + failures.add("target_collision"); + } else { + checks.push(planCheck("target-collision", "Target collision", "passed", "ok", mode === "edit" ? "edit mode permits matching object" : "no collision detected")); + } + + checks.push(planCheck("dry-run", "Dry-run boundary", "passed", "ok", "No Kubernetes, Vault, ESO, or GitOps writes are performed.")); + checks.push(planCheck("secret-values", "Secret value boundary", "passed", "ok", "Plan request and response do not include secret values.")); + risks.push(planRisk("dry_run_only", "info", "This plan is read-only and cannot apply, rotate, or roll back a Secret.")); + if (consumerRollout.enabled && affectedConsumers.length === 0) risks.push(planRisk("consumer_rollout_marker", "info", "Consumer rollout marker is recorded as intent only; no consumer was resolved from the read model.")); + + const failureKind = firstFailureKind(failures); + const ok = !failureKind; + return { + ...basePlanPayload(payload, generatedAt, permission), + ok, + status: ok ? "planned" : "failed", + planId: `esp_${sha256Text(stableJson({ target, mode, externalSecretName, targetSecretName, store, data, refreshInterval, consumerRollout })).slice(0, 16)}`, + failureKind: failureKind || null, + checks, + risks, + plan: { + operation: mode === "edit" ? "edit-external-secret-dry-run" : "create-external-secret-dry-run", + mode, + dryRun: true, + writes: false, + target, + externalSecret: { + name: externalSecretName, + namespace: target.namespace, + refreshInterval + }, + targetSecret: { + name: targetSecretName, + namespace: target.namespace, + presence: targetSecretCollision ? "present" : "not-present", + valuesPrinted: false + }, + store, + data: data.map((item) => ({ + targetKey: item.targetKey, + sourceRef: item.sourceRef, + presence: "not-read-dry-run", + referenceFingerprint: item.referenceFingerprint, + valuesPrinted: false + })), + affectedConsumers, + consumerRollout, + valuesPrinted: false, + secretMaterialStored: false + } + }; +} + +function basePlanPayload(payload, generatedAt, permission) { + return { + contractVersion: CONTRACT_VERSION, + generatedAt, + target: payload.target, + permission, + valuesPrinted: false, + secretMaterialStored: false + }; +} + +function planPermissionDeniedPayload(payload, permission, generatedAt = new Date().toISOString()) { + return { + ...basePlanPayload(payload, generatedAt, permission), + ok: false, + status: "failed", + failureKind: "rbac_denied", + checks: [planCheck("permission", PLAN_PERMISSION, "failed", "rbac_denied", `${PLAN_PERMISSION} is required.`)], + risks: [planRisk("rbac_denied", "blocked", "Current actor is not allowed to plan ExternalSecret changes.")], + error: { + code: "admin_secrets_plan_required", + message: `${PLAN_PERMISSION} permission is required.`, + layer: "admin-secrets-plan", + retryable: false, + valuesPrinted: false + }, + httpStatus: 403 + }; +} + +function adminSecretsPlanPermission(actor) { + const role = text(actor?.role); + const permissions = permissionSetFromActor(actor); + if (role === "admin") return { id: PLAN_PERMISSION, allowed: true, source: "admin-role", valuesPrinted: false }; + if (permissions.has(PLAN_PERMISSION)) return { id: PLAN_PERMISSION, allowed: true, source: "actor-permission", valuesPrinted: false }; + return { id: PLAN_PERMISSION, allowed: false, source: role || "anonymous", valuesPrinted: false }; +} + +function permissionSetFromActor(actor) { + const output = new Set(); + for (const key of ["permissions", "capabilities"]) { + const value = actor?.[key]; + if (Array.isArray(value)) for (const item of value) if (text(item)) output.add(text(item)); + if (record(value)) for (const [name, status] of Object.entries(value)) if (status === true || status === "available" || status === "allowed") output.add(name); + } + return output; +} + +async function readJsonRequestBody(request, limitBytes) { + let raw = ""; + for await (const chunk of request) { + raw += Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk ?? ""); + if (Buffer.byteLength(raw, "utf8") > limitBytes) throw Object.assign(new Error("Plan request body is too large"), { httpStatus: 413, code: "plan_payload_too_large" }); + } + if (!raw.trim()) return {}; + try { + return JSON.parse(raw); + } catch (error) { + throw Object.assign(new Error("Plan request body must be valid JSON"), { httpStatus: 400, code: "plan_payload_parse_error", details: { reason: error.message } }); + } +} + +function normalizePlanMode(value) { + const mode = text(value).toLowerCase(); + return mode === "edit" ? "edit" : "create"; +} + +function normalizePlanStore(value, payload, target) { + const input = record(value) ?? {}; + const firstStore = record(payload.summary?.stores?.[0]) ?? {}; + const kind = text(input.kind) === "SecretStore" ? "SecretStore" : "ClusterSecretStore"; + const namespace = kind === "SecretStore" ? text(input.namespace) || target.namespace : text(input.namespace) || text(firstStore.namespace) || text(payload.target?.infraNamespace) || "platform-infra"; + return { + kind, + name: text(input.name) || text(firstStore.name) || "hwlab-secret-plane-vault-cluster", + namespace, + scope: kind === "SecretStore" ? "namespace" : "cluster", + valuesPrinted: false + }; +} + +function findPlanStore(payload, store) { + const stores = Array.isArray(payload.summary?.stores) ? payload.summary.stores : []; + return stores.find((item) => { + if (text(item.kind) !== store.kind) return false; + if (text(item.name) !== store.name) return false; + if (store.kind === "SecretStore" && text(item.namespace) !== store.namespace) return false; + return true; + }) ?? null; +} + +function normalizePlanData(value) { + const items = Array.isArray(value) ? value : [value].filter(Boolean); + return items.map((entry, index) => { + const item = record(entry) ?? {}; + const sourceRef = record(item.sourceRef) ?? record(item.remoteRef) ?? {}; + const pathValue = text(sourceRef.path) || text(item.path) || text(item.remotePath); + const property = text(sourceRef.property) || text(item.property); + const version = text(sourceRef.version) || text(item.version) || null; + const targetKey = text(item.targetKey); + return { + targetKey, + sourceRef: { + path: pathValue, + property, + version + }, + referenceFingerprint: pathValue || property ? `sha256:${sha256Text(`${pathValue}:${property}:${version ?? ""}`).slice(0, 16)}` : null, + index + }; + }); +} + +function normalizePlanConsumerRollout(value) { + const input = record(value) ?? {}; + const enabled = input.enabled === true || text(input.intent) !== ""; + const intent = text(input.intent) || (enabled ? "rollout-marker" : "none"); + return { + enabled, + intent, + marker: enabled ? "plan-only" : "none", + valuesPrinted: false + }; +} + +function validatePlanData(data, checks, risks) { + const failures = new Set(); + if (data.length === 0) { + checks.push(planCheck("source-ref", "Source reference", "failed", "sync_risk", "At least one sourceRef mapping is required.")); + risks.push(planRisk("sync_risk", "blocked", "ESO sync cannot be planned without sourceRef path/property and target key.")); + failures.add("sync_risk"); + return failures; + } + for (const item of data) { + const label = `data[${item.index}]`; + if (!item.targetKey || !REF_KEY_RE.test(item.targetKey)) { + checks.push(planCheck(`${label}.targetKey`, "Target key", "failed", "validation_failed", "Target key must be non-empty and contain only letters, digits, dot, dash, or underscore.")); + failures.add("validation_failed"); + } + if (!item.sourceRef.path || !REF_PATH_RE.test(item.sourceRef.path)) { + checks.push(planCheck(`${label}.sourceRef.path`, "Source path", "failed", "sync_risk", "sourceRef.path is required and must be a safe remote reference path.")); + risks.push(planRisk("sync_risk", "blocked", "Remote source path is missing or malformed.")); + failures.add("sync_risk"); + } + if (!item.sourceRef.property || !REF_KEY_RE.test(item.sourceRef.property)) { + checks.push(planCheck(`${label}.sourceRef.property`, "Source property", "failed", "sync_risk", "sourceRef.property is required and must be a safe property name.")); + risks.push(planRisk("sync_risk", "blocked", "Remote source property is missing or malformed.")); + failures.add("sync_risk"); + } + if (item.sourceRef.version && !REF_KEY_RE.test(item.sourceRef.version)) { + checks.push(planCheck(`${label}.sourceRef.version`, "Source version", "failed", "validation_failed", "sourceRef.version contains unsupported characters.")); + failures.add("validation_failed"); + } + } + if (failures.size === 0) checks.push(planCheck("source-ref", "Source reference", "passed", "ok", `${data.length} mapping(s)`)); + return failures; +} + +function affectedPlanConsumers(secret) { + if (!secret || !Array.isArray(secret.consumers)) return []; + return secret.consumers.map((item) => ({ + serviceId: text(item.serviceId) || "-", + kind: text(item.kind) || "-", + injectAs: text(item.injectAs) || "-", + envName: text(item.envName) || null, + rollout: text(item.rollout) || "Unknown", + revision: text(item.revision) || null, + valuesPrinted: false + })); +} + +function firstFailureKind(failures) { + for (const kind of PLAN_FAILURE_PRIORITY) if (failures.has(kind)) return kind; + return ""; +} + +function planCheck(id, label, status, code, detail) { + return { id, label, status, code, detail, valuesPrinted: false }; +} + +function planRisk(code, severity, message) { + return { code, severity, message, valuesPrinted: false }; +} + +function findPlanSecretMaterial(value, pathParts = []) { + if (Array.isArray(value)) { + for (let index = 0; index < value.length; index += 1) { + const found = findPlanSecretMaterial(value[index], [...pathParts, String(index)]); + if (found) return found; + } + return null; + } + if (!value || typeof value !== "object") { + if (typeof value === "string" && looksLikeSecretMaterial(value) && !isPlanDescriptorPath(pathParts)) return { path: pathParts.join(".") || "$" }; + return null; + } + for (const [key, item] of Object.entries(value)) { + const nextPath = [...pathParts, key]; + if (isSecretValueKey(key) && typeof item === "string" && item.trim() && !isPlanDescriptorPath(nextPath)) return { path: nextPath.join(".") }; + const found = findPlanSecretMaterial(item, nextPath); + if (found) return found; + } + return null; +} + +function isSecretValueKey(key) { + return /^(value|raw|secretValue|decoded|base64|authorization|cookie|token|apiKey|password)$/u.test(key); +} + +function isPlanDescriptorPath(pathParts) { + const key = pathParts[pathParts.length - 1]; + if (["targetKey", "property", "version", "path", "remotePath", "externalSecretName", "targetSecretName", "name", "namespace", "kind", "intent", "mode", "node", "lane", "refreshInterval"].includes(key)) return true; + const joined = pathParts.join("."); + return /\.(sourceRef|remoteRef)\.(path|property|version)$/u.test(joined); +} + +function looksLikeSecretMaterial(value) { + const item = text(value); + if (!item) return false; + if (/-----BEGIN [A-Z ]*PRIVATE KEY-----/u.test(item)) return true; + if (/^(hsk|ghp|github_pat|sk|xox[baprs])-[-A-Za-z0-9_]{16,}$/u.test(item)) return true; + if (/^[A-Za-z0-9+/]{48,}={0,2}$/u.test(item) && !item.includes("/")) return true; + return false; +} + +function stableJson(value) { + return JSON.stringify(sortJson(value)); +} + +function sortJson(value) { + if (Array.isArray(value)) return value.map(sortJson); + if (value && typeof value === "object") return Object.fromEntries(Object.keys(value).sort().map((key) => [key, sortJson(value[key])])); + return value; +} + function externalSecretsListPayload(payload, url) { const node = text(url.searchParams.get("node")).toUpperCase(); const lane = text(url.searchParams.get("lane")).toLowerCase(); @@ -199,12 +614,12 @@ function eventsPayload(payload, url) { }; } -async function authenticateAdmin(request, options) { +async function authenticateAdmin(request, options, { requireAdmin = true } = {}) { const controller = options.accessController; if (!controller || typeof controller.authenticate !== "function") return secretsError("access_controller_unavailable", "HWLAB access controller is not available", 503); const auth = await controller.authenticate(request, { required: true }); if (!auth.ok) return auth; - if (auth.actor?.role !== "admin") return secretsError("admin_required", "Only admin users can read admin secrets status", 403); + if (requireAdmin && auth.actor?.role !== "admin") return secretsError("admin_required", "Only admin users can read admin secrets status", 403); return auth; } diff --git a/web/hwlab-cloud-web/src/api/adminSecrets.ts b/web/hwlab-cloud-web/src/api/adminSecrets.ts index a1dae66f..5c5a9283 100644 --- a/web/hwlab-cloud-web/src/api/adminSecrets.ts +++ b/web/hwlab-cloud-web/src/api/adminSecrets.ts @@ -1,11 +1,12 @@ import { fetchJson } from "./client"; -import type { AdminSecretsDetailResponse, AdminSecretsEventsResponse, AdminSecretsListResponse, AdminSecretsSummaryResponse, ApiResult } from "@/types"; +import type { AdminSecretsDetailResponse, AdminSecretsEventsResponse, AdminSecretsListResponse, AdminSecretsPlanRequest, AdminSecretsPlanResponse, AdminSecretsSummaryResponse, ApiResult } from "@/types"; export const adminSecretsAPI = { summary: (): Promise> => fetchJson("/v1/admin/secrets/summary", { timeoutMs: 12000, timeoutName: "admin secrets summary" }), externalSecrets: (params: { node?: string; lane?: string; namespace?: string } = {}): Promise> => fetchJson(`/v1/admin/secrets/external-secrets${query(params)}`, { timeoutMs: 12000, timeoutName: "admin secrets list" }), detail: (namespace: string, name: string): Promise> => fetchJson(`/v1/admin/secrets/external-secrets/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}`, { timeoutMs: 12000, timeoutName: "admin secrets detail" }), - events: (params: { namespace?: string; limit?: number } = {}): Promise> => fetchJson(`/v1/admin/secrets/events${query(params)}`, { timeoutMs: 12000, timeoutName: "admin secrets events" }) + events: (params: { namespace?: string; limit?: number } = {}): Promise> => fetchJson(`/v1/admin/secrets/events${query(params)}`, { timeoutMs: 12000, timeoutName: "admin secrets events" }), + plan: (payload: AdminSecretsPlanRequest): Promise> => fetchJson("/v1/admin/secrets/external-secrets/plan", { method: "POST", body: JSON.stringify(payload), timeoutMs: 12000, timeoutName: "admin secrets plan" }) }; function query(params: Record): string { diff --git a/web/hwlab-cloud-web/src/stores/admin-secrets-view.ts b/web/hwlab-cloud-web/src/stores/admin-secrets-view.ts index ae940d18..7e3beea5 100644 --- a/web/hwlab-cloud-web/src/stores/admin-secrets-view.ts +++ b/web/hwlab-cloud-web/src/stores/admin-secrets-view.ts @@ -20,6 +20,7 @@ export interface AdminSecretsSummaryView { generatedAt: string; valuesPrinted: boolean; secretMaterialStored: boolean; + permissions: { plan: boolean; apply: boolean; valuesPrinted: boolean }; target: AdminSecretsTargetView; sourcePath: string; sourcePresent: boolean; @@ -88,12 +89,18 @@ export function normalizeAdminSecretsSummary(payload: AdminSecretsSummaryRespons const consumerRollout = asRecord(summary?.consumerRollout); const evidence = asRecord(summary?.latestEvidence); const source = asRecord(payload?.source); + const permissions = asRecord(payload?.permissions); return { contractVersion: nonEmptyString(payload?.contractVersion) ?? "hwlab-admin-secrets-v1", status: nonEmptyString(summary?.status) ?? nonEmptyString(payload?.status) ?? "Unknown", generatedAt: nonEmptyString(payload?.generatedAt) ?? "", valuesPrinted: payload?.valuesPrinted === false ? false : true, secretMaterialStored: payload?.secretMaterialStored === false ? false : true, + permissions: { + plan: permissions?.plan === true || permissions?.["admin.secrets.plan"] === true, + apply: permissions?.apply === true, + valuesPrinted: permissions?.valuesPrinted === true + }, target: normalizeTarget(payload?.target), sourcePath: nonEmptyString(source?.path) ?? "-", sourcePresent: source?.present === true, diff --git a/web/hwlab-cloud-web/src/styles/workbench.css b/web/hwlab-cloud-web/src/styles/workbench.css index 1c32dace..7dc5658f 100644 --- a/web/hwlab-cloud-web/src/styles/workbench.css +++ b/web/hwlab-cloud-web/src/styles/workbench.css @@ -2477,6 +2477,14 @@ color: #991b1b; } +.status-pill[data-status="dry-run"], +.status-pill[data-status="planned"], +.status-pill[data-status="pending"] { + border-color: #bae6fd; + background: #e0f2fe; + color: #075985; +} + .field-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); @@ -2532,6 +2540,7 @@ .form-grid select, .admin-form input, +.admin-form select, .admin-form textarea { min-width: 0; border: 1px solid #cbd5e1; @@ -2713,6 +2722,126 @@ font-size: 12px; } +.secrets-plan-panel { + border-color: #bae6fd; + background: #f8fcff; +} + +.secrets-plan-steps { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} + +.secrets-plan-steps li { + display: grid; + min-width: 0; + grid-template-columns: auto minmax(0, 1fr); + gap: 7px; + align-items: center; + border: 1px solid #d8e1eb; + border-radius: 8px; + background: white; + padding: 8px 10px; + color: #334155; + font-size: 12px; + font-weight: 750; +} + +.secrets-plan-steps strong { + display: inline-grid; + width: 22px; + height: 22px; + place-items: center; + border-radius: 999px; + background: #0f766e; + color: white; + font-size: 11px; +} + +.secrets-plan-form { + gap: 12px; +} + +.secrets-plan-fieldset { + display: grid; + min-width: 0; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; + border: 1px solid #d8e1eb; + border-radius: 8px; + background: white; + margin: 0; + padding: 12px; +} + +.secrets-plan-fieldset legend { + padding: 0 4px; + color: #0f766e; + font-size: 12px; + font-weight: 800; +} + +.secrets-plan-fieldset input[readonly] { + background: #f8fafc; + color: #475569; +} + +.secrets-plan-note { + margin: 0; + color: #64748b; + font-size: 12px; +} + +.secrets-plan-toggle { + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + align-self: end; + border: 1px solid #d8e1eb; + border-radius: 8px; + background: #f8fafc; + padding: 9px 10px; +} + +.secrets-plan-toggle input { + width: 16px; + height: 16px; + margin: 0; +} + +.secrets-plan-result { + display: grid; + min-width: 0; + gap: 12px; + border: 1px solid #d8e1eb; + border-radius: 8px; + background: white; + padding: 12px; +} + +.secrets-plan-result h3 { + margin: 0; + color: #0f172a; + font-size: 14px; +} + +.secrets-plan-result-grid { + display: grid; + min-width: 0; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.secrets-plan-checks { + display: flex; + min-width: 0; + flex-wrap: wrap; + gap: 8px; +} + .secrets-event-list { display: grid; max-height: 560px; @@ -3496,7 +3625,12 @@ .performance-contract-strip, .performance-collection-strip, .performance-chart-grid, - .performance-analytics-grid { + .performance-analytics-grid, + .secrets-summary-grid, + .secrets-readiness-grid, + .secrets-detail-grid, + .secrets-plan-fieldset, + .secrets-plan-result-grid { grid-template-columns: 1fr; } @@ -3520,7 +3654,9 @@ .field-grid, .form-grid.two-col, - .compact-form-grid { + .compact-form-grid, + .secrets-fact-grid, + .secrets-plan-steps { grid-template-columns: 1fr; } } diff --git a/web/hwlab-cloud-web/src/types/index.ts b/web/hwlab-cloud-web/src/types/index.ts index bb07a49b..86f9f3cd 100644 --- a/web/hwlab-cloud-web/src/types/index.ts +++ b/web/hwlab-cloud-web/src/types/index.ts @@ -393,6 +393,52 @@ export interface AdminSecretsTargetKey { key?: string; presence?: string; finger export interface AdminSecretsRemoteRef { path?: string | null; property?: string | null; version?: string | null; fingerprint?: string | null; redacted?: boolean; [key: string]: unknown } export interface AdminSecretsDataMapping { targetKey?: string; remoteRef?: AdminSecretsRemoteRef; [key: string]: unknown } export interface AdminSecretsConsumer { serviceId?: string; kind?: string; injectAs?: string; envName?: string | null; status?: AdminSecretStatus; rollout?: string; revision?: string | null; valuesPrinted?: boolean; [key: string]: unknown } +export interface AdminSecretsPlanPermission { id?: string; allowed?: boolean; source?: string; valuesPrinted?: boolean; [key: string]: unknown } +export interface AdminSecretsPlanCheck { id?: string; label?: string; status?: string; code?: string; detail?: string; valuesPrinted?: boolean; [key: string]: unknown } +export interface AdminSecretsPlanRisk { code?: string; severity?: string; message?: string; valuesPrinted?: boolean; [key: string]: unknown } +export interface AdminSecretsPlanRequest { + mode?: "create" | "edit" | string; + target?: { node?: string; lane?: string; namespace?: string; runtimeNamespace?: string; [key: string]: unknown }; + externalSecretName?: string; + targetSecretName?: string; + store?: { kind?: "SecretStore" | "ClusterSecretStore" | string; name?: string; namespace?: string; [key: string]: unknown }; + data?: Array<{ targetKey?: string; sourceRef?: AdminSecretsRemoteRef; remoteRef?: AdminSecretsRemoteRef; [key: string]: unknown }>; + refreshInterval?: string; + consumerRollout?: { enabled?: boolean; intent?: string; [key: string]: unknown }; + [key: string]: unknown; +} +export interface AdminSecretsPlanResponse { + ok?: boolean; + status?: string; + contractVersion?: string; + generatedAt?: string; + target?: AdminSecretsTarget; + permission?: AdminSecretsPlanPermission; + planId?: string | null; + failureKind?: string | null; + checks?: AdminSecretsPlanCheck[]; + risks?: AdminSecretsPlanRisk[]; + plan?: { + operation?: string; + mode?: string; + dryRun?: boolean; + writes?: boolean; + target?: { node?: string; lane?: string; namespace?: string; [key: string]: unknown }; + externalSecret?: { name?: string; namespace?: string; refreshInterval?: string; [key: string]: unknown }; + targetSecret?: { name?: string; namespace?: string; presence?: string; valuesPrinted?: boolean; [key: string]: unknown }; + store?: AdminSecretsStore; + data?: Array<{ targetKey?: string; sourceRef?: AdminSecretsRemoteRef; presence?: string; referenceFingerprint?: string | null; valuesPrinted?: boolean; [key: string]: unknown }>; + affectedConsumers?: AdminSecretsConsumer[]; + consumerRollout?: { enabled?: boolean; intent?: string; marker?: string; valuesPrinted?: boolean; [key: string]: unknown }; + valuesPrinted?: boolean; + secretMaterialStored?: boolean; + [key: string]: unknown; + }; + error?: Record; + valuesPrinted?: boolean; + secretMaterialStored?: boolean; + [key: string]: unknown; +} export interface AdminSecretsExternalSecret { id?: string; name?: string; @@ -415,7 +461,7 @@ export interface AdminSecretsExternalSecret { [key: string]: unknown; } export interface AdminSecretsEvent { status?: AdminSecretStatus; reason?: string; namespace?: string; resourceKind?: string; resourceName?: string; message?: string; observedAt?: string; traceId?: string | null; jobId?: string | null; source?: string; valuesPrinted?: boolean; [key: string]: unknown } -export interface AdminSecretsSummaryResponse { ok?: boolean; status?: string; contractVersion?: string; target?: AdminSecretsTarget; source?: Record; summary?: Record; externalSecrets?: AdminSecretsExternalSecret[]; events?: AdminSecretsEvent[]; valuesPrinted?: boolean; secretMaterialStored?: boolean; generatedAt?: string; [key: string]: unknown } +export interface AdminSecretsSummaryResponse { ok?: boolean; status?: string; contractVersion?: string; target?: AdminSecretsTarget; permissions?: Record; source?: Record; summary?: Record; externalSecrets?: AdminSecretsExternalSecret[]; events?: AdminSecretsEvent[]; valuesPrinted?: boolean; secretMaterialStored?: boolean; generatedAt?: string; [key: string]: unknown } export interface AdminSecretsListResponse { ok?: boolean; status?: string; contractVersion?: string; target?: AdminSecretsTarget; items?: AdminSecretsExternalSecret[]; count?: number; valuesPrinted?: boolean; secretMaterialStored?: boolean; generatedAt?: string; [key: string]: unknown } export interface AdminSecretsDetailResponse { ok?: boolean; status?: string; contractVersion?: string; target?: AdminSecretsTarget; item?: AdminSecretsExternalSecret; valuesPrinted?: boolean; secretMaterialStored?: boolean; generatedAt?: string; [key: string]: unknown } export interface AdminSecretsEventsResponse { ok?: boolean; status?: string; contractVersion?: string; target?: AdminSecretsTarget; events?: AdminSecretsEvent[]; count?: number; valuesPrinted?: boolean; secretMaterialStored?: boolean; generatedAt?: string; [key: string]: unknown } diff --git a/web/hwlab-cloud-web/src/views/admin/AdminSecretsPlanPanel.vue b/web/hwlab-cloud-web/src/views/admin/AdminSecretsPlanPanel.vue new file mode 100644 index 00000000..65b2b1a5 --- /dev/null +++ b/web/hwlab-cloud-web/src/views/admin/AdminSecretsPlanPanel.vue @@ -0,0 +1,295 @@ + + + diff --git a/web/hwlab-cloud-web/src/views/admin/SecretsView.vue b/web/hwlab-cloud-web/src/views/admin/SecretsView.vue index b2b45796..4078ebe1 100644 --- a/web/hwlab-cloud-web/src/views/admin/SecretsView.vue +++ b/web/hwlab-cloud-web/src/views/admin/SecretsView.vue @@ -1,6 +1,7 @@