From 619d75957376bd3e2e752f802d76a3d116ac9b0e Mon Sep 17 00:00:00 2001 From: Bamboo <13664854532@163.com> Date: Fri, 23 Oct 2026 23:50:41 +0800 Subject: [PATCH 01/14] =?UTF-8?q?fix(k8s):=20SEC-25=20Secret=20=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E8=84=B1=E6=95=8F=E5=B9=B6=E6=96=B0=E5=A2=9E=E5=B8=A6?= =?UTF-8?q?=E5=AE=A1=E8=AE=A1=E7=9A=84=20reveal=20=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - list/detail 只返回键名和值字节数(data_keys/string_data_keys/redacted), data/string_data 保留键、值置空;YAML 中 data/stringData 值替换为 - 三个接口都去掉 kubectl.kubernetes.io/last-applied-configuration 注解 - 新增 GET /api/k8s/secret/:cluster_id/:namespace/:name/reveal,每次调用同步写审计 (仅谁/何时/集群/命名空间/名称/键名,不含值),审计失败时不返回明文 - reveal 在接口目录登记为 k8s:secret:reveal,通配符授权不覆盖该接口 - 更新时回填脱敏值:表单空值保留原值,YAML 占位符还原原值,创建时拒绝占位符 --- internal/app/permissions.go | 20 +- internal/app/permissions_named_test.go | 21 ++ internal/app/permissions_test.go | 26 ++ internal/k8s/api/secret.go | 47 +++ internal/k8s/api/secret_test.go | 130 +++++++ internal/k8s/service/secret_service.go | 269 ++++++++++++-- internal/k8s/service/secret_service_test.go | 342 ++++++++++++++++++ internal/k8s/utils/secret.go | 13 +- internal/k8s/utils/secret_redact.go | 217 +++++++++++ internal/middleware/auth.go | 9 + .../middleware/auth_secret_reveal_test.go | 39 ++ internal/model/k8s_secret.go | 94 ++++- internal/system/service/audit_service.go | 1 + pkg/di/wire_gen.go | 2 +- 14 files changed, 1180 insertions(+), 50 deletions(-) create mode 100644 internal/app/permissions_named_test.go create mode 100644 internal/k8s/api/secret_test.go create mode 100644 internal/k8s/service/secret_service_test.go create mode 100644 internal/k8s/utils/secret_redact.go create mode 100644 internal/middleware/auth_secret_reveal_test.go diff --git a/internal/app/permissions.go b/internal/app/permissions.go index eb2cd5bd..c0d82e09 100644 --- a/internal/app/permissions.go +++ b/internal/app/permissions.go @@ -16,6 +16,20 @@ import ( //go:embed aiops_permissions.json var aiopsPermissionCatalog []byte +// namedPermission 需要稳定权限名和说明的接口(敏感接口),其余接口按路由自动生成名称 +type namedPermission struct { + Name string + Description string +} + +// namedPermissions 以 "METHOD 路由" 为键登记的权限码 +var namedPermissions = map[string]namedPermission{ + "GET " + model.K8sSecretRevealRoute: { + Name: model.K8sSecretRevealPermissionName, + Description: "查看 K8s Secret 明文(SEC-25,每次调用写审计;仅显式授权生效,通配符授权不覆盖)", + }, +} + // bootstrapPermissionCatalog 补充可发现接口,不授予角色或覆盖管理员维护的元数据 func (a *App) bootstrapPermissionCatalog(ctx context.Context) error { methods := map[string]int8{"GET": 1, "POST": 2, "PUT": 3, "DELETE": 4} @@ -56,7 +70,11 @@ func (a *App) bootstrapPermissionCatalog(ctx context.Context) error { if len(handler) > 25 { handler = handler[:25] } - pending = append(pending, model.Api{Name: fmt.Sprintf("%s %s %x", route.Method, handler, digest[:4]), Path: route.Path, Method: method, Description: route.Method + " " + route.Path, Version: "v1", Category: 2, IsPublic: 0}) + entry := model.Api{Name: fmt.Sprintf("%s %s %x", route.Method, handler, digest[:4]), Path: route.Path, Method: method, Description: route.Method + " " + route.Path, Version: "v1", Category: 2, IsPublic: 0} + if named, ok := namedPermissions[route.Method+" "+route.Path]; ok { + entry.Name, entry.Description = named.Name, named.Description + } + pending = append(pending, entry) seen[key(method, route.Path)] = true } if len(pending) == 0 { diff --git a/internal/app/permissions_named_test.go b/internal/app/permissions_named_test.go new file mode 100644 index 00000000..0c0d79a6 --- /dev/null +++ b/internal/app/permissions_named_test.go @@ -0,0 +1,21 @@ +package app + +import ( + "testing" + + "github.com/GoSimplicity/AI-CloudOps/internal/model" +) + +func TestSecretRevealIsRegisteredAsNamedPermission(t *testing.T) { + named, ok := namedPermissions["GET "+model.K8sSecretRevealRoute] + if !ok { + t.Fatal("reveal 接口未登记到接口目录") + } + if named.Name != model.K8sSecretRevealPermissionName || named.Description == "" { + t.Fatalf("权限码不符: %+v", named) + } + // cl_system_apis.name 为 varchar(50) + if len(named.Name) > 50 { + t.Fatal("权限名超过 50 字符") + } +} diff --git a/internal/app/permissions_test.go b/internal/app/permissions_test.go index 94822bea..8ebdc599 100644 --- a/internal/app/permissions_test.go +++ b/internal/app/permissions_test.go @@ -84,3 +84,29 @@ func TestAIProxyCatalogSupportsSeparateAssistantAndWorkflowGrants(t *testing.T) } } } + +func TestPermissionCatalogNamesSecretReveal(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + pool, _ := db.DB() + pool.SetMaxOpenConns(1) + defer pool.Close() + if err := db.AutoMigrate(&model.Api{}); err != nil { + t.Fatal(err) + } + engine := gin.New() + engine.GET(model.K8sSecretRevealRoute, func(c *gin.Context) { c.Status(200) }) + app := &App{db: db, server: engine} + if err := app.bootstrapPermissionCatalog(context.Background()); err != nil { + t.Fatal(err) + } + var row model.Api + if err := db.Where("path = ? AND method = ?", model.K8sSecretRevealRoute, 1).First(&row).Error; err != nil { + t.Fatal(err) + } + if row.Name != model.K8sSecretRevealPermissionName { + t.Fatalf("权限名=%q", row.Name) + } +} diff --git a/internal/k8s/api/secret.go b/internal/k8s/api/secret.go index 4871f9e9..7c9176ea 100644 --- a/internal/k8s/api/secret.go +++ b/internal/k8s/api/secret.go @@ -50,6 +50,7 @@ func (h *K8sSecretHandler) RegisterRouters(server *gin.Engine) { k8sGroup.GET("/secret/:cluster_id/list", h.GetSecretList) k8sGroup.GET("/secret/:cluster_id/:namespace/:name/detail", h.GetSecret) k8sGroup.GET("/secret/:cluster_id/:namespace/:name/detail/yaml", h.GetSecretYAML) + k8sGroup.GET("/secret/:cluster_id/:namespace/:name/reveal", h.RevealSecret) k8sGroup.POST("/secret/:cluster_id/create", h.CreateSecret) k8sGroup.POST("/secret/:cluster_id/create/yaml", h.CreateSecretByYaml) k8sGroup.PUT("/secret/:cluster_id/:namespace/:name/update", h.UpdateSecret) @@ -255,3 +256,49 @@ func (h *K8sSecretHandler) UpdateSecretByYaml(ctx *gin.Context) { return nil, h.secretService.UpdateSecretByYaml(ctx.Request.Context(), &req) }) } + +// RevealSecret 返回 Secret 明文(SEC-25)。list/detail/yaml 默认脱敏,明文只能经此接口获取, +// 每次调用都会写一条审计(只含操作者、集群、命名空间、名称与键名)。 +func (h *K8sSecretHandler) RevealSecret(ctx *gin.Context) { + var req model.RevealSecretReq + + clusterID, err := base.GetCustomParamID(ctx, "cluster_id") + if err != nil { + base.BadRequestError(ctx, err.Error()) + return + } + + namespace, err := base.GetParamCustomName(ctx, "namespace") + if err != nil { + base.BadRequestError(ctx, err.Error()) + return + } + + name, err := base.GetParamCustomName(ctx, "name") + if err != nil { + base.BadRequestError(ctx, err.Error()) + return + } + + req.ClusterID = clusterID + req.Namespace = namespace + req.Name = name + + user := base.GetUserInfoFromGinContext(ctx) + actor := model.SecretRevealActor{ + UserID: user.UserID, + Username: user.Username, + IP: user.IP, + UserAgent: user.UserAgent, + TraceID: ctx.GetHeader("X-Trace-ID"), + Endpoint: ctx.Request.URL.Path, + } + + // 明文响应禁止被浏览器或中间代理缓存 + ctx.Header("Cache-Control", "no-store") + ctx.Header("Pragma", "no-cache") + + base.HandleRequest(ctx, &req, func() (interface{}, error) { + return h.secretService.RevealSecret(ctx.Request.Context(), &req, actor) + }) +} diff --git a/internal/k8s/api/secret_test.go b/internal/k8s/api/secret_test.go new file mode 100644 index 00000000..2fa7332d --- /dev/null +++ b/internal/k8s/api/secret_test.go @@ -0,0 +1,130 @@ +package api + +import ( + "context" + "encoding/base64" + "encoding/json" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/GoSimplicity/AI-CloudOps/internal/k8s/manager" + "github.com/GoSimplicity/AI-CloudOps/internal/k8s/service" + "github.com/GoSimplicity/AI-CloudOps/internal/middleware" + "github.com/GoSimplicity/AI-CloudOps/internal/model" + systemService "github.com/GoSimplicity/AI-CloudOps/internal/system/service" + "github.com/GoSimplicity/AI-CloudOps/pkg/jwt" + "github.com/gin-gonic/gin" + "go.uber.org/zap" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const handlerFixtureValue = "plain-handler-fixture-777" + +type handlerSecretManager struct { + manager.SecretManager +} + +func (handlerSecretManager) GetSecret(_ context.Context, _ int, namespace, name string) (*corev1.Secret, error) { + return &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, + Type: corev1.SecretTypeOpaque, + Data: map[string][]byte{"alpha": []byte(handlerFixtureValue)}, + }, nil +} + +// recordingAudit 同时记录 service 的同步审计与中间件的异步审计 +type recordingAudit struct { + systemService.AuditService + mu sync.Mutex + sync []*model.CreateAuditLogRequest + async []*model.CreateAuditLogRequest +} + +func (a *recordingAudit) CreateAuditLog(_ context.Context, req *model.CreateAuditLogRequest) error { + a.mu.Lock() + defer a.mu.Unlock() + a.sync = append(a.sync, req) + return nil +} + +func (a *recordingAudit) CreateAuditLogAsync(_ context.Context, req *model.CreateAuditLogRequest) { + a.mu.Lock() + defer a.mu.Unlock() + a.async = append(a.async, req) +} + +func TestRevealRouteMatchesPermissionCode(t *testing.T) { + gin.SetMode(gin.TestMode) + engine := gin.New() + NewK8sSecretHandler(nil).RegisterRouters(engine) + found := false + for _, r := range engine.Routes() { + if r.Method == "GET" && r.Path == model.K8sSecretRevealRoute { + found = true + } + } + if !found { + t.Fatalf("未注册 reveal 路由 %s", model.K8sSecretRevealRoute) + } +} + +func TestRevealEndpointReturnsPlaintextAndAuditsWithoutValues(t *testing.T) { + gin.SetMode(gin.TestMode) + audit := &recordingAudit{} + svc := service.NewSecretService(handlerSecretManager{}, audit, zap.NewNop()) + + engine := gin.New() + engine.Use(func(c *gin.Context) { + c.Set("user", jwt.UserClaims{Uid: 9, Username: "bob"}) + c.Next() + }) + // 与线上一致挂载审计中间件,确认它不会把响应体(明文)写入审计 + engine.Use(middleware.NewAuditLogMiddleware(audit, zap.NewNop()).AuditLog()) + NewK8sSecretHandler(svc).RegisterRouters(engine) + + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, httptest.NewRequest("GET", "/api/k8s/secret/3/ops/app/reveal", nil)) + if rec.Code != 200 { + t.Fatalf("status=%d", rec.Code) + } + if rec.Header().Get("Cache-Control") != "no-store" { + t.Fatal("reveal 响应应禁止缓存") + } + var resp struct { + Code int `json:"code"` + Data model.K8sSecretReveal `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + if string(resp.Data.Data["alpha"]) != handlerFixtureValue || resp.Data.Namespace != "ops" || resp.Data.Name != "app" || resp.Data.ClusterID != 3 { + t.Fatalf("reveal 响应不符: %s", rec.Body.String()) + } + + audit.mu.Lock() + defer audit.mu.Unlock() + if len(audit.sync) != 1 { + t.Fatalf("reveal 应写 1 条审计,实际 %d", len(audit.sync)) + } + all := append(append([]*model.CreateAuditLogRequest{}, audit.sync...), audit.async...) + raw, _ := json.Marshal(all) + if strings.Contains(string(raw), handlerFixtureValue) || strings.Contains(string(raw), base64.StdEncoding.EncodeToString([]byte(handlerFixtureValue))) { + t.Fatal("审计记录中出现了明文") + } + for _, l := range all { + if len(l.ResponseBody) != 0 { + t.Fatal("审计不应记录响应体") + } + } + got := audit.sync[0] + if got.UserID != 9 || got.TargetID != "3/ops/app" || got.Endpoint != "/api/k8s/secret/3/ops/app/reveal" { + t.Fatalf("审计元数据不符: %+v", got) + } + var body model.SecretRevealAuditRecord + if err := json.Unmarshal(got.RequestBody, &body); err != nil || body.Username != "bob" || strings.Join(body.Keys, ",") != "alpha" { + t.Fatalf("审计内容不符: %s", got.RequestBody) + } +} diff --git a/internal/k8s/service/secret_service.go b/internal/k8s/service/secret_service.go index a203f1f1..4046e6fc 100644 --- a/internal/k8s/service/secret_service.go +++ b/internal/k8s/service/secret_service.go @@ -27,7 +27,9 @@ package service import ( "context" + "encoding/json" "fmt" + "sort" "strings" "time" @@ -35,6 +37,7 @@ import ( "github.com/GoSimplicity/AI-CloudOps/internal/k8s/utils" k8sutils "github.com/GoSimplicity/AI-CloudOps/internal/k8s/utils" "github.com/GoSimplicity/AI-CloudOps/internal/model" + systemService "github.com/GoSimplicity/AI-CloudOps/internal/system/service" "go.uber.org/zap" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/errors" @@ -51,17 +54,21 @@ type SecretService interface { UpdateSecretByYaml(ctx context.Context, req *model.UpdateSecretByYamlReq) error DeleteSecret(ctx context.Context, req *model.DeleteSecretReq) error GetSecretYAML(ctx context.Context, req *model.GetSecretYamlReq) (*model.K8sYaml, error) + // RevealSecret 返回 Secret 明文;每次调用都会写一条审计(只含元数据与键名,不含值) + RevealSecret(ctx context.Context, req *model.RevealSecretReq, actor model.SecretRevealActor) (*model.K8sSecretReveal, error) } // secretService Secret服务实现 type secretService struct { secretManager manager.SecretManager + auditService systemService.AuditService logger *zap.Logger } -func NewSecretService(secretManager manager.SecretManager, logger *zap.Logger) SecretService { +func NewSecretService(secretManager manager.SecretManager, auditService systemService.AuditService, logger *zap.Logger) SecretService { return &secretService{ secretManager: secretManager, + auditService: auditService, logger: logger, } } @@ -189,21 +196,25 @@ func (s *secretService) convertToK8sSecret(secret *corev1.Secret, clusterID int) } return &model.K8sSecret{ - Name: secret.Name, - Namespace: secret.Namespace, - ClusterID: clusterID, - UID: string(secret.UID), - Type: model.K8sSecretType(secret.Type), - Data: model.BinaryDataMap(secret.Data), - StringData: secret.StringData, // 通常为空,因为K8s会转换为Data - Labels: secret.Labels, - Annotations: secret.Annotations, - Immutable: immutable, - DataCount: dataCount, - Size: size, - Age: time.Since(secret.CreationTimestamp.Time).String(), - CreatedAt: secret.CreationTimestamp.Time, - RawSecret: secret, + Name: secret.Name, + Namespace: secret.Namespace, + ClusterID: clusterID, + UID: string(secret.UID), + Type: model.K8sSecretType(secret.Type), + // SEC-25:列表/详情默认脱敏,只返回键与值长度;明文走 reveal 接口 + Data: k8sutils.RedactSecretData(secret.Data), + StringData: k8sutils.RedactSecretStringData(secret.StringData), // 通常为空,因为K8s会转换为Data + DataKeys: k8sutils.SecretDataKeyInfos(secret.Data), + StringDataKeys: k8sutils.SecretStringDataKeyInfos(secret.StringData), + Redacted: true, + Labels: secret.Labels, + Annotations: k8sutils.RedactSecretAnnotations(secret.Annotations), + Immutable: immutable, + DataCount: dataCount, + Size: size, + Age: time.Since(secret.CreationTimestamp.Time).String(), + CreatedAt: secret.CreationTimestamp.Time, + RawSecret: secret, } } @@ -275,10 +286,10 @@ func (s *secretService) UpdateSecret(ctx context.Context, req *model.UpdateSecre updatedSecret := existingSecret.DeepCopy() - updatedSecret.Data = map[string][]byte(req.Data) + updatedSecret.Data = preserveRedactedData(map[string][]byte(req.Data), existingSecret.Data) updatedSecret.StringData = req.StringData updatedSecret.Labels = req.Labels - updatedSecret.Annotations = req.Annotations + updatedSecret.Annotations = preserveLastApplied(req.Annotations, existingSecret.Annotations) // Type和Immutable字段在创建后通常不能修改,保持原值 // 如果需要修改,K8s会返回错误 @@ -363,6 +374,13 @@ func (s *secretService) CreateSecretByYaml(ctx context.Context, req *model.Creat return fmt.Errorf("请求参数不能为空") } + // SEC-25:脱敏占位符不是真实值,禁止用于创建 + if hasPlaceholder, err := k8sutils.SecretYAMLHasRedactedPlaceholder(req.YAML); err != nil { + return err + } else if hasPlaceholder { + return fmt.Errorf("YAML 中包含脱敏占位符 %s,请填写真实值后再创建", k8sutils.SecretRedactedPlaceholder) + } + sec, err := k8sutils.YAMLToSecret(req.YAML) if err != nil { return fmt.Errorf("解析YAML失败: %w", err) @@ -404,8 +422,24 @@ func (s *secretService) UpdateSecretByYaml(ctx context.Context, req *model.Updat zap.String("namespace", req.Namespace), zap.String("name", req.Name)) + // 获取现有资源以获取ResourceVersion(用于乐观锁,避免并发冲突),并用于恢复脱敏占位符 + existing, err := s.secretManager.GetSecret(ctx, req.ClusterID, req.Namespace, req.Name) + if err != nil { + if errors.IsNotFound(err) { + return fmt.Errorf("Secret不存在: %s/%s", req.Namespace, req.Name) + } + s.logger.Error("获取现有Secret失败", zap.Error(err)) + return fmt.Errorf("获取现有Secret失败: %w", err) + } + + // SEC-25:详情 YAML 已脱敏,仍为占位符的键保持集群中的原值,避免占位符被写回 + restoredYAML, err := k8sutils.RestoreRedactedSecretYAML(req.YAML, existing) + if err != nil { + return err + } + // 解析YAML - sec, err := k8sutils.YAMLToSecret(req.YAML) + sec, err := k8sutils.YAMLToSecret(restoredYAML) if err != nil { s.logger.Error("解析YAML失败", zap.Error(err)) return fmt.Errorf("解析YAML失败: %w", err) @@ -415,16 +449,6 @@ func (s *secretService) UpdateSecretByYaml(ctx context.Context, req *model.Updat sec.Namespace = req.Namespace sec.Name = req.Name - // 获取现有资源以获取ResourceVersion(用于乐观锁,避免并发冲突) - existing, err := s.secretManager.GetSecret(ctx, req.ClusterID, req.Namespace, req.Name) - if err != nil { - if errors.IsNotFound(err) { - return fmt.Errorf("Secret不存在: %s/%s", req.Namespace, req.Name) - } - s.logger.Error("获取现有Secret失败", zap.Error(err)) - return fmt.Errorf("获取现有Secret失败: %w", err) - } - // 保留ResourceVersion和UID等关键元数据 sec.ResourceVersion = existing.ResourceVersion sec.UID = existing.UID @@ -445,3 +469,190 @@ func (s *secretService) UpdateSecretByYaml(ctx context.Context, req *model.Updat return nil } + +// preserveRedactedData 兼容脱敏后的详情回填:请求中值为空、而集群中该键已有非空值时保留原值。 +// 详情接口只返回空值,前端若直接回填提交,不能因此把真实值清空。 +// 如需删除某个键,请在请求中去掉该键。 +func preserveRedactedData(requested, existing map[string][]byte) map[string][]byte { + if requested == nil { + return nil + } + out := make(map[string][]byte, len(requested)) + for k, v := range requested { + if len(v) == 0 { + if old, ok := existing[k]; ok && len(old) > 0 { + out[k] = old + continue + } + } + out[k] = v + } + return out +} + +// preserveLastApplied 列表/详情不再返回 last-applied-configuration,更新时若请求未携带则保留集群中的原注解 +func preserveLastApplied(requested, existing map[string]string) map[string]string { + old, ok := existing[k8sutils.LastAppliedConfigAnnotation] + if !ok { + return requested + } + if _, provided := requested[k8sutils.LastAppliedConfigAnnotation]; provided { + return requested + } + out := make(map[string]string, len(requested)+1) + for k, v := range requested { + out[k] = v + } + out[k8sutils.LastAppliedConfigAnnotation] = old + return out +} + +// RevealSecret 返回 Secret 明文。每次调用都写审计,审计只含操作者、集群、命名空间、名称与键名,绝不含值。 +// 成功读取后若审计写入失败,则不返回明文(fail closed)。 +func (s *secretService) RevealSecret(ctx context.Context, req *model.RevealSecretReq, actor model.SecretRevealActor) (*model.K8sSecretReveal, error) { + if req == nil { + return nil, fmt.Errorf("请求参数不能为空") + } + start := time.Now() + + secret, err := s.secretManager.GetSecret(ctx, req.ClusterID, req.Namespace, req.Name) + if err != nil { + var userErr error + if errors.IsNotFound(err) { + userErr = fmt.Errorf("Secret不存在: %s/%s", req.Namespace, req.Name) + } else { + s.logger.Error("获取Secret失败", zap.Error(err), + zap.Int("cluster_id", req.ClusterID), + zap.String("namespace", req.Namespace), + zap.String("name", req.Name)) + userErr = fmt.Errorf("获取Secret失败: %w", err) + } + _ = s.writeRevealAudit(ctx, req, actor, normalizeKeys(req.Keys), start, userErr) + return nil, userErr + } + + result := &model.K8sSecretReveal{ + ClusterID: req.ClusterID, + Namespace: secret.Namespace, + Name: secret.Name, + Type: model.K8sSecretType(secret.Type), + } + + wanted := normalizeKeys(req.Keys) + if len(wanted) == 0 { + result.Data = model.BinaryDataMap(secret.Data) + result.StringData = secret.StringData + } else { + result.Data = model.BinaryDataMap{} + for _, k := range wanted { + if v, ok := secret.Data[k]; ok { + result.Data[k] = v + continue + } + if v, ok := secret.StringData[k]; ok { + if result.StringData == nil { + result.StringData = map[string]string{} + } + result.StringData[k] = v + continue + } + keyErr := fmt.Errorf("Secret %s/%s 中不存在键 %s", req.Namespace, req.Name, k) + _ = s.writeRevealAudit(ctx, req, actor, wanted, start, keyErr) + return nil, keyErr + } + } + + keySet := make(map[string]struct{}, len(result.Data)+len(result.StringData)) + for k := range result.Data { + keySet[k] = struct{}{} + } + for k := range result.StringData { + keySet[k] = struct{}{} + } + result.Keys = make([]string, 0, len(keySet)) + for k := range keySet { + result.Keys = append(result.Keys, k) + } + sort.Strings(result.Keys) + + if err := s.writeRevealAudit(ctx, req, actor, result.Keys, start, nil); err != nil { + s.logger.Error("写入Secret明文查看审计失败,拒绝返回明文", zap.Error(err), + zap.Int("cluster_id", req.ClusterID), + zap.String("namespace", req.Namespace), + zap.String("name", req.Name)) + return nil, fmt.Errorf("写入审计失败,已拒绝返回Secret明文") + } + + s.logger.Info("Secret明文已查看", + zap.Int("cluster_id", req.ClusterID), + zap.String("namespace", req.Namespace), + zap.String("name", req.Name), + zap.Int("user_id", actor.UserID), + zap.Strings("keys", result.Keys)) + + return result, nil +} + +// normalizeKeys 去重、去空并排序请求中的键,同时支持逗号分隔 +func normalizeKeys(keys []string) []string { + seen := make(map[string]struct{}, len(keys)) + out := make([]string, 0, len(keys)) + for _, raw := range keys { + for _, k := range strings.Split(raw, ",") { + k = strings.TrimSpace(k) + if k == "" { + continue + } + if _, ok := seen[k]; ok { + continue + } + seen[k] = struct{}{} + out = append(out, k) + } + } + sort.Strings(out) + return out +} + +// writeRevealAudit 同步写入 reveal 审计;内容只含元数据与键名 +func (s *secretService) writeRevealAudit(ctx context.Context, req *model.RevealSecretReq, actor model.SecretRevealActor, keys []string, start time.Time, opErr error) error { + if s.auditService == nil { + return fmt.Errorf("审计服务未初始化") + } + if keys == nil { + keys = []string{} + } + body, err := json.Marshal(model.SecretRevealAuditRecord{ + Action: model.SecretRevealAuditAction, + UserID: actor.UserID, + Username: actor.Username, + ClusterID: req.ClusterID, + Namespace: req.Namespace, + Name: req.Name, + Keys: keys, + Success: opErr == nil, + RevealedAt: start.UTC().Format(time.RFC3339), + }) + if err != nil { + return err + } + errMsg := "" + if opErr != nil { + errMsg = opErr.Error() + } + return s.auditService.CreateAuditLog(ctx, &model.CreateAuditLogRequest{ + UserID: actor.UserID, + TraceID: actor.TraceID, + IPAddress: actor.IP, + UserAgent: actor.UserAgent, + HttpMethod: "GET", + Endpoint: actor.Endpoint, + OperationType: model.SecretRevealAuditOperation, + TargetType: model.SecretRevealAuditTargetType, + TargetID: fmt.Sprintf("%d/%s/%s", req.ClusterID, req.Namespace, req.Name), + StatusCode: 200, + RequestBody: body, + Duration: time.Since(start).Microseconds(), + ErrorMsg: errMsg, + }) +} diff --git a/internal/k8s/service/secret_service_test.go b/internal/k8s/service/secret_service_test.go new file mode 100644 index 00000000..4f2441ea --- /dev/null +++ b/internal/k8s/service/secret_service_test.go @@ -0,0 +1,342 @@ +package service + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "strings" + "testing" + "time" + + "github.com/GoSimplicity/AI-CloudOps/internal/k8s/manager" + k8sutils "github.com/GoSimplicity/AI-CloudOps/internal/k8s/utils" + "github.com/GoSimplicity/AI-CloudOps/internal/model" + systemService "github.com/GoSimplicity/AI-CloudOps/internal/system/service" + "go.uber.org/zap" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/yaml" +) + +// 测试用的明文(非真实凭据),用于断言任何脱敏输出中都不出现 +const ( + fixtureAlpha = "plain-alpha-fixture-001" + fixtureBeta = "plain-beta-fixture-0002" + fixtureApplied = "plain-applied-fixture-03" +) + +type secretManagerFixture struct { + manager.SecretManager + secret *corev1.Secret + getErr error + updated *corev1.Secret + created *corev1.Secret +} + +func (m *secretManagerFixture) GetSecret(_ context.Context, _ int, _, _ string) (*corev1.Secret, error) { + if m.getErr != nil { + return nil, m.getErr + } + return m.secret.DeepCopy(), nil +} + +func (m *secretManagerFixture) ListSecrets(_ context.Context, _ int, _ string) (*corev1.SecretList, error) { + return &corev1.SecretList{Items: []corev1.Secret{*m.secret.DeepCopy()}}, nil +} + +func (m *secretManagerFixture) UpdateSecret(_ context.Context, _ int, secret *corev1.Secret) (*corev1.Secret, error) { + m.updated = secret.DeepCopy() + return secret, nil +} + +func (m *secretManagerFixture) CreateSecret(_ context.Context, _ int, secret *corev1.Secret) (*corev1.Secret, error) { + m.created = secret.DeepCopy() + return secret, nil +} + +type auditFixture struct { + systemService.AuditService + logs []*model.CreateAuditLogRequest + err error +} + +func (a *auditFixture) CreateAuditLog(_ context.Context, req *model.CreateAuditLogRequest) error { + if a.err != nil { + return a.err + } + cp := *req + a.logs = append(a.logs, &cp) + return nil +} + +func newSecretFixture() *corev1.Secret { + applied, _ := json.Marshal(map[string]interface{}{ + "apiVersion": "v1", + "kind": "Secret", + "metadata": map[string]interface{}{"name": "app", "namespace": "ops"}, + "data": map[string]string{"alpha": base64.StdEncoding.EncodeToString([]byte(fixtureApplied))}, + "stringData": map[string]string{"beta": fixtureApplied}, + }) + return &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "app", + Namespace: "ops", + UID: "uid-1", + ResourceVersion: "42", + CreationTimestamp: metav1.NewTime(time.Unix(100, 0)), + Labels: map[string]string{"app": "demo"}, + Annotations: map[string]string{ + k8sutils.LastAppliedConfigAnnotation: string(applied), + "owner": "team-a", + }, + }, + Type: corev1.SecretTypeOpaque, + Data: map[string][]byte{"alpha": []byte(fixtureAlpha), "beta": []byte(fixtureBeta)}, + StringData: map[string]string{"gamma": fixtureBeta}, + } +} + +// assertNoPlaintext 断言输出中不含任何明文及其 base64 形式 +func assertNoPlaintext(t *testing.T, where, out string) { + t.Helper() + for _, v := range []string{fixtureAlpha, fixtureBeta, fixtureApplied} { + if strings.Contains(out, v) || strings.Contains(out, base64.StdEncoding.EncodeToString([]byte(v))) { + t.Fatalf("%s 泄露了明文值", where) + } + } + if strings.Contains(out, k8sutils.LastAppliedConfigAnnotation) { + t.Fatalf("%s 仍包含 last-applied-configuration 注解", where) + } +} + +func newTestSecretService(m *secretManagerFixture, a *auditFixture) SecretService { + return NewSecretService(m, a, zap.NewNop()) +} + +func TestSecretListAndDetailAreRedacted(t *testing.T) { + m := &secretManagerFixture{secret: newSecretFixture()} + svc := newTestSecretService(m, &auditFixture{}) + + list, err := svc.GetSecretList(context.Background(), &model.GetSecretListReq{ClusterID: 1, Namespace: "ops"}) + if err != nil || len(list.Items) != 1 { + t.Fatalf("list err=%v items=%d", err, len(list.Items)) + } + detail, err := svc.GetSecret(context.Background(), &model.GetSecretDetailsReq{ClusterID: 1, Namespace: "ops", Name: "app"}) + if err != nil { + t.Fatal(err) + } + + for where, item := range map[string]*model.K8sSecret{"list": list.Items[0], "detail": detail} { + raw, err := json.Marshal(item) + if err != nil { + t.Fatal(err) + } + assertNoPlaintext(t, where, string(raw)) + + if !item.Redacted { + t.Fatalf("%s 未标记 redacted", where) + } + if len(item.Data) != 2 || len(item.Data["alpha"]) != 0 || len(item.Data["beta"]) != 0 { + t.Fatalf("%s data 应只保留键且值为空: %v", where, item.Data) + } + if v, ok := item.StringData["gamma"]; !ok || v != "" { + t.Fatalf("%s string_data 应只保留键", where) + } + wantKeys := []model.SecretKeyInfo{{Key: "alpha", Size: len(fixtureAlpha)}, {Key: "beta", Size: len(fixtureBeta)}} + if len(item.DataKeys) != 2 || item.DataKeys[0] != wantKeys[0] || item.DataKeys[1] != wantKeys[1] { + t.Fatalf("%s data_keys 不符: %+v", where, item.DataKeys) + } + if len(item.StringDataKeys) != 1 || item.StringDataKeys[0] != (model.SecretKeyInfo{Key: "gamma", Size: len(fixtureBeta)}) { + t.Fatalf("%s string_data_keys 不符: %+v", where, item.StringDataKeys) + } + if item.Annotations["owner"] != "team-a" { + t.Fatalf("%s 普通注解应保留", where) + } + if item.DataCount != 3 { + t.Fatalf("%s data_count=%d", where, item.DataCount) + } + } + + // 原始对象不被修改 + if string(m.secret.Data["alpha"]) != fixtureAlpha { + t.Fatal("脱敏不应修改原始对象") + } +} + +func TestSecretYAMLIsRedacted(t *testing.T) { + m := &secretManagerFixture{secret: newSecretFixture()} + svc := newTestSecretService(m, &auditFixture{}) + out, err := svc.GetSecretYAML(context.Background(), &model.GetSecretYamlReq{ClusterID: 1, Namespace: "ops", Name: "app"}) + if err != nil { + t.Fatal(err) + } + assertNoPlaintext(t, "yaml", out.YAML) + + var obj struct { + Data map[string]string `json:"data"` + StringData map[string]string `json:"stringData"` + Metadata struct { + Annotations map[string]string `json:"annotations"` + } `json:"metadata"` + } + if err := yaml.Unmarshal([]byte(out.YAML), &obj); err != nil { + t.Fatal(err) + } + if obj.Data["alpha"] != k8sutils.SecretRedactedPlaceholder || obj.Data["beta"] != k8sutils.SecretRedactedPlaceholder || obj.StringData["gamma"] != k8sutils.SecretRedactedPlaceholder { + t.Fatalf("data/stringData 应替换为占位符: %+v", obj) + } + if obj.Metadata.Annotations["owner"] != "team-a" { + t.Fatal("普通注解应保留") + } +} + +func TestRevealSecretReturnsValuesAndAuditsWithoutValues(t *testing.T) { + m := &secretManagerFixture{secret: newSecretFixture()} + a := &auditFixture{} + svc := newTestSecretService(m, a) + actor := model.SecretRevealActor{UserID: 7, Username: "alice", IP: "10.0.0.1", UserAgent: "ua", Endpoint: "/api/k8s/secret/1/ops/app/reveal"} + + out, err := svc.RevealSecret(context.Background(), &model.RevealSecretReq{ClusterID: 1, Namespace: "ops", Name: "app"}, actor) + if err != nil { + t.Fatal(err) + } + if string(out.Data["alpha"]) != fixtureAlpha || string(out.Data["beta"]) != fixtureBeta || out.StringData["gamma"] != fixtureBeta { + t.Fatal("reveal 未返回明文") + } + raw, _ := json.Marshal(out) + if !strings.Contains(string(raw), base64.StdEncoding.EncodeToString([]byte(fixtureAlpha))) { + t.Fatal("reveal data 应保持 base64 编码") + } + if strings.Join(out.Keys, ",") != "alpha,beta,gamma" { + t.Fatalf("keys=%v", out.Keys) + } + + if len(a.logs) != 1 { + t.Fatalf("应写 1 条审计,实际 %d", len(a.logs)) + } + log := a.logs[0] + auditRaw, _ := json.Marshal(log) + assertNoPlaintext(t, "audit", string(auditRaw)) + if log.ResponseBody != nil { + t.Fatal("审计不应记录响应体") + } + if log.UserID != 7 || log.OperationType != "REVEAL" || log.TargetType != "k8s_secret" || log.TargetID != "1/ops/app" || log.ErrorMsg != "" || log.Endpoint != actor.Endpoint { + t.Fatalf("审计元数据不符: %+v", log) + } + var rec model.SecretRevealAuditRecord + if err := json.Unmarshal(log.RequestBody, &rec); err != nil { + t.Fatal(err) + } + if rec.Username != "alice" || rec.ClusterID != 1 || rec.Namespace != "ops" || rec.Name != "app" || strings.Join(rec.Keys, ",") != "alpha,beta,gamma" || !rec.Success || rec.RevealedAt == "" { + t.Fatalf("审计内容不符: %+v", rec) + } +} + +func TestRevealSecretSelectedKeys(t *testing.T) { + m := &secretManagerFixture{secret: newSecretFixture()} + a := &auditFixture{} + svc := newTestSecretService(m, a) + + out, err := svc.RevealSecret(context.Background(), &model.RevealSecretReq{ClusterID: 1, Namespace: "ops", Name: "app", Keys: []string{"beta", "beta, alpha"}}, model.SecretRevealActor{UserID: 1}) + if err != nil { + t.Fatal(err) + } + if len(out.Data) != 2 || out.StringData != nil || strings.Join(out.Keys, ",") != "alpha,beta" { + t.Fatalf("按键过滤失败: %+v", out.Keys) + } + + if _, err := svc.RevealSecret(context.Background(), &model.RevealSecretReq{ClusterID: 1, Namespace: "ops", Name: "app", Keys: []string{"missing"}}, model.SecretRevealActor{UserID: 1}); err == nil { + t.Fatal("不存在的键应报错") + } + if len(a.logs) != 2 || a.logs[1].ErrorMsg == "" { + t.Fatal("失败的 reveal 也应写审计") + } +} + +func TestRevealSecretFailsClosedWhenAuditFails(t *testing.T) { + m := &secretManagerFixture{secret: newSecretFixture()} + svc := newTestSecretService(m, &auditFixture{err: errors.New("db down")}) + out, err := svc.RevealSecret(context.Background(), &model.RevealSecretReq{ClusterID: 1, Namespace: "ops", Name: "app"}, model.SecretRevealActor{UserID: 1}) + if err == nil || out != nil { + t.Fatal("审计写入失败时不应返回明文") + } + if strings.Contains(err.Error(), fixtureAlpha) { + t.Fatal("错误信息泄露明文") + } +} + +func TestRevealSecretAuditsGetFailure(t *testing.T) { + m := &secretManagerFixture{secret: newSecretFixture(), getErr: errors.New("forbidden")} + a := &auditFixture{} + svc := newTestSecretService(m, a) + if _, err := svc.RevealSecret(context.Background(), &model.RevealSecretReq{ClusterID: 1, Namespace: "ops", Name: "app"}, model.SecretRevealActor{UserID: 1}); err == nil { + t.Fatal("应返回错误") + } + if len(a.logs) != 1 || a.logs[0].ErrorMsg == "" { + t.Fatal("读取失败也应写审计") + } +} + +func TestUpdateSecretKeepsValuesForRedactedRoundTrip(t *testing.T) { + m := &secretManagerFixture{secret: newSecretFixture()} + svc := newTestSecretService(m, &auditFixture{}) + // 模拟前端把脱敏详情原样回填,只改了 beta 与标签 + err := svc.UpdateSecret(context.Background(), &model.UpdateSecretReq{ + ClusterID: 1, Namespace: "ops", Name: "app", + Data: model.BinaryDataMap{"alpha": []byte{}, "beta": []byte("new-beta"), "delta": []byte{}}, + Labels: map[string]string{"app": "demo2"}, + Annotations: map[string]string{"owner": "team-a"}, + }) + if err != nil { + t.Fatal(err) + } + if string(m.updated.Data["alpha"]) != fixtureAlpha || string(m.updated.Data["beta"]) != "new-beta" { + t.Fatal("空值回填应保留原值,非空值应覆盖") + } + if v, ok := m.updated.Data["delta"]; !ok || len(v) != 0 { + t.Fatal("新键空值应原样写入") + } + if m.updated.Annotations[k8sutils.LastAppliedConfigAnnotation] == "" { + t.Fatal("未携带的 last-applied 注解应保留") + } +} + +func TestUpdateSecretByYamlRestoresPlaceholders(t *testing.T) { + m := &secretManagerFixture{secret: newSecretFixture()} + svc := newTestSecretService(m, &auditFixture{}) + y, err := svc.GetSecretYAML(context.Background(), &model.GetSecretYamlReq{ClusterID: 1, Namespace: "ops", Name: "app"}) + if err != nil { + t.Fatal(err) + } + // 只改 beta + edited := strings.Replace(y.YAML, "beta: "+k8sutils.SecretRedactedPlaceholder, "beta: "+base64.StdEncoding.EncodeToString([]byte("new-beta")), 1) + if edited == y.YAML { + t.Fatal("测试前提失败:未找到 beta 占位符") + } + if err := svc.UpdateSecretByYaml(context.Background(), &model.UpdateSecretByYamlReq{ClusterID: 1, Namespace: "ops", Name: "app", YAML: edited}); err != nil { + t.Fatal(err) + } + if string(m.updated.Data["alpha"]) != fixtureAlpha || string(m.updated.Data["beta"]) != "new-beta" { + t.Fatalf("占位符应恢复原值: alpha=%d beta=%q", len(m.updated.Data["alpha"]), m.updated.Data["beta"]) + } + if m.updated.StringData["gamma"] != fixtureBeta { + t.Fatal("stringData 占位符应恢复原值") + } + + // 占位符对应的键在现有 Secret 中不存在 → 报错,不会写入占位符 + m.updated = nil + withUnknown := strings.Replace(edited, "data:\n", "data:\n omega: "+k8sutils.SecretRedactedPlaceholder+"\n", 1) + if err := svc.UpdateSecretByYaml(context.Background(), &model.UpdateSecretByYamlReq{ClusterID: 1, Namespace: "ops", Name: "app", YAML: withUnknown}); err == nil || m.updated != nil { + t.Fatal("未知键的占位符应拒绝更新") + } +} + +func TestCreateSecretByYamlRejectsPlaceholder(t *testing.T) { + m := &secretManagerFixture{secret: newSecretFixture()} + svc := newTestSecretService(m, &auditFixture{}) + y := "apiVersion: v1\nkind: Secret\nmetadata:\n name: x\n namespace: ops\ndata:\n alpha: " + k8sutils.SecretRedactedPlaceholder + "\n" + if err := svc.CreateSecretByYaml(context.Background(), &model.CreateSecretByYamlReq{ClusterID: 1, YAML: y}); err == nil || m.created != nil { + t.Fatal("含占位符的 YAML 不应被创建") + } +} diff --git a/internal/k8s/utils/secret.go b/internal/k8s/utils/secret.go index cee9848b..4d34ef07 100644 --- a/internal/k8s/utils/secret.go +++ b/internal/k8s/utils/secret.go @@ -104,19 +104,24 @@ func CleanSecretForYAML(secret *corev1.Secret) *corev1.Secret { cleaned.ObjectMeta.ManagedFields = nil if cleaned.Annotations != nil { - delete(cleaned.Annotations, "kubectl.kubernetes.io/last-applied-configuration") + delete(cleaned.Annotations, LastAppliedConfigAnnotation) } return cleaned } -// SecretToYAML 将 Secret 转换为 YAML 字符串 +// SecretToYAML 将 Secret 转换为脱敏后的 YAML 字符串: +// data/stringData 的值替换为 SecretRedactedPlaceholder,last-applied-configuration 注解被移除。 +// 明文只能通过 reveal 接口获取。 func SecretToYAML(sec *corev1.Secret) (string, error) { if sec == nil { return "", fmt.Errorf("Secret 不能为空") } - clean := CleanSecretForYAML(sec) - b, err := yaml.Marshal(clean) + obj, err := redactedSecretYAMLObject(sec) + if err != nil { + return "", fmt.Errorf("转换为YAML失败: %w", err) + } + b, err := yaml.Marshal(obj) if err != nil { return "", fmt.Errorf("转换为YAML失败: %w", err) } diff --git a/internal/k8s/utils/secret_redact.go b/internal/k8s/utils/secret_redact.go new file mode 100644 index 00000000..ceb98954 --- /dev/null +++ b/internal/k8s/utils/secret_redact.go @@ -0,0 +1,217 @@ +/* + * MIT License + * + * Copyright (c) 2024 Bamboo + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + */ + +package utils + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "sort" + + "github.com/GoSimplicity/AI-CloudOps/internal/model" + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/yaml" +) + +const ( + // SecretRedactedPlaceholder YAML 中替换 data/stringData 值的占位符。 + // 它不是合法的 base64,因此不可能被当作真实值静默写回集群。 + SecretRedactedPlaceholder = "" + // LastAppliedConfigAnnotation kubectl apply 写入的注解,内容为完整对象(含 Secret 明文) + LastAppliedConfigAnnotation = "kubectl.kubernetes.io/last-applied-configuration" +) + +// SecretDataKeyInfos 返回 data 的键与值字节数(按 key 升序),不包含值 +func SecretDataKeyInfos(data map[string][]byte) []model.SecretKeyInfo { + infos := make([]model.SecretKeyInfo, 0, len(data)) + for k, v := range data { + infos = append(infos, model.SecretKeyInfo{Key: k, Size: len(v)}) + } + sort.Slice(infos, func(i, j int) bool { return infos[i].Key < infos[j].Key }) + return infos +} + +// SecretStringDataKeyInfos 返回 stringData 的键与值字节数(按 key 升序),不包含值 +func SecretStringDataKeyInfos(data map[string]string) []model.SecretKeyInfo { + infos := make([]model.SecretKeyInfo, 0, len(data)) + for k, v := range data { + infos = append(infos, model.SecretKeyInfo{Key: k, Size: len(v)}) + } + sort.Slice(infos, func(i, j int) bool { return infos[i].Key < infos[j].Key }) + return infos +} + +// RedactSecretData 仅保留键,值置为空 +func RedactSecretData(data map[string][]byte) model.BinaryDataMap { + if data == nil { + return nil + } + out := make(model.BinaryDataMap, len(data)) + for k := range data { + out[k] = []byte{} + } + return out +} + +// RedactSecretStringData 仅保留键,值置为空 +func RedactSecretStringData(data map[string]string) map[string]string { + if data == nil { + return nil + } + out := make(map[string]string, len(data)) + for k := range data { + out[k] = "" + } + return out +} + +// RedactSecretAnnotations 复制注解并去掉可能包含明文的 last-applied-configuration +func RedactSecretAnnotations(annotations map[string]string) map[string]string { + if annotations == nil { + return nil + } + out := make(map[string]string, len(annotations)) + for k, v := range annotations { + if k == LastAppliedConfigAnnotation { + continue + } + out[k] = v + } + return out +} + +// redactedSecretYAMLObject 将清理后的 Secret 转为通用对象,并把 data/stringData 的值替换为占位符 +func redactedSecretYAMLObject(sec *corev1.Secret) (map[string]interface{}, error) { + clean := CleanSecretForYAML(sec) + raw, err := json.Marshal(clean) + if err != nil { + return nil, err + } + var obj map[string]interface{} + if err := json.Unmarshal(raw, &obj); err != nil { + return nil, err + } + for _, field := range []string{"data", "stringData"} { + values, ok := obj[field].(map[string]interface{}) + if !ok { + continue + } + for k := range values { + values[k] = SecretRedactedPlaceholder + } + } + return obj, nil +} + +// secretYAMLValues 解析 YAML 中的 data 与 stringData(值统一视为字符串) +func secretYAMLValues(obj map[string]interface{}, field string) map[string]interface{} { + values, _ := obj[field].(map[string]interface{}) + return values +} + +// SecretYAMLHasRedactedPlaceholder 判断 YAML 的 data/stringData 中是否仍含脱敏占位符 +func SecretYAMLHasRedactedPlaceholder(y string) (bool, error) { + var obj map[string]interface{} + if err := yaml.Unmarshal([]byte(y), &obj); err != nil { + return false, fmt.Errorf("解析YAML失败: %w", err) + } + for _, field := range []string{"data", "stringData"} { + for _, v := range secretYAMLValues(obj, field) { + if s, ok := v.(string); ok && s == SecretRedactedPlaceholder { + return true, nil + } + } + } + return false, nil +} + +// RestoreRedactedSecretYAML 将 YAML 中仍为占位符的键恢复为集群中的现有值。 +// 前端拿到脱敏 YAML 后只改了部分字段再提交时,未改动的键保持原值而不是被占位符覆盖。 +// 占位符对应的键在现有 Secret 中不存在时返回错误。 +func RestoreRedactedSecretYAML(y string, existing *corev1.Secret) (string, error) { + var obj map[string]interface{} + if err := yaml.Unmarshal([]byte(y), &obj); err != nil { + return "", fmt.Errorf("解析YAML失败: %w", err) + } + if obj == nil { + return y, nil + } + var existingData map[string][]byte + var existingStringData map[string]string + if existing != nil { + existingData = existing.Data + existingStringData = existing.StringData + } + changed := false + + data := secretYAMLValues(obj, "data") + for k, v := range data { + if s, ok := v.(string); ok && s == SecretRedactedPlaceholder { + if old, exists := existingData[k]; exists { + data[k] = base64.StdEncoding.EncodeToString(old) + } else if old, exists := existingStringData[k]; exists { + data[k] = base64.StdEncoding.EncodeToString([]byte(old)) + } else { + return "", fmt.Errorf("键 %s 仍为脱敏占位符且在现有Secret中不存在,请填写真实值", k) + } + changed = true + } + } + + stringData := secretYAMLValues(obj, "stringData") + for k, v := range stringData { + if s, ok := v.(string); ok && s == SecretRedactedPlaceholder { + if old, exists := existingStringData[k]; exists { + stringData[k] = old + changed = true + continue + } + old, exists := existingData[k] + if !exists { + return "", fmt.Errorf("键 %s 仍为脱敏占位符且在现有Secret中不存在,请填写真实值", k) + } + // stringData 写入后会合并到 data,这里直接以 data 形式恢复原值 + delete(stringData, k) + if data == nil { + data = map[string]interface{}{} + obj["data"] = data + } + if _, set := data[k]; !set { + data[k] = base64.StdEncoding.EncodeToString(old) + } + changed = true + } + } + + if !changed { + return y, nil + } + out, err := yaml.Marshal(obj) + if err != nil { + return "", fmt.Errorf("生成YAML失败: %w", err) + } + return string(out), nil +} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index 1b8ab33a..13ff2850 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -28,6 +28,7 @@ package middleware import ( "strings" + "github.com/GoSimplicity/AI-CloudOps/internal/model" "github.com/GoSimplicity/AI-CloudOps/internal/system/service" "github.com/GoSimplicity/AI-CloudOps/pkg/base" "github.com/GoSimplicity/AI-CloudOps/pkg/jwt" @@ -45,6 +46,11 @@ var skipAuthPaths = map[string]bool{ "/favicon.ico": true, } +// explicitGrantRoutes 敏感路由只接受按路由显式授予的权限,通配符(如 /api/k8s/*)不覆盖 +var explicitGrantRoutes = map[string]bool{ + model.K8sSecretRevealRoute: true, +} + // HTTP方法映射 var methodMapping = map[string]int8{ "GET": 1, @@ -188,6 +194,9 @@ func (am *AuthMiddleware) CheckAuth() gin.HandlerFunc { // 检查API权限 for _, api := range role.Apis { + if explicitGrantRoutes[c.FullPath()] && strings.Contains(api.Path, "*") { + continue + } if (api.Path == c.FullPath() && api.Method == methodCode) || matchWildcardPath(api.Path, path, methodCode, api.Method) { c.Next() return diff --git a/internal/middleware/auth_secret_reveal_test.go b/internal/middleware/auth_secret_reveal_test.go new file mode 100644 index 00000000..e8dad3a2 --- /dev/null +++ b/internal/middleware/auth_secret_reveal_test.go @@ -0,0 +1,39 @@ +package middleware + +import ( + "net/http/httptest" + "testing" + + "github.com/GoSimplicity/AI-CloudOps/internal/model" + "github.com/GoSimplicity/AI-CloudOps/pkg/jwt" + "github.com/gin-gonic/gin" +) + +func TestSecretRevealRequiresExplicitGrant(t *testing.T) { + const detailRoute = "/api/k8s/secret/:cluster_id/:namespace/:name/detail" + for _, tt := range []struct { + name string + grants []*model.Api + path string + want int + }{ + {"explicit reveal grant", []*model.Api{{Path: model.K8sSecretRevealRoute, Method: 1}}, "/api/k8s/secret/1/ops/app/reveal", 204}, + {"module wildcard does not cover reveal", []*model.Api{{Path: "/api/k8s/*", Method: 1}}, "/api/k8s/secret/1/ops/app/reveal", 403}, + {"global wildcard does not cover reveal", []*model.Api{{Path: "/*", Method: 1}}, "/api/k8s/secret/1/ops/app/reveal", 403}, + {"detail grant does not cover reveal", []*model.Api{{Path: detailRoute, Method: 1}}, "/api/k8s/secret/1/ops/app/reveal", 403}, + {"module wildcard still covers detail", []*model.Api{{Path: "/api/k8s/*", Method: 1}}, "/api/k8s/secret/1/ops/app/detail", 204}, + } { + t.Run(tt.name, func(t *testing.T) { + engine := gin.New() + engine.Use(func(c *gin.Context) { c.Set("user", jwt.UserClaims{Uid: 7, Username: "operator"}); c.Next() }) + engine.Use(NewAuthMiddleware(aiProxyRoles{apis: tt.grants}).CheckAuth()) + engine.GET(model.K8sSecretRevealRoute, func(c *gin.Context) { c.Status(204) }) + engine.GET(detailRoute, func(c *gin.Context) { c.Status(204) }) + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, httptest.NewRequest("GET", tt.path, nil)) + if rec.Code != tt.want { + t.Fatalf("status=%d want=%d", rec.Code, tt.want) + } + }) + } +} diff --git a/internal/model/k8s_secret.go b/internal/model/k8s_secret.go index 7c281837..472d2e87 100644 --- a/internal/model/k8s_secret.go +++ b/internal/model/k8s_secret.go @@ -47,21 +47,85 @@ const ( // K8sSecret Kubernetes Secret type K8sSecret struct { - Name string `json:"name" binding:"required,min=1,max=200"` // Secret名称 - Namespace string `json:"namespace" binding:"required,min=1,max=200"` // 所属命名空间 - ClusterID int `json:"cluster_id" gorm:"index;not null"` // 所属集群ID - UID string `json:"uid" gorm:"size:100"` // Secret UID - Type K8sSecretType `json:"type"` // Secret类型 - Data BinaryDataMap `json:"data"` // 加密数据 - StringData map[string]string `json:"string_data"` // 明文数据 - Labels map[string]string `json:"labels"` // 标签 - Annotations map[string]string `json:"annotations"` // 注解 - Immutable bool `json:"immutable"` // 是否不可变 - DataCount int `json:"data_count"` // 数据条目数量 - Size string `json:"size"` // 数据大小 - Age string `json:"age"` // 存在时间 - CreatedAt time.Time `json:"created_at"` // 创建时间 - RawSecret *corev1.Secret `json:"-"` // 原始 Secret 对象,不序列化到 JSON + Name string `json:"name" binding:"required,min=1,max=200"` // Secret名称 + Namespace string `json:"namespace" binding:"required,min=1,max=200"` // 所属命名空间 + ClusterID int `json:"cluster_id" gorm:"index;not null"` // 所属集群ID + UID string `json:"uid" gorm:"size:100"` // Secret UID + Type K8sSecretType `json:"type"` // Secret类型 + Data BinaryDataMap `json:"data"` // 默认脱敏:仅保留键,值恒为空字符串;明文需调用 reveal 接口 + StringData map[string]string `json:"string_data"` // 默认脱敏:仅保留键,值恒为空字符串 + // DataKeys / StringDataKeys 脱敏后的键与原始值字节数(按 key 升序) + DataKeys []SecretKeyInfo `json:"data_keys"` + StringDataKeys []SecretKeyInfo `json:"string_data_keys"` + Redacted bool `json:"redacted"` // 为 true 表示 data/string_data 的值已被移除 + Labels map[string]string `json:"labels"` // 标签 + Annotations map[string]string `json:"annotations"` // 注解 + Immutable bool `json:"immutable"` // 是否不可变 + DataCount int `json:"data_count"` // 数据条目数量 + Size string `json:"size"` // 数据大小 + Age string `json:"age"` // 存在时间 + CreatedAt time.Time `json:"created_at"` // 创建时间 + RawSecret *corev1.Secret `json:"-"` // 原始 Secret 对象,不序列化到 JSON +} + +// SecretKeyInfo Secret 中单个键的脱敏描述,只包含键名与值的字节数,不包含值 +type SecretKeyInfo struct { + Key string `json:"key"` + Size int `json:"size"` // 值的字节数(data 为解码后的原始字节数) +} + +// RevealSecretReq 获取Secret明文请求(每次调用都会写审计) +type RevealSecretReq struct { + ClusterID int `json:"cluster_id" form:"cluster_id" binding:"required" comment:"集群ID"` // 集群ID + Namespace string `json:"namespace" form:"namespace" binding:"required" comment:"命名空间"` // 命名空间 + Name string `json:"name" form:"name" binding:"required" comment:"Secret名称"` // Secret名称 + Keys []string `json:"keys" form:"keys" comment:"仅返回指定的键,为空时返回全部"` // 仅返回指定的键 +} + +// K8sSecretReveal Secret 明文响应 +type K8sSecretReveal struct { + ClusterID int `json:"cluster_id"` + Namespace string `json:"namespace"` + Name string `json:"name"` + Type K8sSecretType `json:"type"` + Data BinaryDataMap `json:"data"` // base64 编码的明文,与改造前 detail 的 data 编码一致 + StringData map[string]string `json:"string_data"` // 明文(通常为空,K8s 会合并进 data) + Keys []string `json:"keys"` // 本次返回的键(同时写入审计) +} + +// SecretRevealActor reveal 操作者信息(由 handler 从登录态中提取) +type SecretRevealActor struct { + UserID int + Username string + IP string + UserAgent string + TraceID string + Endpoint string +} + +// K8sSecretRevealRoute Secret 明文接口的完整路由(权限码),仅允许按该路由显式授权,通配符授权不生效 +const K8sSecretRevealRoute = "/api/k8s/secret/:cluster_id/:namespace/:name/reveal" + +// K8sSecretRevealPermissionName Secret 明文接口在接口目录中的权限名 +const K8sSecretRevealPermissionName = "k8s:secret:reveal" + +const ( + SecretRevealAuditAction = "k8s_secret_reveal" + SecretRevealAuditOperation = "REVEAL" + SecretRevealAuditTargetType = "k8s_secret" +) + +// SecretRevealAuditRecord reveal 审计内容,写入 AuditLog.RequestBody。只含元数据与键名,绝不含值。 +type SecretRevealAuditRecord struct { + Action string `json:"action"` + UserID int `json:"user_id"` + Username string `json:"username"` + ClusterID int `json:"cluster_id"` + Namespace string `json:"namespace"` + Name string `json:"name"` + Keys []string `json:"keys"` + Success bool `json:"success"` + RevealedAt string `json:"revealed_at"` // RFC3339 UTC } // GetSecretListReq 获取Secret列表请求 diff --git a/internal/system/service/audit_service.go b/internal/system/service/audit_service.go index 319a7bc3..d923f091 100644 --- a/internal/system/service/audit_service.go +++ b/internal/system/service/audit_service.go @@ -207,6 +207,7 @@ func (s *auditService) GetAuditTypes(ctx context.Context) ([]model.AuditTypeInfo {Type: "UPDATE", Description: "更新操作", Category: "数据操作"}, {Type: "DELETE", Description: "删除操作", Category: "数据操作"}, {Type: "VIEW", Description: "查看操作", Category: "数据访问"}, + {Type: "REVEAL", Description: "查看敏感明文(如 Secret reveal)", Category: "数据访问"}, {Type: "LOGIN", Description: "登录操作", Category: "身份认证"}, {Type: "LOGOUT", Description: "登出操作", Category: "身份认证"}, {Type: "EXPORT", Description: "导出操作", Category: "数据导出"}, diff --git a/pkg/di/wire_gen.go b/pkg/di/wire_gen.go index 3e14b45d..2d031e87 100644 --- a/pkg/di/wire_gen.go +++ b/pkg/di/wire_gen.go @@ -143,7 +143,7 @@ func ProvideApp(cfg *config.Config) (*app.App, error) { configMapService := service3.NewConfigMapService(k8sClient, configMapManager, logger) k8sConfigMapHandler := api3.NewK8sConfigMapHandler(configMapService) secretManager := manager.NewSecretManager(k8sClient, logger) - secretService := service3.NewSecretService(secretManager, logger) + secretService := service3.NewSecretService(secretManager, auditService, logger) k8sSecretHandler := api3.NewK8sSecretHandler(secretService) alertManagerEventDAO := alert.NewAlertManagerEventDAO(db, logger, userDAO) scrapePoolDAO := scrape.NewScrapePoolDAO(db, logger, userDAO) From c6151280690af419c611524e0d80e84c96bf314d Mon Sep 17 00:00:00 2001 From: Bamboo <13664854532@163.com> Date: Fri, 23 Oct 2026 23:50:41 +0800 Subject: [PATCH 02/14] =?UTF-8?q?docs(k8s):=20SEC-25=20Secret=20=E8=84=B1?= =?UTF-8?q?=E6=95=8F=E4=B8=8E=20reveal=20=E6=8E=A5=E5=8F=A3=E5=A5=91?= =?UTF-8?q?=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/release/k8s-secret-redaction.md | 139 +++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 docs/release/k8s-secret-redaction.md diff --git a/docs/release/k8s-secret-redaction.md b/docs/release/k8s-secret-redaction.md new file mode 100644 index 00000000..3bfb80fc --- /dev/null +++ b/docs/release/k8s-secret-redaction.md @@ -0,0 +1,139 @@ +# K8s Secret 脱敏与 reveal 契约(SEC-25) + +## 结论 + +- `list`、`detail`、`detail/yaml` 三个接口**默认不返回任何 Secret 值**,只返回键名和值的字节数。 +- 明文只能通过新接口 `GET /api/k8s/secret/:cluster_id/:namespace/:name/reveal` 获取。每次调用都会同步写一条审计(`cl_system_audit_logs`),审计只含谁、何时、哪个集群/命名空间/Secret、哪些 key,**不含值**。审计写入失败时不返回明文。 +- `kubectl.kubernetes.io/last-applied-configuration` 注解可能含整份明文,list/detail 的 `annotations` 和 YAML 中都不再返回它。 +- 本次改动不涉及数据库表结构变化(`K8sSecret` 不参与 AutoMigrate,`cl_system_apis`、`cl_system_audit_logs` 的结构未改)。 + +## 1. list / detail:字段变化 + +| 字段 | 改造前 | 改造后 | +| --- | --- | --- | +| `data` | `{key: base64(值)}` | `{key: ""}`:键都保留,值一律为空字符串 | +| `string_data` | `{key: 明文}`(通常为空) | `{key: ""}`:键都保留,值一律为空字符串 | +| `data_keys` | 无 | **新增**。`[{key, size}]` 按 key 升序,`size` 为 base64 解码后的值字节数 | +| `string_data_keys` | 无 | **新增**。`[{key, size}]` 按 key 升序,`size` 为字符串的 UTF-8 字节数 | +| `redacted` | 无 | **新增**。固定为 `true`,表示值已被移除 | +| `annotations` | 原样返回 | 不再返回 `kubectl.kubernetes.io/last-applied-configuration`,其余注解照常返回 | +| `data_count` / `size` | 键数 / 总大小 | 不变 | + +前端兼容说明:`data` 仍是对象,键集合没变,已有的“按 key 列表展示”逻辑无需修改。如果前端原来会对值做 base64 解码,现在得到的是空字符串,请改为展示 `data_keys[].size` 并提供“查看”按钮调用 reveal。 + +示例(`GET /api/k8s/secret/1/list` 中的单个 item,或 `GET /api/k8s/secret/1/ops/app-secret/detail` 返回的 `data`): + +```json +{ + "code": 0, + "data": { + "name": "app-secret", + "namespace": "ops", + "cluster_id": 1, + "uid": "00000000-0000-0000-0000-000000000000", + "type": "Opaque", + "data": { "db-user": "", "db-pass": "" }, + "string_data": { "note": "" }, + "data_keys": [ + { "key": "db-pass", "size": 16 }, + { "key": "db-user", "size": 5 } + ], + "string_data_keys": [ + { "key": "note", "size": 9 } + ], + "redacted": true, + "labels": { "app": "demo" }, + "annotations": { "owner": "team-a" }, + "immutable": false, + "data_count": 3, + "size": "30 B", + "age": "240h0m0s", + "created_at": "2026-10-01T00:00:00Z" + }, + "message": "请求成功" +} +``` + +## 2. detail/yaml:字段变化 + +- `data` 和 `stringData` 下每个键的值都替换为占位符 ``,键保留。这个占位符不是合法的 base64,不可能被当作真实值写回集群。 +- `metadata.annotations` 中的 `last-applied-configuration` 被移除(改造前已经如此),`resourceVersion`、`uid`、`managedFields` 等系统字段的清理方式不变。 + +```json +{ + "code": 0, + "data": { + "yaml": "data:\n db-pass: \n db-user: \nmetadata:\n annotations:\n owner: team-a\n labels:\n app: demo\n name: app-secret\n namespace: ops\nstringData:\n note: \ntype: Opaque\n" + }, + "message": "请求成功" +} +``` + +## 3. 新接口:reveal + +`GET /api/k8s/secret/:cluster_id/:namespace/:name/reveal[?keys=k1&keys=k2]` + +- `keys` 可选,可以重复传,也可以用逗号分隔。为空时返回全部键;指定的键不存在时报错,同样会写审计。 +- `data` 的编码与改造前 detail 一致(base64),前端原有的解码逻辑可以直接复用。 +- 响应头带 `Cache-Control: no-store`、`Pragma: no-cache`。 +- 权限码:接口目录(`cl_system_apis`)中的名称为 `k8s:secret:reveal`,路径为 `/api/k8s/secret/:cluster_id/:namespace/:name/reveal`,方法为 GET。**只有显式授予这一条才生效**:`/api/k8s/*`、`/*` 这类通配符授权、以及 detail 权限都不覆盖 reveal。用户名为 `admin` 的账号与其他接口一样直接放行。 + +```json +{ + "code": 0, + "data": { + "cluster_id": 1, + "namespace": "ops", + "name": "app-secret", + "type": "Opaque", + "data": { "db-pass": "", "db-user": "" }, + "string_data": null, + "keys": ["db-pass", "db-user"] + }, + "message": "请求成功" +} +``` + +### 审计记录 + +由 reveal 接口在 service 层同步写入,不依赖审计中间件(中间件对 GET 请求不记录,也从不记录请求体或响应体)。 + +| 列 | 值 | +| --- | --- | +| `user_id` | 操作者 ID | +| `ip_address` / `user_agent` / `trace_id` | 请求元数据(表结构要求 IP 非空) | +| `http_method` / `endpoint` | `GET` / 实际请求路径 | +| `operation_type` | `REVEAL`(已加入 `GetAuditTypes`) | +| `target_type` / `target_id` | `k8s_secret` / `集群ID/命名空间/名称` | +| `request_body` | 见下方 JSON,只含元数据和键名 | +| `response_body` | 始终为空 | +| `error_msg` | 失败原因(不含值) | +| `created_at` | 写入时间 | + +```json +{ + "action": "k8s_secret_reveal", + "user_id": 7, + "username": "alice", + "cluster_id": 1, + "namespace": "ops", + "name": "app-secret", + "keys": ["db-pass", "db-user"], + "success": true, + "revealed_at": "2026-10-23T15:00:00Z" +} +``` + +## 4. 回写保护(保证前端改造前不丢数据) + +- `PUT .../update`:如果请求 `data` 中某个键的值为空,而集群中该键当前有非空值,就保留原值。这样前端把脱敏后的详情原样回填提交时,真实值不会被清空。要删除一个键,请在请求里去掉它。副作用是:通过表单无法把一个已有非空值的键改成空值,需要时请改用 YAML 更新,在 YAML 中写 `""`。请求没有携带 `last-applied-configuration` 注解时,保留集群中的原注解。 +- `PUT .../update/yaml`:值仍为 `` 的键保持集群中的原值,用户只需修改需要变更的键。占位符对应的键在集群中不存在时拒绝更新。 +- `POST .../create/yaml`:YAML 中含 `` 时拒绝创建。 + +## 5. 前端待办 + +“查看值”“复制”“编辑”三个入口需要明文时,改为先调用 reveal(可以按 `keys` 只取需要的键),不再读取 detail/yaml 中的值。 + +## 6. 回滚 + +本次没有数据库迁移,回退提交即可恢复旧行为。回退后,reveal 接口在 `cl_system_apis` 中登记的那一行会留下,但不会产生影响;如需清理,删除 `name = 'k8s:secret:reveal'` 的记录及其在 `cl_system_role_apis` 中的关联即可。 From f2b5714e30402c27ac6ebaddfa9e10201876db83 Mon Sep 17 00:00:00 2001 From: Bamboo <13664854532@163.com> Date: Sat, 24 Oct 2026 00:07:25 +0800 Subject: [PATCH 03/14] =?UTF-8?q?fix(k8s):=20SEC-25=20stringData=20?= =?UTF-8?q?=E7=A9=BA=E5=80=BC=E5=86=99=E5=9B=9E=E4=BF=9D=E6=8A=A4=E3=80=81?= =?UTF-8?q?=E5=AE=A1=E8=AE=A1=E5=A4=B1=E8=B4=A5=E7=8A=B6=E6=80=81=E7=A0=81?= =?UTF-8?q?=E3=80=81=E6=98=BE=E5=BC=8F=E6=8E=88=E6=9D=83=E7=B2=BE=E7=A1=AE?= =?UTF-8?q?=E5=8C=B9=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UpdateSecret:stringData 中值为空且集群已有非空值的键,不再作为 stringData 提交, 改为在 data 中保留原值,避免 K8s 合并 stringData 时清空 data(与 data 的空值保留规则一致) - reveal 审计记录 status_code 写实际结果(200/400/404/403/500),不再固定 200; 同步审计写入失败时返回 HTTP 500(ErrAuditWriteFailed),不含明文 - 显式授权路由只认 api.Path == FullPath 且方法一致的精确匹配, 参数化授权路径(:param)、具体请求路径、通配符均不覆盖 reveal - reveal 按 keys 过滤时,data 与 stringData 同名键两处都返回,与全量输出一致 - 补测试:stringData 占位符落到 data 的分支、data/stringData 同名键的脱敏/写回/reveal、 审计状态码、审计失败 500、参数化授权 403、admin 放行 - 同步更新 docs/release/k8s-secret-redaction.md --- docs/release/k8s-secret-redaction.md | 8 +- internal/k8s/api/secret.go | 25 ++- internal/k8s/api/secret_test.go | 31 ++++ internal/k8s/service/secret_service.go | 85 ++++++++- internal/k8s/service/secret_service_test.go | 170 ++++++++++++++++++ internal/k8s/utils/secret_redact_test.go | 121 +++++++++++++ internal/middleware/auth.go | 10 +- .../middleware/auth_secret_reveal_test.go | 21 +++ 8 files changed, 456 insertions(+), 15 deletions(-) create mode 100644 internal/k8s/utils/secret_redact_test.go diff --git a/docs/release/k8s-secret-redaction.md b/docs/release/k8s-secret-redaction.md index 3bfb80fc..12442e44 100644 --- a/docs/release/k8s-secret-redaction.md +++ b/docs/release/k8s-secret-redaction.md @@ -73,10 +73,11 @@ `GET /api/k8s/secret/:cluster_id/:namespace/:name/reveal[?keys=k1&keys=k2]` -- `keys` 可选,可以重复传,也可以用逗号分隔。为空时返回全部键;指定的键不存在时报错,同样会写审计。 +- `keys` 可选,可以重复传,也可以用逗号分隔。为空时返回全部键;指定的键不存在时报错,同样会写审计。同一个键同时出现在 `data` 和 `stringData` 中时,两处的值都会返回,`keys` 中该键只出现一次。 - `data` 的编码与改造前 detail 一致(base64),前端原有的解码逻辑可以直接复用。 - 响应头带 `Cache-Control: no-store`、`Pragma: no-cache`。 -- 权限码:接口目录(`cl_system_apis`)中的名称为 `k8s:secret:reveal`,路径为 `/api/k8s/secret/:cluster_id/:namespace/:name/reveal`,方法为 GET。**只有显式授予这一条才生效**:`/api/k8s/*`、`/*` 这类通配符授权、以及 detail 权限都不覆盖 reveal。用户名为 `admin` 的账号与其他接口一样直接放行。 +- 同步审计写入失败时返回 **HTTP 500**(`code=1`,`message` 为“写入审计失败,已拒绝返回Secret明文”),不含任何值。其他失败(Secret 不存在、键不存在等)沿用项目惯例:HTTP 200、`code=1`。 +- 权限码:接口目录(`cl_system_apis`)中的名称为 `k8s:secret:reveal`,路径为 `/api/k8s/secret/:cluster_id/:namespace/:name/reveal`,方法为 GET。**只有显式授予这一条才生效**:授权记录的 `path` 必须与上面的路由模板逐字相等、方法为 GET。`/api/k8s/*`、`/*`、`/api/k8s/*/reveal` 这类通配符授权,`/api/k8s/secret/:cluster_id/:namespace/:name/:action`、`/api/k8s/secret/:a/:b/:c/reveal` 这类参数化授权(不含 `*`,参数名或段不同),具体请求路径(如 `/api/k8s/secret/1/ops/app/reveal`),以及 detail 权限,都不覆盖 reveal。用户名为 `admin` 的账号与其他接口一样直接放行。 ```json { @@ -103,6 +104,7 @@ | `user_id` | 操作者 ID | | `ip_address` / `user_agent` / `trace_id` | 请求元数据(表结构要求 IP 非空) | | `http_method` / `endpoint` | `GET` / 实际请求路径 | +| `status_code` | 本次 reveal 的实际结果:成功 `200`;指定的键不存在 `400`;读取 Secret 失败时取 K8s API 返回的状态码(如 `404`、`403`),无法识别时 `500` | | `operation_type` | `REVEAL`(已加入 `GetAuditTypes`) | | `target_type` / `target_id` | `k8s_secret` / `集群ID/命名空间/名称` | | `request_body` | 见下方 JSON,只含元数据和键名 | @@ -126,7 +128,7 @@ ## 4. 回写保护(保证前端改造前不丢数据) -- `PUT .../update`:如果请求 `data` 中某个键的值为空,而集群中该键当前有非空值,就保留原值。这样前端把脱敏后的详情原样回填提交时,真实值不会被清空。要删除一个键,请在请求里去掉它。副作用是:通过表单无法把一个已有非空值的键改成空值,需要时请改用 YAML 更新,在 YAML 中写 `""`。请求没有携带 `last-applied-configuration` 注解时,保留集群中的原注解。 +- `PUT .../update`:如果请求 `data` 中某个键的值为空,而集群中该键当前有非空值,就保留原值。这样前端把脱敏后的详情原样回填提交时,真实值不会被清空。要删除一个键,请在请求里去掉它。`string_data` 按同样规则处理:某个键的值为空、而集群中该键当前有非空值时,这个键不会作为 `stringData` 提交(否则 K8s 合并 `stringData` 时会把 `data` 中的值覆盖成空),而是在 `data` 中保留原值;如果请求 `data` 对同名键给了非空新值,以请求的新值为准。集群中不存在的键按原样写入。副作用是:通过表单无法把一个已有非空值的键改成空值(`data` 和 `string_data` 都不行),需要时请改用 YAML 更新,在 YAML 中写 `""`。请求没有携带 `last-applied-configuration` 注解时,保留集群中的原注解。 - `PUT .../update/yaml`:值仍为 `` 的键保持集群中的原值,用户只需修改需要变更的键。占位符对应的键在集群中不存在时拒绝更新。 - `POST .../create/yaml`:YAML 中含 `` 时拒绝创建。 diff --git a/internal/k8s/api/secret.go b/internal/k8s/api/secret.go index 7c9176ea..957fd747 100644 --- a/internal/k8s/api/secret.go +++ b/internal/k8s/api/secret.go @@ -26,6 +26,8 @@ package api import ( + "errors" + "github.com/GoSimplicity/AI-CloudOps/internal/k8s/service" "github.com/GoSimplicity/AI-CloudOps/internal/model" "github.com/GoSimplicity/AI-CloudOps/pkg/base" @@ -298,7 +300,28 @@ func (h *K8sSecretHandler) RevealSecret(ctx *gin.Context) { ctx.Header("Cache-Control", "no-store") ctx.Header("Pragma", "no-cache") - base.HandleRequest(ctx, &req, func() (interface{}, error) { + handleAuditedRequest(ctx, &req, func() (interface{}, error) { return h.secretService.RevealSecret(ctx.Request.Context(), &req, actor) }) } + +// handleAuditedRequest 与 base.HandleRequest 相同,但同步审计写入失败(service.ErrAuditWriteFailed)时 +// 返回 HTTP 500 而不是 200+code=1,使网关/监控与审计告警能看到真实的失败状态。响应中不含任何明文。 +func handleAuditedRequest(ctx *gin.Context, req interface{}, action func() (interface{}, error)) { + if req != nil { + if err := base.BindRequest(ctx, req); err != nil { + base.BadRequestWithDetails(ctx, err.Error(), "绑定数据失败") + return + } + } + data, err := action() + if err != nil { + if errors.Is(err, service.ErrAuditWriteFailed) { + base.InternalServerErrorWithDetails(ctx, map[string]interface{}{}, err.Error()) + return + } + base.ErrorWithMessage(ctx, err.Error()) + return + } + base.SuccessWithData(ctx, data) +} diff --git a/internal/k8s/api/secret_test.go b/internal/k8s/api/secret_test.go index 2fa7332d..e59e0294 100644 --- a/internal/k8s/api/secret_test.go +++ b/internal/k8s/api/secret_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/base64" "encoding/json" + "errors" "net/http/httptest" "strings" "sync" @@ -128,3 +129,33 @@ func TestRevealEndpointReturnsPlaintextAndAuditsWithoutValues(t *testing.T) { t.Fatalf("审计内容不符: %s", got.RequestBody) } } + +type failingAudit struct { + systemService.AuditService +} + +func (failingAudit) CreateAuditLog(context.Context, *model.CreateAuditLogRequest) error { + return errors.New("audit db unavailable") +} + +// 审计写入失败:HTTP 500、不返回明文、仍带 no-store +func TestRevealEndpointAuditFailureReturns500WithoutPlaintext(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := service.NewSecretService(handlerSecretManager{}, failingAudit{}, zap.NewNop()) + engine := gin.New() + engine.Use(func(c *gin.Context) { c.Set("user", jwt.UserClaims{Uid: 9, Username: "bob"}); c.Next() }) + NewK8sSecretHandler(svc).RegisterRouters(engine) + + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, httptest.NewRequest("GET", "/api/k8s/secret/3/ops/app/reveal", nil)) + if rec.Code != 500 { + t.Fatalf("审计失败应返回 500, got %d", rec.Code) + } + if rec.Header().Get("Cache-Control") != "no-store" { + t.Fatal("失败响应也应禁止缓存") + } + body := rec.Body.String() + if strings.Contains(body, handlerFixtureValue) || strings.Contains(body, base64.StdEncoding.EncodeToString([]byte(handlerFixtureValue))) { + t.Fatal("审计失败时响应中出现了明文") + } +} diff --git a/internal/k8s/service/secret_service.go b/internal/k8s/service/secret_service.go index 4046e6fc..568c8d55 100644 --- a/internal/k8s/service/secret_service.go +++ b/internal/k8s/service/secret_service.go @@ -28,7 +28,9 @@ package service import ( "context" "encoding/json" + stderrors "errors" "fmt" + "net/http" "sort" "strings" "time" @@ -287,7 +289,7 @@ func (s *secretService) UpdateSecret(ctx context.Context, req *model.UpdateSecre updatedSecret := existingSecret.DeepCopy() updatedSecret.Data = preserveRedactedData(map[string][]byte(req.Data), existingSecret.Data) - updatedSecret.StringData = req.StringData + updatedSecret.Data, updatedSecret.StringData = preserveRedactedStringData(req.StringData, updatedSecret.Data, existingSecret) updatedSecret.Labels = req.Labels updatedSecret.Annotations = preserveLastApplied(req.Annotations, existingSecret.Annotations) @@ -490,6 +492,44 @@ func preserveRedactedData(requested, existing map[string][]byte) map[string][]by return out } +// preserveRedactedStringData 与 data 相同按“空值保留原值”处理 stringData。 +// K8s 写入时 stringData 会覆盖 data 中的同名键,若把脱敏回填的空字符串原样提交,会把 data 中的真实值清空。 +// 因此:stringData 中值为空、而集群中该键已有非空值(data 优先,其次 stringData)时, +// 从 stringData 中移除该键,并在 data 中保留原值(请求 data 对同名键给了非空新值时以请求为准)。 +// 集群中不存在或原值为空的键按原样写入。返回处理后的 data 与 stringData。 +func preserveRedactedStringData(requested map[string]string, data map[string][]byte, existing *corev1.Secret) (map[string][]byte, map[string]string) { + if requested == nil { + return data, nil + } + var existingData map[string][]byte + var existingStringData map[string]string + if existing != nil { + existingData, existingStringData = existing.Data, existing.StringData + } + out := make(map[string]string, len(requested)) + for k, v := range requested { + if v == "" { + old, ok := existingData[k] + if !ok || len(old) == 0 { + if s, sok := existingStringData[k]; sok && s != "" { + old, ok = []byte(s), true + } + } + if ok && len(old) > 0 { + if data == nil { + data = map[string][]byte{} + } + if cur, set := data[k]; !set || len(cur) == 0 { + data[k] = old + } + continue + } + } + out[k] = v + } + return data, out +} + // preserveLastApplied 列表/详情不再返回 last-applied-configuration,更新时若请求未携带则保留集群中的原注解 func preserveLastApplied(requested, existing map[string]string) map[string]string { old, ok := existing[k8sutils.LastAppliedConfigAnnotation] @@ -527,7 +567,7 @@ func (s *secretService) RevealSecret(ctx context.Context, req *model.RevealSecre zap.String("name", req.Name)) userErr = fmt.Errorf("获取Secret失败: %w", err) } - _ = s.writeRevealAudit(ctx, req, actor, normalizeKeys(req.Keys), start, userErr) + _ = s.writeRevealAudit(ctx, req, actor, normalizeKeys(req.Keys), start, k8sErrorStatusCode(err), userErr) return nil, userErr } @@ -545,19 +585,24 @@ func (s *secretService) RevealSecret(ctx context.Context, req *model.RevealSecre } else { result.Data = model.BinaryDataMap{} for _, k := range wanted { + found := false if v, ok := secret.Data[k]; ok { result.Data[k] = v - continue + found = true } + // data 与 stringData 存在同名键时两者都返回,与不指定 keys 时的输出保持一致 if v, ok := secret.StringData[k]; ok { if result.StringData == nil { result.StringData = map[string]string{} } result.StringData[k] = v + found = true + } + if found { continue } keyErr := fmt.Errorf("Secret %s/%s 中不存在键 %s", req.Namespace, req.Name, k) - _ = s.writeRevealAudit(ctx, req, actor, wanted, start, keyErr) + _ = s.writeRevealAudit(ctx, req, actor, wanted, start, http.StatusBadRequest, keyErr) return nil, keyErr } } @@ -575,12 +620,12 @@ func (s *secretService) RevealSecret(ctx context.Context, req *model.RevealSecre } sort.Strings(result.Keys) - if err := s.writeRevealAudit(ctx, req, actor, result.Keys, start, nil); err != nil { + if err := s.writeRevealAudit(ctx, req, actor, result.Keys, start, http.StatusOK, nil); err != nil { s.logger.Error("写入Secret明文查看审计失败,拒绝返回明文", zap.Error(err), zap.Int("cluster_id", req.ClusterID), zap.String("namespace", req.Namespace), zap.String("name", req.Name)) - return nil, fmt.Errorf("写入审计失败,已拒绝返回Secret明文") + return nil, fmt.Errorf("%w,已拒绝返回Secret明文", ErrAuditWriteFailed) } s.logger.Info("Secret明文已查看", @@ -614,8 +659,30 @@ func normalizeKeys(keys []string) []string { return out } -// writeRevealAudit 同步写入 reveal 审计;内容只含元数据与键名 -func (s *secretService) writeRevealAudit(ctx context.Context, req *model.RevealSecretReq, actor model.SecretRevealActor, keys []string, start time.Time, opErr error) error { +// ErrAuditWriteFailed 敏感读取接口(Secret reveal 等)同步审计写入失败。 +// handler 据此返回 HTTP 500,且不返回任何明文。 +var ErrAuditWriteFailed = stderrors.New("写入审计失败") + +// k8sErrorStatusCode 将读取 K8s 资源的错误映射为审计记录中的 HTTP 状态码: +// 优先使用 K8s API 返回的状态码(404/403 等),无法识别时记为 500。 +func k8sErrorStatusCode(err error) int { + if err == nil { + return http.StatusOK + } + var status errors.APIStatus + if stderrors.As(err, &status) { + if code := int(status.Status().Code); code >= 400 && code <= 599 { + return code + } + } + if errors.IsNotFound(err) { + return http.StatusNotFound + } + return http.StatusInternalServerError +} + +// writeRevealAudit 同步写入 reveal 审计;内容只含元数据与键名。statusCode 为本次操作实际的结果码(成功 200,失败为 4xx/5xx) +func (s *secretService) writeRevealAudit(ctx context.Context, req *model.RevealSecretReq, actor model.SecretRevealActor, keys []string, start time.Time, statusCode int, opErr error) error { if s.auditService == nil { return fmt.Errorf("审计服务未初始化") } @@ -650,7 +717,7 @@ func (s *secretService) writeRevealAudit(ctx context.Context, req *model.RevealS OperationType: model.SecretRevealAuditOperation, TargetType: model.SecretRevealAuditTargetType, TargetID: fmt.Sprintf("%d/%s/%s", req.ClusterID, req.Namespace, req.Name), - StatusCode: 200, + StatusCode: statusCode, RequestBody: body, Duration: time.Since(start).Microseconds(), ErrorMsg: errMsg, diff --git a/internal/k8s/service/secret_service_test.go b/internal/k8s/service/secret_service_test.go index 4f2441ea..af350e63 100644 --- a/internal/k8s/service/secret_service_test.go +++ b/internal/k8s/service/secret_service_test.go @@ -15,7 +15,9 @@ import ( systemService "github.com/GoSimplicity/AI-CloudOps/internal/system/service" "go.uber.org/zap" corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" "sigs.k8s.io/yaml" ) @@ -340,3 +342,171 @@ func TestCreateSecretByYamlRejectsPlaceholder(t *testing.T) { t.Fatal("含占位符的 YAML 不应被创建") } } + +// JSON 更新:stringData 中值为空的键与 data 一样保留原值,不能借 K8s 的 stringData→data 合并把真实值清空 +func TestUpdateSecretKeepsValuesForEmptyStringData(t *testing.T) { + m := &secretManagerFixture{secret: newSecretFixture()} + svc := newTestSecretService(m, &auditFixture{}) + err := svc.UpdateSecret(context.Background(), &model.UpdateSecretReq{ + ClusterID: 1, Namespace: "ops", Name: "app", + // alpha 只在 stringData 中以空值出现;beta 在 data 中给新值、stringData 中为空;gamma 原本只在 stringData 中 + Data: model.BinaryDataMap{"beta": []byte("new-beta")}, + StringData: map[string]string{"alpha": "", "beta": "", "gamma": "", "delta": "", "eps": "new-eps"}, + }) + if err != nil { + t.Fatal(err) + } + u := m.updated + if string(u.Data["alpha"]) != fixtureAlpha { + t.Fatal("stringData 空值应保留 data 中的原值") + } + if string(u.Data["beta"]) != "new-beta" { + t.Fatal("data 中的新值不应被原值覆盖") + } + if string(u.Data["gamma"]) != fixtureBeta { + t.Fatal("stringData 空值应保留 stringData 中的原值") + } + for _, k := range []string{"alpha", "beta", "gamma"} { + if _, ok := u.StringData[k]; ok { + t.Fatalf("保留原值的键 %s 应从 stringData 中移除,否则写入时会覆盖 data", k) + } + } + if v, ok := u.StringData["delta"]; !ok || v != "" { + t.Fatal("集群中不存在的新键空值应原样写入") + } + if u.StringData["eps"] != "new-eps" { + t.Fatal("非空 stringData 应原样写入") + } +} + +func TestUpdateSecretEmptyStringDataWithNilData(t *testing.T) { + m := &secretManagerFixture{secret: newSecretFixture()} + svc := newTestSecretService(m, &auditFixture{}) + if err := svc.UpdateSecret(context.Background(), &model.UpdateSecretReq{ + ClusterID: 1, Namespace: "ops", Name: "app", + StringData: map[string]string{"alpha": ""}, + }); err != nil { + t.Fatal(err) + } + if string(m.updated.Data["alpha"]) != fixtureAlpha || len(m.updated.StringData) != 0 { + t.Fatal("data 为空时 stringData 空值也应保留原值") + } +} + +// 审计记录中的 StatusCode 为实际结果:成功 200,失败为 4xx/5xx +func TestRevealSecretAuditStatusCodes(t *testing.T) { + ctx := context.Background() + req := &model.RevealSecretReq{ClusterID: 1, Namespace: "ops", Name: "app"} + for _, tt := range []struct { + name string + getErr error + keys []string + want int + }{ + {"success", nil, nil, 200}, + {"missing key", nil, []string{"missing"}, 400}, + {"not found", apierrors.NewNotFound(schema.GroupResource{Resource: "secrets"}, "app"), nil, 404}, + {"k8s forbidden", apierrors.NewForbidden(schema.GroupResource{Resource: "secrets"}, "app", errors.New("rbac")), nil, 403}, + {"unknown error", errors.New("connection refused"), nil, 500}, + } { + t.Run(tt.name, func(t *testing.T) { + a := &auditFixture{} + svc := newTestSecretService(&secretManagerFixture{secret: newSecretFixture(), getErr: tt.getErr}, a) + r := *req + r.Keys = tt.keys + _, _ = svc.RevealSecret(ctx, &r, model.SecretRevealActor{UserID: 1}) + if len(a.logs) != 1 || a.logs[0].StatusCode != tt.want { + t.Fatalf("审计 StatusCode 不符: %+v", a.logs) + } + }) + } +} + +func TestRevealSecretAuditFailureIsTyped(t *testing.T) { + svc := newTestSecretService(&secretManagerFixture{secret: newSecretFixture()}, &auditFixture{err: errors.New("db down")}) + _, err := svc.RevealSecret(context.Background(), &model.RevealSecretReq{ClusterID: 1, Namespace: "ops", Name: "app"}, model.SecretRevealActor{UserID: 1}) + if !errors.Is(err, ErrAuditWriteFailed) { + t.Fatalf("审计失败应返回 ErrAuditWriteFailed, got %v", err) + } +} + +// data 与 stringData 存在同名键:脱敏、回写与 reveal 都要正确 +func TestSecretSameKeyInDataAndStringData(t *testing.T) { + newSame := func() *corev1.Secret { + s := newSecretFixture() + s.Data = map[string][]byte{"shared": []byte(fixtureAlpha)} + s.StringData = map[string]string{"shared": fixtureBeta} + return s + } + ctx := context.Background() + + t.Run("detail 脱敏", func(t *testing.T) { + svc := newTestSecretService(&secretManagerFixture{secret: newSame()}, &auditFixture{}) + d, err := svc.GetSecret(ctx, &model.GetSecretDetailsReq{ClusterID: 1, Namespace: "ops", Name: "app"}) + if err != nil { + t.Fatal(err) + } + raw, _ := json.Marshal(d) + assertNoPlaintext(t, "detail", string(raw)) + if v, ok := d.Data["shared"]; !ok || len(v) != 0 { + t.Fatal("data 同名键应保留且值为空") + } + if v, ok := d.StringData["shared"]; !ok || v != "" { + t.Fatal("stringData 同名键应保留且值为空") + } + if len(d.DataKeys) != 1 || d.DataKeys[0].Size != len(fixtureAlpha) || len(d.StringDataKeys) != 1 || d.StringDataKeys[0].Size != len(fixtureBeta) { + t.Fatal("同名键的 size 应各自统计") + } + y, err := svc.GetSecretYAML(ctx, &model.GetSecretYamlReq{ClusterID: 1, Namespace: "ops", Name: "app"}) + if err != nil { + t.Fatal(err) + } + assertNoPlaintext(t, "yaml", y.YAML) + }) + + t.Run("JSON 原样回填保留原值", func(t *testing.T) { + m := &secretManagerFixture{secret: newSame()} + svc := newTestSecretService(m, &auditFixture{}) + if err := svc.UpdateSecret(ctx, &model.UpdateSecretReq{ClusterID: 1, Namespace: "ops", Name: "app", + Data: model.BinaryDataMap{"shared": []byte{}}, StringData: map[string]string{"shared": ""}}); err != nil { + t.Fatal(err) + } + if string(m.updated.Data["shared"]) != fixtureAlpha { + t.Fatal("同名键原样回填后 data 应保留原值") + } + if _, ok := m.updated.StringData["shared"]; ok { + t.Fatal("stringData 空值不应提交,否则会覆盖 data") + } + }) + + t.Run("YAML 原样回写保留原值", func(t *testing.T) { + m := &secretManagerFixture{secret: newSame()} + svc := newTestSecretService(m, &auditFixture{}) + y, _ := svc.GetSecretYAML(ctx, &model.GetSecretYamlReq{ClusterID: 1, Namespace: "ops", Name: "app"}) + if err := svc.UpdateSecretByYaml(ctx, &model.UpdateSecretByYamlReq{ClusterID: 1, Namespace: "ops", Name: "app", YAML: y.YAML}); err != nil { + t.Fatal(err) + } + if string(m.updated.Data["shared"]) != fixtureAlpha || m.updated.StringData["shared"] != fixtureBeta { + t.Fatal("同名键 YAML 回写后应各自恢复原值") + } + }) + + t.Run("reveal 全量与按键返回两处的值", func(t *testing.T) { + a := &auditFixture{} + svc := newTestSecretService(&secretManagerFixture{secret: newSame()}, a) + for _, keys := range [][]string{nil, {"shared"}} { + out, err := svc.RevealSecret(ctx, &model.RevealSecretReq{ClusterID: 1, Namespace: "ops", Name: "app", Keys: keys}, model.SecretRevealActor{UserID: 1}) + if err != nil { + t.Fatal(err) + } + if string(out.Data["shared"]) != fixtureAlpha || out.StringData["shared"] != fixtureBeta { + t.Fatalf("keys=%v 时应同时返回 data 与 stringData 的值", keys) + } + if strings.Join(out.Keys, ",") != "shared" { + t.Fatalf("同名键在 keys 中只应出现一次: %v", out.Keys) + } + } + raw, _ := json.Marshal(a.logs) + assertNoPlaintext(t, "audit", string(raw)) + }) +} diff --git a/internal/k8s/utils/secret_redact_test.go b/internal/k8s/utils/secret_redact_test.go new file mode 100644 index 00000000..9492439c --- /dev/null +++ b/internal/k8s/utils/secret_redact_test.go @@ -0,0 +1,121 @@ +package utils + +import ( + "encoding/base64" + "strings" + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/yaml" +) + +// 测试用的明文(非真实凭据) +const ( + redactFixtureData = "plain-redact-data-fixture-01" + redactFixtureString = "plain-redact-string-fixture-02" + redactFixtureNew = "plain-redact-new-fixture-03" +) + +func parseSecretYAML(t *testing.T, y string) *corev1.Secret { + t.Helper() + var sec corev1.Secret + if err := yaml.Unmarshal([]byte(y), &sec); err != nil { + t.Fatalf("解析恢复后的 YAML 失败: %v", err) + } + return &sec +} + +// stringData 中的占位符在现有 Secret 的 stringData 中不存在、只在 data 中存在时, +// 应从 stringData 移除并以 data 形式恢复原值(secret_redact.go 中 stringData→data 分支)。 +func TestRestoreRedactedSecretYAMLStringDataPlaceholderFallsBackToData(t *testing.T) { + existing := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "app", Namespace: "ops"}, + Data: map[string][]byte{"token": []byte(redactFixtureData)}, + } + + t.Run("data 中未出现该键时补入 data", func(t *testing.T) { + in := "apiVersion: v1\nkind: Secret\nmetadata:\n name: app\n namespace: ops\nstringData:\n token: " + SecretRedactedPlaceholder + "\n" + out, err := RestoreRedactedSecretYAML(in, existing) + if err != nil { + t.Fatal(err) + } + sec := parseSecretYAML(t, out) + if _, ok := sec.StringData["token"]; ok { + t.Fatal("占位符键应从 stringData 中移除") + } + if string(sec.Data["token"]) != redactFixtureData { + t.Fatalf("应以 data 形式恢复原值, got %d bytes", len(sec.Data["token"])) + } + if strings.Contains(out, SecretRedactedPlaceholder) { + t.Fatal("恢复后的 YAML 不应再含占位符") + } + }) + + t.Run("data 中已有同名新值时以 data 为准", func(t *testing.T) { + in := "apiVersion: v1\nkind: Secret\nmetadata:\n name: app\n namespace: ops\ndata:\n token: " + + base64.StdEncoding.EncodeToString([]byte(redactFixtureNew)) + "\nstringData:\n token: " + SecretRedactedPlaceholder + "\n" + out, err := RestoreRedactedSecretYAML(in, existing) + if err != nil { + t.Fatal(err) + } + sec := parseSecretYAML(t, out) + if _, ok := sec.StringData["token"]; ok { + t.Fatal("占位符键应从 stringData 中移除,否则 K8s 合并时会覆盖 data") + } + if string(sec.Data["token"]) != redactFixtureNew { + t.Fatal("data 中用户填写的新值不应被原值覆盖") + } + }) + + t.Run("data 与 stringData 均不存在时拒绝", func(t *testing.T) { + in := "apiVersion: v1\nkind: Secret\nmetadata:\n name: app\n namespace: ops\nstringData:\n missing: " + SecretRedactedPlaceholder + "\n" + if _, err := RestoreRedactedSecretYAML(in, existing); err == nil { + t.Fatal("未知键的占位符应报错") + } + }) +} + +// data 与 stringData 存在同名键时:脱敏 YAML 两处都替换为占位符;原样回写后两处各自恢复原值。 +func TestRedactAndRestoreSameKeyInDataAndStringData(t *testing.T) { + existing := &corev1.Secret{ + TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Secret"}, + ObjectMeta: metav1.ObjectMeta{Name: "app", Namespace: "ops"}, + Data: map[string][]byte{"shared": []byte(redactFixtureData)}, + StringData: map[string]string{"shared": redactFixtureString}, + } + y, err := SecretToYAML(existing) + if err != nil { + t.Fatal(err) + } + for _, v := range []string{redactFixtureData, redactFixtureString, base64.StdEncoding.EncodeToString([]byte(redactFixtureData))} { + if strings.Contains(y, v) { + t.Fatal("脱敏 YAML 泄露了明文") + } + } + if strings.Count(y, "shared: "+SecretRedactedPlaceholder) != 2 { + t.Fatalf("data 与 stringData 的同名键都应为占位符:\n%s", y) + } + + out, err := RestoreRedactedSecretYAML(y, existing) + if err != nil { + t.Fatal(err) + } + sec := parseSecretYAML(t, out) + if string(sec.Data["shared"]) != redactFixtureData || sec.StringData["shared"] != redactFixtureString { + t.Fatal("同名键原样回写后 data 与 stringData 应各自恢复原值") + } + + if got := RedactSecretData(existing.Data); len(got["shared"]) != 0 { + t.Fatal("data 值应被清空") + } + if got := RedactSecretStringData(existing.StringData); got["shared"] != "" { + t.Fatal("stringData 值应被清空") + } + if k := SecretDataKeyInfos(existing.Data); len(k) != 1 || k[0].Size != len(redactFixtureData) { + t.Fatalf("data_keys 不符: %+v", k) + } + if k := SecretStringDataKeyInfos(existing.StringData); len(k) != 1 || k[0].Size != len(redactFixtureString) { + t.Fatalf("string_data_keys 不符: %+v", k) + } +} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index 13ff2850..cdc0a11c 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -46,7 +46,8 @@ var skipAuthPaths = map[string]bool{ "/favicon.ico": true, } -// explicitGrantRoutes 敏感路由只接受按路由显式授予的权限,通配符(如 /api/k8s/*)不覆盖 +// explicitGrantRoutes 敏感路由只接受按路由显式授予的权限:授权路径必须与路由模板(gin FullPath)逐字相等且方法一致。 +// 通配符(/api/k8s/*、/*)、参数化路径(如 /api/k8s/secret/:a/:b/:c/:action)以及具体请求路径都不覆盖。 var explicitGrantRoutes = map[string]bool{ model.K8sSecretRevealRoute: true, } @@ -194,7 +195,12 @@ func (am *AuthMiddleware) CheckAuth() gin.HandlerFunc { // 检查API权限 for _, api := range role.Apis { - if explicitGrantRoutes[c.FullPath()] && strings.Contains(api.Path, "*") { + if explicitGrantRoutes[c.FullPath()] { + // 敏感路由只认精确匹配,不走 matchWildcardPath 的通配/参数段匹配 + if api.Path == c.FullPath() && api.Method == methodCode { + c.Next() + return + } continue } if (api.Path == c.FullPath() && api.Method == methodCode) || matchWildcardPath(api.Path, path, methodCode, api.Method) { diff --git a/internal/middleware/auth_secret_reveal_test.go b/internal/middleware/auth_secret_reveal_test.go index e8dad3a2..1de3f29a 100644 --- a/internal/middleware/auth_secret_reveal_test.go +++ b/internal/middleware/auth_secret_reveal_test.go @@ -22,6 +22,15 @@ func TestSecretRevealRequiresExplicitGrant(t *testing.T) { {"global wildcard does not cover reveal", []*model.Api{{Path: "/*", Method: 1}}, "/api/k8s/secret/1/ops/app/reveal", 403}, {"detail grant does not cover reveal", []*model.Api{{Path: detailRoute, Method: 1}}, "/api/k8s/secret/1/ops/app/reveal", 403}, {"module wildcard still covers detail", []*model.Api{{Path: "/api/k8s/*", Method: 1}}, "/api/k8s/secret/1/ops/app/detail", 204}, + // 参数化路径(不含 *)在 matchWildcardPath 中按段匹配,但敏感路由只认与路由模板逐字相等的授权 + {"parameterized action path does not cover reveal", []*model.Api{{Path: "/api/k8s/secret/:cluster_id/:namespace/:name/:action", Method: 1}}, "/api/k8s/secret/1/ops/app/reveal", 403}, + {"renamed params do not cover reveal", []*model.Api{{Path: "/api/k8s/secret/:a/:b/:c/reveal", Method: 1}}, "/api/k8s/secret/1/ops/app/reveal", 403}, + {"fully parameterized path does not cover reveal", []*model.Api{{Path: "/:a/:b/:c/:d/:e/:f/:g", Method: 1}}, "/api/k8s/secret/1/ops/app/reveal", 403}, + {"concrete request path does not cover reveal", []*model.Api{{Path: "/api/k8s/secret/1/ops/app/reveal", Method: 1}}, "/api/k8s/secret/1/ops/app/reveal", 403}, + {"middle wildcard does not cover reveal", []*model.Api{{Path: "/api/k8s/*/reveal", Method: 1}}, "/api/k8s/secret/1/ops/app/reveal", 403}, + {"reveal route with wrong method does not cover reveal", []*model.Api{{Path: model.K8sSecretRevealRoute, Method: 2}}, "/api/k8s/secret/1/ops/app/reveal", 403}, + {"exact grant among wildcard grants", []*model.Api{{Path: "/*", Method: 1}, {Path: model.K8sSecretRevealRoute, Method: 1}}, "/api/k8s/secret/1/ops/app/reveal", 204}, + {"parameterized grant still covers detail", []*model.Api{{Path: "/api/k8s/secret/:a/:b/:c/detail", Method: 1}}, "/api/k8s/secret/1/ops/app/detail", 204}, } { t.Run(tt.name, func(t *testing.T) { engine := gin.New() @@ -37,3 +46,15 @@ func TestSecretRevealRequiresExplicitGrant(t *testing.T) { }) } } + +func TestSecretRevealAdminBypass(t *testing.T) { + engine := gin.New() + engine.Use(func(c *gin.Context) { c.Set("user", jwt.UserClaims{Uid: 1, Username: "admin"}); c.Next() }) + engine.Use(NewAuthMiddleware(aiProxyRoles{}).CheckAuth()) + engine.GET(model.K8sSecretRevealRoute, func(c *gin.Context) { c.Status(204) }) + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, httptest.NewRequest("GET", "/api/k8s/secret/1/ops/app/reveal", nil)) + if rec.Code != 204 { + t.Fatalf("admin 应直接放行, status=%d", rec.Code) + } +} From b8565a640199fb26bc30457db3edcf2dbcf894e9 Mon Sep 17 00:00:00 2001 From: Bamboo <13664854532@163.com> Date: Sat, 24 Oct 2026 00:19:10 +0800 Subject: [PATCH 04/14] =?UTF-8?q?fix(k8s):=20SEC-29=20ServiceAccount=20tok?= =?UTF-8?q?en=20=E5=BD=92=E5=B1=9E=E6=A0=A1=E9=AA=8C=E3=80=81=E6=98=BE?= =?UTF-8?q?=E5=BC=8F=E6=8E=88=E6=9D=83=E4=B8=8E=E5=90=8C=E6=AD=A5=E5=AE=A1?= =?UTF-8?q?=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /token 只返回注解 service-account.name/uid 与 SA 匹配的 token Secret,借挂的 Secret 被拒绝 - 创建/更新 SA 忽略请求中的 secrets 引用(记 Warn),更新保留集群现有引用;YAML 更新以 URL 的 name/namespace 为准 - GET/POST /token 加入显式授权精确匹配名单,权限码 k8s:serviceaccount:token / k8s:serviceaccount:token:create, 启动时登记到 cl_system_apis,已有自动名称行按条件改名 - 每次调用同步写 TOKEN_REVEAL / TOKEN_CREATE 审计(仅元数据),审计失败不返回 token(HTTP 500),响应 no-store - ServiceAccountService 注入 AuditService,重新生成 wire_gen.go - 文档 docs/release/k8s-serviceaccount-token.md --- docs/release/k8s-serviceaccount-token.md | 106 ++++++ internal/app/permissions.go | 55 ++- internal/app/permissions_named_test.go | 25 ++ internal/app/permissions_test.go | 73 ++++ internal/k8s/api/serviceaccount.go | 34 +- internal/k8s/api/serviceaccount_test.go | 154 +++++++++ .../k8s/manager/serviceaccount_manager.go | 21 +- .../k8s/service/serviceaccount_service.go | 220 +++++++++++- .../service/serviceaccount_service_test.go | 317 ++++++++++++++++++ internal/k8s/utils/serviceaccount.go | 50 +++ .../k8s/utils/serviceaccount_token_test.go | 76 +++++ internal/middleware/auth.go | 3 +- internal/middleware/auth_sa_token_test.go | 72 ++++ internal/model/k8s_serviceaccount.go | 36 ++ pkg/di/wire_gen.go | 2 +- 15 files changed, 1211 insertions(+), 33 deletions(-) create mode 100644 docs/release/k8s-serviceaccount-token.md create mode 100644 internal/k8s/api/serviceaccount_test.go create mode 100644 internal/k8s/service/serviceaccount_service_test.go create mode 100644 internal/k8s/utils/serviceaccount_token_test.go create mode 100644 internal/middleware/auth_sa_token_test.go diff --git a/docs/release/k8s-serviceaccount-token.md b/docs/release/k8s-serviceaccount-token.md new file mode 100644 index 00000000..086ef7cb --- /dev/null +++ b/docs/release/k8s-serviceaccount-token.md @@ -0,0 +1,106 @@ +# K8s ServiceAccount token 接口加固(SEC-29) + +## 结论 + +- `GET /api/k8s/serviceaccount/:cluster_id/:namespace/:name/token` 只返回**属于该 SA 的** token Secret:类型必须为 `kubernetes.io/service-account-token`,注解 `kubernetes.io/service-account.name` 必须等于 SA 名称,若有注解 `kubernetes.io/service-account.uid` 则必须等于 SA 的 UID,命名空间一致且 `token` 非空。仅凭 `sa.secrets` 引用挂进来的其他 SA 的 token Secret(借挂)一律不返回。 +- 创建/更新 SA 时请求里的 `secrets` 引用**被忽略并记 Warn 日志**(只记 Secret 名称),不再写入集群。 +- GET 与 POST(TokenRequest)`/token` 都改为**只认显式授权**:必须按完整路由模板 + 方法授权,`/api/k8s/*`、`/*`、带 `:param` 的模板、具体请求路径都不覆盖。admin 照常放行。 +- 两个接口每次调用都**同步**写一条审计,只含元数据、不含 token;审计写入失败时**不返回 token**(HTTP 500)。响应头带 `Cache-Control: no-store`、`Pragma: no-cache`。 +- 不涉及数据库表结构变化,没有 DDL。启动时只会向 `cl_system_apis` 补登/改名权限行(见第 3 节)。 + +## 1. 接口契约变化 + +### 1.1 GET `/token`(读取 token Secret) + +| 场景 | 改造前 | 改造后 | +| --- | --- | --- | +| SA 自己的 token Secret | 返回 token | 不变 | +| `sa.secrets` 引用了其他 SA 的 token Secret | 返回其中第一个 token(越权) | 跳过;只剩借挂的 Secret 时返回 `code=1`、`msg` 含“没有属于自己的token Secret” | +| 注解 name 相同但 uid 不同(同名 SA 删除重建后残留) | 返回旧 token | 跳过 | +| 无 uid 注解 | 返回 | 只校验 name,返回 | +| 审计写入失败 | 无审计 | HTTP 500,不返回 token | + +返回结构 `{token, expiration_seconds, created_at, expiration_time}` 不变。 + +注意:Kubernetes 1.24+ 默认不再自动生成 token Secret,`sa.secrets` 通常为空,此时 GET 一般返回“没有 token Secret”。推荐改用 POST(TokenRequest)签发短期 token。 + +### 1.2 POST `/token`(TokenRequest) + +- 请求体 `{"expiration_seconds": 600}`;`cluster_id`、`namespace`、`service_account_name` 以 URL 为准,请求体里的同名字段会被 URL 值覆盖。 +- K8s 返回错误时审计记录真实状态码(403/404/…)。 +- **审计写入失败时不返回 token(HTTP 500)。但 token 已由 K8s 签发**,TokenRequest 签发的 token 无法单独吊销,会在 `expiration_seconds` 到期前保持有效(调用方拿不到它)。如需立即失效,只能删除并重建该 SA。 + +### 1.3 创建 / 更新 SA 的 `secrets` 字段 + +| 接口 | 改造后行为 | +| --- | --- | +| `POST .../create`、`POST .../create/yaml` | 请求里的 `secrets` 被清空后再创建,`image_pull_secrets` 不受影响 | +| `PUT .../update` | 忽略请求里的 `secrets`,保留集群中该 SA 现有的 `secrets` 引用(原来 Update 是整体替换,不传会清空控制器维护的引用) | +| `PUT .../update/yaml` | 同上;另外 YAML 中的 `metadata.name` / `metadata.namespace` 被强制改为 URL 中的值,不能借 YAML 改到其他 SA | + +为什么是“忽略 + 日志”而不是“拒绝”:`secrets` 字段在现有 API 契约和前端类型(`k8s_serviceaccount.ts` 的 `secrets?: string[]`)中都存在,并且 detail YAML 里带有集群维护的 `secrets`,前端/用户把 YAML 原样回传时如果直接拒绝会把正常的编辑请求打失败。忽略后既堵住了“把别人的 token Secret 挂到自己 SA 上”的路径,又不破坏现有调用。日志关键字:`忽略请求中的ServiceAccount secrets引用`(仅含 Secret 名称)。 + +## 2. 权限码与授权 + +| 方法 | 路由(`cl_system_apis.path`) | `method` | 权限码(`cl_system_apis.name`) | +| --- | --- | --- | --- | +| GET | `/api/k8s/serviceaccount/:cluster_id/:namespace/:name/token` | 1 | `k8s:serviceaccount:token` | +| POST | `/api/k8s/serviceaccount/:cluster_id/:namespace/:name/token` | 2 | `k8s:serviceaccount:token:create` | + +- `cl_system_apis.name` 有唯一索引,GET 与 POST 不能共用一个名字,所以 POST 用 `:create` 后缀。 +- 授权按 `path + method` 精确匹配:GET 授权不覆盖 POST,反之亦然。 +- 以下授权都**不再**放行 `/token`:`/api/k8s/*`、`/*`、`/api/k8s/serviceaccount/:cluster_id/:namespace/:name/:action`、换了参数名的模板、具体路径如 `/api/k8s/serviceaccount/1/ops/app/token`、中间通配 `/api/k8s/*/token`。 + +## 3. 启动时的权限目录变化(无 DDL) + +- 新实例:启动时 `bootstrapPermissionCatalog` 按上表名称登记两行。 +- 已部署实例:`/token` 路由在 SEC-29 之前就存在,目录里已有按路由自动生成的名称(形如 `GET GetServiceAccountToken-fm 1a2b3c4d`)。启动时**仅当名称仍等于自动生成值**时改成上表的权限码与说明(`UPDATE ... WHERE id = ? AND name = <自动名>`);管理员改过的名称、说明保持不变。改名失败(例如权限码已被其他行占用)只记 Warn 日志,不影响启动,也不影响授权(授权只看 path + method)。 +- 不会自动授予任何角色。 + +## 4. 审计字段 + +写入 `cl_system_audit_logs`,同步写入(不经异步队列): + +| 字段 | GET | POST | +| --- | --- | --- | +| `operation_type` | `TOKEN_REVEAL` | `TOKEN_CREATE` | +| `http_method` | `GET` | `POST` | +| `target_type` | `k8s_serviceaccount_token` | 同左 | +| `target_id` | `//` | 同左 | +| `status_code` | 200 / 404 / K8s 真实状态码 | 200 / K8s 真实状态码 | +| `user_id`、`ip_address`、`user_agent`、`trace_id`、`endpoint`、`duration`、`error_msg` | 照常 | 照常 | +| `response_body` | 空 | 空 | + +`request_body` 为 JSON 元数据(示例值均为虚构): + +```json +{ + "action": "k8s_serviceaccount_token_reveal", + "user_id": 7, + "username": "operator", + "cluster_id": 1, + "namespace": "ops", + "service_account": "app", + "service_account_uid": "00000000-0000-0000-0000-000000000000", + "secret_name": "app-token-xxxxx", + "success": true, + "issued_at": "2026-01-01T00:00:00Z" +} +``` + +POST 的 `action` 为 `k8s_serviceaccount_token_create`,不含 `secret_name`,含 `expiration_seconds`(请求值)。**任何审计字段都不含 token。** 请求失败(SA 不存在、无合法 token Secret、K8s 拒绝)也会写审计,`success=false`。通用审计中间件对 GET 不记录;对 POST 另记一条 `CREATE` 元数据记录,不记录请求体和响应体。 + +## 5. 兼容性影响 + +- **只靠通配符授权(`/api/k8s/*`、`/*` 等)的非 admin 角色将失去 GET/POST `/token`**,返回 403。需要时按第 2 节为角色显式授权对应的 path + method。 +- 依赖“借挂 token Secret”拿 token 的用法失效(这正是要堵的越权路径)。 +- 创建/更新时传 `secrets` 不再生效,但请求不会失败。 +- `/token` 响应不可缓存(`no-store`)。 +- 审计库不可用时 `/token` 返回 HTTP 500。 + +## 6. 回滚 + +- 回滚代码即可,无需数据库回滚。 +- 回滚后 `cl_system_apis` 中残留的行:`k8s:serviceaccount:token`(GET)和 `k8s:serviceaccount:token:create`(POST),或在已部署实例上是被改名后的原行。旧代码启动时按 `(path, method)` 判断已存在,**不会改回自动名称,也不会新增重复行**,残留名称不影响授权。如需恢复原名称,可由 DBA 手动改 `name`;保留不动也无副作用。 +- 回滚后通配符授权重新覆盖 `/token`,借挂 token Secret 重新可被读出,审计和 no-store 一并消失。为 SEC-29 新增的显式授权行在旧代码下仍然有效(旧代码同样接受精确授权)。 +- 回滚期间已写入的 `TOKEN_REVEAL` / `TOKEN_CREATE` 审计记录保留,旧代码不读取也不受影响。 diff --git a/internal/app/permissions.go b/internal/app/permissions.go index c0d82e09..ac261b76 100644 --- a/internal/app/permissions.go +++ b/internal/app/permissions.go @@ -10,6 +10,7 @@ import ( "github.com/GoSimplicity/AI-CloudOps/internal/model" "github.com/gin-gonic/gin" + "go.uber.org/zap" "gorm.io/gorm/clause" ) @@ -28,6 +29,24 @@ var namedPermissions = map[string]namedPermission{ Name: model.K8sSecretRevealPermissionName, Description: "查看 K8s Secret 明文(SEC-25,每次调用写审计;仅显式授权生效,通配符授权不覆盖)", }, + "GET " + model.K8sServiceAccountTokenRoute: { + Name: model.K8sServiceAccountTokenPermissionName, + Description: "读取 K8s ServiceAccount token Secret(SEC-29,每次调用写审计;仅显式授权生效,通配符授权不覆盖)", + }, + "POST " + model.K8sServiceAccountTokenRoute: { + Name: model.K8sServiceAccountTokenCreatePermissionName, + Description: "通过 TokenRequest 签发 K8s ServiceAccount token(SEC-29,每次调用写审计;仅显式授权生效,通配符授权不覆盖)", + }, +} + +// autoPermissionName 未登记权限码的接口按路由自动生成的名称 +func autoPermissionName(route gin.RouteInfo) string { + digest := sha256.Sum256([]byte(route.Method + " " + route.Path)) + handler := route.Handler[strings.LastIndex(route.Handler, ".")+1:] + if len(handler) > 25 { + handler = handler[:25] + } + return fmt.Sprintf("%s %s %x", route.Method, handler, digest[:4]) } // bootstrapPermissionCatalog 补充可发现接口,不授予角色或覆盖管理员维护的元数据 @@ -35,13 +54,15 @@ func (a *App) bootstrapPermissionCatalog(ctx context.Context) error { methods := map[string]int8{"GET": 1, "POST": 2, "PUT": 3, "DELETE": 4} public := map[string]bool{"/api/user/login": true, "/api/user/logout": true, "/api/user/refresh_token": true, "/api/not_auth/getBindIps": true, "/api/not_auth/getTreeNodeBindIps": true} var existing []model.Api - if err := a.db.WithContext(ctx).Select("path", "method").Find(&existing).Error; err != nil { + if err := a.db.WithContext(ctx).Select("id", "name", "path", "method").Find(&existing).Error; err != nil { return err } seen := make(map[string]bool, len(existing)) + existingByKey := make(map[string]model.Api, len(existing)) key := func(method int8, path string) string { return fmt.Sprintf("%d %s", method, path) } for _, api := range existing { seen[key(api.Method, api.Path)] = true + existingByKey[key(api.Method, api.Path)] = api } routes := a.server.Routes() for _, route := range routes { @@ -62,15 +83,14 @@ func (a *App) bootstrapPermissionCatalog(ctx context.Context) error { pending := make([]model.Api, 0) for _, route := range routes { method, ok := methods[route.Method] - if !ok || !strings.HasPrefix(route.Path, "/api/") || public[route.Path] || seen[key(method, route.Path)] { + if !ok || !strings.HasPrefix(route.Path, "/api/") || public[route.Path] { continue } - digest := sha256.Sum256([]byte(route.Method + " " + route.Path)) - handler := route.Handler[strings.LastIndex(route.Handler, ".")+1:] - if len(handler) > 25 { - handler = handler[:25] + if seen[key(method, route.Path)] { + a.renameAutoNamedPermission(ctx, route, existingByKey[key(method, route.Path)]) + continue } - entry := model.Api{Name: fmt.Sprintf("%s %s %x", route.Method, handler, digest[:4]), Path: route.Path, Method: method, Description: route.Method + " " + route.Path, Version: "v1", Category: 2, IsPublic: 0} + entry := model.Api{Name: autoPermissionName(route), Path: route.Path, Method: method, Description: route.Method + " " + route.Path, Version: "v1", Category: 2, IsPublic: 0} if named, ok := namedPermissions[route.Method+" "+route.Path]; ok { entry.Name, entry.Description = named.Name, named.Description } @@ -82,3 +102,24 @@ func (a *App) bootstrapPermissionCatalog(ctx context.Context) error { } return a.db.WithContext(ctx).Clauses(clause.OnConflict{DoNothing: true}).CreateInBatches(pending, 100).Error } + +// renameAutoNamedPermission 已登记过的敏感接口(如 SEC-29 之前就存在的 SA token 路由)在目录中仍是自动生成的名称时, +// 改为稳定的权限码。只在名称仍等于自动生成值时更新(WHERE name = 自动名),管理员改过的名称不覆盖; +// 更新失败(例如权限码已被其他记录占用)只记日志,不影响启动。 +func (a *App) renameAutoNamedPermission(ctx context.Context, route gin.RouteInfo, row model.Api) { + named, ok := namedPermissions[route.Method+" "+route.Path] + if !ok || row.ID == 0 || row.Name == named.Name { + return + } + auto := autoPermissionName(route) + if row.Name != auto { + return + } + err := a.db.WithContext(ctx).Model(&model.Api{}). + Where("id = ? AND name = ?", row.ID, auto). + Updates(map[string]interface{}{"name": named.Name, "description": named.Description}).Error + if err != nil && a.logger != nil { + a.logger.Warn("更新敏感接口权限名失败,保留原名称", + zap.String("path", route.Path), zap.String("method", route.Method), zap.Error(err)) + } +} diff --git a/internal/app/permissions_named_test.go b/internal/app/permissions_named_test.go index 0c0d79a6..99f0c4ca 100644 --- a/internal/app/permissions_named_test.go +++ b/internal/app/permissions_named_test.go @@ -19,3 +19,28 @@ func TestSecretRevealIsRegisteredAsNamedPermission(t *testing.T) { t.Fatal("权限名超过 50 字符") } } + +func TestServiceAccountTokenIsRegisteredAsNamedPermission(t *testing.T) { + names := map[string]bool{} + for _, method := range []string{"GET", "POST"} { + named, ok := namedPermissions[method+" "+model.K8sServiceAccountTokenRoute] + if !ok { + t.Fatalf("%s token 接口未登记到接口目录", method) + } + if named.Description == "" || len(named.Name) > 50 { + t.Fatalf("权限码不符: %+v", named) + } + names[named.Name] = true + } + if !names[model.K8sServiceAccountTokenPermissionName] || !names[model.K8sServiceAccountTokenCreatePermissionName] { + t.Fatalf("权限码不符: %v", names) + } + // cl_system_apis.name 有唯一索引,所有登记的权限码不能重复 + seen := map[string]bool{} + for _, named := range namedPermissions { + if seen[named.Name] { + t.Fatalf("权限码重复: %s", named.Name) + } + seen[named.Name] = true + } +} diff --git a/internal/app/permissions_test.go b/internal/app/permissions_test.go index 8ebdc599..2e175478 100644 --- a/internal/app/permissions_test.go +++ b/internal/app/permissions_test.go @@ -110,3 +110,76 @@ func TestPermissionCatalogNamesSecretReveal(t *testing.T) { t.Fatalf("权限名=%q", row.Name) } } + +func TestPermissionCatalogNamesServiceAccountToken(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + pool, _ := db.DB() + pool.SetMaxOpenConns(1) + defer pool.Close() + if err := db.AutoMigrate(&model.Api{}); err != nil { + t.Fatal(err) + } + engine := gin.New() + handler := func(c *gin.Context) { c.Status(200) } + engine.GET(model.K8sServiceAccountTokenRoute, handler) + engine.POST(model.K8sServiceAccountTokenRoute, handler) + app := &App{db: db, server: engine} + for i := 0; i < 2; i++ { + if err := app.bootstrapPermissionCatalog(context.Background()); err != nil { + t.Fatal(err) + } + } + for method, want := range map[int8]string{1: model.K8sServiceAccountTokenPermissionName, 2: model.K8sServiceAccountTokenCreatePermissionName} { + var rows []model.Api + db.Where("path = ? AND method = ?", model.K8sServiceAccountTokenRoute, method).Find(&rows) + if len(rows) != 1 || rows[0].Name != want { + t.Fatalf("method=%d 权限目录不符: %+v", method, rows) + } + } +} + +// 升级场景:SEC-29 之前 token 路由已按自动名称登记过;自动名称改为权限码,管理员改过的名称保持不变 +func TestPermissionCatalogRenamesAutoNamedServiceAccountToken(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + pool, _ := db.DB() + pool.SetMaxOpenConns(1) + defer pool.Close() + if err := db.AutoMigrate(&model.Api{}); err != nil { + t.Fatal(err) + } + engine := gin.New() + handler := func(c *gin.Context) { c.Status(200) } + engine.GET(model.K8sServiceAccountTokenRoute, handler) + engine.POST(model.K8sServiceAccountTokenRoute, handler) + var getRoute gin.RouteInfo + for _, r := range engine.Routes() { + if r.Method == "GET" { + getRoute = r + } + } + legacy := []model.Api{ + {Name: autoPermissionName(getRoute), Path: model.K8sServiceAccountTokenRoute, Method: 1, Description: "GET " + model.K8sServiceAccountTokenRoute, Version: "v1", Category: 2}, + {Name: "admin custom token name", Path: model.K8sServiceAccountTokenRoute, Method: 2, Description: "custom metadata", Version: "v1", Category: 2}, + } + if err := db.Create(&legacy).Error; err != nil { + t.Fatal(err) + } + app := &App{db: db, server: engine} + if err := app.bootstrapPermissionCatalog(context.Background()); err != nil { + t.Fatal(err) + } + var count int64 + db.Model(&model.Api{}).Count(&count) + var get, post model.Api + db.First(&get, legacy[0].ID) + db.First(&post, legacy[1].ID) + if count != 2 || get.Name != model.K8sServiceAccountTokenPermissionName || post.Name != "admin custom token name" || post.Description != "custom metadata" { + t.Fatalf("升级后权限目录不符: count=%d get=%q post=%q/%q", count, get.Name, post.Name, post.Description) + } +} diff --git a/internal/k8s/api/serviceaccount.go b/internal/k8s/api/serviceaccount.go index e1a03e6f..df980c8c 100644 --- a/internal/k8s/api/serviceaccount.go +++ b/internal/k8s/api/serviceaccount.go @@ -281,8 +281,12 @@ func (h *K8sServiceAccountHandler) GetServiceAccountToken(ctx *gin.Context) { req.Namespace = namespace req.Name = name - base.HandleRequest(ctx, &req, func() (interface{}, error) { - return h.serviceAccountService.GetServiceAccountToken(ctx.Request.Context(), &req) + actor := sensitiveReadActor(ctx) + // token 响应禁止被浏览器或中间代理缓存 + setNoStore(ctx) + + handleAuditedRequest(ctx, &req, func() (interface{}, error) { + return h.serviceAccountService.GetServiceAccountToken(ctx.Request.Context(), &req, actor) }) } @@ -311,7 +315,29 @@ func (h *K8sServiceAccountHandler) CreateServiceAccountToken(ctx *gin.Context) { req.Namespace = namespace req.ServiceAccountName = name - base.HandleRequest(ctx, &req, func() (interface{}, error) { - return h.serviceAccountService.CreateServiceAccountToken(ctx.Request.Context(), &req) + actor := sensitiveReadActor(ctx) + setNoStore(ctx) + + handleAuditedRequest(ctx, &req, func() (interface{}, error) { + return h.serviceAccountService.CreateServiceAccountToken(ctx.Request.Context(), &req, actor) }) } + +// sensitiveReadActor 从登录态中提取敏感接口(Secret reveal、SA token)审计所需的操作者信息 +func sensitiveReadActor(ctx *gin.Context) model.SecretRevealActor { + user := base.GetUserInfoFromGinContext(ctx) + return model.SecretRevealActor{ + UserID: user.UserID, + Username: user.Username, + IP: user.IP, + UserAgent: user.UserAgent, + TraceID: ctx.GetHeader("X-Trace-ID"), + Endpoint: ctx.Request.URL.Path, + } +} + +// setNoStore 敏感响应禁止被浏览器或中间代理缓存 +func setNoStore(ctx *gin.Context) { + ctx.Header("Cache-Control", "no-store") + ctx.Header("Pragma", "no-cache") +} diff --git a/internal/k8s/api/serviceaccount_test.go b/internal/k8s/api/serviceaccount_test.go new file mode 100644 index 00000000..2c33a0ae --- /dev/null +++ b/internal/k8s/api/serviceaccount_test.go @@ -0,0 +1,154 @@ +package api + +import ( + "context" + "encoding/json" + "net/http/httptest" + "strings" + "testing" + + "github.com/GoSimplicity/AI-CloudOps/internal/k8s/manager" + "github.com/GoSimplicity/AI-CloudOps/internal/k8s/service" + "github.com/GoSimplicity/AI-CloudOps/internal/middleware" + "github.com/GoSimplicity/AI-CloudOps/internal/model" + "github.com/GoSimplicity/AI-CloudOps/pkg/jwt" + "github.com/gin-gonic/gin" + "go.uber.org/zap" + authv1 "k8s.io/api/authentication/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" +) + +// 测试用 token(非真实凭据) +const ( + handlerOwnToken = "fake-handler-sa-token-01" + handlerIssuedToken = "fake-handler-sa-token-02" +) + +type handlerSAManager struct { + manager.ServiceAccountManager + tokenTarget string +} + +func (handlerSAManager) GetServiceAccount(_ context.Context, _ int, namespace, name string) (*corev1.ServiceAccount, error) { + return &corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace, UID: types.UID("uid-" + name)}}, nil +} + +func (handlerSAManager) GetServiceAccountSecrets(_ context.Context, _ int, namespace, name string) ([]corev1.Secret, error) { + return []corev1.Secret{{ + ObjectMeta: metav1.ObjectMeta{Name: name + "-token", Namespace: namespace, Annotations: map[string]string{ + corev1.ServiceAccountNameKey: name, corev1.ServiceAccountUIDKey: "uid-" + name, + }}, + Type: corev1.SecretTypeServiceAccountToken, + Data: map[string][]byte{corev1.ServiceAccountTokenKey: []byte(handlerOwnToken)}, + }}, nil +} + +func (m *handlerSAManager) CreateServiceAccountToken(_ context.Context, _ int, _, name string, tr *authv1.TokenRequest) (*authv1.TokenRequest, error) { + m.tokenTarget = name + out := tr.DeepCopy() + out.Status.Token = handlerIssuedToken + return out, nil +} + +func TestServiceAccountTokenRoutesMatchPermissionCode(t *testing.T) { + gin.SetMode(gin.TestMode) + engine := gin.New() + NewK8sServiceAccountHandler(nil).RegisterRouters(engine) + found := map[string]bool{} + for _, r := range engine.Routes() { + if r.Path == model.K8sServiceAccountTokenRoute { + found[r.Method] = true + } + } + if !found["GET"] || !found["POST"] { + t.Fatalf("token 路由与权限码路径不一致: %v", found) + } +} + +func newSATokenEngine(audit service.ServiceAccountService) *gin.Engine { + engine := gin.New() + engine.Use(func(c *gin.Context) { c.Set("user", jwt.UserClaims{Uid: 9, Username: "bob"}); c.Next() }) + NewK8sServiceAccountHandler(audit).RegisterRouters(engine) + return engine +} + +func TestServiceAccountTokenEndpointsNoStoreAndAuditWithoutToken(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, tt := range []struct { + method, body, token, op string + }{ + {"GET", "", handlerOwnToken, model.ServiceAccountTokenRevealAuditOperation}, + // 请求体中的 service_account_name 不能覆盖 URL 中的 SA + {"POST", `{"expiration_seconds":600,"service_account_name":"admin"}`, handlerIssuedToken, model.ServiceAccountTokenCreateAuditOperation}, + } { + t.Run(tt.method, func(t *testing.T) { + audit := &recordingAudit{} + mgr := &handlerSAManager{} + engine := gin.New() + engine.Use(func(c *gin.Context) { c.Set("user", jwt.UserClaims{Uid: 9, Username: "bob"}); c.Next() }) + engine.Use(middleware.NewAuditLogMiddleware(audit, zap.NewNop()).AuditLog()) + NewK8sServiceAccountHandler(service.NewServiceAccountService(mgr, audit, zap.NewNop())).RegisterRouters(engine) + + var req = httptest.NewRequest(tt.method, "/api/k8s/serviceaccount/3/ops/app/token", strings.NewReader(tt.body)) + if tt.body != "" { + req.Header.Set("Content-Type", "application/json") + } + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, req) + if rec.Code != 200 || rec.Header().Get("Cache-Control") != "no-store" || rec.Header().Get("Pragma") != "no-cache" { + t.Fatalf("status=%d headers=%v body=%s", rec.Code, rec.Header(), rec.Body.String()) + } + var resp struct { + Code int `json:"code"` + Data model.ServiceAccountTokenInfo `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil || resp.Data.Token != tt.token { + t.Fatalf("响应不符: %s", rec.Body.String()) + } + if tt.method == "POST" && mgr.tokenTarget != "app" { + t.Fatalf("TokenRequest 目标应为 URL 中的 SA, got %q", mgr.tokenTarget) + } + + audit.mu.Lock() + defer audit.mu.Unlock() + if len(audit.sync) != 1 || audit.sync[0].OperationType != tt.op || audit.sync[0].TargetID != "3/ops/app" || + audit.sync[0].Endpoint != "/api/k8s/serviceaccount/3/ops/app/token" || audit.sync[0].UserID != 9 { + t.Fatalf("同步审计不符: %+v", audit.sync) + } + raw, _ := json.Marshal(append(append([]*model.CreateAuditLogRequest{}, audit.sync...), audit.async...)) + if strings.Contains(string(raw), tt.token) { + t.Fatal("审计记录中出现了 token") + } + for _, l := range audit.async { + if len(l.ResponseBody) != 0 { + t.Fatal("审计中间件不应记录响应体") + } + } + }) + } +} + +func TestServiceAccountTokenEndpointsAuditFailureReturns500WithoutToken(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, method := range []string{"GET", "POST"} { + t.Run(method, func(t *testing.T) { + engine := newSATokenEngine(service.NewServiceAccountService(&handlerSAManager{}, failingAudit{}, zap.NewNop())) + req := httptest.NewRequest(method, "/api/k8s/serviceaccount/3/ops/app/token", strings.NewReader(`{}`)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, req) + if rec.Code != 500 { + t.Fatalf("审计失败应返回 500, got %d", rec.Code) + } + if rec.Header().Get("Cache-Control") != "no-store" { + t.Fatal("失败响应也应禁止缓存") + } + body := rec.Body.String() + if strings.Contains(body, handlerOwnToken) || strings.Contains(body, handlerIssuedToken) { + t.Fatal("审计失败时响应中出现了 token") + } + }) + } +} diff --git a/internal/k8s/manager/serviceaccount_manager.go b/internal/k8s/manager/serviceaccount_manager.go index 5ec1f1be..4b75cb0c 100644 --- a/internal/k8s/manager/serviceaccount_manager.go +++ b/internal/k8s/manager/serviceaccount_manager.go @@ -4,6 +4,7 @@ import ( "context" "github.com/GoSimplicity/AI-CloudOps/internal/k8s/client" + "github.com/GoSimplicity/AI-CloudOps/internal/k8s/utils" "go.uber.org/zap" authv1 "k8s.io/api/authentication/v1" corev1 "k8s.io/api/core/v1" @@ -239,18 +240,30 @@ func (m *serviceAccountManager) GetServiceAccountSecrets(ctx context.Context, cl } func (m *serviceAccountManager) GetServiceAccountTokens(ctx context.Context, clusterID int, namespace, name string) ([]string, error) { + sa, err := m.GetServiceAccount(ctx, clusterID, namespace, name) + if err != nil { + return nil, err + } + secrets, err := m.GetServiceAccountSecrets(ctx, clusterID, namespace, name) if err != nil { return nil, err } + // SEC-29:只返回注解指向本 SA(名称 + UID)的 token,sa.secrets 中借挂的其他 SA token 一律忽略 var tokens []string - for _, secret := range secrets { - if secret.Type == corev1.SecretTypeServiceAccountToken { - if token, exists := secret.Data["token"]; exists { - tokens = append(tokens, string(token)) + for i := range secrets { + if !utils.IsServiceAccountTokenSecretFor(&secrets[i], sa) { + if secrets[i].Type == corev1.SecretTypeServiceAccountToken { + m.logger.Warn("忽略不属于该ServiceAccount的token Secret", + zap.Int("clusterID", clusterID), + zap.String("namespace", namespace), + zap.String("serviceAccountName", name), + zap.String("secretName", secrets[i].Name)) } + continue } + tokens = append(tokens, string(secrets[i].Data[corev1.ServiceAccountTokenKey])) } m.logger.Debug("获取ServiceAccount的Token", diff --git a/internal/k8s/service/serviceaccount_service.go b/internal/k8s/service/serviceaccount_service.go index 501f4b58..47c1a451 100644 --- a/internal/k8s/service/serviceaccount_service.go +++ b/internal/k8s/service/serviceaccount_service.go @@ -27,14 +27,20 @@ package service import ( "context" + "encoding/json" "fmt" + "net/http" + "slices" "time" "github.com/GoSimplicity/AI-CloudOps/internal/k8s/manager" k8sutils "github.com/GoSimplicity/AI-CloudOps/internal/k8s/utils" "github.com/GoSimplicity/AI-CloudOps/internal/model" + systemService "github.com/GoSimplicity/AI-CloudOps/internal/system/service" "go.uber.org/zap" authv1 "k8s.io/api/authentication/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -49,18 +55,22 @@ type ServiceAccountService interface { GetServiceAccountYaml(ctx context.Context, req *model.GetServiceAccountYamlReq) (*model.K8sYaml, error) UpdateServiceAccountYaml(ctx context.Context, req *model.UpdateServiceAccountByYamlReq) error - GetServiceAccountToken(ctx context.Context, req *model.GetServiceAccountTokenReq) (*model.ServiceAccountTokenInfo, error) - CreateServiceAccountToken(ctx context.Context, req *model.CreateServiceAccountTokenReq) (*model.ServiceAccountTokenInfo, error) + // GetServiceAccountToken 返回属于该 SA 的 token Secret 中的 token;每次调用同步写审计(不含 token),审计失败不返回 token + GetServiceAccountToken(ctx context.Context, req *model.GetServiceAccountTokenReq, actor model.SecretRevealActor) (*model.ServiceAccountTokenInfo, error) + // CreateServiceAccountToken 通过 TokenRequest 签发 token;审计规则同 GetServiceAccountToken + CreateServiceAccountToken(ctx context.Context, req *model.CreateServiceAccountTokenReq, actor model.SecretRevealActor) (*model.ServiceAccountTokenInfo, error) } type serviceAccountService struct { serviceAccountManager manager.ServiceAccountManager + auditService systemService.AuditService logger *zap.Logger } -func NewServiceAccountService(serviceAccountManager manager.ServiceAccountManager, logger *zap.Logger) ServiceAccountService { +func NewServiceAccountService(serviceAccountManager manager.ServiceAccountManager, auditService systemService.AuditService, logger *zap.Logger) ServiceAccountService { return &serviceAccountService{ serviceAccountManager: serviceAccountManager, + auditService: auditService, logger: logger, } } @@ -129,6 +139,8 @@ func (s *serviceAccountService) GetServiceAccountDetails(ctx context.Context, re func (s *serviceAccountService) CreateServiceAccount(ctx context.Context, req *model.CreateServiceAccountReq) error { sa := k8sutils.ConvertToK8sServiceAccount(req) + // SEC-29:secrets 引用由集群(token controller)维护,用户提交的引用一律忽略,防止借挂其他 SA 的 token Secret + s.dropUserSecretRefs(sa, req.ClusterID, "create") return s.serviceAccountManager.CreateServiceAccount(ctx, req.ClusterID, req.Namespace, sa) } @@ -141,12 +153,52 @@ func (s *serviceAccountService) UpdateServiceAccount(ctx context.Context, req *m Annotations: req.Annotations, AutomountServiceAccountToken: req.AutomountServiceAccountToken, ImagePullSecrets: req.ImagePullSecrets, - Secrets: req.Secrets, } sa := k8sutils.ConvertToK8sServiceAccount(createReq) + // SEC-29:忽略请求中的 secrets,保留集群中现有的引用(Update 为整体替换,不保留会清空集群维护的引用) + if err := s.keepExistingSecretRefs(ctx, req.ClusterID, req.Namespace, req.Name, sa, req.Secrets, "update"); err != nil { + return err + } return s.serviceAccountManager.UpdateServiceAccount(ctx, req.ClusterID, req.Namespace, sa) } +// dropUserSecretRefs 创建时丢弃用户提交的 secrets 引用并记录告警日志(只记录名称) +func (s *serviceAccountService) dropUserSecretRefs(sa *corev1.ServiceAccount, clusterID int, op string) { + if len(sa.Secrets) == 0 { + return + } + s.logger.Warn("忽略请求中的ServiceAccount secrets引用(SEC-29:该字段由集群维护)", + zap.String("operation", op), + zap.Int("cluster_id", clusterID), + zap.String("namespace", sa.Namespace), + zap.String("name", sa.Name), + zap.Strings("ignored_secrets", k8sutils.ServiceAccountSecretRefNames(sa.Secrets))) + sa.Secrets = nil +} + +// keepExistingSecretRefs 更新时用集群中现有的 secrets 引用覆盖请求值;请求值与现有值不同则记录告警日志 +func (s *serviceAccountService) keepExistingSecretRefs(ctx context.Context, clusterID int, namespace, name string, sa *corev1.ServiceAccount, requested []string, op string) error { + existing, err := s.serviceAccountManager.GetServiceAccount(ctx, clusterID, namespace, name) + if err != nil { + if errors.IsNotFound(err) { + return fmt.Errorf("ServiceAccount不存在: %s/%s", namespace, name) + } + return fmt.Errorf("获取现有ServiceAccount失败: %w", err) + } + existingNames := k8sutils.ServiceAccountSecretRefNames(existing.Secrets) + if requested != nil && !slices.Equal(requested, existingNames) { + s.logger.Warn("忽略请求中的ServiceAccount secrets引用(SEC-29:该字段由集群维护),保留集群现有引用", + zap.String("operation", op), + zap.Int("cluster_id", clusterID), + zap.String("namespace", namespace), + zap.String("name", name), + zap.Strings("ignored_secrets", requested), + zap.Strings("kept_secrets", existingNames)) + } + sa.Secrets = existing.Secrets + return nil +} + func (s *serviceAccountService) DeleteServiceAccount(ctx context.Context, req *model.DeleteServiceAccountReq) error { return s.serviceAccountManager.DeleteServiceAccount(ctx, req.ClusterID, req.Namespace, req.Name, metav1.DeleteOptions{}) } @@ -175,6 +227,19 @@ func (s *serviceAccountService) UpdateServiceAccountYaml(ctx context.Context, re return err } + // 以URL参数为准,避免 YAML 中的 name/namespace 指向另一个 SA + serviceAccount.Namespace = req.Namespace + serviceAccount.Name = req.Name + + var requested []string + if serviceAccount.Secrets != nil { + requested = k8sutils.ServiceAccountSecretRefNames(serviceAccount.Secrets) + } + // SEC-29:YAML 中的 secrets 同样忽略,保留集群现有引用(从详情 YAML 原样回写时不产生变化) + if err := s.keepExistingSecretRefs(ctx, req.ClusterID, req.Namespace, req.Name, serviceAccount, requested, "update_yaml"); err != nil { + return err + } + return s.serviceAccountManager.UpdateServiceAccount(ctx, req.ClusterID, req.Namespace, serviceAccount) } @@ -192,28 +257,90 @@ func (s *serviceAccountService) CreateServiceAccountByYaml(ctx context.Context, zap.String("name", serviceAccount.Name)) } + // SEC-29:YAML 中的 secrets 引用同样忽略 + s.dropUserSecretRefs(serviceAccount, req.ClusterID, "create_yaml") + return s.serviceAccountManager.CreateServiceAccount(ctx, req.ClusterID, serviceAccount.Namespace, serviceAccount) } -func (s *serviceAccountService) GetServiceAccountToken(ctx context.Context, req *model.GetServiceAccountTokenReq) (*model.ServiceAccountTokenInfo, error) { - // Get existing tokens - this is a simplified implementation - tokens, err := s.serviceAccountManager.GetServiceAccountTokens(ctx, req.ClusterID, req.Namespace, req.Name) +// GetServiceAccountToken 只返回注解 kubernetes.io/service-account.name(及存在时的 .uid)指向该 SA 的 token Secret。 +// 每次调用同步写审计(只含元数据,绝不含 token);成功读取后审计写入失败则不返回 token(fail closed)。 +func (s *serviceAccountService) GetServiceAccountToken(ctx context.Context, req *model.GetServiceAccountTokenReq, actor model.SecretRevealActor) (*model.ServiceAccountTokenInfo, error) { + if req == nil { + return nil, fmt.Errorf("请求参数不能为空") + } + start := time.Now() + record := model.ServiceAccountTokenAuditRecord{ + Action: model.ServiceAccountTokenRevealAuditAction, + ClusterID: req.ClusterID, + Namespace: req.Namespace, + ServiceAccount: req.Name, + } + fail := func(status int, err error) error { + _ = s.writeTokenAudit(ctx, actor, record, model.ServiceAccountTokenRevealAuditOperation, "GET", start, status, err) + return err + } + + sa, err := s.serviceAccountManager.GetServiceAccount(ctx, req.ClusterID, req.Namespace, req.Name) if err != nil { - return nil, err + return nil, fail(k8sErrorStatusCode(err), serviceAccountGetError(req.Namespace, req.Name, err)) + } + record.ServiceAccountUID = string(sa.UID) + + secrets, err := s.serviceAccountManager.GetServiceAccountSecrets(ctx, req.ClusterID, req.Namespace, req.Name) + if err != nil { + return nil, fail(k8sErrorStatusCode(err), fmt.Errorf("获取ServiceAccount关联的Secret失败: %w", err)) } - if len(tokens) == 0 { - return nil, fmt.Errorf("no tokens found for service account %s", req.Name) + selected, rejected := k8sutils.SelectServiceAccountTokenSecret(sa, secrets) + if len(rejected) > 0 { + s.logger.Warn("忽略不属于该ServiceAccount的token Secret(注解 service-account.name/uid 不匹配)", + zap.Int("cluster_id", req.ClusterID), + zap.String("namespace", req.Namespace), + zap.String("name", req.Name), + zap.Strings("rejected_secrets", rejected)) + } + if selected == nil { + return nil, fail(http.StatusNotFound, fmt.Errorf("ServiceAccount %s/%s 没有属于自己的token Secret", req.Namespace, req.Name)) } + record.SecretName = selected.Name + + if err := s.writeTokenAudit(ctx, actor, record, model.ServiceAccountTokenRevealAuditOperation, "GET", start, http.StatusOK, nil); err != nil { + s.logger.Error("写入ServiceAccount token查看审计失败,拒绝返回token", zap.Error(err), + zap.Int("cluster_id", req.ClusterID), + zap.String("namespace", req.Namespace), + zap.String("name", req.Name)) + return nil, fmt.Errorf("%w,已拒绝返回ServiceAccount token", ErrAuditWriteFailed) + } + + s.logger.Info("ServiceAccount token已查看", + zap.Int("cluster_id", req.ClusterID), + zap.String("namespace", req.Namespace), + zap.String("name", req.Name), + zap.String("secret_name", selected.Name), + zap.Int("user_id", actor.UserID)) - // Return the first token (simplified) return &model.ServiceAccountTokenInfo{ - Token: tokens[0], + Token: string(selected.Data[corev1.ServiceAccountTokenKey]), + CreatedAt: selected.CreationTimestamp.UTC().Format(time.RFC3339), }, nil } -func (s *serviceAccountService) CreateServiceAccountToken(ctx context.Context, req *model.CreateServiceAccountTokenReq) (*model.ServiceAccountTokenInfo, error) { - // Create token request +// CreateServiceAccountToken 通过 TokenRequest 为 SA 签发 token。审计与 fail closed 规则同 GetServiceAccountToken: +// 签发成功但审计写入失败时不返回 token(已签发的 token 无法撤销,只能等待过期,见发布文档)。 +func (s *serviceAccountService) CreateServiceAccountToken(ctx context.Context, req *model.CreateServiceAccountTokenReq, actor model.SecretRevealActor) (*model.ServiceAccountTokenInfo, error) { + if req == nil { + return nil, fmt.Errorf("请求参数不能为空") + } + start := time.Now() + record := model.ServiceAccountTokenAuditRecord{ + Action: model.ServiceAccountTokenCreateAuditAction, + ClusterID: req.ClusterID, + Namespace: req.Namespace, + ServiceAccount: req.ServiceAccountName, + ExpirationSeconds: req.ExpirationSeconds, + } + tokenRequest := &authv1.TokenRequest{ Spec: authv1.TokenRequestSpec{ ExpirationSeconds: req.ExpirationSeconds, @@ -222,12 +349,73 @@ func (s *serviceAccountService) CreateServiceAccountToken(ctx context.Context, r tokenResp, err := s.serviceAccountManager.CreateServiceAccountToken(ctx, req.ClusterID, req.Namespace, req.ServiceAccountName, tokenRequest) if err != nil { - return nil, err + userErr := fmt.Errorf("创建ServiceAccount token失败: %w", err) + _ = s.writeTokenAudit(ctx, actor, record, model.ServiceAccountTokenCreateAuditOperation, "POST", start, k8sErrorStatusCode(err), userErr) + return nil, userErr + } + + if err := s.writeTokenAudit(ctx, actor, record, model.ServiceAccountTokenCreateAuditOperation, "POST", start, http.StatusOK, nil); err != nil { + s.logger.Error("写入ServiceAccount token签发审计失败,拒绝返回token", zap.Error(err), + zap.Int("cluster_id", req.ClusterID), + zap.String("namespace", req.Namespace), + zap.String("name", req.ServiceAccountName)) + return nil, fmt.Errorf("%w,已拒绝返回ServiceAccount token", ErrAuditWriteFailed) } + s.logger.Info("ServiceAccount token已签发", + zap.Int("cluster_id", req.ClusterID), + zap.String("namespace", req.Namespace), + zap.String("name", req.ServiceAccountName), + zap.Int("user_id", actor.UserID)) + + expiration := req.ExpirationSeconds + if tokenResp.Status.ExpirationTimestamp.IsZero() { + return &model.ServiceAccountTokenInfo{Token: tokenResp.Status.Token, ExpirationSeconds: expiration}, nil + } return &model.ServiceAccountTokenInfo{ Token: tokenResp.Status.Token, - ExpirationSeconds: req.ExpirationSeconds, + ExpirationSeconds: expiration, ExpirationTime: tokenResp.Status.ExpirationTimestamp.Time.Format("2006-01-02T15:04:05Z"), }, nil } + +func serviceAccountGetError(namespace, name string, err error) error { + if errors.IsNotFound(err) { + return fmt.Errorf("ServiceAccount不存在: %s/%s", namespace, name) + } + return fmt.Errorf("获取ServiceAccount失败: %w", err) +} + +// writeTokenAudit 同步写入 token 接口审计;RequestBody 只含元数据,绝不含 token。 +func (s *serviceAccountService) writeTokenAudit(ctx context.Context, actor model.SecretRevealActor, record model.ServiceAccountTokenAuditRecord, operation, method string, start time.Time, statusCode int, opErr error) error { + if s.auditService == nil { + return fmt.Errorf("审计服务未初始化") + } + record.UserID = actor.UserID + record.Username = actor.Username + record.Success = opErr == nil + record.IssuedAt = start.UTC().Format(time.RFC3339) + body, err := json.Marshal(record) + if err != nil { + return err + } + errMsg := "" + if opErr != nil { + errMsg = opErr.Error() + } + return s.auditService.CreateAuditLog(ctx, &model.CreateAuditLogRequest{ + UserID: actor.UserID, + TraceID: actor.TraceID, + IPAddress: actor.IP, + UserAgent: actor.UserAgent, + HttpMethod: method, + Endpoint: actor.Endpoint, + OperationType: operation, + TargetType: model.ServiceAccountTokenAuditTargetType, + TargetID: fmt.Sprintf("%d/%s/%s", record.ClusterID, record.Namespace, record.ServiceAccount), + StatusCode: statusCode, + RequestBody: body, + Duration: time.Since(start).Microseconds(), + ErrorMsg: errMsg, + }) +} diff --git a/internal/k8s/service/serviceaccount_service_test.go b/internal/k8s/service/serviceaccount_service_test.go new file mode 100644 index 00000000..7b704fb7 --- /dev/null +++ b/internal/k8s/service/serviceaccount_service_test.go @@ -0,0 +1,317 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/GoSimplicity/AI-CloudOps/internal/k8s/manager" + "github.com/GoSimplicity/AI-CloudOps/internal/model" + "go.uber.org/zap" + authv1 "k8s.io/api/authentication/v1" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" +) + +// 测试用的 token(非真实凭据),用于断言审计与错误中都不出现 +const ( + fixtureOwnToken = "fake-sa-token-own-0001" + fixtureVictimToken = "fake-sa-token-victim-0002" + fixtureIssuedToken = "fake-sa-token-issued-0003" +) + +type saManagerFixture struct { + manager.ServiceAccountManager + sa *corev1.ServiceAccount + secrets []corev1.Secret + getErr error + created *corev1.ServiceAccount + updated *corev1.ServiceAccount + tokenReq *authv1.TokenRequest + tokenErr error + tokenTarget string +} + +func (m *saManagerFixture) GetServiceAccount(_ context.Context, _ int, _, _ string) (*corev1.ServiceAccount, error) { + if m.getErr != nil { + return nil, m.getErr + } + return m.sa.DeepCopy(), nil +} + +func (m *saManagerFixture) GetServiceAccountSecrets(_ context.Context, _ int, _, _ string) ([]corev1.Secret, error) { + out := make([]corev1.Secret, 0, len(m.secrets)) + for i := range m.secrets { + out = append(out, *m.secrets[i].DeepCopy()) + } + return out, nil +} + +func (m *saManagerFixture) CreateServiceAccount(_ context.Context, _ int, _ string, sa *corev1.ServiceAccount) error { + m.created = sa.DeepCopy() + return nil +} + +func (m *saManagerFixture) UpdateServiceAccount(_ context.Context, _ int, _ string, sa *corev1.ServiceAccount) error { + m.updated = sa.DeepCopy() + return nil +} + +func (m *saManagerFixture) CreateServiceAccountToken(_ context.Context, _ int, _, name string, tr *authv1.TokenRequest) (*authv1.TokenRequest, error) { + m.tokenTarget = name + if m.tokenErr != nil { + return nil, m.tokenErr + } + m.tokenReq = tr.DeepCopy() + out := tr.DeepCopy() + out.Status.Token = fixtureIssuedToken + out.Status.ExpirationTimestamp = metav1.Now() + return out, nil +} + +func newSAFixture() *corev1.ServiceAccount { + return &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{Name: "app", Namespace: "ops", UID: types.UID("uid-app")}, + Secrets: []corev1.ObjectReference{{Name: "app-token"}}, + } +} + +func tokenSecret(name, saName, saUID, token string) corev1.Secret { + ann := map[string]string{corev1.ServiceAccountNameKey: saName} + if saUID != "" { + ann[corev1.ServiceAccountUIDKey] = saUID + } + return corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "ops", Annotations: ann}, + Type: corev1.SecretTypeServiceAccountToken, + Data: map[string][]byte{corev1.ServiceAccountTokenKey: []byte(token)}, + } +} + +var saActor = model.SecretRevealActor{UserID: 7, Username: "operator", IP: "192.0.2.10", Endpoint: "/api/k8s/serviceaccount/1/ops/app/token"} + +func assertNoToken(t *testing.T, v interface{}, tokens ...string) { + t.Helper() + raw, _ := json.Marshal(v) + for _, tok := range tokens { + if strings.Contains(string(raw), tok) { + t.Fatalf("出现了 token: %s", raw) + } + } +} + +func TestGetServiceAccountTokenRejectsBorrowedSecret(t *testing.T) { + for _, tt := range []struct { + name string + secrets []corev1.Secret + }{ + {"借挂其他 SA 的 token Secret", []corev1.Secret{tokenSecret("admin-token", "admin", "uid-admin", fixtureVictimToken)}}, + {"名称相同但 UID 不同(同名 SA 被重建)", []corev1.Secret{tokenSecret("app-token-old", "app", "uid-old", fixtureVictimToken)}}, + {"UID 注解为空串", []corev1.Secret{tokenSecret("app-token-empty", "app", "", fixtureVictimToken)}}, + {"缺少 name 注解", []corev1.Secret{{ + ObjectMeta: metav1.ObjectMeta{Name: "no-ann", Namespace: "ops"}, + Type: corev1.SecretTypeServiceAccountToken, + Data: map[string][]byte{corev1.ServiceAccountTokenKey: []byte(fixtureVictimToken)}, + }}}, + {"非 service-account-token 类型", []corev1.Secret{func() corev1.Secret { + s := tokenSecret("opaque", "app", "uid-app", fixtureVictimToken) + s.Type = corev1.SecretTypeOpaque + return s + }()}}, + {"其他命名空间", []corev1.Secret{func() corev1.Secret { + s := tokenSecret("other-ns", "app", "uid-app", fixtureVictimToken) + s.Namespace = "kube-system" + return s + }()}}, + } { + t.Run(tt.name, func(t *testing.T) { + if tt.name == "UID 注解为空串" { + tt.secrets[0].Annotations[corev1.ServiceAccountUIDKey] = "" + } + audit := &auditFixture{} + svc := NewServiceAccountService(&saManagerFixture{sa: newSAFixture(), secrets: tt.secrets}, audit, zap.NewNop()) + got, err := svc.GetServiceAccountToken(context.Background(), &model.GetServiceAccountTokenReq{ClusterID: 1, Namespace: "ops", Name: "app"}, saActor) + if err == nil || got != nil { + t.Fatalf("借挂的 token 不应返回: %+v", got) + } + assertNoToken(t, err.Error(), fixtureVictimToken) + if len(audit.logs) != 1 || audit.logs[0].StatusCode != 404 || audit.logs[0].OperationType != model.ServiceAccountTokenRevealAuditOperation { + t.Fatalf("拒绝也要写审计: %+v", audit.logs) + } + assertNoToken(t, audit.logs, fixtureVictimToken) + }) + } +} + +func TestGetServiceAccountTokenReturnsOwnSecretOnly(t *testing.T) { + audit := &auditFixture{} + fx := &saManagerFixture{sa: newSAFixture(), secrets: []corev1.Secret{ + tokenSecret("admin-token", "admin", "uid-admin", fixtureVictimToken), + tokenSecret("app-token", "app", "uid-app", fixtureOwnToken), + }} + svc := NewServiceAccountService(fx, audit, zap.NewNop()) + got, err := svc.GetServiceAccountToken(context.Background(), &model.GetServiceAccountTokenReq{ClusterID: 1, Namespace: "ops", Name: "app"}, saActor) + if err != nil || got == nil || got.Token != fixtureOwnToken { + t.Fatalf("应返回 SA 自己的 token: %+v %v", got, err) + } + + // 没有 uid 注解时只校验名称 + fx.secrets = []corev1.Secret{tokenSecret("app-token", "app", "", fixtureOwnToken)} + got, err = svc.GetServiceAccountToken(context.Background(), &model.GetServiceAccountTokenReq{ClusterID: 1, Namespace: "ops", Name: "app"}, saActor) + if err != nil || got.Token != fixtureOwnToken { + t.Fatalf("无 uid 注解时名称匹配应放行: %+v %v", got, err) + } +} + +func TestGetServiceAccountTokenAuditHasMetadataOnly(t *testing.T) { + audit := &auditFixture{} + svc := NewServiceAccountService(&saManagerFixture{sa: newSAFixture(), secrets: []corev1.Secret{tokenSecret("app-token", "app", "uid-app", fixtureOwnToken)}}, audit, zap.NewNop()) + if _, err := svc.GetServiceAccountToken(context.Background(), &model.GetServiceAccountTokenReq{ClusterID: 1, Namespace: "ops", Name: "app"}, saActor); err != nil { + t.Fatal(err) + } + if len(audit.logs) != 1 { + t.Fatalf("应同步写 1 条审计,实际 %d", len(audit.logs)) + } + l := audit.logs[0] + if l.OperationType != "TOKEN_REVEAL" || l.TargetType != model.ServiceAccountTokenAuditTargetType || l.TargetID != "1/ops/app" || + l.StatusCode != 200 || l.HttpMethod != "GET" || l.UserID != 7 || l.IPAddress != "192.0.2.10" || len(l.ResponseBody) != 0 { + t.Fatalf("审计元数据不符: %+v", l) + } + var rec model.ServiceAccountTokenAuditRecord + if err := json.Unmarshal(l.RequestBody, &rec); err != nil { + t.Fatal(err) + } + if rec.SecretName != "app-token" || rec.ServiceAccountUID != "uid-app" || !rec.Success || rec.Username != "operator" { + t.Fatalf("审计内容不符: %s", l.RequestBody) + } + assertNoToken(t, audit.logs, fixtureOwnToken) +} + +func TestGetServiceAccountTokenAuditFailureReturnsNoToken(t *testing.T) { + svc := NewServiceAccountService(&saManagerFixture{sa: newSAFixture(), secrets: []corev1.Secret{tokenSecret("app-token", "app", "uid-app", fixtureOwnToken)}}, + &auditFixture{err: errors.New("audit db unavailable")}, zap.NewNop()) + got, err := svc.GetServiceAccountToken(context.Background(), &model.GetServiceAccountTokenReq{ClusterID: 1, Namespace: "ops", Name: "app"}, saActor) + if got != nil || !errors.Is(err, ErrAuditWriteFailed) { + t.Fatalf("审计失败不应返回 token: %+v %v", got, err) + } + assertNoToken(t, err.Error(), fixtureOwnToken) + + // 审计服务缺失同样 fail closed + svc = NewServiceAccountService(&saManagerFixture{sa: newSAFixture(), secrets: []corev1.Secret{tokenSecret("app-token", "app", "uid-app", fixtureOwnToken)}}, nil, zap.NewNop()) + if got, err := svc.GetServiceAccountToken(context.Background(), &model.GetServiceAccountTokenReq{ClusterID: 1, Namespace: "ops", Name: "app"}, saActor); got != nil || !errors.Is(err, ErrAuditWriteFailed) { + t.Fatalf("未注入审计服务时不应返回 token: %+v %v", got, err) + } +} + +func TestGetServiceAccountTokenSANotFoundAudited(t *testing.T) { + audit := &auditFixture{} + svc := NewServiceAccountService(&saManagerFixture{getErr: apierrors.NewNotFound(schema.GroupResource{Resource: "serviceaccounts"}, "app")}, audit, zap.NewNop()) + if _, err := svc.GetServiceAccountToken(context.Background(), &model.GetServiceAccountTokenReq{ClusterID: 1, Namespace: "ops", Name: "app"}, saActor); err == nil { + t.Fatal("SA 不存在应报错") + } + if len(audit.logs) != 1 || audit.logs[0].StatusCode != 404 { + t.Fatalf("审计状态码应为 404: %+v", audit.logs) + } +} + +func TestCreateServiceAccountTokenAudited(t *testing.T) { + exp := int64(600) + req := &model.CreateServiceAccountTokenReq{ClusterID: 1, Namespace: "ops", ServiceAccountName: "app", ExpirationSeconds: &exp} + + audit := &auditFixture{} + fx := &saManagerFixture{} + svc := NewServiceAccountService(fx, audit, zap.NewNop()) + got, err := svc.CreateServiceAccountToken(context.Background(), req, saActor) + if err != nil || got.Token != fixtureIssuedToken || fx.tokenTarget != "app" || *fx.tokenReq.Spec.ExpirationSeconds != 600 { + t.Fatalf("TokenRequest 结果不符: %+v %v", got, err) + } + if len(audit.logs) != 1 || audit.logs[0].OperationType != "TOKEN_CREATE" || audit.logs[0].HttpMethod != "POST" || audit.logs[0].StatusCode != 200 { + t.Fatalf("TokenRequest 审计不符: %+v", audit.logs) + } + var rec model.ServiceAccountTokenAuditRecord + if err := json.Unmarshal(audit.logs[0].RequestBody, &rec); err != nil || rec.ExpirationSeconds == nil || *rec.ExpirationSeconds != 600 { + t.Fatalf("审计内容不符: %s", audit.logs[0].RequestBody) + } + assertNoToken(t, audit.logs, fixtureIssuedToken) + + // 审计失败:已签发也不返回 + svc = NewServiceAccountService(&saManagerFixture{}, &auditFixture{err: errors.New("audit db unavailable")}, zap.NewNop()) + got, err = svc.CreateServiceAccountToken(context.Background(), req, saActor) + if got != nil || !errors.Is(err, ErrAuditWriteFailed) { + t.Fatalf("审计失败不应返回签发的 token: %+v %v", got, err) + } + assertNoToken(t, err.Error(), fixtureIssuedToken) + + // K8s 拒绝:审计记录真实状态码 + audit = &auditFixture{} + svc = NewServiceAccountService(&saManagerFixture{tokenErr: apierrors.NewForbidden(schema.GroupResource{Resource: "serviceaccounts/token"}, "app", errors.New("denied"))}, audit, zap.NewNop()) + if _, err := svc.CreateServiceAccountToken(context.Background(), req, saActor); err == nil { + t.Fatal("应返回错误") + } + if len(audit.logs) != 1 || audit.logs[0].StatusCode != 403 { + t.Fatalf("审计状态码应为 403: %+v", audit.logs) + } +} + +func TestCreateServiceAccountIgnoresSecretRefs(t *testing.T) { + fx := &saManagerFixture{} + svc := NewServiceAccountService(fx, &auditFixture{}, zap.NewNop()) + err := svc.CreateServiceAccount(context.Background(), &model.CreateServiceAccountReq{ + ClusterID: 1, Namespace: "ops", Name: "app", Secrets: []string{"admin-token"}, ImagePullSecrets: []string{"registry"}, + }) + if err != nil || fx.created == nil || len(fx.created.Secrets) != 0 || len(fx.created.ImagePullSecrets) != 1 { + t.Fatalf("创建时应忽略 secrets、保留 imagePullSecrets: %+v %v", fx.created, err) + } + + fx = &saManagerFixture{} + svc = NewServiceAccountService(fx, &auditFixture{}, zap.NewNop()) + yamlContent := "apiVersion: v1\nkind: ServiceAccount\nmetadata:\n name: app\n namespace: ops\nsecrets:\n- name: admin-token\n" + if err := svc.CreateServiceAccountByYaml(context.Background(), &model.CreateServiceAccountByYamlReq{ClusterID: 1, YamlContent: yamlContent}); err != nil { + t.Fatal(err) + } + if fx.created == nil || len(fx.created.Secrets) != 0 { + t.Fatalf("YAML 创建时应忽略 secrets: %+v", fx.created) + } +} + +func TestUpdateServiceAccountKeepsExistingSecretRefs(t *testing.T) { + fx := &saManagerFixture{sa: newSAFixture()} + svc := NewServiceAccountService(fx, &auditFixture{}, zap.NewNop()) + err := svc.UpdateServiceAccount(context.Background(), &model.UpdateServiceAccountReq{ + ClusterID: 1, Namespace: "ops", Name: "app", Secrets: []string{"admin-token"}, Labels: map[string]string{"team": "ops"}, + }) + if err != nil || len(fx.updated.Secrets) != 1 || fx.updated.Secrets[0].Name != "app-token" || fx.updated.Labels["team"] != "ops" { + t.Fatalf("更新时应忽略请求 secrets、保留集群现有引用: %+v %v", fx.updated, err) + } + + // 不传 secrets 也不能清空集群维护的引用 + fx.updated = nil + if err := svc.UpdateServiceAccount(context.Background(), &model.UpdateServiceAccountReq{ClusterID: 1, Namespace: "ops", Name: "app"}); err != nil { + t.Fatal(err) + } + if len(fx.updated.Secrets) != 1 || fx.updated.Secrets[0].Name != "app-token" { + t.Fatalf("未传 secrets 时应保留现有引用: %+v", fx.updated.Secrets) + } + + // YAML 更新:secrets 被忽略,name/namespace 以 URL 为准 + fx.updated = nil + yamlContent := "apiVersion: v1\nkind: ServiceAccount\nmetadata:\n name: other\n namespace: kube-system\nsecrets:\n- name: admin-token\n" + if err := svc.UpdateServiceAccountYaml(context.Background(), &model.UpdateServiceAccountByYamlReq{ClusterID: 1, Namespace: "ops", Name: "app", YamlContent: yamlContent}); err != nil { + t.Fatal(err) + } + if fx.updated.Name != "app" || fx.updated.Namespace != "ops" || len(fx.updated.Secrets) != 1 || fx.updated.Secrets[0].Name != "app-token" { + t.Fatalf("YAML 更新应忽略 secrets 并以 URL 为准: %+v", fx.updated) + } + + // SA 不存在时不更新 + fx = &saManagerFixture{getErr: apierrors.NewNotFound(schema.GroupResource{Resource: "serviceaccounts"}, "app")} + svc = NewServiceAccountService(fx, &auditFixture{}, zap.NewNop()) + if err := svc.UpdateServiceAccount(context.Background(), &model.UpdateServiceAccountReq{ClusterID: 1, Namespace: "ops", Name: "app"}); err == nil || fx.updated != nil { + t.Fatalf("SA 不存在应报错且不更新: %v", err) + } +} diff --git a/internal/k8s/utils/serviceaccount.go b/internal/k8s/utils/serviceaccount.go index b6420234..6f723885 100644 --- a/internal/k8s/utils/serviceaccount.go +++ b/internal/k8s/utils/serviceaccount.go @@ -245,3 +245,53 @@ func CreateServiceAccountToken(ctx context.Context, kubeClient *kubernetes.Clien return resp, nil } + +// IsServiceAccountTokenSecretFor 判断 secret 是否是 sa 自己的 token Secret(SEC-29): +// 类型必须是 kubernetes.io/service-account-token,注解 kubernetes.io/service-account.name 必须等于 SA 名称, +// 若存在注解 kubernetes.io/service-account.uid 则必须等于 SA 的 UID,且命名空间一致、token 非空。 +// sa.secrets 只是引用列表,任何能更新 SA 的人都能把别的 SA 的 token Secret 挂进来,因此不能仅凭引用信任。 +func IsServiceAccountTokenSecretFor(secret *corev1.Secret, sa *corev1.ServiceAccount) bool { + if secret == nil || sa == nil || sa.Name == "" { + return false + } + if secret.Type != corev1.SecretTypeServiceAccountToken { + return false + } + if secret.Namespace != "" && sa.Namespace != "" && secret.Namespace != sa.Namespace { + return false + } + if secret.Annotations[corev1.ServiceAccountNameKey] != sa.Name { + return false + } + if uid, ok := secret.Annotations[corev1.ServiceAccountUIDKey]; ok && uid != string(sa.UID) { + return false + } + return len(secret.Data[corev1.ServiceAccountTokenKey]) > 0 +} + +// SelectServiceAccountTokenSecret 从 SA 引用的 Secrets 中挑出第一个属于该 SA 的 token Secret; +// rejected 返回被拒绝的 service-account-token 类型 Secret 名称(只含名称,便于记录日志)。 +func SelectServiceAccountTokenSecret(sa *corev1.ServiceAccount, secrets []corev1.Secret) (selected *corev1.Secret, rejected []string) { + for i := range secrets { + secret := &secrets[i] + if IsServiceAccountTokenSecretFor(secret, sa) { + if selected == nil { + selected = secret + } + continue + } + if secret.Type == corev1.SecretTypeServiceAccountToken { + rejected = append(rejected, secret.Name) + } + } + return selected, rejected +} + +// ServiceAccountSecretRefNames 返回 SA secrets 引用中的名称列表 +func ServiceAccountSecretRefNames(refs []corev1.ObjectReference) []string { + names := make([]string, 0, len(refs)) + for _, ref := range refs { + names = append(names, ref.Name) + } + return names +} diff --git a/internal/k8s/utils/serviceaccount_token_test.go b/internal/k8s/utils/serviceaccount_token_test.go new file mode 100644 index 00000000..c0e5a257 --- /dev/null +++ b/internal/k8s/utils/serviceaccount_token_test.go @@ -0,0 +1,76 @@ +package utils + +import ( + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" +) + +func saTokenSecret(name string, ann map[string]string) corev1.Secret { + return corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "ops", Annotations: ann}, + Type: corev1.SecretTypeServiceAccountToken, + Data: map[string][]byte{corev1.ServiceAccountTokenKey: []byte("fake-utils-token")}, + } +} + +func TestIsServiceAccountTokenSecretFor(t *testing.T) { + sa := &corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Name: "app", Namespace: "ops", UID: types.UID("uid-app")}} + own := map[string]string{corev1.ServiceAccountNameKey: "app", corev1.ServiceAccountUIDKey: "uid-app"} + for _, tt := range []struct { + name string + mutate func(*corev1.Secret) + want bool + }{ + {"own secret", func(*corev1.Secret) {}, true}, + {"no uid annotation", func(s *corev1.Secret) { delete(s.Annotations, corev1.ServiceAccountUIDKey) }, true}, + {"name mismatch", func(s *corev1.Secret) { s.Annotations[corev1.ServiceAccountNameKey] = "admin" }, false}, + {"uid mismatch", func(s *corev1.Secret) { s.Annotations[corev1.ServiceAccountUIDKey] = "uid-old" }, false}, + {"empty uid annotation", func(s *corev1.Secret) { s.Annotations[corev1.ServiceAccountUIDKey] = "" }, false}, + {"no annotations", func(s *corev1.Secret) { s.Annotations = nil }, false}, + {"opaque type", func(s *corev1.Secret) { s.Type = corev1.SecretTypeOpaque }, false}, + {"other namespace", func(s *corev1.Secret) { s.Namespace = "kube-system" }, false}, + {"empty token", func(s *corev1.Secret) { s.Data = map[string][]byte{corev1.ServiceAccountTokenKey: nil} }, false}, + } { + t.Run(tt.name, func(t *testing.T) { + ann := map[string]string{} + for k, v := range own { + ann[k] = v + } + s := saTokenSecret("s", ann) + tt.mutate(&s) + if got := IsServiceAccountTokenSecretFor(&s, sa); got != tt.want { + t.Fatalf("got %v want %v", got, tt.want) + } + }) + } + if IsServiceAccountTokenSecretFor(nil, sa) || IsServiceAccountTokenSecretFor(&corev1.Secret{}, nil) { + t.Fatal("nil 输入应返回 false") + } +} + +func TestSelectServiceAccountTokenSecret(t *testing.T) { + sa := &corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Name: "app", Namespace: "ops", UID: types.UID("uid-app")}} + opaque := saTokenSecret("cfg", nil) + opaque.Type = corev1.SecretTypeOpaque + secrets := []corev1.Secret{ + saTokenSecret("admin-token", map[string]string{corev1.ServiceAccountNameKey: "admin"}), + opaque, + saTokenSecret("app-token", map[string]string{corev1.ServiceAccountNameKey: "app", corev1.ServiceAccountUIDKey: "uid-app"}), + } + selected, rejected := SelectServiceAccountTokenSecret(sa, secrets) + if selected == nil || selected.Name != "app-token" { + t.Fatalf("应选中 SA 自己的 token Secret: %+v", selected) + } + if len(rejected) != 1 || rejected[0] != "admin-token" { + t.Fatalf("rejected 应只含借挂的 token Secret: %v", rejected) + } + if s, _ := SelectServiceAccountTokenSecret(sa, secrets[:2]); s != nil { + t.Fatal("没有属于自己的 token Secret 时应返回 nil") + } + if names := ServiceAccountSecretRefNames([]corev1.ObjectReference{{Name: "a"}, {Name: ""}, {Name: "b"}}); len(names) < 2 { + t.Fatalf("names=%v", names) + } +} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index cdc0a11c..d57a18f1 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -49,7 +49,8 @@ var skipAuthPaths = map[string]bool{ // explicitGrantRoutes 敏感路由只接受按路由显式授予的权限:授权路径必须与路由模板(gin FullPath)逐字相等且方法一致。 // 通配符(/api/k8s/*、/*)、参数化路径(如 /api/k8s/secret/:a/:b/:c/:action)以及具体请求路径都不覆盖。 var explicitGrantRoutes = map[string]bool{ - model.K8sSecretRevealRoute: true, + model.K8sSecretRevealRoute: true, + model.K8sServiceAccountTokenRoute: true, // GET 读取 token Secret、POST TokenRequest 签发,均按方法精确授权 } // HTTP方法映射 diff --git a/internal/middleware/auth_sa_token_test.go b/internal/middleware/auth_sa_token_test.go new file mode 100644 index 00000000..4d4edf06 --- /dev/null +++ b/internal/middleware/auth_sa_token_test.go @@ -0,0 +1,72 @@ +package middleware + +import ( + "net/http/httptest" + "testing" + + "github.com/GoSimplicity/AI-CloudOps/internal/model" + "github.com/GoSimplicity/AI-CloudOps/pkg/jwt" + "github.com/gin-gonic/gin" +) + +// SEC-29:GET(token Secret)与 POST(TokenRequest)/token 只接受按路由模板 + 方法的显式授权 +func TestServiceAccountTokenRequiresExplicitGrant(t *testing.T) { + const ( + detailRoute = "/api/k8s/serviceaccount/:cluster_id/:namespace/:name/detail" + tokenPath = "/api/k8s/serviceaccount/1/ops/app/token" + detailPath = "/api/k8s/serviceaccount/1/ops/app/detail" + ) + for _, tt := range []struct { + name string + grants []*model.Api + method string + path string + want int + }{ + {"explicit GET grant", []*model.Api{{Path: model.K8sServiceAccountTokenRoute, Method: 1}}, "GET", tokenPath, 204}, + {"explicit POST grant", []*model.Api{{Path: model.K8sServiceAccountTokenRoute, Method: 2}}, "POST", tokenPath, 204}, + {"GET grant does not cover POST TokenRequest", []*model.Api{{Path: model.K8sServiceAccountTokenRoute, Method: 1}}, "POST", tokenPath, 403}, + {"POST grant does not cover GET", []*model.Api{{Path: model.K8sServiceAccountTokenRoute, Method: 2}}, "GET", tokenPath, 403}, + {"module wildcard does not cover GET token", []*model.Api{{Path: "/api/k8s/*", Method: 1}}, "GET", tokenPath, 403}, + {"module wildcard does not cover POST token", []*model.Api{{Path: "/api/k8s/*", Method: 2}}, "POST", tokenPath, 403}, + {"global wildcard does not cover GET token", []*model.Api{{Path: "/*", Method: 1}}, "GET", tokenPath, 403}, + {"global wildcard does not cover POST token", []*model.Api{{Path: "/*", Method: 2}}, "POST", tokenPath, 403}, + {"parameterized action path does not cover token", []*model.Api{{Path: "/api/k8s/serviceaccount/:cluster_id/:namespace/:name/:action", Method: 1}}, "GET", tokenPath, 403}, + {"renamed params do not cover token", []*model.Api{{Path: "/api/k8s/serviceaccount/:a/:b/:c/token", Method: 2}}, "POST", tokenPath, 403}, + {"fully parameterized path does not cover token", []*model.Api{{Path: "/:a/:b/:c/:d/:e/:f/:g", Method: 1}}, "GET", tokenPath, 403}, + {"concrete request path does not cover token", []*model.Api{{Path: tokenPath, Method: 1}}, "GET", tokenPath, 403}, + {"middle wildcard does not cover token", []*model.Api{{Path: "/api/k8s/*/token", Method: 1}}, "GET", tokenPath, 403}, + {"detail grant does not cover token", []*model.Api{{Path: detailRoute, Method: 1}}, "GET", tokenPath, 403}, + {"exact grant among wildcard grants", []*model.Api{{Path: "/api/k8s/*", Method: 1}, {Path: model.K8sServiceAccountTokenRoute, Method: 1}}, "GET", tokenPath, 204}, + {"module wildcard still covers detail", []*model.Api{{Path: "/api/k8s/*", Method: 1}}, "GET", detailPath, 204}, + } { + t.Run(tt.name, func(t *testing.T) { + engine := gin.New() + engine.Use(func(c *gin.Context) { c.Set("user", jwt.UserClaims{Uid: 7, Username: "operator"}); c.Next() }) + engine.Use(NewAuthMiddleware(aiProxyRoles{apis: tt.grants}).CheckAuth()) + ok := func(c *gin.Context) { c.Status(204) } + engine.GET(model.K8sServiceAccountTokenRoute, ok) + engine.POST(model.K8sServiceAccountTokenRoute, ok) + engine.GET(detailRoute, ok) + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, httptest.NewRequest(tt.method, tt.path, nil)) + if rec.Code != tt.want { + t.Fatalf("status=%d want=%d", rec.Code, tt.want) + } + }) + } +} + +func TestServiceAccountTokenAdminBypass(t *testing.T) { + for _, method := range []string{"GET", "POST"} { + engine := gin.New() + engine.Use(func(c *gin.Context) { c.Set("user", jwt.UserClaims{Uid: 1, Username: "admin"}); c.Next() }) + engine.Use(NewAuthMiddleware(aiProxyRoles{}).CheckAuth()) + engine.Handle(method, model.K8sServiceAccountTokenRoute, func(c *gin.Context) { c.Status(204) }) + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, httptest.NewRequest(method, "/api/k8s/serviceaccount/1/ops/app/token", nil)) + if rec.Code != 204 { + t.Fatalf("%s admin 应直接放行, status=%d", method, rec.Code) + } + } +} diff --git a/internal/model/k8s_serviceaccount.go b/internal/model/k8s_serviceaccount.go index 2ba949ab..be06496a 100644 --- a/internal/model/k8s_serviceaccount.go +++ b/internal/model/k8s_serviceaccount.go @@ -134,3 +134,39 @@ type ServiceAccountTokenInfo struct { CreatedAt string `json:"created_at"` ExpirationTime string `json:"expiration_time"` } + +// K8sServiceAccountTokenRoute ServiceAccount token 接口的完整路由(权限码对应的 path)。 +// GET 读取 SA 名下的 token Secret,POST 通过 TokenRequest 签发新 token;两者都只允许按该路由 + 方法显式授权, +// /api/k8s/*、/* 及带 :param 的通配授权都不生效。 +const K8sServiceAccountTokenRoute = "/api/k8s/serviceaccount/:cluster_id/:namespace/:name/token" + +const ( + // K8sServiceAccountTokenPermissionName GET token 在接口目录(cl_system_apis.name)中的权限名 + K8sServiceAccountTokenPermissionName = "k8s:serviceaccount:token" + // K8sServiceAccountTokenCreatePermissionName POST token(TokenRequest)在接口目录中的权限名; + // cl_system_apis.name 有唯一索引,GET/POST 不能共用同一个名字 + K8sServiceAccountTokenCreatePermissionName = "k8s:serviceaccount:token:create" +) + +const ( + ServiceAccountTokenRevealAuditAction = "k8s_serviceaccount_token_reveal" + ServiceAccountTokenCreateAuditAction = "k8s_serviceaccount_token_create" + ServiceAccountTokenRevealAuditOperation = "TOKEN_REVEAL" + ServiceAccountTokenCreateAuditOperation = "TOKEN_CREATE" + ServiceAccountTokenAuditTargetType = "k8s_serviceaccount_token" +) + +// ServiceAccountTokenAuditRecord token 接口审计内容,写入 AuditLog.RequestBody。只含元数据,绝不含 token。 +type ServiceAccountTokenAuditRecord struct { + Action string `json:"action"` + UserID int `json:"user_id"` + Username string `json:"username"` + ClusterID int `json:"cluster_id"` + Namespace string `json:"namespace"` + ServiceAccount string `json:"service_account"` + ServiceAccountUID string `json:"service_account_uid,omitempty"` + SecretName string `json:"secret_name,omitempty"` // GET:返回的 token 所在 Secret 名称 + ExpirationSeconds *int64 `json:"expiration_seconds,omitempty"` // POST:请求的有效期 + Success bool `json:"success"` + IssuedAt string `json:"issued_at"` // RFC3339 UTC +} diff --git a/pkg/di/wire_gen.go b/pkg/di/wire_gen.go index 2d031e87..e3c91c37 100644 --- a/pkg/di/wire_gen.go +++ b/pkg/di/wire_gen.go @@ -125,7 +125,7 @@ func ProvideApp(cfg *config.Config) (*app.App, error) { statefulSetService := service3.NewStatefulSetService(statefulSetManager, logger) k8sStatefulSetHandler := api3.NewK8sStatefulSetHandler(statefulSetService) serviceAccountManager := manager.NewServiceAccountManager(logger, k8sClient) - serviceAccountService := service3.NewServiceAccountService(serviceAccountManager, logger) + serviceAccountService := service3.NewServiceAccountService(serviceAccountManager, auditService, logger) k8sServiceAccountHandler := api3.NewK8sServiceAccountHandler(serviceAccountService) roleManager := manager.NewRoleManager(k8sClient, logger) serviceRoleService := service3.NewRoleService(roleManager, logger) From 64450ddd99078e3851bdee43dadb0f854aa93530 Mon Sep 17 00:00:00 2001 From: Bamboo <13664854532@163.com> Date: Sat, 24 Oct 2026 00:38:09 +0800 Subject: [PATCH 05/14] =?UTF-8?q?fix(k8s):=20SEC-29=20TokenRequest=20?= =?UTF-8?q?=E6=9C=89=E6=95=88=E6=9C=9F=E6=A0=A1=E9=AA=8C=EF=BC=88=E9=BB=98?= =?UTF-8?q?=E8=AE=A43600=EF=BC=8C600~3600=EF=BC=8C=E8=B6=8A=E7=95=8C400?= =?UTF-8?q?=E5=B9=B6=E5=AE=A1=E8=AE=A1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/release/k8s-serviceaccount-token.md | 19 ++++-- internal/k8s/api/secret.go | 10 ++- internal/k8s/api/serviceaccount_test.go | 57 +++++++++++++++++ .../k8s/service/serviceaccount_service.go | 35 +++++++++-- .../service/serviceaccount_service_test.go | 62 +++++++++++++++++++ internal/model/k8s_serviceaccount.go | 12 +++- 6 files changed, 184 insertions(+), 11 deletions(-) diff --git a/docs/release/k8s-serviceaccount-token.md b/docs/release/k8s-serviceaccount-token.md index 086ef7cb..afee9078 100644 --- a/docs/release/k8s-serviceaccount-token.md +++ b/docs/release/k8s-serviceaccount-token.md @@ -22,13 +22,23 @@ 返回结构 `{token, expiration_seconds, created_at, expiration_time}` 不变。 -注意:Kubernetes 1.24+ 默认不再自动生成 token Secret,`sa.secrets` 通常为空,此时 GET 一般返回“没有 token Secret”。推荐改用 POST(TokenRequest)签发短期 token。 +注意:Kubernetes 1.24 及以上的集群(`LegacyServiceAccountTokenNoAutoGeneration` 默认开启)通常**不会**再为 SA 自动生成 token Secret,`sa.secrets` 一般为空,GET `/token` 基本拿不到结果(返回 `code=1`,提示没有属于自己的 token Secret)。除非手工创建了带 `kubernetes.io/service-account.name` 注解的 `kubernetes.io/service-account-token` Secret,否则**主要入口是 POST(TokenRequest)**签发短期 token。 ### 1.2 POST `/token`(TokenRequest) - 请求体 `{"expiration_seconds": 600}`;`cluster_id`、`namespace`、`service_account_name` 以 URL 为准,请求体里的同名字段会被 URL 值覆盖。 +- `expiration_seconds`(秒): + + | 请求值 | 结果 | + | --- | --- | + | 不传 / `null` / `0` | 按默认 **3600** 签发 | + | `600` ~ `3600`(含边界) | 按请求值签发 | + | `< 600`(含负数)或 `> 3600` | **HTTP 400**,`code=1`,`msg` 说明取值范围;**不调用 K8s、不签发 token**;同步写一条审计(`status_code=400`、`success=false`、`expiration_seconds` 为请求值) | + | 非整数(如字符串) | 绑定失败,HTTP 400(与其他接口的绑定错误一致,此时不进入 service、不写 token 审计) | + + 下限 600 是 K8s TokenRequest 允许的最小值;上限 3600 是本平台的策略上限。校验在调用 K8s 之前完成。响应中的 `expiration_seconds` 为实际签发使用的值(不传时为 3600)。 - K8s 返回错误时审计记录真实状态码(403/404/…)。 -- **审计写入失败时不返回 token(HTTP 500)。但 token 已由 K8s 签发**,TokenRequest 签发的 token 无法单独吊销,会在 `expiration_seconds` 到期前保持有效(调用方拿不到它)。如需立即失效,只能删除并重建该 SA。 +- **审计写入失败时不返回 token(HTTP 500)。但 token 已由 K8s 签发**,TokenRequest 签发的 token 无法单独吊销,会在 `expiration_seconds` 到期前保持有效(调用方拿不到它)。由于有效期上限为 3600,这类“签发了但没交出去”的 token **最多有效 1 小时**。如需立即失效,只能删除并重建该 SA。 ### 1.3 创建 / 更新 SA 的 `secrets` 字段 @@ -67,7 +77,7 @@ | `http_method` | `GET` | `POST` | | `target_type` | `k8s_serviceaccount_token` | 同左 | | `target_id` | `//` | 同左 | -| `status_code` | 200 / 404 / K8s 真实状态码 | 200 / K8s 真实状态码 | +| `status_code` | 200 / 404 / K8s 真实状态码 | 200 / 400(有效期越界)/ K8s 真实状态码 | | `user_id`、`ip_address`、`user_agent`、`trace_id`、`endpoint`、`duration`、`error_msg` | 照常 | 照常 | | `response_body` | 空 | 空 | @@ -88,7 +98,7 @@ } ``` -POST 的 `action` 为 `k8s_serviceaccount_token_create`,不含 `secret_name`,含 `expiration_seconds`(请求值)。**任何审计字段都不含 token。** 请求失败(SA 不存在、无合法 token Secret、K8s 拒绝)也会写审计,`success=false`。通用审计中间件对 GET 不记录;对 POST 另记一条 `CREATE` 元数据记录,不记录请求体和响应体。 +POST 的 `action` 为 `k8s_serviceaccount_token_create`,不含 `secret_name`,含 `expiration_seconds`(生效值:不传时记 3600;越界被拒时记请求值)。**任何审计字段都不含 token。** 请求失败(SA 不存在、无合法 token Secret、有效期越界、K8s 拒绝)也会写审计,`success=false`。通用审计中间件对 GET 不记录;对 POST 另记一条 `CREATE` 元数据记录,不记录请求体和响应体。 ## 5. 兼容性影响 @@ -96,6 +106,7 @@ POST 的 `action` 为 `k8s_serviceaccount_token_create`,不含 `secret_name` - 依赖“借挂 token Secret”拿 token 的用法失效(这正是要堵的越权路径)。 - 创建/更新时传 `secrets` 不再生效,但请求不会失败。 - `/token` 响应不可缓存(`no-store`)。 +- POST `/token` 的 `expiration_seconds` 超出 600~3600 时由原来的“透传给 K8s”改为 HTTP 400;不传时由“交给 K8s 默认”改为由平台显式传 3600(K8s API 默认值同为 3600,实际效果不变)。 - 审计库不可用时 `/token` 返回 HTTP 500。 ## 6. 回滚 diff --git a/internal/k8s/api/secret.go b/internal/k8s/api/secret.go index 957fd747..dce3e4ca 100644 --- a/internal/k8s/api/secret.go +++ b/internal/k8s/api/secret.go @@ -28,6 +28,7 @@ package api import ( "errors" + "github.com/GoSimplicity/AI-CloudOps/internal/constants" "github.com/GoSimplicity/AI-CloudOps/internal/k8s/service" "github.com/GoSimplicity/AI-CloudOps/internal/model" "github.com/GoSimplicity/AI-CloudOps/pkg/base" @@ -306,7 +307,8 @@ func (h *K8sSecretHandler) RevealSecret(ctx *gin.Context) { } // handleAuditedRequest 与 base.HandleRequest 相同,但同步审计写入失败(service.ErrAuditWriteFailed)时 -// 返回 HTTP 500 而不是 200+code=1,使网关/监控与审计告警能看到真实的失败状态。响应中不含任何明文。 +// 返回 HTTP 500 而不是 200+code=1,使网关/监控与审计告警能看到真实的失败状态; +// service 返回的参数错误(BusinessError{constants.ErrInvalidParam})返回 HTTP 400。响应中不含任何明文。 func handleAuditedRequest(ctx *gin.Context, req interface{}, action func() (interface{}, error)) { if req != nil { if err := base.BindRequest(ctx, req); err != nil { @@ -320,6 +322,12 @@ func handleAuditedRequest(ctx *gin.Context, req interface{}, action func() (inte base.InternalServerErrorWithDetails(ctx, map[string]interface{}{}, err.Error()) return } + // 参数错误(如 TokenRequest 有效期越界)按项目约定返回 HTTP 400 + code=1 + var bizErr *base.BusinessError + if errors.As(err, &bizErr) && errors.Is(bizErr.Code, constants.ErrInvalidParam) { + base.BadRequestError(ctx, err.Error()) + return + } base.ErrorWithMessage(ctx, err.Error()) return } diff --git a/internal/k8s/api/serviceaccount_test.go b/internal/k8s/api/serviceaccount_test.go index 2c33a0ae..34a2fabc 100644 --- a/internal/k8s/api/serviceaccount_test.go +++ b/internal/k8s/api/serviceaccount_test.go @@ -29,6 +29,7 @@ const ( type handlerSAManager struct { manager.ServiceAccountManager tokenTarget string + tokenReq *authv1.TokenRequest } func (handlerSAManager) GetServiceAccount(_ context.Context, _ int, namespace, name string) (*corev1.ServiceAccount, error) { @@ -47,6 +48,7 @@ func (handlerSAManager) GetServiceAccountSecrets(_ context.Context, _ int, names func (m *handlerSAManager) CreateServiceAccountToken(_ context.Context, _ int, _, name string, tr *authv1.TokenRequest) (*authv1.TokenRequest, error) { m.tokenTarget = name + m.tokenReq = tr.DeepCopy() out := tr.DeepCopy() out.Status.Token = handlerIssuedToken return out, nil @@ -152,3 +154,58 @@ func TestServiceAccountTokenEndpointsAuditFailureReturns500WithoutToken(t *testi }) } } + +func TestServiceAccountTokenCreateExpirationValidation(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, tt := range []struct { + name, body string + wantHTTP int + wantExp int64 // 传给 K8s 的有效期;0 表示不应调用 K8s + }{ + {"不传", `{}`, 200, 3600}, + {"传0", `{"expiration_seconds":0}`, 200, 3600}, + {"599", `{"expiration_seconds":599}`, 400, 0}, + {"600", `{"expiration_seconds":600}`, 200, 600}, + {"3600", `{"expiration_seconds":3600}`, 200, 3600}, + {"3601", `{"expiration_seconds":3601}`, 400, 0}, + {"负数", `{"expiration_seconds":-1}`, 400, 0}, + } { + t.Run(tt.name, func(t *testing.T) { + audit := &recordingAudit{} + mgr := &handlerSAManager{} + engine := newSATokenEngine(service.NewServiceAccountService(mgr, audit, zap.NewNop())) + req := httptest.NewRequest("POST", "/api/k8s/serviceaccount/3/ops/app/token", strings.NewReader(tt.body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, req) + if rec.Code != tt.wantHTTP || rec.Header().Get("Cache-Control") != "no-store" { + t.Fatalf("status=%d headers=%v body=%s", rec.Code, rec.Header(), rec.Body.String()) + } + var resp struct { + Code int `json:"code"` + Data model.ServiceAccountTokenInfo `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + audit.mu.Lock() + defer audit.mu.Unlock() + if len(audit.sync) != 1 || audit.sync[0].StatusCode != tt.wantHTTP { + t.Fatalf("应写一条状态码为 %d 的审计: %+v", tt.wantHTTP, audit.sync) + } + if tt.wantExp == 0 { + if resp.Code != 1 || resp.Data.Token != "" || strings.Contains(rec.Body.String(), handlerIssuedToken) { + t.Fatalf("越界应返回参数错误且不含 token: %s", rec.Body.String()) + } + if mgr.tokenTarget != "" || mgr.tokenReq != nil { + t.Fatal("越界时不应调用 K8s TokenRequest") + } + return + } + if resp.Code != 0 || resp.Data.Token != handlerIssuedToken || mgr.tokenReq == nil || + mgr.tokenReq.Spec.ExpirationSeconds == nil || *mgr.tokenReq.Spec.ExpirationSeconds != tt.wantExp { + t.Fatalf("应以 %d 秒签发: %s %+v", tt.wantExp, rec.Body.String(), mgr.tokenReq) + } + }) + } +} diff --git a/internal/k8s/service/serviceaccount_service.go b/internal/k8s/service/serviceaccount_service.go index 47c1a451..0d28fe8d 100644 --- a/internal/k8s/service/serviceaccount_service.go +++ b/internal/k8s/service/serviceaccount_service.go @@ -33,10 +33,12 @@ import ( "slices" "time" + "github.com/GoSimplicity/AI-CloudOps/internal/constants" "github.com/GoSimplicity/AI-CloudOps/internal/k8s/manager" k8sutils "github.com/GoSimplicity/AI-CloudOps/internal/k8s/utils" "github.com/GoSimplicity/AI-CloudOps/internal/model" systemService "github.com/GoSimplicity/AI-CloudOps/internal/system/service" + "github.com/GoSimplicity/AI-CloudOps/pkg/base" "go.uber.org/zap" authv1 "k8s.io/api/authentication/v1" corev1 "k8s.io/api/core/v1" @@ -341,9 +343,17 @@ func (s *serviceAccountService) CreateServiceAccountToken(ctx context.Context, r ExpirationSeconds: req.ExpirationSeconds, } + // 有效期校验必须在调用 K8s 之前完成:越界直接拒绝(400),不签发 token,但仍写审计 + expiration, err := normalizeTokenExpiration(req.ExpirationSeconds) + if err != nil { + _ = s.writeTokenAudit(ctx, actor, record, model.ServiceAccountTokenCreateAuditOperation, "POST", start, http.StatusBadRequest, err) + return nil, err + } + record.ExpirationSeconds = &expiration + tokenRequest := &authv1.TokenRequest{ Spec: authv1.TokenRequestSpec{ - ExpirationSeconds: req.ExpirationSeconds, + ExpirationSeconds: &expiration, }, } @@ -368,17 +378,34 @@ func (s *serviceAccountService) CreateServiceAccountToken(ctx context.Context, r zap.String("name", req.ServiceAccountName), zap.Int("user_id", actor.UserID)) - expiration := req.ExpirationSeconds + if tokenResp.Spec.ExpirationSeconds != nil { + expiration = *tokenResp.Spec.ExpirationSeconds + } if tokenResp.Status.ExpirationTimestamp.IsZero() { - return &model.ServiceAccountTokenInfo{Token: tokenResp.Status.Token, ExpirationSeconds: expiration}, nil + return &model.ServiceAccountTokenInfo{Token: tokenResp.Status.Token, ExpirationSeconds: &expiration}, nil } return &model.ServiceAccountTokenInfo{ Token: tokenResp.Status.Token, - ExpirationSeconds: expiration, + ExpirationSeconds: &expiration, ExpirationTime: tokenResp.Status.ExpirationTimestamp.Time.Format("2006-01-02T15:04:05Z"), }, nil } +// normalizeTokenExpiration 校验 TokenRequest 有效期:nil 或 0 取默认值 3600; +// 小于 600 或大于 3600(含负数)返回参数错误(BusinessError{ErrInvalidParam},handler 映射为 HTTP 400)。 +func normalizeTokenExpiration(requested *int64) (int64, error) { + if requested == nil || *requested == 0 { + return model.ServiceAccountTokenDefaultExpirationSeconds, nil + } + v := *requested + if v < model.ServiceAccountTokenMinExpirationSeconds || v > model.ServiceAccountTokenMaxExpirationSeconds { + return 0, base.NewBusinessError(constants.ErrInvalidParam, fmt.Sprintf("expiration_seconds 取值范围为 %d~%d 秒(不传或传 0 默认 %d),当前为 %d", + model.ServiceAccountTokenMinExpirationSeconds, model.ServiceAccountTokenMaxExpirationSeconds, + model.ServiceAccountTokenDefaultExpirationSeconds, v)) + } + return v, nil +} + func serviceAccountGetError(namespace, name string, err error) error { if errors.IsNotFound(err) { return fmt.Errorf("ServiceAccount不存在: %s/%s", namespace, name) diff --git a/internal/k8s/service/serviceaccount_service_test.go b/internal/k8s/service/serviceaccount_service_test.go index 7b704fb7..35f6f3a3 100644 --- a/internal/k8s/service/serviceaccount_service_test.go +++ b/internal/k8s/service/serviceaccount_service_test.go @@ -7,8 +7,10 @@ import ( "strings" "testing" + "github.com/GoSimplicity/AI-CloudOps/internal/constants" "github.com/GoSimplicity/AI-CloudOps/internal/k8s/manager" "github.com/GoSimplicity/AI-CloudOps/internal/model" + "github.com/GoSimplicity/AI-CloudOps/pkg/base" "go.uber.org/zap" authv1 "k8s.io/api/authentication/v1" corev1 "k8s.io/api/core/v1" @@ -315,3 +317,63 @@ func TestUpdateServiceAccountKeepsExistingSecretRefs(t *testing.T) { t.Fatalf("SA 不存在应报错且不更新: %v", err) } } + +func TestCreateServiceAccountTokenExpirationBounds(t *testing.T) { + i64 := func(v int64) *int64 { return &v } + for _, tt := range []struct { + name string + requested *int64 + want int64 // 期望传给 K8s 的值;0 表示应被拒绝 + }{ + {"不传默认3600", nil, 3600}, + {"传0默认3600", i64(0), 3600}, + {"599低于下限", i64(599), 0}, + {"600下限", i64(600), 600}, + {"3600上限", i64(3600), 3600}, + {"3601高于上限", i64(3601), 0}, + {"负数", i64(-1), 0}, + {"负的600", i64(-600), 0}, + } { + t.Run(tt.name, func(t *testing.T) { + audit := &auditFixture{} + fx := &saManagerFixture{} + svc := NewServiceAccountService(fx, audit, zap.NewNop()) + got, err := svc.CreateServiceAccountToken(context.Background(), &model.CreateServiceAccountTokenReq{ + ClusterID: 1, Namespace: "ops", ServiceAccountName: "app", ExpirationSeconds: tt.requested, + }, saActor) + if len(audit.logs) != 1 { + t.Fatalf("应写且只写一条审计: %+v", audit.logs) + } + var rec model.ServiceAccountTokenAuditRecord + if err := json.Unmarshal(audit.logs[0].RequestBody, &rec); err != nil { + t.Fatal(err) + } + if tt.want == 0 { + var bizErr *base.BusinessError + if got != nil || !errors.As(err, &bizErr) || !errors.Is(bizErr.Code, constants.ErrInvalidParam) { + t.Fatalf("越界应返回参数错误且不返回 token: %+v %v", got, err) + } + if fx.tokenTarget != "" || fx.tokenReq != nil { + t.Fatalf("越界时不应调用 K8s TokenRequest: target=%q", fx.tokenTarget) + } + if audit.logs[0].StatusCode != 400 || rec.Success || rec.ExpirationSeconds == nil || *rec.ExpirationSeconds != *tt.requested { + t.Fatalf("越界审计应为 400、success=false、记录请求值: %+v %s", audit.logs[0], audit.logs[0].RequestBody) + } + assertNoToken(t, audit.logs, fixtureIssuedToken) + return + } + if err != nil || got == nil || got.Token != fixtureIssuedToken { + t.Fatalf("应签发成功: %+v %v", got, err) + } + if fx.tokenReq == nil || fx.tokenReq.Spec.ExpirationSeconds == nil || *fx.tokenReq.Spec.ExpirationSeconds != tt.want { + t.Fatalf("传给 K8s 的有效期应为 %d: %+v", tt.want, fx.tokenReq) + } + if got.ExpirationSeconds == nil || *got.ExpirationSeconds != tt.want { + t.Fatalf("响应有效期应为 %d: %+v", tt.want, got.ExpirationSeconds) + } + if audit.logs[0].StatusCode != 200 || rec.ExpirationSeconds == nil || *rec.ExpirationSeconds != tt.want { + t.Fatalf("审计应记录生效有效期 %d: %s", tt.want, audit.logs[0].RequestBody) + } + }) + } +} diff --git a/internal/model/k8s_serviceaccount.go b/internal/model/k8s_serviceaccount.go index be06496a..5e0fa967 100644 --- a/internal/model/k8s_serviceaccount.go +++ b/internal/model/k8s_serviceaccount.go @@ -107,7 +107,7 @@ type CreateServiceAccountTokenReq struct { ClusterID int `json:"cluster_id" binding:"required" comment:"集群ID"` Namespace string `json:"namespace" binding:"required" comment:"命名空间"` ServiceAccountName string `json:"service_account_name" binding:"required" comment:"ServiceAccount名称"` - ExpirationSeconds *int64 `json:"expiration_seconds" comment:"令牌过期时间(秒)"` + ExpirationSeconds *int64 `json:"expiration_seconds" comment:"令牌过期时间(秒),不传或0为3600,允许600~3600"` } // K8sServiceAccount ServiceAccount主model @@ -135,6 +135,14 @@ type ServiceAccountTokenInfo struct { ExpirationTime string `json:"expiration_time"` } +// POST /token(TokenRequest)的有效期约束(秒)。不传或传 0 使用默认值; +// 下限为 K8s TokenRequest 允许的最小值 600,上限 3600,越界返回参数错误且不调用 K8s。 +const ( + ServiceAccountTokenDefaultExpirationSeconds int64 = 3600 + ServiceAccountTokenMinExpirationSeconds int64 = 600 + ServiceAccountTokenMaxExpirationSeconds int64 = 3600 +) + // K8sServiceAccountTokenRoute ServiceAccount token 接口的完整路由(权限码对应的 path)。 // GET 读取 SA 名下的 token Secret,POST 通过 TokenRequest 签发新 token;两者都只允许按该路由 + 方法显式授权, // /api/k8s/*、/* 及带 :param 的通配授权都不生效。 @@ -166,7 +174,7 @@ type ServiceAccountTokenAuditRecord struct { ServiceAccount string `json:"service_account"` ServiceAccountUID string `json:"service_account_uid,omitempty"` SecretName string `json:"secret_name,omitempty"` // GET:返回的 token 所在 Secret 名称 - ExpirationSeconds *int64 `json:"expiration_seconds,omitempty"` // POST:请求的有效期 + ExpirationSeconds *int64 `json:"expiration_seconds,omitempty"` // POST:生效的有效期(越界被拒时为请求值) Success bool `json:"success"` IssuedAt string `json:"issued_at"` // RFC3339 UTC } From 833293e92be0e987d5220a22272234ad1b7b97c6 Mon Sep 17 00:00:00 2001 From: Bamboo <13664854532@163.com> Date: Sat, 24 Oct 2026 00:40:41 +0800 Subject: [PATCH 06/14] =?UTF-8?q?fix(k8s):=20SEC-29=20ServiceAccount=20?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=94=B9=E7=94=A8=20RetryOnConflict=EF=BC=8C?= =?UTF-8?q?=E5=9F=BA=E4=BA=8E=E6=9C=80=E6=96=B0=E7=89=88=E6=9C=AC=E5=BA=94?= =?UTF-8?q?=E7=94=A8=E5=8F=AF=E7=BC=96=E8=BE=91=E5=AD=97=E6=AE=B5=E5=B9=B6?= =?UTF-8?q?=E4=BF=9D=E7=95=99=20secrets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/release/k8s-serviceaccount-token.md | 21 +++ .../k8s/manager/serviceaccount_manager.go | 29 ++++ .../k8s/service/serviceaccount_service.go | 74 +++++----- .../service/serviceaccount_service_test.go | 138 ++++++++++++++++++ internal/k8s/utils/serviceaccount_update.go | 81 ++++++++++ .../k8s/utils/serviceaccount_update_test.go | 117 +++++++++++++++ 6 files changed, 424 insertions(+), 36 deletions(-) create mode 100644 internal/k8s/utils/serviceaccount_update.go create mode 100644 internal/k8s/utils/serviceaccount_update_test.go diff --git a/docs/release/k8s-serviceaccount-token.md b/docs/release/k8s-serviceaccount-token.md index afee9078..9067fdb1 100644 --- a/docs/release/k8s-serviceaccount-token.md +++ b/docs/release/k8s-serviceaccount-token.md @@ -50,6 +50,26 @@ 为什么是“忽略 + 日志”而不是“拒绝”:`secrets` 字段在现有 API 契约和前端类型(`k8s_serviceaccount.ts` 的 `secrets?: string[]`)中都存在,并且 detail YAML 里带有集群维护的 `secrets`,前端/用户把 YAML 原样回传时如果直接拒绝会把正常的编辑请求打失败。忽略后既堵住了“把别人的 token Secret 挂到自己 SA 上”的路径,又不破坏现有调用。日志关键字:`忽略请求中的ServiceAccount secrets引用`(仅含 Secret 名称)。 +### 1.4 更新 SA 改为冲突重试(`PUT .../update`、`PUT .../update/yaml`) + +两条更新路径都改用 `k8s.io/client-go/util/retry` 的 `retry.RetryOnConflict(retry.DefaultRetry, ...)`(`client-go` 原有依赖,`go.mod`/`go.sum` 未改动): + +1. 每次尝试先按 URL 中的 `namespace` / `name` **Get 最新版本**(带最新 `resourceVersion`); +2. 只把请求中允许修改的字段**整体覆盖**到最新版本上:`labels`、`annotations`、`automount_service_account_token`、`image_pull_secrets`; +3. `secrets` 引用保留集群最新版本中的值(请求/YAML 中的 `secrets` 忽略并记 Warn 日志),`name` / `namespace` 以 URL 为准; +4. Update;返回 409 Conflict 时回到第 1 步。 + +| 场景 | 改造前 | 改造后 | +| --- | --- | --- | +| 读取与写入之间 SA 被并发修改(如 token controller 追加了 `secrets` 引用) | 用旧快照整体替换,可能抹掉刚写入的引用 | 冲突后重新读取最新版本再应用字段,保留最新的 `secrets` 引用 | +| 连续冲突超过 `retry.DefaultRetry`(共 5 次尝试,间隔约 10ms) | — | 返回 `code=1`,`msg` 形如 `更新ServiceAccount失败: Operation cannot be fulfilled ...` | +| SA 不存在 | 报“ServiceAccount不存在” | 不变;不会调用 Update | +| YAML 中的 `metadata.resourceVersion` | 透传给 K8s,旧版本 YAML 会直接 409 | **忽略**,以最新版本为基础(对允许修改的字段是“后写覆盖”) | +| YAML 中上述 4 个字段以外的内容(`ownerReferences`、`finalizers`、`uid` 等) | 随整体替换写入 | **不生效**,保留集群现有值 | +| JSON 中未传 `labels` / `annotations` / `image_pull_secrets` / `automount_service_account_token` | 整体替换为空 | 不变(仍是整体覆盖为空),与原契约一致 | + +日志关键字:失败时 `更新ServiceAccount失败`(manager),忽略 secrets 时同 1.3。 + ## 2. 权限码与授权 | 方法 | 路由(`cl_system_apis.path`) | `method` | 权限码(`cl_system_apis.name`) | @@ -105,6 +125,7 @@ POST 的 `action` 为 `k8s_serviceaccount_token_create`,不含 `secret_name` - **只靠通配符授权(`/api/k8s/*`、`/*` 等)的非 admin 角色将失去 GET/POST `/token`**,返回 403。需要时按第 2 节为角色显式授权对应的 path + method。 - 依赖“借挂 token Secret”拿 token 的用法失效(这正是要堵的越权路径)。 - 创建/更新时传 `secrets` 不再生效,但请求不会失败。 +- 更新 SA 改为“读最新版本 + 只应用允许修改的字段 + 冲突重试”:YAML 更新中 `resourceVersion` 及 4 个可编辑字段以外的内容不再生效(见 1.4)。 - `/token` 响应不可缓存(`no-store`)。 - POST `/token` 的 `expiration_seconds` 超出 600~3600 时由原来的“透传给 K8s”改为 HTTP 400;不传时由“交给 K8s 默认”改为由平台显式传 3600(K8s API 默认值同为 3600,实际效果不变)。 - 审计库不可用时 `/token` 返回 HTTP 500。 diff --git a/internal/k8s/manager/serviceaccount_manager.go b/internal/k8s/manager/serviceaccount_manager.go index 4b75cb0c..289f1c95 100644 --- a/internal/k8s/manager/serviceaccount_manager.go +++ b/internal/k8s/manager/serviceaccount_manager.go @@ -19,6 +19,9 @@ type ServiceAccountManager interface { GetServiceAccount(ctx context.Context, clusterID int, namespace, name string) (*corev1.ServiceAccount, error) GetServiceAccountList(ctx context.Context, clusterID int, namespace string, listOptions metav1.ListOptions) (*corev1.ServiceAccountList, error) UpdateServiceAccount(ctx context.Context, clusterID int, namespace string, sa *corev1.ServiceAccount) error + // UpdateServiceAccountWithRetry 冲突时重试的更新:每次先 Get 最新版本,经 mutate 应用允许修改的字段后 Update; + // name/namespace 与 secrets 引用始终以集群最新版本为准 + UpdateServiceAccountWithRetry(ctx context.Context, clusterID int, namespace, name string, mutate utils.ServiceAccountMutateFunc) error DeleteServiceAccount(ctx context.Context, clusterID int, namespace, name string, deleteOptions metav1.DeleteOptions) error // 高级功能 @@ -145,6 +148,32 @@ func (m *serviceAccountManager) UpdateServiceAccount(ctx context.Context, cluste return nil } +func (m *serviceAccountManager) UpdateServiceAccountWithRetry(ctx context.Context, clusterID int, namespace, name string, mutate utils.ServiceAccountMutateFunc) error { + kubeClient, err := m.client.GetKubeClient(clusterID) + if err != nil { + m.logger.Error("获取Kubernetes客户端失败", + zap.Int("clusterID", clusterID), + zap.Error(err)) + return err + } + + if _, err := utils.UpdateServiceAccountWithRetry(ctx, kubeClient.CoreV1().ServiceAccounts(namespace), name, mutate); err != nil { + m.logger.Error("更新ServiceAccount失败", + zap.Int("clusterID", clusterID), + zap.String("namespace", namespace), + zap.String("name", name), + zap.Error(err)) + return err + } + + m.logger.Info("成功更新ServiceAccount", + zap.Int("clusterID", clusterID), + zap.String("namespace", namespace), + zap.String("name", name)) + + return nil +} + func (m *serviceAccountManager) DeleteServiceAccount(ctx context.Context, clusterID int, namespace, name string, deleteOptions metav1.DeleteOptions) error { kubeClient, err := m.client.GetKubeClient(clusterID) if err != nil { diff --git a/internal/k8s/service/serviceaccount_service.go b/internal/k8s/service/serviceaccount_service.go index 0d28fe8d..1c2e57e0 100644 --- a/internal/k8s/service/serviceaccount_service.go +++ b/internal/k8s/service/serviceaccount_service.go @@ -156,12 +156,26 @@ func (s *serviceAccountService) UpdateServiceAccount(ctx context.Context, req *m AutomountServiceAccountToken: req.AutomountServiceAccountToken, ImagePullSecrets: req.ImagePullSecrets, } - sa := k8sutils.ConvertToK8sServiceAccount(createReq) - // SEC-29:忽略请求中的 secrets,保留集群中现有的引用(Update 为整体替换,不保留会清空集群维护的引用) - if err := s.keepExistingSecretRefs(ctx, req.ClusterID, req.Namespace, req.Name, sa, req.Secrets, "update"); err != nil { - return err + desired := k8sutils.ConvertToK8sServiceAccount(createReq) + return s.updateWithRetry(ctx, req.ClusterID, req.Namespace, req.Name, desired, req.Secrets, "update") +} + +// updateWithRetry 用 RetryOnConflict 更新 SA:每次重试先读取最新版本,只把允许修改的字段 +// (labels/annotations/automountServiceAccountToken/imagePullSecrets)应用上去, +// SEC-29:请求中的 secrets 一律忽略,保留集群现有引用;name/namespace 以 URL 为准。 +func (s *serviceAccountService) updateWithRetry(ctx context.Context, clusterID int, namespace, name string, desired *corev1.ServiceAccount, requestedSecrets []string, op string) error { + err := s.serviceAccountManager.UpdateServiceAccountWithRetry(ctx, clusterID, namespace, name, func(latest *corev1.ServiceAccount) error { + s.warnIgnoredSecretRefs(latest, clusterID, requestedSecrets, op) + k8sutils.ApplyServiceAccountEditableFields(latest, desired) + return nil + }) + if err != nil { + if errors.IsNotFound(err) { + return fmt.Errorf("ServiceAccount不存在: %s/%s", namespace, name) + } + return fmt.Errorf("更新ServiceAccount失败: %w", err) } - return s.serviceAccountManager.UpdateServiceAccount(ctx, req.ClusterID, req.Namespace, sa) + return nil } // dropUserSecretRefs 创建时丢弃用户提交的 secrets 引用并记录告警日志(只记录名称) @@ -178,27 +192,22 @@ func (s *serviceAccountService) dropUserSecretRefs(sa *corev1.ServiceAccount, cl sa.Secrets = nil } -// keepExistingSecretRefs 更新时用集群中现有的 secrets 引用覆盖请求值;请求值与现有值不同则记录告警日志 -func (s *serviceAccountService) keepExistingSecretRefs(ctx context.Context, clusterID int, namespace, name string, sa *corev1.ServiceAccount, requested []string, op string) error { - existing, err := s.serviceAccountManager.GetServiceAccount(ctx, clusterID, namespace, name) - if err != nil { - if errors.IsNotFound(err) { - return fmt.Errorf("ServiceAccount不存在: %s/%s", namespace, name) - } - return fmt.Errorf("获取现有ServiceAccount失败: %w", err) - } - existingNames := k8sutils.ServiceAccountSecretRefNames(existing.Secrets) - if requested != nil && !slices.Equal(requested, existingNames) { - s.logger.Warn("忽略请求中的ServiceAccount secrets引用(SEC-29:该字段由集群维护),保留集群现有引用", - zap.String("operation", op), - zap.Int("cluster_id", clusterID), - zap.String("namespace", namespace), - zap.String("name", name), - zap.Strings("ignored_secrets", requested), - zap.Strings("kept_secrets", existingNames)) - } - sa.Secrets = existing.Secrets - return nil +// warnIgnoredSecretRefs 请求中的 secrets 与集群现有引用不同时记录告警日志(只记录名称);引用本身不会被修改 +func (s *serviceAccountService) warnIgnoredSecretRefs(latest *corev1.ServiceAccount, clusterID int, requested []string, op string) { + if requested == nil { + return + } + existingNames := k8sutils.ServiceAccountSecretRefNames(latest.Secrets) + if slices.Equal(requested, existingNames) { + return + } + s.logger.Warn("忽略请求中的ServiceAccount secrets引用(SEC-29:该字段由集群维护),保留集群现有引用", + zap.String("operation", op), + zap.Int("cluster_id", clusterID), + zap.String("namespace", latest.Namespace), + zap.String("name", latest.Name), + zap.Strings("ignored_secrets", requested), + zap.Strings("kept_secrets", existingNames)) } func (s *serviceAccountService) DeleteServiceAccount(ctx context.Context, req *model.DeleteServiceAccountReq) error { @@ -229,20 +238,13 @@ func (s *serviceAccountService) UpdateServiceAccountYaml(ctx context.Context, re return err } - // 以URL参数为准,避免 YAML 中的 name/namespace 指向另一个 SA - serviceAccount.Namespace = req.Namespace - serviceAccount.Name = req.Name - + // name/namespace 以 URL 为准(重试时按 URL 读取最新版本,YAML 中的 name/namespace/resourceVersion 不生效); + // SEC-29:YAML 中的 secrets 同样忽略,保留集群现有引用(从详情 YAML 原样回写时不产生变化) var requested []string if serviceAccount.Secrets != nil { requested = k8sutils.ServiceAccountSecretRefNames(serviceAccount.Secrets) } - // SEC-29:YAML 中的 secrets 同样忽略,保留集群现有引用(从详情 YAML 原样回写时不产生变化) - if err := s.keepExistingSecretRefs(ctx, req.ClusterID, req.Namespace, req.Name, serviceAccount, requested, "update_yaml"); err != nil { - return err - } - - return s.serviceAccountManager.UpdateServiceAccount(ctx, req.ClusterID, req.Namespace, serviceAccount) + return s.updateWithRetry(ctx, req.ClusterID, req.Namespace, req.Name, serviceAccount, requested, "update_yaml") } func (s *serviceAccountService) CreateServiceAccountByYaml(ctx context.Context, req *model.CreateServiceAccountByYamlReq) error { diff --git a/internal/k8s/service/serviceaccount_service_test.go b/internal/k8s/service/serviceaccount_service_test.go index 35f6f3a3..4cf8376c 100644 --- a/internal/k8s/service/serviceaccount_service_test.go +++ b/internal/k8s/service/serviceaccount_service_test.go @@ -9,6 +9,7 @@ import ( "github.com/GoSimplicity/AI-CloudOps/internal/constants" "github.com/GoSimplicity/AI-CloudOps/internal/k8s/manager" + k8sutils "github.com/GoSimplicity/AI-CloudOps/internal/k8s/utils" "github.com/GoSimplicity/AI-CloudOps/internal/model" "github.com/GoSimplicity/AI-CloudOps/pkg/base" "go.uber.org/zap" @@ -16,8 +17,12 @@ import ( corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/kubernetes/fake" + k8stesting "k8s.io/client-go/testing" + "k8s.io/client-go/util/retry" ) // 测试用的 token(非真实凭据),用于断言审计与错误中都不出现 @@ -37,6 +42,9 @@ type saManagerFixture struct { tokenReq *authv1.TokenRequest tokenErr error tokenTarget string + // kube 为 UpdateServiceAccountWithRetry 使用的 fake clientset,首次使用时以 sa 为初始数据创建; + // 测试可预先设置并挂 reactor 模拟并发冲突 + kube *fake.Clientset } func (m *saManagerFixture) GetServiceAccount(_ context.Context, _ int, _, _ string) (*corev1.ServiceAccount, error) { @@ -64,6 +72,22 @@ func (m *saManagerFixture) UpdateServiceAccount(_ context.Context, _ int, _ stri return nil } +func (m *saManagerFixture) UpdateServiceAccountWithRetry(ctx context.Context, _ int, namespace, name string, mutate k8sutils.ServiceAccountMutateFunc) error { + if m.kube == nil { + var objs []runtime.Object + if m.sa != nil { + objs = append(objs, m.sa.DeepCopy()) + } + m.kube = fake.NewSimpleClientset(objs...) + } + updated, err := k8sutils.UpdateServiceAccountWithRetry(ctx, m.kube.CoreV1().ServiceAccounts(namespace), name, mutate) + if err != nil { + return err + } + m.updated = updated.DeepCopy() + return nil +} + func (m *saManagerFixture) CreateServiceAccountToken(_ context.Context, _ int, _, name string, tr *authv1.TokenRequest) (*authv1.TokenRequest, error) { m.tokenTarget = name if m.tokenErr != nil { @@ -377,3 +401,117 @@ func TestCreateServiceAccountTokenExpirationBounds(t *testing.T) { }) } } + +func countSAUpdates(c *fake.Clientset) int { + n := 0 + for _, a := range c.Actions() { + if a.GetVerb() == "update" && a.GetResource().Resource == "serviceaccounts" { + n++ + } + } + return n +} + +// 第一次 Update 前集群中的 SA 被并发修改并返回 Conflict,第二次成功:JSON 与 YAML 两条路径都应基于最新版本重试、保留最新 secrets +func TestUpdateServiceAccountRetriesOnConflict(t *testing.T) { + yamlContent := "apiVersion: v1\nkind: ServiceAccount\nmetadata:\n name: other\n namespace: kube-system\n resourceVersion: \"1\"\n labels:\n team: ops\nsecrets:\n- name: admin-token\n" + for _, tt := range []struct { + name string + update func(svc ServiceAccountService) error + }{ + {"json", func(svc ServiceAccountService) error { + return svc.UpdateServiceAccount(context.Background(), &model.UpdateServiceAccountReq{ + ClusterID: 1, Namespace: "ops", Name: "app", Secrets: []string{"admin-token"}, Labels: map[string]string{"team": "ops"}, + }) + }}, + {"yaml", func(svc ServiceAccountService) error { + return svc.UpdateServiceAccountYaml(context.Background(), &model.UpdateServiceAccountByYamlReq{ClusterID: 1, Namespace: "ops", Name: "app", YamlContent: yamlContent}) + }}, + } { + t.Run(tt.name, func(t *testing.T) { + seed := newSAFixture() + seed.ResourceVersion = "1" + kube := fake.NewSimpleClientset(seed) + calls := 0 + kube.PrependReactor("update", "serviceaccounts", func(k8stesting.Action) (bool, runtime.Object, error) { + calls++ + if calls > 1 { + return false, nil, nil + } + concurrent := newSAFixture() + concurrent.ResourceVersion = "2" + concurrent.Secrets = append(concurrent.Secrets, corev1.ObjectReference{Name: "app-token-rotated"}) + if err := kube.Tracker().Update(corev1.SchemeGroupVersion.WithResource("serviceaccounts"), concurrent, "ops"); err != nil { + t.Errorf("模拟并发修改失败: %v", err) + } + return true, nil, apierrors.NewConflict(schema.GroupResource{Resource: "serviceaccounts"}, "app", errors.New("object has been modified")) + }) + fx := &saManagerFixture{sa: newSAFixture(), kube: kube} + if err := tt.update(NewServiceAccountService(fx, &auditFixture{}, zap.NewNop())); err != nil { + t.Fatal(err) + } + if calls != 2 { + t.Fatalf("应冲突一次后重试成功, update 调用 %d 次", calls) + } + var last *corev1.ServiceAccount + for _, a := range kube.Actions() { + if ua, ok := a.(k8stesting.UpdateAction); ok && a.GetResource().Resource == "serviceaccounts" { + last = ua.GetObject().(*corev1.ServiceAccount) + } + } + if last == nil || last.ResourceVersion != "2" { + t.Fatalf("最后一次 Update 应基于最新版本 resourceVersion=2: %+v", last) + } + if last.Name != "app" || last.Namespace != "ops" || last.Labels["team"] != "ops" { + t.Fatalf("name/namespace 应以 URL 为准、字段应被应用: %+v", last.ObjectMeta) + } + if got := k8sutils.ServiceAccountSecretRefNames(last.Secrets); len(got) != 2 || got[0] != "app-token" || got[1] != "app-token-rotated" { + t.Fatalf("应保留集群最新的 secrets 引用、忽略请求中的 admin-token: %v", got) + } + }) + } +} + +func TestUpdateServiceAccountConflictExhaustedReturnsError(t *testing.T) { + kube := fake.NewSimpleClientset(newSAFixture()) + kube.PrependReactor("update", "serviceaccounts", func(k8stesting.Action) (bool, runtime.Object, error) { + return true, nil, apierrors.NewConflict(schema.GroupResource{Resource: "serviceaccounts"}, "app", errors.New("object has been modified")) + }) + fx := &saManagerFixture{sa: newSAFixture(), kube: kube} + svc := NewServiceAccountService(fx, &auditFixture{}, zap.NewNop()) + err := svc.UpdateServiceAccount(context.Background(), &model.UpdateServiceAccountReq{ClusterID: 1, Namespace: "ops", Name: "app"}) + if err == nil || !apierrors.IsConflict(err) || fx.updated != nil { + t.Fatalf("连续冲突超过重试次数应返回 Conflict 错误: %v", err) + } + if n := countSAUpdates(kube); n != retry.DefaultRetry.Steps { + t.Fatalf("应尝试 %d 次, got %d", retry.DefaultRetry.Steps, n) + } + if err := svc.UpdateServiceAccountYaml(context.Background(), &model.UpdateServiceAccountByYamlReq{ + ClusterID: 1, Namespace: "ops", Name: "app", YamlContent: "apiVersion: v1\nkind: ServiceAccount\nmetadata:\n name: app\n", + }); !apierrors.IsConflict(err) { + t.Fatalf("YAML 路径连续冲突同样应返回错误: %v", err) + } +} + +func TestUpdateServiceAccountNotFoundSkipsUpdate(t *testing.T) { + for _, op := range []string{"json", "yaml"} { + t.Run(op, func(t *testing.T) { + fx := &saManagerFixture{} + svc := NewServiceAccountService(fx, &auditFixture{}, zap.NewNop()) + var err error + if op == "json" { + err = svc.UpdateServiceAccount(context.Background(), &model.UpdateServiceAccountReq{ClusterID: 1, Namespace: "ops", Name: "app"}) + } else { + err = svc.UpdateServiceAccountYaml(context.Background(), &model.UpdateServiceAccountByYamlReq{ + ClusterID: 1, Namespace: "ops", Name: "app", YamlContent: "apiVersion: v1\nkind: ServiceAccount\nmetadata:\n name: app\n", + }) + } + if err == nil || !strings.Contains(err.Error(), "ServiceAccount不存在") { + t.Fatalf("SA 不存在应报错: %v", err) + } + if n := countSAUpdates(fx.kube); n != 0 || fx.updated != nil { + t.Fatalf("SA 不存在时不应调用 Update, got %d", n) + } + }) + } +} diff --git a/internal/k8s/utils/serviceaccount_update.go b/internal/k8s/utils/serviceaccount_update.go new file mode 100644 index 00000000..f8f2dc1d --- /dev/null +++ b/internal/k8s/utils/serviceaccount_update.go @@ -0,0 +1,81 @@ +/* + * MIT License + * + * Copyright (c) 2024 Bamboo + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + */ + +package utils + +import ( + "context" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + corev1client "k8s.io/client-go/kubernetes/typed/core/v1" + "k8s.io/client-go/util/retry" +) + +// ServiceAccountMutateFunc 把请求中允许修改的字段应用到从集群读取的最新 SA 上。 +// 每次冲突重试都会以新读取的对象重新调用,实现必须是幂等的。 +type ServiceAccountMutateFunc func(latest *corev1.ServiceAccount) error + +// UpdateServiceAccountWithRetry 以 retry.RetryOnConflict(retry.DefaultRetry) 更新 SA: +// 每次尝试先 Get 最新版本(携带最新 resourceVersion),调用 mutate 应用允许修改的字段,再 Update。 +// 无论 mutate 做了什么,name 与 secrets 引用都以集群中最新版本为准(SEC-29:secrets 由集群维护)。 +// Get 失败(如 NotFound)直接返回且不调用 Update;连续冲突超过重试次数时返回最后一次的 Conflict 错误。 +func UpdateServiceAccountWithRetry(ctx context.Context, saClient corev1client.ServiceAccountInterface, name string, mutate ServiceAccountMutateFunc) (*corev1.ServiceAccount, error) { + var updated *corev1.ServiceAccount + err := retry.RetryOnConflict(retry.DefaultRetry, func() error { + latest, err := saClient.Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return err + } + keptName, keptNamespace := latest.Name, latest.Namespace + keptSecrets := append([]corev1.ObjectReference(nil), latest.Secrets...) + if mutate != nil { + if err := mutate(latest); err != nil { + return err + } + } + latest.Name, latest.Namespace = keptName, keptNamespace + latest.Secrets = keptSecrets + updated, err = saClient.Update(ctx, latest, metav1.UpdateOptions{}) + return err + }) + if err != nil { + return nil, err + } + return updated, nil +} + +// ApplyServiceAccountEditableFields 把 desired 中允许通过更新接口修改的字段整体覆盖到 latest: +// labels、annotations、automountServiceAccountToken、imagePullSecrets。 +// 其余字段(name/namespace、secrets、resourceVersion、uid、ownerReferences、finalizers 等)保持 latest 的值。 +func ApplyServiceAccountEditableFields(latest, desired *corev1.ServiceAccount) { + if latest == nil || desired == nil { + return + } + latest.Labels = desired.Labels + latest.Annotations = desired.Annotations + latest.AutomountServiceAccountToken = desired.AutomountServiceAccountToken + latest.ImagePullSecrets = desired.ImagePullSecrets +} diff --git a/internal/k8s/utils/serviceaccount_update_test.go b/internal/k8s/utils/serviceaccount_update_test.go new file mode 100644 index 00000000..5fcb842d --- /dev/null +++ b/internal/k8s/utils/serviceaccount_update_test.go @@ -0,0 +1,117 @@ +package utils + +import ( + "context" + "testing" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/kubernetes/fake" + k8stesting "k8s.io/client-go/testing" + "k8s.io/client-go/util/retry" +) + +var saGVR = corev1.SchemeGroupVersion.WithResource("serviceaccounts") + +func newRetrySA() *corev1.ServiceAccount { + return &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{Name: "app", Namespace: "ops", ResourceVersion: "1", Labels: map[string]string{"v": "1"}}, + Secrets: []corev1.ObjectReference{{Name: "app-token"}}, + } +} + +func saUpdateActions(c *fake.Clientset) []k8stesting.UpdateAction { + var out []k8stesting.UpdateAction + for _, a := range c.Actions() { + if a.GetVerb() == "update" && a.GetResource().Resource == "serviceaccounts" { + out = append(out, a.(k8stesting.UpdateAction)) + } + } + return out +} + +func conflictErr() error { + return apierrors.NewConflict(schema.GroupResource{Resource: "serviceaccounts"}, "app", nil) +} + +// 第一次 Update 前集群里的 SA 被并发修改(resourceVersion 变为 2、多了一个 secrets 引用),Update 返回 Conflict; +// 重试时应重新 Get 最新版本,在其上应用字段后再 Update,且保留最新的 secrets 引用。 +func TestUpdateServiceAccountWithRetryConflictThenSuccess(t *testing.T) { + client := fake.NewSimpleClientset(newRetrySA()) + calls := 0 + client.PrependReactor("update", "serviceaccounts", func(k8stesting.Action) (bool, runtime.Object, error) { + calls++ + if calls > 1 { + return false, nil, nil // 交给默认 tracker 处理 + } + concurrent := newRetrySA() + concurrent.ResourceVersion = "2" + concurrent.Labels = map[string]string{"v": "2"} + concurrent.Secrets = append(concurrent.Secrets, corev1.ObjectReference{Name: "app-token-rotated"}) + if err := client.Tracker().Update(saGVR, concurrent, "ops"); err != nil { + t.Errorf("模拟并发修改失败: %v", err) + } + return true, nil, conflictErr() + }) + + mutations := 0 + updated, err := UpdateServiceAccountWithRetry(context.Background(), client.CoreV1().ServiceAccounts("ops"), "app", func(latest *corev1.ServiceAccount) error { + mutations++ + latest.Labels = map[string]string{"team": "ops"} + latest.Secrets = []corev1.ObjectReference{{Name: "admin-token"}} // 企图篡改 secrets,必须被还原 + latest.Name = "admin" // 企图改名,必须被还原 + return nil + }) + if err != nil { + t.Fatal(err) + } + actions := saUpdateActions(client) + if calls != 2 || len(actions) != 2 || mutations != 2 { + t.Fatalf("应冲突一次后重试成功: updateCalls=%d actions=%d mutations=%d", calls, len(actions), mutations) + } + last := actions[1].GetObject().(*corev1.ServiceAccount) + if last.ResourceVersion != "2" { + t.Fatalf("重试应基于最新版本(resourceVersion=2),got %q", last.ResourceVersion) + } + if last.Name != "app" || last.Labels["team"] != "ops" { + t.Fatalf("name 应保持 URL 值、字段应被应用: %+v", last.ObjectMeta) + } + if len(last.Secrets) != 2 || last.Secrets[0].Name != "app-token" || last.Secrets[1].Name != "app-token-rotated" { + t.Fatalf("应保留集群最新的 secrets 引用: %+v", last.Secrets) + } + if updated == nil || len(updated.Secrets) != 2 { + t.Fatalf("返回值应为更新后的对象: %+v", updated) + } +} + +func TestUpdateServiceAccountWithRetryExhausted(t *testing.T) { + client := fake.NewSimpleClientset(newRetrySA()) + client.PrependReactor("update", "serviceaccounts", func(k8stesting.Action) (bool, runtime.Object, error) { + return true, nil, conflictErr() + }) + _, err := UpdateServiceAccountWithRetry(context.Background(), client.CoreV1().ServiceAccounts("ops"), "app", func(*corev1.ServiceAccount) error { return nil }) + if !apierrors.IsConflict(err) { + t.Fatalf("连续冲突超过重试次数应返回 Conflict 错误, got %v", err) + } + if n := len(saUpdateActions(client)); n != retry.DefaultRetry.Steps { + t.Fatalf("应尝试 %d 次 Update, got %d", retry.DefaultRetry.Steps, n) + } +} + +func TestUpdateServiceAccountWithRetryNotFound(t *testing.T) { + client := fake.NewSimpleClientset() + called := false + _, err := UpdateServiceAccountWithRetry(context.Background(), client.CoreV1().ServiceAccounts("ops"), "app", func(*corev1.ServiceAccount) error { + called = true + return nil + }) + if !apierrors.IsNotFound(err) || called { + t.Fatalf("SA 不存在应返回 NotFound 且不调用 mutate: %v called=%v", err, called) + } + if n := len(saUpdateActions(client)); n != 0 { + t.Fatalf("SA 不存在时不应调用 Update, got %d", n) + } +} From 44d6f886c333349c952a698e7891ba6b6e9539b8 Mon Sep 17 00:00:00 2001 From: Bamboo <13664854532@163.com> Date: Sat, 24 Oct 2026 00:47:56 +0800 Subject: [PATCH 07/14] =?UTF-8?q?fix(app):=20SEC-29=20=E6=9D=83=E9=99=90?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=E6=94=B9=E5=90=8D=E5=90=8C=E6=97=B6=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=E8=87=AA=E5=8A=A8=E8=AF=B4=E6=98=8E=EF=BC=8C=E6=8F=92?= =?UTF-8?q?=E5=85=A5=E8=A2=AB=E8=B7=B3=E8=BF=87=E7=9A=84=E6=9D=83=E9=99=90?= =?UTF-8?q?=E7=A0=81=E8=AE=B0=20Warn?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - renameAutoNamedPermission 的 WHERE 增加 description = ' ',管理员只改说明的行整行不动 - 读取现有行时一并读取 description - CreateInBatches(OnConflict DoNothing) 后核对本次登记的权限码,缺行记 Warn(只含权限码) - 测试:管理员只改说明不被覆盖、自动命名行正确改名、权限码冲突被跳过时告警 --- internal/app/permissions.go | 71 ++++++++++++++++++-- internal/app/permissions_test.go | 108 +++++++++++++++++++++++++++++++ 2 files changed, 173 insertions(+), 6 deletions(-) diff --git a/internal/app/permissions.go b/internal/app/permissions.go index ac261b76..cab0f620 100644 --- a/internal/app/permissions.go +++ b/internal/app/permissions.go @@ -6,6 +6,7 @@ import ( _ "embed" "encoding/json" "fmt" + "sort" "strings" "github.com/GoSimplicity/AI-CloudOps/internal/model" @@ -49,12 +50,17 @@ func autoPermissionName(route gin.RouteInfo) string { return fmt.Sprintf("%s %s %x", route.Method, handler, digest[:4]) } +// autoPermissionDescription 未登记权限码的接口自动生成的说明:" " +func autoPermissionDescription(route gin.RouteInfo) string { + return route.Method + " " + route.Path +} + // bootstrapPermissionCatalog 补充可发现接口,不授予角色或覆盖管理员维护的元数据 func (a *App) bootstrapPermissionCatalog(ctx context.Context) error { methods := map[string]int8{"GET": 1, "POST": 2, "PUT": 3, "DELETE": 4} public := map[string]bool{"/api/user/login": true, "/api/user/logout": true, "/api/user/refresh_token": true, "/api/not_auth/getBindIps": true, "/api/not_auth/getTreeNodeBindIps": true} var existing []model.Api - if err := a.db.WithContext(ctx).Select("id", "name", "path", "method").Find(&existing).Error; err != nil { + if err := a.db.WithContext(ctx).Select("id", "name", "path", "method", "description").Find(&existing).Error; err != nil { return err } seen := make(map[string]bool, len(existing)) @@ -90,7 +96,7 @@ func (a *App) bootstrapPermissionCatalog(ctx context.Context) error { a.renameAutoNamedPermission(ctx, route, existingByKey[key(method, route.Path)]) continue } - entry := model.Api{Name: autoPermissionName(route), Path: route.Path, Method: method, Description: route.Method + " " + route.Path, Version: "v1", Category: 2, IsPublic: 0} + entry := model.Api{Name: autoPermissionName(route), Path: route.Path, Method: method, Description: autoPermissionDescription(route), Version: "v1", Category: 2, IsPublic: 0} if named, ok := namedPermissions[route.Method+" "+route.Path]; ok { entry.Name, entry.Description = named.Name, named.Description } @@ -100,11 +106,56 @@ func (a *App) bootstrapPermissionCatalog(ctx context.Context) error { if len(pending) == 0 { return nil } - return a.db.WithContext(ctx).Clauses(clause.OnConflict{DoNothing: true}).CreateInBatches(pending, 100).Error + if err := a.db.WithContext(ctx).Clauses(clause.OnConflict{DoNothing: true}).CreateInBatches(pending, 100).Error; err != nil { + return err + } + a.warnMissingNamedPermissions(ctx, pending) + return nil +} + +// warnMissingNamedPermissions OnConflict DoNothing 遇到唯一键冲突(例如权限码已被其他接口或已软删除的行占用)会静默跳过插入。 +// 插入后逐个核对本次尝试登记的权限码:表中不存在 name + path + method 均一致的行时记一条 Warn(只记权限码),不影响启动。 +func (a *App) warnMissingNamedPermissions(ctx context.Context, pending []model.Api) { + expected := make(map[string]model.Api) + for _, entry := range pending { + for _, named := range namedPermissions { + if entry.Name == named.Name { + expected[entry.Name] = entry + } + } + } + if len(expected) == 0 { + return + } + names := make([]string, 0, len(expected)) + for name := range expected { + names = append(names, name) + } + sort.Strings(names) + var rows []model.Api + if err := a.db.WithContext(ctx).Select("id", "name", "path", "method").Where("name IN ?", names).Find(&rows).Error; err != nil { + if a.logger != nil { + a.logger.Warn("核对敏感接口权限码失败", zap.Strings("permissions", names), zap.Error(err)) + } + return + } + present := make(map[string]bool, len(rows)) + for _, row := range rows { + if want, ok := expected[row.Name]; ok && row.Path == want.Path && row.Method == want.Method { + present[row.Name] = true + } + } + for _, name := range names { + if !present[name] && a.logger != nil { + a.logger.Warn("敏感接口权限码未登记到接口目录(插入因唯一键冲突被跳过),请人工核对 cl_system_apis", + zap.String("permission", name)) + } + } } -// renameAutoNamedPermission 已登记过的敏感接口(如 SEC-29 之前就存在的 SA token 路由)在目录中仍是自动生成的名称时, -// 改为稳定的权限码。只在名称仍等于自动生成值时更新(WHERE name = 自动名),管理员改过的名称不覆盖; +// renameAutoNamedPermission 已登记过的敏感接口(如 SEC-29 之前就存在的 SA token 路由)在目录中仍是自动生成的 +// 名称与说明时,改为稳定的权限码与说明。只在名称和说明都仍等于自动生成值时更新 +// (WHERE id = ? AND name = <自动名> AND description = " "),管理员改过名称或说明(任一项)的行整行不动; // 更新失败(例如权限码已被其他记录占用)只记日志,不影响启动。 func (a *App) renameAutoNamedPermission(ctx context.Context, route gin.RouteInfo, row model.Api) { named, ok := namedPermissions[route.Method+" "+route.Path] @@ -112,11 +163,19 @@ func (a *App) renameAutoNamedPermission(ctx context.Context, route gin.RouteInfo return } auto := autoPermissionName(route) + autoDescription := autoPermissionDescription(route) if row.Name != auto { return } + if row.Description != autoDescription { + if a.logger != nil { + a.logger.Info("敏感接口权限行的说明已被管理员修改,保留原名称与说明", + zap.String("path", route.Path), zap.String("method", route.Method), zap.String("permission", named.Name)) + } + return + } err := a.db.WithContext(ctx).Model(&model.Api{}). - Where("id = ? AND name = ?", row.ID, auto). + Where("id = ? AND name = ? AND description = ?", row.ID, auto, autoDescription). Updates(map[string]interface{}{"name": named.Name, "description": named.Description}).Error if err != nil && a.logger != nil { a.logger.Warn("更新敏感接口权限名失败,保留原名称", diff --git a/internal/app/permissions_test.go b/internal/app/permissions_test.go index 2e175478..62b0fbc5 100644 --- a/internal/app/permissions_test.go +++ b/internal/app/permissions_test.go @@ -8,6 +8,9 @@ import ( "github.com/GoSimplicity/AI-CloudOps/internal/model" "github.com/gin-gonic/gin" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "go.uber.org/zap/zaptest/observer" "gorm.io/driver/sqlite" "gorm.io/gorm" ) @@ -182,4 +185,109 @@ func TestPermissionCatalogRenamesAutoNamedServiceAccountToken(t *testing.T) { if count != 2 || get.Name != model.K8sServiceAccountTokenPermissionName || post.Name != "admin custom token name" || post.Description != "custom metadata" { t.Fatalf("升级后权限目录不符: count=%d get=%q post=%q/%q", count, get.Name, post.Name, post.Description) } + if get.Description != namedPermissions["GET "+model.K8sServiceAccountTokenRoute].Description { + t.Fatalf("自动命名的行应同时更新为登记的说明: %q", get.Description) + } +} + +func newPermissionTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + pool, _ := db.DB() + pool.SetMaxOpenConns(1) + t.Cleanup(func() { pool.Close() }) + if err := db.AutoMigrate(&model.Api{}); err != nil { + t.Fatal(err) + } + return db +} + +func newTokenRouteEngine() (*gin.Engine, map[string]gin.RouteInfo) { + engine := gin.New() + handler := func(c *gin.Context) { c.Status(200) } + engine.GET(model.K8sServiceAccountTokenRoute, handler) + engine.POST(model.K8sServiceAccountTokenRoute, handler) + routes := map[string]gin.RouteInfo{} + for _, r := range engine.Routes() { + routes[r.Method] = r + } + return engine, routes +} + +// SEC-29 P2:管理员只改了说明(名称仍是自动名)时,启动迁移不能覆盖该行的名称和说明 +func TestPermissionCatalogKeepsAdminEditedDescription(t *testing.T) { + db := newPermissionTestDB(t) + engine, routes := newTokenRouteEngine() + legacy := []model.Api{ + // 管理员只改了说明 + {Name: autoPermissionName(routes["GET"]), Path: model.K8sServiceAccountTokenRoute, Method: 1, Description: "运维自定义说明", Version: "v1", Category: 2}, + // 完全是自动生成的行,应被改名 + {Name: autoPermissionName(routes["POST"]), Path: model.K8sServiceAccountTokenRoute, Method: 2, Description: "POST " + model.K8sServiceAccountTokenRoute, Version: "v1", Category: 2}, + } + if err := db.Create(&legacy).Error; err != nil { + t.Fatal(err) + } + app := &App{db: db, server: engine, logger: zap.NewNop()} + for i := 0; i < 2; i++ { + if err := app.bootstrapPermissionCatalog(context.Background()); err != nil { + t.Fatal(err) + } + } + var get, post model.Api + db.First(&get, legacy[0].ID) + db.First(&post, legacy[1].ID) + if get.Name != autoPermissionName(routes["GET"]) || get.Description != "运维自定义说明" { + t.Fatalf("管理员改过说明的行不应被覆盖: %q/%q", get.Name, get.Description) + } + wantPost := namedPermissions["POST "+model.K8sServiceAccountTokenRoute] + if post.Name != wantPost.Name || post.Description != wantPost.Description { + t.Fatalf("自动命名的行应改为权限码与登记说明: %q/%q", post.Name, post.Description) + } + var count int64 + db.Model(&model.Api{}).Count(&count) + if count != 2 { + t.Fatalf("不应新增行: %d", count) + } +} + +// SEC-29 P3-1:权限码被其他行占用导致 OnConflict DoNothing 静默跳过时,记 Warn(只含权限码) +func TestPermissionCatalogWarnsWhenNamedInsertSkipped(t *testing.T) { + db := newPermissionTestDB(t) + engine, _ := newTokenRouteEngine() + occupant := model.Api{Name: model.K8sServiceAccountTokenPermissionName, Path: "/api/other/occupant", Method: 1, Description: "occupant", Version: "v1", Category: 2} + if err := db.Create(&occupant).Error; err != nil { + t.Fatal(err) + } + core, logs := observer.New(zapcore.InfoLevel) + app := &App{db: db, server: engine, logger: zap.New(core)} + if err := app.bootstrapPermissionCatalog(context.Background()); err != nil { + t.Fatal(err) + } + var rows []model.Api + db.Where("path = ?", model.K8sServiceAccountTokenRoute).Find(&rows) + if len(rows) != 1 || rows[0].Name != model.K8sServiceAccountTokenCreatePermissionName { + t.Fatalf("前提:GET 行应因权限码冲突被跳过,只插入 POST 行: %+v", rows) + } + warns := logs.FilterLevelExact(zapcore.WarnLevel).All() + if len(warns) != 1 { + t.Fatalf("应只记一条 Warn: %+v", warns) + } + fields := warns[0].ContextMap() + if len(fields) != 1 || fields["permission"] != model.K8sServiceAccountTokenPermissionName { + t.Fatalf("Warn 只应包含缺失的权限码: %+v", fields) + } + + // 全部登记成功时不记 Warn + db2 := newPermissionTestDB(t) + core2, logs2 := observer.New(zapcore.InfoLevel) + app2 := &App{db: db2, server: engine, logger: zap.New(core2)} + if err := app2.bootstrapPermissionCatalog(context.Background()); err != nil { + t.Fatal(err) + } + if n := logs2.FilterLevelExact(zapcore.WarnLevel).Len(); n != 0 { + t.Fatalf("权限码均已登记时不应告警: %d", n) + } } From 9ef1acb8c91f1cd88535af3475209d2c8efbd516 Mon Sep 17 00:00:00 2001 From: Bamboo <13664854532@163.com> Date: Sat, 24 Oct 2026 00:52:43 +0800 Subject: [PATCH 08/14] =?UTF-8?q?fix(k8s):=20SEC-29=20SA=20YAML=20?= =?UTF-8?q?=E4=B9=90=E8=A7=82=E9=94=81=20409=E3=80=81token=20=E8=AF=BB?= =?UTF-8?q?=E5=8F=96=E5=A4=8D=E7=94=A8=20SA=E3=80=81=E8=BF=87=E6=9C=9F?= =?UTF-8?q?=E6=97=B6=E9=97=B4=E8=BD=AC=20UTC=E3=80=81=E8=B7=AF=E7=94=B1?= =?UTF-8?q?=E7=BB=95=E8=BF=87=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - YAML 带 metadata.resourceVersion 时严格乐观锁:只 Update 一次,冲突返回 BusinessError{ErrK8sResourceConflict}, handler 经 respondBusinessError 映射为 HTTP 409 + code=1;不带版本保持 RetryOnConflict - 新增 base.Conflict/ConflictError、constants.ErrK8sResourceConflict、utils/manager UpdateServiceAccountWithResourceVersion - GetServiceAccountSecrets 改为接收已读取的 SA,GetServiceAccountToken / GetServiceAccountTokens 不再重复 Get - TokenRequest 的 ExpirationTime 先 .UTC() 再格式化 - 测试:乐观锁过期/正确/无版本三条路径、409 映射、UTC 转换、SA 只读一次、末尾斜杠与大小写不能绕过 token 显式授权 - 旧 UpdateServiceAccount 方法保留(留给 SEC-30) --- internal/constants/k8s.go | 3 + internal/k8s/api/secret.go | 44 ++++- internal/k8s/api/serviceaccount.go | 3 +- internal/k8s/api/serviceaccount_route_test.go | 129 +++++++++++++ internal/k8s/api/serviceaccount_test.go | 3 +- .../api/serviceaccount_update_yaml_test.go | 86 +++++++++ .../k8s/manager/serviceaccount_manager.go | 43 ++++- .../k8s/service/serviceaccount_service.go | 33 +++- .../service/serviceaccount_service_test.go | 172 +++++++++++++++++- internal/k8s/utils/serviceaccount_update.go | 22 +++ .../k8s/utils/serviceaccount_update_test.go | 24 +++ pkg/base/apiresponse.go | 14 ++ 12 files changed, 555 insertions(+), 21 deletions(-) create mode 100644 internal/k8s/api/serviceaccount_route_test.go create mode 100644 internal/k8s/api/serviceaccount_update_yaml_test.go diff --git a/internal/constants/k8s.go b/internal/constants/k8s.go index fda8a7d2..07a86d69 100644 --- a/internal/constants/k8s.go +++ b/internal/constants/k8s.go @@ -51,4 +51,7 @@ var ( ErrK8sGetPodLogs = errors.New("k8s get pod logs failed") ErrK8sPortForward = errors.New("k8s port forward failed") ErrorK8sIngressHealthFailed = errors.New("k8s ingress check health failed") + + // ErrK8sResourceConflict 乐观锁冲突(提交的 resourceVersion 已过期),handler 映射为 HTTP 409 + ErrK8sResourceConflict = errors.New("k8s resource conflict") ) diff --git a/internal/k8s/api/secret.go b/internal/k8s/api/secret.go index dce3e4ca..3f785066 100644 --- a/internal/k8s/api/secret.go +++ b/internal/k8s/api/secret.go @@ -322,14 +322,46 @@ func handleAuditedRequest(ctx *gin.Context, req interface{}, action func() (inte base.InternalServerErrorWithDetails(ctx, map[string]interface{}{}, err.Error()) return } - // 参数错误(如 TokenRequest 有效期越界)按项目约定返回 HTTP 400 + code=1 - var bizErr *base.BusinessError - if errors.As(err, &bizErr) && errors.Is(bizErr.Code, constants.ErrInvalidParam) { - base.BadRequestError(ctx, err.Error()) + respondBusinessError(ctx, err) + return + } + base.SuccessWithData(ctx, data) +} + +// handleMappedRequest 与 base.HandleRequest 相同,但按 respondBusinessError 把带类型的业务错误映射为对应 HTTP 状态码 +func handleMappedRequest(ctx *gin.Context, req interface{}, action func() (interface{}, error)) { + if req != nil { + if err := base.BindRequest(ctx, req); err != nil { + base.BadRequestWithDetails(ctx, err.Error(), "绑定数据失败") return } - base.ErrorWithMessage(ctx, err.Error()) + } + data, err := action() + if err != nil { + respondBusinessError(ctx, err) return } - base.SuccessWithData(ctx, data) + if data != nil { + base.SuccessWithData(ctx, data) + } else { + base.Success(ctx) + } +} + +// respondBusinessError 业务错误映射:BusinessError{constants.ErrInvalidParam} → HTTP 400; +// BusinessError{constants.ErrK8sResourceConflict}(乐观锁冲突)→ HTTP 409;其余保持项目约定的 HTTP 200 + code=1 +func respondBusinessError(ctx *gin.Context, err error) { + var bizErr *base.BusinessError + if errors.As(err, &bizErr) { + switch { + case errors.Is(bizErr.Code, constants.ErrInvalidParam): + // 参数错误(如 TokenRequest 有效期越界)按项目约定返回 HTTP 400 + code=1 + base.BadRequestError(ctx, err.Error()) + return + case errors.Is(bizErr.Code, constants.ErrK8sResourceConflict): + base.ConflictError(ctx, err.Error()) + return + } + } + base.ErrorWithMessage(ctx, err.Error()) } diff --git a/internal/k8s/api/serviceaccount.go b/internal/k8s/api/serviceaccount.go index df980c8c..56e46ab9 100644 --- a/internal/k8s/api/serviceaccount.go +++ b/internal/k8s/api/serviceaccount.go @@ -251,7 +251,8 @@ func (h *K8sServiceAccountHandler) UpdateServiceAccountYaml(ctx *gin.Context) { req.Namespace = namespace req.Name = name - base.HandleRequest(ctx, &req, func() (interface{}, error) { + // YAML 带 resourceVersion 时走乐观锁,版本冲突返回 HTTP 409 + handleMappedRequest(ctx, &req, func() (interface{}, error) { return nil, h.serviceAccountService.UpdateServiceAccountYaml(ctx.Request.Context(), &req) }) } diff --git a/internal/k8s/api/serviceaccount_route_test.go b/internal/k8s/api/serviceaccount_route_test.go new file mode 100644 index 00000000..afada2f8 --- /dev/null +++ b/internal/k8s/api/serviceaccount_route_test.go @@ -0,0 +1,129 @@ +package api + +import ( + "context" + "net/http/httptest" + "strings" + "testing" + + "github.com/GoSimplicity/AI-CloudOps/internal/k8s/service" + "github.com/GoSimplicity/AI-CloudOps/internal/middleware" + "github.com/GoSimplicity/AI-CloudOps/internal/model" + systemservice "github.com/GoSimplicity/AI-CloudOps/internal/system/service" + "github.com/GoSimplicity/AI-CloudOps/pkg/jwt" + "github.com/gin-gonic/gin" + "go.uber.org/zap" + authv1 "k8s.io/api/authentication/v1" + corev1 "k8s.io/api/core/v1" +) + +type routeGrantRoles struct { + systemservice.RoleService + apis []*model.Api +} + +func (r routeGrantRoles) GetUserRoles(context.Context, int) (*model.ListResp[*model.Role], error) { + return &model.ListResp[*model.Role]{Items: []*model.Role{{Status: 1, Apis: r.apis}}}, nil +} + +// routeProbeSAManager 记录 token 相关的 manager 调用:handler 被执行时必然会调用其中之一 +type routeProbeSAManager struct { + *handlerSAManager + reveals, issues int +} + +func (m *routeProbeSAManager) GetServiceAccount(ctx context.Context, clusterID int, namespace, name string) (*corev1.ServiceAccount, error) { + m.reveals++ + return m.handlerSAManager.GetServiceAccount(ctx, clusterID, namespace, name) +} + +func (m *routeProbeSAManager) CreateServiceAccountToken(ctx context.Context, clusterID int, namespace, name string, tr *authv1.TokenRequest) (*authv1.TokenRequest, error) { + m.issues++ + return m.handlerSAManager.CreateServiceAccountToken(ctx, clusterID, namespace, name, tr) +} + +// newSARouteAuthEngine 与生产一致:gin.New()(RedirectTrailingSlash=true、RedirectFixedPath=false)+ 认证中间件 + 真实路由注册 +// (不测 RedirectFixedPath=true:gin v1.10.0 的 findCaseInsensitivePath 在本路由树上会 panic,生产也未开启) +func newSARouteAuthEngine(mgr *routeProbeSAManager, apis []*model.Api) *gin.Engine { + engine := gin.New() + engine.Use(func(c *gin.Context) { c.Set("user", jwt.UserClaims{Uid: 9, Username: "bob"}); c.Next() }) + engine.Use(middleware.NewAuthMiddleware(routeGrantRoles{apis: apis}).CheckAuth()) + NewK8sServiceAccountHandler(service.NewServiceAccountService(mgr, &recordingAudit{}, zap.NewNop())).RegisterRouters(engine) + return engine +} + +// serveFollowingRedirects 发请求并跟随最多 3 次重定向(模拟客户端自动跟随),返回最终状态码 +func serveFollowingRedirects(engine *gin.Engine, method, path string) int { + for i := 0; i < 4; i++ { + req := httptest.NewRequest(method, path, strings.NewReader(`{"expiration_seconds":600}`)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, req) + if rec.Code < 300 || rec.Code >= 400 { + return rec.Code + } + loc := rec.Header().Get("Location") + if loc == "" { + return rec.Code + } + path = loc + } + return -1 +} + +// SEC-29 P3-4:只有通配/参数化授权(没有显式授予 token 权限码)时,末尾斜杠、大小写变体、重复斜杠等路径 +// 都不能执行 k8s:serviceaccount:token(GET)或 k8s:serviceaccount:token:create(POST)的 handler; +// 即使跟随 gin 的末尾斜杠重定向,落到规范路径后仍被显式授权检查拦截。 +func TestServiceAccountTokenRoutesTrailingSlashAndCaseCannotBypassPermission(t *testing.T) { + gin.SetMode(gin.TestMode) + wildcardOnly := []*model.Api{ + {Path: "/api/k8s/*", Method: 1}, {Path: "/api/k8s/*", Method: 2}, + {Path: "/*", Method: 1}, {Path: "/*", Method: 2}, + {Path: "/api/k8s/serviceaccount/:cluster_id/:namespace/:name/:action", Method: 1}, + {Path: "/api/k8s/serviceaccount/:cluster_id/:namespace/:name/:action", Method: 2}, + } + variants := []string{ + "/api/k8s/serviceaccount/3/ops/app/token", + "/api/k8s/serviceaccount/3/ops/app/token/", + "/api/k8s/serviceaccount/3/ops/app/token//", + "/api/k8s/serviceaccount/3/ops/app//token", + "/api/k8s/serviceaccount/3/ops/app/Token", + "/api/k8s/serviceaccount/3/ops/app/TOKEN", + "/api/k8s/serviceaccount/3/ops/app/TOKEN/", + "/api/k8s/ServiceAccount/3/ops/app/token", + "/API/K8S/SERVICEACCOUNT/3/ops/app/token", + "/api/k8s/serviceaccount/3/ops/app/token/.", + "/api/k8s/serviceaccount/3/ops/app/./token", + } + for _, method := range []string{"GET", "POST"} { + for _, path := range variants { + mgr := &routeProbeSAManager{handlerSAManager: &handlerSAManager{}} + code := serveFollowingRedirects(newSARouteAuthEngine(mgr, wildcardOnly), method, path) + if mgr.reveals != 0 || mgr.issues != 0 || (code >= 200 && code < 300) { + t.Fatalf("%s %s 绕过了 token 显式授权: status=%d reveals=%d issues=%d", method, path, code, mgr.reveals, mgr.issues) + } + } + } + + // 只显式授予 GET(k8s:serviceaccount:token)时,POST 的任何路径变体都不能签发 token + getOnly := append([]*model.Api{{Path: model.K8sServiceAccountTokenRoute, Method: 1}}, wildcardOnly...) + for _, path := range variants { + mgr := &routeProbeSAManager{handlerSAManager: &handlerSAManager{}} + code := serveFollowingRedirects(newSARouteAuthEngine(mgr, getOnly), "POST", path) + if mgr.issues != 0 || (code >= 200 && code < 300) { + t.Fatalf("POST %s 未授予 token:create 却签发了 token: status=%d", path, code) + } + } + + // 对照:显式授权后规范路径可以正常访问(证明上面的拒绝来自授权检查而非路由缺失) + for _, tt := range []struct { + method string + code int8 + }{{"GET", 1}, {"POST", 2}} { + mgr := &routeProbeSAManager{handlerSAManager: &handlerSAManager{}} + engine := newSARouteAuthEngine(mgr, []*model.Api{{Path: model.K8sServiceAccountTokenRoute, Method: tt.code}}) + if code := serveFollowingRedirects(engine, tt.method, "/api/k8s/serviceaccount/3/ops/app/token"); code != 200 || mgr.reveals+mgr.issues != 1 { + t.Fatalf("%s 显式授权后应可访问: status=%d", tt.method, code) + } + } +} diff --git a/internal/k8s/api/serviceaccount_test.go b/internal/k8s/api/serviceaccount_test.go index 34a2fabc..174f622b 100644 --- a/internal/k8s/api/serviceaccount_test.go +++ b/internal/k8s/api/serviceaccount_test.go @@ -36,7 +36,8 @@ func (handlerSAManager) GetServiceAccount(_ context.Context, _ int, namespace, n return &corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace, UID: types.UID("uid-" + name)}}, nil } -func (handlerSAManager) GetServiceAccountSecrets(_ context.Context, _ int, namespace, name string) ([]corev1.Secret, error) { +func (handlerSAManager) GetServiceAccountSecrets(_ context.Context, _ int, sa *corev1.ServiceAccount) ([]corev1.Secret, error) { + namespace, name := sa.Namespace, sa.Name return []corev1.Secret{{ ObjectMeta: metav1.ObjectMeta{Name: name + "-token", Namespace: namespace, Annotations: map[string]string{ corev1.ServiceAccountNameKey: name, corev1.ServiceAccountUIDKey: "uid-" + name, diff --git a/internal/k8s/api/serviceaccount_update_yaml_test.go b/internal/k8s/api/serviceaccount_update_yaml_test.go new file mode 100644 index 00000000..cfda34d7 --- /dev/null +++ b/internal/k8s/api/serviceaccount_update_yaml_test.go @@ -0,0 +1,86 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "net/http/httptest" + "strings" + "testing" + + "github.com/GoSimplicity/AI-CloudOps/internal/k8s/service" + k8sutils "github.com/GoSimplicity/AI-CloudOps/internal/k8s/utils" + "github.com/gin-gonic/gin" + "go.uber.org/zap" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// yamlUpdateSAManager 模拟 manager 层的两种 YAML 更新路径 +type yamlUpdateSAManager struct { + *handlerSAManager + strictErr error + strictCalls, retries int + strictRV string +} + +func (m *yamlUpdateSAManager) UpdateServiceAccountWithResourceVersion(_ context.Context, _ int, _, _, rv string, _ k8sutils.ServiceAccountMutateFunc) error { + m.strictCalls++ + m.strictRV = rv + return m.strictErr +} + +func (m *yamlUpdateSAManager) UpdateServiceAccountWithRetry(context.Context, int, string, string, k8sutils.ServiceAccountMutateFunc) error { + m.retries++ + return nil +} + +func putSAYaml(engine *gin.Engine, yaml string) *httptest.ResponseRecorder { + body, _ := json.Marshal(map[string]interface{}{"cluster_id": 3, "namespace": "ops", "name": "app", "yaml_content": yaml}) + req := httptest.NewRequest("PUT", "/api/k8s/serviceaccount/3/ops/app/update/yaml", strings.NewReader(string(body))) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, req) + return rec +} + +// SEC-29(A):YAML 带过期 resourceVersion → HTTP 409 + code=1(respondBusinessError 映射 BusinessError{ErrK8sResourceConflict}); +// 版本正确 → 200;不带版本 → 走 RetryOnConflict 路径 +func TestUpdateServiceAccountYamlConflictMapsTo409(t *testing.T) { + gin.SetMode(gin.TestMode) + withRV := "apiVersion: v1\nkind: ServiceAccount\nmetadata:\n name: app\n resourceVersion: \"4\"\n" + conflict := apierrors.NewConflict(schema.GroupResource{Resource: "serviceaccounts"}, "app", errors.New("the object has been modified")) + + mgr := &yamlUpdateSAManager{handlerSAManager: &handlerSAManager{}, strictErr: conflict} + rec := putSAYaml(newSATokenEngine(service.NewServiceAccountService(mgr, &recordingAudit{}, zap.NewNop())), withRV) + var resp struct { + Code int `json:"code"` + Message string `json:"message"` + } + _ = json.Unmarshal(rec.Body.Bytes(), &resp) + if rec.Code != 409 || resp.Code != 1 || !strings.Contains(resp.Message, "resourceVersion") { + t.Fatalf("版本冲突应返回 HTTP 409 + code=1: status=%d body=%s", rec.Code, rec.Body.String()) + } + if mgr.strictCalls != 1 || mgr.retries != 0 || mgr.strictRV != "4" { + t.Fatalf("应只走一次乐观锁更新、不重试: strict=%d retry=%d rv=%q", mgr.strictCalls, mgr.retries, mgr.strictRV) + } + + mgr = &yamlUpdateSAManager{handlerSAManager: &handlerSAManager{}} + if rec := putSAYaml(newSATokenEngine(service.NewServiceAccountService(mgr, &recordingAudit{}, zap.NewNop())), withRV); rec.Code != 200 || mgr.strictCalls != 1 { + t.Fatalf("版本正确应返回 200: status=%d body=%s", rec.Code, rec.Body.String()) + } + + mgr = &yamlUpdateSAManager{handlerSAManager: &handlerSAManager{}} + noRV := "apiVersion: v1\nkind: ServiceAccount\nmetadata:\n name: app\n" + if rec := putSAYaml(newSATokenEngine(service.NewServiceAccountService(mgr, &recordingAudit{}, zap.NewNop())), noRV); rec.Code != 200 || mgr.retries != 1 || mgr.strictCalls != 0 { + t.Fatalf("不带 resourceVersion 应走重试路径: status=%d strict=%d retry=%d", rec.Code, mgr.strictCalls, mgr.retries) + } + + // 其他错误保持项目约定:HTTP 200 + code=1 + mgr = &yamlUpdateSAManager{handlerSAManager: &handlerSAManager{}, strictErr: errors.New("boom")} + rec = putSAYaml(newSATokenEngine(service.NewServiceAccountService(mgr, &recordingAudit{}, zap.NewNop())), withRV) + _ = json.Unmarshal(rec.Body.Bytes(), &resp) + if rec.Code != 200 || resp.Code != 1 { + t.Fatalf("非冲突错误应保持 200 + code=1: status=%d body=%s", rec.Code, rec.Body.String()) + } +} diff --git a/internal/k8s/manager/serviceaccount_manager.go b/internal/k8s/manager/serviceaccount_manager.go index 289f1c95..4257bf38 100644 --- a/internal/k8s/manager/serviceaccount_manager.go +++ b/internal/k8s/manager/serviceaccount_manager.go @@ -2,6 +2,7 @@ package manager import ( "context" + "fmt" "github.com/GoSimplicity/AI-CloudOps/internal/k8s/client" "github.com/GoSimplicity/AI-CloudOps/internal/k8s/utils" @@ -22,12 +23,15 @@ type ServiceAccountManager interface { // UpdateServiceAccountWithRetry 冲突时重试的更新:每次先 Get 最新版本,经 mutate 应用允许修改的字段后 Update; // name/namespace 与 secrets 引用始终以集群最新版本为准 UpdateServiceAccountWithRetry(ctx context.Context, clusterID int, namespace, name string, mutate utils.ServiceAccountMutateFunc) error + // UpdateServiceAccountWithResourceVersion 严格乐观锁更新:以调用方提交的 resourceVersion 只 Update 一次,冲突直接返回 Conflict,不重试 + UpdateServiceAccountWithResourceVersion(ctx context.Context, clusterID int, namespace, name, resourceVersion string, mutate utils.ServiceAccountMutateFunc) error DeleteServiceAccount(ctx context.Context, clusterID int, namespace, name string, deleteOptions metav1.DeleteOptions) error // 高级功能 PatchServiceAccount(ctx context.Context, clusterID int, namespace, name string, data []byte, patchType string) (*corev1.ServiceAccount, error) - GetServiceAccountSecrets(ctx context.Context, clusterID int, namespace, name string) ([]corev1.Secret, error) + // GetServiceAccountSecrets 读取 sa.secrets 引用的 Secret;sa 由调用方传入(复用已读取的对象,不再重复 Get) + GetServiceAccountSecrets(ctx context.Context, clusterID int, sa *corev1.ServiceAccount) ([]corev1.Secret, error) GetServiceAccountTokens(ctx context.Context, clusterID int, namespace, name string) ([]string, error) CreateServiceAccountToken(ctx context.Context, clusterID int, namespace, name string, tokenRequest *authv1.TokenRequest) (*authv1.TokenRequest, error) BindServiceAccountToRole(ctx context.Context, clusterID int, namespace, saName, roleName string) error @@ -174,6 +178,33 @@ func (m *serviceAccountManager) UpdateServiceAccountWithRetry(ctx context.Contex return nil } +func (m *serviceAccountManager) UpdateServiceAccountWithResourceVersion(ctx context.Context, clusterID int, namespace, name, resourceVersion string, mutate utils.ServiceAccountMutateFunc) error { + kubeClient, err := m.client.GetKubeClient(clusterID) + if err != nil { + m.logger.Error("获取Kubernetes客户端失败", + zap.Int("clusterID", clusterID), + zap.Error(err)) + return err + } + + if _, err := utils.UpdateServiceAccountWithResourceVersion(ctx, kubeClient.CoreV1().ServiceAccounts(namespace), name, resourceVersion, mutate); err != nil { + m.logger.Error("按resourceVersion更新ServiceAccount失败", + zap.Int("clusterID", clusterID), + zap.String("namespace", namespace), + zap.String("name", name), + zap.String("resourceVersion", resourceVersion), + zap.Error(err)) + return err + } + + m.logger.Info("成功更新ServiceAccount", + zap.Int("clusterID", clusterID), + zap.String("namespace", namespace), + zap.String("name", name)) + + return nil +} + func (m *serviceAccountManager) DeleteServiceAccount(ctx context.Context, clusterID int, namespace, name string, deleteOptions metav1.DeleteOptions) error { kubeClient, err := m.client.GetKubeClient(clusterID) if err != nil { @@ -231,11 +262,11 @@ func (m *serviceAccountManager) PatchServiceAccount(ctx context.Context, cluster return sa, nil } -func (m *serviceAccountManager) GetServiceAccountSecrets(ctx context.Context, clusterID int, namespace, name string) ([]corev1.Secret, error) { - sa, err := m.GetServiceAccount(ctx, clusterID, namespace, name) - if err != nil { - return nil, err +func (m *serviceAccountManager) GetServiceAccountSecrets(ctx context.Context, clusterID int, sa *corev1.ServiceAccount) ([]corev1.Secret, error) { + if sa == nil { + return nil, fmt.Errorf("ServiceAccount不能为空") } + namespace, name := sa.Namespace, sa.Name kubeClient, err := m.client.GetKubeClient(clusterID) if err != nil { @@ -274,7 +305,7 @@ func (m *serviceAccountManager) GetServiceAccountTokens(ctx context.Context, clu return nil, err } - secrets, err := m.GetServiceAccountSecrets(ctx, clusterID, namespace, name) + secrets, err := m.GetServiceAccountSecrets(ctx, clusterID, sa) if err != nil { return nil, err } diff --git a/internal/k8s/service/serviceaccount_service.go b/internal/k8s/service/serviceaccount_service.go index 1c2e57e0..8fac2148 100644 --- a/internal/k8s/service/serviceaccount_service.go +++ b/internal/k8s/service/serviceaccount_service.go @@ -238,15 +238,41 @@ func (s *serviceAccountService) UpdateServiceAccountYaml(ctx context.Context, re return err } - // name/namespace 以 URL 为准(重试时按 URL 读取最新版本,YAML 中的 name/namespace/resourceVersion 不生效); + // name/namespace 以 URL 为准(YAML 中的 name/namespace 不生效); // SEC-29:YAML 中的 secrets 同样忽略,保留集群现有引用(从详情 YAML 原样回写时不产生变化) var requested []string if serviceAccount.Secrets != nil { requested = k8sutils.ServiceAccountSecretRefNames(serviceAccount.Secrets) } + if rv := serviceAccount.ResourceVersion; rv != "" { + // YAML 带了 metadata.resourceVersion:严格乐观锁,只 Update 一次,版本过期返回 409,不重试 + return s.updateWithResourceVersion(ctx, req.ClusterID, req.Namespace, req.Name, rv, serviceAccount, requested, "update_yaml") + } + // 未带 resourceVersion:RetryOnConflict,基于最新版本应用可编辑字段 return s.updateWithRetry(ctx, req.ClusterID, req.Namespace, req.Name, serviceAccount, requested, "update_yaml") } +// updateWithResourceVersion 以调用方提交的 resourceVersion 做一次乐观锁更新;可编辑字段与 secrets 规则同 updateWithRetry。 +// 版本冲突返回 BusinessError{constants.ErrK8sResourceConflict},handler 映射为 HTTP 409。 +func (s *serviceAccountService) updateWithResourceVersion(ctx context.Context, clusterID int, namespace, name, resourceVersion string, desired *corev1.ServiceAccount, requestedSecrets []string, op string) error { + err := s.serviceAccountManager.UpdateServiceAccountWithResourceVersion(ctx, clusterID, namespace, name, resourceVersion, func(latest *corev1.ServiceAccount) error { + s.warnIgnoredSecretRefs(latest, clusterID, requestedSecrets, op) + k8sutils.ApplyServiceAccountEditableFields(latest, desired) + return nil + }) + if err != nil { + switch { + case errors.IsNotFound(err): + return fmt.Errorf("ServiceAccount不存在: %s/%s", namespace, name) + case errors.IsConflict(err): + return base.NewBusinessError(constants.ErrK8sResourceConflict, fmt.Sprintf( + "ServiceAccount %s/%s 已被修改(提交的 resourceVersion=%s 已过期),请重新获取 YAML 后再提交", namespace, name, resourceVersion)) + } + return fmt.Errorf("更新ServiceAccount失败: %w", err) + } + return nil +} + func (s *serviceAccountService) CreateServiceAccountByYaml(ctx context.Context, req *model.CreateServiceAccountByYamlReq) error { serviceAccount, err := k8sutils.YAMLToServiceAccount(req.YamlContent) if err != nil { @@ -291,7 +317,8 @@ func (s *serviceAccountService) GetServiceAccountToken(ctx context.Context, req } record.ServiceAccountUID = string(sa.UID) - secrets, err := s.serviceAccountManager.GetServiceAccountSecrets(ctx, req.ClusterID, req.Namespace, req.Name) + // 复用上面读到的 SA(归属校验用的也是同一个对象),不再重复 Get + secrets, err := s.serviceAccountManager.GetServiceAccountSecrets(ctx, req.ClusterID, sa) if err != nil { return nil, fail(k8sErrorStatusCode(err), fmt.Errorf("获取ServiceAccount关联的Secret失败: %w", err)) } @@ -389,7 +416,7 @@ func (s *serviceAccountService) CreateServiceAccountToken(ctx context.Context, r return &model.ServiceAccountTokenInfo{ Token: tokenResp.Status.Token, ExpirationSeconds: &expiration, - ExpirationTime: tokenResp.Status.ExpirationTimestamp.Time.Format("2006-01-02T15:04:05Z"), + ExpirationTime: tokenResp.Status.ExpirationTimestamp.Time.UTC().Format("2006-01-02T15:04:05Z"), }, nil } diff --git a/internal/k8s/service/serviceaccount_service_test.go b/internal/k8s/service/serviceaccount_service_test.go index 4cf8376c..d37b94e5 100644 --- a/internal/k8s/service/serviceaccount_service_test.go +++ b/internal/k8s/service/serviceaccount_service_test.go @@ -4,8 +4,10 @@ import ( "context" "encoding/json" "errors" + "strconv" "strings" "testing" + "time" "github.com/GoSimplicity/AI-CloudOps/internal/constants" "github.com/GoSimplicity/AI-CloudOps/internal/k8s/manager" @@ -45,16 +47,23 @@ type saManagerFixture struct { // kube 为 UpdateServiceAccountWithRetry 使用的 fake clientset,首次使用时以 sa 为初始数据创建; // 测试可预先设置并挂 reactor 模拟并发冲突 kube *fake.Clientset + // getCalls 记录 GetServiceAccount 调用次数;secretsSA 记录传给 GetServiceAccountSecrets 的 SA + getCalls int + secretsSA *corev1.ServiceAccount + // tokenExpiry 非零时作为 TokenRequest 返回的过期时间(默认 metav1.Now()) + tokenExpiry time.Time } func (m *saManagerFixture) GetServiceAccount(_ context.Context, _ int, _, _ string) (*corev1.ServiceAccount, error) { + m.getCalls++ if m.getErr != nil { return nil, m.getErr } return m.sa.DeepCopy(), nil } -func (m *saManagerFixture) GetServiceAccountSecrets(_ context.Context, _ int, _, _ string) ([]corev1.Secret, error) { +func (m *saManagerFixture) GetServiceAccountSecrets(_ context.Context, _ int, sa *corev1.ServiceAccount) ([]corev1.Secret, error) { + m.secretsSA = sa out := make([]corev1.Secret, 0, len(m.secrets)) for i := range m.secrets { out = append(out, *m.secrets[i].DeepCopy()) @@ -72,7 +81,7 @@ func (m *saManagerFixture) UpdateServiceAccount(_ context.Context, _ int, _ stri return nil } -func (m *saManagerFixture) UpdateServiceAccountWithRetry(ctx context.Context, _ int, namespace, name string, mutate k8sutils.ServiceAccountMutateFunc) error { +func (m *saManagerFixture) ensureKube() { if m.kube == nil { var objs []runtime.Object if m.sa != nil { @@ -80,6 +89,20 @@ func (m *saManagerFixture) UpdateServiceAccountWithRetry(ctx context.Context, _ } m.kube = fake.NewSimpleClientset(objs...) } +} + +func (m *saManagerFixture) UpdateServiceAccountWithResourceVersion(ctx context.Context, _ int, namespace, name, resourceVersion string, mutate k8sutils.ServiceAccountMutateFunc) error { + m.ensureKube() + updated, err := k8sutils.UpdateServiceAccountWithResourceVersion(ctx, m.kube.CoreV1().ServiceAccounts(namespace), name, resourceVersion, mutate) + if err != nil { + return err + } + m.updated = updated.DeepCopy() + return nil +} + +func (m *saManagerFixture) UpdateServiceAccountWithRetry(ctx context.Context, _ int, namespace, name string, mutate k8sutils.ServiceAccountMutateFunc) error { + m.ensureKube() updated, err := k8sutils.UpdateServiceAccountWithRetry(ctx, m.kube.CoreV1().ServiceAccounts(namespace), name, mutate) if err != nil { return err @@ -97,6 +120,9 @@ func (m *saManagerFixture) CreateServiceAccountToken(_ context.Context, _ int, _ out := tr.DeepCopy() out.Status.Token = fixtureIssuedToken out.Status.ExpirationTimestamp = metav1.Now() + if !m.tokenExpiry.IsZero() { + out.Status.ExpirationTimestamp = metav1.NewTime(m.tokenExpiry) + } return out, nil } @@ -412,9 +438,9 @@ func countSAUpdates(c *fake.Clientset) int { return n } -// 第一次 Update 前集群中的 SA 被并发修改并返回 Conflict,第二次成功:JSON 与 YAML 两条路径都应基于最新版本重试、保留最新 secrets +// 第一次 Update 前集群中的 SA 被并发修改并返回 Conflict(YAML 不带 resourceVersion,走 RetryOnConflict),第二次成功:JSON 与 YAML 两条路径都应基于最新版本重试、保留最新 secrets func TestUpdateServiceAccountRetriesOnConflict(t *testing.T) { - yamlContent := "apiVersion: v1\nkind: ServiceAccount\nmetadata:\n name: other\n namespace: kube-system\n resourceVersion: \"1\"\n labels:\n team: ops\nsecrets:\n- name: admin-token\n" + yamlContent := "apiVersion: v1\nkind: ServiceAccount\nmetadata:\n name: other\n namespace: kube-system\n labels:\n team: ops\nsecrets:\n- name: admin-token\n" for _, tt := range []struct { name string update func(svc ServiceAccountService) error @@ -515,3 +541,141 @@ func TestUpdateServiceAccountNotFoundSkipsUpdate(t *testing.T) { }) } } + +// optimisticLockReactor 模拟 API Server 的乐观锁:Update 提交的 resourceVersion 与当前存储不一致时返回 Conflict, +// 一致时把版本号加 1 后交给默认 tracker(fake clientset 本身不校验 resourceVersion) +func optimisticLockReactor(t *testing.T, kube *fake.Clientset) { + gvr := corev1.SchemeGroupVersion.WithResource("serviceaccounts") + kube.PrependReactor("update", "serviceaccounts", func(a k8stesting.Action) (bool, runtime.Object, error) { + obj := a.(k8stesting.UpdateAction).GetObject().(*corev1.ServiceAccount) + cur, err := kube.Tracker().Get(gvr, obj.Namespace, obj.Name) + if err != nil { + return true, nil, err + } + curRV := cur.(*corev1.ServiceAccount).ResourceVersion + if obj.ResourceVersion != curRV { + return true, nil, apierrors.NewConflict(schema.GroupResource{Resource: "serviceaccounts"}, obj.Name, errors.New("the object has been modified")) + } + next := obj.DeepCopy() + n, _ := strconv.Atoi(curRV) + next.ResourceVersion = strconv.Itoa(n + 1) + if err := kube.Tracker().Update(gvr, next, obj.Namespace); err != nil { + t.Errorf("tracker 更新失败: %v", err) + } + return true, next, nil + }) +} + +func saYamlWithRV(rv string) string { + y := "apiVersion: v1\nkind: ServiceAccount\nmetadata:\n name: app\n namespace: ops\n" + if rv != "" { + y += " resourceVersion: \"" + rv + "\"\n" + } + return y + " labels:\n team: yaml\nsecrets:\n- name: admin-token\n" +} + +// SEC-29(A):YAML 带 resourceVersion 时严格乐观锁——版本过期只 Update 一次并返回 409 业务错误,不重试 +func TestUpdateServiceAccountYamlStaleResourceVersionReturnsConflict(t *testing.T) { + seed := newSAFixture() + seed.ResourceVersion = "5" + kube := fake.NewSimpleClientset(seed) + optimisticLockReactor(t, kube) + fx := &saManagerFixture{sa: newSAFixture(), kube: kube} + svc := NewServiceAccountService(fx, &auditFixture{}, zap.NewNop()) + + err := svc.UpdateServiceAccountYaml(context.Background(), &model.UpdateServiceAccountByYamlReq{ClusterID: 1, Namespace: "ops", Name: "app", YamlContent: saYamlWithRV("4")}) + var bizErr *base.BusinessError + if !errors.As(err, &bizErr) || !errors.Is(bizErr.Code, constants.ErrK8sResourceConflict) { + t.Fatalf("过期 resourceVersion 应返回 ErrK8sResourceConflict 业务错误: %v", err) + } + if n := countSAUpdates(kube); n != 1 { + t.Fatalf("严格乐观锁只应 Update 一次(不重试), got %d", n) + } + if fx.updated != nil { + t.Fatalf("冲突时不应视为更新成功: %+v", fx.updated) + } + cur, _ := kube.CoreV1().ServiceAccounts("ops").Get(context.Background(), "app", metav1.GetOptions{}) + if cur.ResourceVersion != "5" || cur.Labels["team"] != "" { + t.Fatalf("冲突时集群对象不应被修改: %+v", cur.ObjectMeta) + } +} + +func TestUpdateServiceAccountYamlCurrentResourceVersionSucceeds(t *testing.T) { + seed := newSAFixture() + seed.ResourceVersion = "5" + kube := fake.NewSimpleClientset(seed) + optimisticLockReactor(t, kube) + fx := &saManagerFixture{sa: newSAFixture(), kube: kube} + svc := NewServiceAccountService(fx, &auditFixture{}, zap.NewNop()) + + if err := svc.UpdateServiceAccountYaml(context.Background(), &model.UpdateServiceAccountByYamlReq{ClusterID: 1, Namespace: "ops", Name: "app", YamlContent: saYamlWithRV("5")}); err != nil { + t.Fatal(err) + } + if n := countSAUpdates(kube); n != 1 { + t.Fatalf("应只 Update 一次, got %d", n) + } + cur, _ := kube.CoreV1().ServiceAccounts("ops").Get(context.Background(), "app", metav1.GetOptions{}) + if cur.ResourceVersion != "6" || cur.Labels["team"] != "yaml" { + t.Fatalf("应以提交的版本更新成功并应用字段: %+v", cur.ObjectMeta) + } + if got := k8sutils.ServiceAccountSecretRefNames(cur.Secrets); len(got) != 1 || got[0] != "app-token" { + t.Fatalf("乐观锁路径同样应忽略 YAML 中的 secrets、保留集群引用: %v", got) + } +} + +// 不带 resourceVersion 时保持 RetryOnConflict:即使存储中的版本与 YAML 无关,也基于最新版本重试成功 +func TestUpdateServiceAccountYamlWithoutResourceVersionUsesRetry(t *testing.T) { + seed := newSAFixture() + seed.ResourceVersion = "5" + kube := fake.NewSimpleClientset(seed) + optimisticLockReactor(t, kube) + conflicts := 0 + kube.PrependReactor("update", "serviceaccounts", func(k8stesting.Action) (bool, runtime.Object, error) { + if conflicts == 0 { + conflicts++ + return true, nil, apierrors.NewConflict(schema.GroupResource{Resource: "serviceaccounts"}, "app", errors.New("the object has been modified")) + } + return false, nil, nil + }) + fx := &saManagerFixture{sa: newSAFixture(), kube: kube} + svc := NewServiceAccountService(fx, &auditFixture{}, zap.NewNop()) + if err := svc.UpdateServiceAccountYaml(context.Background(), &model.UpdateServiceAccountByYamlReq{ClusterID: 1, Namespace: "ops", Name: "app", YamlContent: saYamlWithRV("")}); err != nil { + t.Fatal(err) + } + if n := countSAUpdates(kube); n != 2 { + t.Fatalf("未带 resourceVersion 应走重试路径(冲突 1 次后成功), update %d 次", n) + } + cur, _ := kube.CoreV1().ServiceAccounts("ops").Get(context.Background(), "app", metav1.GetOptions{}) + if cur.Labels["team"] != "yaml" || cur.ResourceVersion != "6" { + t.Fatalf("重试后应基于最新版本更新成功: %+v", cur.ObjectMeta) + } +} + +// SEC-29(B):K8s 返回非 UTC 时区的过期时间时,响应中的 expiration_time 仍是正确的 UTC 字符串 +func TestCreateServiceAccountTokenExpirationTimeIsUTC(t *testing.T) { + cst := time.FixedZone("CST", 8*3600) + fx := &saManagerFixture{tokenExpiry: time.Date(2026, 10, 24, 9, 30, 15, 0, cst)} + svc := NewServiceAccountService(fx, &auditFixture{}, zap.NewNop()) + got, err := svc.CreateServiceAccountToken(context.Background(), &model.CreateServiceAccountTokenReq{ClusterID: 1, Namespace: "ops", ServiceAccountName: "app"}, saActor) + if err != nil { + t.Fatal(err) + } + if got.ExpirationTime != "2026-10-24T01:30:15Z" { + t.Fatalf("expiration_time 应转换为 UTC: %q", got.ExpirationTime) + } +} + +// SEC-29 P3-3:GetServiceAccountToken 只读取一次 SA,并把同一个对象传给 GetServiceAccountSecrets +func TestGetServiceAccountTokenReadsServiceAccountOnce(t *testing.T) { + fx := &saManagerFixture{sa: newSAFixture(), secrets: []corev1.Secret{tokenSecret("app-token", "app", "uid-app", fixtureOwnToken)}} + svc := NewServiceAccountService(fx, &auditFixture{}, zap.NewNop()) + if _, err := svc.GetServiceAccountToken(context.Background(), &model.GetServiceAccountTokenReq{ClusterID: 1, Namespace: "ops", Name: "app"}, saActor); err != nil { + t.Fatal(err) + } + if fx.getCalls != 1 { + t.Fatalf("GetServiceAccount 应只调用一次, got %d", fx.getCalls) + } + if fx.secretsSA == nil || fx.secretsSA.Name != "app" || fx.secretsSA.UID != "uid-app" { + t.Fatalf("应把已读取的 SA 传给 GetServiceAccountSecrets: %+v", fx.secretsSA) + } +} diff --git a/internal/k8s/utils/serviceaccount_update.go b/internal/k8s/utils/serviceaccount_update.go index f8f2dc1d..a9a8c8d2 100644 --- a/internal/k8s/utils/serviceaccount_update.go +++ b/internal/k8s/utils/serviceaccount_update.go @@ -67,6 +67,28 @@ func UpdateServiceAccountWithRetry(ctx context.Context, saClient corev1client.Se return updated, nil } +// UpdateServiceAccountWithResourceVersion 严格乐观锁更新:调用方提交了 resourceVersion(如 YAML 中的 metadata.resourceVersion)时使用。 +// 先 Get 当前对象作为基础(保留不可编辑字段与集群维护的 secrets 引用),调用 mutate 应用允许修改的字段, +// 再以调用方提交的 resourceVersion 只做一次 Update:版本过期时由 API Server 返回 409 Conflict,原样返回,不重试。 +// Get 失败(如 NotFound)直接返回且不调用 Update。 +func UpdateServiceAccountWithResourceVersion(ctx context.Context, saClient corev1client.ServiceAccountInterface, name, resourceVersion string, mutate ServiceAccountMutateFunc) (*corev1.ServiceAccount, error) { + latest, err := saClient.Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return nil, err + } + keptName, keptNamespace := latest.Name, latest.Namespace + keptSecrets := append([]corev1.ObjectReference(nil), latest.Secrets...) + if mutate != nil { + if err := mutate(latest); err != nil { + return nil, err + } + } + latest.Name, latest.Namespace = keptName, keptNamespace + latest.Secrets = keptSecrets + latest.ResourceVersion = resourceVersion + return saClient.Update(ctx, latest, metav1.UpdateOptions{}) +} + // ApplyServiceAccountEditableFields 把 desired 中允许通过更新接口修改的字段整体覆盖到 latest: // labels、annotations、automountServiceAccountToken、imagePullSecrets。 // 其余字段(name/namespace、secrets、resourceVersion、uid、ownerReferences、finalizers 等)保持 latest 的值。 diff --git a/internal/k8s/utils/serviceaccount_update_test.go b/internal/k8s/utils/serviceaccount_update_test.go index 5fcb842d..5bdb387f 100644 --- a/internal/k8s/utils/serviceaccount_update_test.go +++ b/internal/k8s/utils/serviceaccount_update_test.go @@ -115,3 +115,27 @@ func TestUpdateServiceAccountWithRetryNotFound(t *testing.T) { t.Fatalf("SA 不存在时不应调用 Update, got %d", n) } } + +// 严格乐观锁:以调用方提交的 resourceVersion 只 Update 一次;Conflict 原样返回、不重试;保留集群 secrets 与 name/namespace +func TestUpdateServiceAccountWithResourceVersionSingleAttempt(t *testing.T) { + client := fake.NewSimpleClientset(newRetrySA()) + client.PrependReactor("update", "serviceaccounts", func(k8stesting.Action) (bool, runtime.Object, error) { + return true, nil, conflictErr() + }) + _, err := UpdateServiceAccountWithResourceVersion(context.Background(), client.CoreV1().ServiceAccounts("ops"), "app", "7", func(sa *corev1.ServiceAccount) error { + sa.Name, sa.Namespace = "evil", "kube-system" + sa.Secrets = nil + return nil + }) + if !apierrors.IsConflict(err) { + t.Fatalf("应原样返回 Conflict: %v", err) + } + actions := saUpdateActions(client) + if len(actions) != 1 { + t.Fatalf("只应 Update 一次, got %d", len(actions)) + } + sent := actions[0].GetObject().(*corev1.ServiceAccount) + if sent.ResourceVersion != "7" || sent.Name != "app" || sent.Namespace != "ops" || len(sent.Secrets) != len(newRetrySA().Secrets) { + t.Fatalf("提交对象应携带调用方 resourceVersion 并保留不可编辑字段: %+v", sent) + } +} diff --git a/pkg/base/apiresponse.go b/pkg/base/apiresponse.go index f74586f8..25833e42 100644 --- a/pkg/base/apiresponse.go +++ b/pkg/base/apiresponse.go @@ -98,6 +98,20 @@ func BadRequest(c *gin.Context, code int, data interface{}, message string) { }) } +// Conflict 资源版本冲突的返回,使用HTTP 409状态码 +func Conflict(c *gin.Context, code int, data interface{}, message string) { + c.JSON(http.StatusConflict, ApiResponse{ + Code: code, + Data: data, + Message: message, + }) +} + +// ConflictError 资源版本冲突(乐观锁失败)的返回:HTTP 409 + code=1 +func ConflictError(c *gin.Context, message string) { + Conflict(c, StatusError, map[string]interface{}{}, message) +} + // Forbidden 无权限的返回,使用HTTP 403状态码 func Forbidden(c *gin.Context, data interface{}, message string) { c.JSON(http.StatusForbidden, ApiResponse{ From b3d43d38f1066291a51bca37d545eaff24141a90 Mon Sep 17 00:00:00 2001 From: Bamboo <13664854532@163.com> Date: Sat, 24 Oct 2026 00:53:42 +0800 Subject: [PATCH 09/14] =?UTF-8?q?fix(k8s):=20SEC-25=20Secret=20=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E6=97=B6=20data=20=E7=BC=BA=E7=9C=81=E4=BF=9D?= =?UTF-8?q?=E7=95=99=E3=80=81stringData=20=E7=A9=BA=E4=B8=B2=E5=8F=AA?= =?UTF-8?q?=E6=81=A2=E5=A4=8D=E8=AF=B7=E6=B1=82=20data=20=E4=B8=AD?= =?UTF-8?q?=E7=9A=84=E9=94=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - preserveRedactedData:请求 data 为 nil(未携带/null)时保留集群现有 data;{} 表示删除全部 data 键 - preserveRedactedStringData:stringData 空串只在请求 data 为 nil 或包含该键时恢复原值,否则该键被删除 - 测试:键在 data 中→恢复、键不在→删除;data 为 nil→保留、data 为 {}→清空;更新原有用例到新语义 --- internal/k8s/service/secret_service.go | 25 +++++- internal/k8s/service/secret_service_test.go | 84 +++++++++++++++++++-- 2 files changed, 100 insertions(+), 9 deletions(-) diff --git a/internal/k8s/service/secret_service.go b/internal/k8s/service/secret_service.go index 568c8d55..e5bc6f67 100644 --- a/internal/k8s/service/secret_service.go +++ b/internal/k8s/service/secret_service.go @@ -288,8 +288,9 @@ func (s *secretService) UpdateSecret(ctx context.Context, req *model.UpdateSecre updatedSecret := existingSecret.DeepCopy() + // SEC-25:请求未携带 data(nil)时保留集群现有 data;携带空对象 {} 表示删除全部 data 键 updatedSecret.Data = preserveRedactedData(map[string][]byte(req.Data), existingSecret.Data) - updatedSecret.Data, updatedSecret.StringData = preserveRedactedStringData(req.StringData, updatedSecret.Data, existingSecret) + updatedSecret.Data, updatedSecret.StringData = preserveRedactedStringData(req.StringData, map[string][]byte(req.Data), updatedSecret.Data, existingSecret) updatedSecret.Labels = req.Labels updatedSecret.Annotations = preserveLastApplied(req.Annotations, existingSecret.Annotations) @@ -475,9 +476,19 @@ func (s *secretService) UpdateSecretByYaml(ctx context.Context, req *model.Updat // preserveRedactedData 兼容脱敏后的详情回填:请求中值为空、而集群中该键已有非空值时保留原值。 // 详情接口只返回空值,前端若直接回填提交,不能因此把真实值清空。 // 如需删除某个键,请在请求中去掉该键。 +// +// SEC-25 P3-2:requested 为 nil(请求未携带 data 字段或为 null)时视为“不修改 data”,返回集群现有 data 的副本; +// requested 为非 nil 的空 map(请求携带 "data": {})时视为显式清空,返回空 map,集群中的 data 键全部删除。 func preserveRedactedData(requested, existing map[string][]byte) map[string][]byte { if requested == nil { - return nil + if existing == nil { + return nil + } + kept := make(map[string][]byte, len(existing)) + for k, v := range existing { + kept[k] = append([]byte(nil), v...) + } + return kept } out := make(map[string][]byte, len(requested)) for k, v := range requested { @@ -497,7 +508,11 @@ func preserveRedactedData(requested, existing map[string][]byte) map[string][]by // 因此:stringData 中值为空、而集群中该键已有非空值(data 优先,其次 stringData)时, // 从 stringData 中移除该键,并在 data 中保留原值(请求 data 对同名键给了非空新值时以请求为准)。 // 集群中不存在或原值为空的键按原样写入。返回处理后的 data 与 stringData。 -func preserveRedactedStringData(requested map[string]string, data map[string][]byte, existing *corev1.Secret) (map[string][]byte, map[string]string) { +// +// SEC-25 P3-1:只有请求的 data 同样“包含”该键时才恢复原值——requestedData 为 nil(未携带 data,整体保留) +// 或 requestedData 中有该键。请求 data 非 nil 且不含该键时,表示该键已被用户删除:从 stringData 中移除, +// 也不写回 data,更新后该键被删除(不会因为 stringData 中残留的空串被恢复,也不会被写成空值)。 +func preserveRedactedStringData(requested map[string]string, requestedData, data map[string][]byte, existing *corev1.Secret) (map[string][]byte, map[string]string) { if requested == nil { return data, nil } @@ -516,6 +531,10 @@ func preserveRedactedStringData(requested map[string]string, data map[string][]b } } if ok && len(old) > 0 { + if _, inRequest := requestedData[k]; requestedData != nil && !inRequest { + delete(data, k) + continue + } if data == nil { data = map[string][]byte{} } diff --git a/internal/k8s/service/secret_service_test.go b/internal/k8s/service/secret_service_test.go index af350e63..3dfabe3d 100644 --- a/internal/k8s/service/secret_service_test.go +++ b/internal/k8s/service/secret_service_test.go @@ -349,7 +349,7 @@ func TestUpdateSecretKeepsValuesForEmptyStringData(t *testing.T) { svc := newTestSecretService(m, &auditFixture{}) err := svc.UpdateSecret(context.Background(), &model.UpdateSecretReq{ ClusterID: 1, Namespace: "ops", Name: "app", - // alpha 只在 stringData 中以空值出现;beta 在 data 中给新值、stringData 中为空;gamma 原本只在 stringData 中 + // alpha 只在 stringData 中以空值出现(请求 data 不含);beta 在 data 中给新值、stringData 中为空;gamma 原本只在 stringData 中 Data: model.BinaryDataMap{"beta": []byte("new-beta")}, StringData: map[string]string{"alpha": "", "beta": "", "gamma": "", "delta": "", "eps": "new-eps"}, }) @@ -357,15 +357,15 @@ func TestUpdateSecretKeepsValuesForEmptyStringData(t *testing.T) { t.Fatal(err) } u := m.updated - if string(u.Data["alpha"]) != fixtureAlpha { - t.Fatal("stringData 空值应保留 data 中的原值") + // SEC-25 P3-1:alpha/gamma 不在请求 data 中,视为已删除,不因 stringData 空串被恢复 + for _, k := range []string{"alpha", "gamma"} { + if _, ok := u.Data[k]; ok { + t.Fatalf("请求 data 不含 %s 时不应恢复原值", k) + } } if string(u.Data["beta"]) != "new-beta" { t.Fatal("data 中的新值不应被原值覆盖") } - if string(u.Data["gamma"]) != fixtureBeta { - t.Fatal("stringData 空值应保留 stringData 中的原值") - } for _, k := range []string{"alpha", "beta", "gamma"} { if _, ok := u.StringData[k]; ok { t.Fatalf("保留原值的键 %s 应从 stringData 中移除,否则写入时会覆盖 data", k) @@ -510,3 +510,75 @@ func TestSecretSameKeyInDataAndStringData(t *testing.T) { assertNoPlaintext(t, "audit", string(raw)) }) } + +// SEC-25 P3-1:stringData 空串只在请求 data 同样包含该键时恢复原值;data 不含该键时该键被删除 +func TestUpdateSecretEmptyStringDataRestoresOnlyKeysInRequestData(t *testing.T) { + m := &secretManagerFixture{secret: newSecretFixture()} + svc := newTestSecretService(m, &auditFixture{}) + if err := svc.UpdateSecret(context.Background(), &model.UpdateSecretReq{ + ClusterID: 1, Namespace: "ops", Name: "app", + // alpha 在 data 中(脱敏回填的空值)→ 恢复;beta 不在 data 中 → 删除 + Data: model.BinaryDataMap{"alpha": []byte{}}, + StringData: map[string]string{"alpha": "", "beta": ""}, + }); err != nil { + t.Fatal(err) + } + u := m.updated + if string(u.Data["alpha"]) != fixtureAlpha { + t.Fatal("请求 data 含该键时 stringData 空串应恢复原值") + } + if _, ok := u.Data["beta"]; ok { + t.Fatal("请求 data 不含该键时应删除,不应恢复原值") + } + if len(u.StringData) != 0 { + t.Fatalf("恢复或删除的键都不应留在 stringData 中(否则写入时会覆盖/写空 data): %v", u.StringData) + } +} + +// SEC-25 P3-2:请求未携带 data(nil)时保留集群现有 data;携带空对象 {} 时删除全部 data 键 +func TestUpdateSecretNilDataKeepsExistingEmptyDataClears(t *testing.T) { + m := &secretManagerFixture{secret: newSecretFixture()} + svc := newTestSecretService(m, &auditFixture{}) + if err := svc.UpdateSecret(context.Background(), &model.UpdateSecretReq{ + ClusterID: 1, Namespace: "ops", Name: "app", Labels: map[string]string{"app": "demo2"}, + }); err != nil { + t.Fatal(err) + } + if string(m.updated.Data["alpha"]) != fixtureAlpha || string(m.updated.Data["beta"]) != fixtureBeta || len(m.updated.Data) != 2 { + t.Fatalf("data 为 nil 时应保留集群现有 data: %v", keysOf(m.updated.Data)) + } + if m.updated.Labels["app"] != "demo2" { + t.Fatal("其他字段应正常更新") + } + + // JSON 层面:缺省 data 与 "data": null 均为 nil,"data": {} 为非 nil 空 map + for _, body := range []string{`{}`, `{"data":null}`} { + var req model.UpdateSecretReq + if err := json.Unmarshal([]byte(body), &req); err != nil || req.Data != nil { + t.Fatalf("%s 应解析为 nil data: %v %v", body, req.Data, err) + } + } + var empty model.UpdateSecretReq + if err := json.Unmarshal([]byte(`{"data":{}}`), &empty); err != nil || empty.Data == nil || len(empty.Data) != 0 { + t.Fatalf(`{"data":{}} 应解析为非 nil 空 map: %v %v`, empty.Data, err) + } + + m = &secretManagerFixture{secret: newSecretFixture()} + svc = newTestSecretService(m, &auditFixture{}) + if err := svc.UpdateSecret(context.Background(), &model.UpdateSecretReq{ + ClusterID: 1, Namespace: "ops", Name: "app", Data: empty.Data, + }); err != nil { + t.Fatal(err) + } + if m.updated.Data == nil || len(m.updated.Data) != 0 { + t.Fatalf("data 为 {} 时应删除全部 data 键: %v", keysOf(m.updated.Data)) + } +} + +func keysOf(m map[string][]byte) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} From ed683efb937f89be325ed0e9e1bb8874143185bf Mon Sep 17 00:00:00 2001 From: Bamboo <13664854532@163.com> Date: Sat, 24 Oct 2026 00:55:30 +0800 Subject: [PATCH 10/14] =?UTF-8?q?docs(release):=20SEC-29/SEC-25=20?= =?UTF-8?q?=E5=90=AF=E5=8A=A8=E8=BF=81=E7=A7=BB=E8=AF=B4=E6=98=8E=E3=80=81?= =?UTF-8?q?=E5=9B=9E=E6=BB=9A=20SQL=20=E8=8D=89=E6=A1=88=E3=80=81=E4=B9=90?= =?UTF-8?q?=E8=A7=82=E9=94=81=20409=20=E4=B8=8E=20Secret=20=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E8=AF=AD=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/release/k8s-secret-redaction.md | 4 + docs/release/k8s-serviceaccount-token.md | 121 ++++++++++++++++++++--- 2 files changed, 113 insertions(+), 12 deletions(-) diff --git a/docs/release/k8s-secret-redaction.md b/docs/release/k8s-secret-redaction.md index 12442e44..c3b82bdc 100644 --- a/docs/release/k8s-secret-redaction.md +++ b/docs/release/k8s-secret-redaction.md @@ -129,6 +129,10 @@ ## 4. 回写保护(保证前端改造前不丢数据) - `PUT .../update`:如果请求 `data` 中某个键的值为空,而集群中该键当前有非空值,就保留原值。这样前端把脱敏后的详情原样回填提交时,真实值不会被清空。要删除一个键,请在请求里去掉它。`string_data` 按同样规则处理:某个键的值为空、而集群中该键当前有非空值时,这个键不会作为 `stringData` 提交(否则 K8s 合并 `stringData` 时会把 `data` 中的值覆盖成空),而是在 `data` 中保留原值;如果请求 `data` 对同名键给了非空新值,以请求的新值为准。集群中不存在的键按原样写入。副作用是:通过表单无法把一个已有非空值的键改成空值(`data` 和 `string_data` 都不行),需要时请改用 YAML 更新,在 YAML 中写 `""`。请求没有携带 `last-applied-configuration` 注解时,保留集群中的原注解。 +- **SEC-29 同批次调整(SEC-25 P3)**: + - `data` 缺省与空对象的区别:请求**没有 `data` 字段**或为 `"data": null` 时,视为“不修改 data”,**保留集群现有的全部 `data` 键和值**(改造前会把 `data` 整体清空);请求带 **`"data": {}`** 时表示**显式清空**,集群中的 `data` 键全部删除。 + - `string_data` 空串的恢复范围收窄:某个键在 `string_data` 中为空串、集群中有非空原值时,**只有请求 `data` 也包含该键**(或请求根本没带 `data`,即整体保留)才恢复原值;请求 `data` 不包含该键时,该键视为已被删除,不会被恢复,也不会被写成空值(从 `stringData` 中移除,更新后集群中不再有该键)。 + - 示例:集群 `data` 为 `{a, b}`,请求 `{"data": {"a": ""}, "string_data": {"a": "", "b": ""}}` → 更新后 `a` 保留原值、`b` 被删除;请求 `{"string_data": {"a": ""}}`(无 `data`)→ `a`、`b` 都保留;请求 `{"data": {}}` → `a`、`b` 都删除。 - `PUT .../update/yaml`:值仍为 `` 的键保持集群中的原值,用户只需修改需要变更的键。占位符对应的键在集群中不存在时拒绝更新。 - `POST .../create/yaml`:YAML 中含 `` 时拒绝创建。 diff --git a/docs/release/k8s-serviceaccount-token.md b/docs/release/k8s-serviceaccount-token.md index 9067fdb1..d0891dc8 100644 --- a/docs/release/k8s-serviceaccount-token.md +++ b/docs/release/k8s-serviceaccount-token.md @@ -6,7 +6,8 @@ - 创建/更新 SA 时请求里的 `secrets` 引用**被忽略并记 Warn 日志**(只记 Secret 名称),不再写入集群。 - GET 与 POST(TokenRequest)`/token` 都改为**只认显式授权**:必须按完整路由模板 + 方法授权,`/api/k8s/*`、`/*`、带 `:param` 的模板、具体请求路径都不覆盖。admin 照常放行。 - 两个接口每次调用都**同步**写一条审计,只含元数据、不含 token;审计写入失败时**不返回 token**(HTTP 500)。响应头带 `Cache-Control: no-store`、`Pragma: no-cache`。 -- 不涉及数据库表结构变化,没有 DDL。启动时只会向 `cl_system_apis` 补登/改名权限行(见第 3 节)。 +- 不涉及数据库表结构变化,没有 DDL。启动时只会向 `cl_system_apis` 补登/改名权限行(见第 3 节)。**该启动迁移需要用户批准后才能上线**(见 3.6)。 +- `PUT .../update/yaml`:YAML 带 `metadata.resourceVersion` 时按乐观锁更新,版本过期返回 **HTTP 409**;不带时保持冲突重试(见 1.4)。 ## 1. 接口契约变化 @@ -36,7 +37,8 @@ | `< 600`(含负数)或 `> 3600` | **HTTP 400**,`code=1`,`msg` 说明取值范围;**不调用 K8s、不签发 token**;同步写一条审计(`status_code=400`、`success=false`、`expiration_seconds` 为请求值) | | 非整数(如字符串) | 绑定失败,HTTP 400(与其他接口的绑定错误一致,此时不进入 service、不写 token 审计) | - 下限 600 是 K8s TokenRequest 允许的最小值;上限 3600 是本平台的策略上限。校验在调用 K8s 之前完成。响应中的 `expiration_seconds` 为实际签发使用的值(不传时为 3600)。 + 下限 600 是 K8s TokenRequest 允许的最小值;**上限 3600 是本平台自己的策略,不是 K8s 的限制**(K8s 本身允许更长的有效期,最大值由 apiserver 的 `--service-account-max-token-expiration` 决定)。超出范围返回 **HTTP 400**,这一行为已确认可以接受。校验在调用 K8s 之前完成。响应中的 `expiration_seconds` 为实际签发使用的值(不传时为 3600)。 +- 响应中的 `expiration_time` 固定为 **UTC**,格式 `2006-01-02T15:04:05Z`:K8s 返回的过期时间先 `.UTC()` 再格式化(改造前直接格式化,非 UTC 时区的时间会被错误地加上 `Z` 后缀)。 - K8s 返回错误时审计记录真实状态码(403/404/…)。 - **审计写入失败时不返回 token(HTTP 500)。但 token 已由 K8s 签发**,TokenRequest 签发的 token 无法单独吊销,会在 `expiration_seconds` 到期前保持有效(调用方拿不到它)。由于有效期上限为 3600,这类“签发了但没交出去”的 token **最多有效 1 小时**。如需立即失效,只能删除并重建该 SA。 @@ -50,9 +52,18 @@ 为什么是“忽略 + 日志”而不是“拒绝”:`secrets` 字段在现有 API 契约和前端类型(`k8s_serviceaccount.ts` 的 `secrets?: string[]`)中都存在,并且 detail YAML 里带有集群维护的 `secrets`,前端/用户把 YAML 原样回传时如果直接拒绝会把正常的编辑请求打失败。忽略后既堵住了“把别人的 token Secret 挂到自己 SA 上”的路径,又不破坏现有调用。日志关键字:`忽略请求中的ServiceAccount secrets引用`(仅含 Secret 名称)。 -### 1.4 更新 SA 改为冲突重试(`PUT .../update`、`PUT .../update/yaml`) +### 1.4 更新 SA:YAML 带版本走乐观锁,其余冲突重试(`PUT .../update`、`PUT .../update/yaml`) -两条更新路径都改用 `k8s.io/client-go/util/retry` 的 `retry.RetryOnConflict(retry.DefaultRetry, ...)`(`client-go` 原有依赖,`go.mod`/`go.sum` 未改动): +**YAML 带 `metadata.resourceVersion` 时(严格乐观锁)**: + +1. 按 URL 中的 `namespace` / `name` Get 当前对象作为基础(保留不可编辑字段和集群维护的 `secrets` 引用,`name` / `namespace` 以 URL 为准); +2. 只应用 4 个可编辑字段(同下文第 2 步); +3. 把 `resourceVersion` 设为 **YAML 中提交的值**,只 Update **一次**; +4. 版本已过期时 API Server 返回 Conflict,**不重试**,接口返回 **HTTP 409**、`code=1`,`msg` 形如 `ServiceAccount ops/app 已被修改(提交的 resourceVersion=4 已过期),请重新获取 YAML 后再提交`。 + +409 的映射方式沿用仓库已有的错误映射风格:service 返回带类型的业务错误 `base.BusinessError{Code: constants.ErrK8sResourceConflict}`(与有效期越界返回 `BusinessError{constants.ErrInvalidParam}` → HTTP 400 同一套机制),handler 通过 `respondBusinessError`(`internal/k8s/api/secret.go`,从原 `handleAuditedRequest` 中抽出)用 `errors.As` + `errors.Is` 识别,调用新增的 `base.ConflictError`(HTTP 409 + `code=1`,与 `base.BadRequestError` 同构)。其他错误仍是项目约定的 HTTP 200 + `code=1`。 + +**YAML 不带 `resourceVersion`,以及 JSON 更新(`PUT .../update`)**:保持冲突重试。两条路径都使用 `k8s.io/client-go/util/retry` 的 `retry.RetryOnConflict(retry.DefaultRetry, ...)`(`client-go` 原有依赖,`go.mod`/`go.sum` 未改动): 1. 每次尝试先按 URL 中的 `namespace` / `name` **Get 最新版本**(带最新 `resourceVersion`); 2. 只把请求中允许修改的字段**整体覆盖**到最新版本上:`labels`、`annotations`、`automount_service_account_token`、`image_pull_secrets`; @@ -64,11 +75,14 @@ | 读取与写入之间 SA 被并发修改(如 token controller 追加了 `secrets` 引用) | 用旧快照整体替换,可能抹掉刚写入的引用 | 冲突后重新读取最新版本再应用字段,保留最新的 `secrets` 引用 | | 连续冲突超过 `retry.DefaultRetry`(共 5 次尝试,间隔约 10ms) | — | 返回 `code=1`,`msg` 形如 `更新ServiceAccount失败: Operation cannot be fulfilled ...` | | SA 不存在 | 报“ServiceAccount不存在” | 不变;不会调用 Update | -| YAML 中的 `metadata.resourceVersion` | 透传给 K8s,旧版本 YAML 会直接 409 | **忽略**,以最新版本为基础(对允许修改的字段是“后写覆盖”) | +| YAML 中带 `metadata.resourceVersion` | 透传给 K8s,旧版本 YAML 直接失败(HTTP 200 + `code=1`) | **乐观锁**:版本一致则更新成功;版本过期只 Update 一次并返回 **HTTP 409**,不重试 | +| YAML 中不带 `metadata.resourceVersion` | 用读取时的快照整体替换 | 以最新版本为基础、冲突重试(对允许修改的字段是“后写覆盖”) | | YAML 中上述 4 个字段以外的内容(`ownerReferences`、`finalizers`、`uid` 等) | 随整体替换写入 | **不生效**,保留集群现有值 | | JSON 中未传 `labels` / `annotations` / `image_pull_secrets` / `automount_service_account_token` | 整体替换为空 | 不变(仍是整体覆盖为空),与原契约一致 | -日志关键字:失败时 `更新ServiceAccount失败`(manager),忽略 secrets 时同 1.3。 +“JSON 更新时没传的字段会被清空”是**改造前就存在的行为**,本次不改,留给 SEC-30 处理。manager 中旧的 `UpdateServiceAccount`(整体替换)方法同样保留,留给 SEC-30。 + +日志关键字:失败时 `更新ServiceAccount失败` / `按resourceVersion更新ServiceAccount失败`(manager),忽略 secrets 时同 1.3。 ## 2. 权限码与授权 @@ -81,11 +95,84 @@ - 授权按 `path + method` 精确匹配:GET 授权不覆盖 POST,反之亦然。 - 以下授权都**不再**放行 `/token`:`/api/k8s/*`、`/*`、`/api/k8s/serviceaccount/:cluster_id/:namespace/:name/:action`、换了参数名的模板、具体路径如 `/api/k8s/serviceaccount/1/ops/app/token`、中间通配 `/api/k8s/*/token`。 -## 3. 启动时的权限目录变化(无 DDL) +## 3. 启动时的权限目录迁移(无 DDL) + +**本节描述的启动迁移需要用户批准后才能上线。** 它在服务每次启动时由 `bootstrapPermissionCatalog`(`internal/app/permissions.go`)自动执行,没有单独的开关;批准前不要把包含本改动的版本部署到已有数据的环境。 + +### 3.1 涉及的表和行 + +只涉及 `cl_system_apis` 一张表(`model.Api`),不改表结构、不动其他表、不授予任何角色(不写 `cl_role_apis` / `cl_user_apis`)。可能被写入的只有下面三行(`method`:1=GET,2=POST): + +| `path` | `method` | 目标 `name` | 目标 `description` | +| --- | --- | --- | --- | +| `/api/k8s/serviceaccount/:cluster_id/:namespace/:name/token` | 1 | `k8s:serviceaccount:token` | `读取 K8s ServiceAccount token Secret(SEC-29,每次调用写审计;仅显式授权生效,通配符授权不覆盖)` | +| `/api/k8s/serviceaccount/:cluster_id/:namespace/:name/token` | 2 | `k8s:serviceaccount:token:create` | `通过 TokenRequest 签发 K8s ServiceAccount token(SEC-29,每次调用写审计;仅显式授权生效,通配符授权不覆盖)` | +| `/api/k8s/secret/:cluster_id/:namespace/:name/reveal`(SEC-25) | 1 | `k8s:secret:reveal` | `查看 K8s Secret 明文(SEC-25,…)` | + +其他路由仍按原逻辑只补登缺失的行(自动名 + 说明 `" "`),本次没有改变。 + +### 3.2 两种操作与条件 + +**(a) 插入(行不存在时)**:按 `(path, method)` 判断该路由在表中没有未删除的行(查询走 GORM 软删除过滤 `deleted_at = 0`,已软删除的行不计入,与原逻辑一致)时,按上表的 `name` / `description` 插入,`version='v1'`、`category=2`、`is_public=0`。插入使用 `INSERT ... ON CONFLICT DO NOTHING`:若 `name` 已被其他行占用(唯一索引 `idx_name_del` = `name + deleted_at`),插入被静默跳过。插入后会核对这三个权限码,**缺行时记一条 Warn**:`敏感接口权限码未登记到接口目录(插入因唯一键冲突被跳过),请人工核对 cl_system_apis`,字段只有 `permission=<权限码>`,不影响启动。 + +**(b) 改名(行已存在时)**:已部署实例在 SEC-29 之前就登记过 `/token` 的 GET/POST 行,名称是自动生成的。只有同时满足以下条件才更新: + +- `id` = 按 `(path, method)` 查到的那一行; +- `name` 仍等于自动生成值 `" ") 前 4 字节 hex>"`,对 `/token` 两行分别为 `GET GetServiceAccountToken-fm af783601`、`POST CreateServiceAccountToken 8ff0d051`(由代码计算,上线前请与库中实际值核对); +- `description` 仍等于自动生成值 `" "`,即 `GET /api/k8s/serviceaccount/:cluster_id/:namespace/:name/token` / `POST /api/k8s/serviceaccount/:cluster_id/:namespace/:name/token`。 + +等价 SQL(**仅说明语义,由程序通过 GORM 执行,请勿手工执行**): + +```sql +UPDATE cl_system_apis + SET name = '<目标 name>', description = '<目标 description>', updated_at = <当前时间> + WHERE id = ? AND name = '<自动名>' AND description = ' ' AND deleted_at = 0; +``` + +只改 `name`、`description` 两个字段(GORM 会同时刷新 `updated_at`),`path`、`method`、`version`、`category`、`is_public` 和角色关联都不动。 + +- 管理员改过名称或说明(任一项)的行**整行不动**,记一条 Info 日志 `敏感接口权限行的说明已被管理员修改,保留原名称与说明`。这类行保留自动名,不影响授权(授权只看 `path + method`)。 +- 改名失败(例如目标权限码已被其他行占用触发唯一索引冲突)只记 Warn 日志,不影响启动。 + +### 3.3 幂等性 + +- 插入以 `(path, method)` 是否已有行为前提,再加 `ON CONFLICT DO NOTHING`,重复启动不会产生重复行。 +- 改名的 WHERE 带“名称、说明都仍是自动值”的条件,改过一次后条件不再成立,再次启动是空操作;多个实例同时启动时,同一行最多被改一次,其余实例的 UPDATE 影响 0 行。 +- 缺行 Warn 只针对本次实际尝试插入的权限码;已存在(包括被管理员改名)的行不会每次启动都告警。 + +### 3.4 回滚 SQL(草稿,只写在文档里,绝不执行) -- 新实例:启动时 `bootstrapPermissionCatalog` 按上表名称登记两行。 -- 已部署实例:`/token` 路由在 SEC-29 之前就存在,目录里已有按路由自动生成的名称(形如 `GET GetServiceAccountToken-fm 1a2b3c4d`)。启动时**仅当名称仍等于自动生成值**时改成上表的权限码与说明(`UPDATE ... WHERE id = ? AND name = <自动名>`);管理员改过的名称、说明保持不变。改名失败(例如权限码已被其他行占用)只记 Warn 日志,不影响启动,也不影响授权(授权只看 path + method)。 -- 不会自动授予任何角色。 +以下 SQL **只是草稿**,本次开发和验证过程中没有执行过,也不应由程序或流水线执行。如确需把改过名的两行恢复为自动名,由 DBA 在批准后先核对 `id` 和当前值再手工执行: + +```sql +-- 草稿:仅供审阅,未执行。执行前请先 SELECT 确认行与值。 +-- SELECT id, name, path, method, description FROM cl_system_apis +-- WHERE path = '/api/k8s/serviceaccount/:cluster_id/:namespace/:name/token' AND deleted_at = 0; + +UPDATE cl_system_apis + SET name = 'GET GetServiceAccountToken-fm af783601', + description = 'GET /api/k8s/serviceaccount/:cluster_id/:namespace/:name/token' + WHERE path = '/api/k8s/serviceaccount/:cluster_id/:namespace/:name/token' + AND method = 1 AND name = 'k8s:serviceaccount:token' AND deleted_at = 0; + +UPDATE cl_system_apis + SET name = 'POST CreateServiceAccountToken 8ff0d051', + description = 'POST /api/k8s/serviceaccount/:cluster_id/:namespace/:name/token' + WHERE path = '/api/k8s/serviceaccount/:cluster_id/:namespace/:name/token' + AND method = 2 AND name = 'k8s:serviceaccount:token:create' AND deleted_at = 0; +``` + +注意:新实例上这两行是 SEC-29 直接插入的(没有“原自动名”),回滚代码后保留即可;恢复名称不是回滚的必要步骤(见第 6 节)。不建议删除这些行:删除会让已经按这两行显式授权的角色失去授权关联。 + +### 3.5 不会做的事 + +- 不会自动授予任何角色; +- 不会修改管理员维护过的名称、说明; +- 不会删除任何行,也不会改 `path` / `method`。 + +### 3.6 上线审批 + +该迁移会在启动时写生产库的 `cl_system_apis`,**需要用户批准后才能上线**。审批时请确认:3.2 中的两个自动名与库中实际值一致;是否接受“管理员只改过说明的行保留自动名”;是否需要按 3.4 准备回滚。 ## 4. 审计字段 @@ -125,14 +212,24 @@ POST 的 `action` 为 `k8s_serviceaccount_token_create`,不含 `secret_name` - **只靠通配符授权(`/api/k8s/*`、`/*` 等)的非 admin 角色将失去 GET/POST `/token`**,返回 403。需要时按第 2 节为角色显式授权对应的 path + method。 - 依赖“借挂 token Secret”拿 token 的用法失效(这正是要堵的越权路径)。 - 创建/更新时传 `secrets` 不再生效,但请求不会失败。 -- 更新 SA 改为“读最新版本 + 只应用允许修改的字段 + 冲突重试”:YAML 更新中 `resourceVersion` 及 4 个可编辑字段以外的内容不再生效(见 1.4)。 +- 更新 SA 改为“读最新版本 + 只应用允许修改的字段”:YAML 中 4 个可编辑字段以外的内容不再生效;YAML 带 `resourceVersion` 时为乐观锁,版本过期返回 **HTTP 409**(原来是 HTTP 200 + `code=1`),不带时冲突重试(见 1.4)。调用方需要能处理 409(提示用户重新获取 YAML)。 +- JSON 更新时没传的字段会被清空:原有行为,本次未改,留给 SEC-30。 +- `expiration_time` 统一为 UTC 字符串。 - `/token` 响应不可缓存(`no-store`)。 - POST `/token` 的 `expiration_seconds` 超出 600~3600 时由原来的“透传给 K8s”改为 HTTP 400;不传时由“交给 K8s 默认”改为由平台显式传 3600(K8s API 默认值同为 3600,实际效果不变)。 - 审计库不可用时 `/token` 返回 HTTP 500。 ## 6. 回滚 -- 回滚代码即可,无需数据库回滚。 +- 回滚代码即可,无需数据库回滚(3.4 的 SQL 只是可选的名称恢复草稿,不是必要步骤)。 - 回滚后 `cl_system_apis` 中残留的行:`k8s:serviceaccount:token`(GET)和 `k8s:serviceaccount:token:create`(POST),或在已部署实例上是被改名后的原行。旧代码启动时按 `(path, method)` 判断已存在,**不会改回自动名称,也不会新增重复行**,残留名称不影响授权。如需恢复原名称,可由 DBA 手动改 `name`;保留不动也无副作用。 - 回滚后通配符授权重新覆盖 `/token`,借挂 token Secret 重新可被读出,审计和 no-store 一并消失。为 SEC-29 新增的显式授权行在旧代码下仍然有效(旧代码同样接受精确授权)。 - 回滚期间已写入的 `TOKEN_REVEAL` / `TOKEN_CREATE` 审计记录保留,旧代码不读取也不受影响。 +- 回滚后 YAML 更新不再返回 409(恢复为改造前行为)。 + +## 7. 同批次的 SEC-25 语义变化(Secret 更新) + +详见 `docs/release/k8s-secret-redaction.md` 第 4 节,摘要: + +- `PUT /api/k8s/secret/.../update` 的 `string_data` 中值为空串的键,**只有当请求 `data` 也包含该键时**才恢复集群原值;请求 `data` 不包含该键时,该键被**删除**(不再因为 `string_data` 里残留的空串被恢复)。 +- 请求**没有 `data` 字段**(或 `"data": null`)时,**保留**集群现有的 `data`(原来会把 `data` 整体清空);请求带 **`"data": {}`**(空对象)表示**显式删除全部 `data` 键**。 From f7636015d39df3033dce54932fd933c92c6c3eb2 Mon Sep 17 00:00:00 2001 From: Bamboo <13664854532@163.com> Date: Sat, 24 Oct 2026 06:45:25 +0800 Subject: [PATCH 11/14] =?UTF-8?q?fix(k8s/secret):=20SEC-26=20P2=20?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E5=8C=96=E8=A7=A3=E6=9E=90=20Secret=20YAML?= =?UTF-8?q?=20=E5=90=8E=E5=86=8D=E6=81=A2=E5=A4=8D/=E6=A3=80=E6=9F=A5?= =?UTF-8?q?=E5=8D=A0=E4=BD=8D=E7=AC=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 先把 YAML 解析为类型化 Secret(字段与 corev1.Secret 一一对应,data 值保留原始文本), 在 sec.Data / sec.StringData 上恢复和检查占位符,不再按小写键查 map, Data/DATA/StringData/stringdata 等大小写变体与规范写法同样处理 - 拒绝多文档 YAML(含末尾空文档)、任意层级完全重复的键、顶层与 metadata 下只差大小写的字段 - data 解码后(含折行 base64)为占位符或其空白变体、stringData 为占位符空白变体时拒绝 - YAMLToSecret 复用同一解析并兜底拒绝残留占位符 - 解析错误抹除可能回显的值 - 文档补充 SEC-26 P2 行为变化 --- docs/release/k8s-secret-redaction.md | 7 + internal/k8s/utils/secret.go | 16 +- internal/k8s/utils/secret_redact.go | 306 ++++++++++++--- internal/k8s/utils/secret_redact_case_test.go | 370 ++++++++++++++++++ internal/k8s/utils/secret_redact_test.go | 81 ++++ 5 files changed, 722 insertions(+), 58 deletions(-) create mode 100644 internal/k8s/utils/secret_redact_case_test.go diff --git a/docs/release/k8s-secret-redaction.md b/docs/release/k8s-secret-redaction.md index c3b82bdc..ed580a7b 100644 --- a/docs/release/k8s-secret-redaction.md +++ b/docs/release/k8s-secret-redaction.md @@ -135,6 +135,13 @@ - 示例:集群 `data` 为 `{a, b}`,请求 `{"data": {"a": ""}, "string_data": {"a": "", "b": ""}}` → 更新后 `a` 保留原值、`b` 被删除;请求 `{"string_data": {"a": ""}}`(无 `data`)→ `a`、`b` 都保留;请求 `{"data": {}}` → `a`、`b` 都删除。 - `PUT .../update/yaml`:值仍为 `` 的键保持集群中的原值,用户只需修改需要变更的键。占位符对应的键在集群中不存在时拒绝更新。 - `POST .../create/yaml`:YAML 中含 `` 时拒绝创建。 +- **SEC-26 P2:YAML 解析与校验收紧**(`update/yaml` 与 `create/yaml` 都适用): + - 占位符的识别和恢复改为在类型化的 Secret 对象上进行,字段名按 Kubernetes 解码规则不区分大小写:`Data`、`DATA`、`StringData`、`stringdata`、`STRINGDATA` 等写法与 `data`/`stringData` 一样处理(更新时恢复原值,创建时拒绝)。改造前这些写法会绕过检查,把 `` 原样写入集群。 + - 只接受**单个 YAML 文档**。含 `---` 分隔的第二个文档(包括末尾多出一个只有 `---` 的空文档)一律拒绝。开头的 `---` 和末尾的 `...` 仍可使用。 + - 拒绝**重复键**:任意层级完全相同的重复键,以及顶层字段、`metadata` 下只差大小写的字段(如同时写 `stringData` 和 `StringData`,或 `name` 和 `Name`)。使用合并键 `<<` 后又显式覆盖其中某个键的写法也会因为重复键被拒绝。 + - 下面这些写法会被视为仍含占位符:`data` 中 base64 解码后(含折行写法,比如 `|` 块里分两行)为 `` 或其首尾带空白的变体;`stringData` 中首尾带空白的 ``(如 `" "`)。更新时报错要求填写真实值,创建时拒绝。 + - 报错只包含位置、字段名和键名,不回显 Secret 的值。 + - 更新时如果发生了占位符恢复,提交给集群的 YAML 由解析后的对象重新生成,字段顺序和格式会规范化(未识别的顶层字段会被丢弃,与改造前写入集群的结果一致)。 ## 5. 前端待办 diff --git a/internal/k8s/utils/secret.go b/internal/k8s/utils/secret.go index 4d34ef07..333e31f5 100644 --- a/internal/k8s/utils/secret.go +++ b/internal/k8s/utils/secret.go @@ -27,6 +27,7 @@ package utils import ( "fmt" + "strings" "github.com/GoSimplicity/AI-CloudOps/internal/model" corev1 "k8s.io/api/core/v1" @@ -133,11 +134,18 @@ func YAMLToSecret(y string) (*corev1.Secret, error) { if y == "" { return nil, fmt.Errorf("YAML 字符串不能为空") } - var sec corev1.Secret - if err := yaml.Unmarshal([]byte(y), &sec); err != nil { - return nil, fmt.Errorf("解析YAML失败: %w", err) + // 与占位符检查/恢复共用同一个类型化解析:单文档、无重复键、错误不回显值 + sec, placeholders, err := parseSecretYAMLTyped(y) + if err != nil { + return nil, err + } + if len(placeholders) > 0 { + return nil, fmt.Errorf("data 中键 %s 仍为脱敏占位符 %s,请填写真实值", strings.Join(placeholders, ", "), SecretRedactedPlaceholder) + } + if err := checkNoSecretPlaceholder(sec); err != nil { + return nil, err } - return &sec, nil + return sec, nil } func ValidateSecretData(secretType corev1.SecretType, data map[string][]byte, stringData map[string]string) error { diff --git a/internal/k8s/utils/secret_redact.go b/internal/k8s/utils/secret_redact.go index ceb98954..91442651 100644 --- a/internal/k8s/utils/secret_redact.go +++ b/internal/k8s/utils/secret_redact.go @@ -26,13 +26,21 @@ package utils import ( - "encoding/base64" + "bytes" "encoding/json" + "errors" "fmt" + "io" + "regexp" "sort" + "strings" + "unicode" + "unicode/utf8" "github.com/GoSimplicity/AI-CloudOps/internal/model" + yamlv2 "go.yaml.in/yaml/v2" corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/yaml" ) @@ -126,38 +134,219 @@ func redactedSecretYAMLObject(sec *corev1.Secret) (map[string]interface{}, error return obj, nil } -// secretYAMLValues 解析 YAML 中的 data 与 stringData(值统一视为字符串) -func secretYAMLValues(obj map[string]interface{}, field string) map[string]interface{} { - values, _ := obj[field].(map[string]interface{}) - return values +// secretYAMLDocument 与 corev1.Secret 的字段及 JSON 名称一一对应(由测试保证), +// 唯一区别是 data 的值保留原始 JSON 文本:base64 解码前先识别裸占位符, +// 其余值再按 corev1.Secret 相同的规则(encoding/json 的 []byte 解码)解码。 +// 不能用嵌入 corev1.Secret 的方式定义:嵌入字段在 sigs.k8s.io/yaml 中会丢失数值转字符串的处理。 +type secretYAMLDocument struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + Immutable *bool `json:"immutable,omitempty"` + Data map[string]json.RawMessage `json:"data,omitempty"` + StringData map[string]string `json:"stringData,omitempty"` + Type corev1.SecretType `json:"type,omitempty"` } -// SecretYAMLHasRedactedPlaceholder 判断 YAML 的 data/stringData 中是否仍含脱敏占位符 -func SecretYAMLHasRedactedPlaceholder(y string) (bool, error) { - var obj map[string]interface{} - if err := yaml.Unmarshal([]byte(y), &obj); err != nil { - return false, fmt.Errorf("解析YAML失败: %w", err) +var ( + errSecretYAMLMultiDoc = errors.New("解析YAML失败: 只支持单个 YAML 文档,请去掉 --- 分隔的其他文档") + // yaml 库的部分报错会用反引号或 %#v 带出标量值,统一抹掉 + secretYAMLQuotedValue = regexp.MustCompile("`[^`]*`") +) + +// sanitizeSecretYAMLError 去掉解析错误中可能回显的值,只保留位置与原因 +func sanitizeSecretYAMLError(err error) error { + msg := err.Error() + if i := strings.Index(msg, "unsupported map key"); i >= 0 { + msg = msg[:i] + "unsupported map key(键必须是字符串)" } - for _, field := range []string{"data", "stringData"} { - for _, v := range secretYAMLValues(obj, field) { - if s, ok := v.(string); ok && s == SecretRedactedPlaceholder { - return true, nil + msg = secretYAMLQuotedValue.ReplaceAllString(msg, "`...`") + return fmt.Errorf("解析YAML失败: %s", msg) +} + +// checkSingleYAMLDocument 拒绝多文档 YAML(包括末尾多出的空文档 ---) +func checkSingleYAMLDocument(raw []byte) error { + dec := yamlv2.NewDecoder(bytes.NewReader(raw)) + var first interface{} + if err := dec.Decode(&first); err != nil { + if errors.Is(err, io.EOF) { + return nil + } + return sanitizeSecretYAMLError(err) + } + var next interface{} + if err := dec.Decode(&next); !errors.Is(err, io.EOF) { + return errSecretYAMLMultiDoc + } + return nil +} + +// foldSecretFieldName 与 encoding/json 匹配字段名时的大小写折叠规则一致 +func foldSecretFieldName(name string) string { + var b strings.Builder + for _, r := range name { + if r < utf8.RuneSelf { + if 'a' <= r && r <= 'z' { + r -= 'a' - 'A' } + b.WriteRune(r) + continue + } + for { + r2 := unicode.SimpleFold(r) + if r2 <= r { + r = r2 + break + } + r = r2 + } + b.WriteRune(r) + } + return b.String() +} + +// checkFoldedDuplicateKeys 拒绝只差大小写、会被 encoding/json 合并到同一字段的键 +func checkFoldedDuplicateKeys(obj map[string]json.RawMessage, prefix string) error { + keys := make([]string, 0, len(obj)) + for k := range obj { + keys = append(keys, k) + } + sort.Strings(keys) + seen := make(map[string]string, len(keys)) + for _, k := range keys { + f := foldSecretFieldName(k) + if prev, ok := seen[f]; ok { + return fmt.Errorf("解析YAML失败: 字段 %s%s 与 %s%s 只差大小写,请只保留一个", prefix, prev, prefix, k) } + seen[f] = k + } + return nil +} + +// checkSecretFieldCaseDuplicates 检查顶层与 metadata 下只差大小写的重复字段 +func checkSecretFieldCaseDuplicates(j []byte) error { + var top map[string]json.RawMessage + if err := json.Unmarshal(j, &top); err != nil { + // 非对象由后续类型化解析报错 + return nil + } + if err := checkFoldedDuplicateKeys(top, ""); err != nil { + return err + } + metaName := foldSecretFieldName("metadata") + for k, v := range top { + if foldSecretFieldName(k) != metaName { + continue + } + var meta map[string]json.RawMessage + if err := json.Unmarshal(v, &meta); err != nil { + continue + } + if err := checkFoldedDuplicateKeys(meta, k+"."); err != nil { + return err + } + } + return nil +} + +// parseSecretYAMLTyped 把单文档 YAML 解析为类型化的 corev1.Secret。 +// 返回值中 data 里值恰为占位符的键不放入 sec.Data,而是按升序单独返回,供调用方恢复或拒绝。 +// 所有错误都不包含 Secret 的值。 +func parseSecretYAMLTyped(y string) (*corev1.Secret, []string, error) { + raw := []byte(y) + if err := checkSingleYAMLDocument(raw); err != nil { + return nil, nil, err + } + // 严格模式:任意层级完全相同的重复键直接报错(报错只含键名) + j, err := yaml.YAMLToJSONStrict(raw) + if err != nil { + return nil, nil, sanitizeSecretYAMLError(err) + } + if err := checkSecretFieldCaseDuplicates(j); err != nil { + return nil, nil, err + } + var doc secretYAMLDocument + if err := yaml.Unmarshal(raw, &doc); err != nil { + return nil, nil, sanitizeSecretYAMLError(err) + } + sec := &corev1.Secret{ + TypeMeta: doc.TypeMeta, + ObjectMeta: doc.ObjectMeta, + Immutable: doc.Immutable, + StringData: doc.StringData, + Type: doc.Type, + } + var placeholders []string + if doc.Data != nil { + sec.Data = make(map[string][]byte, len(doc.Data)) + for k, v := range doc.Data { + var s string + if json.Unmarshal(v, &s) == nil && s == SecretRedactedPlaceholder { + placeholders = append(placeholders, k) + continue + } + var b []byte + if err := json.Unmarshal(v, &b); err != nil { + return nil, nil, fmt.Errorf("解析YAML失败: data 中键 %s 的值不是合法的 base64 字符串", k) + } + sec.Data[k] = b + } + } + sort.Strings(placeholders) + return sec, placeholders, nil +} + +// isSecretPlaceholderValue 判断值去掉首尾空白后是否为占位符 +func isSecretPlaceholderValue(v string) bool { + return strings.TrimSpace(v) == SecretRedactedPlaceholder +} + +// checkNoSecretPlaceholder 确认 data(解码后)与 stringData 中没有占位符或其带空白的变体 +func checkNoSecretPlaceholder(sec *corev1.Secret) error { + dataKeys := make([]string, 0, len(sec.Data)) + for k := range sec.Data { + dataKeys = append(dataKeys, k) + } + sort.Strings(dataKeys) + for _, k := range dataKeys { + if isSecretPlaceholderValue(string(sec.Data[k])) { + return fmt.Errorf("data 中键 %s 解码后仍为脱敏占位符 %s,请填写真实值", k, SecretRedactedPlaceholder) + } + } + stringKeys := make([]string, 0, len(sec.StringData)) + for k := range sec.StringData { + stringKeys = append(stringKeys, k) + } + sort.Strings(stringKeys) + for _, k := range stringKeys { + if isSecretPlaceholderValue(sec.StringData[k]) { + return fmt.Errorf("stringData 中键 %s 仍为脱敏占位符 %s(或带空白的变体),请填写真实值", k, SecretRedactedPlaceholder) + } + } + return nil +} + +// SecretYAMLHasRedactedPlaceholder 判断 YAML 的 data/stringData 中是否仍含脱敏占位符。 +// 先解析为类型化的 Secret(字段名大小写变体按 Kubernetes 解码规则归并), +// data 中的裸占位符、解码后为占位符的值、stringData 中带空白的占位符都算。 +func SecretYAMLHasRedactedPlaceholder(y string) (bool, error) { + sec, placeholders, err := parseSecretYAMLTyped(y) + if err != nil { + return false, err + } + if len(placeholders) > 0 || checkNoSecretPlaceholder(sec) != nil { + return true, nil } return false, nil } // RestoreRedactedSecretYAML 将 YAML 中仍为占位符的键恢复为集群中的现有值。 // 前端拿到脱敏 YAML 后只改了部分字段再提交时,未改动的键保持原值而不是被占位符覆盖。 -// 占位符对应的键在现有 Secret 中不存在时返回错误。 +// 占位符对应的键在现有 Secret 中不存在、或恢复后仍残留占位符变体时返回错误。 +// 有恢复时返回由类型化 Secret 重新生成的 YAML,否则原样返回。 func RestoreRedactedSecretYAML(y string, existing *corev1.Secret) (string, error) { - var obj map[string]interface{} - if err := yaml.Unmarshal([]byte(y), &obj); err != nil { - return "", fmt.Errorf("解析YAML失败: %w", err) - } - if obj == nil { - return y, nil + sec, placeholders, err := parseSecretYAMLTyped(y) + if err != nil { + return "", err } var existingData map[string][]byte var existingStringData map[string]string @@ -167,49 +356,58 @@ func RestoreRedactedSecretYAML(y string, existing *corev1.Secret) (string, error } changed := false - data := secretYAMLValues(obj, "data") - for k, v := range data { - if s, ok := v.(string); ok && s == SecretRedactedPlaceholder { - if old, exists := existingData[k]; exists { - data[k] = base64.StdEncoding.EncodeToString(old) - } else if old, exists := existingStringData[k]; exists { - data[k] = base64.StdEncoding.EncodeToString([]byte(old)) - } else { - return "", fmt.Errorf("键 %s 仍为脱敏占位符且在现有Secret中不存在,请填写真实值", k) - } - changed = true + for _, k := range placeholders { + var restored []byte + if old, exists := existingData[k]; exists { + restored = old + } else if old, exists := existingStringData[k]; exists { + restored = []byte(old) + } else { + return "", fmt.Errorf("键 %s 仍为脱敏占位符且在现有Secret中不存在,请填写真实值", k) + } + if sec.Data == nil { + sec.Data = map[string][]byte{} } + sec.Data[k] = restored + changed = true } - stringData := secretYAMLValues(obj, "stringData") - for k, v := range stringData { - if s, ok := v.(string); ok && s == SecretRedactedPlaceholder { - if old, exists := existingStringData[k]; exists { - stringData[k] = old - changed = true - continue - } - old, exists := existingData[k] - if !exists { - return "", fmt.Errorf("键 %s 仍为脱敏占位符且在现有Secret中不存在,请填写真实值", k) - } - // stringData 写入后会合并到 data,这里直接以 data 形式恢复原值 - delete(stringData, k) - if data == nil { - data = map[string]interface{}{} - obj["data"] = data - } - if _, set := data[k]; !set { - data[k] = base64.StdEncoding.EncodeToString(old) - } + stringKeys := make([]string, 0, len(sec.StringData)) + for k := range sec.StringData { + stringKeys = append(stringKeys, k) + } + sort.Strings(stringKeys) + for _, k := range stringKeys { + if sec.StringData[k] != SecretRedactedPlaceholder { + continue + } + if old, exists := existingStringData[k]; exists { + sec.StringData[k] = old changed = true + continue + } + old, exists := existingData[k] + if !exists { + return "", fmt.Errorf("键 %s 仍为脱敏占位符且在现有Secret中不存在,请填写真实值", k) + } + // stringData 写入后会合并到 data,这里直接以 data 形式恢复原值 + delete(sec.StringData, k) + if sec.Data == nil { + sec.Data = map[string][]byte{} } + if _, set := sec.Data[k]; !set { + sec.Data[k] = old + } + changed = true } + if err := checkNoSecretPlaceholder(sec); err != nil { + return "", err + } if !changed { return y, nil } - out, err := yaml.Marshal(obj) + out, err := yaml.Marshal(sec) if err != nil { return "", fmt.Errorf("生成YAML失败: %w", err) } diff --git a/internal/k8s/utils/secret_redact_case_test.go b/internal/k8s/utils/secret_redact_case_test.go new file mode 100644 index 00000000..bf87fb09 --- /dev/null +++ b/internal/k8s/utils/secret_redact_case_test.go @@ -0,0 +1,370 @@ +package utils + +import ( + "encoding/base64" + "strings" + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// SEC-26 P2:字段名大小写变体、折行 base64、多文档、重复键、错误回显。 +// 本文件只调用公开函数,可在修复前的代码上编译运行(先红后绿)。 + +const p2Head = "apiVersion: v1\nkind: Secret\nmetadata:\n name: app\n namespace: ops\n" + +// "" 的 base64 为 PHJlZGFjdGVkPg== +const p2PlaceholderB64 = "PHJlZGFjdGVkPg==" + +func p2Existing() *corev1.Secret { + return &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "app", Namespace: "ops"}, + Data: map[string][]byte{"k": []byte(redactFixtureData)}, + } +} + +// 模拟 service 层 UpdateSecretByYaml:先恢复占位符,再解析。 +func p2Update(y string, existing *corev1.Secret) (*corev1.Secret, error) { + restored, err := RestoreRedactedSecretYAML(y, existing) + if err != nil { + return nil, err + } + return YAMLToSecret(restored) +} + +// 模拟 service 层 CreateSecretByYaml:先检查占位符,再解析。 +func p2Create(y string) (*corev1.Secret, error) { + has, err := SecretYAMLHasRedactedPlaceholder(y) + if err != nil { + return nil, err + } + if has { + return nil, errPlaceholderForTest + } + return YAMLToSecret(y) +} + +type p2Err string + +func (e p2Err) Error() string { return string(e) } + +const errPlaceholderForTest = p2Err("YAML 中包含脱敏占位符") + +// 最终要写入集群的对象里不得出现占位符(含空白变体)。 +func p2AssertNoPlaceholder(t *testing.T, sec *corev1.Secret) { + t.Helper() + for k, v := range sec.Data { + if strings.TrimSpace(string(v)) == SecretRedactedPlaceholder { + t.Fatalf("data[%s] 将以占位符写入集群", k) + } + } + for k, v := range sec.StringData { + if strings.TrimSpace(v) == SecretRedactedPlaceholder { + t.Fatalf("stringData[%s] 将以占位符写入集群", k) + } + } +} + +// 错误信息不得包含任何测试假值(明文或 base64)。只报告是否泄露,不打印内容。 +func p2AssertNoEcho(t *testing.T, err error) { + t.Helper() + if err == nil { + return + } + msg := err.Error() + for i, v := range []string{redactFixtureData, redactFixtureString, redactFixtureNew} { + if strings.Contains(msg, v) || strings.Contains(msg, base64.StdEncoding.EncodeToString([]byte(v))) { + t.Fatalf("错误信息回显了第 %d 个测试假值", i) + } + } +} + +// 应被拒绝:更新和新建两条路径都必须报错,且不回显值。 +func p2MustReject(t *testing.T, y string) { + t.Helper() + sec, err := p2Update(y, p2Existing()) + if err == nil { + p2AssertNoPlaceholder(t, sec) + t.Fatalf("更新路径应拒绝,但被接受") + } + p2AssertNoEcho(t, err) + sec, err = p2Create(y) + if err == nil { + p2AssertNoPlaceholder(t, sec) + t.Fatalf("新建路径应拒绝,但被接受") + } + p2AssertNoEcho(t, err) +} + +// 1) stringData 字段名大小写变体(e01/e02/e03/e10):更新应恢复原值,新建应拒绝。 +func TestP2StringDataKeyVariants(t *testing.T) { + for _, field := range []string{"stringData", "StringData", "stringdata", "STRINGDATA", "ſtringData"} { + t.Run(field, func(t *testing.T) { + y := p2Head + field + ":\n k: " + SecretRedactedPlaceholder + "\n" + sec, err := p2Update(y, p2Existing()) + if err != nil { + t.Fatalf("更新路径失败: %v", err) + } + p2AssertNoPlaceholder(t, sec) + if string(sec.Data["k"]) != redactFixtureData { + t.Fatal("更新路径未从现有 Secret 恢复 k") + } + if _, ok := sec.StringData["k"]; ok { + t.Fatal("占位符键应从 stringData 移除") + } + if _, err := p2Create(y); err == nil { + t.Fatal("新建路径应拒绝占位符") + } else { + p2AssertNoEcho(t, err) + } + }) + } +} + +// 2) data 字段名大小写变体 + 裸占位符(e04):更新应恢复原值,新建应拒绝。 +func TestP2DataKeyVariantsBarePlaceholder(t *testing.T) { + for _, field := range []string{"data", "Data", "DATA", "dAtA"} { + t.Run(field, func(t *testing.T) { + y := p2Head + field + ":\n k: " + SecretRedactedPlaceholder + "\n s: " + SecretRedactedPlaceholder + "\n" + existing := p2Existing() + existing.StringData = map[string]string{"s": redactFixtureString} + sec, err := p2Update(y, existing) + if err != nil { + t.Fatalf("更新路径失败: %v", err) + } + p2AssertNoPlaceholder(t, sec) + if string(sec.Data["k"]) != redactFixtureData { + t.Fatal("更新路径未从现有 Secret 的 data 恢复 k") + } + if string(sec.Data["s"]) != redactFixtureString { + t.Fatal("更新路径未从现有 Secret 的 stringData 恢复 s") + } + if _, err := p2Create(y); err == nil { + t.Fatal("新建路径应拒绝占位符") + } else { + p2AssertNoEcho(t, err) + } + }) + } +} + +// 3) data 下 base64 编码的占位符:折行(e05/e08)、转义(d01-d03)、单行。两条路径都应拒绝。 +func TestP2DataEncodedPlaceholderRejected(t *testing.T) { + cases := map[string]string{ + "Data_folded": "Data:\n k: |\n PHJlZGFj\n dGVkPg==\n", + "data_folded": "data:\n k: |\n PHJlZGFj\n dGVkPg==\n", + "Data_folded_trailing": "Data:\n k: |\n PHJlZGFj\n dGVkPg==\n---\n", + "DATA_escaped": "DATA:\n k: \"\\x50HJlZGFjdGVkPg==\"\n", + "Data_plain_b64": "Data:\n k: " + p2PlaceholderB64 + "\n", + "data_plain_b64": "data:\n k: " + p2PlaceholderB64 + "\n", + "data_b64_with_space": "data:\n k: " + base64.StdEncoding.EncodeToString([]byte(SecretRedactedPlaceholder+" ")) + "\n", + } + for name, body := range cases { + t.Run(name, func(t *testing.T) { p2MustReject(t, p2Head+body) }) + } +} + +// 4) stringData 带空白的占位符变体(d09/d10):两条路径都应拒绝。 +func TestP2StringDataWhitespacePlaceholderRejected(t *testing.T) { + cases := map[string]string{ + "trailing_space": "stringData:\n k: \"" + SecretRedactedPlaceholder + " \"\n", + "trailing_newline": "stringData:\n k: \"" + SecretRedactedPlaceholder + "\\n\"\n", + "StringData_space": "StringData:\n k: \" " + SecretRedactedPlaceholder + "\"\n", + } + for name, body := range cases { + t.Run(name, func(t *testing.T) { p2MustReject(t, p2Head+body) }) + } +} + +// 5) 多文档 YAML(p02):两条路径都应拒绝。 +func TestP2MultiDocumentRejected(t *testing.T) { + second := "---\n" + p2Head + "stringData:\n k: " + SecretRedactedPlaceholder + "\n" + cases := map[string]string{ + "two_docs": p2Head + "stringData:\n k: " + redactFixtureNew + "\n" + second, + "second_has_token": p2Head + "type: Opaque\n" + second, + "trailing_sep": p2Head + "type: Opaque\n---\n", + "doc_end_then_sep": p2Head + "type: Opaque\n...\n---\n" + p2Head, + "second_broken": p2Head + "type: Opaque\n---\n: : [\n", + } + for name, y := range cases { + t.Run(name, func(t *testing.T) { p2MustReject(t, y) }) + } +} + +// 6) 重复键(p01),含只差大小写:两条路径都应拒绝。 +func TestP2DuplicateKeysRejected(t *testing.T) { + cases := map[string]string{ + "exact_stringData": p2Head + "stringData:\n k: " + redactFixtureNew + "\nstringData:\n k: " + SecretRedactedPlaceholder + "\n", + "exact_data": p2Head + "data:\n k: " + p2PlaceholderB64 + "\ndata:\n j: " + p2PlaceholderB64 + "\n", + "case_stringData": p2Head + "stringData:\n k: " + redactFixtureNew + "\nStringData:\n k: " + SecretRedactedPlaceholder + "\n", + "case_data": p2Head + "data:\n k: " + SecretRedactedPlaceholder + "\nData:\n j: " + p2PlaceholderB64 + "\n", + "case_data_upper": p2Head + "data:\n k: " + SecretRedactedPlaceholder + "\nDATA:\n j: " + SecretRedactedPlaceholder + "\n", + "inner_key_exact": p2Head + "stringData:\n k: " + redactFixtureNew + "\n k: " + SecretRedactedPlaceholder + "\n", + "case_kind": p2Head + "Kind: Secret\nstringData:\n k: " + redactFixtureNew + "\n", + "case_metadata": p2Head + "Metadata:\n name: other\nstringData:\n k: " + redactFixtureNew + "\n", + "case_metadata_name": "apiVersion: v1\nkind: Secret\nmetadata:\n name: app\n Name: other\n namespace: ops\nstringData:\n k: " + redactFixtureNew + "\n", + } + for name, y := range cases { + t.Run(name, func(t *testing.T) { p2MustReject(t, y) }) + } +} + +// 7) 解析错误不得回显值:非字符串键、类型标签错误、非法 base64。 +func TestP2ErrorsDoNotEchoValues(t *testing.T) { + newB64 := base64.StdEncoding.EncodeToString([]byte(redactFixtureNew)) + cases := map[string]string{ + "null_key_data": p2Head + "data:\n ~: " + newB64 + "\n", + "null_key_stringData": p2Head + "stringData:\n ~: " + redactFixtureNew + "\n", + "int_key_data": p2Head + "data:\n ? [a]\n : " + newB64 + "\n", + "bad_tag": p2Head + "stringData:\n k: !!int " + redactFixtureNew + "\n", + "bad_base64": p2Head + "data:\n k: " + redactFixtureNew + "\n", + } + for name, y := range cases { + t.Run(name, func(t *testing.T) { + _, err := p2Update(y, p2Existing()) + if err == nil { + t.Fatal("更新路径应报错") + } + p2AssertNoEcho(t, err) + _, err = p2Create(y) + if err == nil { + t.Fatal("新建路径应报错") + } + p2AssertNoEcho(t, err) + }) + } +} + +// 8) 正常输入保持兼容(对照组):规范键、带 --- 前导、数值转字符串、合法折行 base64。 +func TestP2CanonicalInputsStillAccepted(t *testing.T) { + newB64 := base64.StdEncoding.EncodeToString([]byte(redactFixtureNew)) + folded := newB64[:8] + "\n " + newB64[8:] + t.Run("leading_separator", func(t *testing.T) { + y := "---\n" + p2Head + "stringData:\n k: " + SecretRedactedPlaceholder + "\n" + sec, err := p2Update(y, p2Existing()) + if err != nil { + t.Fatal(err) + } + if string(sec.Data["k"]) != redactFixtureData { + t.Fatal("未恢复") + } + }) + t.Run("doc_end_marker", func(t *testing.T) { + y := p2Head + "stringData:\n k: " + redactFixtureNew + "\n...\n" + if _, err := p2Create(y); err != nil { + t.Fatal(err) + } + }) + t.Run("number_coercion", func(t *testing.T) { + y := p2Head + " labels:\n port: 8080\nstringData:\n port: 8080\n flag: true\n" + sec, err := p2Create(y) + if err != nil { + t.Fatal(err) + } + if sec.StringData["port"] != "8080" || sec.Labels["port"] != "8080" || sec.StringData["flag"] != "true" { + t.Fatal("数值/布尔未按原逻辑转为字符串") + } + }) + t.Run("folded_real_base64", func(t *testing.T) { + y := p2Head + "data:\n k: |\n " + folded + "\n j: " + SecretRedactedPlaceholder + "\n" + existing := p2Existing() + existing.Data["j"] = []byte(redactFixtureString) + sec, err := p2Update(y, existing) + if err != nil { + t.Fatal(err) + } + if string(sec.Data["k"]) != redactFixtureNew || string(sec.Data["j"]) != redactFixtureString { + t.Fatal("合法折行 base64 或占位符恢复结果不对") + } + }) + t.Run("no_placeholder_unchanged", func(t *testing.T) { + y := p2Head + "data:\n k: " + newB64 + "\n" + out, err := RestoreRedactedSecretYAML(y, p2Existing()) + if err != nil { + t.Fatal(err) + } + if out != y { + t.Fatal("无占位符时应原样返回") + } + }) + t.Run("missing_key_error_names_key_only", func(t *testing.T) { + y := p2Head + "Data:\n absent: " + SecretRedactedPlaceholder + "\n" + _, err := p2Update(y, p2Existing()) + if err == nil || !strings.Contains(err.Error(), "absent") { + t.Fatalf("应报出缺失的键名, got err=%v", err != nil) + } + p2AssertNoEcho(t, err) + }) +} + +// 9) YAML 合并键把占位符带进 StringData:更新恢复,新建拒绝。 +func TestP2MergeKeyStringData(t *testing.T) { + y := p2Head + "x: &a\n k: " + SecretRedactedPlaceholder + "\nStringData:\n <<: *a\n" + sec, err := p2Update(y, p2Existing()) + if err != nil { + t.Fatalf("更新路径失败: %v", err) + } + p2AssertNoPlaceholder(t, sec) + if _, err := p2Create(y); err == nil { + t.Fatal("新建路径应拒绝占位符") + } +} + +// 10) 每一层单独都要拦住(纵深防御):Has 判定、Restore 残留检查、YAMLToSecret 兜底各自独立生效。 +func TestP2EachLayerRejectsIndependently(t *testing.T) { + inputs := map[string]string{ + "Data_folded": p2Head + "Data:\n k: |\n PHJlZGFj\n dGVkPg==\n", + "stringData_space": p2Head + "stringData:\n k: \"" + SecretRedactedPlaceholder + " \"\n", + "STRINGDATA_nl": p2Head + "STRINGDATA:\n k: \"" + SecretRedactedPlaceholder + "\\n\"\n", + } + for name, y := range inputs { + t.Run(name, func(t *testing.T) { + if has, err := SecretYAMLHasRedactedPlaceholder(y); err == nil && !has { + t.Fatal("SecretYAMLHasRedactedPlaceholder 未识别") + } + if _, err := RestoreRedactedSecretYAML(y, p2Existing()); err == nil { + t.Fatal("RestoreRedactedSecretYAML 未拒绝残留占位符") + } + if _, err := YAMLToSecret(y); err == nil { + t.Fatal("YAMLToSecret 未拒绝占位符") + } + }) + } + // data 裸占位符:Has 必须为 true,YAMLToSecret 必须拒绝(Restore 会恢复,不在此列) + for _, field := range []string{"data", "Data"} { + y := p2Head + field + ":\n k: " + SecretRedactedPlaceholder + "\n" + if has, err := SecretYAMLHasRedactedPlaceholder(y); err != nil || !has { + t.Fatalf("%s: SecretYAMLHasRedactedPlaceholder 应返回 true", field) + } + if _, err := YAMLToSecret(y); err == nil { + t.Fatalf("%s: YAMLToSecret 应拒绝", field) + } + } + // 结构问题:YAMLToSecret 自身也要拒绝(不依赖前置检查) + structural := map[string]string{ + "multi_doc": p2Head + "type: Opaque\n---\n" + p2Head, + "dup_exact": p2Head + "type: Opaque\ntype: Opaque\n", + "dup_case": p2Head + "stringData:\n a: b\nStringData:\n c: d\n", + "dup_meta": p2Head + " Name: other\n", + "dup_unicod": p2Head + "stringData:\n a: b\nſtringData:\n c: d\n", + } + for name, y := range structural { + if _, err := YAMLToSecret(y); err == nil { + t.Fatalf("%s: YAMLToSecret 应拒绝", name) + } + if _, err := SecretYAMLHasRedactedPlaceholder(y); err == nil { + t.Fatalf("%s: SecretYAMLHasRedactedPlaceholder 应报错", name) + } + } +} + +// 11) 只有注释/空文档时保持原行为:Restore 原样返回,不当作多文档。 +func TestP2EmptyDocumentCompat(t *testing.T) { + for _, y := range []string{"# only comment\n", "---\n"} { + out, err := RestoreRedactedSecretYAML(y, p2Existing()) + if err != nil || out != y { + t.Fatalf("空文档应原样返回: %q", y) + } + } +} diff --git a/internal/k8s/utils/secret_redact_test.go b/internal/k8s/utils/secret_redact_test.go index 9492439c..f5e8d1c8 100644 --- a/internal/k8s/utils/secret_redact_test.go +++ b/internal/k8s/utils/secret_redact_test.go @@ -2,6 +2,7 @@ package utils import ( "encoding/base64" + "reflect" "strings" "testing" @@ -119,3 +120,83 @@ func TestRedactAndRestoreSameKeyInDataAndStringData(t *testing.T) { t.Fatalf("string_data_keys 不符: %+v", k) } } + +// secretYAMLDocument 必须与 corev1.Secret 的字段一一对应(名称、JSON 标签、类型;仅 data 的值类型不同), +// 否则 k8s 升级新增字段时会被静默丢弃。 +func TestSecretYAMLDocumentMatchesCoreV1Secret(t *testing.T) { + want := reflect.TypeOf(corev1.Secret{}) + got := reflect.TypeOf(secretYAMLDocument{}) + if want.NumField() != got.NumField() { + t.Fatalf("字段数不一致: corev1.Secret=%d secretYAMLDocument=%d", want.NumField(), got.NumField()) + } + for i := 0; i < want.NumField(); i++ { + w, g := want.Field(i), got.Field(i) + if w.Name != g.Name || w.Anonymous != g.Anonymous || w.Tag.Get("json") != g.Tag.Get("json") { + t.Fatalf("第 %d 个字段不一致: %s %q vs %s %q", i, w.Name, w.Tag.Get("json"), g.Name, g.Tag.Get("json")) + } + if w.Name == "Data" { + continue + } + if w.Type != g.Type { + t.Fatalf("字段 %s 类型不一致: %s vs %s", w.Name, w.Type, g.Type) + } + } +} + +// 对合法的单文档 YAML,类型化解析结果必须与直接 yaml.Unmarshal 到 corev1.Secret 完全一致。 +func TestYAMLToSecretMatchesDirectUnmarshal(t *testing.T) { + newB64 := base64.StdEncoding.EncodeToString([]byte(redactFixtureNew)) + inputs := []string{ + "apiVersion: v1\nkind: Secret\nmetadata:\n name: app\n namespace: ops\n labels:\n port: 8080\n on: yes\n annotations:\n a: \"1\"\ntype: Opaque\nimmutable: true\ndata:\n k: " + newB64 + "\n n: null\n e: \"\"\nstringData:\n s: " + redactFixtureString + "\n i: 12\n", + "apiVersion: v1\nkind: Secret\nmetadata:\n name: app\nData:\n k: |\n " + newB64[:8] + "\n " + newB64[8:] + "\nStringData:\n s: x\n", + "---\nkind: Secret\nmetadata: {name: app, creationTimestamp: null}\ndata: {}\n", + "kind: Secret\n", + } + for i, y := range inputs { + var direct corev1.Secret + if err := yaml.Unmarshal([]byte(y), &direct); err != nil { + t.Fatalf("输入 %d 直接解析失败", i) + } + typed, err := YAMLToSecret(y) + if err != nil { + t.Fatalf("输入 %d 类型化解析失败: %v", i, err) + } + if !reflect.DeepEqual(&direct, typed) { + t.Fatalf("输入 %d 解析结果不一致", i) + } + } +} + +// 恢复后重新生成的 YAML 必须能被 YAMLToSecret 解析,且不含占位符。 +func TestRestoreRedactedSecretYAMLOutputReparses(t *testing.T) { + existing := &corev1.Secret{Data: map[string][]byte{"k": []byte(redactFixtureData)}} + in := "apiVersion: v1\nkind: Secret\nmetadata:\n name: app\nDATA:\n k: " + SecretRedactedPlaceholder + "\n" + out, err := RestoreRedactedSecretYAML(in, existing) + if err != nil { + t.Fatal(err) + } + if strings.Contains(out, SecretRedactedPlaceholder) { + t.Fatal("恢复结果仍含占位符") + } + sec, err := YAMLToSecret(out) + if err != nil { + t.Fatal(err) + } + if string(sec.Data["k"]) != redactFixtureData || sec.Name != "app" { + t.Fatal("恢复结果不对") + } +} + +func TestSanitizeSecretYAMLError(t *testing.T) { + err := sanitizeSecretYAMLError(errString("line 3: cannot decode !!str `" + redactFixtureNew + "` as a !!int; unsupported map key of type: , key: , value: \"" + redactFixtureNew + "\"")) + if strings.Contains(err.Error(), redactFixtureNew) { + t.Fatal("未抹掉回显的值") + } + if !strings.Contains(err.Error(), "line 3") { + t.Fatal("应保留位置信息") + } +} + +type errString string + +func (e errString) Error() string { return string(e) } From 4e0e9bb316472549d7616e2d0b68639ddc388dc7 Mon Sep 17 00:00:00 2001 From: Bamboo <13664854532@163.com> Date: Sat, 24 Oct 2026 07:29:49 +0800 Subject: [PATCH 12/14] =?UTF-8?q?fix(k8s/secret):=20SEC-26=20P2=20Review?= =?UTF-8?q?=20=E8=B7=9F=E8=BF=9B=EF=BC=9A=E6=8A=A5=E9=94=99=E7=99=BD?= =?UTF-8?q?=E5=90=8D=E5=8D=95=E3=80=81=E9=9B=B6=E5=AE=BD=E5=AD=97=E7=AC=A6?= =?UTF-8?q?=E5=8D=A0=E4=BD=8D=E7=AC=A6=E3=80=81=E5=AD=97=E7=AC=A6=E4=B8=B2?= =?UTF-8?q?=E5=8C=96=E9=87=8D=E5=A4=8D=E9=94=AE=E3=80=811=20MiB=20?= =?UTF-8?q?=E4=B8=8A=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P2-1:YAML 库报错按白名单改写,只保留错误类别与行号,invalid map key 等不再回显值 - P3-1:判断占位符前去掉首尾空白、NUL 与 U+200B/U+200C/U+200D/U+FEFF - P3-2:同一映射中转成字符串后相同的键(1 与 "1")按重复键拒绝,结果确定 - P3-3:注释与文档改为 encoding/json 不区分大小写的规则,补充已知差异与升级风险 - P3-4:补非标准大小写 Metadata 下的重复键测试 - P3-6:Secret YAML 入口 1 MiB 上限,解析前检查 --- docs/release/k8s-secret-redaction.md | 13 +- internal/k8s/utils/secret_redact.go | 135 ++++++++- internal/k8s/utils/secret_redact_r2_test.go | 305 ++++++++++++++++++++ 3 files changed, 434 insertions(+), 19 deletions(-) create mode 100644 internal/k8s/utils/secret_redact_r2_test.go diff --git a/docs/release/k8s-secret-redaction.md b/docs/release/k8s-secret-redaction.md index ed580a7b..3d3e76fb 100644 --- a/docs/release/k8s-secret-redaction.md +++ b/docs/release/k8s-secret-redaction.md @@ -136,12 +136,17 @@ - `PUT .../update/yaml`:值仍为 `` 的键保持集群中的原值,用户只需修改需要变更的键。占位符对应的键在集群中不存在时拒绝更新。 - `POST .../create/yaml`:YAML 中含 `` 时拒绝创建。 - **SEC-26 P2:YAML 解析与校验收紧**(`update/yaml` 与 `create/yaml` 都适用): - - 占位符的识别和恢复改为在类型化的 Secret 对象上进行,字段名按 Kubernetes 解码规则不区分大小写:`Data`、`DATA`、`StringData`、`stringdata`、`STRINGDATA` 等写法与 `data`/`stringData` 一样处理(更新时恢复原值,创建时拒绝)。改造前这些写法会绕过检查,把 `` 原样写入集群。 + - 占位符的识别和恢复改为在类型化的 Secret 对象上进行,字段名按 encoding/json 不区分大小写的规则匹配(apiserver/kubectl 是区分大小写的):`Data`、`DATA`、`StringData`、`stringdata`、`STRINGDATA` 等写法与 `data`/`stringData` 一样处理(更新时恢复原值,创建时拒绝)。改造前这些写法会绕过检查,把 `` 原样写入集群。 - 只接受**单个 YAML 文档**。含 `---` 分隔的第二个文档(包括末尾多出一个只有 `---` 的空文档)一律拒绝。开头的 `---` 和末尾的 `...` 仍可使用。 - - 拒绝**重复键**:任意层级完全相同的重复键,以及顶层字段、`metadata` 下只差大小写的字段(如同时写 `stringData` 和 `StringData`,或 `name` 和 `Name`)。使用合并键 `<<` 后又显式覆盖其中某个键的写法也会因为重复键被拒绝。 - - 下面这些写法会被视为仍含占位符:`data` 中 base64 解码后(含折行写法,比如 `|` 块里分两行)为 `` 或其首尾带空白的变体;`stringData` 中首尾带空白的 ``(如 `" "`)。更新时报错要求填写真实值,创建时拒绝。 - - 报错只包含位置、字段名和键名,不回显 Secret 的值。 + - 拒绝**重复键**:任意层级完全相同的重复键,以及顶层字段、`metadata` 下只差大小写的字段(如同时写 `stringData` 和 `StringData`,或 `name` 和 `Name`)。使用合并键 `<<` 后又显式覆盖其中某个键的写法也会因为重复键被拒绝。同一映射中转成字符串后相同的键(如 `1` 与 `"1"`、`true` 与 `"true"`、`yes` 与 `"true"`、`1.5` 与 `"1.5"`)也按重复键拒绝,改造前以哪个为准取决于遍历顺序、结果不确定。 + - 下面这些写法会被视为仍含占位符:`data` 中 base64 解码后(含折行写法,比如 `|` 块里分两行)为 `` 或其首尾带空白、NUL(`\0`)、零宽字符(U+200B、U+200C、U+200D、U+FEFF)的变体;`stringData` 中首尾带上述字符的 ``(如 `" "`、`"\u200b"`)。这些字符只在首尾时去掉,夹在中间的不算占位符。更新时报错要求填写真实值,创建时拒绝。 + - YAML 库给出的解析错误按白名单改写,只保留错误类别和行号(如 `解析YAML失败: line 3: 映射键必须是字符串等标量`),不再带出任何键或值;本模块自己的校验报错(只差大小写的字段、非法 base64、仍为占位符)只包含字段名和键名。不回显 Secret 的值。 + - 入口大小上限 **1 MiB(1048576 字节)**:`update/yaml`、`create/yaml` 提交的 YAML 超过上限时在任何解析之前拒绝,报错为通用的 `YAML 超过 1 MiB 上限`。上限按整个 YAML 文本计算,`data` 中的值是 base64(约膨胀 4/3),因此通过 YAML 能提交的 Secret 实际数据量小于 Kubernetes 自身的 1 MiB 上限。 - 更新时如果发生了占位符恢复,提交给集群的 YAML 由解析后的对象重新生成,字段顺序和格式会规范化(未识别的顶层字段会被丢弃,与改造前写入集群的结果一致)。 + - **已知行为差异**:因为字段名按 encoding/json 不区分大小写匹配,单独写的 `StringData:`(没有标准的 `stringData:`)也会被当作 `stringData` 处理;而 apiserver/kubectl 区分大小写,会把它当作未知字段。按 TL 决定保留这一兼容行为,前提是任何情况下都不会把占位符写回集群:更新时其中的 `` 恢复为原值,创建时拒绝。 + - **升级风险**(TL 已接受): + - 使用合并键 `<<` 后再显式覆盖其中某个键的 YAML,改造前能提交,现在按重复键拒绝,需要去掉覆盖或展开合并键。 + - 末尾多出一个只有 `---` 的空文档(常见于模板拼接或 `kubectl get -o yaml` 结果拼接)的 YAML,改造前能提交,现在按多文档拒绝,需要删掉末尾的 `---`。 ## 5. 前端待办 diff --git a/internal/k8s/utils/secret_redact.go b/internal/k8s/utils/secret_redact.go index 91442651..90630689 100644 --- a/internal/k8s/utils/secret_redact.go +++ b/internal/k8s/utils/secret_redact.go @@ -33,6 +33,7 @@ import ( "io" "regexp" "sort" + "strconv" "strings" "unicode" "unicode/utf8" @@ -147,23 +148,111 @@ type secretYAMLDocument struct { Type corev1.SecretType `json:"type,omitempty"` } +// maxSecretYAMLBytes 是 Secret YAML 入口允许的最大字节数(1 MiB),在任何解析之前检查 +const maxSecretYAMLBytes = 1 << 20 + var ( - errSecretYAMLMultiDoc = errors.New("解析YAML失败: 只支持单个 YAML 文档,请去掉 --- 分隔的其他文档") - // yaml 库的部分报错会用反引号或 %#v 带出标量值,统一抹掉 - secretYAMLQuotedValue = regexp.MustCompile("`[^`]*`") + errSecretYAMLMultiDoc = errors.New("解析YAML失败: 只支持单个 YAML 文档,请去掉 --- 分隔的其他文档") + errSecretYAMLTooLarge = errors.New("解析YAML失败: YAML 超过 1 MiB 上限") + errSecretYAMLDupKeyStr = errors.New("解析YAML失败: 存在按字符串比较相同的重复键(例如 1 与 \"1\"),请只保留一个") + // 只从库报错里取行号,其余内容一律不保留 + secretYAMLErrorLine = regexp.MustCompile(`line ([0-9]+)`) ) -// sanitizeSecretYAMLError 去掉解析错误中可能回显的值,只保留位置与原因 +// secretYAMLErrorClasses 是库报错到固定文案的白名单映射,按顺序匹配第一项。 +// 报错中只保留类别与行号,不保留任何来自输入的键或值。 +var secretYAMLErrorClasses = []struct { + marker string + text string +}{ + {"invalid map key", "映射键必须是字符串等标量"}, + {"unsupported map key", "映射键必须是字符串等标量"}, + {"already set in map", "存在重复键"}, + {"unknown anchor", "引用了未定义的锚点"}, + {"excessive aliasing", "别名展开过多"}, + {"exceeded max depth", "嵌套层级过深"}, + {"map merge requires map", "合并键 << 只能引用映射"}, + {"cannot decode", "值的类型与标签不匹配"}, + {"cannot unmarshal", "字段类型不匹配"}, + {"parsing time", "时间字段格式不正确"}, + {"illegal base64", "base64 格式不正确"}, + {"yaml:", "YAML 语法错误"}, +} + +// sanitizeSecretYAMLError 按白名单重写解析错误:只保留错误类别与行号,不回显任何键或值 func sanitizeSecretYAMLError(err error) error { msg := err.Error() - if i := strings.Index(msg, "unsupported map key"); i >= 0 { - msg = msg[:i] + "unsupported map key(键必须是字符串)" + text := "YAML 格式或字段类型错误" + for _, c := range secretYAMLErrorClasses { + if strings.Contains(msg, c.marker) { + text = c.text + break + } } - msg = secretYAMLQuotedValue.ReplaceAllString(msg, "`...`") - return fmt.Errorf("解析YAML失败: %s", msg) + if m := secretYAMLErrorLine.FindStringSubmatch(msg); m != nil { + return fmt.Errorf("解析YAML失败: line %s: %s", m[1], text) + } + return fmt.Errorf("解析YAML失败: %s", text) } -// checkSingleYAMLDocument 拒绝多文档 YAML(包括末尾多出的空文档 ---) +// secretYAMLKeyString 与 sigs.k8s.io/yaml 把 YAML 键转成 JSON 键的规则一致;不支持的键类型返回 false +func secretYAMLKeyString(k interface{}) (string, bool) { + switch v := k.(type) { + case string: + return v, true + case int: + return strconv.Itoa(v), true + case int64: + return strconv.FormatInt(v, 10), true + case float64: + s := strconv.FormatFloat(v, 'g', -1, 32) + switch s { + case "+Inf": + s = ".inf" + case "-Inf": + s = "-.inf" + case "NaN": + s = ".nan" + } + return s, true + case bool: + if v { + return "true", true + } + return "false", true + } + return "", false +} + +// checkStringifiedDuplicateKeys 拒绝同一映射中转成字符串后相同的键(如 1 与 "1"), +// 否则 encoding/json 合并时以哪个为准取决于 map 遍历顺序,结果不确定。 +func checkStringifiedDuplicateKeys(node interface{}) error { + switch v := node.(type) { + case map[interface{}]interface{}: + seen := make(map[string]struct{}, len(v)) + for k, child := range v { + if s, ok := secretYAMLKeyString(k); ok { + if _, dup := seen[s]; dup { + return errSecretYAMLDupKeyStr + } + seen[s] = struct{}{} + } + if err := checkStringifiedDuplicateKeys(child); err != nil { + return err + } + } + case []interface{}: + for _, child := range v { + if err := checkStringifiedDuplicateKeys(child); err != nil { + return err + } + } + } + return nil +} + +// checkSingleYAMLDocument 拒绝多文档 YAML(包括末尾多出的空文档 ---), +// 并拒绝转成字符串后相同的重复键 func checkSingleYAMLDocument(raw []byte) error { dec := yamlv2.NewDecoder(bytes.NewReader(raw)) var first interface{} @@ -173,6 +262,9 @@ func checkSingleYAMLDocument(raw []byte) error { } return sanitizeSecretYAMLError(err) } + if err := checkStringifiedDuplicateKeys(first); err != nil { + return err + } var next interface{} if err := dec.Decode(&next); !errors.Is(err, io.EOF) { return errSecretYAMLMultiDoc @@ -252,6 +344,9 @@ func checkSecretFieldCaseDuplicates(j []byte) error { // 返回值中 data 里值恰为占位符的键不放入 sec.Data,而是按升序单独返回,供调用方恢复或拒绝。 // 所有错误都不包含 Secret 的值。 func parseSecretYAMLTyped(y string) (*corev1.Secret, []string, error) { + if len(y) > maxSecretYAMLBytes { + return nil, nil, errSecretYAMLTooLarge + } raw := []byte(y) if err := checkSingleYAMLDocument(raw); err != nil { return nil, nil, err @@ -295,12 +390,21 @@ func parseSecretYAMLTyped(y string) (*corev1.Secret, []string, error) { return sec, placeholders, nil } -// isSecretPlaceholderValue 判断值去掉首尾空白后是否为占位符 +// isSecretPlaceholderPadding 是判断占位符前要去掉的首尾字符:空白、NUL 与零宽字符 +func isSecretPlaceholderPadding(r rune) bool { + switch r { + case 0, '\u200b', '\u200c', '\u200d', '\ufeff': + return true + } + return unicode.IsSpace(r) +} + +// isSecretPlaceholderValue 判断值去掉首尾空白、NUL、零宽字符(U+200B/U+200C/U+200D/U+FEFF)后是否为占位符 func isSecretPlaceholderValue(v string) bool { - return strings.TrimSpace(v) == SecretRedactedPlaceholder + return strings.TrimFunc(v, isSecretPlaceholderPadding) == SecretRedactedPlaceholder } -// checkNoSecretPlaceholder 确认 data(解码后)与 stringData 中没有占位符或其带空白的变体 +// checkNoSecretPlaceholder 确认 data(解码后)与 stringData 中没有占位符或其首尾带空白/NUL/零宽字符的变体 func checkNoSecretPlaceholder(sec *corev1.Secret) error { dataKeys := make([]string, 0, len(sec.Data)) for k := range sec.Data { @@ -319,15 +423,16 @@ func checkNoSecretPlaceholder(sec *corev1.Secret) error { sort.Strings(stringKeys) for _, k := range stringKeys { if isSecretPlaceholderValue(sec.StringData[k]) { - return fmt.Errorf("stringData 中键 %s 仍为脱敏占位符 %s(或带空白的变体),请填写真实值", k, SecretRedactedPlaceholder) + return fmt.Errorf("stringData 中键 %s 仍为脱敏占位符 %s(或首尾带空白、NUL、零宽字符的变体),请填写真实值", k, SecretRedactedPlaceholder) } } return nil } // SecretYAMLHasRedactedPlaceholder 判断 YAML 的 data/stringData 中是否仍含脱敏占位符。 -// 先解析为类型化的 Secret(字段名大小写变体按 Kubernetes 解码规则归并), -// data 中的裸占位符、解码后为占位符的值、stringData 中带空白的占位符都算。 +// 先解析为类型化的 Secret(字段名大小写变体按 encoding/json 不区分大小写的规则归并; +// apiserver/kubectl 是区分大小写的,见 docs/release/k8s-secret-redaction.md), +// data 中的裸占位符、解码后为占位符的值、stringData 中首尾带空白/NUL/零宽字符的占位符都算。 func SecretYAMLHasRedactedPlaceholder(y string) (bool, error) { sec, placeholders, err := parseSecretYAMLTyped(y) if err != nil { diff --git a/internal/k8s/utils/secret_redact_r2_test.go b/internal/k8s/utils/secret_redact_r2_test.go new file mode 100644 index 00000000..659505cd --- /dev/null +++ b/internal/k8s/utils/secret_redact_r2_test.go @@ -0,0 +1,305 @@ +package utils + +import ( + "encoding/base64" + "errors" + "strings" + "testing" +) + +// SEC-26 P2 第一层 Review 跟进(P2-1、P3-1、P3-2、P3-4、P3-6)。 +// 只调用 f763601 上已存在的函数,可在修复前的代码上编译运行(先红后绿)。 + +// 哨兵前缀:放在值的位置,报错里出现它即视为回显。 +const r2Sentinel = "ZQXSNTL" + +func r2AssertNoSentinel(t *testing.T, err error) { + t.Helper() + if err != nil && strings.Contains(err.Error(), r2Sentinel) { + t.Fatal("错误信息回显了输入中的值") + } +} + +// 两条路径都必须报错,且报错不含哨兵。 +func r2MustErrorNoEcho(t *testing.T, y string) { + t.Helper() + _, err := p2Update(y, p2Existing()) + if err == nil { + t.Fatal("更新路径应报错") + } + r2AssertNoSentinel(t, err) + _, err = p2Create(y) + if err == nil { + t.Fatal("新建路径应报错") + } + r2AssertNoSentinel(t, err) +} + +// P2-1:invalid map key(复杂键)报错不得回显值。 +func TestR2InvalidMapKeyDoesNotEcho(t *testing.T) { + cases := map[string]string{ + "flow_double_brace_stringData": "stringData: {{password: " + r2Sentinel + "01}}\n", + "flow_double_brace_data": "data: {{password: " + r2Sentinel + "02}}\n", + "seq_key_stringData": "stringData:\n ? [" + r2Sentinel + "03]\n : x\n", + "map_key_stringData": "stringData:\n ? {a: " + r2Sentinel + "04}\n : x\n", + "seq_key_value_side": "stringData:\n ? [a]\n : " + r2Sentinel + "05\n", + "top_level_brace": "{{password: " + r2Sentinel + "06}}\n", + } + for name, body := range cases { + t.Run(name, func(t *testing.T) { r2MustErrorNoEcho(t, p2Head+body) }) + } +} + +// P2-1:直接构造 yaml/json 库可能给出的报错形态,确认过滤器不放行值。 +func TestR2SanitizeKnownLibraryMessages(t *testing.T) { + msgs := []string{ + `yaml: invalid map key: map[interface {}]interface {}{"password":"` + r2Sentinel + `"}`, + `yaml: line 7: invalid map key: []interface {}{"` + r2Sentinel + `"}`, + `unsupported map key of type: , key: , value: "` + r2Sentinel + `"`, + "yaml: line 3: cannot decode !!str `" + r2Sentinel + "` as a !!int", + `parsing time "` + r2Sentinel + `" as "2006-01-02T15:04:05Z07:00": cannot parse`, + "yaml: unknown anchor '" + r2Sentinel + "' referenced", + `error converting YAML to JSON: yaml: line 4: key "` + r2Sentinel + `" already set in map`, + `json: cannot unmarshal string into Go struct field secretYAMLDocument.metadata.name of type ` + r2Sentinel, + "yaml: line 2: found character that cannot start any token " + r2Sentinel, + } + for i, m := range msgs { + err := sanitizeSecretYAMLError(errors.New(m)) + if strings.Contains(err.Error(), r2Sentinel) { + t.Fatalf("第 %d 条报错未过滤", i) + } + } + // 行号要保留 + if err := sanitizeSecretYAMLError(errors.New(`yaml: line 7: invalid map key: []interface {}{"x"}`)); !strings.Contains(err.Error(), "7") { + t.Fatal("应保留行号") + } +} + +// P2-1:表驱动覆盖各类 yaml/json 报错,值的位置放哨兵。 +func TestR2YAMLErrorClassesDoNotEcho(t *testing.T) { + s := r2Sentinel + cases := map[string]string{ + "unclosed_dquote": p2Head + "stringData:\n k: \"" + s + "a\n", + "unclosed_squote": p2Head + "stringData:\n k: '" + s + "b\n", + "unclosed_flow": p2Head + "stringData: {k: " + s + "c\n", + "bad_escape": p2Head + "stringData:\n k: \"\\q" + s + "d\"\n", + "tab_indent": p2Head + "stringData:\n\tk: " + s + "e\n", + "mapping_in_value": p2Head + "stringData:\n k: " + s + "f: g\n", + "tag_int": p2Head + "stringData:\n k: !!int " + s + "g\n", + "tag_float": p2Head + "stringData:\n k: !!float " + s + "h\n", + "tag_bool": p2Head + "stringData:\n k: !!bool " + s + "i\n", + "tag_binary": p2Head + "stringData:\n k: !!binary " + s + "j!!\n", + "tag_timestamp": p2Head + "stringData:\n k: !!timestamp " + s + "k\n", + "unknown_anchor": p2Head + "stringData:\n k: *" + s + "l\n", + "creation_timestamp": "apiVersion: v1\nkind: Secret\nmetadata:\n name: app\n creationTimestamp: " + s + "m\n", + "data_is_scalar": p2Head + "data: " + s + "n\n", + "stringData_is_list": p2Head + "stringData: [" + s + "o]\n", + "stringData_val_map": p2Head + "stringData:\n k: {a: " + s + "p}\n", + "immutable_string": p2Head + "immutable: " + s + "q\n", + "name_is_list": "apiVersion: v1\nkind: Secret\nmetadata:\n name: [" + s + "r]\n", + "labels_val_list": p2Head + " labels:\n a: [" + s + "s]\n", + "top_level_list": "- " + s + "t\n", + "top_level_scalar": s + "u\n", + "bad_base64": p2Head + "data:\n k: " + s + "v!\n", + "null_key": p2Head + "stringData:\n ~: " + s + "w\n", + "dup_key_same_value": p2Head + "stringData:\n k: " + s + "x\n k: " + s + "x\n", + "second_doc_broken": p2Head + "stringData:\n k: a\n---\nk: \"" + s + "y\n", + "control_char": p2Head + "stringData:\n k: " + s + "z\x01\n", + "merge_non_map": p2Head + "stringData:\n <<: " + s + "A\n", + "directive_bad": "%YAML 9.9 " + s + "B\n" + p2Head, + "anchor_then_override": p2Head + "x: &a {k: " + s + "C}\nstringData:\n <<: *a\n k: " + s + "D\n", + } + for name, y := range cases { + t.Run(name, func(t *testing.T) { r2MustErrorNoEcho(t, y) }) + } +} + +// P2-1:模糊测试,把任意片段放到多个值位置,报错不得出现哨兵。 +func FuzzSecretYAMLErrorsDoNotEcho(f *testing.F) { + for _, seed := range []string{"x", "}}", "]", "\"", "'", "\t", ": y", "\n - z", "!!int", "*a", "{", "\\", "\x00", "{{a: b}}"} { + f.Add(seed) + } + templates := []string{ + p2Head + "stringData:\n k: %s\n", + p2Head + "stringData: {{password: %s}}\n", + p2Head + "stringData:\n ? [%s]\n : x\n", + p2Head + "data:\n k: %s\n", + p2Head + "stringData:\n k: \"%s\n", + p2Head + "stringData:\n k: !!int %s\n", + "apiVersion: v1\nkind: Secret\nmetadata:\n name: app\n creationTimestamp: %s\n", + } + f.Fuzz(func(t *testing.T, frag string) { + for _, tpl := range templates { + y := strings.Replace(tpl, "%s", r2Sentinel+frag, 1) + if _, err := p2Update(y, p2Existing()); err != nil { + r2AssertNoSentinel(t, err) + } + if _, err := p2Create(y); err != nil { + r2AssertNoSentinel(t, err) + } + } + }) +} + +// P3-1:首尾的 NUL 与零宽字符(ZWSP/ZWNJ/ZWJ/BOM)也要去掉后再判断占位符。 +func TestR2PlaceholderWithInvisibleChars(t *testing.T) { + variants := map[string]string{ + "nul_suffix": SecretRedactedPlaceholder + "\x00", + "nul_prefix": "\x00" + SecretRedactedPlaceholder, + "zwsp": "\u200b" + SecretRedactedPlaceholder, + "zwnj": SecretRedactedPlaceholder + "\u200c", + "zwj": "\u200d" + SecretRedactedPlaceholder + "\u200d", + "bom": "\ufeff" + SecretRedactedPlaceholder, + "mixed": "\ufeff \u200b" + SecretRedactedPlaceholder + "\x00\n\u200c", + "nul_nul_ws": "\x00\x00 " + SecretRedactedPlaceholder + " \x00", + "zwsp_suffix": SecretRedactedPlaceholder + "\u200b", + } + for name, v := range variants { + t.Run("data_"+name, func(t *testing.T) { + p2MustReject(t, p2Head+"data:\n k: "+base64.StdEncoding.EncodeToString([]byte(v))+"\n") + }) + t.Run("stringData_"+name, func(t *testing.T) { + p2MustReject(t, p2Head+"stringData:\n k: \""+r2YAMLEscape(v)+"\"\n") + }) + } + // 对照:零宽字符在中间时不是占位符,按普通值接受 + t.Run("interior_not_placeholder", func(t *testing.T) { + y := p2Head + "stringData:\n k: \"\"\n" + if _, err := p2Create(y); err != nil { + t.Fatal(err) + } + }) +} + +// 把字符串转成 YAML 双引号字面量(不可见字符用转义写出) +func r2YAMLEscape(v string) string { + var b strings.Builder + for _, r := range v { + switch { + case r == 0: + b.WriteString(`\0`) + case r == '\n': + b.WriteString(`\n`) + case r == '"' || r == '\\': + b.WriteRune('\\') + b.WriteRune(r) + case r > 0x7e: + b.WriteString(`\u` + hex4(r)) + default: + b.WriteRune(r) + } + } + return b.String() +} + +func hex4(r rune) string { + const digits = "0123456789abcdef" + return string([]byte{digits[(r>>12)&0xf], digits[(r>>8)&0xf], digits[(r>>4)&0xf], digits[r&0xf]}) +} + +// P3-2:字符串化后相同的键(1 与 "1"、true 与 "true"、1.5 与 "1.5")按重复键拒绝,多次运行结果稳定。 +func TestR2StringifiedDuplicateKeysRejectedStably(t *testing.T) { + newB64 := base64.StdEncoding.EncodeToString([]byte(redactFixtureNew)) + cases := map[string]string{ + "stringData_int": p2Head + "stringData:\n 1: \"" + SecretRedactedPlaceholder + "\"\n \"1\": " + redactFixtureNew + "\n", + "data_int": p2Head + "data:\n 1: \"" + SecretRedactedPlaceholder + "\"\n \"1\": " + newB64 + "\n", + "stringData_bool": p2Head + "stringData:\n true: \"" + SecretRedactedPlaceholder + "\"\n \"true\": " + redactFixtureNew + "\n", + "stringData_yes": p2Head + "stringData:\n yes: \"" + SecretRedactedPlaceholder + "\"\n \"true\": " + redactFixtureNew + "\n", + "stringData_float": p2Head + "stringData:\n 1.5: \"" + SecretRedactedPlaceholder + "\"\n \"1.5\": " + redactFixtureNew + "\n", + "labels_int": p2Head + " labels:\n 1: a\n \"1\": b\n", + "nested_in_list": p2Head + "extra:\n - 1: a\n \"1\": b\n", + } + for name, y := range cases { + t.Run(name, func(t *testing.T) { + for i := 0; i < 50; i++ { + if _, err := RestoreRedactedSecretYAML(y, p2Existing()); err == nil { + t.Fatalf("第 %d 次:RestoreRedactedSecretYAML 应拒绝", i) + } + if _, err := SecretYAMLHasRedactedPlaceholder(y); err == nil { + t.Fatalf("第 %d 次:SecretYAMLHasRedactedPlaceholder 应报错", i) + } + if _, err := YAMLToSecret(y); err == nil { + t.Fatalf("第 %d 次:YAMLToSecret 应拒绝", i) + } + } + }) + } + // 对照:类型不同但字符串化后不同的键照常接受 + t.Run("distinct_ok", func(t *testing.T) { + y := p2Head + "stringData:\n 1: a\n \"2\": b\n" + sec, err := YAMLToSecret(y) + if err != nil || sec.StringData["1"] != "a" || sec.StringData["2"] != "b" { + t.Fatal("不同键应照常接受") + } + }) +} + +// P3-4:metadata 字段名本身为非标准大小写时,其下只差大小写的键也要拒绝。 +func TestR2NonCanonicalMetadataFieldDuplicates(t *testing.T) { + for _, field := range []string{"Metadata", "METADATA", "metaData"} { + t.Run(field, func(t *testing.T) { + y := "apiVersion: v1\nkind: Secret\n" + field + ":\n name: app\n Name: other\n namespace: ops\nstringData:\n k: " + redactFixtureNew + "\n" + p2MustReject(t, y) + if _, err := YAMLToSecret(y); err == nil { + t.Fatal("YAMLToSecret 应拒绝") + } + }) + } + // 对照:只有非标准写法、没有重复时照常接受(按 encoding/json 不区分大小写的规则归并) + t.Run("Metadata_alone_ok", func(t *testing.T) { + sec, err := YAMLToSecret("apiVersion: v1\nkind: Secret\nMetadata:\n name: app\n") + if err != nil || sec.Name != "app" { + t.Fatal("单独的 Metadata 应照常解析") + } + }) +} + +// P3-6:入口大小上限 1 MiB(1048576 字节),超限在任何解析前报通用错误。 +func TestR2SizeLimit(t *testing.T) { + const limit = 1048576 + build := func(total int) string { + head := p2Head + "stringData:\n k: " + pad := total - len(head) - 1 + return head + strings.Repeat("a", pad) + "\n" + } + at := build(limit) + over := build(limit + 1) + if len(at) != limit || len(over) != limit+1 { + t.Fatal("构造长度不对") + } + t.Run("exactly_limit_accepted", func(t *testing.T) { + if _, err := p2Create(at); err != nil { + t.Fatalf("新建路径: %v", err) + } + if _, err := p2Update(at, p2Existing()); err != nil { + t.Fatalf("更新路径: %v", err) + } + }) + t.Run("limit_plus_one_rejected", func(t *testing.T) { + if _, err := p2Create(over); err == nil { + t.Fatal("新建路径应拒绝") + } + if _, err := p2Update(over, p2Existing()); err == nil { + t.Fatal("更新路径应拒绝") + } + if _, err := YAMLToSecret(over); err == nil { + t.Fatal("YAMLToSecret 应拒绝") + } + }) + t.Run("checked_before_parse", func(t *testing.T) { + // 超限且语法错误、含哨兵:应报大小错误(与语法错误文案不同),不回显 + broken := "stringData: {{k: " + r2Sentinel + strings.Repeat("b", limit) + "\n" + _, errSize := SecretYAMLHasRedactedPlaceholder(broken) + _, errSyntax := SecretYAMLHasRedactedPlaceholder("stringData: {{k: " + r2Sentinel + "\n") + if errSize == nil || errSyntax == nil { + t.Fatal("两者都应报错") + } + r2AssertNoSentinel(t, errSize) + if errSize.Error() == errSyntax.Error() { + t.Fatal("超限输入应在解析前以大小错误拒绝") + } + if !strings.Contains(errSize.Error(), "1 MiB") { + t.Fatal("应提示 1 MiB 上限") + } + }) +} From 1b75ca99d792fd48df9043a9bd01d6751c08621a Mon Sep 17 00:00:00 2001 From: Bamboo <13664854532@163.com> Date: Sat, 24 Oct 2026 07:30:26 +0800 Subject: [PATCH 13/14] =?UTF-8?q?chore(go.mod):=20SEC-26=20P3-7=20go.yaml.?= =?UTF-8?q?in/yaml/v2=20=E6=94=B9=E4=B8=BA=E7=9B=B4=E6=8E=A5=E4=BE=9D?= =?UTF-8?q?=E8=B5=96=EF=BC=88=E7=89=88=E6=9C=AC=E4=B8=8D=E5=8F=98=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 626d9faa..7062da3d 100644 --- a/go.mod +++ b/go.mod @@ -34,6 +34,7 @@ require ( github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cvm v1.3.183 github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/region v1.3.194 go.uber.org/zap v1.27.1 + go.yaml.in/yaml/v2 v2.4.4 golang.org/x/crypto v0.57.0 golang.org/x/sync v0.23.0 golang.org/x/sys v0.48.0 @@ -224,7 +225,6 @@ require ( go.opentelemetry.io/proto/otlp v1.11.0 // indirect go.uber.org/atomic v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect - go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/arch v0.16.0 // indirect golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect From 12494681df44ef8c25faa5d0dc1880476300133d Mon Sep 17 00:00:00 2001 From: Bamboo <13664854532@163.com> Date: Sat, 24 Oct 2026 07:38:43 +0800 Subject: [PATCH 14/14] =?UTF-8?q?fix(k8s/secret):=20SEC-26=20P3-6=20Secret?= =?UTF-8?q?=20YAML=20=E5=85=A5=E5=8F=A3=E4=B8=8A=E9=99=90=E6=94=BE?= =?UTF-8?q?=E5=AE=BD=E5=88=B0=202=20MiB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - maxSecretYAMLBytes 由 1 << 20 改为 2 << 20,仍按 YAML 文本字节数计算,在任何解析之前检查;报错文案改为「YAML 超过 2 MiB 上限」,不回显内容。 - data 的值是 base64(膨胀约 4/3),1 MiB 的入口上限会挡住接近 Kubernetes 1 MiB 上限的 Secret;放宽后这类 Secret 也能提交,真正的大小限制交给 apiserver。 - TestR2SizeLimit:2097152 字节通过,2097153 字节在三条路径上均以固定的大小错误拒绝且不回显;恰好上限的语法错误输入进入解析、多 1 字节则在解析前拒绝。 - 发布说明同步更新上限与原因,升级风险中补充超过 2 MiB 的 YAML 会在入口被拒绝。 --- docs/release/k8s-secret-redaction.md | 3 +- internal/k8s/utils/secret_redact.go | 8 ++-- internal/k8s/utils/secret_redact_r2_test.go | 49 +++++++++++++-------- 3 files changed, 37 insertions(+), 23 deletions(-) diff --git a/docs/release/k8s-secret-redaction.md b/docs/release/k8s-secret-redaction.md index 3d3e76fb..57ef64c3 100644 --- a/docs/release/k8s-secret-redaction.md +++ b/docs/release/k8s-secret-redaction.md @@ -141,12 +141,13 @@ - 拒绝**重复键**:任意层级完全相同的重复键,以及顶层字段、`metadata` 下只差大小写的字段(如同时写 `stringData` 和 `StringData`,或 `name` 和 `Name`)。使用合并键 `<<` 后又显式覆盖其中某个键的写法也会因为重复键被拒绝。同一映射中转成字符串后相同的键(如 `1` 与 `"1"`、`true` 与 `"true"`、`yes` 与 `"true"`、`1.5` 与 `"1.5"`)也按重复键拒绝,改造前以哪个为准取决于遍历顺序、结果不确定。 - 下面这些写法会被视为仍含占位符:`data` 中 base64 解码后(含折行写法,比如 `|` 块里分两行)为 `` 或其首尾带空白、NUL(`\0`)、零宽字符(U+200B、U+200C、U+200D、U+FEFF)的变体;`stringData` 中首尾带上述字符的 ``(如 `" "`、`"\u200b"`)。这些字符只在首尾时去掉,夹在中间的不算占位符。更新时报错要求填写真实值,创建时拒绝。 - YAML 库给出的解析错误按白名单改写,只保留错误类别和行号(如 `解析YAML失败: line 3: 映射键必须是字符串等标量`),不再带出任何键或值;本模块自己的校验报错(只差大小写的字段、非法 base64、仍为占位符)只包含字段名和键名。不回显 Secret 的值。 - - 入口大小上限 **1 MiB(1048576 字节)**:`update/yaml`、`create/yaml` 提交的 YAML 超过上限时在任何解析之前拒绝,报错为通用的 `YAML 超过 1 MiB 上限`。上限按整个 YAML 文本计算,`data` 中的值是 base64(约膨胀 4/3),因此通过 YAML 能提交的 Secret 实际数据量小于 Kubernetes 自身的 1 MiB 上限。 + - 入口大小上限 **2 MiB(2097152 字节)**:`update/yaml`、`create/yaml` 提交的 YAML 超过上限时在任何解析之前拒绝,报错为通用的 `YAML 超过 2 MiB 上限`,不回显内容。上限按整个 YAML 文本的字节数计算。取 2 MiB 而不是 1 MiB,是因为 `data` 中的值是 base64(膨胀约 4/3),1 MiB 的入口上限会挡住数据量接近 Kubernetes 自身 1 MiB 上限的 Secret;放宽到 2 MiB 后这类 Secret 也能提交,Secret 真正的大小限制交给 apiserver 判定,入口上限只用于防止超大输入进入解析。 - 更新时如果发生了占位符恢复,提交给集群的 YAML 由解析后的对象重新生成,字段顺序和格式会规范化(未识别的顶层字段会被丢弃,与改造前写入集群的结果一致)。 - **已知行为差异**:因为字段名按 encoding/json 不区分大小写匹配,单独写的 `StringData:`(没有标准的 `stringData:`)也会被当作 `stringData` 处理;而 apiserver/kubectl 区分大小写,会把它当作未知字段。按 TL 决定保留这一兼容行为,前提是任何情况下都不会把占位符写回集群:更新时其中的 `` 恢复为原值,创建时拒绝。 - **升级风险**(TL 已接受): - 使用合并键 `<<` 后再显式覆盖其中某个键的 YAML,改造前能提交,现在按重复键拒绝,需要去掉覆盖或展开合并键。 - 末尾多出一个只有 `---` 的空文档(常见于模板拼接或 `kubectl get -o yaml` 结果拼接)的 YAML,改造前能提交,现在按多文档拒绝,需要删掉末尾的 `---`。 + - 超过 2 MiB(按 YAML 文本字节数计)的 YAML,改造前直接交给 apiserver 判定,现在在入口按大小拒绝。由于 Secret 本身受 Kubernetes 1 MiB 上限约束,正常的 Secret 不会触及这一上限;只有带大量注释、空白等非数据内容的 YAML 可能受影响,需要精简后再提交。 ## 5. 前端待办 diff --git a/internal/k8s/utils/secret_redact.go b/internal/k8s/utils/secret_redact.go index 90630689..7e2fdc46 100644 --- a/internal/k8s/utils/secret_redact.go +++ b/internal/k8s/utils/secret_redact.go @@ -148,12 +148,14 @@ type secretYAMLDocument struct { Type corev1.SecretType `json:"type,omitempty"` } -// maxSecretYAMLBytes 是 Secret YAML 入口允许的最大字节数(1 MiB),在任何解析之前检查 -const maxSecretYAMLBytes = 1 << 20 +// maxSecretYAMLBytes 是 Secret YAML 入口允许的最大字节数(2 MiB),按 YAML 文本字节数计,在任何解析之前检查。 +// data 的值是 base64(膨胀约 4/3),取 2 MiB 是为了让接近 Kubernetes 1 MiB 上限的 Secret 也能提交, +// Secret 本身的大小限制交给 apiserver 判定;这里只防止超大输入进入解析。 +const maxSecretYAMLBytes = 2 << 20 var ( errSecretYAMLMultiDoc = errors.New("解析YAML失败: 只支持单个 YAML 文档,请去掉 --- 分隔的其他文档") - errSecretYAMLTooLarge = errors.New("解析YAML失败: YAML 超过 1 MiB 上限") + errSecretYAMLTooLarge = errors.New("解析YAML失败: YAML 超过 2 MiB 上限") errSecretYAMLDupKeyStr = errors.New("解析YAML失败: 存在按字符串比较相同的重复键(例如 1 与 \"1\"),请只保留一个") // 只从库报错里取行号,其余内容一律不保留 secretYAMLErrorLine = regexp.MustCompile(`line ([0-9]+)`) diff --git a/internal/k8s/utils/secret_redact_r2_test.go b/internal/k8s/utils/secret_redact_r2_test.go index 659505cd..eb3fa685 100644 --- a/internal/k8s/utils/secret_redact_r2_test.go +++ b/internal/k8s/utils/secret_redact_r2_test.go @@ -254,11 +254,11 @@ func TestR2NonCanonicalMetadataFieldDuplicates(t *testing.T) { }) } -// P3-6:入口大小上限 1 MiB(1048576 字节),超限在任何解析前报通用错误。 +// P3-6:入口大小上限 2 MiB(2097152 字节,按 YAML 文本字节数计),超限在任何解析前报通用错误。 func TestR2SizeLimit(t *testing.T) { - const limit = 1048576 + const limit = 2097152 build := func(total int) string { - head := p2Head + "stringData:\n k: " + head := p2Head + "stringData:\n k: " + r2Sentinel pad := total - len(head) - 1 return head + strings.Repeat("a", pad) + "\n" } @@ -274,32 +274,43 @@ func TestR2SizeLimit(t *testing.T) { if _, err := p2Update(at, p2Existing()); err != nil { t.Fatalf("更新路径: %v", err) } + if _, err := YAMLToSecret(at); err != nil { + t.Fatalf("YAMLToSecret: %v", err) + } }) t.Run("limit_plus_one_rejected", func(t *testing.T) { - if _, err := p2Create(over); err == nil { - t.Fatal("新建路径应拒绝") - } - if _, err := p2Update(over, p2Existing()); err == nil { - t.Fatal("更新路径应拒绝") - } - if _, err := YAMLToSecret(over); err == nil { - t.Fatal("YAMLToSecret 应拒绝") + _, errCreate := p2Create(over) + _, errUpdate := p2Update(over, p2Existing()) + _, errParse := YAMLToSecret(over) + for name, err := range map[string]error{"新建路径": errCreate, "更新路径": errUpdate, "YAMLToSecret": errParse} { + if !errors.Is(err, errSecretYAMLTooLarge) { + t.Fatalf("%s 应以大小错误拒绝,实际: %v", name, err) + } + r2AssertNoSentinel(t, err) } }) t.Run("checked_before_parse", func(t *testing.T) { - // 超限且语法错误、含哨兵:应报大小错误(与语法错误文案不同),不回显 - broken := "stringData: {{k: " + r2Sentinel + strings.Repeat("b", limit) + "\n" - _, errSize := SecretYAMLHasRedactedPlaceholder(broken) - _, errSyntax := SecretYAMLHasRedactedPlaceholder("stringData: {{k: " + r2Sentinel + "\n") + // 语法错误且含哨兵:恰好 limit 字节时报语法错误(进入了解析), + // 多 1 字节时报大小错误(未进入解析),两者文案不同且都不回显 + brokenOf := func(total int) string { + head := "stringData: {{k: " + r2Sentinel + return head + strings.Repeat("b", total-len(head)) + } + _, errSyntax := SecretYAMLHasRedactedPlaceholder(brokenOf(limit)) + _, errSize := SecretYAMLHasRedactedPlaceholder(brokenOf(limit + 1)) if errSize == nil || errSyntax == nil { t.Fatal("两者都应报错") } r2AssertNoSentinel(t, errSize) - if errSize.Error() == errSyntax.Error() { - t.Fatal("超限输入应在解析前以大小错误拒绝") + r2AssertNoSentinel(t, errSyntax) + if errors.Is(errSyntax, errSecretYAMLTooLarge) { + t.Fatal("恰好 limit 字节的输入不应以大小错误拒绝") + } + if errSize.Error() != errSecretYAMLTooLarge.Error() { + t.Fatalf("超限输入应在解析前以固定的大小错误拒绝,实际: %v", errSize) } - if !strings.Contains(errSize.Error(), "1 MiB") { - t.Fatal("应提示 1 MiB 上限") + if !strings.Contains(errSize.Error(), "2 MiB") { + t.Fatal("应提示 2 MiB 上限") } }) }