From 36d01d7a787df5feb7ce98621b5439280082438d Mon Sep 17 00:00:00 2001 From: Ivan Zvyagintsev Date: Mon, 21 Sep 2026 22:38:55 +0300 Subject: [PATCH 01/58] rbac: add the rbac.yaml declaration format and the role-model contract constants rbacyaml loads and validates modules//rbac.yaml (rbac.deckhouse.io/v1alpha1): strict keys, one entry per resource with the levels of both role models or a documented denial, scope from the module's CRDs or declared for external resources, verbs listed explicitly, localized texts required for capabilities outside the view/edit convention, and a reason wherever the declaration widens access beyond what the resource alone implies (a whole group, a namespaced resource at a system level). Normalization gives the generator and the sync rule a canonical order. rbaccontract holds the lineages, per-lineage levels, legacy access levels, label keys and the conventional localized texts, each with its source in the deckhouse repository. Signed-off-by: Ivan Zvyagintsev --- .../rbac/rules/rbaccontract/contract.go | 196 ++++++++ pkg/linters/rbac/rules/rbacyaml/load.go | 102 ++++ pkg/linters/rbac/rules/rbacyaml/load_test.go | 458 ++++++++++++++++++ pkg/linters/rbac/rules/rbacyaml/types.go | 195 ++++++++ pkg/linters/rbac/rules/rbacyaml/validate.go | 342 +++++++++++++ 5 files changed, 1293 insertions(+) create mode 100644 pkg/linters/rbac/rules/rbaccontract/contract.go create mode 100644 pkg/linters/rbac/rules/rbacyaml/load.go create mode 100644 pkg/linters/rbac/rules/rbacyaml/load_test.go create mode 100644 pkg/linters/rbac/rules/rbacyaml/types.go create mode 100644 pkg/linters/rbac/rules/rbacyaml/validate.go diff --git a/pkg/linters/rbac/rules/rbaccontract/contract.go b/pkg/linters/rbac/rules/rbaccontract/contract.go new file mode 100644 index 00000000..03c57a40 --- /dev/null +++ b/pkg/linters/rbac/rules/rbaccontract/contract.go @@ -0,0 +1,196 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package rbaccontract holds the constants of the Deckhouse RBACv2 role model that the rbac +// linter rules and the rbac.yaml generator share: lineages, the levels each lineage accepts, +// the legacy access levels of user-authz v1, the label and annotation keys, and the +// conventional localized texts of view/edit capabilities. +// +// Every constant names its source in the deckhouse repository. The contract is described for +// humans in modules/140-user-authz/docs/internal/RBACV2_MODULE_MIGRATION.md and enforced +// in-tree by testing/rbacv2/rbacv2_templates_validation_test.go. +package rbaccontract + +import "slices" + +// Label and annotation keys of the role model +// (modules/140-user-authz/docs/internal/RBACV2_MODULE_MIGRATION.md, "Reference of role labels and annotations"). +const ( + LabelKind = "rbac.deckhouse.io/kind" + LabelScope = "rbac.deckhouse.io/scope" + LabelCapability = "rbac.deckhouse.io/capability" + LabelUseRole = "rbac.deckhouse.io/use-role" + LabelDelegatable = "rbac.deckhouse.io/delegatable" + LabelNamespace = "rbac.deckhouse.io/namespace" + LabelModule = "module" + LabelHeritage = "heritage" + + // AggregationLabelPrefix and AggregationLabelSuffix frame the lineage in + // rbac.deckhouse.io/aggregate-to--as. + AggregationLabelPrefix = "rbac.deckhouse.io/aggregate-to-" + AggregationLabelSuffix = "-as" + + // AccessLevelAnnotation marks a legacy (user-authz v1) ClusterRole with its access level + // (modules/140-user-authz/hooks/... and templates/user-authz-cluster-roles.yaml of every module). + AccessLevelAnnotation = "user-authz.deckhouse.io/access-level" + + KindRole = "role" + KindCapability = "capability" + + // Capability name prefixes per scope (RBACV2_MODULE_MIGRATION.md, "Naming"). + NamespaceCapabilityPrefix = "d8:namespace-capability:" + SystemCapabilityPrefix = "d8:system-capability:" + LegacyRolePrefix = "d8:user-authz:" +) + +// Lineages of the role model. A capability aggregates into the roles of one or more lineages +// through the aggregate-to--as label. +const ( + LineageNamespace = "namespace" + LineageProject = "project" + LineageSystem = "system" +) + +// Subsystems are the lineages of the subsystem roles d8:subsystem:: +// (modules/140-user-authz/templates/rbacv2/global/subsystem/roles//). +var Subsystems = []string{ + "deckhouse", + "infrastructure", + "kubernetes", + "networking", + "observability", + "security", + "storage", +} + +// Levels a capability may aggregate to, per lineage. The namespace lineage carries the full +// ladder; the system and subsystem lineages have no user and admin rungs +// (RBACV2_MODULE_MIGRATION.md, "Access levels"; spec 005 R29). +var ( + NamespaceLevels = []string{"viewer", "user", "manager", "admin", "superadmin"} + SystemLevels = []string{"viewer", "manager", "superadmin"} + // ProjectLevels are the levels of the project lineage; a module capability never aggregates + // there directly (project roles aggregate namespace roles), but the contract check on + // platform roles needs the set. + ProjectLevels = NamespaceLevels +) + +// LegacyLevels is the access-level enum of ClusterAuthorizationRule +// (modules/140-user-authz/crds/clusterauthorizationrule.yaml); AuthorizationRule serves only the +// first four. +var LegacyLevels = []string{"User", "PrivilegedUser", "Editor", "Admin", "ClusterEditor", "ClusterAdmin", "SuperAdmin"} + +// Verbs are the resource verbs Kubernetes RBAC knows. rbac.yaml lists verbs explicitly; there +// are no aliases (spec 005 R2). "*" is accepted here and judged by the wildcards rule. +var Verbs = []string{"get", "list", "watch", "create", "update", "patch", "delete", "deletecollection", "*"} + +// AllLineages returns every lineage a capability label may name: the three base lineages and +// the seven subsystems. +func AllLineages() []string { + out := make([]string, 0, 3+len(Subsystems)) + out = append(out, LineageNamespace, LineageProject, LineageSystem) + out = append(out, Subsystems...) + + return out +} + +// LevelsOf returns the levels the given lineage accepts, or nil for an unknown lineage. +func LevelsOf(lineage string) []string { + switch lineage { + case LineageNamespace: + return NamespaceLevels + case LineageProject: + return ProjectLevels + case LineageSystem: + return SystemLevels + } + + for _, s := range Subsystems { + if s == lineage { + return SystemLevels + } + } + + return nil +} + +// IsSubsystem reports whether the name is one of the seven subsystems. +func IsSubsystem(name string) bool { + return slices.Contains(Subsystems, name) +} + +// CapabilityAction maps a level to the action suffix of the capability it produces: +// viewer -> view, manager -> edit, the rest as they are (ADR "Что генерируется"; the live +// convention is use/admin.yaml with marker namespace-capability.cert-manager.admin). +func CapabilityAction(level string) string { + switch level { + case "viewer": + return "view" + case "manager": + return "edit" + } + + return level +} + +// ConventionalActions are the capability actions whose localized texts come from the platform +// convention and need no capabilities entry in rbac.yaml. +var ConventionalActions = []string{"view", "edit"} + +// IsConventionalAction reports whether the texts of a capability with this action are supplied +// by the platform (view/edit) rather than by the declaration. +func IsConventionalAction(action string) bool { + return action == "view" || action == "edit" +} + +// Text is a localized title/description pair. +type Text struct { + EN string + RU string +} + +// ConventionalTexts are the titles and descriptions of view/edit capabilities of both lineages, +// with %s standing for the module name. Source: the TEXTS table of +// modules/140-user-authz/docs/internal/rbacv2-migrate-module.sh, reproduced in the ADR. +var ConventionalTexts = map[string]struct{ Title, Description Text }{ + LineageNamespace + ".view": { + Title: Text{EN: "Module %s: view", RU: "Модуль %s: просмотр"}, + Description: Text{EN: "Read-only access to %s resources in a namespace.", RU: "Доступ только на чтение к ресурсам модуля %s в пространстве имён."}, + }, + LineageNamespace + ".edit": { + Title: Text{EN: "Module %s: edit", RU: "Модуль %s: редактирование"}, + Description: Text{EN: "Manage %s resources in a namespace.", RU: "Управление ресурсами модуля %s в пространстве имён."}, + }, + LineageSystem + ".view": { + Title: Text{EN: "Module %s: view configuration", RU: "Модуль %s: просмотр конфигурации"}, + Description: Text{EN: "Read-only access to the %s module configuration.", RU: "Доступ только на чтение к конфигурации модуля %s."}, + }, + LineageSystem + ".edit": { + Title: Text{EN: "Module %s: edit configuration", RU: "Модуль %s: управление конфигурацией"}, + Description: Text{EN: "Manage the %s module configuration.", RU: "Управление конфигурацией модуля %s."}, + }, +} + +// Annotation keys of the localized texts. +const ( + AnnotationTitleEN = "en.meta.deckhouse.io/title" + AnnotationTitleRU = "ru.meta.deckhouse.io/title" + AnnotationDescriptionEN = "en.meta.deckhouse.io/description" + AnnotationDescriptionRU = "ru.meta.deckhouse.io/description" +) + +// I18nAnnotations lists the four annotations every RBACv2 role and capability must carry. +var I18nAnnotations = []string{AnnotationTitleEN, AnnotationTitleRU, AnnotationDescriptionEN, AnnotationDescriptionRU} diff --git a/pkg/linters/rbac/rules/rbacyaml/load.go b/pkg/linters/rbac/rules/rbacyaml/load.go new file mode 100644 index 00000000..341e8e16 --- /dev/null +++ b/pkg/linters/rbac/rules/rbacyaml/load.go @@ -0,0 +1,102 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rbacyaml + +import ( + "bytes" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "sort" + + "gopkg.in/yaml.v3" +) + +// ErrNotFound is returned by Load when the module has no rbac.yaml. The coverage and sync rules +// treat it as "nothing to check"; only the contract rule runs on such a module. +var ErrNotFound = errors.New("rbac.yaml not found") + +// Path returns the declaration's path for a module directory. +func Path(modulePath string) string { + return filepath.Join(modulePath, Filename) +} + +// Load reads and parses modules//rbac.yaml. Unknown keys are an error: a misspelled +// key would otherwise silently drop the rights it was meant to grant. Parsing errors are +// returned as one error; the semantic checks are Validate's. +func Load(modulePath string) (*Declaration, error) { + data, err := os.ReadFile(Path(modulePath)) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, ErrNotFound + } + + return nil, fmt.Errorf("read %s: %w", Filename, err) + } + + return Parse(data) +} + +// Parse parses the declaration from its bytes and normalizes it (see Normalize). +func Parse(data []byte) (*Declaration, error) { + decl := new(Declaration) + + decoder := yaml.NewDecoder(bytes.NewReader(data)) + decoder.KnownFields(true) + + if err := decoder.Decode(decl); err != nil { + return nil, fmt.Errorf("parse %s: %w", Filename, err) + } + + // A second document in the file would be silently ignored otherwise. + switch err := decoder.Decode(new(Declaration)); { + case err == nil: + return nil, fmt.Errorf("parse %s: the file must hold a single YAML document", Filename) + case !errors.Is(err, io.EOF): + return nil, fmt.Errorf("parse %s: %w", Filename, err) + } + + decl.Normalize() + + return decl, nil +} + +// Normalize puts the declaration into its canonical order, so that the generator's output and +// the sync comparison do not depend on how the author ordered the file: resources by group and +// resource, verbs sorted, subsystems sorted. Duplicates are left in place for Validate to +// report. +func (d *Declaration) Normalize() { + sort.SliceStable(d.Resources, func(i, j int) bool { + if d.Resources[i].Group != d.Resources[j].Group { + return d.Resources[i].Group < d.Resources[j].Group + } + + return d.Resources[i].Resource < d.Resources[j].Resource + }) + + for i := range d.Resources { + for _, levels := range []map[string][]string{d.Resources[i].Namespace, d.Resources[i].System, d.Resources[i].Legacy} { + for level := range levels { + sort.Strings(levels[level]) + } + } + } + + sort.Strings(d.Subsystems) +} diff --git a/pkg/linters/rbac/rules/rbacyaml/load_test.go b/pkg/linters/rbac/rules/rbacyaml/load_test.go new file mode 100644 index 00000000..82f0d1bf --- /dev/null +++ b/pkg/linters/rbac/rules/rbacyaml/load_test.go @@ -0,0 +1,458 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rbacyaml + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// certManagerCRDs is what the linter sees in modules/101-cert-manager/crds/cert-manager/. +var certManagerCRDs = CRDScopes{ + "cert-manager.io/certificates": ScopeNamespaced, + "cert-manager.io/certificaterequests": ScopeNamespaced, + "cert-manager.io/issuers": ScopeNamespaced, + "cert-manager.io/clusterissuers": ScopeCluster, +} + +// validDeclaration is the ADR's cert-manager example, trimmed to what the loader needs. +const validDeclaration = ` +apiVersion: rbac.deckhouse.io/v1alpha1 +resources: + - group: cert-manager.io + resource: issuers + namespace: + viewer: [watch, get, list] + manager: [create, update, patch, delete, deletecollection] + legacy: + User: [get, list, watch] + Editor: [create, update, patch, delete, deletecollection] + - group: cert-manager.io + resource: certificates + namespace: + viewer: [get, list, watch] + admin: [delete] + legacy: + User: [get, list, watch] + Admin: [delete] + - group: cert-manager.io + resource: clusterissuers + system: + viewer: [get, list, watch] + manager: [create, update, patch, delete, deletecollection] + legacy: + User: [get, list, watch] + ClusterEditor: [create, update, patch, delete, deletecollection] + - group: trivy.deckhouse.io + resource: vulnerabilityreports + scope: Namespaced + namespace: + viewer: [get, list, watch] + - group: constraints.gatekeeper.sh + resource: "*" + scope: Cluster + reason: "one CRD per ConstraintTemplate is created at runtime; the names are not known statically" + system: + viewer: [get, list, watch] + - group: cert-manager.io + resource: certificaterequests + noAccess: "internal resource, managed by the controller" + - group: deckhouse.io + resource: foos/status + scope: Cluster + system: + manager: [get, patch, update] +capabilities: + namespace.admin: + title: {en: "Module cert-manager: admin", ru: "Модуль cert-manager: администрирование"} + description: {en: "Delete certificates in a namespace.", ru: "Удаление сертификатов в пространстве имён."} +serviceAccounts: + - name: cainjector + path: cainjector + when: .Values.certManager.internal.enableCAInjector + labels: {app: cainjector} + clusterRules: + - apiGroups: [cert-manager.io] + resources: [certificates] + verbs: [get, list, watch] + - nonResourceURLs: [/metrics] + verbs: [get] + namespaceRules: + - apiGroups: [coordination.k8s.io] + resources: [leases] + verbs: [get, list, watch, create, update, patch] + bindClusterRoles: [d8:rbac-proxy] + bindRoles: + - namespace: kube-system + name: extension-apiserver-authentication-reader +prometheusAccess: + deployments: [cert-manager] +access: + - name: admin-kubeconfig + subjects: + - kind: Group + name: kubeadm:cluster-admins + clusterRules: + - apiGroups: [cert-manager.io] + resources: [clusterissuers] + verbs: [get, list, watch] + - name: auth + subjects: + - kind: ServiceAccount + name: ingress-nginx + namespace: d8-ingress-nginx + namespaceRules: + - apiGroups: [apps] + resources: [deployments/http] + resourceNames: [documentation] + verbs: [get] +` + +func TestParseAndValidate_ValidDeclaration(t *testing.T) { + decl, err := Parse([]byte(validDeclaration)) + require.NoError(t, err) + + errs := Validate(decl, certManagerCRDs) + assert.Empty(t, errs, "the ADR example must validate cleanly") + + // Normalize: resources by group then resource, verbs sorted. + keys := make([]string, 0, len(decl.Resources)) + for _, r := range decl.Resources { + keys = append(keys, r.Key()) + } + + assert.Equal(t, []string{ + "cert-manager.io/certificaterequests", + "cert-manager.io/certificates", + "cert-manager.io/clusterissuers", + "cert-manager.io/issuers", + "constraints.gatekeeper.sh/*", + "deckhouse.io/foos/status", + "trivy.deckhouse.io/vulnerabilityreports", + }, keys) + assert.Equal(t, []string{"get", "list", "watch"}, decl.Resources[3].Namespace["viewer"], "verbs are sorted") + + // Second parse of the same bytes yields an identical declaration: normalization is a fixed point. + again, err := Parse([]byte(validDeclaration)) + require.NoError(t, err) + assert.Equal(t, decl, again) +} + +func TestParse_RejectsWhatTheFormatDoesNotKnow(t *testing.T) { + for name, tc := range map[string]struct { + yaml string + wantErr string + }{ + "unknown top-level key": { + yaml: "apiVersion: rbac.deckhouse.io/v1alpha1\nfoo: bar\n", + wantErr: "field foo not found", + }, + "unknown key in a resource": { + yaml: "apiVersion: rbac.deckhouse.io/v1alpha1\nresources:\n - group: g\n resource: r\n verbs: [get]\n", + wantErr: "field verbs not found", + }, + "two documents": { + yaml: "apiVersion: rbac.deckhouse.io/v1alpha1\n---\napiVersion: rbac.deckhouse.io/v1alpha1\n", + wantErr: "single YAML document", + }, + "not a mapping": { + yaml: "- a\n- b\n", + wantErr: "parse rbac.yaml", + }, + } { + t.Run(name, func(t *testing.T) { + _, err := Parse([]byte(tc.yaml)) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + }) + } +} + +// entry wraps one resource entry into a declaration, so each table row reads as the entry alone. +func entry(body string) string { + return "apiVersion: rbac.deckhouse.io/v1alpha1\nresources:\n - " + strings.ReplaceAll(strings.TrimSpace(body), "\n", "\n ") + "\n" +} + +func TestValidate_ResourceEntries(t *testing.T) { + for name, tc := range map[string]struct { + yaml string + crds CRDScopes + wantErr string // substring of exactly one error; "" means no errors + }{ + "R2: a verb alias is not a verb": { + yaml: entry(`group: cert-manager.io +resource: issuers +namespace: + viewer: [read]`), + crds: certManagerCRDs, + wantErr: `"read" is not a verb; verbs are listed explicitly`, + }, + "R3: namespace level on a cluster-scoped resource (scope from CRD)": { + yaml: entry(`group: cert-manager.io +resource: clusterissuers +namespace: + viewer: [get]`), + crds: certManagerCRDs, + wantErr: "namespace levels are not allowed for a cluster-scoped resource", + }, + "R3: namespace level on a cluster-scoped resource (scope declared)": { + yaml: entry(`group: external.io +resource: things +scope: Cluster +namespace: + viewer: [get]`), + crds: certManagerCRDs, + wantErr: "namespace levels are not allowed for a cluster-scoped resource", + }, + "R4: noAccess together with levels": { + yaml: entry(`group: cert-manager.io +resource: issuers +noAccess: "internal" +namespace: + viewer: [get]`), + crds: certManagerCRDs, + wantErr: "noAccess excludes namespace, system and legacy", + }, + "R4: neither levels nor noAccess": { + yaml: entry(`group: cert-manager.io +resource: issuers`), + crds: certManagerCRDs, + wantErr: "must grant at least one level", + }, + "R6: external resource without scope": { + yaml: entry(`group: trivy.deckhouse.io +resource: vulnerabilityreports +namespace: + viewer: [get]`), + crds: certManagerCRDs, + wantErr: "ships no CRD for this resource, so scope is required", + }, + "R6: denied external resource needs no scope": { + yaml: entry(`group: trivy.deckhouse.io +resource: vulnerabilityreports +noAccess: "never shown to users"`), + crds: certManagerCRDs, + wantErr: "", + }, + "R6a: declared scope disagrees with the CRD": { + yaml: entry(`group: cert-manager.io +resource: issuers +scope: Cluster +reason: "watched cluster-wide" +system: + viewer: [get]`), + crds: certManagerCRDs, + wantErr: `scope "Cluster" disagrees with the CRD in crds/, which says "Namespaced"`, + }, + "R6c: wildcard without reason": { + yaml: entry(`group: constraints.gatekeeper.sh +resource: "*" +scope: Cluster +system: + viewer: [get]`), + crds: certManagerCRDs, + wantErr: `resource "*" requires reason`, + }, + "D5: wildcard on a group the module ships CRDs for": { + yaml: entry(`group: cert-manager.io +resource: "*" +scope: Namespaced +reason: "lazy" +namespace: + viewer: [get]`), + crds: certManagerCRDs, + wantErr: `resource "*" is allowed only for a group the module ships no CRD for`, + }, + "R7: non-conventional level without texts": { + yaml: entry(`group: cert-manager.io +resource: issuers +namespace: + admin: [delete]`), + crds: certManagerCRDs, + wantErr: `"namespace.admin" is used by a resource entry but has no title and description`, + }, + "R26: namespaced resource at a system level without reason": { + yaml: entry(`group: cert-manager.io +resource: issuers +system: + viewer: [get]`), + crds: certManagerCRDs, + wantErr: "granted across the whole cluster; confirm it with reason", + }, + "R26: namespaced resource at a system level with reason": { + yaml: entry(`group: cert-manager.io +resource: issuers +reason: "the module operator watches issuers in every namespace" +system: + viewer: [get]`), + crds: certManagerCRDs, + wantErr: "", + }, + "R29: admin is not a system level": { + yaml: entry(`group: cert-manager.io +resource: clusterissuers +system: + admin: [get]`), + crds: certManagerCRDs, + wantErr: `system level "admin" is not valid; the system levels are viewer, manager, superadmin`, + }, + "R29: unknown legacy level": { + yaml: entry(`group: cert-manager.io +resource: issuers +legacy: + Viewer: [get]`), + crds: certManagerCRDs, + wantErr: `legacy level "Viewer" is not valid`, + }, + "verbs: empty list": { + yaml: entry(`group: cert-manager.io +resource: issuers +namespace: + viewer: []`), + crds: certManagerCRDs, + wantErr: "namespace.viewer lists no verbs", + }, + "verbs: duplicate": { + yaml: entry(`group: cert-manager.io +resource: issuers +namespace: + viewer: [get, get]`), + crds: certManagerCRDs, + wantErr: `namespace.viewer lists "get" twice`, + }, + "subresource inherits the base scope": { + yaml: entry(`group: cert-manager.io +resource: clusterissuers/status +namespace: + viewer: [get]`), + crds: certManagerCRDs, + wantErr: "namespace levels are not allowed for a cluster-scoped resource", + }, + "bad scope value": { + yaml: entry(`group: external.io +resource: things +scope: cluster +system: + viewer: [get]`), + crds: certManagerCRDs, + wantErr: `scope must be "Namespaced" or "Cluster", got "cluster"`, + }, + } { + t.Run(name, func(t *testing.T) { + decl, err := Parse([]byte(tc.yaml)) + require.NoError(t, err) + + errs := Validate(decl, tc.crds) + if tc.wantErr == "" { + assert.Empty(t, errs) + return + } + + require.Len(t, errs, 1, "exactly one error expected, got: %v", errs) + assert.Contains(t, errs[0].Error(), tc.wantErr) + }) + } +} + +func TestValidate_TopLevel(t *testing.T) { + for name, tc := range map[string]struct { + yaml string + wantErr string + }{ + "R39a: unknown apiVersion": { + yaml: "apiVersion: rbac.deckhouse.io/v2\n", + wantErr: `apiVersion must be "rbac.deckhouse.io/v1alpha1", got "rbac.deckhouse.io/v2"`, + }, + "R39a: missing apiVersion": { + yaml: "resources: []\n", + wantErr: `apiVersion must be "rbac.deckhouse.io/v1alpha1", got ""`, + }, + "subsystems: not a subsystem": { + yaml: "apiVersion: rbac.deckhouse.io/v1alpha1\nsubsystems: [networking, billing]\n", + wantErr: `subsystems: "billing" is not a subsystem of the role model`, + }, + "duplicate resource entry": { + yaml: "apiVersion: rbac.deckhouse.io/v1alpha1\nresources:\n" + + " - {group: g.io, resource: r, scope: Cluster, system: {viewer: [get]}}\n" + + " - {group: g.io, resource: r, scope: Cluster, system: {viewer: [list]}}\n", + wantErr: "duplicate entry for g.io/r", + }, + "capabilities: conventional action needs no texts": { + yaml: "apiVersion: rbac.deckhouse.io/v1alpha1\ncapabilities:\n namespace.view: {title: {en: a, ru: b}, description: {en: c, ru: d}}\n", + wantErr: `"namespace.view" needs no texts`, + }, + "capabilities: missing ru": { + yaml: "apiVersion: rbac.deckhouse.io/v1alpha1\ncapabilities:\n namespace.admin: {title: {en: a}, description: {en: c, ru: d}}\n", + wantErr: "namespace.admin.title requires both en and ru", + }, + "capabilities: bad key": { + yaml: "apiVersion: rbac.deckhouse.io/v1alpha1\ncapabilities:\n project.admin: {title: {en: a, ru: b}, description: {en: c, ru: d}}\n", + wantErr: `key "project.admin" must be "namespace." or "system."`, + }, + "access: both rule kinds": { + yaml: "apiVersion: rbac.deckhouse.io/v1alpha1\naccess:\n - name: x\n subjects: [{kind: Group, name: g}]\n" + + " clusterRules: [{apiGroups: [a], resources: [b], verbs: [get]}]\n namespaceRules: [{apiGroups: [a], resources: [b], verbs: [get]}]\n", + wantErr: "exactly one of clusterRules and namespaceRules", + }, + "access: ServiceAccount subject without namespace": { + yaml: "apiVersion: rbac.deckhouse.io/v1alpha1\naccess:\n - name: x\n subjects: [{kind: ServiceAccount, name: s}]\n" + + " clusterRules: [{apiGroups: [a], resources: [b], verbs: [get]}]\n", + wantErr: "a ServiceAccount subject requires namespace", + }, + "serviceAccounts: duplicate name": { + yaml: "apiVersion: rbac.deckhouse.io/v1alpha1\nserviceAccounts:\n - name: a\n - name: a\n", + wantErr: "serviceAccounts[1] (a): duplicate name", + }, + "serviceAccounts: rule without verbs": { + yaml: "apiVersion: rbac.deckhouse.io/v1alpha1\nserviceAccounts:\n - name: a\n clusterRules: [{apiGroups: [x], resources: [y]}]\n", + wantErr: "serviceAccounts[0] (a).clusterRules[0]: verbs is required", + }, + "prometheusAccess: empty": { + yaml: "apiVersion: rbac.deckhouse.io/v1alpha1\nprometheusAccess: {}\n", + wantErr: "prometheusAccess: names no workload", + }, + } { + t.Run(name, func(t *testing.T) { + decl, err := Parse([]byte(tc.yaml)) + require.NoError(t, err) + + errs := Validate(decl, nil) + require.Len(t, errs, 1, "exactly one error expected, got: %v", errs) + assert.Contains(t, errs[0].Error(), tc.wantErr) + }) + } +} + +func TestLoad(t *testing.T) { + modulePath := t.TempDir() + + _, err := Load(modulePath) + assert.True(t, errors.Is(err, ErrNotFound), "a module without rbac.yaml is not an error of the file, got %v", err) + + require.NoError(t, os.WriteFile(filepath.Join(modulePath, Filename), []byte(validDeclaration), 0o600)) + + decl, err := Load(modulePath) + require.NoError(t, err) + assert.Equal(t, APIVersionV1Alpha1, decl.APIVersion) + assert.Len(t, decl.Resources, 7) +} diff --git a/pkg/linters/rbac/rules/rbacyaml/types.go b/pkg/linters/rbac/rules/rbacyaml/types.go new file mode 100644 index 00000000..bc35f4d4 --- /dev/null +++ b/pkg/linters/rbac/rules/rbacyaml/types.go @@ -0,0 +1,195 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package rbacyaml reads and validates the module RBAC declaration, modules//rbac.yaml: +// the single source the rbac linter's coverage and sync rules compare the rendered RBAC objects +// against, and the generator renders templates from. The format is the one of the ADR +// "Единый rbac.yaml модуля" (platform-security/2026-04-27-module-rbac-yaml.md), version +// rbac.deckhouse.io/v1alpha1. +package rbacyaml + +// Filename is the declaration's name in the module root. +const Filename = "rbac.yaml" + +// APIVersionV1Alpha1 is the only format version this package accepts. The format is frozen as +// rbac.deckhouse.io/v1 after the pilots. +const APIVersionV1Alpha1 = "rbac.deckhouse.io/v1alpha1" + +// NoAccessTODO is the placeholder the coverage autofix writes into a stub entry. It is a valid +// value for the loader, so the rest of the file stays usable, and an error for the coverage +// rule: a decision is still owed. +const NoAccessTODO = "TODO" + +// Resource scopes as the CRD spells them. +const ( + ScopeNamespaced = "Namespaced" + ScopeCluster = "Cluster" +) + +// Declaration is the parsed rbac.yaml. +type Declaration struct { + APIVersion string `yaml:"apiVersion"` + + // Subsystems are the lineages the module's system capabilities aggregate into. Empty means + // "the subsystems of module.yaml"; a module whose templates aggregate into more subsystems + // than module.yaml declares must set it (kube-dns, kube-proxy, istio). + Subsystems []string `yaml:"subsystems,omitempty"` + + Resources []Resource `yaml:"resources,omitempty"` + + // Capabilities holds the localized texts of capabilities outside the platform convention, + // keyed "." (for example "namespace.admin"). view/edit need no entry. + Capabilities map[string]CapabilityText `yaml:"capabilities,omitempty"` + + ServiceAccounts []ServiceAccount `yaml:"serviceAccounts,omitempty"` + + PrometheusAccess *PrometheusAccess `yaml:"prometheusAccess,omitempty"` + + Access []Access `yaml:"access,omitempty"` +} + +// Resource is one user-facing resource of the module and the access every role model grants +// to it. Exactly one of two shapes is valid: NoAccess with a reason, or at least one of +// Namespace/System/Legacy. +type Resource struct { + Group string `yaml:"group"` + Resource string `yaml:"resource"` + + // Scope is required when the module ships no CRD for the resource (the linter cannot see + // it) and when Resource is "*"; when the CRD is in the module tree the scope is read from + // it and a declared Scope must agree. + Scope string `yaml:"scope,omitempty"` + + // Reason is required when Resource is "*" (why the resource names are not known + // statically) and when a Namespaced resource is granted at a system level (why the access + // has to be cluster-wide). It is free text for the reader of the declaration. + Reason string `yaml:"reason,omitempty"` + + // When is a Helm expression; the generated rules are wrapped in {{- if }}. It must + // evaluate under the linter's value stubs. A rule under When that is absent from the render + // is not a divergence. + When string `yaml:"when,omitempty"` + + // NoAccess documents the deliberate decision to grant users nothing on this resource. It + // excludes Namespace, System and Legacy. NoAccessTODO is the undecided stub. + NoAccess string `yaml:"noAccess,omitempty"` + + // Namespace maps RBACv2 namespace-lineage levels to verbs. Allowed for Namespaced + // resources only. + Namespace map[string][]string `yaml:"namespace,omitempty"` + // System maps RBACv2 system-lineage levels to verbs. Allowed for both scopes. + System map[string][]string `yaml:"system,omitempty"` + // Legacy maps user-authz v1 access levels to verbs. It is never derived from the RBACv2 + // levels; an absent Legacy means no legacy rights. + Legacy map[string][]string `yaml:"legacy,omitempty"` +} + +// IsWildcard reports whether the entry grants a whole group ("resource: *"). +func (r Resource) IsWildcard() bool { return r.Resource == "*" } + +// IsSubresource reports whether the entry names a subresource (a "/" in the name). +func (r Resource) IsSubresource() bool { + for i := 0; i < len(r.Resource); i++ { + if r.Resource[i] == '/' { + return true + } + } + + return false +} + +// HasLevels reports whether any role model grants something on the resource. +func (r Resource) HasLevels() bool { + return len(r.Namespace) > 0 || len(r.System) > 0 || len(r.Legacy) > 0 +} + +// Key returns "group/resource", the identity of the entry. +func (r Resource) Key() string { return r.Group + "/" + r.Resource } + +// CapabilityText is the localized title and description of a capability. +type CapabilityText struct { + Title LocalizedText `yaml:"title"` + Description LocalizedText `yaml:"description"` +} + +// LocalizedText is an en/ru pair; both are required. +type LocalizedText struct { + EN string `yaml:"en"` + RU string `yaml:"ru"` +} + +// PolicyRule is a raw RBAC rule as Kubernetes spells it; the generator copies it verbatim. +type PolicyRule struct { + APIGroups []string `yaml:"apiGroups,omitempty"` + Resources []string `yaml:"resources,omitempty"` + ResourceNames []string `yaml:"resourceNames,omitempty"` + NonResourceURLs []string `yaml:"nonResourceURLs,omitempty"` + Verbs []string `yaml:"verbs"` +} + +// ServiceAccount declares one ServiceAccount of the module with its rights. The generator +// produces the ServiceAccount, ClusterRole d8:: with its ClusterRoleBinding +// (from ClusterRules), Role in the module namespace with its RoleBinding (from +// NamespaceRules), a ClusterRoleBinding per BindClusterRoles entry and a RoleBinding in a +// foreign namespace per BindRoles entry, all into templates/[/]rbac-for-us.yaml. +type ServiceAccount struct { + Name string `yaml:"name"` + // Path is the component directory under templates/; empty means the module root file. + Path string `yaml:"path,omitempty"` + When string `yaml:"when,omitempty"` + Labels map[string]string `yaml:"labels,omitempty"` + ClusterRules []PolicyRule `yaml:"clusterRules,omitempty"` + NamespaceRules []PolicyRule `yaml:"namespaceRules,omitempty"` + BindClusterRoles []string `yaml:"bindClusterRoles,omitempty"` + BindRoles []RoleRef `yaml:"bindRoles,omitempty"` +} + +// RoleRef names an existing Role in a foreign namespace to bind a ServiceAccount to. +type RoleRef struct { + Namespace string `yaml:"namespace"` + Name string `yaml:"name"` +} + +// PrometheusAccess declares the workloads whose metrics Prometheus scrapes; the generator +// produces the access-to- Role and RoleBinding in the module namespace. +type PrometheusAccess struct { + Deployments []string `yaml:"deployments,omitempty"` + DaemonSets []string `yaml:"daemonsets,omitempty"` + StatefulSets []string `yaml:"statefulsets,omitempty"` +} + +// Access grants arbitrary subjects rights on the module. ClusterRules produce a ClusterRole and +// ClusterRoleBinding d8:: in templates/rbac-for-us.yaml; NamespaceRules produce a +// Role and RoleBinding access-to-- in templates/rbac-to-us.yaml. Exactly one of the +// two must be set: the placement rule keeps cluster-scoped objects out of rbac-to-us.yaml. +type Access struct { + Name string `yaml:"name"` + Subjects []Subject `yaml:"subjects"` + ClusterRules []PolicyRule `yaml:"clusterRules,omitempty"` + NamespaceRules []PolicyRule `yaml:"namespaceRules,omitempty"` +} + +// Subject is an RBAC subject; Namespace is required for a ServiceAccount. +type Subject struct { + Kind string `yaml:"kind"` + Name string `yaml:"name"` + Namespace string `yaml:"namespace,omitempty"` +} + +// CRDScopes is what the module tree says about its own resources: the scope of every CRD +// under crds/, keyed "group/plural". It is the loader's view of what is resolvable; a resource +// absent from it is external to this lint run. +type CRDScopes map[string]string diff --git a/pkg/linters/rbac/rules/rbacyaml/validate.go b/pkg/linters/rbac/rules/rbacyaml/validate.go new file mode 100644 index 00000000..9ef21508 --- /dev/null +++ b/pkg/linters/rbac/rules/rbacyaml/validate.go @@ -0,0 +1,342 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rbacyaml + +import ( + "fmt" + "slices" + "strings" + + "github.com/deckhouse/dmt/pkg/linters/rbac/rules/rbaccontract" +) + +// Validate checks the declaration against the format rules. crds is what the linted tree says +// about the module's own resources (the scope of every CRD under crds/); an entry whose +// resource is not in it is external, and its scope has to be declared. Every problem is +// returned; none is fixed, because a declaration error is a decision the author has to make. +// +// The messages are stable: the sync rule reports them verbatim and the coverage rule and the +// generator refuse to run on a declaration with any of them. +func Validate(d *Declaration, crds CRDScopes) []error { + var errs []error + + report := func(format string, args ...any) { + errs = append(errs, fmt.Errorf(format, args...)) + } + + if d.APIVersion != APIVersionV1Alpha1 { + report("apiVersion must be %q, got %q", APIVersionV1Alpha1, d.APIVersion) + } + + for _, s := range d.Subsystems { + if !rbaccontract.IsSubsystem(s) { + report("subsystems: %q is not a subsystem of the role model (%s)", s, strings.Join(rbaccontract.Subsystems, ", ")) + } + } + + seen := make(map[string]struct{}, len(d.Resources)) + usedCapabilities := make(map[string]struct{}) + + for i := range d.Resources { + r := &d.Resources[i] + where := fmt.Sprintf("resources[%d] (%s)", i, r.Key()) + + if _, dup := seen[r.Key()]; dup { + report("%s: duplicate entry for %s", where, r.Key()) + } + + seen[r.Key()] = struct{}{} + + validateResource(r, where, crds, usedCapabilities, report) + } + + validateCapabilities(d.Capabilities, usedCapabilities, report) + validateServiceAccounts(d.ServiceAccounts, report) + validateAccess(d.Access, report) + + if d.PrometheusAccess != nil && + len(d.PrometheusAccess.Deployments)+len(d.PrometheusAccess.DaemonSets)+len(d.PrometheusAccess.StatefulSets) == 0 { + report("prometheusAccess: names no workload; remove the section or list deployments, daemonsets or statefulsets") + } + + return errs +} + +type reporter func(format string, args ...any) + +func validateResource(r *Resource, where string, crds CRDScopes, usedCapabilities map[string]struct{}, report reporter) { + if r.Resource == "" { + report("%s: resource is required", where) + return + } + + // Which shape is this entry? + switch { + case r.NoAccess != "" && r.HasLevels(): + report("%s: noAccess excludes namespace, system and legacy: an entry either denies access with a reason or grants levels", where) + case r.NoAccess == "" && !r.HasLevels(): + report("%s: an entry must grant at least one level (namespace, system or legacy) or deny access with noAccess: \"\"", where) + } + + scope, scopeErr := resolveScope(r, crds) + if scopeErr != "" { + report("%s: %s", where, scopeErr) + } + + if r.IsWildcard() { + if _, known := crds.groupKnown(r.Group); known { + report("%s: resource \"*\" is allowed only for a group the module ships no CRD for; list the resources of %q", where, r.Group) + } + + if r.Reason == "" { + report("%s: resource \"*\" requires reason: why the resource names are not known statically", where) + } + } + + if scope == ScopeCluster && len(r.Namespace) > 0 { + report("%s: namespace levels are not allowed for a cluster-scoped resource: a namespace capability is granted through a RoleBinding, where such a rule grants nothing; use system", where) + } + + if scope == ScopeNamespaced && len(r.System) > 0 && r.Reason == "" { + report("%s: a namespaced resource granted at a system level is granted across the whole cluster; confirm it with reason: \"\"", where) + } + + validateLevels(r.Namespace, rbaccontract.LineageNamespace, rbaccontract.NamespaceLevels, where, usedCapabilities, report) + validateLevels(r.System, rbaccontract.LineageSystem, rbaccontract.SystemLevels, where, usedCapabilities, report) + validateLevels(r.Legacy, "legacy", rbaccontract.LegacyLevels, where, nil, report) +} + +// resolveScope returns the effective scope of the entry: the declared one, checked against the +// CRD when the tree has it; the CRD's when nothing is declared; and an error when neither is +// available. A subresource inherits the scope of its base resource. +func resolveScope(r *Resource, crds CRDScopes) (string, string) { + if r.Scope != "" && r.Scope != ScopeNamespaced && r.Scope != ScopeCluster { + return "", fmt.Sprintf("scope must be %q or %q, got %q", ScopeNamespaced, ScopeCluster, r.Scope) + } + + base := r.Resource + if i := strings.IndexByte(base, '/'); i >= 0 { + base = base[:i] + } + + fromCRD, known := crds[r.Group+"/"+base] + + switch { + case known && r.Scope != "" && r.Scope != fromCRD: + return fromCRD, fmt.Sprintf("scope %q disagrees with the CRD in crds/, which says %q", r.Scope, fromCRD) + case known: + return fromCRD, "" + case r.Scope != "": + return r.Scope, "" + case r.NoAccess != "": + // A denied external resource needs no scope: nothing is generated for it. + return "", "" + default: + return "", "the module ships no CRD for this resource, so scope is required (Namespaced or Cluster)" + } +} + +// groupKnown reports whether any CRD of the tree belongs to the group. +func (c CRDScopes) groupKnown(group string) (string, bool) { + for key := range c { + if strings.HasPrefix(key, group+"/") { + return key, true + } + } + + return "", false +} + +func validateLevels(levels map[string][]string, lineage string, allowed []string, where string, usedCapabilities map[string]struct{}, report reporter) { + for level, verbs := range levels { + if !slices.Contains(allowed, level) { + report("%s: %s level %q is not valid; the %s levels are %s", where, lineage, level, lineage, strings.Join(allowed, ", ")) + continue + } + + if len(verbs) == 0 { + report("%s: %s.%s lists no verbs", where, lineage, level) + } + + for _, verb := range verbs { + if !slices.Contains(rbaccontract.Verbs, verb) { + report("%s: %s.%s: %q is not a verb; verbs are listed explicitly (%s), there are no aliases", where, lineage, level, verb, strings.Join(rbaccontract.Verbs[:len(rbaccontract.Verbs)-1], ", ")) + } + } + + if dup := firstDuplicate(verbs); dup != "" { + report("%s: %s.%s lists %q twice", where, lineage, level, dup) + } + + if usedCapabilities != nil { + usedCapabilities[lineage+"."+rbaccontract.CapabilityAction(level)] = struct{}{} + } + } +} + +// validateCapabilities requires localized texts for every capability outside the platform +// convention (anything but view/edit) and rejects malformed entries. +func validateCapabilities(texts map[string]CapabilityText, used map[string]struct{}, report reporter) { + for key, text := range texts { + lineage, action, ok := strings.Cut(key, ".") + if !ok || (lineage != rbaccontract.LineageNamespace && lineage != rbaccontract.LineageSystem) { + report("capabilities: key %q must be \"namespace.\" or \"system.\"", key) + continue + } + + if rbaccontract.IsConventionalAction(action) { + report("capabilities: %q needs no texts: view and edit capabilities take the platform's conventional texts", key) + } + + for field, value := range map[string]LocalizedText{"title": text.Title, "description": text.Description} { + if value.EN == "" || value.RU == "" { + report("capabilities: %s.%s requires both en and ru", key, field) + } + } + } + + for key := range used { + _, action, _ := strings.Cut(key, ".") + if rbaccontract.IsConventionalAction(action) { + continue + } + + if _, ok := texts[key]; !ok { + report("capabilities: %q is used by a resource entry but has no title and description; a capability outside the view/edit convention needs localized texts", key) + } + } +} + +func validateServiceAccounts(accounts []ServiceAccount, report reporter) { + names := make(map[string]struct{}, len(accounts)) + + for i, sa := range accounts { + where := fmt.Sprintf("serviceAccounts[%d] (%s)", i, sa.Name) + + if sa.Name == "" { + report("serviceAccounts[%d]: name is required", i) + continue + } + + if _, dup := names[sa.Name]; dup { + report("%s: duplicate name", where) + } + + names[sa.Name] = struct{}{} + + if strings.HasPrefix(sa.Path, "/") || strings.HasSuffix(sa.Path, "/") || strings.Contains(sa.Path, "..") { + report("%s: path must be a directory under templates/ without leading or trailing slashes, got %q", where, sa.Path) + } + + validatePolicyRules(sa.ClusterRules, where+".clusterRules", report) + validatePolicyRules(sa.NamespaceRules, where+".namespaceRules", report) + + for j, ref := range sa.BindRoles { + if ref.Namespace == "" || ref.Name == "" { + report("%s.bindRoles[%d]: namespace and name are required", where, j) + } + } + + for j, name := range sa.BindClusterRoles { + if name == "" { + report("%s.bindClusterRoles[%d]: empty name", where, j) + } + } + } +} + +func validateAccess(access []Access, report reporter) { + names := make(map[string]struct{}, len(access)) + + for i, a := range access { + where := fmt.Sprintf("access[%d] (%s)", i, a.Name) + + if a.Name == "" { + report("access[%d]: name is required", i) + continue + } + + if _, dup := names[a.Name]; dup { + report("%s: duplicate name", where) + } + + names[a.Name] = struct{}{} + + if len(a.Subjects) == 0 { + report("%s: subjects is required", where) + } + + for j, s := range a.Subjects { + switch s.Kind { + case "User", "Group": + if s.Namespace != "" { + report("%s.subjects[%d]: a %s has no namespace", where, j, s.Kind) + } + case "ServiceAccount": + if s.Namespace == "" { + report("%s.subjects[%d]: a ServiceAccount subject requires namespace", where, j) + } + default: + report("%s.subjects[%d]: kind must be User, Group or ServiceAccount, got %q", where, j, s.Kind) + } + + if s.Name == "" { + report("%s.subjects[%d]: name is required", where, j) + } + } + + switch { + case len(a.ClusterRules) > 0 && len(a.NamespaceRules) > 0: + report("%s: exactly one of clusterRules and namespaceRules: cluster rules go to rbac-for-us.yaml, namespace rules to rbac-to-us.yaml", where) + case len(a.ClusterRules) == 0 && len(a.NamespaceRules) == 0: + report("%s: clusterRules or namespaceRules is required", where) + } + + validatePolicyRules(a.ClusterRules, where+".clusterRules", report) + validatePolicyRules(a.NamespaceRules, where+".namespaceRules", report) + } +} + +func validatePolicyRules(rules []PolicyRule, where string, report reporter) { + for i, rule := range rules { + if len(rule.Verbs) == 0 { + report("%s[%d]: verbs is required", where, i) + } + + if len(rule.NonResourceURLs) > 0 && (len(rule.APIGroups) > 0 || len(rule.Resources) > 0 || len(rule.ResourceNames) > 0) { + report("%s[%d]: nonResourceURLs cannot be combined with apiGroups, resources or resourceNames", where, i) + } + + if len(rule.NonResourceURLs) == 0 && len(rule.Resources) == 0 { + report("%s[%d]: resources (with apiGroups) or nonResourceURLs is required", where, i) + } + } +} + +func firstDuplicate(values []string) string { + seen := make(map[string]struct{}, len(values)) + + for _, v := range values { + if _, ok := seen[v]; ok { + return v + } + + seen[v] = struct{}{} + } + + return "" +} From b51b44faaa449a8b92e24cbbaa827ba79049bf25 Mon Sep 17 00:00:00 2001 From: Ivan Zvyagintsev Date: Mon, 21 Sep 2026 22:42:18 +0300 Subject: [PATCH 02/58] rbac: add the coverage rule -- every CRD of the module needs a decision in rbac.yaml coverage runs only when the module has an rbac.yaml. It reads the CRDs under crds/ at any depth, selecting them by kind, and requires an entry for each: levels of either role model, or noAccess with the reason. A CRD without an entry gets an autofix that appends an undecided stub (noAccess: "TODO") and then still reports the finding -- the stub is not a decision, and a --fix run that wrote stubs must not end green. A stub left as TODO is an error without a fix; a resource of a known group that no CRD spells is a warning. Signed-off-by: Ivan Zvyagintsev --- pkg/linters/rbac/rules/coverage.go | 252 ++++++++++++++++++++++++ pkg/linters/rbac/rules/coverage_test.go | 228 +++++++++++++++++++++ pkg/linters/rbac/rules/crds.go | 119 +++++++++++ 3 files changed, 599 insertions(+) create mode 100644 pkg/linters/rbac/rules/coverage.go create mode 100644 pkg/linters/rbac/rules/coverage_test.go create mode 100644 pkg/linters/rbac/rules/crds.go diff --git a/pkg/linters/rbac/rules/coverage.go b/pkg/linters/rbac/rules/coverage.go new file mode 100644 index 00000000..167695c2 --- /dev/null +++ b/pkg/linters/rbac/rules/coverage.go @@ -0,0 +1,252 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rules + +import ( + "bytes" + "context" + stderrors "errors" + "fmt" + "os" + + "gopkg.in/yaml.v3" + + "github.com/deckhouse/dmt/pkg" + "github.com/deckhouse/dmt/pkg/errors" + "github.com/deckhouse/dmt/pkg/linters/rbac/rules/rbacyaml" +) + +const ( + CoverageRuleName = "coverage" + + // FixCommand is what closes a coverage or sync finding that carries an autofix. + FixCommand = "dmt lint --linter rbac --fix" +) + +// CoverageRule requires a decision on the user access to every CRD the module ships: an entry +// in rbac.yaml that grants levels or denies access with a reason. It runs only when the module +// has an rbac.yaml (spec 005 R22); without one, only the contract rule applies. +// +// Its autofix appends an undecided stub (noAccess: "TODO") for each CRD without an entry and +// then reports that a decision is still owed, so a --fix run that wrote stubs does not end +// green (R33): the tool never takes the decision for the author (R10). +type CoverageRule struct { + pkg.RuleMeta + pkg.StringRule + + module pkg.Module + errorList *errors.LintRuleErrorsList +} + +var _ pkg.Rule = (*CoverageRule)(nil) + +// NewCoverageRule builds the rule. excludeRules lists "group/resource" keys of CRDs the module +// deliberately keeps out of the declaration. +func NewCoverageRule(excludeRules []pkg.StringRuleExclude, m pkg.Module, errorList *errors.LintRuleErrorsList) *CoverageRule { + return &CoverageRule{ + RuleMeta: pkg.RuleMeta{Name: CoverageRuleName}, + StringRule: pkg.StringRule{ExcludeRules: excludeRules}, + module: m, + errorList: errorList.WithRule(CoverageRuleName), + } +} + +func (r *CoverageRule) Check(_ context.Context) { + modulePath := r.module.GetPath() + errorList := r.errorList.WithFilePath(rbacyaml.Filename) + + decl, err := rbacyaml.Load(modulePath) + if err != nil { + // No declaration: nothing to cover (R22). A declaration that does not parse is the sync + // rule's finding; reporting it twice would only double the noise. + return + } + + crds, err := moduleCRDs(modulePath) + if err != nil { + r.errorList.WithFilePath("crds").Errorf("cannot read the module CRDs: %v", err) + return + } + + entries := make(map[string]rbacyaml.Resource, len(decl.Resources)) + for _, res := range decl.Resources { + entries[res.Key()] = res + } + + groups := make(map[string]struct{}, len(crds)) + known := make(map[string]struct{}, len(crds)) + + for _, crd := range crds { + groups[crd.Group] = struct{}{} + known[crd.Key()] = struct{}{} + + if !r.Enabled(crd.Key()) { + continue + } + + entry, ok := entries[crd.Key()] + if !ok { + errorList. + WithObjectID("CustomResourceDefinition/"+crd.Key()). + WithFix(appendStubFix(modulePath, crd)). + Errorf("CRD %s (%s) has no entry in %s: decide the user access to it -- namespace, system or legacy levels, or noAccess with the reason; `%s` adds an undecided stub", + crd.Key(), crd.File, rbacyaml.Filename, FixCommand) + + continue + } + + if entry.NoAccess == rbacyaml.NoAccessTODO { + errorList. + WithObjectID("CustomResourceDefinition/"+crd.Key()). + Errorf("%s is still noAccess: %q in %s: a decision is needed -- grant levels, or replace %q with the reason users get no access; only a person can close this", + crd.Key(), rbacyaml.NoAccessTODO, rbacyaml.Filename, rbacyaml.NoAccessTODO) + } + } + + // A resource of a group the module ships CRDs for, but not one of them, is most likely a + // misspelling (R11). Whole-group and subresource entries are exempt: CRDs describe neither. + for _, res := range decl.Resources { + if res.IsWildcard() || res.IsSubresource() { + continue + } + + if _, groupKnown := groups[res.Group]; !groupKnown { + continue + } + + if _, resourceKnown := known[res.Key()]; !resourceKnown { + errorList. + WithObjectID("rbac.yaml/"+res.Key()). + Warnf("%s names a resource the module's CRDs of group %s do not have; check the spelling, or drop the entry if the resource is gone", + res.Key(), res.Group) + } + } +} + +// appendStubFix returns the autofix for a CRD without an entry: append an undecided stub to +// rbac.yaml. The closure reads the file when it runs, so several stubs written in one run land +// in the same file; it leaves an already present entry alone, so the fix is idempotent. It +// returns an error on purpose after a successful write: the stub is not a decision, and the +// finding must stay in the output and in the exit code of the run that wrote it (R33). +func appendStubFix(modulePath string, crd crdInfo) errors.AutofixFunc { + return func() error { + added, err := appendStub(rbacyaml.Path(modulePath), crd.Group, crd.Plural) + if err != nil { + return fmt.Errorf("add a stub for %s to %s: %w", crd.Key(), rbacyaml.Filename, err) + } + + if !added { + return nil + } + + return fmt.Errorf("a stub for %s was added to %s; decide its access (noAccess: %q is not a decision)", + crd.Key(), rbacyaml.Filename, rbacyaml.NoAccessTODO) + } +} + +// appendStub adds `- group: \n resource: \n noAccess: "TODO"` to the +// resources of the declaration, keeping the rest of the file -- comments included -- as it is. +// It reports whether anything was written. +func appendStub(path, group, resource string) (bool, error) { + data, err := os.ReadFile(path) + if err != nil { + return false, err + } + + var root yaml.Node + if err := yaml.Unmarshal(data, &root); err != nil { + return false, err + } + + if root.Kind != yaml.DocumentNode || len(root.Content) != 1 || root.Content[0].Kind != yaml.MappingNode { + return false, stderrors.New("the file is not a YAML mapping") + } + + doc := root.Content[0] + resources := mappingValue(doc, "resources") + + if resources == nil { + doc.Content = append(doc.Content, + &yaml.Node{Kind: yaml.ScalarNode, Value: "resources"}, + &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"}) + resources = doc.Content[len(doc.Content)-1] + } + + if resources.Kind != yaml.SequenceNode { + // `resources: null` or a scalar: replace with a sequence so the stub has somewhere to go. + *resources = yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"} + } + + for _, item := range resources.Content { + if scalarValue(mappingValue(item, "group")) == group && scalarValue(mappingValue(item, "resource")) == resource { + return false, nil + } + } + + resources.Content = append(resources.Content, &yaml.Node{ + Kind: yaml.MappingNode, + Tag: "!!map", + Content: []*yaml.Node{ + {Kind: yaml.ScalarNode, Value: "group"}, {Kind: yaml.ScalarNode, Value: group}, + {Kind: yaml.ScalarNode, Value: "resource"}, {Kind: yaml.ScalarNode, Value: resource}, + {Kind: yaml.ScalarNode, Value: "noAccess"}, {Kind: yaml.ScalarNode, Value: rbacyaml.NoAccessTODO, Style: yaml.DoubleQuotedStyle}, + }, + }) + + var buf bytes.Buffer + + encoder := yaml.NewEncoder(&buf) + encoder.SetIndent(2) + + if err := encoder.Encode(&root); err != nil { + return false, err + } + + if err := encoder.Close(); err != nil { + return false, err + } + + info, err := os.Stat(path) + if err != nil { + return false, err + } + + return true, os.WriteFile(path, buf.Bytes(), info.Mode().Perm()) +} + +// mappingValue returns the value node of key in a mapping node, or nil. +func mappingValue(mapping *yaml.Node, key string) *yaml.Node { + if mapping == nil || mapping.Kind != yaml.MappingNode { + return nil + } + + for i := 0; i+1 < len(mapping.Content); i += 2 { + if mapping.Content[i].Value == key { + return mapping.Content[i+1] + } + } + + return nil +} + +func scalarValue(node *yaml.Node) string { + if node == nil || node.Kind != yaml.ScalarNode { + return "" + } + + return node.Value +} diff --git a/pkg/linters/rbac/rules/coverage_test.go b/pkg/linters/rbac/rules/coverage_test.go new file mode 100644 index 00000000..de196a2b --- /dev/null +++ b/pkg/linters/rbac/rules/coverage_test.go @@ -0,0 +1,228 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rules + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/gojuno/minimock/v3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/deckhouse/dmt/internal/mocks" + "github.com/deckhouse/dmt/pkg" + "github.com/deckhouse/dmt/pkg/errors" + "github.com/deckhouse/dmt/pkg/linters/rbac/rules/rbacyaml" +) + +// writeModule lays out a module directory from a map of relative paths to contents. +func writeModule(t *testing.T, files map[string]string) string { + t.Helper() + + modulePath := filepath.Join(t.TempDir(), "module") + + for rel, content := range files { + full := filepath.Join(modulePath, rel) + require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755)) + require.NoError(t, os.WriteFile(full, []byte(content), 0o600)) + } + + return modulePath +} + +func crdYAML(group, plural, scope string) string { + return "apiVersion: apiextensions.k8s.io/v1\nkind: CustomResourceDefinition\nmetadata:\n name: " + plural + "." + group + + "\nspec:\n group: " + group + "\n names:\n plural: " + plural + "\n kind: X\n scope: " + scope + "\n versions: []\n" +} + +func coverageModule(t *testing.T, path string) *mocks.ModuleMock { + t.Helper() + + m := mocks.NewModuleMock(minimock.NewController(t)) + m.GetPathMock.Return(path) + + return m +} + +func runCoverage(t *testing.T, modulePath string, excludes ...string) *errors.LintRuleErrorsList { + t.Helper() + + errorList := errors.NewLintRuleErrorsList() + rule := NewCoverageRule(pkg.StringRuleExcludeList(excludes).Get(), coverageModule(t, modulePath), errorList) + rule.Check(context.Background()) + + return errorList +} + +func texts(errorList *errors.LintRuleErrorsList) []string { + errs := errorList.GetErrors() + out := make([]string, 0, len(errs)) + + for _, e := range errs { + out = append(out, e.Level.String()+": "+e.Text) + } + + return out +} + +func TestModuleCRDs(t *testing.T) { + modulePath := writeModule(t, map[string]string{ + // nested directory, two documents in one file, a README and a translation to skip + "crds/vendor/a.yaml": crdYAML("a.io", "alphas", "Namespaced") + "---\n" + crdYAML("a.io", "betas", "Cluster"), + "crds/b.yml": crdYAML("b.io", "gammas", "Cluster"), + "crds/README.md": "# not yaml\n", + "crds/doc-ru-a.yaml": "spec: {group: a.io}\n", + "crds/other.yaml": "apiVersion: v1\nkind: ConfigMap\nmetadata: {name: x}\n", + "images/img/testdata/crds/look-alike.yaml": crdYAML("z.io", "zetas", "Cluster"), + }) + + crds, err := moduleCRDs(modulePath) + require.NoError(t, err) + + keys := make([]string, 0, len(crds)) + for _, c := range crds { + keys = append(keys, c.Key()+":"+c.Scope+"@"+c.File) + } + + assert.Equal(t, []string{ + "a.io/alphas:Namespaced@crds/vendor/a.yaml", + "a.io/betas:Cluster@crds/vendor/a.yaml", + "b.io/gammas:Cluster@crds/b.yml", + }, keys, "CRDs are selected by kind at any depth under crds/, and only there") +} + +func TestCoverage_WithoutDeclarationIsSilent(t *testing.T) { + modulePath := writeModule(t, map[string]string{"crds/a.yaml": crdYAML("a.io", "alphas", "Namespaced")}) + + assert.Empty(t, runCoverage(t, modulePath).GetErrors(), "a module without rbac.yaml gets the contract check only (R22)") +} + +func TestCoverage_FindingsAndStubFix(t *testing.T) { + const declaration = `apiVersion: rbac.deckhouse.io/v1alpha1 +# keep me: comments survive the autofix +resources: + - group: a.io + resource: alphas + namespace: + viewer: [get, list, watch] + - group: a.io + resource: betas + noAccess: "TODO" + - group: a.io + resource: gamas # misspelled + scope: Cluster + system: + viewer: [get] + - group: external.io + resource: "*" + scope: Cluster + reason: "created at runtime" + system: + viewer: [get] +` + + modulePath := writeModule(t, map[string]string{ + "crds/a.yaml": crdYAML("a.io", "alphas", "Namespaced") + "---\n" + crdYAML("a.io", "betas", "Cluster") + "---\n" + crdYAML("a.io", "gammas", "Cluster"), + "crds/d.yaml": crdYAML("d.io", "deltas", "Namespaced"), + rbacyaml.Filename: declaration, + }) + + errorList := runCoverage(t, modulePath) + got := texts(errorList) + + require.Len(t, got, 4, "got: %v", got) + assert.Contains(t, got, "error: CRD a.io/gammas (crds/a.yaml) has no entry in rbac.yaml: decide the user access to it -- namespace, system or legacy levels, or noAccess with the reason; `dmt lint --linter rbac --fix` adds an undecided stub") + assert.Contains(t, got, "error: CRD d.io/deltas (crds/d.yaml) has no entry in rbac.yaml: decide the user access to it -- namespace, system or legacy levels, or noAccess with the reason; `dmt lint --linter rbac --fix` adds an undecided stub") + assert.Contains(t, got, `error: a.io/betas is still noAccess: "TODO" in rbac.yaml: a decision is needed -- grant levels, or replace "TODO" with the reason users get no access; only a person can close this`) + assert.Contains(t, got, "warn: a.io/gamas names a resource the module's CRDs of group a.io do not have; check the spelling, or drop the entry if the resource is gone") + + // --fix: two stubs are written, and both findings stay, each with the reason (R33). + fixes := errorList.GetFixes() + require.Len(t, fixes, 2, "only the two missing entries carry an autofix") + + for _, fix := range fixes { + fix() + } + + remaining := errorList.GetErrors() + require.Len(t, remaining, 4, "a stub is not a decision: the findings stay after --fix") + + var fixErrors int + + for _, e := range remaining { + if e.FixError != nil { + fixErrors++ + + assert.Contains(t, e.FixError.Error(), `was added to rbac.yaml; decide its access (noAccess: "TODO" is not a decision)`) + } + } + + assert.Equal(t, 2, fixErrors) + + after, err := os.ReadFile(rbacyaml.Path(modulePath)) + require.NoError(t, err) + assert.Contains(t, string(after), "# keep me: comments survive the autofix") + assert.Contains(t, string(after), "- group: a.io\n resource: gammas\n noAccess: \"TODO\"\n") + assert.Contains(t, string(after), "- group: d.io\n resource: deltas\n noAccess: \"TODO\"\n") + + decl, err := rbacyaml.Load(modulePath) + require.NoError(t, err, "the file the autofix wrote still parses") + assert.Len(t, decl.Resources, 6) + + // The next run: no missing entries, three undecided stubs, the misspelling still flagged. + second := texts(runCoverage(t, modulePath)) + assert.Len(t, second, 4, "got: %v", second) + assert.NotContains(t, strings.Join(second, "\n"), "has no entry") + assert.Equal(t, 3, strings.Count(strings.Join(second, "\n"), `is still noAccess: "TODO"`)) + + // Idempotency (R17): fixes of a run that has nothing to add do not touch the file. + third := runCoverage(t, modulePath) + for _, fix := range third.GetFixes() { + fix() + } + + unchanged, err := os.ReadFile(rbacyaml.Path(modulePath)) + require.NoError(t, err) + assert.Equal(t, string(after), string(unchanged)) +} + +func TestCoverage_ExcludedCRDAndDeclarationWithoutResources(t *testing.T) { + modulePath := writeModule(t, map[string]string{ + "crds/a.yaml": crdYAML("a.io", "alphas", "Namespaced") + "---\n" + crdYAML("a.io", "betas", "Cluster"), + rbacyaml.Filename: "apiVersion: rbac.deckhouse.io/v1alpha1\n", + }) + + got := texts(runCoverage(t, modulePath, "a.io/betas")) + require.Len(t, got, 1, "the excluded CRD is not required, got: %v", got) + assert.Contains(t, got[0], "CRD a.io/alphas") + + // The autofix creates the resources list in a declaration that has none yet. + errorList := runCoverage(t, modulePath, "a.io/betas") + for _, fix := range errorList.GetFixes() { + fix() + } + + decl, err := rbacyaml.Load(modulePath) + require.NoError(t, err) + require.Len(t, decl.Resources, 1) + assert.Equal(t, "a.io/alphas", decl.Resources[0].Key()) + assert.Equal(t, rbacyaml.NoAccessTODO, decl.Resources[0].NoAccess) +} diff --git a/pkg/linters/rbac/rules/crds.go b/pkg/linters/rbac/rules/crds.go new file mode 100644 index 00000000..4da4622e --- /dev/null +++ b/pkg/linters/rbac/rules/crds.go @@ -0,0 +1,119 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rules + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + + "sigs.k8s.io/yaml" + + "github.com/deckhouse/dmt/internal/fsutils" +) + +// crdInfo is what the rbac rules need to know about a CRD the module ships: its identity and +// scope, and the file it came from for the finding. +type crdInfo struct { + Group string + Plural string + Scope string + // File is the path relative to the module root. + File string +} + +// Key returns "group/plural", the identity an rbac.yaml entry is matched by. +func (c crdInfo) Key() string { return c.Group + "/" + c.Plural } + +// crdsYamlRegex selects the files of the module's crds/ directory, at any depth: cert-manager +// keeps its CRDs in crds/cert-manager/, operator-trivy in crds/native/. The anchor keeps +// images/**/testdata/crds/ and other look-alikes out; a file is a CRD by its kind, not by its +// directory (spec 005 R8). +var crdsYamlRegex = regexp.MustCompile(`^crds/.*\.ya?ml$`) + +func filterCRDFiles(rootPath, path string) bool { + path = fsutils.Rel(rootPath, path) + + filename := filepath.Base(path) + if strings.HasSuffix(filename, "-tests.yaml") || strings.HasPrefix(filename, "doc-ru-") { + return false + } + + return crdsYamlRegex.MatchString(path) +} + +// crdDocument is the part of a CustomResourceDefinition the rules read. +type crdDocument struct { + Kind string `json:"kind"` + Spec struct { + Group string `json:"group"` + Names struct { + Plural string `json:"plural"` + } `json:"names"` + Scope string `json:"scope"` + } `json:"spec"` +} + +// moduleCRDs returns the CRDs the module ships under crds/, sorted by group and plural. +// Documents that are not a CustomResourceDefinition are skipped: a crds/ directory may hold a +// README or other manifests. A file that does not parse is an error: a CRD the rule cannot read +// is a CRD whose access nobody decided on. +func moduleCRDs(modulePath string) ([]crdInfo, error) { + var out []crdInfo + + for _, file := range fsutils.GetFiles(modulePath, true, filterCRDFiles) { + data, err := os.ReadFile(file) + if err != nil { + return nil, fmt.Errorf("read %s: %w", fsutils.Rel(modulePath, file), err) + } + + for _, doc := range fsutils.SplitManifests(string(data)) { + if strings.TrimSpace(doc) == "" { + continue + } + + var crd crdDocument + if err := yaml.Unmarshal([]byte(doc), &crd); err != nil { + return nil, fmt.Errorf("parse %s: %w", fsutils.Rel(modulePath, file), err) + } + + if crd.Kind != "CustomResourceDefinition" { + continue + } + + out = append(out, crdInfo{ + Group: crd.Spec.Group, + Plural: crd.Spec.Names.Plural, + Scope: crd.Spec.Scope, + File: fsutils.Rel(modulePath, file), + }) + } + } + + sort.Slice(out, func(i, j int) bool { + if out[i].Group != out[j].Group { + return out[i].Group < out[j].Group + } + + return out[i].Plural < out[j].Plural + }) + + return out, nil +} From a391a16f7ff2823e1057e4cc384d37ea0e4f5775 Mon Sep 17 00:00:00 2001 From: Ivan Zvyagintsev Date: Mon, 21 Sep 2026 22:45:58 +0300 Subject: [PATCH 03/58] rbac: add the contract rule -- the platform's RBACv2 label and naming contract on rendered objects contract ports the first part of deckhouse/testing/rbacv2/rbacv2_templates_validation_test.go to the rendered ClusterRoles under templates/rbacv2/, so that a module outside the platform repository is held to the same contract: the d8: prefix, the four localized annotations, kind and scope labels, the shape of roles and capabilities, aggregation labels with a lineage and a level of that lineage, delegatable only on namespace/project roles. New here, as a warning for now: a cluster-scoped resource inside a namespace capability grants nothing through the RoleBinding it is bound with. The scope comes from the module's CRDs or its rbac.yaml; a resource the run knows nothing about is not judged. The rule needs no rbac.yaml. Signed-off-by: Ivan Zvyagintsev --- pkg/linters/rbac/rules/contract.go | 351 ++++++++++++++++++++++++ pkg/linters/rbac/rules/contract_test.go | 221 +++++++++++++++ 2 files changed, 572 insertions(+) create mode 100644 pkg/linters/rbac/rules/contract.go create mode 100644 pkg/linters/rbac/rules/contract_test.go diff --git a/pkg/linters/rbac/rules/contract.go b/pkg/linters/rbac/rules/contract.go new file mode 100644 index 00000000..a724b519 --- /dev/null +++ b/pkg/linters/rbac/rules/contract.go @@ -0,0 +1,351 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rules + +import ( + "context" + "regexp" + "slices" + "sort" + "strings" + + rbacv1 "k8s.io/api/rbac/v1" + "k8s.io/apimachinery/pkg/runtime" + + "github.com/deckhouse/dmt/internal/storage" + "github.com/deckhouse/dmt/pkg" + "github.com/deckhouse/dmt/pkg/errors" + "github.com/deckhouse/dmt/pkg/linters/rbac/rules/rbaccontract" + "github.com/deckhouse/dmt/pkg/linters/rbac/rules/rbacyaml" +) + +const ( + ContractRuleName = "contract" + + // rbacv2TemplatesDir is the directory whose rendered ClusterRoles the contract applies to -- + // the same population the platform test in deckhouse/testing/rbacv2 walks. The compatibility + // aliases in templates/rbacv2-compat/ keep the pre-1.78 names on purpose and are outside it. + rbacv2TemplatesDir = "templates/rbacv2/" + + // dictRoleName is a standalone helper role bound by the handle_dict_bindings hook; it lives + // outside the role/capability framework and carries no kind/scope labels. + dictRoleName = "d8:dict" +) + +var ( + aggregateLabelRe = regexp.MustCompile(`^rbac\.deckhouse\.io/aggregate-to-([a-z0-9-]+)-as$`) + // labelValueRe is the Kubernetes label-value grammar the capability marker must satisfy. + labelValueRe = regexp.MustCompile(`^(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?$`) + + roleNameRe = map[string]*regexp.Regexp{ + "system": regexp.MustCompile(`^d8:system:([a-z]+)$`), + "subsystem": regexp.MustCompile(`^d8:subsystem:([a-z0-9-]+):([a-z]+)$`), + "namespace": regexp.MustCompile(`^d8:namespace:([a-z]+)$`), + "project": regexp.MustCompile(`^d8:project:([a-z]+)$`), + } + + capabilityNamePrefix = map[string]string{ + "system": "d8:system-capability:", + "subsystem": "d8:subsystem-capability:", + "namespace": "d8:namespace-capability:", + "project": "d8:project-capability:", + } + + validScopes = []string{"system", "subsystem", "namespace", "project"} +) + +// ContractRule checks the rendered RBACv2 ClusterRoles of a module against the platform's label +// and naming contract -- the first part of deckhouse/testing/rbacv2/rbacv2_templates_validation_test.go, +// so that a module outside the platform repository is held to the same contract. It works on +// rendered objects, not template text, and needs no rbac.yaml. +// +// One check is new here: a cluster-scoped resource inside a namespace capability. Such a rule +// grants nothing through the RoleBinding the capability is bound with. It is reported as a +// warning: three in-tree modules carry such rules today, and it becomes an error once they are +// fixed. The scope of a resource is known from the module's CRDs or from its rbac.yaml entry; +// a resource the run knows nothing about is not judged. +// +// What stays in the platform test on purpose: the levels of sensitive capabilities and the +// closure of aggregation across two modules (rbacv2_capability_levels_test.go), and the global +// uniqueness of the capability marker -- a rule sees one module. +type ContractRule struct { + pkg.RuleMeta + pkg.KindRule + + module pkg.Module + errorList *errors.LintRuleErrorsList +} + +var _ pkg.Rule = (*ContractRule)(nil) + +func NewContractRule(excludeRules []pkg.KindRuleExclude, m pkg.Module, errorList *errors.LintRuleErrorsList) *ContractRule { + return &ContractRule{ + RuleMeta: pkg.RuleMeta{Name: ContractRuleName}, + KindRule: pkg.KindRule{ExcludeRules: excludeRules}, + module: m, + errorList: errorList.WithRule(ContractRuleName), + } +} + +func (r *ContractRule) Check(_ context.Context) { + scopes := r.resourceScopes() + + // Sorted for a deterministic order of findings across runs and render variants. + objects := make([]storage.StoreObject, 0) + + for _, object := range r.module.GetStorage() { + if object.Unstructured.GetKind() != "ClusterRole" || !strings.HasPrefix(object.ShortPath(), rbacv2TemplatesDir) { + continue + } + + if !r.Enabled(object.Unstructured.GetKind(), object.Unstructured.GetName()) { + continue + } + + objects = append(objects, object) + } + + sort.Slice(objects, func(i, j int) bool { return objects[i].Unstructured.GetName() < objects[j].Unstructured.GetName() }) + + for _, object := range objects { + errorList := r.errorList.WithObjectID(object.Identity()).WithFilePath(object.ShortPath()) + + role := new(rbacv1.ClusterRole) + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(object.Unstructured.UnstructuredContent(), role); err != nil { + errorList.Errorf("cannot convert the object to a ClusterRole: %v", err) + continue + } + + checkContract(role, scopes, errorList) + } +} + +// resourceScopes collects what this run knows about resource scopes: the module's CRDs and the +// entries of its rbac.yaml. A resource missing from the map is not judged. +func (r *ContractRule) resourceScopes() rbacyaml.CRDScopes { + scopes := make(rbacyaml.CRDScopes) + + if crds, err := moduleCRDs(r.module.GetPath()); err == nil { + for _, crd := range crds { + scopes[crd.Key()] = crd.Scope + } + } + + if decl, err := rbacyaml.Load(r.module.GetPath()); err == nil { + for _, res := range decl.Resources { + if res.Scope != "" && !res.IsWildcard() && !res.IsSubresource() { + if _, fromCRD := scopes[res.Key()]; !fromCRD { + scopes[res.Key()] = res.Scope + } + } + } + } + + return scopes +} + +// checkContract applies the contract to one rendered ClusterRole. The checks and their messages +// follow the platform test so that both give the same verdict on a module. +func checkContract(role *rbacv1.ClusterRole, scopes rbacyaml.CRDScopes, errorList *errors.LintRuleErrorsList) { + name := role.Name + labels := role.Labels + annotations := role.Annotations + + if !strings.HasPrefix(name, "d8:") { + errorList.Errorf("name %q must start with the d8: prefix", name) + } + + for _, key := range rbaccontract.I18nAnnotations { + if annotations[key] == "" { + errorList.Errorf("missing the %s annotation: every RBACv2 role and capability carries localized en/ru title and description", key) + } + } + + if name == dictRoleName { + return + } + + kind := labels[rbaccontract.LabelKind] + scope := labels[rbaccontract.LabelScope] + + if kind != rbaccontract.KindRole && kind != rbaccontract.KindCapability { + errorList.Errorf("label %s must be %q or %q, got %q", rbaccontract.LabelKind, rbaccontract.KindRole, rbaccontract.KindCapability, kind) + return + } + + if !slices.Contains(validScopes, scope) { + errorList.Errorf("label %s must be one of %s, got %q", rbaccontract.LabelScope, strings.Join(validScopes, "/"), scope) + return + } + + switch kind { + case rbaccontract.KindRole: + checkRole(role, scope, errorList) + case rbaccontract.KindCapability: + checkCapability(role, scope, scopes, errorList) + } + + // Aggregation labels: the lineage must exist and the level must be one of that lineage (R29). + for _, key := range sortedKeys(labels) { + m := aggregateLabelRe.FindStringSubmatch(key) + if m == nil { + continue + } + + lineage, level := m[1], labels[key] + + levels := rbaccontract.LevelsOf(lineage) + if levels == nil { + errorList.Errorf("aggregation label %q targets unknown lineage %q", key, lineage) + continue + } + + if !slices.Contains(levels, level) { + errorList.Errorf("aggregation label %q has invalid level %q; the %s lineage has %s", key, level, lineage, strings.Join(levels, ", ")) + } + } + + if _, ok := labels[rbaccontract.LabelDelegatable]; ok { + if kind != rbaccontract.KindRole || (scope != "namespace" && scope != "project") { + errorList.Errorf("label %s is only allowed on namespace/project roles", rbaccontract.LabelDelegatable) + } + } +} + +func checkRole(role *rbacv1.ClusterRole, scope string, errorList *errors.LintRuleErrorsList) { + name, labels := role.Name, role.Labels + + re := roleNameRe[scope] + + m := re.FindStringSubmatch(name) + if m == nil { + errorList.Errorf("role name %q does not match the %s-scope pattern %s", name, scope, re) + return + } + + level := m[len(m)-1] + if !slices.Contains(rbaccontract.NamespaceLevels, level) { + errorList.Errorf("role name %q has invalid level %q", name, level) + } + + if scope == "subsystem" { + if !rbaccontract.IsSubsystem(m[1]) { + errorList.Errorf("role name %q references unknown subsystem %q", name, m[1]) + } + + if got := labels["rbac.deckhouse.io/subsystem"]; got != m[1] { + errorList.Errorf("label rbac.deckhouse.io/subsystem %q does not match the subsystem %q from the role name", got, m[1]) + } + } + + if scope == "system" || scope == "subsystem" { + if useRole := labels[rbaccontract.LabelUseRole]; !slices.Contains(rbaccontract.NamespaceLevels, useRole) { + errorList.Errorf("label %s must carry a valid level, got %q", rbaccontract.LabelUseRole, useRole) + } + } + + if len(role.Rules) > 0 { + errorList.Errorf("role %q must not define its own rules; move them into a capability", name) + } + + if role.AggregationRule == nil || len(role.AggregationRule.ClusterRoleSelectors) == 0 { + errorList.Errorf("role %q must define aggregationRule.clusterRoleSelectors", name) + return + } + + for _, selector := range role.AggregationRule.ClusterRoleSelectors { + for _, key := range sortedKeys(selector.MatchLabels) { + value := selector.MatchLabels[key] + + m := aggregateLabelRe.FindStringSubmatch(key) + if m == nil { + errorList.Errorf("role %q aggregation selector uses non-aggregation label %q", name, key) + continue + } + + levels := rbaccontract.LevelsOf(m[1]) + if levels == nil { + errorList.Errorf("role %q aggregation selector targets unknown lineage %q", name, m[1]) + } else if !slices.Contains(levels, value) { + errorList.Errorf("role %q aggregation selector has invalid level %q", name, value) + } + } + } +} + +func checkCapability(role *rbacv1.ClusterRole, scope string, scopes rbacyaml.CRDScopes, errorList *errors.LintRuleErrorsList) { + name, labels := role.Name, role.Labels + + if prefix := capabilityNamePrefix[scope]; !strings.HasPrefix(name, prefix) { + errorList.Errorf("capability name %q must start with %q for scope %q", name, prefix, scope) + } + + if len(role.Rules) == 0 { + errorList.Errorf("capability %q must define rules", name) + } + + if role.AggregationRule != nil { + errorList.Errorf("capability %q must not define aggregationRule", name) + } + + var aggregates bool + + for key := range labels { + if aggregateLabelRe.MatchString(key) { + aggregates = true + break + } + } + + if !aggregates { + errorList.Errorf("capability %q does not aggregate into any role (no aggregate-to-*-as labels)", name) + } + + marker := labels[rbaccontract.LabelCapability] + + switch { + case marker == "": + errorList.Errorf("capability %q must carry the %s label", name, rbaccontract.LabelCapability) + case len(marker) > 63 || !labelValueRe.MatchString(marker): + errorList.Errorf("capability %q has invalid %s label value %q", name, rbaccontract.LabelCapability, marker) + } + + // A namespace capability is granted through a RoleBinding; a cluster-scoped resource in it + // grants nothing. Warn for now (D8): three in-tree modules carry such rules. + if scope == "namespace" { + for _, rule := range role.Rules { + for _, group := range rule.APIGroups { + for _, resource := range rule.Resources { + if scopes[group+"/"+resource] == rbacyaml.ScopeCluster { + errorList.Warnf("capability %q grants %s/%s, a cluster-scoped resource, in a namespace capability: bound through a RoleBinding the rule grants nothing; move it to a system capability", name, group, resource) + } + } + } + } + } +} + +func sortedKeys(m map[string]string) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + + sort.Strings(keys) + + return keys +} diff --git a/pkg/linters/rbac/rules/contract_test.go b/pkg/linters/rbac/rules/contract_test.go new file mode 100644 index 00000000..4293778d --- /dev/null +++ b/pkg/linters/rbac/rules/contract_test.go @@ -0,0 +1,221 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rules + +import ( + "context" + "strings" + "testing" + + "github.com/gojuno/minimock/v3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sigs.k8s.io/yaml" + + "github.com/deckhouse/dmt/internal/mocks" + "github.com/deckhouse/dmt/internal/storage" + "github.com/deckhouse/dmt/pkg/errors" + "github.com/deckhouse/dmt/pkg/linters/rbac/rules/rbacyaml" +) + +// rendered is one rendered object: the template it came from and its manifest. +type rendered struct { + path string + yaml string +} + +func storeOf(t *testing.T, objects ...rendered) map[storage.ResourceIndex]storage.StoreObject { + t.Helper() + + store := storage.NewUnstructuredObjectStore() + + for _, o := range objects { + var content map[string]any + require.NoError(t, yaml.Unmarshal([]byte(o.yaml), &content)) + require.NoError(t, store.Put("/module/"+o.path, o.path, content, []byte(o.yaml))) + } + + return store.Storage +} + +func runContract(t *testing.T, modulePath string, objects ...rendered) []string { + t.Helper() + + m := mocks.NewModuleMock(minimock.NewController(t)) + m.GetPathMock.Return(modulePath) + m.GetStorageMock.Return(storeOf(t, objects...)) + + errorList := errors.NewLintRuleErrorsList() + NewContractRule(nil, m, errorList).Check(context.Background()) + + return texts(errorList) +} + +const i18n = ` + en.meta.deckhouse.io/title: "t" + ru.meta.deckhouse.io/title: "т" + en.meta.deckhouse.io/description: "d" + ru.meta.deckhouse.io/description: "д"` + +func clusterRole(name string, labels map[string]string, annotations, body string) string { + var b strings.Builder + + b.WriteString("apiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRole\nmetadata:\n name: \"" + name + "\"\n labels:\n") + + for _, k := range sortedKeys(labels) { + b.WriteString(" " + k + ": \"" + labels[k] + "\"\n") + } + + if annotations != "" { + b.WriteString(" annotations:" + annotations + "\n") + } + + b.WriteString(body) + + return b.String() +} + +var ( + validCapability = clusterRole("d8:namespace-capability:cert-manager:view", map[string]string{ + "rbac.deckhouse.io/kind": "capability", + "rbac.deckhouse.io/scope": "namespace", + "rbac.deckhouse.io/capability": "namespace-capability.cert-manager.view", + "rbac.deckhouse.io/aggregate-to-namespace-as": "viewer", + "module": "cert-manager", + }, i18n, "rules:\n- apiGroups: [cert-manager.io]\n resources: [certificates]\n verbs: [get, list, watch]\n") + + validRole = clusterRole("d8:subsystem:networking:viewer", map[string]string{ + "rbac.deckhouse.io/kind": "role", + "rbac.deckhouse.io/scope": "subsystem", + "rbac.deckhouse.io/subsystem": "networking", + "rbac.deckhouse.io/use-role": "viewer", + }, i18n, "aggregationRule:\n clusterRoleSelectors:\n - matchLabels:\n rbac.deckhouse.io/aggregate-to-networking-as: viewer\n") +) + +func TestContract_CleanObjectsAndOutOfScopeFiles(t *testing.T) { + got := runContract(t, t.TempDir(), + rendered{"templates/rbacv2/use/view.yaml", validCapability}, + rendered{"templates/rbacv2/global/subsystem/roles/networking/viewer.yaml", validRole}, + // the compatibility aliases keep the old names on purpose and are outside the contract + rendered{"templates/rbacv2-compat/aliases.yaml", clusterRole("d8:manage:networking:viewer", map[string]string{"rbac.deckhouse.io/kind": "role"}, "", "")}, + // a controller ClusterRole elsewhere is none of the contract's business + rendered{"templates/rbac-for-us.yaml", clusterRole("d8:cert-manager:controller", nil, "", "rules: []\n")}, + // d8:dict is a helper outside the framework: only the prefix and the texts are required + rendered{"templates/rbacv2/global/dict.yaml", clusterRole("d8:dict", nil, i18n, "rules: []\n")}, + ) + assert.Empty(t, got) +} + +func TestContract_Findings(t *testing.T) { + for name, tc := range map[string]struct { + object string + wantErrs []string + }{ + "name prefix": { + object: clusterRole("namespace-capability:x:view", map[string]string{ + "rbac.deckhouse.io/kind": "capability", "rbac.deckhouse.io/scope": "namespace", + "rbac.deckhouse.io/capability": "namespace-capability.x.view", "rbac.deckhouse.io/aggregate-to-namespace-as": "viewer", + }, i18n, "rules:\n- apiGroups: [x.io]\n resources: [ys]\n verbs: [get]\n"), + wantErrs: []string{ + `error: name "namespace-capability:x:view" must start with the d8: prefix`, + `error: capability name "namespace-capability:x:view" must start with "d8:namespace-capability:" for scope "namespace"`, + }, + }, + "missing i18n": { + object: clusterRole("d8:namespace-capability:x:view", map[string]string{ + "rbac.deckhouse.io/kind": "capability", "rbac.deckhouse.io/scope": "namespace", + "rbac.deckhouse.io/capability": "namespace-capability.x.view", "rbac.deckhouse.io/aggregate-to-namespace-as": "viewer", + }, "\n en.meta.deckhouse.io/title: \"t\"\n en.meta.deckhouse.io/description: \"d\"", "rules:\n- apiGroups: [x.io]\n resources: [ys]\n verbs: [get]\n"), + wantErrs: []string{ + "error: missing the ru.meta.deckhouse.io/title annotation: every RBACv2 role and capability carries localized en/ru title and description", + "error: missing the ru.meta.deckhouse.io/description annotation: every RBACv2 role and capability carries localized en/ru title and description", + }, + }, + "capability with aggregationRule and no marker": { + object: clusterRole("d8:namespace-capability:x:view", map[string]string{ + "rbac.deckhouse.io/kind": "capability", "rbac.deckhouse.io/scope": "namespace", + "rbac.deckhouse.io/aggregate-to-namespace-as": "viewer", + }, i18n, "rules:\n- apiGroups: [x.io]\n resources: [ys]\n verbs: [get]\naggregationRule:\n clusterRoleSelectors:\n - matchLabels: {a: b}\n"), + wantErrs: []string{ + `error: capability "d8:namespace-capability:x:view" must not define aggregationRule`, + `error: capability "d8:namespace-capability:x:view" must carry the rbac.deckhouse.io/capability label`, + }, + }, + "role with rules and a wrong selector": { + object: clusterRole("d8:namespace:viewer", map[string]string{ + "rbac.deckhouse.io/kind": "role", "rbac.deckhouse.io/scope": "namespace", + }, i18n, "rules:\n- apiGroups: [x.io]\n resources: [ys]\n verbs: [get]\naggregationRule:\n clusterRoleSelectors:\n - matchLabels:\n rbac.deckhouse.io/kind: capability\n"), + wantErrs: []string{ + `error: role "d8:namespace:viewer" must not define its own rules; move them into a capability`, + `error: role "d8:namespace:viewer" aggregation selector uses non-aggregation label "rbac.deckhouse.io/kind"`, + }, + }, + "delegatable on a system role, use-role missing": { + object: clusterRole("d8:system:viewer", map[string]string{ + "rbac.deckhouse.io/kind": "role", "rbac.deckhouse.io/scope": "system", "rbac.deckhouse.io/delegatable": "true", + }, i18n, "aggregationRule:\n clusterRoleSelectors:\n - matchLabels:\n rbac.deckhouse.io/aggregate-to-system-as: viewer\n"), + wantErrs: []string{ + `error: label rbac.deckhouse.io/use-role must carry a valid level, got ""`, + "error: label rbac.deckhouse.io/delegatable is only allowed on namespace/project roles", + }, + }, + "R29: level not of the lineage": { + object: clusterRole("d8:system-capability:x:edit", map[string]string{ + "rbac.deckhouse.io/kind": "capability", "rbac.deckhouse.io/scope": "system", + "rbac.deckhouse.io/capability": "system-capability.x.edit", "rbac.deckhouse.io/aggregate-to-system-as": "admin", + }, i18n, "rules:\n- apiGroups: [x.io]\n resources: [ys]\n verbs: [get]\n"), + wantErrs: []string{ + `error: aggregation label "rbac.deckhouse.io/aggregate-to-system-as" has invalid level "admin"; the system lineage has viewer, manager, superadmin`, + }, + }, + "unknown lineage and bad scope label": { + object: clusterRole("d8:namespace-capability:x:view", map[string]string{ + "rbac.deckhouse.io/kind": "capability", "rbac.deckhouse.io/scope": "tenant", + }, i18n, "rules:\n- apiGroups: [x.io]\n resources: [ys]\n verbs: [get]\n"), + wantErrs: []string{ + `error: label rbac.deckhouse.io/scope must be one of system/subsystem/namespace/project, got "tenant"`, + }, + }, + } { + t.Run(name, func(t *testing.T) { + got := runContract(t, t.TempDir(), rendered{"templates/rbacv2/x.yaml", tc.object}) + assert.ElementsMatch(t, tc.wantErrs, got) + }) + } +} + +func TestContract_ClusterScopedResourceInNamespaceCapabilityIsAWarning(t *testing.T) { + capability := clusterRole("d8:namespace-capability:cert-manager:view", map[string]string{ + "rbac.deckhouse.io/kind": "capability", + "rbac.deckhouse.io/scope": "namespace", + "rbac.deckhouse.io/capability": "namespace-capability.cert-manager.view", + "rbac.deckhouse.io/aggregate-to-namespace-as": "viewer", + }, i18n, "rules:\n- apiGroups: [cert-manager.io]\n resources: [certificates, clusterissuers]\n verbs: [get]\n- apiGroups: [external.io]\n resources: [globals, unknowns]\n verbs: [get]\n") + + // Scope from the module's CRDs (clusterissuers) and from rbac.yaml (external.io/globals); + // external.io/unknowns is known to nobody and is not judged. + modulePath := writeModule(t, map[string]string{ + "crds/cm.yaml": crdYAML("cert-manager.io", "certificates", "Namespaced") + "---\n" + crdYAML("cert-manager.io", "clusterissuers", "Cluster"), + rbacyaml.Filename: "apiVersion: rbac.deckhouse.io/v1alpha1\nresources:\n - {group: external.io, resource: globals, scope: Cluster, system: {viewer: [get]}}\n", + }) + + got := runContract(t, modulePath, rendered{"templates/rbacv2/use/view.yaml", capability}) + assert.ElementsMatch(t, []string{ + `warn: capability "d8:namespace-capability:cert-manager:view" grants cert-manager.io/clusterissuers, a cluster-scoped resource, in a namespace capability: bound through a RoleBinding the rule grants nothing; move it to a system capability`, + `warn: capability "d8:namespace-capability:cert-manager:view" grants external.io/globals, a cluster-scoped resource, in a namespace capability: bound through a RoleBinding the rule grants nothing; move it to a system capability`, + }, got) +} From f2287f0bf18af79280c954b887db2a0907690a21 Mon Sep 17 00:00:00 2001 From: Ivan Zvyagintsev Date: Mon, 21 Sep 2026 22:49:26 +0300 Subject: [PATCH 04/58] rbac: register contract and coverage, and give the declaration rules per-rule levels and exclusions The root configuration now accepts linters-settings.rbac.rules.{coverage,sync,contract}.impact, and the module configuration exclude-rules.{coverage,contract,sync}; the levels fall back to the linter's. The four original rbac rules keep the linter level as before: wiring them up would change the severity of existing findings. Both new rules are registered in the static scope. Signed-off-by: Ivan Zvyagintsev --- internal/metrics/metrics_test.go | 2 +- internal/modules/module.go | 16 ++++++++++++++++ pkg/config.go | 6 ++++++ pkg/config/global/global.go | 17 ++++++++++++++++- pkg/config/linters_settings.go | 4 ++++ pkg/linters/rbac/rbac.go | 16 ++++++++++++---- pkg/scopes/static.go | 2 ++ 7 files changed, 57 insertions(+), 6 deletions(-) diff --git a/internal/metrics/metrics_test.go b/internal/metrics/metrics_test.go index 803c1d71..f39dfa41 100644 --- a/internal/metrics/metrics_test.go +++ b/internal/metrics/metrics_test.go @@ -22,7 +22,7 @@ func Test_SetLinterWarningsMetrics_AddsWarningsForAllLinters(t *testing.T) { Module: global.ModuleLinterConfig{}, NoCyrillic: global.LinterConfig{Impact: pkg.Warn.String()}, OpenAPI: global.OpenAPILinterConfig{LinterConfig: global.LinterConfig{Impact: pkg.Warn.String()}}, - Rbac: global.LinterConfig{Impact: pkg.Warn.String()}, + Rbac: global.RbacLinterConfig{LinterConfig: global.LinterConfig{Impact: pkg.Warn.String()}}, Templates: global.TemplatesLinterConfig{}, Documentation: global.DocumentationLinterConfig{}, }, diff --git a/internal/modules/module.go b/internal/modules/module.go index cf23f94e..c12ddc61 100644 --- a/internal/modules/module.go +++ b/internal/modules/module.go @@ -229,10 +229,23 @@ func mapRuleSettings(linterSettings *pkg.LintersSettings, configSettings *config // OpenAPI rules (uses global rule config + local fallback) mapOpenAPIRules(linterSettings, configSettings, globalConfig) + // RBAC declaration rules (uses global rule config + local fallback); the four original rbac + // rules keep the linter level (see mapSimpleLinterRules) + mapRBACRules(linterSettings, configSettings, globalConfig) + // Other linter rules (use local linter-level impact) mapSimpleLinterRules(linterSettings, configSettings) } +// mapRBACRules configures the per-rule levels of the rbac rules added for the module RBAC +// declaration: coverage, sync and contract read their impact from the root configuration and +// fall back to the linter's. +func mapRBACRules(linterSettings *pkg.LintersSettings, configSettings *config.LintersSettings, globalConfig *global.Linters) { + linterSettings.RBAC.Rules.CoverageRule.SetLevel(globalConfig.Rbac.Rules.CoverageRule.Impact, configSettings.Rbac.Impact) + linterSettings.RBAC.Rules.SyncRule.SetLevel(globalConfig.Rbac.Rules.SyncRule.Impact, configSettings.Rbac.Impact) + linterSettings.RBAC.Rules.ContractRule.SetLevel(globalConfig.Rbac.Rules.ContractRule.Impact, configSettings.Rbac.Impact) +} + // mapContainerRules configures Container linter rules func mapContainerRules(linterSettings *pkg.LintersSettings, configSettings *config.LintersSettings, globalConfig *global.Linters) { linterSettings.Container.Rules.RecommendedLabelsRule.SetLevel( @@ -582,6 +595,9 @@ func mapRBACExclusions(linterSettings *pkg.LintersSettings, configSettings *conf excludes.BindingSubject = pkg.StringRuleExcludeList(configExcludes.BindingSubject) excludes.Placement = configExcludes.Placement.Get() excludes.Wildcards = configExcludes.Wildcards.Get() + excludes.Coverage = pkg.StringRuleExcludeList(configExcludes.Coverage) + excludes.Contract = configExcludes.Contract.Get() + excludes.Sync = configExcludes.Sync.Get() } // mapHooksSettings maps Hooks linter settings diff --git a/pkg/config.go b/pkg/config.go index 822ef335..a1a91b3c 100644 --- a/pkg/config.go +++ b/pkg/config.go @@ -244,12 +244,18 @@ type RBACLinterRules struct { BindingRule RuleConfig PlacementRule RuleConfig WildcardsRule RuleConfig + CoverageRule RuleConfig + SyncRule RuleConfig + ContractRule RuleConfig } type RBACExcludeRules struct { BindingSubject StringRuleExcludeList Placement KindRuleExcludeList Wildcards KindRuleExcludeList + Coverage StringRuleExcludeList + Contract KindRuleExcludeList + Sync KindRuleExcludeList } type HooksLinterConfig struct { LinterConfig diff --git a/pkg/config/global/global.go b/pkg/config/global/global.go index 42c8343e..0e372a65 100644 --- a/pkg/config/global/global.go +++ b/pkg/config/global/global.go @@ -30,7 +30,7 @@ type Linters struct { Module ModuleLinterConfig `mapstructure:"module"` NoCyrillic LinterConfig `mapstructure:"no-cyrillic"` OpenAPI OpenAPILinterConfig `mapstructure:"openapi"` - Rbac LinterConfig `mapstructure:"rbac"` + Rbac RbacLinterConfig `mapstructure:"rbac"` Templates TemplatesLinterConfig `mapstructure:"templates"` Documentation DocumentationLinterConfig `mapstructure:"documentation"` } @@ -71,6 +71,21 @@ type ContainerRules struct { SysCgroupMountRule RuleConfig `mapstructure:"sys-cgroup-mount"` } +// RbacLinterConfig carries the linter-level impact of rbac and the per-rule impacts of the rules +// added for the module RBAC declaration. The four original rules (user-authz, binding-subject, +// placement, wildcards) have never had per-rule levels and keep the linter's: wiring them up +// would change the severity of existing findings. +type RbacLinterConfig struct { + LinterConfig `mapstructure:",squash"` + Rules RbacRules `mapstructure:"rules"` +} + +type RbacRules struct { + CoverageRule RuleConfig `mapstructure:"coverage"` + SyncRule RuleConfig `mapstructure:"sync"` + ContractRule RuleConfig `mapstructure:"contract"` +} + type ImagesLinterConfig struct { LinterConfig `mapstructure:",squash"` Rules ImageRules `mapstructure:"rules"` diff --git a/pkg/config/linters_settings.go b/pkg/config/linters_settings.go index 75ebfe1f..00aad918 100644 --- a/pkg/config/linters_settings.go +++ b/pkg/config/linters_settings.go @@ -222,6 +222,10 @@ type RBACExcludeRules struct { BindingSubject StringRuleExcludeList `mapstructure:"binding-subject"` Placement KindRuleExcludeList `mapstructure:"placement"` Wildcards KindRuleExcludeList `mapstructure:"wildcards"` + // Coverage lists "group/resource" keys of CRDs the declaration deliberately leaves out. + Coverage StringRuleExcludeList `mapstructure:"coverage"` + Contract KindRuleExcludeList `mapstructure:"contract"` + Sync KindRuleExcludeList `mapstructure:"sync"` } type TemplatesSettings struct { diff --git a/pkg/linters/rbac/rbac.go b/pkg/linters/rbac/rbac.go index df8fa92d..315dde91 100644 --- a/pkg/linters/rbac/rbac.go +++ b/pkg/linters/rbac/rbac.go @@ -60,15 +60,23 @@ func (l *Rbac) rules() []pkg.Rule { m := l.module errorList := l.ErrorList.WithModule(m.GetName()) - // rbac has never applied its per-rule impact levels (pkg.RBACLinterConfig.Rules - // is populated from config but was not consulted here), so every rule gets the - // linter-level error list unchanged. Wiring those levels up is a separate change: - // it would alter the severity of existing findings. + // The four original rules have never applied per-rule impact levels + // (pkg.RBACLinterConfig.Rules was populated from config but not consulted here), so + // they keep the linter-level error list unchanged: wiring their levels up would alter + // the severity of existing findings. The rules added for the module RBAC declaration + // (coverage, contract, sync) do read their own level, so that they can start as + // warnings in a tree that has not adopted the declaration yet. + level := func(rule pkg.RuleConfig) *errors.LintRuleErrorsList { + return errorList.WithMaxLevel(rule.GetLevel()) + } + return []pkg.Rule{ rules.NewUserAuthZRule(m, errorList), rules.NewBindingSubjectRule(l.cfg.ExcludeRules.BindingSubject.Get(), m, errorList), rules.NewPlacementRule(l.cfg.ExcludeRules.Placement.Get(), m, errorList), rules.NewWildcardsRule(l.cfg.ExcludeRules.Wildcards.Get(), m, errorList), + rules.NewContractRule(l.cfg.ExcludeRules.Contract.Get(), m, level(l.cfg.Rules.ContractRule)), + rules.NewCoverageRule(l.cfg.ExcludeRules.Coverage.Get(), m, level(l.cfg.Rules.CoverageRule)), } } diff --git a/pkg/scopes/static.go b/pkg/scopes/static.go index b9fc3fde..8114fdf9 100644 --- a/pkg/scopes/static.go +++ b/pkg/scopes/static.go @@ -123,6 +123,8 @@ var staticRules = map[string]set.Set{ ), rbac.ID: set.New( rbacrules.BindingSubjectRuleName, + rbacrules.ContractRuleName, + rbacrules.CoverageRuleName, rbacrules.PlacementRuleName, rbacrules.UserAuthZRuleName, rbacrules.WildcardsRuleName, From 187090f8ef1ffe5e1635bd7947e8a0dcd1cbace0 Mon Sep 17 00:00:00 2001 From: Ivan Zvyagintsev Date: Mon, 21 Sep 2026 22:51:02 +0300 Subject: [PATCH 05/58] rbac: e2e cases for the contract and coverage rules Signed-off-by: Ivan Zvyagintsev --- test/e2e/README.md | 7 ++++++ .../rbac/contract-clean/expected.yaml | 7 ++++++ .../rbac/contract-clean/module/module.yaml | 3 +++ .../module/openapi/config-values.yaml | 2 ++ .../contract-clean/module/openapi/values.yaml | 4 +++ .../module/templates/rbacv2/manage/view.yaml | 22 ++++++++++++++++ .../module/templates/rbacv2/use/view.yaml | 20 +++++++++++++++ .../expected.yaml | 15 +++++++++++ .../module/crds/vendor/globals.yaml | 9 +++++++ .../module/crds/vendor/widgets.yaml | 9 +++++++ .../module/module.yaml | 3 +++ .../module/openapi/config-values.yaml | 2 ++ .../module/openapi/values.yaml | 4 +++ .../module/templates/rbacv2/use/view.yaml | 18 +++++++++++++ .../rbac/contract-violations/expected.yaml | 25 +++++++++++++++++++ .../contract-violations/module/module.yaml | 3 +++ .../module/openapi/config-values.yaml | 2 ++ .../module/openapi/values.yaml | 4 +++ .../module/templates/rbacv2/roles/viewer.yaml | 21 ++++++++++++++++ .../module/templates/rbacv2/use/view.yaml | 15 +++++++++++ .../coverage-fix-keeps-finding/expected.yaml | 11 ++++++++ .../module/crds/vendor/globals.yaml | 9 +++++++ .../module/crds/vendor/widgets.yaml | 9 +++++++ .../module/module.yaml | 3 +++ .../module/openapi/config-values.yaml | 2 ++ .../module/openapi/values.yaml | 4 +++ .../module/rbac.yaml | 6 +++++ .../rbac/coverage-missing-entry/expected.yaml | 10 ++++++++ .../module/crds/vendor/globals.yaml | 9 +++++++ .../module/crds/vendor/widgets.yaml | 9 +++++++ .../coverage-missing-entry/module/module.yaml | 3 +++ .../module/openapi/config-values.yaml | 2 ++ .../module/openapi/values.yaml | 4 +++ .../coverage-missing-entry/module/rbac.yaml | 6 +++++ .../testdata/rbac/coverage-todo/expected.yaml | 15 +++++++++++ .../coverage-todo/module/crds/widgets.yaml | 9 +++++++ .../rbac/coverage-todo/module/module.yaml | 3 +++ .../module/openapi/config-values.yaml | 2 ++ .../coverage-todo/module/openapi/values.yaml | 4 +++ .../rbac/coverage-todo/module/rbac.yaml | 10 ++++++++ .../coverage-without-rbac-yaml/expected.yaml | 7 ++++++ .../module/crds/widgets.yaml | 9 +++++++ .../module/module.yaml | 3 +++ .../module/openapi/config-values.yaml | 2 ++ .../module/openapi/values.yaml | 4 +++ 45 files changed, 350 insertions(+) create mode 100644 test/e2e/testdata/rbac/contract-clean/expected.yaml create mode 100644 test/e2e/testdata/rbac/contract-clean/module/module.yaml create mode 100644 test/e2e/testdata/rbac/contract-clean/module/openapi/config-values.yaml create mode 100644 test/e2e/testdata/rbac/contract-clean/module/openapi/values.yaml create mode 100644 test/e2e/testdata/rbac/contract-clean/module/templates/rbacv2/manage/view.yaml create mode 100644 test/e2e/testdata/rbac/contract-clean/module/templates/rbacv2/use/view.yaml create mode 100644 test/e2e/testdata/rbac/contract-cluster-scoped-in-namespace-capability/expected.yaml create mode 100644 test/e2e/testdata/rbac/contract-cluster-scoped-in-namespace-capability/module/crds/vendor/globals.yaml create mode 100644 test/e2e/testdata/rbac/contract-cluster-scoped-in-namespace-capability/module/crds/vendor/widgets.yaml create mode 100644 test/e2e/testdata/rbac/contract-cluster-scoped-in-namespace-capability/module/module.yaml create mode 100644 test/e2e/testdata/rbac/contract-cluster-scoped-in-namespace-capability/module/openapi/config-values.yaml create mode 100644 test/e2e/testdata/rbac/contract-cluster-scoped-in-namespace-capability/module/openapi/values.yaml create mode 100644 test/e2e/testdata/rbac/contract-cluster-scoped-in-namespace-capability/module/templates/rbacv2/use/view.yaml create mode 100644 test/e2e/testdata/rbac/contract-violations/expected.yaml create mode 100644 test/e2e/testdata/rbac/contract-violations/module/module.yaml create mode 100644 test/e2e/testdata/rbac/contract-violations/module/openapi/config-values.yaml create mode 100644 test/e2e/testdata/rbac/contract-violations/module/openapi/values.yaml create mode 100644 test/e2e/testdata/rbac/contract-violations/module/templates/rbacv2/roles/viewer.yaml create mode 100644 test/e2e/testdata/rbac/contract-violations/module/templates/rbacv2/use/view.yaml create mode 100644 test/e2e/testdata/rbac/coverage-fix-keeps-finding/expected.yaml create mode 100644 test/e2e/testdata/rbac/coverage-fix-keeps-finding/module/crds/vendor/globals.yaml create mode 100644 test/e2e/testdata/rbac/coverage-fix-keeps-finding/module/crds/vendor/widgets.yaml create mode 100644 test/e2e/testdata/rbac/coverage-fix-keeps-finding/module/module.yaml create mode 100644 test/e2e/testdata/rbac/coverage-fix-keeps-finding/module/openapi/config-values.yaml create mode 100644 test/e2e/testdata/rbac/coverage-fix-keeps-finding/module/openapi/values.yaml create mode 100644 test/e2e/testdata/rbac/coverage-fix-keeps-finding/module/rbac.yaml create mode 100644 test/e2e/testdata/rbac/coverage-missing-entry/expected.yaml create mode 100644 test/e2e/testdata/rbac/coverage-missing-entry/module/crds/vendor/globals.yaml create mode 100644 test/e2e/testdata/rbac/coverage-missing-entry/module/crds/vendor/widgets.yaml create mode 100644 test/e2e/testdata/rbac/coverage-missing-entry/module/module.yaml create mode 100644 test/e2e/testdata/rbac/coverage-missing-entry/module/openapi/config-values.yaml create mode 100644 test/e2e/testdata/rbac/coverage-missing-entry/module/openapi/values.yaml create mode 100644 test/e2e/testdata/rbac/coverage-missing-entry/module/rbac.yaml create mode 100644 test/e2e/testdata/rbac/coverage-todo/expected.yaml create mode 100644 test/e2e/testdata/rbac/coverage-todo/module/crds/widgets.yaml create mode 100644 test/e2e/testdata/rbac/coverage-todo/module/module.yaml create mode 100644 test/e2e/testdata/rbac/coverage-todo/module/openapi/config-values.yaml create mode 100644 test/e2e/testdata/rbac/coverage-todo/module/openapi/values.yaml create mode 100644 test/e2e/testdata/rbac/coverage-todo/module/rbac.yaml create mode 100644 test/e2e/testdata/rbac/coverage-without-rbac-yaml/expected.yaml create mode 100644 test/e2e/testdata/rbac/coverage-without-rbac-yaml/module/crds/widgets.yaml create mode 100644 test/e2e/testdata/rbac/coverage-without-rbac-yaml/module/module.yaml create mode 100644 test/e2e/testdata/rbac/coverage-without-rbac-yaml/module/openapi/config-values.yaml create mode 100644 test/e2e/testdata/rbac/coverage-without-rbac-yaml/module/openapi/values.yaml diff --git a/test/e2e/README.md b/test/e2e/README.md index 737301c0..4fd2aff2 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -107,6 +107,13 @@ go test ./test/e2e/ -run 'TestE2E//' -v | `no-cyrillic/skip-russian-files` | no-cyrillic linter skips Russian localized files (`*.ru.yml`, `*.ru.yaml`, `*.ru.json`, `doc-ru-*.yml`) while still reporting a regular Cyrillic template | | `no-cyrillic/skip-filenames-extensions` | no-cyrillic linter skips every filename/path pattern (`doc-ru-*`, `*.ru.{yaml,yml,json,md,html}`, `*_RU.md`, `docs/site/_*`, `docs/documentation/_*`, `tools/spelling/*`, `openapi/conversions/*`, `module.yaml`, `i18n/*`, `ru.*`) and non-scanned extensions (`.txt`), reporting only one genuine Cyrillic template | | `rbac/wildcards` | rbac linter (wildcards in a Role) | +| `rbac/contract-clean` | rbac linter `contract` (well-formed RBACv2 namespace and system capabilities pass; no rbac.yaml needed) | +| `rbac/contract-violations` | rbac linter `contract` (missing ru texts, missing capability marker, role with its own rules) | +| `rbac/contract-cluster-scoped-in-namespace-capability` | rbac linter `contract` (warning: cluster-scoped resource, scope read from a nested `crds/`, inside a namespace capability) | +| `rbac/coverage-missing-entry` | rbac linter `coverage` (CRD without an entry in rbac.yaml) | +| `rbac/coverage-fix-keeps-finding` | rbac linter `coverage` with `--fix` (a stub is written and the finding stays -- a stub is not a decision) | +| `rbac/coverage-todo` | rbac linter `coverage` (undecided `noAccess: "TODO"` stub; misspelled resource of a known group is a warning) | +| `rbac/coverage-without-rbac-yaml` | rbac linter `coverage` stays silent on a module without rbac.yaml | | `hooks/ingress` | hooks linter (Ingress without copy_custom_certificate hook) | | `openapi/bilingual` | openapi linter (missing doc-ru- translation, missing CRD module label) | | `images/werf` | images linter (werf fromImage not under base/) | diff --git a/test/e2e/testdata/rbac/contract-clean/expected.yaml b/test/e2e/testdata/rbac/contract-clean/expected.yaml new file mode 100644 index 00000000..e148731a --- /dev/null +++ b/test/e2e/testdata/rbac/contract-clean/expected.yaml @@ -0,0 +1,7 @@ +description: > + Two well-formed RBACv2 capabilities (namespace and system lineage) under templates/rbacv2/ + pass the rbac contract rule; the rule needs no rbac.yaml. +module: module +expectPass: + - linter: rbac + rule: contract diff --git a/test/e2e/testdata/rbac/contract-clean/module/module.yaml b/test/e2e/testdata/rbac/contract-clean/module/module.yaml new file mode 100644 index 00000000..bca5470d --- /dev/null +++ b/test/e2e/testdata/rbac/contract-clean/module/module.yaml @@ -0,0 +1,3 @@ +name: e2e-rbac +namespace: d8-e2e-rbac +subsystems: [networking] diff --git a/test/e2e/testdata/rbac/contract-clean/module/openapi/config-values.yaml b/test/e2e/testdata/rbac/contract-clean/module/openapi/config-values.yaml new file mode 100644 index 00000000..03b0d8bf --- /dev/null +++ b/test/e2e/testdata/rbac/contract-clean/module/openapi/config-values.yaml @@ -0,0 +1,2 @@ +type: object +properties: {} diff --git a/test/e2e/testdata/rbac/contract-clean/module/openapi/values.yaml b/test/e2e/testdata/rbac/contract-clean/module/openapi/values.yaml new file mode 100644 index 00000000..47180da5 --- /dev/null +++ b/test/e2e/testdata/rbac/contract-clean/module/openapi/values.yaml @@ -0,0 +1,4 @@ +x-extend: + schema: config-values.yaml +type: object +properties: {} diff --git a/test/e2e/testdata/rbac/contract-clean/module/templates/rbacv2/manage/view.yaml b/test/e2e/testdata/rbac/contract-clean/module/templates/rbacv2/manage/view.yaml new file mode 100644 index 00000000..e6dfa577 --- /dev/null +++ b/test/e2e/testdata/rbac/contract-clean/module/templates/rbacv2/manage/view.yaml @@ -0,0 +1,22 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: d8:system-capability:e2e-rbac:view + labels: + heritage: deckhouse + module: e2e-rbac + rbac.deckhouse.io/kind: capability + rbac.deckhouse.io/scope: system + rbac.deckhouse.io/capability: system-capability.e2e-rbac.view + rbac.deckhouse.io/aggregate-to-networking-as: viewer + rbac.deckhouse.io/namespace: d8-e2e-rbac + annotations: + en.meta.deckhouse.io/title: "Module e2e-rbac: view" + ru.meta.deckhouse.io/title: "Модуль e2e-rbac: просмотр" + en.meta.deckhouse.io/description: "Read-only access to e2e-rbac resources in a namespace." + ru.meta.deckhouse.io/description: "Доступ только на чтение к ресурсам модуля e2e-rbac в пространстве имён." +rules: +- apiGroups: [deckhouse.io] + resources: [moduleconfigs] + resourceNames: [e2e-rbac] + verbs: [get, list, watch] diff --git a/test/e2e/testdata/rbac/contract-clean/module/templates/rbacv2/use/view.yaml b/test/e2e/testdata/rbac/contract-clean/module/templates/rbacv2/use/view.yaml new file mode 100644 index 00000000..9cdfdc2f --- /dev/null +++ b/test/e2e/testdata/rbac/contract-clean/module/templates/rbacv2/use/view.yaml @@ -0,0 +1,20 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: d8:namespace-capability:e2e-rbac:view + labels: + heritage: deckhouse + module: e2e-rbac + rbac.deckhouse.io/kind: capability + rbac.deckhouse.io/scope: namespace + rbac.deckhouse.io/capability: namespace-capability.e2e-rbac.view + rbac.deckhouse.io/aggregate-to-namespace-as: viewer + annotations: + en.meta.deckhouse.io/title: "Module e2e-rbac: view" + ru.meta.deckhouse.io/title: "Модуль e2e-rbac: просмотр" + en.meta.deckhouse.io/description: "Read-only access to e2e-rbac resources in a namespace." + ru.meta.deckhouse.io/description: "Доступ только на чтение к ресурсам модуля e2e-rbac в пространстве имён." +rules: +- apiGroups: [e2e.deckhouse.io] + resources: [widgets] + verbs: [get, list, watch] diff --git a/test/e2e/testdata/rbac/contract-cluster-scoped-in-namespace-capability/expected.yaml b/test/e2e/testdata/rbac/contract-cluster-scoped-in-namespace-capability/expected.yaml new file mode 100644 index 00000000..c31d2122 --- /dev/null +++ b/test/e2e/testdata/rbac/contract-cluster-scoped-in-namespace-capability/expected.yaml @@ -0,0 +1,15 @@ +description: > + A cluster-scoped resource (scope read from the module's CRDs under a nested crds/ directory) + inside a namespace capability grants nothing through a RoleBinding; the contract rule reports it + as a warning during the transition. +module: module +expect: + - linter: rbac + rule: contract + level: warn + textContains: "grants e2e.deckhouse.io/globals, a cluster-scoped resource, in a namespace capability" + count: 1 +expectPass: + - linter: rbac + rule: contract + level: error diff --git a/test/e2e/testdata/rbac/contract-cluster-scoped-in-namespace-capability/module/crds/vendor/globals.yaml b/test/e2e/testdata/rbac/contract-cluster-scoped-in-namespace-capability/module/crds/vendor/globals.yaml new file mode 100644 index 00000000..8aa2bdc4 --- /dev/null +++ b/test/e2e/testdata/rbac/contract-cluster-scoped-in-namespace-capability/module/crds/vendor/globals.yaml @@ -0,0 +1,9 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: globals.e2e.deckhouse.io +spec: + group: e2e.deckhouse.io + names: {plural: globals, kind: X} + scope: Cluster + versions: [] diff --git a/test/e2e/testdata/rbac/contract-cluster-scoped-in-namespace-capability/module/crds/vendor/widgets.yaml b/test/e2e/testdata/rbac/contract-cluster-scoped-in-namespace-capability/module/crds/vendor/widgets.yaml new file mode 100644 index 00000000..491afd4f --- /dev/null +++ b/test/e2e/testdata/rbac/contract-cluster-scoped-in-namespace-capability/module/crds/vendor/widgets.yaml @@ -0,0 +1,9 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: widgets.e2e.deckhouse.io +spec: + group: e2e.deckhouse.io + names: {plural: widgets, kind: X} + scope: Namespaced + versions: [] diff --git a/test/e2e/testdata/rbac/contract-cluster-scoped-in-namespace-capability/module/module.yaml b/test/e2e/testdata/rbac/contract-cluster-scoped-in-namespace-capability/module/module.yaml new file mode 100644 index 00000000..bca5470d --- /dev/null +++ b/test/e2e/testdata/rbac/contract-cluster-scoped-in-namespace-capability/module/module.yaml @@ -0,0 +1,3 @@ +name: e2e-rbac +namespace: d8-e2e-rbac +subsystems: [networking] diff --git a/test/e2e/testdata/rbac/contract-cluster-scoped-in-namespace-capability/module/openapi/config-values.yaml b/test/e2e/testdata/rbac/contract-cluster-scoped-in-namespace-capability/module/openapi/config-values.yaml new file mode 100644 index 00000000..03b0d8bf --- /dev/null +++ b/test/e2e/testdata/rbac/contract-cluster-scoped-in-namespace-capability/module/openapi/config-values.yaml @@ -0,0 +1,2 @@ +type: object +properties: {} diff --git a/test/e2e/testdata/rbac/contract-cluster-scoped-in-namespace-capability/module/openapi/values.yaml b/test/e2e/testdata/rbac/contract-cluster-scoped-in-namespace-capability/module/openapi/values.yaml new file mode 100644 index 00000000..47180da5 --- /dev/null +++ b/test/e2e/testdata/rbac/contract-cluster-scoped-in-namespace-capability/module/openapi/values.yaml @@ -0,0 +1,4 @@ +x-extend: + schema: config-values.yaml +type: object +properties: {} diff --git a/test/e2e/testdata/rbac/contract-cluster-scoped-in-namespace-capability/module/templates/rbacv2/use/view.yaml b/test/e2e/testdata/rbac/contract-cluster-scoped-in-namespace-capability/module/templates/rbacv2/use/view.yaml new file mode 100644 index 00000000..5b13c1cd --- /dev/null +++ b/test/e2e/testdata/rbac/contract-cluster-scoped-in-namespace-capability/module/templates/rbacv2/use/view.yaml @@ -0,0 +1,18 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: d8:namespace-capability:e2e-rbac:view + labels: + rbac.deckhouse.io/kind: capability + rbac.deckhouse.io/scope: namespace + rbac.deckhouse.io/capability: namespace-capability.e2e-rbac.view + rbac.deckhouse.io/aggregate-to-namespace-as: viewer + annotations: + en.meta.deckhouse.io/title: "Module e2e-rbac: view" + ru.meta.deckhouse.io/title: "Модуль e2e-rbac: просмотр" + en.meta.deckhouse.io/description: "Read-only access to e2e-rbac resources in a namespace." + ru.meta.deckhouse.io/description: "Доступ только на чтение к ресурсам модуля e2e-rbac в пространстве имён." +rules: +- apiGroups: [e2e.deckhouse.io] + resources: [widgets, globals] + verbs: [get, list, watch] diff --git a/test/e2e/testdata/rbac/contract-violations/expected.yaml b/test/e2e/testdata/rbac/contract-violations/expected.yaml new file mode 100644 index 00000000..5132e7bd --- /dev/null +++ b/test/e2e/testdata/rbac/contract-violations/expected.yaml @@ -0,0 +1,25 @@ +description: > + A capability without the ru texts and the capability marker, and a role that carries its own + rules, are reported by the rbac contract rule with the platform test's wording. +module: module +expect: + - linter: rbac + rule: contract + level: error + textContains: "missing the ru.meta.deckhouse.io/title annotation" + count: 1 + - linter: rbac + rule: contract + level: error + textContains: "missing the ru.meta.deckhouse.io/description annotation" + count: 1 + - linter: rbac + rule: contract + level: error + textContains: 'capability "d8:namespace-capability:e2e-rbac:view" must carry the rbac.deckhouse.io/capability label' + count: 1 + - linter: rbac + rule: contract + level: error + textContains: 'role "d8:namespace:viewer" must not define its own rules; move them into a capability' + count: 1 diff --git a/test/e2e/testdata/rbac/contract-violations/module/module.yaml b/test/e2e/testdata/rbac/contract-violations/module/module.yaml new file mode 100644 index 00000000..bca5470d --- /dev/null +++ b/test/e2e/testdata/rbac/contract-violations/module/module.yaml @@ -0,0 +1,3 @@ +name: e2e-rbac +namespace: d8-e2e-rbac +subsystems: [networking] diff --git a/test/e2e/testdata/rbac/contract-violations/module/openapi/config-values.yaml b/test/e2e/testdata/rbac/contract-violations/module/openapi/config-values.yaml new file mode 100644 index 00000000..03b0d8bf --- /dev/null +++ b/test/e2e/testdata/rbac/contract-violations/module/openapi/config-values.yaml @@ -0,0 +1,2 @@ +type: object +properties: {} diff --git a/test/e2e/testdata/rbac/contract-violations/module/openapi/values.yaml b/test/e2e/testdata/rbac/contract-violations/module/openapi/values.yaml new file mode 100644 index 00000000..47180da5 --- /dev/null +++ b/test/e2e/testdata/rbac/contract-violations/module/openapi/values.yaml @@ -0,0 +1,4 @@ +x-extend: + schema: config-values.yaml +type: object +properties: {} diff --git a/test/e2e/testdata/rbac/contract-violations/module/templates/rbacv2/roles/viewer.yaml b/test/e2e/testdata/rbac/contract-violations/module/templates/rbacv2/roles/viewer.yaml new file mode 100644 index 00000000..e07f0caf --- /dev/null +++ b/test/e2e/testdata/rbac/contract-violations/module/templates/rbacv2/roles/viewer.yaml @@ -0,0 +1,21 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: d8:namespace:viewer + labels: + rbac.deckhouse.io/kind: role + rbac.deckhouse.io/scope: namespace + rbac.deckhouse.io/delegatable: "true" + annotations: + en.meta.deckhouse.io/title: "t" + ru.meta.deckhouse.io/title: "т" + en.meta.deckhouse.io/description: "d" + ru.meta.deckhouse.io/description: "д" +rules: +- apiGroups: [""] + resources: [pods] + verbs: [get] +aggregationRule: + clusterRoleSelectors: + - matchLabels: + rbac.deckhouse.io/aggregate-to-namespace-as: viewer diff --git a/test/e2e/testdata/rbac/contract-violations/module/templates/rbacv2/use/view.yaml b/test/e2e/testdata/rbac/contract-violations/module/templates/rbacv2/use/view.yaml new file mode 100644 index 00000000..f5a127d7 --- /dev/null +++ b/test/e2e/testdata/rbac/contract-violations/module/templates/rbacv2/use/view.yaml @@ -0,0 +1,15 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: d8:namespace-capability:e2e-rbac:view + labels: + rbac.deckhouse.io/kind: capability + rbac.deckhouse.io/scope: namespace + rbac.deckhouse.io/aggregate-to-namespace-as: viewer + annotations: + en.meta.deckhouse.io/title: "Module e2e-rbac: view" + en.meta.deckhouse.io/description: "Read-only access." +rules: +- apiGroups: [e2e.deckhouse.io] + resources: [widgets] + verbs: [get] diff --git a/test/e2e/testdata/rbac/coverage-fix-keeps-finding/expected.yaml b/test/e2e/testdata/rbac/coverage-fix-keeps-finding/expected.yaml new file mode 100644 index 00000000..7cbb433c --- /dev/null +++ b/test/e2e/testdata/rbac/coverage-fix-keeps-finding/expected.yaml @@ -0,0 +1,11 @@ +description: > + --fix appends an undecided stub (noAccess: "TODO") for the CRD without an entry, and the finding + stays: a stub is not a decision, so the run that wrote it must not end green (spec 005 R33). +kind: fix +module: module +expect: + - linter: rbac + rule: coverage + level: error + textContains: "CRD e2e.deckhouse.io/globals (crds/vendor/globals.yaml) has no entry in rbac.yaml" + count: 1 diff --git a/test/e2e/testdata/rbac/coverage-fix-keeps-finding/module/crds/vendor/globals.yaml b/test/e2e/testdata/rbac/coverage-fix-keeps-finding/module/crds/vendor/globals.yaml new file mode 100644 index 00000000..8aa2bdc4 --- /dev/null +++ b/test/e2e/testdata/rbac/coverage-fix-keeps-finding/module/crds/vendor/globals.yaml @@ -0,0 +1,9 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: globals.e2e.deckhouse.io +spec: + group: e2e.deckhouse.io + names: {plural: globals, kind: X} + scope: Cluster + versions: [] diff --git a/test/e2e/testdata/rbac/coverage-fix-keeps-finding/module/crds/vendor/widgets.yaml b/test/e2e/testdata/rbac/coverage-fix-keeps-finding/module/crds/vendor/widgets.yaml new file mode 100644 index 00000000..491afd4f --- /dev/null +++ b/test/e2e/testdata/rbac/coverage-fix-keeps-finding/module/crds/vendor/widgets.yaml @@ -0,0 +1,9 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: widgets.e2e.deckhouse.io +spec: + group: e2e.deckhouse.io + names: {plural: widgets, kind: X} + scope: Namespaced + versions: [] diff --git a/test/e2e/testdata/rbac/coverage-fix-keeps-finding/module/module.yaml b/test/e2e/testdata/rbac/coverage-fix-keeps-finding/module/module.yaml new file mode 100644 index 00000000..bca5470d --- /dev/null +++ b/test/e2e/testdata/rbac/coverage-fix-keeps-finding/module/module.yaml @@ -0,0 +1,3 @@ +name: e2e-rbac +namespace: d8-e2e-rbac +subsystems: [networking] diff --git a/test/e2e/testdata/rbac/coverage-fix-keeps-finding/module/openapi/config-values.yaml b/test/e2e/testdata/rbac/coverage-fix-keeps-finding/module/openapi/config-values.yaml new file mode 100644 index 00000000..03b0d8bf --- /dev/null +++ b/test/e2e/testdata/rbac/coverage-fix-keeps-finding/module/openapi/config-values.yaml @@ -0,0 +1,2 @@ +type: object +properties: {} diff --git a/test/e2e/testdata/rbac/coverage-fix-keeps-finding/module/openapi/values.yaml b/test/e2e/testdata/rbac/coverage-fix-keeps-finding/module/openapi/values.yaml new file mode 100644 index 00000000..47180da5 --- /dev/null +++ b/test/e2e/testdata/rbac/coverage-fix-keeps-finding/module/openapi/values.yaml @@ -0,0 +1,4 @@ +x-extend: + schema: config-values.yaml +type: object +properties: {} diff --git a/test/e2e/testdata/rbac/coverage-fix-keeps-finding/module/rbac.yaml b/test/e2e/testdata/rbac/coverage-fix-keeps-finding/module/rbac.yaml new file mode 100644 index 00000000..0f89b575 --- /dev/null +++ b/test/e2e/testdata/rbac/coverage-fix-keeps-finding/module/rbac.yaml @@ -0,0 +1,6 @@ +apiVersion: rbac.deckhouse.io/v1alpha1 +resources: + - group: e2e.deckhouse.io + resource: widgets + namespace: + viewer: [get, list, watch] diff --git a/test/e2e/testdata/rbac/coverage-missing-entry/expected.yaml b/test/e2e/testdata/rbac/coverage-missing-entry/expected.yaml new file mode 100644 index 00000000..6acdf665 --- /dev/null +++ b/test/e2e/testdata/rbac/coverage-missing-entry/expected.yaml @@ -0,0 +1,10 @@ +description: > + A CRD of the module (in a nested crds/ directory) without an entry in rbac.yaml is a coverage + error that names the autofix command. +module: module +expect: + - linter: rbac + rule: coverage + level: error + textContains: "CRD e2e.deckhouse.io/globals (crds/vendor/globals.yaml) has no entry in rbac.yaml" + count: 1 diff --git a/test/e2e/testdata/rbac/coverage-missing-entry/module/crds/vendor/globals.yaml b/test/e2e/testdata/rbac/coverage-missing-entry/module/crds/vendor/globals.yaml new file mode 100644 index 00000000..8aa2bdc4 --- /dev/null +++ b/test/e2e/testdata/rbac/coverage-missing-entry/module/crds/vendor/globals.yaml @@ -0,0 +1,9 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: globals.e2e.deckhouse.io +spec: + group: e2e.deckhouse.io + names: {plural: globals, kind: X} + scope: Cluster + versions: [] diff --git a/test/e2e/testdata/rbac/coverage-missing-entry/module/crds/vendor/widgets.yaml b/test/e2e/testdata/rbac/coverage-missing-entry/module/crds/vendor/widgets.yaml new file mode 100644 index 00000000..491afd4f --- /dev/null +++ b/test/e2e/testdata/rbac/coverage-missing-entry/module/crds/vendor/widgets.yaml @@ -0,0 +1,9 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: widgets.e2e.deckhouse.io +spec: + group: e2e.deckhouse.io + names: {plural: widgets, kind: X} + scope: Namespaced + versions: [] diff --git a/test/e2e/testdata/rbac/coverage-missing-entry/module/module.yaml b/test/e2e/testdata/rbac/coverage-missing-entry/module/module.yaml new file mode 100644 index 00000000..bca5470d --- /dev/null +++ b/test/e2e/testdata/rbac/coverage-missing-entry/module/module.yaml @@ -0,0 +1,3 @@ +name: e2e-rbac +namespace: d8-e2e-rbac +subsystems: [networking] diff --git a/test/e2e/testdata/rbac/coverage-missing-entry/module/openapi/config-values.yaml b/test/e2e/testdata/rbac/coverage-missing-entry/module/openapi/config-values.yaml new file mode 100644 index 00000000..03b0d8bf --- /dev/null +++ b/test/e2e/testdata/rbac/coverage-missing-entry/module/openapi/config-values.yaml @@ -0,0 +1,2 @@ +type: object +properties: {} diff --git a/test/e2e/testdata/rbac/coverage-missing-entry/module/openapi/values.yaml b/test/e2e/testdata/rbac/coverage-missing-entry/module/openapi/values.yaml new file mode 100644 index 00000000..47180da5 --- /dev/null +++ b/test/e2e/testdata/rbac/coverage-missing-entry/module/openapi/values.yaml @@ -0,0 +1,4 @@ +x-extend: + schema: config-values.yaml +type: object +properties: {} diff --git a/test/e2e/testdata/rbac/coverage-missing-entry/module/rbac.yaml b/test/e2e/testdata/rbac/coverage-missing-entry/module/rbac.yaml new file mode 100644 index 00000000..0f89b575 --- /dev/null +++ b/test/e2e/testdata/rbac/coverage-missing-entry/module/rbac.yaml @@ -0,0 +1,6 @@ +apiVersion: rbac.deckhouse.io/v1alpha1 +resources: + - group: e2e.deckhouse.io + resource: widgets + namespace: + viewer: [get, list, watch] diff --git a/test/e2e/testdata/rbac/coverage-todo/expected.yaml b/test/e2e/testdata/rbac/coverage-todo/expected.yaml new file mode 100644 index 00000000..d2086984 --- /dev/null +++ b/test/e2e/testdata/rbac/coverage-todo/expected.yaml @@ -0,0 +1,15 @@ +description: > + An undecided stub is an error the autofix cannot close, and a resource of a known group that no + CRD spells is a warning about a likely misspelling. +module: module +expect: + - linter: rbac + rule: coverage + level: error + textContains: 'e2e.deckhouse.io/widgets is still noAccess: "TODO" in rbac.yaml' + count: 1 + - linter: rbac + rule: coverage + level: warn + textContains: "e2e.deckhouse.io/widgts names a resource the module's CRDs of group e2e.deckhouse.io do not have" + count: 1 diff --git a/test/e2e/testdata/rbac/coverage-todo/module/crds/widgets.yaml b/test/e2e/testdata/rbac/coverage-todo/module/crds/widgets.yaml new file mode 100644 index 00000000..491afd4f --- /dev/null +++ b/test/e2e/testdata/rbac/coverage-todo/module/crds/widgets.yaml @@ -0,0 +1,9 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: widgets.e2e.deckhouse.io +spec: + group: e2e.deckhouse.io + names: {plural: widgets, kind: X} + scope: Namespaced + versions: [] diff --git a/test/e2e/testdata/rbac/coverage-todo/module/module.yaml b/test/e2e/testdata/rbac/coverage-todo/module/module.yaml new file mode 100644 index 00000000..bca5470d --- /dev/null +++ b/test/e2e/testdata/rbac/coverage-todo/module/module.yaml @@ -0,0 +1,3 @@ +name: e2e-rbac +namespace: d8-e2e-rbac +subsystems: [networking] diff --git a/test/e2e/testdata/rbac/coverage-todo/module/openapi/config-values.yaml b/test/e2e/testdata/rbac/coverage-todo/module/openapi/config-values.yaml new file mode 100644 index 00000000..03b0d8bf --- /dev/null +++ b/test/e2e/testdata/rbac/coverage-todo/module/openapi/config-values.yaml @@ -0,0 +1,2 @@ +type: object +properties: {} diff --git a/test/e2e/testdata/rbac/coverage-todo/module/openapi/values.yaml b/test/e2e/testdata/rbac/coverage-todo/module/openapi/values.yaml new file mode 100644 index 00000000..47180da5 --- /dev/null +++ b/test/e2e/testdata/rbac/coverage-todo/module/openapi/values.yaml @@ -0,0 +1,4 @@ +x-extend: + schema: config-values.yaml +type: object +properties: {} diff --git a/test/e2e/testdata/rbac/coverage-todo/module/rbac.yaml b/test/e2e/testdata/rbac/coverage-todo/module/rbac.yaml new file mode 100644 index 00000000..ade6fe6b --- /dev/null +++ b/test/e2e/testdata/rbac/coverage-todo/module/rbac.yaml @@ -0,0 +1,10 @@ +apiVersion: rbac.deckhouse.io/v1alpha1 +resources: + - group: e2e.deckhouse.io + resource: widgets + noAccess: "TODO" + - group: e2e.deckhouse.io + resource: widgts + scope: Namespaced + namespace: + viewer: [get] diff --git a/test/e2e/testdata/rbac/coverage-without-rbac-yaml/expected.yaml b/test/e2e/testdata/rbac/coverage-without-rbac-yaml/expected.yaml new file mode 100644 index 00000000..02eb2686 --- /dev/null +++ b/test/e2e/testdata/rbac/coverage-without-rbac-yaml/expected.yaml @@ -0,0 +1,7 @@ +description: > + A module without rbac.yaml gets the contract check only: coverage stays silent even though the + module ships CRDs (spec 005 R22, US-F1). +module: module +expectPass: + - linter: rbac + rule: coverage diff --git a/test/e2e/testdata/rbac/coverage-without-rbac-yaml/module/crds/widgets.yaml b/test/e2e/testdata/rbac/coverage-without-rbac-yaml/module/crds/widgets.yaml new file mode 100644 index 00000000..491afd4f --- /dev/null +++ b/test/e2e/testdata/rbac/coverage-without-rbac-yaml/module/crds/widgets.yaml @@ -0,0 +1,9 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: widgets.e2e.deckhouse.io +spec: + group: e2e.deckhouse.io + names: {plural: widgets, kind: X} + scope: Namespaced + versions: [] diff --git a/test/e2e/testdata/rbac/coverage-without-rbac-yaml/module/module.yaml b/test/e2e/testdata/rbac/coverage-without-rbac-yaml/module/module.yaml new file mode 100644 index 00000000..bca5470d --- /dev/null +++ b/test/e2e/testdata/rbac/coverage-without-rbac-yaml/module/module.yaml @@ -0,0 +1,3 @@ +name: e2e-rbac +namespace: d8-e2e-rbac +subsystems: [networking] diff --git a/test/e2e/testdata/rbac/coverage-without-rbac-yaml/module/openapi/config-values.yaml b/test/e2e/testdata/rbac/coverage-without-rbac-yaml/module/openapi/config-values.yaml new file mode 100644 index 00000000..03b0d8bf --- /dev/null +++ b/test/e2e/testdata/rbac/coverage-without-rbac-yaml/module/openapi/config-values.yaml @@ -0,0 +1,2 @@ +type: object +properties: {} diff --git a/test/e2e/testdata/rbac/coverage-without-rbac-yaml/module/openapi/values.yaml b/test/e2e/testdata/rbac/coverage-without-rbac-yaml/module/openapi/values.yaml new file mode 100644 index 00000000..47180da5 --- /dev/null +++ b/test/e2e/testdata/rbac/coverage-without-rbac-yaml/module/openapi/values.yaml @@ -0,0 +1,4 @@ +x-extend: + schema: config-values.yaml +type: object +properties: {} From 0f47992b896e6fffd4ba1f2b8295e070a651d0a1 Mon Sep 17 00:00:00 2001 From: Ivan Zvyagintsev Date: Mon, 21 Sep 2026 22:57:50 +0300 Subject: [PATCH 06/58] rbac: add the rbac.yaml generator -- an object model and the Helm templates rendered from it generate.Build derives the RBAC objects a declaration produces (namespace and system capabilities, legacy roles, ServiceAccount rights, external access) with their names, labels, localized texts and rules, following the ADR table; generate.Render writes them as Helm templates under a header the sync autofix recognizes. The model is what the sync rule compares the rendered chart against, so the comparison and the generated text can never disagree. Golden files for the cert-manager example pin the output; rendering is a pure function of the model. Signed-off-by: Ivan Zvyagintsev --- .../rbac/rules/generate/generate_test.go | 201 +++++++ pkg/linters/rbac/rules/generate/model.go | 500 ++++++++++++++++++ pkg/linters/rbac/rules/generate/render.go | 231 ++++++++ .../templates/cainjector/rbac-for-us.yaml | 115 ++++ .../expected/templates/rbac-for-us.yaml | 34 ++ .../expected/templates/rbac-to-us.yaml | 67 +++ .../templates/rbacv2/manage/edit.yaml | 34 ++ .../templates/rbacv2/manage/view.yaml | 31 ++ .../expected/templates/rbacv2/use/admin.yaml | 22 + .../expected/templates/rbacv2/use/edit.yaml | 30 ++ .../expected/templates/rbacv2/use/view.yaml | 55 ++ .../templates/user-authz-cluster-roles.yaml | 113 ++++ .../testdata/cert-manager/module.yaml | 4 + .../generate/testdata/cert-manager/rbac.yaml | 96 ++++ .../rbac/rules/rbaccontract/contract.go | 26 + 15 files changed, 1559 insertions(+) create mode 100644 pkg/linters/rbac/rules/generate/generate_test.go create mode 100644 pkg/linters/rbac/rules/generate/model.go create mode 100644 pkg/linters/rbac/rules/generate/render.go create mode 100644 pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/cainjector/rbac-for-us.yaml create mode 100644 pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/rbac-for-us.yaml create mode 100644 pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/rbac-to-us.yaml create mode 100644 pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/rbacv2/manage/edit.yaml create mode 100644 pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/rbacv2/manage/view.yaml create mode 100644 pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/rbacv2/use/admin.yaml create mode 100644 pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/rbacv2/use/edit.yaml create mode 100644 pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/rbacv2/use/view.yaml create mode 100644 pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/user-authz-cluster-roles.yaml create mode 100644 pkg/linters/rbac/rules/generate/testdata/cert-manager/module.yaml create mode 100644 pkg/linters/rbac/rules/generate/testdata/cert-manager/rbac.yaml diff --git a/pkg/linters/rbac/rules/generate/generate_test.go b/pkg/linters/rbac/rules/generate/generate_test.go new file mode 100644 index 00000000..4506502f --- /dev/null +++ b/pkg/linters/rbac/rules/generate/generate_test.go @@ -0,0 +1,201 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package generate + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/deckhouse/dmt/pkg/linters/rbac/rules/rbacyaml" +) + +// updateGolden rewrites the expected files from the current output: UPDATE_GOLDEN=1 go test ./... +// Review the diff before committing it -- the golden files are the contract of the generator. +var updateGolden = os.Getenv("UPDATE_GOLDEN") == "1" + +func certManagerInput(t *testing.T) Input { + t.Helper() + + decl, err := rbacyaml.Load("testdata/cert-manager") + require.NoError(t, err) + require.Empty(t, rbacyaml.Validate(decl, rbacyaml.CRDScopes{ + "cert-manager.io/certificates": rbacyaml.ScopeNamespaced, + "cert-manager.io/certificaterequests": rbacyaml.ScopeNamespaced, + "cert-manager.io/issuers": rbacyaml.ScopeNamespaced, + "cert-manager.io/clusterissuers": rbacyaml.ScopeCluster, + "acme.cert-manager.io/orders": rbacyaml.ScopeNamespaced, + "acme.cert-manager.io/challenges": rbacyaml.ScopeNamespaced, + })) + + return Input{Module: "cert-manager", Namespace: "d8-cert-manager", Subsystems: []string{"security"}, Decl: decl} +} + +func TestBuild_CertManagerModel(t *testing.T) { + model, err := Build(certManagerInput(t)) + require.NoError(t, err) + + assert.Equal(t, []string{ + "templates/cainjector/rbac-for-us.yaml", + "templates/rbac-for-us.yaml", + "templates/rbac-to-us.yaml", + "templates/rbacv2/manage/edit.yaml", + "templates/rbacv2/manage/view.yaml", + "templates/rbacv2/use/admin.yaml", + "templates/rbacv2/use/edit.yaml", + "templates/rbacv2/use/view.yaml", + "templates/user-authz-cluster-roles.yaml", + }, model.Paths()) + + // The system view capability always carries the ModuleConfig rule, aggregates into every + // subsystem of module.yaml and names the module namespace. + view := model.File("templates/rbacv2/manage/view.yaml").Objects[0] + assert.Equal(t, "d8:system-capability:cert-manager:view", view.Name) + assert.Equal(t, []LineageLevel{{Lineage: "security", Level: "viewer"}}, view.AggregationLabels()) + assert.Equal(t, "d8-cert-manager", view.Labels["rbac.deckhouse.io/namespace"]) + assert.Equal(t, "system-capability.cert-manager.view", view.Labels["rbac.deckhouse.io/capability"]) + require.Len(t, view.Rules, 2) + assert.Equal(t, []string{"clusterissuers"}, view.Rules[0].Resources) + assert.Equal(t, []string{"moduleconfigs"}, view.Rules[1].Resources) + assert.Equal(t, []string{"cert-manager"}, view.Rules[1].ResourceNames) + assert.Equal(t, "Module cert-manager: view configuration", view.Annotations["en.meta.deckhouse.io/title"]) + + // The admin capability takes its texts from the declaration. + admin := model.File("templates/rbacv2/use/admin.yaml").Objects[0] + assert.Equal(t, "Модуль cert-manager: администрирование", admin.Annotations["ru.meta.deckhouse.io/title"]) + assert.Equal(t, []LineageLevel{{Lineage: "namespace", Level: "admin"}}, admin.AggregationLabels()) + + // A rule under when keeps its condition; the rest of the file does not. + useView := model.File("templates/rbacv2/use/view.yaml").Objects[0] + + conditional := make([]string, 0, 1) + + for _, r := range useView.Rules { + if r.When != "" { + conditional = append(conditional, r.Resources[0]+"@"+r.When) + } + } + + assert.Equal(t, []string{"challenges@.Values.certManager.internal.acmeEnabled"}, conditional) + + // Legacy roles: one per level in enum order, kebab-case names. + legacy := model.File("templates/user-authz-cluster-roles.yaml") + + names := make([]string, 0, len(legacy.Objects)) + for _, o := range legacy.Objects { + names = append(names, o.Name+"="+o.Annotations["user-authz.deckhouse.io/access-level"]) + } + + assert.Equal(t, []string{ + "d8:user-authz:cert-manager:user=User", + "d8:user-authz:cert-manager:editor=Editor", + "d8:user-authz:cert-manager:admin=Admin", + "d8:user-authz:cert-manager:cluster-editor=ClusterEditor", + }, names) + + // The ServiceAccount file: every object under the account's condition, names as the placement + // rule expects, the foreign-namespace binding in its namespace. + sa := model.File("templates/cainjector/rbac-for-us.yaml") + + ids := make([]string, 0, len(sa.Objects)) + for _, o := range sa.Objects { + ids = append(ids, o.Identity()+"@"+o.When) + } + + assert.Equal(t, []string{ + "d8-cert-manager/ServiceAccount/cainjector@.Values.certManager.internal.enableCAInjector", + "ClusterRole/d8:cert-manager:cainjector@.Values.certManager.internal.enableCAInjector", + "ClusterRoleBinding/d8:cert-manager:cainjector@.Values.certManager.internal.enableCAInjector", + "d8-cert-manager/Role/cainjector@.Values.certManager.internal.enableCAInjector", + "d8-cert-manager/RoleBinding/cainjector@.Values.certManager.internal.enableCAInjector", + "ClusterRoleBinding/d8:cert-manager:cainjector:rbac-proxy@.Values.certManager.internal.enableCAInjector", + "kube-system/RoleBinding/d8:cert-manager:cainjector:extension-apiserver-authentication-reader@.Values.certManager.internal.enableCAInjector", + }, ids) + + // Access: cluster rules land in rbac-for-us.yaml, namespace rules and the metrics access in rbac-to-us.yaml. + forUs := make([]string, 0, 2) + for _, o := range model.File("templates/rbac-for-us.yaml").Objects { + forUs = append(forUs, o.Identity()) + } + + toUs := make([]string, 0, 4) + for _, o := range model.File("templates/rbac-to-us.yaml").Objects { + toUs = append(toUs, o.Identity()) + } + + assert.Equal(t, []string{"ClusterRole/d8:cert-manager:admin-kubeconfig", "ClusterRoleBinding/d8:cert-manager:admin-kubeconfig"}, forUs) + assert.Equal(t, []string{ + "d8-cert-manager/Role/access-to-cert-manager", "d8-cert-manager/RoleBinding/access-to-cert-manager", + "d8-cert-manager/Role/access-to-cert-manager-auth", "d8-cert-manager/RoleBinding/access-to-cert-manager-auth", + }, toUs) +} + +func TestBuild_SubsystemsOverrideAndNamespaceLabel(t *testing.T) { + in := certManagerInput(t) + in.Decl.Subsystems = []string{"networking", "kubernetes"} + in.Namespace = "default" + in.Decl.Normalize() + + model, err := Build(in) + require.NoError(t, err) + + edit := model.File("templates/rbacv2/manage/edit.yaml").Objects[0] + assert.Equal(t, []LineageLevel{{Lineage: "kubernetes", Level: "manager"}, {Lineage: "networking", Level: "manager"}}, edit.AggregationLabels()) + _, hasNamespaceLabel := edit.Labels["rbac.deckhouse.io/namespace"] + assert.False(t, hasNamespaceLabel, "the namespace label is set only for a d8- namespace") + assert.Equal(t, []string{"create", "delete", "patch", "update"}, edit.Rules[len(edit.Rules)-1].Verbs, "the edit ModuleConfig rule has no read verbs") +} + +func TestRender_GoldenAndIdempotent(t *testing.T) { + model, err := Build(certManagerInput(t)) + require.NoError(t, err) + + first := Render(model) + second := Render(model) + assert.Equal(t, first, second, "rendering is a pure function of the model") + + for _, f := range first { + golden := filepath.Join("testdata", "cert-manager", "expected", f.Path) + + if updateGolden { + require.NoError(t, os.MkdirAll(filepath.Dir(golden), 0o755)) + require.NoError(t, os.WriteFile(golden, []byte(f.Content), 0o600)) + } + + want, err := os.ReadFile(golden) + require.NoError(t, err, "missing golden file %s (run with UPDATE_GOLDEN=1)", golden) + assert.Equal(t, string(want), f.Content, "generated %s differs from the golden file", f.Path) + + generated, version := ParseHeader(f.Content) + assert.True(t, generated) + assert.Equal(t, "1", version) + } +} + +func TestParseHeader(t *testing.T) { + assert.True(t, func() bool { g, _ := ParseHeader(Header() + "\n---\n"); return g }()) + + generated, version := ParseHeader("# Generated by dmt (rbac/sync) from rbac.yaml, contract 0. Edit rbac.yaml and run \"dmt lint --linter rbac --fix\", or remove this line to maintain the file by hand.\n") + assert.True(t, generated) + assert.Equal(t, "0", version) + + generated, _ = ParseHeader("---\napiVersion: v1\n") + assert.False(t, generated, "a file without the header is maintained by hand") +} diff --git a/pkg/linters/rbac/rules/generate/model.go b/pkg/linters/rbac/rules/generate/model.go new file mode 100644 index 00000000..03565fe7 --- /dev/null +++ b/pkg/linters/rbac/rules/generate/model.go @@ -0,0 +1,500 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package generate turns a module RBAC declaration (rbac.yaml) into the RBAC objects the module +// ships and into the Helm templates that produce them. The model is built first and the text is +// rendered from it, so that the sync rule compares the rendered chart against the very objects the +// generator would write. +// +// What is produced follows the ADR "Единый rbac.yaml модуля", "Что генерируется": one file per +// capability under templates/rbacv2/{use,manage}/, the legacy roles in +// templates/user-authz-cluster-roles.yaml, the ServiceAccount rights in templates/[/]rbac-for-us.yaml +// and the external access in templates/rbac-to-us.yaml. +package generate + +import ( + "fmt" + "sort" + "strings" + + "github.com/deckhouse/dmt/pkg/linters/rbac/rules/rbaccontract" + "github.com/deckhouse/dmt/pkg/linters/rbac/rules/rbacyaml" +) + +// Input is everything the generator needs besides the declaration: the module identity from +// module.yaml. Subsystems are module.yaml's unless the declaration overrides them. +type Input struct { + Module string + Namespace string + Subsystems []string + Decl *rbacyaml.Declaration +} + +// Class is one of the three classes of objects the sync rule owns (ADR, "Область ответственности sync"). +type Class string + +const ( + // ClassLegacy is a legacy (user-authz v1) ClusterRole, recognized by its access-level annotation. + ClassLegacy Class = "legacy" + // ClassCapability is an RBACv2 capability of the module, recognized by kind: capability and the + // module label. + ClassCapability Class = "capability" + // ClassDeclared is an object whose name the generator builds from serviceAccounts, access and + // prometheusAccess; it is owned only when declared. + ClassDeclared Class = "declared" +) + +// Rule is one PolicyRule of an object, with the condition it is rendered under ("" for always). +type Rule struct { + rbacyaml.PolicyRule + When string +} + +// Subject is an RBAC subject of a binding. +type Subject struct { + Kind string + Name string + Namespace string +} + +// Object is one RBAC object the generator produces. +type Object struct { + Kind string + Name string + Namespace string + Class Class + + // When is the condition the whole object is rendered under ("" for always). + When string + + // Labels are the labels the generator sets besides the module labels helm_lib_module_labels adds + // (heritage, module). For capabilities these carry the contract: kind, scope, marker, aggregation. + Labels map[string]string + // Annotations are the localized texts of a capability or the access level of a legacy role. + Annotations map[string]string + + Rules []Rule + + // Binding fields. + RoleRefKind string + RoleRefName string + Subjects []Subject + + // ServiceAccount fields. + AutomountToken *bool +} + +// Identity returns Kind/Name or Namespace/Kind/Name, matching storage.ResourceIndex.AsString. +func (o Object) Identity() string { + if o.Namespace == "" { + return o.Kind + "/" + o.Name + } + + return o.Namespace + "/" + o.Kind + "/" + o.Name +} + +// AggregationLabels returns the (lineage, level) pairs of a capability, sorted by lineage. +func (o Object) AggregationLabels() []LineageLevel { + out := make([]LineageLevel, 0, len(o.Labels)) + + for key, value := range o.Labels { + if strings.HasPrefix(key, rbaccontract.AggregationLabelPrefix) && strings.HasSuffix(key, rbaccontract.AggregationLabelSuffix) { + out = append(out, LineageLevel{ + Lineage: strings.TrimSuffix(strings.TrimPrefix(key, rbaccontract.AggregationLabelPrefix), rbaccontract.AggregationLabelSuffix), + Level: value, + }) + } + } + + sort.Slice(out, func(i, j int) bool { return out[i].Lineage < out[j].Lineage }) + + return out +} + +// LineageLevel is one aggregation edge of a capability. +type LineageLevel struct { + Lineage string + Level string +} + +// File is one generated template with its objects in order. +type File struct { + // Path is relative to the module root. + Path string + Objects []Object +} + +// Model is the full set of generated files, sorted by path. +type Model struct { + Files []File +} + +// File returns the file at path, or nil. +func (m *Model) File(path string) *File { + for i := range m.Files { + if m.Files[i].Path == path { + return &m.Files[i] + } + } + + return nil +} + +// Paths returns the generated paths in order. +func (m *Model) Paths() []string { + out := make([]string, 0, len(m.Files)) + for _, f := range m.Files { + out = append(out, f.Path) + } + + return out +} + +// Build derives the object model from the declaration. The declaration must have passed +// rbacyaml.Validate: Build trusts it. +func Build(in Input) (*Model, error) { + if in.Decl == nil { + return nil, fmt.Errorf("no declaration") + } + + if in.Module == "" { + return nil, fmt.Errorf("the module name is required") + } + + b := &builder{in: in, files: map[string]*File{}} + + b.capabilities() + b.legacyRoles() + b.serviceAccounts() + b.access() + + model := &Model{Files: make([]File, 0, len(b.files))} + for _, f := range b.files { + model.Files = append(model.Files, *f) + } + + sort.Slice(model.Files, func(i, j int) bool { return model.Files[i].Path < model.Files[j].Path }) + + return model, nil +} + +type builder struct { + in Input + files map[string]*File +} + +func (b *builder) add(path string, obj Object) { + f, ok := b.files[path] + if !ok { + f = &File{Path: path} + b.files[path] = f + } + + f.Objects = append(f.Objects, obj) +} + +func (b *builder) subsystems() []string { + if len(b.in.Decl.Subsystems) > 0 { + return b.in.Decl.Subsystems + } + + out := append([]string(nil), b.in.Subsystems...) + sort.Strings(out) + + return out +} + +// capabilities produces one namespace capability per namespace level in use and the system +// capabilities: view and edit always (every module gets access to its own ModuleConfig), the +// other levels when a resource names them. +func (b *builder) capabilities() { + namespaceLevels := map[string][]Rule{} + systemLevels := map[string][]Rule{} + + for _, res := range b.in.Decl.Resources { + for level, verbs := range res.Namespace { + namespaceLevels[level] = append(namespaceLevels[level], resourceRule(res, verbs)) + } + + for level, verbs := range res.System { + systemLevels[level] = append(systemLevels[level], resourceRule(res, verbs)) + } + } + + for _, level := range rbaccontract.NamespaceLevels { + rules, ok := namespaceLevels[level] + if !ok { + continue + } + + action := rbaccontract.CapabilityAction(level) + b.add("templates/rbacv2/use/"+action+".yaml", Object{ + Kind: "ClusterRole", + Name: rbaccontract.NamespaceCapabilityPrefix + b.in.Module + ":" + action, + Class: ClassCapability, + Labels: map[string]string{ + rbaccontract.LabelKind: rbaccontract.KindCapability, + rbaccontract.LabelScope: rbaccontract.LineageNamespace, + rbaccontract.LabelCapability: rbaccontract.LineageNamespace + "-capability." + b.in.Module + "." + action, + rbaccontract.AggregationLabelPrefix + rbaccontract.LineageNamespace + rbaccontract.AggregationLabelSuffix: level, + }, + Annotations: b.texts(rbaccontract.LineageNamespace, action), + Rules: sortRules(rules), + }) + } + + for _, level := range rbaccontract.SystemLevels { + action := rbaccontract.CapabilityAction(level) + rules := systemLevels[level] + + switch action { + case "view": + rules = append(rules, moduleConfigRule(b.in.Module, []string{"get", "list", "watch"})) + case "edit": + rules = append(rules, moduleConfigRule(b.in.Module, []string{"create", "update", "patch", "delete"})) + default: + if len(rules) == 0 { + continue + } + } + + labels := map[string]string{ + rbaccontract.LabelKind: rbaccontract.KindCapability, + rbaccontract.LabelScope: rbaccontract.LineageSystem, + rbaccontract.LabelCapability: rbaccontract.LineageSystem + "-capability." + b.in.Module + "." + action, + } + + for _, subsystem := range b.subsystems() { + labels[rbaccontract.AggregationLabelPrefix+subsystem+rbaccontract.AggregationLabelSuffix] = level + } + + if strings.HasPrefix(b.in.Namespace, "d8-") { + labels[rbaccontract.LabelNamespace] = b.in.Namespace + } + + b.add("templates/rbacv2/manage/"+action+".yaml", Object{ + Kind: "ClusterRole", + Name: rbaccontract.SystemCapabilityPrefix + b.in.Module + ":" + action, + Class: ClassCapability, + Labels: labels, + Annotations: b.texts(rbaccontract.LineageSystem, action), + Rules: sortRules(rules), + }) + } +} + +// texts returns the four localized annotations of a capability: the platform convention for +// view/edit, the declaration's capabilities entry otherwise (Validate made sure it exists). +func (b *builder) texts(lineage, action string) map[string]string { + var title, description rbaccontract.Text + + if conventional, ok := rbaccontract.ConventionalTexts[lineage+"."+action]; ok { + title = rbaccontract.Text{EN: fmt.Sprintf(conventional.Title.EN, b.in.Module), RU: fmt.Sprintf(conventional.Title.RU, b.in.Module)} + description = rbaccontract.Text{EN: fmt.Sprintf(conventional.Description.EN, b.in.Module), RU: fmt.Sprintf(conventional.Description.RU, b.in.Module)} + } else if custom, ok := b.in.Decl.Capabilities[lineage+"."+action]; ok { + title = rbaccontract.Text{EN: custom.Title.EN, RU: custom.Title.RU} + description = rbaccontract.Text{EN: custom.Description.EN, RU: custom.Description.RU} + } + + return map[string]string{ + rbaccontract.AnnotationTitleEN: title.EN, + rbaccontract.AnnotationTitleRU: title.RU, + rbaccontract.AnnotationDescriptionEN: description.EN, + rbaccontract.AnnotationDescriptionRU: description.RU, + } +} + +// legacyRoles produces one legacy ClusterRole per access level in use, in the enum order. +func (b *builder) legacyRoles() { + byLevel := map[string][]Rule{} + + for _, res := range b.in.Decl.Resources { + for level, verbs := range res.Legacy { + byLevel[level] = append(byLevel[level], resourceRule(res, verbs)) + } + } + + for _, level := range rbaccontract.LegacyLevels { + rules, ok := byLevel[level] + if !ok { + continue + } + + b.add("templates/user-authz-cluster-roles.yaml", Object{ + Kind: "ClusterRole", + Name: rbaccontract.LegacyRolePrefix + b.in.Module + ":" + rbaccontract.LegacyKebab(level), + Class: ClassLegacy, + Annotations: map[string]string{rbaccontract.AccessLevelAnnotation: level}, + Rules: sortRules(rules), + }) + } +} + +// serviceAccounts produces the ServiceAccount, its ClusterRole/ClusterRoleBinding, Role/RoleBinding +// and the extra bindings, into templates/[/]rbac-for-us.yaml. +func (b *builder) serviceAccounts() { + accounts := append([]rbacyaml.ServiceAccount(nil), b.in.Decl.ServiceAccounts...) + sort.Slice(accounts, func(i, j int) bool { return accounts[i].Name < accounts[j].Name }) + + for _, sa := range accounts { + path := "templates/rbac-for-us.yaml" + if sa.Path != "" { + path = "templates/" + sa.Path + "/rbac-for-us.yaml" + } + + labels := map[string]string{} + for k, v := range sa.Labels { + labels[k] = v + } + + automount := false + b.add(path, Object{ + Kind: "ServiceAccount", Name: sa.Name, Namespace: b.in.Namespace, Class: ClassDeclared, + When: sa.When, Labels: labels, AutomountToken: &automount, + }) + + subject := []Subject{{Kind: "ServiceAccount", Name: sa.Name, Namespace: b.in.Namespace}} + clusterName := "d8:" + b.in.Module + ":" + sa.Name + + if len(sa.ClusterRules) > 0 { + b.add(path, Object{Kind: "ClusterRole", Name: clusterName, Class: ClassDeclared, When: sa.When, Labels: labels, Rules: policyRules(sa.ClusterRules)}) + b.add(path, Object{Kind: "ClusterRoleBinding", Name: clusterName, Class: ClassDeclared, When: sa.When, Labels: labels, RoleRefKind: "ClusterRole", RoleRefName: clusterName, Subjects: subject}) + } + + if len(sa.NamespaceRules) > 0 { + b.add(path, Object{Kind: "Role", Name: sa.Name, Namespace: b.in.Namespace, Class: ClassDeclared, When: sa.When, Labels: labels, Rules: policyRules(sa.NamespaceRules)}) + b.add(path, Object{Kind: "RoleBinding", Name: sa.Name, Namespace: b.in.Namespace, Class: ClassDeclared, When: sa.When, Labels: labels, RoleRefKind: "Role", RoleRefName: sa.Name, Subjects: subject}) + } + + for _, bound := range sa.BindClusterRoles { + b.add(path, Object{ + Kind: "ClusterRoleBinding", Name: clusterName + ":" + bindingSuffix(bound), Class: ClassDeclared, When: sa.When, Labels: labels, + RoleRefKind: "ClusterRole", RoleRefName: bound, Subjects: subject, + }) + } + + for _, ref := range sa.BindRoles { + b.add(path, Object{ + Kind: "RoleBinding", Name: clusterName + ":" + bindingSuffix(ref.Name), Namespace: ref.Namespace, Class: ClassDeclared, When: sa.When, Labels: labels, + RoleRefKind: "Role", RoleRefName: ref.Name, Subjects: subject, + }) + } + } +} + +// bindingSuffix names the binding to an existing role after that role: d8:rbac-proxy -> rbac-proxy, +// extension-apiserver-authentication-reader stays as it is. +func bindingSuffix(roleName string) string { + return strings.ReplaceAll(strings.TrimPrefix(roleName, "d8:"), ":", "-") +} + +// access produces the Prometheus access and the arbitrary-subject grants: cluster rules go to +// templates/rbac-for-us.yaml (the placement rule keeps ClusterRoles there), namespace rules and the +// metrics access to templates/rbac-to-us.yaml. +func (b *builder) access() { + if pa := b.in.Decl.PrometheusAccess; pa != nil { + var rules []Rule + + for kind, names := range map[string][]string{"deployments": pa.Deployments, "daemonsets": pa.DaemonSets, "statefulsets": pa.StatefulSets} { + if len(names) == 0 { + continue + } + + sorted := append([]string(nil), names...) + sort.Strings(sorted) + + rules = append(rules, Rule{PolicyRule: rbacyaml.PolicyRule{ + APIGroups: []string{"apps"}, Resources: []string{kind + "/prometheus-metrics"}, ResourceNames: sorted, Verbs: []string{"get"}, + }}) + } + + rules = sortRules(rules) + name := "access-to-" + b.in.Module + + b.add("templates/rbac-to-us.yaml", Object{Kind: "Role", Name: name, Namespace: b.in.Namespace, Class: ClassDeclared, Rules: rules}) + b.add("templates/rbac-to-us.yaml", Object{ + Kind: "RoleBinding", Name: name, Namespace: b.in.Namespace, Class: ClassDeclared, RoleRefKind: "Role", RoleRefName: name, + Subjects: []Subject{{Kind: "User", Name: "d8-monitoring:scraper"}, {Kind: "ServiceAccount", Name: "prometheus", Namespace: "d8-monitoring"}}, + }) + } + + grants := append([]rbacyaml.Access(nil), b.in.Decl.Access...) + sort.Slice(grants, func(i, j int) bool { return grants[i].Name < grants[j].Name }) + + for _, a := range grants { + subjects := make([]Subject, 0, len(a.Subjects)) + for _, s := range a.Subjects { + subjects = append(subjects, Subject{Kind: s.Kind, Name: s.Name, Namespace: s.Namespace}) + } + + if len(a.ClusterRules) > 0 { + name := "d8:" + b.in.Module + ":" + a.Name + b.add("templates/rbac-for-us.yaml", Object{Kind: "ClusterRole", Name: name, Class: ClassDeclared, Rules: policyRules(a.ClusterRules)}) + b.add("templates/rbac-for-us.yaml", Object{Kind: "ClusterRoleBinding", Name: name, Class: ClassDeclared, RoleRefKind: "ClusterRole", RoleRefName: name, Subjects: subjects}) + } + + if len(a.NamespaceRules) > 0 { + name := "access-to-" + b.in.Module + "-" + a.Name + b.add("templates/rbac-to-us.yaml", Object{Kind: "Role", Name: name, Namespace: b.in.Namespace, Class: ClassDeclared, Rules: policyRules(a.NamespaceRules)}) + b.add("templates/rbac-to-us.yaml", Object{Kind: "RoleBinding", Name: name, Namespace: b.in.Namespace, Class: ClassDeclared, RoleRefKind: "Role", RoleRefName: name, Subjects: subjects}) + } + } +} + +func resourceRule(res rbacyaml.Resource, verbs []string) Rule { + sorted := append([]string(nil), verbs...) + sort.Strings(sorted) + + return Rule{PolicyRule: rbacyaml.PolicyRule{APIGroups: []string{res.Group}, Resources: []string{res.Resource}, Verbs: sorted}, When: res.When} +} + +func moduleConfigRule(module string, verbs []string) Rule { + sorted := append([]string(nil), verbs...) + sort.Strings(sorted) + + return Rule{PolicyRule: rbacyaml.PolicyRule{APIGroups: []string{"deckhouse.io"}, Resources: []string{"moduleconfigs"}, ResourceNames: []string{module}, Verbs: sorted}} +} + +// policyRules copies raw rules as they are; their condition is the object's, not their own. +func policyRules(rules []rbacyaml.PolicyRule) []Rule { + out := make([]Rule, 0, len(rules)) + for _, r := range rules { + out = append(out, Rule{PolicyRule: r}) + } + + return out +} + +// sortRules orders rules by group, then resource: the order of a generated file never depends on the +// order of the declaration. The ModuleConfig rule sorts with the rest (deckhouse.io). +func sortRules(rules []Rule) []Rule { + out := append([]Rule(nil), rules...) + sort.SliceStable(out, func(i, j int) bool { + gi, gj := strings.Join(out[i].APIGroups, ","), strings.Join(out[j].APIGroups, ",") + if gi != gj { + return gi < gj + } + + ri, rj := strings.Join(out[i].Resources, ","), strings.Join(out[j].Resources, ",") + if ri != rj { + return ri < rj + } + + return strings.Join(out[i].NonResourceURLs, ",") < strings.Join(out[j].NonResourceURLs, ",") + }) + + return out +} diff --git a/pkg/linters/rbac/rules/generate/render.go b/pkg/linters/rbac/rules/generate/render.go new file mode 100644 index 00000000..7f583e94 --- /dev/null +++ b/pkg/linters/rbac/rules/generate/render.go @@ -0,0 +1,231 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package generate + +import ( + "sort" + "strconv" + "strings" + + "github.com/deckhouse/dmt/pkg/linters/rbac/rules/rbaccontract" +) + +const ( + headerPrefix = "# Generated by dmt (rbac/sync) from rbac.yaml, contract " + headerSuffix = `. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand.` +) + +// Header is the first line of every generated file. The sync autofix rewrites a file only when it +// starts with a header: a file without one is maintained by hand and is left alone. +func Header() string { + return headerPrefix + rbaccontract.ContractVersion + headerSuffix +} + +// ParseHeader reports whether the content starts with a generator header and, if so, the contract +// version it names. A header of another version means the file was generated under an older (or +// newer) contract. +func ParseHeader(content string) (bool, string) { + line, _, _ := strings.Cut(content, "\n") + if !strings.HasPrefix(line, headerPrefix) || !strings.HasSuffix(line, headerSuffix) { + return false, "" + } + + return true, strings.TrimSuffix(strings.TrimPrefix(line, headerPrefix), headerSuffix) +} + +// Rendered is the text of one generated file. +type Rendered struct { + Path string + Content string +} + +// Render writes every file of the model as a Helm template. The output is a pure function of the +// model: the same declaration always renders the same bytes. +func Render(m *Model) []Rendered { + out := make([]Rendered, 0, len(m.Files)) + for _, f := range m.Files { + out = append(out, Rendered{Path: f.Path, Content: RenderFile(f)}) + } + + return out +} + +// RenderFile renders one file: the header, then each object as its own YAML document, wrapped in +// {{- if }} when the object is conditional. +func RenderFile(f File) string { + var b strings.Builder + + b.WriteString(Header()) + b.WriteByte('\n') + + // Consecutive objects under the same condition share one {{- if }} block. + open := "" + + for _, o := range f.Objects { + if o.When != open { + if open != "" { + b.WriteString("{{- end }}\n") + } + + if o.When != "" { + b.WriteString("{{- if " + o.When + " }}\n") + } + + open = o.When + } + + b.WriteString("---\n") + renderObject(&b, o) + } + + if open != "" { + b.WriteString("{{- end }}\n") + } + + return b.String() +} + +func renderObject(b *strings.Builder, o Object) { + apiVersion := "rbac.authorization.k8s.io/v1" + if o.Kind == "ServiceAccount" { + apiVersion = "v1" + } + + b.WriteString("apiVersion: " + apiVersion + "\n") + b.WriteString("kind: " + o.Kind + "\n") + b.WriteString("metadata:\n") + b.WriteString(" name: " + o.Name + "\n") + + if o.Namespace != "" { + b.WriteString(" namespace: " + o.Namespace + "\n") + } + + b.WriteString(" " + labelsInclude(o.Labels) + "\n") + + if len(o.Annotations) > 0 { + b.WriteString(" annotations:\n") + + for _, key := range sortedKeys(o.Annotations) { + b.WriteString(" " + key + ": " + strconv.Quote(o.Annotations[key]) + "\n") + } + } + + switch o.Kind { + case "ServiceAccount": + if o.AutomountToken != nil { + b.WriteString("automountServiceAccountToken: " + strconv.FormatBool(*o.AutomountToken) + "\n") + } + case "ClusterRole", "Role": + renderRules(b, o.Rules) + case "ClusterRoleBinding", "RoleBinding": + b.WriteString("roleRef:\n apiGroup: rbac.authorization.k8s.io\n kind: " + o.RoleRefKind + "\n name: " + o.RoleRefName + "\n") + b.WriteString("subjects:\n") + + for _, s := range o.Subjects { + switch s.Kind { + case "ServiceAccount": + b.WriteString("- kind: ServiceAccount\n name: " + s.Name + "\n namespace: " + s.Namespace + "\n") + default: + b.WriteString("- apiGroup: rbac.authorization.k8s.io\n kind: " + s.Kind + "\n name: " + s.Name + "\n") + } + } + } +} + +// labelsInclude renders the helm_lib_module_labels call that adds the module labels (heritage, +// module) and the labels the generator sets, keys sorted. +func labelsInclude(labels map[string]string) string { + if len(labels) == 0 { + return `{{- include "helm_lib_module_labels" (list .) | nindent 2 }}` + } + + pairs := make([]string, 0, len(labels)) + for _, key := range sortedKeys(labels) { + pairs = append(pairs, strconv.Quote(key)+" "+strconv.Quote(labels[key])) + } + + return `{{- include "helm_lib_module_labels" (list . (dict ` + strings.Join(pairs, " ") + `)) | nindent 2 }}` +} + +func renderRules(b *strings.Builder, rules []Rule) { + if len(rules) == 0 { + b.WriteString("rules: []\n") + return + } + + b.WriteString("rules:\n") + + for _, r := range rules { + if r.When != "" { + b.WriteString("{{- if " + r.When + " }}\n") + } + + first := true + item := func(key string, values []string) { + if len(values) == 0 { + return + } + + indent := " " + if first { + indent = "- " + first = false + } + + b.WriteString(indent + key + ":\n") + + for _, v := range values { + b.WriteString(" - " + yamlScalar(v) + "\n") + } + } + + if len(r.NonResourceURLs) > 0 { + item("nonResourceURLs", r.NonResourceURLs) + } else { + item("apiGroups", r.APIGroups) + item("resources", r.Resources) + item("resourceNames", r.ResourceNames) + } + + item("verbs", r.Verbs) + + if r.When != "" { + b.WriteString("{{- end }}\n") + } + } +} + +// yamlScalar quotes the values YAML would otherwise misread: the empty core API group, the +// wildcard, anything starting with an indicator character, and anything with a ": " or " #" inside. +func yamlScalar(v string) string { + if v == "" || strings.ContainsAny(v[:1], "-?:,[]{}#&*!|>'\"%@`") || strings.Contains(v, ": ") || strings.Contains(v, " #") { + return strconv.Quote(v) + } + + return v +} + +func sortedKeys(m map[string]string) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + + sort.Strings(keys) + + return keys +} diff --git a/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/cainjector/rbac-for-us.yaml b/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/cainjector/rbac-for-us.yaml new file mode 100644 index 00000000..6cf3648d --- /dev/null +++ b/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/cainjector/rbac-for-us.yaml @@ -0,0 +1,115 @@ +# Generated by dmt (rbac/sync) from rbac.yaml, contract 1. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +{{- if .Values.certManager.internal.enableCAInjector }} +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: cainjector + namespace: d8-cert-manager + {{- include "helm_lib_module_labels" (list . (dict "app" "cainjector")) | nindent 2 }} +automountServiceAccountToken: false +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: d8:cert-manager:cainjector + {{- include "helm_lib_module_labels" (list . (dict "app" "cainjector")) | nindent 2 }} +rules: +- apiGroups: + - cert-manager.io + resources: + - certificates + verbs: + - get + - list + - watch +- apiGroups: + - "" + resources: + - secrets + verbs: + - get + - list + - watch +- nonResourceURLs: + - /metrics + verbs: + - get +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: d8:cert-manager:cainjector + {{- include "helm_lib_module_labels" (list . (dict "app" "cainjector")) | nindent 2 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: d8:cert-manager:cainjector +subjects: +- kind: ServiceAccount + name: cainjector + namespace: d8-cert-manager +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: cainjector + namespace: d8-cert-manager + {{- include "helm_lib_module_labels" (list . (dict "app" "cainjector")) | nindent 2 }} +rules: +- apiGroups: + - coordination.k8s.io + resources: + - leases + verbs: + - get + - list + - watch + - create + - update + - patch +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: cainjector + namespace: d8-cert-manager + {{- include "helm_lib_module_labels" (list . (dict "app" "cainjector")) | nindent 2 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: cainjector +subjects: +- kind: ServiceAccount + name: cainjector + namespace: d8-cert-manager +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: d8:cert-manager:cainjector:rbac-proxy + {{- include "helm_lib_module_labels" (list . (dict "app" "cainjector")) | nindent 2 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: d8:rbac-proxy +subjects: +- kind: ServiceAccount + name: cainjector + namespace: d8-cert-manager +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: d8:cert-manager:cainjector:extension-apiserver-authentication-reader + namespace: kube-system + {{- include "helm_lib_module_labels" (list . (dict "app" "cainjector")) | nindent 2 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: extension-apiserver-authentication-reader +subjects: +- kind: ServiceAccount + name: cainjector + namespace: d8-cert-manager +{{- end }} diff --git a/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/rbac-for-us.yaml b/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/rbac-for-us.yaml new file mode 100644 index 00000000..9b7a734a --- /dev/null +++ b/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/rbac-for-us.yaml @@ -0,0 +1,34 @@ +# Generated by dmt (rbac/sync) from rbac.yaml, contract 1. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: d8:cert-manager:admin-kubeconfig + {{- include "helm_lib_module_labels" (list .) | nindent 2 }} +rules: +- apiGroups: + - cert-manager.io + resources: + - clusterissuers + verbs: + - get + - list + - watch + - create + - update + - patch + - delete +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: d8:cert-manager:admin-kubeconfig + {{- include "helm_lib_module_labels" (list .) | nindent 2 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: d8:cert-manager:admin-kubeconfig +subjects: +- apiGroup: rbac.authorization.k8s.io + kind: Group + name: kubeadm:cluster-admins diff --git a/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/rbac-to-us.yaml b/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/rbac-to-us.yaml new file mode 100644 index 00000000..5f4dce11 --- /dev/null +++ b/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/rbac-to-us.yaml @@ -0,0 +1,67 @@ +# Generated by dmt (rbac/sync) from rbac.yaml, contract 1. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: access-to-cert-manager + namespace: d8-cert-manager + {{- include "helm_lib_module_labels" (list .) | nindent 2 }} +rules: +- apiGroups: + - apps + resources: + - deployments/prometheus-metrics + resourceNames: + - cainjector + - cert-manager + verbs: + - get +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: access-to-cert-manager + namespace: d8-cert-manager + {{- include "helm_lib_module_labels" (list .) | nindent 2 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: access-to-cert-manager +subjects: +- apiGroup: rbac.authorization.k8s.io + kind: User + name: d8-monitoring:scraper +- kind: ServiceAccount + name: prometheus + namespace: d8-monitoring +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: access-to-cert-manager-auth + namespace: d8-cert-manager + {{- include "helm_lib_module_labels" (list .) | nindent 2 }} +rules: +- apiGroups: + - apps + resources: + - deployments/http + resourceNames: + - cert-manager + verbs: + - get +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: access-to-cert-manager-auth + namespace: d8-cert-manager + {{- include "helm_lib_module_labels" (list .) | nindent 2 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: access-to-cert-manager-auth +subjects: +- kind: ServiceAccount + name: ingress-nginx + namespace: d8-ingress-nginx diff --git a/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/rbacv2/manage/edit.yaml b/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/rbacv2/manage/edit.yaml new file mode 100644 index 00000000..a490c211 --- /dev/null +++ b/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/rbacv2/manage/edit.yaml @@ -0,0 +1,34 @@ +# Generated by dmt (rbac/sync) from rbac.yaml, contract 1. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: d8:system-capability:cert-manager:edit + {{- include "helm_lib_module_labels" (list . (dict "rbac.deckhouse.io/aggregate-to-security-as" "manager" "rbac.deckhouse.io/capability" "system-capability.cert-manager.edit" "rbac.deckhouse.io/kind" "capability" "rbac.deckhouse.io/namespace" "d8-cert-manager" "rbac.deckhouse.io/scope" "system")) | nindent 2 }} + annotations: + en.meta.deckhouse.io/description: "Manage the cert-manager module configuration." + en.meta.deckhouse.io/title: "Module cert-manager: edit configuration" + ru.meta.deckhouse.io/description: "Управление конфигурацией модуля cert-manager." + ru.meta.deckhouse.io/title: "Модуль cert-manager: управление конфигурацией" +rules: +- apiGroups: + - cert-manager.io + resources: + - clusterissuers + verbs: + - create + - delete + - deletecollection + - patch + - update +- apiGroups: + - deckhouse.io + resources: + - moduleconfigs + resourceNames: + - cert-manager + verbs: + - create + - delete + - patch + - update diff --git a/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/rbacv2/manage/view.yaml b/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/rbacv2/manage/view.yaml new file mode 100644 index 00000000..7013011b --- /dev/null +++ b/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/rbacv2/manage/view.yaml @@ -0,0 +1,31 @@ +# Generated by dmt (rbac/sync) from rbac.yaml, contract 1. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: d8:system-capability:cert-manager:view + {{- include "helm_lib_module_labels" (list . (dict "rbac.deckhouse.io/aggregate-to-security-as" "viewer" "rbac.deckhouse.io/capability" "system-capability.cert-manager.view" "rbac.deckhouse.io/kind" "capability" "rbac.deckhouse.io/namespace" "d8-cert-manager" "rbac.deckhouse.io/scope" "system")) | nindent 2 }} + annotations: + en.meta.deckhouse.io/description: "Read-only access to the cert-manager module configuration." + en.meta.deckhouse.io/title: "Module cert-manager: view configuration" + ru.meta.deckhouse.io/description: "Доступ только на чтение к конфигурации модуля cert-manager." + ru.meta.deckhouse.io/title: "Модуль cert-manager: просмотр конфигурации" +rules: +- apiGroups: + - cert-manager.io + resources: + - clusterissuers + verbs: + - get + - list + - watch +- apiGroups: + - deckhouse.io + resources: + - moduleconfigs + resourceNames: + - cert-manager + verbs: + - get + - list + - watch diff --git a/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/rbacv2/use/admin.yaml b/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/rbacv2/use/admin.yaml new file mode 100644 index 00000000..31792698 --- /dev/null +++ b/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/rbacv2/use/admin.yaml @@ -0,0 +1,22 @@ +# Generated by dmt (rbac/sync) from rbac.yaml, contract 1. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: d8:namespace-capability:cert-manager:admin + {{- include "helm_lib_module_labels" (list . (dict "rbac.deckhouse.io/aggregate-to-namespace-as" "admin" "rbac.deckhouse.io/capability" "namespace-capability.cert-manager.admin" "rbac.deckhouse.io/kind" "capability" "rbac.deckhouse.io/scope" "namespace")) | nindent 2 }} + annotations: + en.meta.deckhouse.io/description: "Manage cert-manager Issuers in a namespace." + en.meta.deckhouse.io/title: "Module cert-manager: admin" + ru.meta.deckhouse.io/description: "Управление Issuer модуля cert-manager в пространстве имён." + ru.meta.deckhouse.io/title: "Модуль cert-manager: администрирование" +rules: +- apiGroups: + - cert-manager.io + resources: + - issuers + verbs: + - create + - delete + - patch + - update diff --git a/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/rbacv2/use/edit.yaml b/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/rbacv2/use/edit.yaml new file mode 100644 index 00000000..22d5ecb6 --- /dev/null +++ b/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/rbacv2/use/edit.yaml @@ -0,0 +1,30 @@ +# Generated by dmt (rbac/sync) from rbac.yaml, contract 1. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: d8:namespace-capability:cert-manager:edit + {{- include "helm_lib_module_labels" (list . (dict "rbac.deckhouse.io/aggregate-to-namespace-as" "manager" "rbac.deckhouse.io/capability" "namespace-capability.cert-manager.edit" "rbac.deckhouse.io/kind" "capability" "rbac.deckhouse.io/scope" "namespace")) | nindent 2 }} + annotations: + en.meta.deckhouse.io/description: "Manage cert-manager resources in a namespace." + en.meta.deckhouse.io/title: "Module cert-manager: edit" + ru.meta.deckhouse.io/description: "Управление ресурсами модуля cert-manager в пространстве имён." + ru.meta.deckhouse.io/title: "Модуль cert-manager: редактирование" +rules: +- apiGroups: + - cert-manager.io + resources: + - certificaterequests + verbs: + - delete + - deletecollection +- apiGroups: + - cert-manager.io + resources: + - certificates + verbs: + - create + - delete + - deletecollection + - patch + - update diff --git a/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/rbacv2/use/view.yaml b/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/rbacv2/use/view.yaml new file mode 100644 index 00000000..92c9568c --- /dev/null +++ b/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/rbacv2/use/view.yaml @@ -0,0 +1,55 @@ +# Generated by dmt (rbac/sync) from rbac.yaml, contract 1. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: d8:namespace-capability:cert-manager:view + {{- include "helm_lib_module_labels" (list . (dict "rbac.deckhouse.io/aggregate-to-namespace-as" "viewer" "rbac.deckhouse.io/capability" "namespace-capability.cert-manager.view" "rbac.deckhouse.io/kind" "capability" "rbac.deckhouse.io/scope" "namespace")) | nindent 2 }} + annotations: + en.meta.deckhouse.io/description: "Read-only access to cert-manager resources in a namespace." + en.meta.deckhouse.io/title: "Module cert-manager: view" + ru.meta.deckhouse.io/description: "Доступ только на чтение к ресурсам модуля cert-manager в пространстве имён." + ru.meta.deckhouse.io/title: "Модуль cert-manager: просмотр" +rules: +{{- if .Values.certManager.internal.acmeEnabled }} +- apiGroups: + - acme.cert-manager.io + resources: + - challenges + verbs: + - get + - list + - watch +{{- end }} +- apiGroups: + - acme.cert-manager.io + resources: + - orders + verbs: + - get + - list + - watch +- apiGroups: + - cert-manager.io + resources: + - certificaterequests + verbs: + - get + - list + - watch +- apiGroups: + - cert-manager.io + resources: + - certificates + verbs: + - get + - list + - watch +- apiGroups: + - cert-manager.io + resources: + - issuers + verbs: + - get + - list + - watch diff --git a/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/user-authz-cluster-roles.yaml b/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/user-authz-cluster-roles.yaml new file mode 100644 index 00000000..371a61bb --- /dev/null +++ b/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/user-authz-cluster-roles.yaml @@ -0,0 +1,113 @@ +# Generated by dmt (rbac/sync) from rbac.yaml, contract 1. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: d8:user-authz:cert-manager:user + {{- include "helm_lib_module_labels" (list .) | nindent 2 }} + annotations: + user-authz.deckhouse.io/access-level: "User" +rules: +- apiGroups: + - acme.cert-manager.io + resources: + - orders + verbs: + - get + - list + - watch +- apiGroups: + - cert-manager.io + resources: + - certificaterequests + verbs: + - get + - list + - watch +- apiGroups: + - cert-manager.io + resources: + - certificates + verbs: + - get + - list + - watch +- apiGroups: + - cert-manager.io + resources: + - clusterissuers + verbs: + - get + - list + - watch +- apiGroups: + - cert-manager.io + resources: + - issuers + verbs: + - get + - list + - watch +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: d8:user-authz:cert-manager:editor + {{- include "helm_lib_module_labels" (list .) | nindent 2 }} + annotations: + user-authz.deckhouse.io/access-level: "Editor" +rules: +- apiGroups: + - cert-manager.io + resources: + - certificates + verbs: + - create + - delete + - deletecollection + - patch + - update +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: d8:user-authz:cert-manager:admin + {{- include "helm_lib_module_labels" (list .) | nindent 2 }} + annotations: + user-authz.deckhouse.io/access-level: "Admin" +rules: +- apiGroups: + - cert-manager.io + resources: + - certificaterequests + verbs: + - delete + - deletecollection +- apiGroups: + - cert-manager.io + resources: + - issuers + verbs: + - create + - delete + - patch + - update +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: d8:user-authz:cert-manager:cluster-editor + {{- include "helm_lib_module_labels" (list .) | nindent 2 }} + annotations: + user-authz.deckhouse.io/access-level: "ClusterEditor" +rules: +- apiGroups: + - cert-manager.io + resources: + - clusterissuers + verbs: + - create + - delete + - deletecollection + - patch + - update diff --git a/pkg/linters/rbac/rules/generate/testdata/cert-manager/module.yaml b/pkg/linters/rbac/rules/generate/testdata/cert-manager/module.yaml new file mode 100644 index 00000000..455e82a3 --- /dev/null +++ b/pkg/linters/rbac/rules/generate/testdata/cert-manager/module.yaml @@ -0,0 +1,4 @@ +name: cert-manager +namespace: d8-cert-manager +subsystems: + - security diff --git a/pkg/linters/rbac/rules/generate/testdata/cert-manager/rbac.yaml b/pkg/linters/rbac/rules/generate/testdata/cert-manager/rbac.yaml new file mode 100644 index 00000000..589e18b0 --- /dev/null +++ b/pkg/linters/rbac/rules/generate/testdata/cert-manager/rbac.yaml @@ -0,0 +1,96 @@ +apiVersion: rbac.deckhouse.io/v1alpha1 +resources: + - group: cert-manager.io + resource: certificates + namespace: + viewer: [get, list, watch] + manager: [create, update, patch, delete, deletecollection] + legacy: + User: [get, list, watch] + Editor: [create, update, patch, delete, deletecollection] + - group: cert-manager.io + resource: certificaterequests + namespace: + viewer: [get, list, watch] + manager: [delete, deletecollection] + legacy: + User: [get, list, watch] + Admin: [delete, deletecollection] + - group: cert-manager.io + resource: issuers + namespace: + viewer: [get, list, watch] + admin: [create, update, patch, delete] + legacy: + User: [get, list, watch] + Admin: [create, update, patch, delete] + - group: cert-manager.io + resource: clusterissuers + system: + viewer: [get, list, watch] + manager: [create, update, patch, delete, deletecollection] + legacy: + User: [get, list, watch] + ClusterEditor: [create, update, patch, delete, deletecollection] + - group: acme.cert-manager.io + resource: orders + namespace: + viewer: [get, list, watch] + legacy: + User: [get, list, watch] + - group: acme.cert-manager.io + resource: challenges + when: .Values.certManager.internal.acmeEnabled + namespace: + viewer: [get, list, watch] +capabilities: + namespace.admin: + title: + en: "Module cert-manager: admin" + ru: "Модуль cert-manager: администрирование" + description: + en: "Manage cert-manager Issuers in a namespace." + ru: "Управление Issuer модуля cert-manager в пространстве имён." +serviceAccounts: + - name: cainjector + path: cainjector + when: .Values.certManager.internal.enableCAInjector + labels: {app: cainjector} + clusterRules: + - apiGroups: [cert-manager.io] + resources: [certificates] + verbs: [get, list, watch] + - apiGroups: [""] + resources: [secrets] + verbs: [get, list, watch] + - nonResourceURLs: [/metrics] + verbs: [get] + namespaceRules: + - apiGroups: [coordination.k8s.io] + resources: [leases] + verbs: [get, list, watch, create, update, patch] + bindClusterRoles: [d8:rbac-proxy] + bindRoles: + - namespace: kube-system + name: extension-apiserver-authentication-reader +prometheusAccess: + deployments: [cert-manager, cainjector] +access: + - name: admin-kubeconfig + subjects: + - kind: Group + name: kubeadm:cluster-admins + clusterRules: + - apiGroups: [cert-manager.io] + resources: [clusterissuers] + verbs: [get, list, watch, create, update, patch, delete] + - name: auth + subjects: + - kind: ServiceAccount + name: ingress-nginx + namespace: d8-ingress-nginx + namespaceRules: + - apiGroups: [apps] + resources: [deployments/http] + resourceNames: [cert-manager] + verbs: [get] diff --git a/pkg/linters/rbac/rules/rbaccontract/contract.go b/pkg/linters/rbac/rules/rbaccontract/contract.go index 03c57a40..af5274f5 100644 --- a/pkg/linters/rbac/rules/rbaccontract/contract.go +++ b/pkg/linters/rbac/rules/rbaccontract/contract.go @@ -88,6 +88,32 @@ var ( ProjectLevels = NamespaceLevels ) +// ContractVersion is the version of the platform contract the generator writes templates for. It is +// recorded in the header of every generated file, so that a file produced under an older contract +// is recognizable after the contract changes. Bump it when the generated shape changes. +const ContractVersion = "1" + +// LegacyKebab returns the name suffix of the legacy ClusterRole for an access level, as the +// modules spell it today (d8:user-authz::cluster-editor for ClusterEditor). +func LegacyKebab(level string) string { + var b []byte + + for i := 0; i < len(level); i++ { + c := level[i] + if c >= 'A' && c <= 'Z' { + if i > 0 { + b = append(b, '-') + } + + c += 'a' - 'A' + } + + b = append(b, c) + } + + return string(b) +} + // LegacyLevels is the access-level enum of ClusterAuthorizationRule // (modules/140-user-authz/crds/clusterauthorizationrule.yaml); AuthorizationRule serves only the // first four. From 6d9af3661c9ba3910d5fa3f24a7a91b255c9f0a4 Mon Sep 17 00:00:00 2001 From: Ivan Zvyagintsev Date: Mon, 21 Sep 2026 23:03:58 +0300 Subject: [PATCH 07/58] rbac: add the sync rule -- the rendered RBAC objects match rbac.yaml in both directions, and --fix regenerates the templates sync compares the rendered objects of the three classes it owns (legacy roles, the module's capabilities, the objects the generator names) with the model built from the declaration: rules as (group, resource, name, verb) tuples, aggregation edges of capabilities, binding subjects and role references, the marker and module/namespace labels the generator writes. A rule under when that did not render is not a divergence; a rule without when that did not render is. Findings are one per template file and carry the fix command; an object the declaration does not produce in a file it does not generate is a person's decision. The autofix regenerates a file from the declaration with two safeguards: a file without the generator header is maintained by hand and gets the generated text beside it as .generated; a regeneration that would drop a right or an aggregation edge the render grants today is refused with the list of what would be lost. Everything the fix needs is captured while the render exists. Signed-off-by: Ivan Zvyagintsev --- pkg/linters/rbac/rbac.go | 1 + pkg/linters/rbac/rules/sync.go | 520 ++++++++++++++++++++++++++++ pkg/linters/rbac/rules/sync_test.go | 402 +++++++++++++++++++++ pkg/linters/rbac/rules/tuples.go | 171 +++++++++ pkg/scopes/static.go | 1 + 5 files changed, 1095 insertions(+) create mode 100644 pkg/linters/rbac/rules/sync.go create mode 100644 pkg/linters/rbac/rules/sync_test.go create mode 100644 pkg/linters/rbac/rules/tuples.go diff --git a/pkg/linters/rbac/rbac.go b/pkg/linters/rbac/rbac.go index 315dde91..369d0003 100644 --- a/pkg/linters/rbac/rbac.go +++ b/pkg/linters/rbac/rbac.go @@ -77,6 +77,7 @@ func (l *Rbac) rules() []pkg.Rule { rules.NewWildcardsRule(l.cfg.ExcludeRules.Wildcards.Get(), m, errorList), rules.NewContractRule(l.cfg.ExcludeRules.Contract.Get(), m, level(l.cfg.Rules.ContractRule)), rules.NewCoverageRule(l.cfg.ExcludeRules.Coverage.Get(), m, level(l.cfg.Rules.CoverageRule)), + rules.NewSyncRule(l.cfg.ExcludeRules.Sync.Get(), m, level(l.cfg.Rules.SyncRule)), } } diff --git a/pkg/linters/rbac/rules/sync.go b/pkg/linters/rbac/rules/sync.go new file mode 100644 index 00000000..da6b99e8 --- /dev/null +++ b/pkg/linters/rbac/rules/sync.go @@ -0,0 +1,520 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rules + +import ( + "context" + stderrors "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + rbacv1 "k8s.io/api/rbac/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/yaml" + + "github.com/deckhouse/dmt/internal/storage" + "github.com/deckhouse/dmt/pkg" + "github.com/deckhouse/dmt/pkg/errors" + "github.com/deckhouse/dmt/pkg/linters/rbac/rules/generate" + "github.com/deckhouse/dmt/pkg/linters/rbac/rules/rbaccontract" + "github.com/deckhouse/dmt/pkg/linters/rbac/rules/rbacyaml" +) + +const SyncRuleName = "sync" + +// SyncRule compares the RBAC objects the chart renders with the ones rbac.yaml declares, in both +// directions, and regenerates the templates from the declaration on --fix. It owns three classes +// of rendered objects (ADR, "Область ответственности sync"): legacy roles, the module's RBACv2 +// capabilities, and the objects whose names the generator builds; everything else in the render is +// unmanaged and never reported. +// +// It runs only when the module has an rbac.yaml. A declaration that does not validate is reported +// and nothing else is compared or generated (spec 005 R14). A rule declared under `when` that is +// absent from the render is not a divergence (R13a); a rule declared without `when` that is absent +// is (R13b) -- the condition belongs to the declaration. +type SyncRule struct { + pkg.RuleMeta + pkg.KindRule + + module pkg.Module + errorList *errors.LintRuleErrorsList +} + +var _ pkg.Rule = (*SyncRule)(nil) + +func NewSyncRule(excludeRules []pkg.KindRuleExclude, m pkg.Module, errorList *errors.LintRuleErrorsList) *SyncRule { + return &SyncRule{ + RuleMeta: pkg.RuleMeta{Name: SyncRuleName}, + KindRule: pkg.KindRule{ExcludeRules: excludeRules}, + module: m, + errorList: errorList.WithRule(SyncRuleName), + } +} + +// moduleMetadata is the part of module.yaml the generator reads. +type moduleMetadata struct { + Subsystems []string `json:"subsystems"` +} + +func readModuleMetadata(modulePath string) moduleMetadata { + var meta moduleMetadata + + data, err := os.ReadFile(filepath.Join(modulePath, "module.yaml")) + if err != nil { + return meta + } + + _ = yaml.Unmarshal(data, &meta) + + return meta +} + +func (r *SyncRule) Check(_ context.Context) { + modulePath := r.module.GetPath() + declList := r.errorList.WithFilePath(rbacyaml.Filename) + + decl, err := rbacyaml.Load(modulePath) + if stderrors.Is(err, rbacyaml.ErrNotFound) { + return + } + + if err != nil { + declList.Errorf("%v; nothing is compared or generated until the declaration parses", err) + return + } + + crds, err := moduleCRDs(modulePath) + if err != nil { + r.errorList.WithFilePath("crds").Errorf("cannot read the module CRDs: %v", err) + return + } + + if errs := rbacyaml.Validate(decl, crdScopes(crds)); len(errs) > 0 { + for _, e := range errs { + declList.Errorf("%v; nothing is compared or generated until the declaration is valid", e) + } + + return + } + + model, err := generate.Build(generate.Input{ + Module: r.module.GetName(), + Namespace: r.module.GetNamespace(), + Subsystems: readModuleMetadata(modulePath).Subsystems, + Decl: decl, + }) + if err != nil { + declList.Errorf("cannot derive the RBAC objects from the declaration: %v", err) + return + } + + actual := r.managedObjects(model) + divergences := map[string][]string{} + + for _, file := range model.Files { + divergences[file.Path] = append(divergences[file.Path], compareFile(file, actual, r.module.GetName())...) + } + + // A legacy role or a module capability the declaration does not produce is an object the + // declaration must own: it is reported under the template it came from. + modelIdentities := map[string]struct{}{} + + for _, file := range model.Files { + for _, o := range file.Objects { + modelIdentities[o.Identity()] = struct{}{} + } + } + + for identity, obj := range actual { + if _, produced := modelIdentities[identity]; produced || obj.class == generate.ClassDeclared { + continue + } + + divergences[obj.object.ShortPath()] = append(divergences[obj.object.ShortPath()], + fmt.Sprintf("%s is in the render but rbac.yaml does not produce it: declare its rights in rbac.yaml or remove it from the template", identity)) + } + + paths := make([]string, 0, len(divergences)) + for path, list := range divergences { + if len(list) > 0 { + paths = append(paths, path) + } + } + + sort.Strings(paths) + + for _, path := range paths { + list := divergences[path] + sort.Strings(list) + + fileList := r.errorList.WithFilePath(path).WithObjectID(path) + + if file := model.File(path); file != nil { + fileList = fileList.WithFix(regenerateFix(modulePath, *file, actual)) + fileList.Errorf("%s does not match %s: %s. Run `%s` to regenerate the file from the declaration", + path, rbacyaml.Filename, strings.Join(list, "; "), FixCommand) + + continue + } + + fileList.Errorf("%s does not match %s: %s. Only a person can close this: the declaration does not produce this file", + path, rbacyaml.Filename, strings.Join(list, "; ")) + } +} + +// managedObject is a rendered object the sync rule owns, with the class it was recognized by. +type managedObject struct { + object storage.StoreObject + class generate.Class +} + +// managedObjects selects the rendered objects of the three classes, keyed by identity. +func (r *SyncRule) managedObjects(model *generate.Model) map[string]managedObject { + declared := map[string]struct{}{} + + for _, file := range model.Files { + for _, o := range file.Objects { + declared[o.Identity()] = struct{}{} + } + } + + out := map[string]managedObject{} + + for index, object := range r.module.GetStorage() { + if !r.Enabled(object.Unstructured.GetKind(), object.Unstructured.GetName()) { + continue + } + + identity := index.AsString() + labels := object.Unstructured.GetLabels() + annotations := object.Unstructured.GetAnnotations() + + switch { + case object.Unstructured.GetKind() == "ClusterRole" && annotations[rbaccontract.AccessLevelAnnotation] != "": + out[identity] = managedObject{object, generate.ClassLegacy} + case object.Unstructured.GetKind() == "ClusterRole" && labels[rbaccontract.LabelKind] == rbaccontract.KindCapability && labels[rbaccontract.LabelModule] == r.module.GetName(): + out[identity] = managedObject{object, generate.ClassCapability} + default: + if _, ok := declared[identity]; ok { + out[identity] = managedObject{object, generate.ClassDeclared} + } + } + } + + return out +} + +// compareFile lists the divergences between the objects a generated file declares and the render. +func compareFile(file generate.File, actual map[string]managedObject, module string) []string { + var out []string + + for _, expected := range file.Objects { + act, ok := actual[expected.Identity()] + if !ok { + if expected.When == "" { + out = append(out, fmt.Sprintf("%s is declared but absent from the render", expected.Identity())) + } + + continue + } + + out = append(out, compareObject(expected, act.object, module)...) + } + + return out +} + +func compareObject(expected generate.Object, actual storage.StoreObject, module string) []string { + var out []string + + id := expected.Identity() + content := actual.Unstructured.UnstructuredContent() + + switch expected.Kind { + case "ClusterRole", "Role": + var rules []rbacv1.PolicyRule + + var aggregation *rbacv1.AggregationRule + + if expected.Kind == "ClusterRole" { + role := new(rbacv1.ClusterRole) + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(content, role); err != nil { + return []string{fmt.Sprintf("%s cannot be read as a ClusterRole: %v", id, err)} + } + + rules, aggregation = role.Rules, role.AggregationRule + } else { + role := new(rbacv1.Role) + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(content, role); err != nil { + return []string{fmt.Sprintf("%s cannot be read as a Role: %v", id, err)} + } + + rules = role.Rules + } + + actualTuples := expandRenderedRules(rules) + always, conditional := expandModelRules(expected.Rules) + + for _, t := range always.minus(actualTuples) { + out = append(out, fmt.Sprintf("%s: %s is declared but absent from the render", id, t)) + } + + for t := range conditional { + always.add(t) + } + + for _, t := range actualTuples.minus(always) { + out = append(out, fmt.Sprintf("%s: %s is in the render but not declared", id, t)) + } + + if expected.Class == generate.ClassCapability { + out = append(out, compareCapabilityLabels(expected, actual, module, aggregation)...) + } + case "ClusterRoleBinding", "RoleBinding": + var roleRef rbacv1.RoleRef + + var subjects []rbacv1.Subject + + if expected.Kind == "ClusterRoleBinding" { + binding := new(rbacv1.ClusterRoleBinding) + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(content, binding); err != nil { + return []string{fmt.Sprintf("%s cannot be read as a ClusterRoleBinding: %v", id, err)} + } + + roleRef, subjects = binding.RoleRef, binding.Subjects + } else { + binding := new(rbacv1.RoleBinding) + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(content, binding); err != nil { + return []string{fmt.Sprintf("%s cannot be read as a RoleBinding: %v", id, err)} + } + + roleRef, subjects = binding.RoleRef, binding.Subjects + } + + if roleRef.Kind != expected.RoleRefKind || roleRef.Name != expected.RoleRefName { + out = append(out, fmt.Sprintf("%s binds %s %s, the declaration binds %s %s", id, roleRef.Kind, roleRef.Name, expected.RoleRefKind, expected.RoleRefName)) + } + + want := map[string]struct{}{} + for _, s := range expected.Subjects { + want[s.Kind+"/"+s.Namespace+"/"+s.Name] = struct{}{} + } + + got := map[string]struct{}{} + for _, s := range subjects { + got[s.Kind+"/"+s.Namespace+"/"+s.Name] = struct{}{} + } + + for _, s := range sortedSetDiff(want, got) { + out = append(out, fmt.Sprintf("%s: subject %s is declared but absent from the render", id, s)) + } + + for _, s := range sortedSetDiff(got, want) { + out = append(out, fmt.Sprintf("%s: subject %s is in the render but not declared", id, s)) + } + } + + return out +} + +// compareCapabilityLabels checks the aggregation edges in both directions (R25a: a lost lineage is +// a lost right even when the rules agree) and the module-level contract the generator writes: the +// marker, the module label and the namespace label (D8 -- these live here, not in contract). +func compareCapabilityLabels(expected generate.Object, actual storage.StoreObject, module string, aggregation *rbacv1.AggregationRule) []string { + var out []string + + id := expected.Identity() + labels := actual.Unstructured.GetLabels() + + want := lineagesOfLabels(expected.Labels) + got := lineagesOfLabels(labels) + + for _, l := range want.minus(got) { + out = append(out, fmt.Sprintf("%s: aggregation into %s is declared but absent from the render", id, l)) + } + + for _, l := range got.minus(want) { + out = append(out, fmt.Sprintf("%s: aggregation into %s is in the render but not declared", id, l)) + } + + for _, key := range []string{rbaccontract.LabelCapability, rbaccontract.LabelScope, rbaccontract.LabelNamespace} { + if labels[key] != expected.Labels[key] { + out = append(out, fmt.Sprintf("%s: label %s is %q in the render, the declaration produces %q", id, key, labels[key], expected.Labels[key])) + } + } + + if labels[rbaccontract.LabelModule] != module { + out = append(out, fmt.Sprintf("%s: label %s is %q in the render, expected %q", id, rbaccontract.LabelModule, labels[rbaccontract.LabelModule], module)) + } + + if aggregation != nil { + out = append(out, fmt.Sprintf("%s: a capability carries rules and is aggregated by roles; it must not define aggregationRule", id)) + } + + return out +} + +func sortedSetDiff(a, b map[string]struct{}) []string { + var out []string + + for k := range a { + if _, ok := b[k]; !ok { + out = append(out, k) + } + } + + sort.Strings(out) + + return out +} + +// crdScopes indexes the module's CRDs by "group/plural" for the declaration validator. +func crdScopes(crds []crdInfo) rbacyaml.CRDScopes { + scopes := make(rbacyaml.CRDScopes, len(crds)) + for _, c := range crds { + scopes[c.Key()] = c.Scope + } + + return scopes +} + +// regenerateFix returns the autofix for a generated file: write it from the declaration. Everything +// the fix needs is captured now, while the render exists -- the object store is released before +// --fix runs (R32). Two safeguards decide whether the file is written at all: +// +// - a file without the generator header is maintained by hand: the generated text is written +// beside it as .generated and the finding stays (R16, US-F2); +// - the regenerated file must grant everything the current render of that file grants (rules and +// aggregation edges); if anything would disappear the file is left alone and the finding names +// what would be lost (R25, D3). Removing a right is always a person's decision. +func regenerateFix(modulePath string, file generate.File, actual map[string]managedObject) errors.AutofixFunc { + content := generate.RenderFile(file) + current := currentRights(file.Path, actual) + expected := expectedRights(file) + + return func() error { + fullPath := filepath.Join(modulePath, file.Path) + + existing, err := os.ReadFile(fullPath) + exists := err == nil + + if err != nil && !stderrors.Is(err, os.ErrNotExist) { + return fmt.Errorf("read %s: %w", file.Path, err) + } + + if exists { + if generated, _ := generate.ParseHeader(string(existing)); !generated { + aside := fullPath + ".generated" + if err := os.WriteFile(aside, []byte(content), 0o600); err != nil { + return fmt.Errorf("write %s: %w", file.Path+".generated", err) + } + + return fmt.Errorf("%s is maintained by hand (no generator header); the generated version is beside it as %s.generated -- compare, then either delete the file and run `%s` again, or keep maintaining it by hand", + file.Path, file.Path, FixCommand) + } + + if strings.Contains(string(existing), "deckhouseVersion") { + return fmt.Errorf("%s renders different objects depending on the platform version; regenerating it would drop one of the two schemes -- resolve the version condition by hand first", file.Path) + } + } + + if dropped := sortedSetDiff(current, expected); len(dropped) > 0 { + return fmt.Errorf("regenerating %s would drop rights the render grants today: %s; declare them in %s or remove them from the template by hand", + file.Path, strings.Join(dropped, ", "), rbacyaml.Filename) + } + + if exists && string(existing) == content { + return nil + } + + if err := os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil { + return fmt.Errorf("create %s: %w", filepath.Dir(file.Path), err) + } + + perm := os.FileMode(0o644) + if info, err := os.Stat(fullPath); err == nil { + perm = info.Mode().Perm() + } + + return os.WriteFile(fullPath, []byte(content), perm) + } +} + +// currentRights collects what the render grants from the objects of this file: every tuple and +// aggregation edge, keyed by object, from the objects the model declares for the file and from +// the managed objects the render placed in the same template. +func currentRights(path string, actual map[string]managedObject) map[string]struct{} { + out := map[string]struct{}{} + + for identity, obj := range actual { + if obj.object.ShortPath() != path { + continue + } + + content := obj.object.Unstructured.UnstructuredContent() + + switch obj.object.Unstructured.GetKind() { + case "ClusterRole": + role := new(rbacv1.ClusterRole) + if runtime.DefaultUnstructuredConverter.FromUnstructured(content, role) == nil { + for t := range expandRenderedRules(role.Rules) { + out[identity+": "+t.String()] = struct{}{} + } + + for l := range lineagesOfLabels(role.Labels) { + out[identity+": aggregation into "+l] = struct{}{} + } + } + case "Role": + role := new(rbacv1.Role) + if runtime.DefaultUnstructuredConverter.FromUnstructured(content, role) == nil { + for t := range expandRenderedRules(role.Rules) { + out[identity+": "+t.String()] = struct{}{} + } + } + } + } + + return out +} + +// expectedRights collects what the generated file will grant, conditional rules included: they +// are in the text, so they are not lost by regeneration. +func expectedRights(file generate.File) map[string]struct{} { + out := map[string]struct{}{} + + for _, o := range file.Objects { + always, conditional := expandModelRules(o.Rules) + + for t := range always { + out[o.Identity()+": "+t.String()] = struct{}{} + } + + for t := range conditional { + out[o.Identity()+": "+t.String()] = struct{}{} + } + + for l := range lineagesOfLabels(o.Labels) { + out[o.Identity()+": aggregation into "+l] = struct{}{} + } + } + + return out +} diff --git a/pkg/linters/rbac/rules/sync_test.go b/pkg/linters/rbac/rules/sync_test.go new file mode 100644 index 00000000..341464c5 --- /dev/null +++ b/pkg/linters/rbac/rules/sync_test.go @@ -0,0 +1,402 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rules + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/gojuno/minimock/v3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + + "github.com/deckhouse/dmt/internal/mocks" + "github.com/deckhouse/dmt/internal/storage" + "github.com/deckhouse/dmt/pkg/errors" + "github.com/deckhouse/dmt/pkg/linters/rbac/rules/generate" + "github.com/deckhouse/dmt/pkg/linters/rbac/rules/rbacyaml" +) + +const syncModule = "cert-manager" + +// syncModuleDir lays out the cert-manager fixture of the generator as a module: its rbac.yaml, +// module.yaml and the CRDs the declaration relies on for scopes. +func syncModuleDir(t *testing.T) string { + t.Helper() + + decl, err := os.ReadFile(filepath.Join("generate", "testdata", "cert-manager", "rbac.yaml")) + require.NoError(t, err) + + return writeModule(t, map[string]string{ + rbacyaml.Filename: string(decl), + "module.yaml": "name: cert-manager\nnamespace: d8-cert-manager\nsubsystems: [security]\n", + "crds/cm.yaml": crdYAML("cert-manager.io", "certificates", "Namespaced") + "---\n" + + crdYAML("cert-manager.io", "certificaterequests", "Namespaced") + "---\n" + + crdYAML("cert-manager.io", "issuers", "Namespaced") + "---\n" + + crdYAML("cert-manager.io", "clusterissuers", "Cluster") + "---\n" + + crdYAML("acme.cert-manager.io", "orders", "Namespaced") + "---\n" + + crdYAML("acme.cert-manager.io", "challenges", "Namespaced"), + }) +} + +func syncModel(t *testing.T, modulePath string) *generate.Model { + t.Helper() + + decl, err := rbacyaml.Load(modulePath) + require.NoError(t, err) + + model, err := generate.Build(generate.Input{Module: syncModule, Namespace: "d8-cert-manager", Subsystems: []string{"security"}, Decl: decl}) + require.NoError(t, err) + + return model +} + +// renderedFrom simulates the Helm render of the model: every object as the chart would produce it, +// with the module labels helm_lib_module_labels adds. tweak may alter or drop an object (return +// false to drop it) before it is stored. +func renderedFrom(t *testing.T, model *generate.Model, tweak func(o *generate.Object) bool) *storage.UnstructuredObjectStore { + t.Helper() + + store := storage.NewUnstructuredObjectStore() + + for _, file := range model.Files { + for _, o := range file.Objects { + obj := o + if tweak != nil && !tweak(&obj) { + continue + } + + putObject(t, store, file.Path, obj) + } + } + + return store +} + +func putObject(t *testing.T, store *storage.UnstructuredObjectStore, path string, o generate.Object) { + t.Helper() + + labels := map[string]string{"heritage": "deckhouse", "module": syncModule} + for k, v := range o.Labels { + labels[k] = v + } + + meta := metav1.ObjectMeta{Name: o.Name, Namespace: o.Namespace, Labels: labels, Annotations: o.Annotations} + + rules := make([]rbacv1.PolicyRule, 0, len(o.Rules)) + for _, r := range o.Rules { + rules = append(rules, rbacv1.PolicyRule{APIGroups: r.APIGroups, Resources: r.Resources, ResourceNames: r.ResourceNames, NonResourceURLs: r.NonResourceURLs, Verbs: r.Verbs}) + } + + subjects := make([]rbacv1.Subject, 0, len(o.Subjects)) + for _, s := range o.Subjects { + subjects = append(subjects, rbacv1.Subject{Kind: s.Kind, Name: s.Name, Namespace: s.Namespace}) + } + + roleRef := rbacv1.RoleRef{APIGroup: "rbac.authorization.k8s.io", Kind: o.RoleRefKind, Name: o.RoleRefName} + + var typed runtime.Object + + switch o.Kind { + case "ClusterRole": + typed = &rbacv1.ClusterRole{TypeMeta: metav1.TypeMeta{APIVersion: "rbac.authorization.k8s.io/v1", Kind: o.Kind}, ObjectMeta: meta, Rules: rules} + case "Role": + typed = &rbacv1.Role{TypeMeta: metav1.TypeMeta{APIVersion: "rbac.authorization.k8s.io/v1", Kind: o.Kind}, ObjectMeta: meta, Rules: rules} + case "ClusterRoleBinding": + typed = &rbacv1.ClusterRoleBinding{TypeMeta: metav1.TypeMeta{APIVersion: "rbac.authorization.k8s.io/v1", Kind: o.Kind}, ObjectMeta: meta, RoleRef: roleRef, Subjects: subjects} + case "RoleBinding": + typed = &rbacv1.RoleBinding{TypeMeta: metav1.TypeMeta{APIVersion: "rbac.authorization.k8s.io/v1", Kind: o.Kind}, ObjectMeta: meta, RoleRef: roleRef, Subjects: subjects} + case "ServiceAccount": + typed = &corev1.ServiceAccount{TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: o.Kind}, ObjectMeta: meta, AutomountServiceAccountToken: o.AutomountToken} + default: + t.Fatalf("unexpected kind %s", o.Kind) + } + + content, err := runtime.DefaultUnstructuredConverter.ToUnstructured(typed) + require.NoError(t, err) + require.NoError(t, store.Put("/module/"+path, path, content, []byte(o.Identity()))) +} + +func runSync(t *testing.T, modulePath string, store *storage.UnstructuredObjectStore) *errors.LintRuleErrorsList { + t.Helper() + + m := mocks.NewModuleMock(minimock.NewController(t)) + m.GetPathMock.Return(modulePath) + // The rule stops before reading the module when the declaration is missing or invalid. + m.GetNameMock.Optional().Return(syncModule) + m.GetNamespaceMock.Optional().Return("d8-cert-manager") + m.GetStorageMock.Optional().Return(store.Storage) + + errorList := errors.NewLintRuleErrorsList() + NewSyncRule(nil, m, errorList).Check(context.Background()) + + return errorList +} + +func TestSync_CleanRenderMatchesDeclaration(t *testing.T) { + modulePath := syncModuleDir(t) + model := syncModel(t, modulePath) + + // An unmanaged controller ClusterRole beside the managed objects is nobody's business. + store := renderedFrom(t, model, nil) + putObject(t, store, "templates/cert-manager/rbac-for-us.yaml", generate.Object{ + Kind: "ClusterRole", Name: "d8:cert-manager:controller", Class: generate.ClassDeclared, + Rules: []generate.Rule{{PolicyRule: rbacyaml.PolicyRule{APIGroups: []string{"*"}, Resources: []string{"*"}, Verbs: []string{"*"}}}}, + }) + + assert.Empty(t, texts(runSync(t, modulePath, store))) +} + +func TestSync_Divergences(t *testing.T) { + for name, tc := range map[string]struct { + tweak func(o *generate.Object) bool + extra func(t *testing.T, store *storage.UnstructuredObjectStore) + want []string + }{ + "R13b: an unconditional rule is absent from the render": { + tweak: func(o *generate.Object) bool { + if o.Name == "d8:namespace-capability:cert-manager:view" { + o.Rules = o.Rules[:len(o.Rules)-1] // drops issuers (sorted last) + } + + return true + }, + want: []string{"error: templates/rbacv2/use/view.yaml does not match rbac.yaml: ClusterRole/d8:namespace-capability:cert-manager:view: get cert-manager.io/issuers is declared but absent from the render; ClusterRole/d8:namespace-capability:cert-manager:view: list cert-manager.io/issuers is declared but absent from the render; ClusterRole/d8:namespace-capability:cert-manager:view: watch cert-manager.io/issuers is declared but absent from the render. Run `dmt lint --linter rbac --fix` to regenerate the file from the declaration"}, + }, + "R13a: a rule under when that did not render is not a divergence": { + tweak: func(o *generate.Object) bool { + kept := o.Rules[:0] + for _, r := range o.Rules { + if r.When == "" { + kept = append(kept, r) + } + } + + o.Rules = kept + + return true + }, + want: nil, + }, + "a rule in the render that the declaration does not have": { + tweak: func(o *generate.Object) bool { + if o.Name == "d8:user-authz:cert-manager:user" { + o.Rules = append(o.Rules, generate.Rule{PolicyRule: rbacyaml.PolicyRule{APIGroups: []string{""}, Resources: []string{"secrets"}, Verbs: []string{"get"}}}) + } + + return true + }, + want: []string{`error: templates/user-authz-cluster-roles.yaml does not match rbac.yaml: ClusterRole/d8:user-authz:cert-manager:user: get ""/secrets is in the render but not declared. Run ` + "`dmt lint --linter rbac --fix`" + ` to regenerate the file from the declaration`}, + }, + "R25a: the rules agree but a lineage is lost": { + tweak: func(o *generate.Object) bool { + if o.Name == "d8:system-capability:cert-manager:view" { + delete(o.Labels, "rbac.deckhouse.io/aggregate-to-security-as") + } + + return true + }, + want: []string{"error: templates/rbacv2/manage/view.yaml does not match rbac.yaml: ClusterRole/d8:system-capability:cert-manager:view: aggregation into security=viewer is declared but absent from the render. Run `dmt lint --linter rbac --fix` to regenerate the file from the declaration"}, + }, + "a declared object is absent from the render": { + tweak: func(o *generate.Object) bool { + return o.Name != "access-to-cert-manager-auth" || o.Kind != "RoleBinding" + }, + want: []string{"error: templates/rbac-to-us.yaml does not match rbac.yaml: d8-cert-manager/RoleBinding/access-to-cert-manager-auth is declared but absent from the render. Run `dmt lint --linter rbac --fix` to regenerate the file from the declaration"}, + }, + "a conditional object absent from the render is fine": { + tweak: func(o *generate.Object) bool { return o.When == "" }, + want: nil, + }, + "D2: a legacy role the declaration does not produce": { + extra: func(t *testing.T, store *storage.UnstructuredObjectStore) { + putObject(t, store, "templates/user-authz-cluster-roles.yaml", generate.Object{ + Kind: "ClusterRole", Name: "d8:user-authz:cert-manager:super-admin", Class: generate.ClassLegacy, + Annotations: map[string]string{"user-authz.deckhouse.io/access-level": "SuperAdmin"}, + Rules: []generate.Rule{{PolicyRule: rbacyaml.PolicyRule{APIGroups: []string{"cert-manager.io"}, Resources: []string{"issuers"}, Verbs: []string{"deletecollection"}}}}, + }) + }, + want: []string{"error: templates/user-authz-cluster-roles.yaml does not match rbac.yaml: ClusterRole/d8:user-authz:cert-manager:super-admin is in the render but rbac.yaml does not produce it: declare its rights in rbac.yaml or remove it from the template. Run `dmt lint --linter rbac --fix` to regenerate the file from the declaration"}, + }, + "D2: a module capability in a file the declaration does not produce": { + extra: func(t *testing.T, store *storage.UnstructuredObjectStore) { + putObject(t, store, "templates/rbacv2/use/superadmin.yaml", generate.Object{ + Kind: "ClusterRole", Name: "d8:namespace-capability:cert-manager:superadmin", Class: generate.ClassCapability, + Labels: map[string]string{"rbac.deckhouse.io/kind": "capability", "rbac.deckhouse.io/scope": "namespace", "rbac.deckhouse.io/aggregate-to-namespace-as": "superadmin"}, + Rules: []generate.Rule{{PolicyRule: rbacyaml.PolicyRule{APIGroups: []string{"cert-manager.io"}, Resources: []string{"issuers"}, Verbs: []string{"deletecollection"}}}}, + }) + }, + want: []string{"error: templates/rbacv2/use/superadmin.yaml does not match rbac.yaml: ClusterRole/d8:namespace-capability:cert-manager:superadmin is in the render but rbac.yaml does not produce it: declare its rights in rbac.yaml or remove it from the template. Only a person can close this: the declaration does not produce this file"}, + }, + "a binding with a different subject": { + tweak: func(o *generate.Object) bool { + if o.Kind == "ClusterRoleBinding" && o.Name == "d8:cert-manager:admin-kubeconfig" { + o.Subjects = []generate.Subject{{Kind: "Group", Name: "kubeadm:cluster-operators"}} + } + + return true + }, + want: []string{"error: templates/rbac-for-us.yaml does not match rbac.yaml: ClusterRoleBinding/d8:cert-manager:admin-kubeconfig: subject Group//kubeadm:cluster-admins is declared but absent from the render; ClusterRoleBinding/d8:cert-manager:admin-kubeconfig: subject Group//kubeadm:cluster-operators is in the render but not declared. Run `dmt lint --linter rbac --fix` to regenerate the file from the declaration"}, + }, + } { + t.Run(name, func(t *testing.T) { + modulePath := syncModuleDir(t) + model := syncModel(t, modulePath) + store := renderedFrom(t, model, tc.tweak) + + if tc.extra != nil { + tc.extra(t, store) + } + + got := texts(runSync(t, modulePath, store)) + if tc.want == nil { + assert.Empty(t, got) + return + } + + assert.Equal(t, tc.want, got) + }) + } +} + +func TestSync_InvalidDeclarationStopsEverything(t *testing.T) { + modulePath := syncModuleDir(t) + model := syncModel(t, modulePath) + + // Break the declaration after the model is built: the render is fine, the file is not. + require.NoError(t, os.WriteFile(rbacyaml.Path(modulePath), []byte("apiVersion: rbac.deckhouse.io/v1alpha1\nresources:\n - group: cert-manager.io\n resource: clusterissuers\n namespace:\n viewer: [get]\n"), 0o600)) + + got := texts(runSync(t, modulePath, renderedFrom(t, model, nil))) + require.Len(t, got, 1, "got: %v", got) + assert.Contains(t, got[0], "namespace levels are not allowed for a cluster-scoped resource") + assert.Contains(t, got[0], "nothing is compared or generated until the declaration is valid") +} + +func TestSync_WithoutDeclarationIsSilent(t *testing.T) { + modulePath := syncModuleDir(t) + model := syncModel(t, modulePath) + require.NoError(t, os.Remove(rbacyaml.Path(modulePath))) + + assert.Empty(t, texts(runSync(t, modulePath, renderedFrom(t, model, nil)))) +} + +func TestSync_Autofix(t *testing.T) { + t.Run("regenerates a missing file and is idempotent", func(t *testing.T) { + modulePath := syncModuleDir(t) + model := syncModel(t, modulePath) + + // Nothing of the use/view capability is rendered: the file is missing. + store := renderedFrom(t, model, func(o *generate.Object) bool { return o.Name != "d8:namespace-capability:cert-manager:view" }) + errorList := runSync(t, modulePath, store) + + fixes := errorList.GetFixes() + require.Len(t, fixes, 1) + fixes[0]() + + remaining := errorList.GetErrors() + assert.Empty(t, remaining, "a successful fix resolves the finding") + + written, err := os.ReadFile(filepath.Join(modulePath, "templates/rbacv2/use/view.yaml")) + require.NoError(t, err) + assert.Equal(t, generate.RenderFile(*model.File("templates/rbacv2/use/view.yaml")), string(written)) + + generated, version := generate.ParseHeader(string(written)) + assert.True(t, generated) + assert.Equal(t, "1", version) + + // Running the same fix again changes nothing. + before, _ := os.Stat(filepath.Join(modulePath, "templates/rbacv2/use/view.yaml")) + for _, fix := range runSync(t, modulePath, store).GetFixes() { + fix() + } + + after, _ := os.Stat(filepath.Join(modulePath, "templates/rbacv2/use/view.yaml")) + assert.Equal(t, before.ModTime(), after.ModTime()) + }) + + t.Run("D3: refuses to drop a right the render grants", func(t *testing.T) { + modulePath := syncModuleDir(t) + model := syncModel(t, modulePath) + store := renderedFrom(t, model, func(o *generate.Object) bool { + if o.Name == "d8:user-authz:cert-manager:user" { + o.Rules = append(o.Rules, generate.Rule{PolicyRule: rbacyaml.PolicyRule{APIGroups: []string{""}, Resources: []string{"secrets"}, Verbs: []string{"get"}}}) + } + + return true + }) + + // The file exists with a generator header, so only the guard stands in the way. + path := filepath.Join(modulePath, "templates/user-authz-cluster-roles.yaml") + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte(generate.Header()+"\n# stale\n"), 0o600)) + + errorList := runSync(t, modulePath, store) + for _, fix := range errorList.GetFixes() { + fix() + } + + remaining := errorList.GetErrors() + require.Len(t, remaining, 1) + require.Error(t, remaining[0].FixError) + assert.Contains(t, remaining[0].FixError.Error(), `would drop rights the render grants today: ClusterRole/d8:user-authz:cert-manager:user: get ""/secrets`) + + unchanged, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, generate.Header()+"\n# stale\n", string(unchanged), "the file is left alone") + }) + + t.Run("US-F2: a file without the header is maintained by hand", func(t *testing.T) { + modulePath := syncModuleDir(t) + model := syncModel(t, modulePath) + store := renderedFrom(t, model, func(o *generate.Object) bool { + if o.Name == "d8:namespace-capability:cert-manager:view" { + o.Rules = o.Rules[:len(o.Rules)-1] + } + + return true + }) + + path := filepath.Join(modulePath, "templates/rbacv2/use/view.yaml") + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte("# hand-made\napiVersion: v1\n"), 0o600)) + + errorList := runSync(t, modulePath, store) + for _, fix := range errorList.GetFixes() { + fix() + } + + remaining := errorList.GetErrors() + require.Len(t, remaining, 1) + require.Error(t, remaining[0].FixError) + assert.Contains(t, remaining[0].FixError.Error(), "is maintained by hand (no generator header)") + + unchanged, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, "# hand-made\napiVersion: v1\n", string(unchanged)) + + aside, err := os.ReadFile(path + ".generated") + require.NoError(t, err) + assert.True(t, strings.HasPrefix(string(aside), generate.Header())) + }) +} diff --git a/pkg/linters/rbac/rules/tuples.go b/pkg/linters/rbac/rules/tuples.go new file mode 100644 index 00000000..74eda155 --- /dev/null +++ b/pkg/linters/rbac/rules/tuples.go @@ -0,0 +1,171 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rules + +import ( + "sort" + "strings" + + rbacv1 "k8s.io/api/rbac/v1" + + "github.com/deckhouse/dmt/pkg/linters/rbac/rules/generate" + "github.com/deckhouse/dmt/pkg/linters/rbac/rules/rbacyaml" +) + +// A tuple is the atom the sync rule compares: one (apiGroup, resource, resourceName, verb) a +// PolicyRule grants, or one (url, verb) for a non-resource rule. Wildcards are compared literally; +// whether they are acceptable is the wildcards rule's business. +type tuple string + +func resourceTuple(group, resource, name, verb string) tuple { + return tuple(group + "|" + resource + "|" + name + "|" + verb) +} + +func urlTuple(url, verb string) tuple { + return tuple("url:" + url + "|" + verb) +} + +// String renders the tuple for a finding. +func (t tuple) String() string { + s := string(t) + if rest, ok := strings.CutPrefix(s, "url:"); ok { + url, verb, _ := strings.Cut(rest, "|") + return verb + " " + url + } + + parts := strings.SplitN(s, "|", 4) + group, resource, name, verb := parts[0], parts[1], parts[2], parts[3] + + if group == "" { + group = `""` + } + + out := verb + " " + group + "/" + resource + if name != "" { + out += " (" + name + ")" + } + + return out +} + +type tupleSet map[tuple]struct{} + +func (s tupleSet) add(t tuple) { s[t] = struct{}{} } + +// minus returns the tuples of s absent from other, sorted. +func (s tupleSet) minus(other tupleSet) []tuple { + var out []tuple + + for t := range s { + if _, ok := other[t]; !ok { + out = append(out, t) + } + } + + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + + return out +} + +// expandPolicyRule turns one raw rule into its tuples. +func expandPolicyRule(rule rbacyaml.PolicyRule, into tupleSet) { + if len(rule.NonResourceURLs) > 0 { + for _, url := range rule.NonResourceURLs { + for _, verb := range rule.Verbs { + into.add(urlTuple(url, verb)) + } + } + + return + } + + names := rule.ResourceNames + if len(names) == 0 { + names = []string{""} + } + + for _, group := range rule.APIGroups { + for _, resource := range rule.Resources { + for _, name := range names { + for _, verb := range rule.Verbs { + into.add(resourceTuple(group, resource, name, verb)) + } + } + } + } +} + +// expandRenderedRules turns the rules of a rendered role into tuples. +func expandRenderedRules(rules []rbacv1.PolicyRule) tupleSet { + out := tupleSet{} + + for _, r := range rules { + expandPolicyRule(rbacyaml.PolicyRule{ + APIGroups: r.APIGroups, Resources: r.Resources, ResourceNames: r.ResourceNames, NonResourceURLs: r.NonResourceURLs, Verbs: r.Verbs, + }, out) + } + + return out +} + +// expandModelRules splits the generated rules of an object into the tuples rendered always and the +// tuples rendered only under a condition. +func expandModelRules(rules []generate.Rule) (tupleSet, tupleSet) { + always, conditional := tupleSet{}, tupleSet{} + + for _, r := range rules { + if r.When != "" { + expandPolicyRule(r.PolicyRule, conditional) + } else { + expandPolicyRule(r.PolicyRule, always) + } + } + + return always, conditional +} + +// lineageSet is the set of aggregation edges of a capability, as "lineage=level". +type lineageSet map[string]struct{} + +func lineagesOfLabels(labels map[string]string) lineageSet { + out := lineageSet{} + + for key, value := range labels { + m := aggregateLabelRe.FindStringSubmatch(key) + if m == nil { + continue + } + + out[m[1]+"="+value] = struct{}{} + } + + return out +} + +func (s lineageSet) minus(other lineageSet) []string { + var out []string + + for l := range s { + if _, ok := other[l]; !ok { + out = append(out, l) + } + } + + sort.Strings(out) + + return out +} diff --git a/pkg/scopes/static.go b/pkg/scopes/static.go index 8114fdf9..27122931 100644 --- a/pkg/scopes/static.go +++ b/pkg/scopes/static.go @@ -126,6 +126,7 @@ var staticRules = map[string]set.Set{ rbacrules.ContractRuleName, rbacrules.CoverageRuleName, rbacrules.PlacementRuleName, + rbacrules.SyncRuleName, rbacrules.UserAuthZRuleName, rbacrules.WildcardsRuleName, ), From 091c1de116cc56e4d47d05304626abdc9d064989 Mon Sep 17 00:00:00 2001 From: Ivan Zvyagintsev Date: Mon, 21 Sep 2026 23:06:19 +0300 Subject: [PATCH 08/58] rbac: e2e cases for the sync rule, rendering the generated templates through helm_lib The cases vendor deckhouse_lib_helm as the container cases do, and every one asserts that the module rendered (expectPass on the manager linter): without it a render failure would satisfy any expectPass. Signed-off-by: Ivan Zvyagintsev --- test/e2e/README.md | 3 + .../testdata/rbac/sync-clean/expected.yaml | 15 ++ .../charts/deckhouse_lib_helm-1.72.1.tgz | Bin 0 -> 45549 bytes .../module/crds/certificaterequests.yaml | 9 ++ .../sync-clean/module/crds/certificates.yaml | 9 ++ .../sync-clean/module/crds/challenges.yaml | 9 ++ .../module/crds/clusterissuers.yaml | 9 ++ .../rbac/sync-clean/module/crds/issuers.yaml | 9 ++ .../rbac/sync-clean/module/crds/orders.yaml | 9 ++ .../rbac/sync-clean/module/module.yaml | 3 + .../module/openapi/config-values.yaml | 2 + .../sync-clean/module/openapi/values.yaml | 10 ++ .../testdata/rbac/sync-clean/module/rbac.yaml | 96 +++++++++++++ .../templates/cainjector/rbac-for-us.yaml | 115 ++++++++++++++++ .../module/templates/rbac-for-us.yaml | 34 +++++ .../module/templates/rbac-to-us.yaml | 67 +++++++++ .../module/templates/rbacv2/manage/edit.yaml | 34 +++++ .../module/templates/rbacv2/manage/view.yaml | 31 +++++ .../module/templates/rbacv2/use/admin.yaml | 22 +++ .../module/templates/rbacv2/use/edit.yaml | 30 ++++ .../module/templates/rbacv2/use/view.yaml | 55 ++++++++ .../templates/user-authz-cluster-roles.yaml | 113 ++++++++++++++++ .../rbac/sync-fix-regenerates/expected.yaml | 10 ++ .../charts/deckhouse_lib_helm-1.72.1.tgz | Bin 0 -> 45549 bytes .../module/crds/certificaterequests.yaml | 9 ++ .../module/crds/certificates.yaml | 9 ++ .../module/crds/challenges.yaml | 9 ++ .../module/crds/clusterissuers.yaml | 9 ++ .../module/crds/issuers.yaml | 9 ++ .../module/crds/orders.yaml | 9 ++ .../sync-fix-regenerates/module/module.yaml | 3 + .../module/openapi/config-values.yaml | 2 + .../module/openapi/values.yaml | 10 ++ .../sync-fix-regenerates/module/rbac.yaml | 96 +++++++++++++ .../templates/cainjector/rbac-for-us.yaml | 115 ++++++++++++++++ .../module/templates/rbac-for-us.yaml | 34 +++++ .../module/templates/rbac-to-us.yaml | 67 +++++++++ .../module/templates/rbacv2/manage/edit.yaml | 34 +++++ .../module/templates/rbacv2/manage/view.yaml | 31 +++++ .../module/templates/rbacv2/use/edit.yaml | 30 ++++ .../module/templates/rbacv2/use/view.yaml | 55 ++++++++ .../templates/user-authz-cluster-roles.yaml | 113 ++++++++++++++++ .../rbac/sync-hand-edited/expected.yaml | 18 +++ .../charts/deckhouse_lib_helm-1.72.1.tgz | Bin 0 -> 45549 bytes .../module/crds/certificaterequests.yaml | 9 ++ .../module/crds/certificates.yaml | 9 ++ .../module/crds/challenges.yaml | 9 ++ .../module/crds/clusterissuers.yaml | 9 ++ .../sync-hand-edited/module/crds/issuers.yaml | 9 ++ .../sync-hand-edited/module/crds/orders.yaml | 9 ++ .../rbac/sync-hand-edited/module/module.yaml | 3 + .../module/openapi/config-values.yaml | 2 + .../module/openapi/values.yaml | 10 ++ .../rbac/sync-hand-edited/module/rbac.yaml | 96 +++++++++++++ .../templates/cainjector/rbac-for-us.yaml | 115 ++++++++++++++++ .../module/templates/rbac-for-us.yaml | 34 +++++ .../module/templates/rbac-to-us.yaml | 67 +++++++++ .../module/templates/rbacv2/manage/edit.yaml | 34 +++++ .../module/templates/rbacv2/manage/view.yaml | 31 +++++ .../module/templates/rbacv2/use/admin.yaml | 22 +++ .../module/templates/rbacv2/use/edit.yaml | 37 +++++ .../module/templates/rbacv2/use/view.yaml | 55 ++++++++ .../templates/user-authz-cluster-roles.yaml | 128 ++++++++++++++++++ 63 files changed, 2044 insertions(+) create mode 100644 test/e2e/testdata/rbac/sync-clean/expected.yaml create mode 100644 test/e2e/testdata/rbac/sync-clean/module/charts/deckhouse_lib_helm-1.72.1.tgz create mode 100644 test/e2e/testdata/rbac/sync-clean/module/crds/certificaterequests.yaml create mode 100644 test/e2e/testdata/rbac/sync-clean/module/crds/certificates.yaml create mode 100644 test/e2e/testdata/rbac/sync-clean/module/crds/challenges.yaml create mode 100644 test/e2e/testdata/rbac/sync-clean/module/crds/clusterissuers.yaml create mode 100644 test/e2e/testdata/rbac/sync-clean/module/crds/issuers.yaml create mode 100644 test/e2e/testdata/rbac/sync-clean/module/crds/orders.yaml create mode 100644 test/e2e/testdata/rbac/sync-clean/module/module.yaml create mode 100644 test/e2e/testdata/rbac/sync-clean/module/openapi/config-values.yaml create mode 100644 test/e2e/testdata/rbac/sync-clean/module/openapi/values.yaml create mode 100644 test/e2e/testdata/rbac/sync-clean/module/rbac.yaml create mode 100644 test/e2e/testdata/rbac/sync-clean/module/templates/cainjector/rbac-for-us.yaml create mode 100644 test/e2e/testdata/rbac/sync-clean/module/templates/rbac-for-us.yaml create mode 100644 test/e2e/testdata/rbac/sync-clean/module/templates/rbac-to-us.yaml create mode 100644 test/e2e/testdata/rbac/sync-clean/module/templates/rbacv2/manage/edit.yaml create mode 100644 test/e2e/testdata/rbac/sync-clean/module/templates/rbacv2/manage/view.yaml create mode 100644 test/e2e/testdata/rbac/sync-clean/module/templates/rbacv2/use/admin.yaml create mode 100644 test/e2e/testdata/rbac/sync-clean/module/templates/rbacv2/use/edit.yaml create mode 100644 test/e2e/testdata/rbac/sync-clean/module/templates/rbacv2/use/view.yaml create mode 100644 test/e2e/testdata/rbac/sync-clean/module/templates/user-authz-cluster-roles.yaml create mode 100644 test/e2e/testdata/rbac/sync-fix-regenerates/expected.yaml create mode 100644 test/e2e/testdata/rbac/sync-fix-regenerates/module/charts/deckhouse_lib_helm-1.72.1.tgz create mode 100644 test/e2e/testdata/rbac/sync-fix-regenerates/module/crds/certificaterequests.yaml create mode 100644 test/e2e/testdata/rbac/sync-fix-regenerates/module/crds/certificates.yaml create mode 100644 test/e2e/testdata/rbac/sync-fix-regenerates/module/crds/challenges.yaml create mode 100644 test/e2e/testdata/rbac/sync-fix-regenerates/module/crds/clusterissuers.yaml create mode 100644 test/e2e/testdata/rbac/sync-fix-regenerates/module/crds/issuers.yaml create mode 100644 test/e2e/testdata/rbac/sync-fix-regenerates/module/crds/orders.yaml create mode 100644 test/e2e/testdata/rbac/sync-fix-regenerates/module/module.yaml create mode 100644 test/e2e/testdata/rbac/sync-fix-regenerates/module/openapi/config-values.yaml create mode 100644 test/e2e/testdata/rbac/sync-fix-regenerates/module/openapi/values.yaml create mode 100644 test/e2e/testdata/rbac/sync-fix-regenerates/module/rbac.yaml create mode 100644 test/e2e/testdata/rbac/sync-fix-regenerates/module/templates/cainjector/rbac-for-us.yaml create mode 100644 test/e2e/testdata/rbac/sync-fix-regenerates/module/templates/rbac-for-us.yaml create mode 100644 test/e2e/testdata/rbac/sync-fix-regenerates/module/templates/rbac-to-us.yaml create mode 100644 test/e2e/testdata/rbac/sync-fix-regenerates/module/templates/rbacv2/manage/edit.yaml create mode 100644 test/e2e/testdata/rbac/sync-fix-regenerates/module/templates/rbacv2/manage/view.yaml create mode 100644 test/e2e/testdata/rbac/sync-fix-regenerates/module/templates/rbacv2/use/edit.yaml create mode 100644 test/e2e/testdata/rbac/sync-fix-regenerates/module/templates/rbacv2/use/view.yaml create mode 100644 test/e2e/testdata/rbac/sync-fix-regenerates/module/templates/user-authz-cluster-roles.yaml create mode 100644 test/e2e/testdata/rbac/sync-hand-edited/expected.yaml create mode 100644 test/e2e/testdata/rbac/sync-hand-edited/module/charts/deckhouse_lib_helm-1.72.1.tgz create mode 100644 test/e2e/testdata/rbac/sync-hand-edited/module/crds/certificaterequests.yaml create mode 100644 test/e2e/testdata/rbac/sync-hand-edited/module/crds/certificates.yaml create mode 100644 test/e2e/testdata/rbac/sync-hand-edited/module/crds/challenges.yaml create mode 100644 test/e2e/testdata/rbac/sync-hand-edited/module/crds/clusterissuers.yaml create mode 100644 test/e2e/testdata/rbac/sync-hand-edited/module/crds/issuers.yaml create mode 100644 test/e2e/testdata/rbac/sync-hand-edited/module/crds/orders.yaml create mode 100644 test/e2e/testdata/rbac/sync-hand-edited/module/module.yaml create mode 100644 test/e2e/testdata/rbac/sync-hand-edited/module/openapi/config-values.yaml create mode 100644 test/e2e/testdata/rbac/sync-hand-edited/module/openapi/values.yaml create mode 100644 test/e2e/testdata/rbac/sync-hand-edited/module/rbac.yaml create mode 100644 test/e2e/testdata/rbac/sync-hand-edited/module/templates/cainjector/rbac-for-us.yaml create mode 100644 test/e2e/testdata/rbac/sync-hand-edited/module/templates/rbac-for-us.yaml create mode 100644 test/e2e/testdata/rbac/sync-hand-edited/module/templates/rbac-to-us.yaml create mode 100644 test/e2e/testdata/rbac/sync-hand-edited/module/templates/rbacv2/manage/edit.yaml create mode 100644 test/e2e/testdata/rbac/sync-hand-edited/module/templates/rbacv2/manage/view.yaml create mode 100644 test/e2e/testdata/rbac/sync-hand-edited/module/templates/rbacv2/use/admin.yaml create mode 100644 test/e2e/testdata/rbac/sync-hand-edited/module/templates/rbacv2/use/edit.yaml create mode 100644 test/e2e/testdata/rbac/sync-hand-edited/module/templates/rbacv2/use/view.yaml create mode 100644 test/e2e/testdata/rbac/sync-hand-edited/module/templates/user-authz-cluster-roles.yaml diff --git a/test/e2e/README.md b/test/e2e/README.md index 4fd2aff2..73442067 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -114,6 +114,9 @@ go test ./test/e2e/ -run 'TestE2E//' -v | `rbac/coverage-fix-keeps-finding` | rbac linter `coverage` with `--fix` (a stub is written and the finding stays -- a stub is not a decision) | | `rbac/coverage-todo` | rbac linter `coverage` (undecided `noAccess: "TODO"` stub; misspelled resource of a known group is a warning) | | `rbac/coverage-without-rbac-yaml` | rbac linter `coverage` stays silent on a module without rbac.yaml | +| `rbac/sync-clean` | rbac linter `sync` (templates generated from rbac.yaml render exactly the declaration; the generated files also pass placement, contract and coverage; renders `helm_lib_module_labels` from the vendored `deckhouse_lib_helm` chart) | +| `rbac/sync-hand-edited` | rbac linter `sync` (a rule added by hand to a generated capability, and a legacy role the declaration does not produce -- one finding per template) | +| `rbac/sync-fix-regenerates` | rbac linter `sync` with `--fix` (a missing generated capability file is written from rbac.yaml and the finding is resolved) | | `hooks/ingress` | hooks linter (Ingress without copy_custom_certificate hook) | | `openapi/bilingual` | openapi linter (missing doc-ru- translation, missing CRD module label) | | `images/werf` | images linter (werf fromImage not under base/) | diff --git a/test/e2e/testdata/rbac/sync-clean/expected.yaml b/test/e2e/testdata/rbac/sync-clean/expected.yaml new file mode 100644 index 00000000..bb00e945 --- /dev/null +++ b/test/e2e/testdata/rbac/sync-clean/expected.yaml @@ -0,0 +1,15 @@ +description: > + Templates generated from rbac.yaml render exactly what the declaration says: the sync rule has + nothing to report, and the generated files pass the placement, contract and wildcards rules. +module: module +expectPass: + # the module must render: a render failure would otherwise pass every expectPass trivially + - linter: manager + - linter: rbac + rule: sync + - linter: rbac + rule: placement + - linter: rbac + rule: contract + - linter: rbac + rule: coverage diff --git a/test/e2e/testdata/rbac/sync-clean/module/charts/deckhouse_lib_helm-1.72.1.tgz b/test/e2e/testdata/rbac/sync-clean/module/charts/deckhouse_lib_helm-1.72.1.tgz new file mode 100644 index 0000000000000000000000000000000000000000..3f56d38ec75c51bc2f29d3a7a75a4e3ff760550e GIT binary patch literal 45549 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 + A generated capability file that is missing is reported by sync; --fix writes it from rbac.yaml + and the finding is resolved in the same run (nothing is dropped, so the safeguard lets it through). +kind: fix +module: module +expectPass: + # the module must render: a render failure would otherwise pass every expectPass trivially + - linter: manager + - linter: rbac + rule: sync diff --git a/test/e2e/testdata/rbac/sync-fix-regenerates/module/charts/deckhouse_lib_helm-1.72.1.tgz b/test/e2e/testdata/rbac/sync-fix-regenerates/module/charts/deckhouse_lib_helm-1.72.1.tgz new file mode 100644 index 0000000000000000000000000000000000000000..3f56d38ec75c51bc2f29d3a7a75a4e3ff760550e GIT binary patch literal 45549 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 + A rule added by hand to a generated capability and a legacy role the declaration does not produce + are both reported by the sync rule, one finding per template, with the fix command. +module: module +expect: + - linter: rbac + rule: sync + level: error + textContains: 'templates/rbacv2/use/edit.yaml does not match rbac.yaml: ClusterRole/d8:namespace-capability:cert-manager:edit: get ""/secrets is in the render but not declared' + count: 1 + - linter: rbac + rule: sync + level: error + textContains: "templates/user-authz-cluster-roles.yaml does not match rbac.yaml: ClusterRole/d8:user-authz:cert-manager:super-admin is in the render but rbac.yaml does not produce it" + count: 1 +expectPass: + # the module must render: a render failure would otherwise pass every expectPass trivially + - linter: manager diff --git a/test/e2e/testdata/rbac/sync-hand-edited/module/charts/deckhouse_lib_helm-1.72.1.tgz b/test/e2e/testdata/rbac/sync-hand-edited/module/charts/deckhouse_lib_helm-1.72.1.tgz new file mode 100644 index 0000000000000000000000000000000000000000..3f56d38ec75c51bc2f29d3a7a75a4e3ff760550e GIT binary patch literal 45549 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 Date: Mon, 21 Sep 2026 23:09:54 +0300 Subject: [PATCH 09/58] rbac: refuse unknown keys in the rbac configuration blocks viper drops an unknown key without a word; for the per-rule levels and exclusions of the rbac linter that silence leaves a rule at full strength -- or off -- with nobody noticing. The two rbac blocks (global.linters-settings.rbac with its rules, linters-settings.rbac with its exclude-rules) are held to their known keys; the other linters keep the lenient behaviour. Adds the mapping test for the per-rule levels: coverage, sync and contract read their own level from the root configuration and fall back to the linter's, the four original rules keep the linter level. Signed-off-by: Ivan Zvyagintsev --- internal/modules/rbac_rules_config_test.go | 70 +++++++++++++++++ pkg/config/loader.go | 58 ++++++++++++++ pkg/config/rbac_keys_test.go | 91 ++++++++++++++++++++++ 3 files changed, 219 insertions(+) create mode 100644 internal/modules/rbac_rules_config_test.go create mode 100644 pkg/config/rbac_keys_test.go diff --git a/internal/modules/rbac_rules_config_test.go b/internal/modules/rbac_rules_config_test.go new file mode 100644 index 00000000..77698a12 --- /dev/null +++ b/internal/modules/rbac_rules_config_test.go @@ -0,0 +1,70 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package modules + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/deckhouse/dmt/pkg" + "github.com/deckhouse/dmt/pkg/config" + "github.com/deckhouse/dmt/pkg/config/global" +) + +// The rules added for the module RBAC declaration read their own impact from the root +// configuration; the four original rbac rules keep the linter level whatever the root says. +func TestRemapLinterSettings_RBACDeclarationRules(t *testing.T) { + t.Run("per-rule levels from the root configuration, linter level as fallback", func(t *testing.T) { + settings := remapLinterSettings( + &config.LintersSettings{Rbac: config.RbacSettings{Impact: pkg.Error.String()}}, + &global.Linters{Rbac: global.RbacLinterConfig{ + LinterConfig: global.LinterConfig{Impact: pkg.Error.String()}, + Rules: global.RbacRules{ + CoverageRule: global.RuleConfig{Impact: pkg.Warn.String()}, + SyncRule: global.RuleConfig{Impact: pkg.Ignored.String()}, + }, + }}, + ) + + require.Equal(t, pkg.Warn, *settings.RBAC.Rules.CoverageRule.GetLevel()) + require.Equal(t, pkg.Ignored, *settings.RBAC.Rules.SyncRule.GetLevel()) + require.Equal(t, pkg.Error, *settings.RBAC.Rules.ContractRule.GetLevel(), "unset falls back to the linter level") + + // SC5: the original rules are untouched by the per-rule block. + for _, rule := range []*pkg.RuleConfig{ + &settings.RBAC.Rules.UserAuthRule, &settings.RBAC.Rules.BindingRule, &settings.RBAC.Rules.PlacementRule, &settings.RBAC.Rules.WildcardsRule, + } { + require.Equal(t, pkg.Error, *rule.GetLevel()) + } + }) + + t.Run("module-level exclusions for the three rules", func(t *testing.T) { + settings := remapLinterSettings( + &config.LintersSettings{Rbac: config.RbacSettings{ExcludeRules: config.RBACExcludeRules{ + Coverage: config.StringRuleExcludeList{"deckhouse.io/internals"}, + Contract: config.KindRuleExcludeList{{Kind: "ClusterRole", Name: "d8:namespace-capability:x:view"}}, + Sync: config.KindRuleExcludeList{{Kind: "ClusterRole", Name: "d8:user-authz:x:user"}}, + }}}, + &global.Linters{}, + ) + + require.Equal(t, pkg.StringRuleExcludeList{"deckhouse.io/internals"}, settings.RBAC.ExcludeRules.Coverage) + require.Len(t, settings.RBAC.ExcludeRules.Contract.Get(), 1) + require.Len(t, settings.RBAC.ExcludeRules.Sync.Get(), 1) + }) +} diff --git a/pkg/config/loader.go b/pkg/config/loader.go index a3fc8c37..6019d44c 100644 --- a/pkg/config/loader.go +++ b/pkg/config/loader.go @@ -23,6 +23,8 @@ import ( "os" "path/filepath" "slices" + "sort" + "strings" "github.com/mitchellh/go-homedir" "github.com/mitchellh/mapstructure" @@ -150,9 +152,65 @@ func (l *Loader) parseConfig() error { return fmt.Errorf("can't unmarshal config by viper (flags, file): %w", err) } + return validateRbacKeys(l.viper) +} + +// rbacKnownKeys lists the keys the rbac blocks accept. viper drops an unknown key without a word, +// and for these blocks silence is expensive: a misspelled per-rule level or exclusion would leave +// a rule at full strength -- or off -- with nobody noticing. Only the rbac blocks are held to +// this; the other linters keep viper's lenient behaviour. +var rbacKnownKeys = map[string]map[string]struct{}{ + "global.linters-settings.rbac": {"impact": {}, "rules": {}}, + "global.linters-settings.rbac.rules": {"coverage": {}, "sync": {}, "contract": {}}, + "linters-settings.rbac": {"impact": {}, "exclude-rules": {}}, + "linters-settings.rbac.exclude-rules": { + "binding-subject": {}, "placement": {}, "wildcards": {}, "coverage": {}, "contract": {}, "sync": {}, + }, +} + +func validateRbacKeys(v *viper.Viper) error { + paths := make([]string, 0, len(rbacKnownKeys)) + for path := range rbacKnownKeys { + paths = append(paths, path) + } + + sort.Strings(paths) + + for _, path := range paths { + block, ok := v.Get(path).(map[string]any) + if !ok { + continue + } + + keys := make([]string, 0, len(block)) + for key := range block { + if _, known := rbacKnownKeys[path][key]; !known { + keys = append(keys, key) + } + } + + if len(keys) > 0 { + sort.Strings(keys) + + return fmt.Errorf("unknown key(s) %s under %q in %s: the accepted keys are %s", + strings.Join(keys, ", "), path, v.ConfigFileUsed(), strings.Join(sortedKeysOf(rbacKnownKeys[path]), ", ")) + } + } + return nil } +func sortedKeysOf(m map[string]struct{}) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + + sort.Strings(out) + + return out +} + func (l *Loader) setConfigDir() error { usedConfigFile := l.viper.ConfigFileUsed() if usedConfigFile == "" { diff --git a/pkg/config/rbac_keys_test.go b/pkg/config/rbac_keys_test.go new file mode 100644 index 00000000..5324c7ff --- /dev/null +++ b/pkg/config/rbac_keys_test.go @@ -0,0 +1,91 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package config + +import ( + "os" + "path/filepath" + "testing" + + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func loadFrom(t *testing.T, content string) error { + t.Helper() + + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, ".dmtlint.yaml"), []byte(content), 0o600)) + + l := NewLoader(&RootConfig{}, "") + l.viper = viper.New() + l.viper.SetConfigType("yaml") + l.viper.SetConfigName(".dmtlint") + l.viper.AddConfigPath(dir) + + return l.Load() +} + +// The rbac blocks refuse unknown keys: a misspelled per-rule level or exclusion must not be +// dropped in silence. +func TestLoader_RbacKeysAreStrict(t *testing.T) { + t.Run("known keys load", func(t *testing.T) { + require.NoError(t, loadFrom(t, ` +global: + linters-settings: + rbac: + impact: error + rules: + coverage: {impact: warn} + sync: {impact: warn} + contract: {impact: warn} +linters-settings: + rbac: + impact: error + exclude-rules: + coverage: [deckhouse.io/internals] + contract: + - kind: ClusterRole + name: d8:namespace-capability:x:view + sync: [] +`)) + }) + + t.Run("a misspelled rule under the root block is an error", func(t *testing.T) { + err := loadFrom(t, "global:\n linters-settings:\n rbac:\n rules:\n coverge: {impact: warn}\n") + require.Error(t, err) + assert.Contains(t, err.Error(), `unknown key(s) coverge under "global.linters-settings.rbac.rules"`) + assert.Contains(t, err.Error(), "the accepted keys are contract, coverage, sync") + }) + + t.Run("per-rule levels do not belong to the module block", func(t *testing.T) { + err := loadFrom(t, "linters-settings:\n rbac:\n rules:\n coverage: {impact: warn}\n") + require.Error(t, err) + assert.Contains(t, err.Error(), `unknown key(s) rules under "linters-settings.rbac"`) + }) + + t.Run("an unknown exclusion key is an error", func(t *testing.T) { + err := loadFrom(t, "linters-settings:\n rbac:\n exclude-rules:\n coverage-rule: [x]\n") + require.Error(t, err) + assert.Contains(t, err.Error(), `unknown key(s) coverage-rule under "linters-settings.rbac.exclude-rules"`) + }) + + t.Run("other linters keep the lenient behaviour", func(t *testing.T) { + require.NoError(t, loadFrom(t, "linters-settings:\n container:\n impakt: warn\n")) + }) +} From 13a0e5c7e1f224ba146ddc6e6b3e1b1a86242577 Mon Sep 17 00:00:00 2001 From: Ivan Zvyagintsev Date: Mon, 21 Sep 2026 23:09:54 +0300 Subject: [PATCH 10/58] rbac: document the rbac.yaml declaration and the contract, coverage and sync rules Signed-off-by: Ivan Zvyagintsev --- pkg/linters/rbac/README.md | 318 +++++++++++++++++++++++++++++++++++++ 1 file changed, 318 insertions(+) diff --git a/pkg/linters/rbac/README.md b/pkg/linters/rbac/README.md index e2fe1a7c..a1d32a20 100644 --- a/pkg/linters/rbac/README.md +++ b/pkg/linters/rbac/README.md @@ -14,6 +14,9 @@ Proper RBAC configuration is critical for Kubernetes security, ensuring least-pr | [binding-subject](#binding-subject) | Validates RoleBinding/ClusterRoleBinding subjects reference existing ServiceAccounts | ✅ | enabled | | [placement](#placement) | Validates RBAC resource placement and naming conventions | ✅ | enabled | | [wildcards](#wildcards) | Validates Roles/ClusterRoles don't use wildcard permissions | ✅ | enabled | +| [contract](#contract) | Holds the module's RBACv2 roles and capabilities to the platform's label and naming contract | ✅ | enabled | +| [coverage](#coverage) | Requires a decision in `rbac.yaml` on the user access to every CRD the module ships | ✅ | enabled when `rbac.yaml` exists | +| [sync](#sync) | Compares the rendered RBAC objects with `rbac.yaml` in both directions; `--fix` regenerates the templates | ✅ | enabled when `rbac.yaml` exists | "Configurable" means that this rule can be configured using the `.dmtlint.yaml` file, including customizing the rule's parameters and/or disabling the rule. @@ -1340,3 +1343,318 @@ Error: User-authz access ClusterRoles should have annotation "user-authz.deckhou annotations: user-authz.deckhouse.io/access-level: Editor ``` + +--- + +## The module RBAC declaration: `rbac.yaml` + +The three rules below work with one file, `modules//rbac.yaml`: the single machine-readable +source of the RBAC a module ships. It describes, per resource, the access every role model grants +(the RBACv2 namespace and system lineages, and the legacy user-authz access levels), the rights of the +module's ServiceAccounts, and the access other components get to the module. The templates under +`templates/rbacv2/`, `templates/user-authz-cluster-roles.yaml`, `templates/**/rbac-for-us.yaml` and +`templates/rbac-to-us.yaml` are **generated** from it by `dmt lint --linter rbac --fix`. + +```yaml +# modules//rbac.yaml +apiVersion: rbac.deckhouse.io/v1alpha1 + +# Lineages the system capabilities aggregate into. Defaults to `subsystems` of module.yaml; required +# when the module aggregates into more subsystems than module.yaml declares. +subsystems: [networking, kubernetes] + +resources: + # A resource the module ships a CRD for: group and resource are enough, the scope comes from the CRD. + - group: cert-manager.io + resource: certificates + namespace: # RBACv2 namespace lineage; only for Namespaced resources + viewer: [get, list, watch] + manager: [create, update, patch, delete, deletecollection] + legacy: # user-authz v1; never derived from the RBACv2 levels + User: [get, list, watch] + Editor: [create, update, patch, delete, deletecollection] + + # A cluster-scoped resource goes to the system lineage; a Namespaced one may too, with a reason. + - group: cert-manager.io + resource: clusterissuers + system: + viewer: [get, list, watch] + manager: [create, update, patch, delete, deletecollection] + legacy: + ClusterEditor: [create, update, patch, delete, deletecollection] + + # A resource the module ships no CRD for: declare the scope; its existence is not checked. + - group: trivy.deckhouse.io + resource: vulnerabilityreports + scope: Namespaced + namespace: + viewer: [get, list, watch] + + # A whole group whose resources are created at runtime: "*" with a reason. + - group: constraints.gatekeeper.sh + resource: "*" + scope: Cluster + reason: one CRD per ConstraintTemplate is created at runtime; the names are not known statically + system: + viewer: [get, list, watch] + + # A deliberate denial: the reason is for the reader. It excludes namespace, system and legacy. + - group: deckhouse.io + resource: registryscantargets + noAccess: internal resource, managed by the controller + + # A conditional grant: `when` is a Helm expression, rendered as {{- if }} around the rule. + - group: deckhouse.io + resource: bars + when: .Values.foo.barEnabled + namespace: + viewer: [get, list, watch] + +# Localized texts of capabilities outside the view/edit convention (their texts come from the platform). +capabilities: + namespace.admin: + title: {en: "Module cert-manager: admin", ru: "Модуль cert-manager: администрирование"} + description: {en: "Manage cert-manager Issuers in a namespace.", ru: "Управление Issuer модуля cert-manager в пространстве имён."} + +# ServiceAccount rights -> templates/[/]rbac-for-us.yaml. Only declared accounts are managed. +serviceAccounts: + - name: cainjector + path: cainjector # templates/cainjector/rbac-for-us.yaml; omitted -> templates/rbac-for-us.yaml + when: .Values.certManager.internal.enableCAInjector + labels: {app: cainjector} + clusterRules: # ClusterRole d8:: + ClusterRoleBinding + - apiGroups: [cert-manager.io] + resources: [certificates] + verbs: [get, list, watch] + - nonResourceURLs: [/metrics] + verbs: [get] + namespaceRules: # Role in the module namespace + RoleBinding + - apiGroups: [coordination.k8s.io] + resources: [leases] + verbs: [get, list, watch, create, update, patch] + bindClusterRoles: [d8:rbac-proxy] # ClusterRoleBinding d8:::rbac-proxy + bindRoles: # RoleBinding to an existing Role in a foreign namespace + - namespace: kube-system + name: extension-apiserver-authentication-reader + +# Metrics access -> templates/rbac-to-us.yaml (Role/RoleBinding access-to-) +prometheusAccess: + deployments: [cert-manager] + +# Arbitrary subjects: clusterRules -> templates/rbac-for-us.yaml, namespaceRules -> templates/rbac-to-us.yaml +access: + - name: admin-kubeconfig + subjects: + - kind: Group + name: kubeadm:cluster-admins + clusterRules: + - apiGroups: [cert-manager.io] + resources: [clusterissuers] + verbs: [get, list, watch, create, update, patch, delete] +``` + +Format rules the loader enforces: + +- `apiVersion` is required and must be `rbac.deckhouse.io/v1alpha1`; unknown keys anywhere are an error. +- Verbs are listed explicitly (`get`, `list`, `watch`, `create`, `update`, `patch`, `delete`, `deletecollection`); there are no aliases. +- Levels: `namespace` -- `viewer`, `user`, `manager`, `admin`, `superadmin`; `system` -- `viewer`, `manager`, `superadmin`; `legacy` -- `User`, `PrivilegedUser`, `Editor`, `Admin`, `ClusterEditor`, `ClusterAdmin`, `SuperAdmin`. +- `namespace` levels are allowed only for `Namespaced` resources; a `Namespaced` resource at a `system` level needs a `reason`. +- `scope` is required for a resource the module ships no CRD for, and must agree with the CRD when the module ships one. `resource: "*"` is allowed only for a group without CRDs in the module and needs a `reason`. +- `noAccess` is a non-empty reason and excludes the levels. `noAccess: "TODO"` is the stub the coverage autofix writes; it is not a decision. +- A capability outside the view/edit convention (`admin`, `user`, `superadmin`) needs `capabilities..` texts in both languages. + +What the generator produces from it (level `viewer` -> capability `view`, `manager` -> `edit`, the rest as they are): + +| Section | File | Objects | +|---|---|---| +| `resources[].namespace.` | `templates/rbacv2/use/.yaml` | ClusterRole `d8:namespace-capability::` | +| `resources[].system.` | `templates/rbacv2/manage/.yaml` | ClusterRole `d8:system-capability::`; `view` and `edit` are always produced, with the rule on the module's own ModuleConfig | +| `resources[].legacy.` | `templates/user-authz-cluster-roles.yaml` | ClusterRole `d8:user-authz::` with the `user-authz.deckhouse.io/access-level` annotation | +| `serviceAccounts[]` | `templates/[/]rbac-for-us.yaml` | ServiceAccount, ClusterRole/ClusterRoleBinding `d8::`, Role/RoleBinding ``, the extra bindings | +| `access[]` with `clusterRules` | `templates/rbac-for-us.yaml` | ClusterRole/ClusterRoleBinding `d8::` | +| `prometheusAccess`, `access[]` with `namespaceRules` | `templates/rbac-to-us.yaml` | Role/RoleBinding `access-to-[-]` | + +Every generated file starts with a header line naming the generator and the contract version. A file +without that header is maintained by hand and is never overwritten. + +The developer's loop is: edit `rbac.yaml` -> `dmt lint --linter rbac --fix` -> `dmt lint`. `--fix` does not +re-lint: the second `dmt lint` shows the state after the fixes. + +--- + +### contract + +**Purpose:** Holds the RBACv2 roles and capabilities a module renders under `templates/rbacv2/` to the +platform's label and naming contract, so that a module outside the platform repository is checked +the same way the platform's own test (`testing/rbacv2`) checks in-tree modules. Role aggregation +relies on labels the API server cannot validate; a divergent module silently breaks it. + +**Description:** + +Works on the rendered ClusterRoles from `templates/rbacv2/` (the compatibility aliases under +`templates/rbacv2-compat/` are outside the contract by design). Needs no `rbac.yaml`. + +**What it checks:** + +1. The name starts with `d8:`; the four `en|ru.meta.deckhouse.io/title|description` annotations are present. +2. `rbac.deckhouse.io/kind` is `role` or `capability`; `rbac.deckhouse.io/scope` is `system`, `subsystem`, `namespace` or `project`. +3. A role: its name matches the pattern of its scope, it defines no `rules`, its `aggregationRule` selects only by `aggregate-to--as` labels with a known lineage and a level of that lineage; system/subsystem roles carry `rbac.deckhouse.io/use-role` with a valid level. +4. A capability: its name starts with the prefix of its scope, it defines `rules` and no `aggregationRule`, carries at least one `aggregate-to--as` label and a valid `rbac.deckhouse.io/capability` marker (a label value, at most 63 characters). +5. Aggregation labels target a known lineage (`system`, `namespace`, `project` or one of the seven subsystems) with a level that lineage has. +6. `rbac.deckhouse.io/delegatable` appears only on namespace/project roles. +7. **Warning:** a cluster-scoped resource inside a namespace capability. Such a capability is bound through a RoleBinding, where the rule grants nothing. The scope comes from the module's CRDs or from its `rbac.yaml`; a resource the run knows nothing about is not judged. + +What deliberately stays in the platform test: the levels of sensitive capabilities and the closure of +aggregation across two modules, and the global uniqueness of the capability marker -- a rule sees one module. + +**Example finding:** + +``` +Error: capability "d8:namespace-capability:my-module:view" must carry the rbac.deckhouse.io/capability label +Warning: capability "d8:namespace-capability:my-module:view" grants my.io/globals, a cluster-scoped resource, in a namespace capability: bound through a RoleBinding the rule grants nothing; move it to a system capability +``` + +**Configuration:** +```yaml +# root .dmtlint.yaml +global: + linters-settings: + rbac: + rules: + contract: {impact: warn} # the level of this rule alone; the four original rules keep the linter level + +# module .dmtlint.yaml +linters-settings: + rbac: + exclude-rules: + contract: + - kind: ClusterRole + name: d8:namespace-capability:my-module:legacy +``` + +--- + +### coverage + +**Purpose:** Every CRD a module ships gets a decision on the user access to it, written down in +`rbac.yaml`: the levels of either role model, or `noAccess` with the reason. Today 66 of 181 CRDs in +the platform are named in no user rule of their module, and nowhere is it recorded whether that is +deliberate. + +**Description:** + +Runs only when the module has an `rbac.yaml`. Reads the CRDs under `crds/` at any depth (selected by +`kind: CustomResourceDefinition`, so `crds/vendor/` counts and `images/**/testdata/crds/` does not). + +**What it checks:** + +1. Every CRD (`spec.group` / `spec.names.plural`) has an entry in `resources` that grants levels or denies access with a reason -- **error**, with an autofix. +2. An entry left as `noAccess: "TODO"` -- **error**, no autofix: only a person can decide. +3. An entry naming a group the module ships CRDs for, but a resource none of them spells -- **warning** (a likely misspelling). Whole-group (`"*"`) and subresource (`/`) entries are exempt. + +**Autofix:** appends an undecided stub for each CRD without an entry -- + +```yaml + - group: deckhouse.io + resource: foopolicies + noAccess: "TODO" +``` + +-- and then still reports the finding: the stub is not a decision, and a `--fix` run that wrote stubs +does not end green. Existing entries and comments are left as they are; a second `--fix` changes nothing. +The rule does not create `rbac.yaml`: a module adopts the declaration by creating the file with the +`apiVersion` line and running `--fix`. + +**Example finding:** + +``` +Error: CRD deckhouse.io/foopolicies (crds/foopolicies.yaml) has no entry in rbac.yaml: decide the user access to it -- namespace, system or legacy levels, or noAccess with the reason; `dmt lint --linter rbac --fix` adds an undecided stub +``` + +**Configuration:** +```yaml +# root .dmtlint.yaml +global: + linters-settings: + rbac: + rules: + coverage: {impact: warn} + +# module .dmtlint.yaml +linters-settings: + rbac: + exclude-rules: + coverage: + - deckhouse.io/internalthings # "group/resource" of a CRD the declaration deliberately leaves out +``` + +--- + +### sync + +**Purpose:** The rendered RBAC objects and `rbac.yaml` say the same thing, in both directions, so that +a reviewer reads one file to know what the module grants, and a change to the platform's contract is +a regeneration rather than a hand edit of every template. + +**Description:** + +Runs only when the module has an `rbac.yaml`. First validates the declaration; a declaration with +errors is reported and nothing else is compared or generated. Then builds the objects the +declaration produces and compares them with the render. + +`sync` owns exactly three classes of rendered objects: + +1. legacy roles -- ClusterRoles with the `user-authz.deckhouse.io/access-level` annotation; +2. the module's RBACv2 capabilities -- ClusterRoles with `rbac.deckhouse.io/kind: capability` and `module: `; +3. declared objects -- those whose names the generator builds from `serviceAccounts`, `access` and `prometheusAccess`. + +Everything else in the render is unmanaged: not generated, not reported (`include "helm_lib_csi_controller_rbac"`, +controller ClusterRoles with arbitrary names, objects with Helm-computed names). + +**What it checks:** + +1. Every declared object is in the render (unless it is under `when`), and every rule of it: rules are compared as `(apiGroup, resource, resourceName, verb)` tuples, in both directions. A rule under `when` that did not render is not a divergence; a rule without `when` hidden behind a hand-written `{{ if }}` is. +2. A capability's aggregation edges (`aggregate-to--as`) match in both directions: rules may agree while a lineage is lost. Its `rbac.deckhouse.io/capability` marker, `module` and `rbac.deckhouse.io/namespace` labels are what the generator writes. +3. A binding's `roleRef` and subjects match. +4. Every rendered legacy role and module capability is produced by the declaration. + +Findings are one per template file and carry the fix command; the text does not depend on the render variant. + +**Autofix:** regenerates the file from `rbac.yaml`, with two safeguards -- + +- a file without the generator header is maintained by hand: the generated text is written beside it as `.generated` and the finding stays (delete the file and run `--fix` again to hand it back to the generator); +- the regenerated file must grant everything the render of that file grants today, rules and aggregation edges alike; otherwise the file is left alone and the finding names what would be lost. Removing a right is always a person's decision: declare it in `rbac.yaml` or remove it from the template by hand. + +A missing file is created. A second `--fix` without changes to `rbac.yaml` changes nothing. + +**Example finding:** + +``` +Error: templates/rbacv2/use/edit.yaml does not match rbac.yaml: ClusterRole/d8:namespace-capability:my-module:edit: get ""/secrets is in the render but not declared. Run `dmt lint --linter rbac --fix` to regenerate the file from the declaration +``` + +**Configuration:** +```yaml +# root .dmtlint.yaml +global: + linters-settings: + rbac: + rules: + sync: {impact: warn} + +# module .dmtlint.yaml +linters-settings: + rbac: + exclude-rules: + sync: + - kind: ClusterRole + name: d8:user-authz:my-module:super-admin +``` + +**Limits worth knowing:** + +- A conditional rule is checked only where it renders: with the default values, `dmt lint --values-file` or `dmt lint --matrix`. +- The declaration is one per module and describes the union of editions. Linting a single edition directory shows the edition-only objects as absent; lint the merged tree as CI does. +- `dmt lint remote` does not run these rules: a published image carries no chart to render. +- The keys of the `rbac` configuration blocks are checked: an unknown key is an error, not a silent no-op. + From d4df0d859a384b553b6f0d293f70dc4aed1dfe47 Mon Sep 17 00:00:00 2001 From: Ivan Zvyagintsev Date: Mon, 21 Sep 2026 23:43:27 +0300 Subject: [PATCH 11/58] errors: keep the autofix of an ignored finding off A rule switched off with impact: ignore still had its fix closures collected and run by --fix, so files could change on behalf of findings nobody sees. GetFixes now skips findings at the ignored level. Signed-off-by: Ivan Zvyagintsev --- pkg/errors/lint_errors.go | 4 ++- pkg/errors/lint_errors_fixes_test.go | 45 ++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 pkg/errors/lint_errors_fixes_test.go diff --git a/pkg/errors/lint_errors.go b/pkg/errors/lint_errors.go index fdf6c40e..5cf1dc4d 100644 --- a/pkg/errors/lint_errors.go +++ b/pkg/errors/lint_errors.go @@ -254,7 +254,9 @@ func (l *LintRuleErrorsList) GetFixes() []func() { var fixes []func() for idx := range l.storage.errList { - if l.storage.errList[idx].fix == nil { + // A finding at the ignored level is neither shown nor acted on: a rule switched off with + // impact: ignore keeps its autofix off with it. + if l.storage.errList[idx].fix == nil || l.storage.errList[idx].Level == pkg.Ignored { continue } diff --git a/pkg/errors/lint_errors_fixes_test.go b/pkg/errors/lint_errors_fixes_test.go new file mode 100644 index 00000000..a74a6ed3 --- /dev/null +++ b/pkg/errors/lint_errors_fixes_test.go @@ -0,0 +1,45 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package errors + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/deckhouse/dmt/pkg" +) + +// A rule switched off with impact: ignore keeps its autofix off with it: --fix must not rewrite +// files on behalf of findings nobody sees. +func TestGetFixes_SkipsIgnoredFindings(t *testing.T) { + list := NewLintRuleErrorsList() + ran := map[string]int{} + + ignored := pkg.Ignored + list.WithMaxLevel(&ignored).WithFix(func() error { ran["ignored"]++; return nil }).Errorf("switched off") + list.WithFix(func() error { ran["active"]++; return nil }).Errorf("active") + + fixes := list.GetFixes() + require.Len(t, fixes, 1) + + for _, fix := range fixes { + fix() + } + + require.Equal(t, map[string]int{"active": 1}, ran) +} From 3fc2f7a134f8be686e7e0daf9461bf56f963f9a4 Mon Sep 17 00:00:00 2001 From: Ivan Zvyagintsev Date: Mon, 21 Sep 2026 23:43:27 +0300 Subject: [PATCH 12/58] rbac: close the gaps found by checking the rules against the ADR and spec - contract: the module label of a framework role or capability must be the module's name (R21); helpers without a kind label, such as d8:dict, are not judged. - sync: a generated file whose header names another contract version is a divergence and is regenerated (R40); an rbac.yaml inside an edition overlay (ee/modules, ee/be/modules, ...) is an error, the declaration lives in modules// only (R8a, D7). - coverage and sync autofixes run once per target however many render variants reported it (R36); the render variants record what they grant at lint time and the sync fix judges the union, so a right rendered only under some values is not dropped (D3). - rbac.yaml: a when condition must parse as a Helm expression (R13c). - e2e: the hand-labelled contract cases carry the module label. Signed-off-by: Ivan Zvyagintsev --- pkg/linters/rbac/README.md | 12 +- pkg/linters/rbac/rules/contract.go | 10 +- pkg/linters/rbac/rules/contract_test.go | 33 +++-- pkg/linters/rbac/rules/coverage.go | 34 +++-- pkg/linters/rbac/rules/coverage_test.go | 35 +++++ pkg/linters/rbac/rules/fixstate.go | 125 ++++++++++++++++ pkg/linters/rbac/rules/rbacyaml/load_test.go | 18 +++ pkg/linters/rbac/rules/rbacyaml/validate.go | 32 +++++ pkg/linters/rbac/rules/sync.go | 93 +++++++----- pkg/linters/rbac/rules/sync_test.go | 136 +++++++++++++++++- .../module/templates/rbacv2/use/view.yaml | 1 + .../module/templates/rbacv2/roles/viewer.yaml | 1 + .../module/templates/rbacv2/use/view.yaml | 1 + 13 files changed, 466 insertions(+), 65 deletions(-) create mode 100644 pkg/linters/rbac/rules/fixstate.go diff --git a/pkg/linters/rbac/README.md b/pkg/linters/rbac/README.md index a1d32a20..aab73c72 100644 --- a/pkg/linters/rbac/README.md +++ b/pkg/linters/rbac/README.md @@ -1496,7 +1496,7 @@ Works on the rendered ClusterRoles from `templates/rbacv2/` (the compatibility a **What it checks:** -1. The name starts with `d8:`; the four `en|ru.meta.deckhouse.io/title|description` annotations are present. +1. The name starts with `d8:`; the four `en|ru.meta.deckhouse.io/title|description` annotations are present; the `module` label is the module's name (the platform test cannot know the module; dmt does). 2. `rbac.deckhouse.io/kind` is `role` or `capability`; `rbac.deckhouse.io/scope` is `system`, `subsystem`, `namespace` or `project`. 3. A role: its name matches the pattern of its scope, it defines no `rules`, its `aggregationRule` selects only by `aggregate-to--as` labels with a known lineage and a level of that lineage; system/subsystem roles carry `rbac.deckhouse.io/use-role` with a valid level. 4. A capability: its name starts with the prefix of its scope, it defines `rules` and no `aggregationRule`, carries at least one `aggregate-to--as` label and a valid `rbac.deckhouse.io/capability` marker (a label value, at most 63 characters). @@ -1617,6 +1617,7 @@ controller ClusterRoles with arbitrary names, objects with Helm-computed names). 2. A capability's aggregation edges (`aggregate-to--as`) match in both directions: rules may agree while a lineage is lost. Its `rbac.deckhouse.io/capability` marker, `module` and `rbac.deckhouse.io/namespace` labels are what the generator writes. 3. A binding's `roleRef` and subjects match. 4. Every rendered legacy role and module capability is produced by the declaration. +5. A generated file names the contract version it was generated under in its header; a file of another version is a divergence and is regenerated. Findings are one per template file and carry the fix command; the text does not depend on the render variant. @@ -1625,7 +1626,10 @@ Findings are one per template file and carry the fix command; the text does not - a file without the generator header is maintained by hand: the generated text is written beside it as `.generated` and the finding stays (delete the file and run `--fix` again to hand it back to the generator); - the regenerated file must grant everything the render of that file grants today, rules and aggregation edges alike; otherwise the file is left alone and the finding names what would be lost. Removing a right is always a person's decision: declare it in `rbac.yaml` or remove it from the template by hand. -A missing file is created. A second `--fix` without changes to `rbac.yaml` changes nothing. +A missing file is created. A second `--fix` without changes to `rbac.yaml` changes nothing. Under +`--matrix` every render variant reports the file, but the fix runs once: the variants record what +their renders grant while they exist, the first closure checks the union and writes, the others +report its outcome -- so a right rendered only under some values is never dropped. **Example finding:** @@ -1654,7 +1658,9 @@ linters-settings: **Limits worth knowing:** - A conditional rule is checked only where it renders: with the default values, `dmt lint --values-file` or `dmt lint --matrix`. -- The declaration is one per module and describes the union of editions. Linting a single edition directory shows the edition-only objects as absent; lint the merged tree as CI does. +- The declaration is one per module and describes the union of editions. Linting a single edition directory shows the edition-only objects as absent; lint the merged tree as CI does. An `rbac.yaml` inside an edition overlay (`ee/modules`, `ee/be/modules`, ...) is an error: CI merges the overlays over `modules/` before linting, so a copy there would shadow the base one or go unseen. +- `impact: ignore` on a rule switches its autofix off with it: `--fix` never rewrites files on behalf of findings nobody sees. +- A `when` condition must parse as a Helm expression (sprig and Helm functions are known); whether it holds under the linter's value stubs is decided by the render -- a condition that breaks the render is reported by the `helm-render` rule, and the module is not linted further. - `dmt lint remote` does not run these rules: a published image carries no chart to render. - The keys of the `rbac` configuration blocks are checked: an unknown key is an error, not a silent no-op. diff --git a/pkg/linters/rbac/rules/contract.go b/pkg/linters/rbac/rules/contract.go index a724b519..9a5ccdca 100644 --- a/pkg/linters/rbac/rules/contract.go +++ b/pkg/linters/rbac/rules/contract.go @@ -130,7 +130,7 @@ func (r *ContractRule) Check(_ context.Context) { continue } - checkContract(role, scopes, errorList) + checkContract(role, r.module.GetName(), scopes, errorList) } } @@ -160,7 +160,7 @@ func (r *ContractRule) resourceScopes() rbacyaml.CRDScopes { // checkContract applies the contract to one rendered ClusterRole. The checks and their messages // follow the platform test so that both give the same verdict on a module. -func checkContract(role *rbacv1.ClusterRole, scopes rbacyaml.CRDScopes, errorList *errors.LintRuleErrorsList) { +func checkContract(role *rbacv1.ClusterRole, module string, scopes rbacyaml.CRDScopes, errorList *errors.LintRuleErrorsList) { name := role.Name labels := role.Labels annotations := role.Annotations @@ -169,6 +169,12 @@ func checkContract(role *rbacv1.ClusterRole, scopes rbacyaml.CRDScopes, errorLis errorList.Errorf("name %q must start with the d8: prefix", name) } + // The platform test cannot know which module a role or capability belongs to; dmt does (spec + // 005 R21). Helpers outside the framework (no kind label, such as d8:dict) are not judged. + if got, framework := labels[rbaccontract.LabelModule], labels[rbaccontract.LabelKind] != ""; framework && got != module { + errorList.Errorf("label %s must be the module name %q, got %q", rbaccontract.LabelModule, module, got) + } + for _, key := range rbaccontract.I18nAnnotations { if annotations[key] == "" { errorList.Errorf("missing the %s annotation: every RBACv2 role and capability carries localized en/ru title and description", key) diff --git a/pkg/linters/rbac/rules/contract_test.go b/pkg/linters/rbac/rules/contract_test.go index 4293778d..a181c6b1 100644 --- a/pkg/linters/rbac/rules/contract_test.go +++ b/pkg/linters/rbac/rules/contract_test.go @@ -57,6 +57,7 @@ func runContract(t *testing.T, modulePath string, objects ...rendered) []string m := mocks.NewModuleMock(minimock.NewController(t)) m.GetPathMock.Return(modulePath) + m.GetNameMock.Return("cert-manager") m.GetStorageMock.Return(storeOf(t, objects...)) errorList := errors.NewLintRuleErrorsList() @@ -90,15 +91,14 @@ func clusterRole(name string, labels map[string]string, annotations, body string } var ( - validCapability = clusterRole("d8:namespace-capability:cert-manager:view", map[string]string{ + validCapability = clusterRole("d8:namespace-capability:cert-manager:view", map[string]string{"module": "cert-manager", "rbac.deckhouse.io/kind": "capability", "rbac.deckhouse.io/scope": "namespace", "rbac.deckhouse.io/capability": "namespace-capability.cert-manager.view", "rbac.deckhouse.io/aggregate-to-namespace-as": "viewer", - "module": "cert-manager", }, i18n, "rules:\n- apiGroups: [cert-manager.io]\n resources: [certificates]\n verbs: [get, list, watch]\n") - validRole = clusterRole("d8:subsystem:networking:viewer", map[string]string{ + validRole = clusterRole("d8:subsystem:networking:viewer", map[string]string{"module": "cert-manager", "rbac.deckhouse.io/kind": "role", "rbac.deckhouse.io/scope": "subsystem", "rbac.deckhouse.io/subsystem": "networking", @@ -111,7 +111,7 @@ func TestContract_CleanObjectsAndOutOfScopeFiles(t *testing.T) { rendered{"templates/rbacv2/use/view.yaml", validCapability}, rendered{"templates/rbacv2/global/subsystem/roles/networking/viewer.yaml", validRole}, // the compatibility aliases keep the old names on purpose and are outside the contract - rendered{"templates/rbacv2-compat/aliases.yaml", clusterRole("d8:manage:networking:viewer", map[string]string{"rbac.deckhouse.io/kind": "role"}, "", "")}, + rendered{"templates/rbacv2-compat/aliases.yaml", clusterRole("d8:manage:networking:viewer", map[string]string{"module": "cert-manager", "rbac.deckhouse.io/kind": "role"}, "", "")}, // a controller ClusterRole elsewhere is none of the contract's business rendered{"templates/rbac-for-us.yaml", clusterRole("d8:cert-manager:controller", nil, "", "rules: []\n")}, // d8:dict is a helper outside the framework: only the prefix and the texts are required @@ -126,7 +126,7 @@ func TestContract_Findings(t *testing.T) { wantErrs []string }{ "name prefix": { - object: clusterRole("namespace-capability:x:view", map[string]string{ + object: clusterRole("namespace-capability:x:view", map[string]string{"module": "cert-manager", "rbac.deckhouse.io/kind": "capability", "rbac.deckhouse.io/scope": "namespace", "rbac.deckhouse.io/capability": "namespace-capability.x.view", "rbac.deckhouse.io/aggregate-to-namespace-as": "viewer", }, i18n, "rules:\n- apiGroups: [x.io]\n resources: [ys]\n verbs: [get]\n"), @@ -136,7 +136,7 @@ func TestContract_Findings(t *testing.T) { }, }, "missing i18n": { - object: clusterRole("d8:namespace-capability:x:view", map[string]string{ + object: clusterRole("d8:namespace-capability:x:view", map[string]string{"module": "cert-manager", "rbac.deckhouse.io/kind": "capability", "rbac.deckhouse.io/scope": "namespace", "rbac.deckhouse.io/capability": "namespace-capability.x.view", "rbac.deckhouse.io/aggregate-to-namespace-as": "viewer", }, "\n en.meta.deckhouse.io/title: \"t\"\n en.meta.deckhouse.io/description: \"d\"", "rules:\n- apiGroups: [x.io]\n resources: [ys]\n verbs: [get]\n"), @@ -146,7 +146,7 @@ func TestContract_Findings(t *testing.T) { }, }, "capability with aggregationRule and no marker": { - object: clusterRole("d8:namespace-capability:x:view", map[string]string{ + object: clusterRole("d8:namespace-capability:x:view", map[string]string{"module": "cert-manager", "rbac.deckhouse.io/kind": "capability", "rbac.deckhouse.io/scope": "namespace", "rbac.deckhouse.io/aggregate-to-namespace-as": "viewer", }, i18n, "rules:\n- apiGroups: [x.io]\n resources: [ys]\n verbs: [get]\naggregationRule:\n clusterRoleSelectors:\n - matchLabels: {a: b}\n"), @@ -156,7 +156,7 @@ func TestContract_Findings(t *testing.T) { }, }, "role with rules and a wrong selector": { - object: clusterRole("d8:namespace:viewer", map[string]string{ + object: clusterRole("d8:namespace:viewer", map[string]string{"module": "cert-manager", "rbac.deckhouse.io/kind": "role", "rbac.deckhouse.io/scope": "namespace", }, i18n, "rules:\n- apiGroups: [x.io]\n resources: [ys]\n verbs: [get]\naggregationRule:\n clusterRoleSelectors:\n - matchLabels:\n rbac.deckhouse.io/kind: capability\n"), wantErrs: []string{ @@ -165,7 +165,7 @@ func TestContract_Findings(t *testing.T) { }, }, "delegatable on a system role, use-role missing": { - object: clusterRole("d8:system:viewer", map[string]string{ + object: clusterRole("d8:system:viewer", map[string]string{"module": "cert-manager", "rbac.deckhouse.io/kind": "role", "rbac.deckhouse.io/scope": "system", "rbac.deckhouse.io/delegatable": "true", }, i18n, "aggregationRule:\n clusterRoleSelectors:\n - matchLabels:\n rbac.deckhouse.io/aggregate-to-system-as: viewer\n"), wantErrs: []string{ @@ -174,7 +174,7 @@ func TestContract_Findings(t *testing.T) { }, }, "R29: level not of the lineage": { - object: clusterRole("d8:system-capability:x:edit", map[string]string{ + object: clusterRole("d8:system-capability:x:edit", map[string]string{"module": "cert-manager", "rbac.deckhouse.io/kind": "capability", "rbac.deckhouse.io/scope": "system", "rbac.deckhouse.io/capability": "system-capability.x.edit", "rbac.deckhouse.io/aggregate-to-system-as": "admin", }, i18n, "rules:\n- apiGroups: [x.io]\n resources: [ys]\n verbs: [get]\n"), @@ -182,8 +182,17 @@ func TestContract_Findings(t *testing.T) { `error: aggregation label "rbac.deckhouse.io/aggregate-to-system-as" has invalid level "admin"; the system lineage has viewer, manager, superadmin`, }, }, + "R21: the module label names another module": { + object: clusterRole("d8:namespace-capability:x:view", map[string]string{"module": "other", + "rbac.deckhouse.io/kind": "capability", "rbac.deckhouse.io/scope": "namespace", + "rbac.deckhouse.io/capability": "namespace-capability.x.view", "rbac.deckhouse.io/aggregate-to-namespace-as": "viewer", + }, i18n, "rules:\n- apiGroups: [x.io]\n resources: [ys]\n verbs: [get]\n"), + wantErrs: []string{ + `error: label module must be the module name "cert-manager", got "other"`, + }, + }, "unknown lineage and bad scope label": { - object: clusterRole("d8:namespace-capability:x:view", map[string]string{ + object: clusterRole("d8:namespace-capability:x:view", map[string]string{"module": "cert-manager", "rbac.deckhouse.io/kind": "capability", "rbac.deckhouse.io/scope": "tenant", }, i18n, "rules:\n- apiGroups: [x.io]\n resources: [ys]\n verbs: [get]\n"), wantErrs: []string{ @@ -199,7 +208,7 @@ func TestContract_Findings(t *testing.T) { } func TestContract_ClusterScopedResourceInNamespaceCapabilityIsAWarning(t *testing.T) { - capability := clusterRole("d8:namespace-capability:cert-manager:view", map[string]string{ + capability := clusterRole("d8:namespace-capability:cert-manager:view", map[string]string{"module": "cert-manager", "rbac.deckhouse.io/kind": "capability", "rbac.deckhouse.io/scope": "namespace", "rbac.deckhouse.io/capability": "namespace-capability.cert-manager.view", diff --git a/pkg/linters/rbac/rules/coverage.go b/pkg/linters/rbac/rules/coverage.go index 167695c2..5df4dd16 100644 --- a/pkg/linters/rbac/rules/coverage.go +++ b/pkg/linters/rbac/rules/coverage.go @@ -70,9 +70,10 @@ func (r *CoverageRule) Check(_ context.Context) { errorList := r.errorList.WithFilePath(rbacyaml.Filename) decl, err := rbacyaml.Load(modulePath) - if err != nil { - // No declaration: nothing to cover (R22). A declaration that does not parse is the sync - // rule's finding; reporting it twice would only double the noise. + if err != nil || editionOverlay(modulePath) != "" { + // No declaration: nothing to cover (R22). A declaration that does not parse, or that lies + // in an edition overlay (D7), is the sync rule's finding; reporting it twice would only + // double the noise. return } @@ -143,18 +144,23 @@ func (r *CoverageRule) Check(_ context.Context) { // returns an error on purpose after a successful write: the stub is not a decision, and the // finding must stay in the output and in the exit code of the run that wrote it (R33). func appendStubFix(modulePath string, crd crdInfo) errors.AutofixFunc { - return func() error { - added, err := appendStub(rbacyaml.Path(modulePath), crd.Group, crd.Plural) - if err != nil { - return fmt.Errorf("add a stub for %s to %s: %w", crd.Key(), rbacyaml.Filename, err) - } + path := rbacyaml.Path(modulePath) - if !added { - return nil - } - - return fmt.Errorf("a stub for %s was added to %s; decide its access (noAccess: %q is not a decision)", - crd.Key(), rbacyaml.Filename, rbacyaml.NoAccessTODO) + return func() error { + // One stub per CRD per run, however many render variants reported it (R36). + return fixOnce(path+"#"+crd.Key(), func() error { + added, err := appendStub(path, crd.Group, crd.Plural) + if err != nil { + return fmt.Errorf("add a stub for %s to %s: %w", crd.Key(), rbacyaml.Filename, err) + } + + if !added { + return nil + } + + return fmt.Errorf("a stub for %s was added to %s; decide its access (noAccess: %q is not a decision)", + crd.Key(), rbacyaml.Filename, rbacyaml.NoAccessTODO) + }) } } diff --git a/pkg/linters/rbac/rules/coverage_test.go b/pkg/linters/rbac/rules/coverage_test.go index de196a2b..a2b05922 100644 --- a/pkg/linters/rbac/rules/coverage_test.go +++ b/pkg/linters/rbac/rules/coverage_test.go @@ -116,6 +116,9 @@ func TestCoverage_WithoutDeclarationIsSilent(t *testing.T) { } func TestCoverage_FindingsAndStubFix(t *testing.T) { + resetFixState() + t.Cleanup(resetFixState) + const declaration = `apiVersion: rbac.deckhouse.io/v1alpha1 # keep me: comments survive the autofix resources: @@ -226,3 +229,35 @@ func TestCoverage_ExcludedCRDAndDeclarationWithoutResources(t *testing.T) { assert.Equal(t, "a.io/alphas", decl.Resources[0].Key()) assert.Equal(t, rbacyaml.NoAccessTODO, decl.Resources[0].NoAccess) } + +// R36: two render variants report the same missing entry; the stub is written once and both +// findings end the run with the same outcome. +func TestCoverage_StubFixOncePerRun(t *testing.T) { + resetFixState() + t.Cleanup(resetFixState) + + modulePath := writeModule(t, map[string]string{ + "crds/a.yaml": crdYAML("a.io", "alphas", "Namespaced"), + rbacyaml.Filename: "apiVersion: rbac.deckhouse.io/v1alpha1\nresources: []\n", + }) + + variantA := runCoverage(t, modulePath) + variantB := runCoverage(t, modulePath) + + for _, list := range []*errors.LintRuleErrorsList{variantA, variantB} { + for _, fix := range list.GetFixes() { + fix() + } + } + + for _, list := range []*errors.LintRuleErrorsList{variantA, variantB} { + remaining := list.GetErrors() + require.Len(t, remaining, 1) + require.Error(t, remaining[0].FixError) + assert.Contains(t, remaining[0].FixError.Error(), "a stub for a.io/alphas was added to rbac.yaml") + } + + after, err := os.ReadFile(rbacyaml.Path(modulePath)) + require.NoError(t, err) + assert.Equal(t, 1, strings.Count(string(after), "resource: alphas"), "one stub, not one per variant") +} diff --git a/pkg/linters/rbac/rules/fixstate.go b/pkg/linters/rbac/rules/fixstate.go new file mode 100644 index 00000000..3769c345 --- /dev/null +++ b/pkg/linters/rbac/rules/fixstate.go @@ -0,0 +1,125 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rules + +import ( + "path/filepath" + "strings" + "sync" +) + +// fixState is what the autofixes of the coverage and sync rules share across render variants of +// one run. dmt lints a module once per variant under --matrix and every variant collects its own +// finding with its own closure; the state makes them behave as one fix per target (spec 005 R36): +// +// - outcomes remembers the result of the first closure that ran for a target, so the others +// return it instead of doing the work again, and every copy of the finding ends the run in +// the same state; +// - rendered accumulates, at lint time, the rights every variant's render grants in a file, so +// the drop guard of the sync autofix judges the union rather than the render of whichever +// variant happened to run its closure first (D3). +var fixState = struct { + sync.Mutex + outcomes map[string]error + rendered map[string]map[string]struct{} +}{ + outcomes: map[string]error{}, + rendered: map[string]map[string]struct{}{}, +} + +// fixOnce runs fix for the key the first time it is asked and returns that outcome on every later +// call. Fixes run one at a time (Manager.ApplyFixes is sequential), and the fix itself reads the +// state, so it runs outside the lock. +func fixOnce(key string, fix func() error) error { + fixState.Lock() + err, done := fixState.outcomes[key] + fixState.Unlock() + + if done { + return err + } + + err = fix() + + fixState.Lock() + fixState.outcomes[key] = err + fixState.Unlock() + + return err +} + +// recordRenderedRights adds what one render variant grants in the file to what is known about it. +func recordRenderedRights(file string, rights map[string]struct{}) { + fixState.Lock() + defer fixState.Unlock() + + known := fixState.rendered[file] + if known == nil { + known = map[string]struct{}{} + fixState.rendered[file] = known + } + + for r := range rights { + known[r] = struct{}{} + } +} + +// renderedRights returns everything any render variant granted in the file. +func renderedRights(file string) map[string]struct{} { + fixState.Lock() + defer fixState.Unlock() + + out := make(map[string]struct{}, len(fixState.rendered[file])) + for r := range fixState.rendered[file] { + out[r] = struct{}{} + } + + return out +} + +// resetFixState forgets everything; tests call it between runs. +func resetFixState() { + fixState.Lock() + defer fixState.Unlock() + + fixState.outcomes = map[string]error{} + fixState.rendered = map[string]map[string]struct{}{} +} + +// editionOverlay returns the edition overlay a module directory lies in ("ee/modules", +// "ee/be/modules", ...) or "" for a module of the base tree or of an external repository. The +// declaration describes the union of editions and lives in modules// only (spec 005 D7, +// R8a): CI merges the overlays over modules/ before linting, so a declaration in an overlay would +// either shadow the base one or go unseen. +func editionOverlay(modulePath string) string { + parts := strings.Split(filepath.ToSlash(filepath.Clean(modulePath)), "/") + + // parts[n-1] is the module directory, parts[n-2] must be "modules". + n := len(parts) + if n < 3 || parts[n-2] != "modules" { + return "" + } + + switch { + case parts[n-3] == "ee": + return "ee/modules" + case n >= 4 && parts[n-4] == "ee": + return "ee/" + parts[n-3] + "/modules" + default: + return "" + } +} diff --git a/pkg/linters/rbac/rules/rbacyaml/load_test.go b/pkg/linters/rbac/rules/rbacyaml/load_test.go index 82f0d1bf..a185c34e 100644 --- a/pkg/linters/rbac/rules/rbacyaml/load_test.go +++ b/pkg/linters/rbac/rules/rbacyaml/load_test.go @@ -291,6 +291,24 @@ namespace: crds: certManagerCRDs, wantErr: `"namespace.admin" is used by a resource entry but has no title and description`, }, + "R13c: when that is not a Helm expression": { + yaml: entry(`group: cert-manager.io +resource: issuers +when: 'and (.Values.certManager.foo' +namespace: + viewer: [get]`), + crds: certManagerCRDs, + wantErr: `when "and (.Values.certManager.foo" is not a Helm expression`, + }, + "R13c: when with Helm and sprig functions parses": { + yaml: entry(`group: cert-manager.io +resource: issuers +when: 'and .Values.certManager.foo (semverCompare ">= 1.80" .Values.global.deckhouseVersion) (.Capabilities.APIVersions.Has "x/v1")' +namespace: + viewer: [get]`), + crds: certManagerCRDs, + wantErr: "", + }, "R26: namespaced resource at a system level without reason": { yaml: entry(`group: cert-manager.io resource: issuers diff --git a/pkg/linters/rbac/rules/rbacyaml/validate.go b/pkg/linters/rbac/rules/rbacyaml/validate.go index 9ef21508..f39c16cf 100644 --- a/pkg/linters/rbac/rules/rbacyaml/validate.go +++ b/pkg/linters/rbac/rules/rbacyaml/validate.go @@ -20,10 +20,38 @@ import ( "fmt" "slices" "strings" + "text/template" + + "github.com/Masterminds/sprig/v3" "github.com/deckhouse/dmt/pkg/linters/rbac/rules/rbaccontract" ) +// helmFuncs is the function set a `when` expression may use: sprig, as Helm ships it, plus the +// functions Helm's engine adds. Only the names matter here -- the expression is parsed, not +// evaluated; whether it holds under the linter's value stubs is the render's business. +var helmFuncs = func() template.FuncMap { + funcs := sprig.TxtFuncMap() + + for _, name := range []string{"include", "tpl", "required", "lookup", "toYaml", "fromYaml", "fromYamlArray", "toJson", "fromJson", "fromJsonArray", "toToml"} { + funcs[name] = func(...any) any { return nil } + } + + return funcs +}() + +// validateWhen rejects a condition that is not a Helm expression: the generator wraps it in +// {{- if }} verbatim, and a typo there breaks the render of the whole file (R13c). +func validateWhen(when, where string, report reporter) { + if when == "" { + return + } + + if _, err := template.New("when").Funcs(helmFuncs).Parse("{{ if " + when + " }}{{ end }}"); err != nil { + report("%s: when %q is not a Helm expression: %v", where, when, err) + } +} + // Validate checks the declaration against the format rules. crds is what the linted tree says // about the module's own resources (the scope of every CRD under crds/); an entry whose // resource is not in it is external, and its scope has to be declared. Every problem is @@ -92,6 +120,8 @@ func validateResource(r *Resource, where string, crds CRDScopes, usedCapabilitie report("%s: an entry must grant at least one level (namespace, system or legacy) or deny access with noAccess: \"\"", where) } + validateWhen(r.When, where, report) + scope, scopeErr := resolveScope(r, crds) if scopeErr != "" { report("%s: %s", where, scopeErr) @@ -238,6 +268,8 @@ func validateServiceAccounts(accounts []ServiceAccount, report reporter) { names[sa.Name] = struct{}{} + validateWhen(sa.When, where, report) + if strings.HasPrefix(sa.Path, "/") || strings.HasSuffix(sa.Path, "/") || strings.Contains(sa.Path, "..") { report("%s: path must be a directory under templates/ without leading or trailing slashes, got %q", where, sa.Path) } diff --git a/pkg/linters/rbac/rules/sync.go b/pkg/linters/rbac/rules/sync.go index da6b99e8..f258bc83 100644 --- a/pkg/linters/rbac/rules/sync.go +++ b/pkg/linters/rbac/rules/sync.go @@ -95,6 +95,13 @@ func (r *SyncRule) Check(_ context.Context) { return } + if overlay := editionOverlay(modulePath); overlay != "" { + declList.Errorf("%s lies in the edition overlay %s; the declaration describes the union of editions and belongs to modules// only -- CI merges the overlays over modules/ before linting, so a copy here would shadow it or go unseen. Only a person can close this: move the file", + rbacyaml.Filename, overlay) + + return + } + if err != nil { declList.Errorf("%v; nothing is compared or generated until the declaration parses", err) return @@ -151,6 +158,20 @@ func (r *SyncRule) Check(_ context.Context) { fmt.Sprintf("%s is in the render but rbac.yaml does not produce it: declare its rights in rbac.yaml or remove it from the template", identity)) } + // A generated file that names another contract version was written by a dmt of another + // contract; the objects may be labelled or named differently now, so it is regenerated (R40). + for _, file := range model.Files { + content, err := os.ReadFile(filepath.Join(modulePath, file.Path)) + if err != nil { + continue + } + + if generated, version := generate.ParseHeader(string(content)); generated && version != rbaccontract.ContractVersion { + divergences[file.Path] = append(divergences[file.Path], + fmt.Sprintf("the file was generated under contract version %q; the current contract is %q", version, rbaccontract.ContractVersion)) + } + } + paths := make([]string, 0, len(divergences)) for path, list := range divergences { if len(list) > 0 { @@ -406,54 +427,60 @@ func crdScopes(crds []crdInfo) rbacyaml.CRDScopes { // what would be lost (R25, D3). Removing a right is always a person's decision. func regenerateFix(modulePath string, file generate.File, actual map[string]managedObject) errors.AutofixFunc { content := generate.RenderFile(file) - current := currentRights(file.Path, actual) expected := expectedRights(file) + fullPath := filepath.Join(modulePath, file.Path) + + // Under --matrix the module is linted once per render variant and every variant collects its + // own finding with its own closure. Each records what its render grants now, while the store + // exists; the closure that runs first checks the union of them and writes, the others report + // its outcome (R36). A right rendered only under some values is therefore not lost (D3). + recordRenderedRights(fullPath, currentRights(file.Path, actual)) return func() error { - fullPath := filepath.Join(modulePath, file.Path) + return fixOnce(fullPath, func() error { + existing, err := os.ReadFile(fullPath) + exists := err == nil - existing, err := os.ReadFile(fullPath) - exists := err == nil + if err != nil && !stderrors.Is(err, os.ErrNotExist) { + return fmt.Errorf("read %s: %w", file.Path, err) + } - if err != nil && !stderrors.Is(err, os.ErrNotExist) { - return fmt.Errorf("read %s: %w", file.Path, err) - } + if exists { + if generated, _ := generate.ParseHeader(string(existing)); !generated { + aside := fullPath + ".generated" + if err := os.WriteFile(aside, []byte(content), 0o600); err != nil { + return fmt.Errorf("write %s: %w", file.Path+".generated", err) + } - if exists { - if generated, _ := generate.ParseHeader(string(existing)); !generated { - aside := fullPath + ".generated" - if err := os.WriteFile(aside, []byte(content), 0o600); err != nil { - return fmt.Errorf("write %s: %w", file.Path+".generated", err) + return fmt.Errorf("%s is maintained by hand (no generator header); the generated version is beside it as %s.generated -- compare, then either delete the file and run `%s` again, or keep maintaining it by hand", + file.Path, file.Path, FixCommand) } - return fmt.Errorf("%s is maintained by hand (no generator header); the generated version is beside it as %s.generated -- compare, then either delete the file and run `%s` again, or keep maintaining it by hand", - file.Path, file.Path, FixCommand) + if strings.Contains(string(existing), "deckhouseVersion") { + return fmt.Errorf("%s renders different objects depending on the platform version; regenerating it would drop one of the two schemes -- resolve the version condition by hand first", file.Path) + } } - if strings.Contains(string(existing), "deckhouseVersion") { - return fmt.Errorf("%s renders different objects depending on the platform version; regenerating it would drop one of the two schemes -- resolve the version condition by hand first", file.Path) + if dropped := sortedSetDiff(renderedRights(fullPath), expected); len(dropped) > 0 { + return fmt.Errorf("regenerating %s would drop rights the render grants today: %s; declare them in %s or remove them from the template by hand", + file.Path, strings.Join(dropped, ", "), rbacyaml.Filename) } - } - - if dropped := sortedSetDiff(current, expected); len(dropped) > 0 { - return fmt.Errorf("regenerating %s would drop rights the render grants today: %s; declare them in %s or remove them from the template by hand", - file.Path, strings.Join(dropped, ", "), rbacyaml.Filename) - } - if exists && string(existing) == content { - return nil - } + if exists && string(existing) == content { + return nil + } - if err := os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil { - return fmt.Errorf("create %s: %w", filepath.Dir(file.Path), err) - } + if err := os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil { + return fmt.Errorf("create %s: %w", filepath.Dir(file.Path), err) + } - perm := os.FileMode(0o644) - if info, err := os.Stat(fullPath); err == nil { - perm = info.Mode().Perm() - } + perm := os.FileMode(0o644) + if info, err := os.Stat(fullPath); err == nil { + perm = info.Mode().Perm() + } - return os.WriteFile(fullPath, []byte(content), perm) + return os.WriteFile(fullPath, []byte(content), perm) + }) } } diff --git a/pkg/linters/rbac/rules/sync_test.go b/pkg/linters/rbac/rules/sync_test.go index 341464c5..eae5369e 100644 --- a/pkg/linters/rbac/rules/sync_test.go +++ b/pkg/linters/rbac/rules/sync_test.go @@ -302,7 +302,12 @@ func TestSync_WithoutDeclarationIsSilent(t *testing.T) { } func TestSync_Autofix(t *testing.T) { + resetFixState() + t.Cleanup(resetFixState) + t.Run("regenerates a missing file and is idempotent", func(t *testing.T) { + resetFixState() + modulePath := syncModuleDir(t) model := syncModel(t, modulePath) @@ -325,7 +330,9 @@ func TestSync_Autofix(t *testing.T) { assert.True(t, generated) assert.Equal(t, "1", version) - // Running the same fix again changes nothing. + // Running the same fix again, in a new run, changes nothing. + resetFixState() + before, _ := os.Stat(filepath.Join(modulePath, "templates/rbacv2/use/view.yaml")) for _, fix := range runSync(t, modulePath, store).GetFixes() { fix() @@ -336,6 +343,8 @@ func TestSync_Autofix(t *testing.T) { }) t.Run("D3: refuses to drop a right the render grants", func(t *testing.T) { + resetFixState() + modulePath := syncModuleDir(t) model := syncModel(t, modulePath) store := renderedFrom(t, model, func(o *generate.Object) bool { @@ -367,6 +376,8 @@ func TestSync_Autofix(t *testing.T) { }) t.Run("US-F2: a file without the header is maintained by hand", func(t *testing.T) { + resetFixState() + modulePath := syncModuleDir(t) model := syncModel(t, modulePath) store := renderedFrom(t, model, func(o *generate.Object) bool { @@ -400,3 +411,126 @@ func TestSync_Autofix(t *testing.T) { assert.True(t, strings.HasPrefix(string(aside), generate.Header())) }) } + +// R40: a file whose header names another contract version is a divergence, and the fix rewrites it. +func TestSync_ForeignContractVersionIsRegenerated(t *testing.T) { + resetFixState() + t.Cleanup(resetFixState) + + modulePath := syncModuleDir(t) + model := syncModel(t, modulePath) + store := renderedFrom(t, model, nil) + + const rel = "templates/rbacv2/use/view.yaml" + + want := generate.RenderFile(*model.File(rel)) + _, body, _ := strings.Cut(want, "\n") + stale := strings.Replace(generate.Header(), "contract 1.", "contract 0.", 1) + "\n" + body + + path := filepath.Join(modulePath, rel) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte(stale), 0o600)) + + errorList := runSync(t, modulePath, store) + got := texts(errorList) + require.Len(t, got, 1, "got: %v", got) + assert.Contains(t, got[0], `templates/rbacv2/use/view.yaml does not match rbac.yaml: the file was generated under contract version "0"; the current contract is "1". Run `+"`dmt lint --linter rbac --fix`") + + for _, fix := range errorList.GetFixes() { + fix() + } + + assert.Empty(t, errorList.GetErrors()) + + written, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, want, string(written)) +} + +// R36/D3: under --matrix every variant records its render at lint time, so the closure that runs +// first refuses to drop a right only another variant rendered, and the other closures report the +// same outcome instead of writing. +func TestSync_FixSeesEveryRenderVariant(t *testing.T) { + resetFixState() + t.Cleanup(resetFixState) + + modulePath := syncModuleDir(t) + model := syncModel(t, modulePath) + + // Both variants lack a declared rule (the file is stale); variant A also grants a right that is + // not declared -- as a template with a hand-written {{ if }} would under some values. + stale := func(o *generate.Object) bool { + if o.Name == "d8:user-authz:cert-manager:user" { + o.Rules = o.Rules[:len(o.Rules)-1] + } + + return true + } + variantB := renderedFrom(t, model, stale) + variantA := renderedFrom(t, model, func(o *generate.Object) bool { + stale(o) + + if o.Name == "d8:user-authz:cert-manager:user" { + o.Rules = append(o.Rules, generate.Rule{PolicyRule: rbacyaml.PolicyRule{APIGroups: []string{""}, Resources: []string{"secrets"}, Verbs: []string{"get"}}}) + } + + return true + }) + + path := filepath.Join(modulePath, "templates/user-authz-cluster-roles.yaml") + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte(generate.Header()+"\n# stale\n"), 0o600)) + + // Lint both variants first, as the manager does, then apply the fixes: B's closure runs first. + listB := runSync(t, modulePath, variantB) + listA := runSync(t, modulePath, variantA) + require.Len(t, listB.GetFixes(), 1) + require.Len(t, listA.GetFixes(), 1) + + for _, list := range []*errors.LintRuleErrorsList{listB, listA} { + for _, fix := range list.GetFixes() { + fix() + } + } + + for name, list := range map[string]*errors.LintRuleErrorsList{"B": listB, "A": listA} { + remaining := list.GetErrors() + require.Len(t, remaining, 1, "variant %s", name) + require.Error(t, remaining[0].FixError, "variant %s", name) + assert.Contains(t, remaining[0].FixError.Error(), `would drop rights the render grants today: ClusterRole/d8:user-authz:cert-manager:user: get ""/secrets`, "variant %s", name) + } + + unchanged, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, generate.Header()+"\n# stale\n", string(unchanged), "no variant wrote the file") +} + +// R8a/D7: a declaration in an edition overlay is reported by sync and ignored by coverage. +func TestSync_DeclarationInEditionOverlay(t *testing.T) { + src := syncModuleDir(t) + modulePath := filepath.Join(t.TempDir(), "ee", "be", "modules", "101-cert-manager") + require.NoError(t, os.MkdirAll(filepath.Dir(modulePath), 0o755)) + require.NoError(t, os.Rename(src, modulePath)) + + got := texts(runSync(t, modulePath, storage.NewUnstructuredObjectStore())) + require.Len(t, got, 1, "got: %v", got) + assert.Contains(t, got[0], "rbac.yaml lies in the edition overlay ee/be/modules; the declaration describes the union of editions and belongs to modules// only") + assert.Contains(t, got[0], "Only a person can close this") + + assert.Empty(t, texts(runCoverage(t, modulePath)), "coverage leaves the overlay finding to sync") +} + +func TestEditionOverlay(t *testing.T) { + for path, want := range map[string]string{ + "/r/modules/101-cert-manager": "", + "/r/ee/modules/500-x": "ee/modules", + "/r/ee/be/modules/500-x": "ee/be/modules", + "/r/ee/se-plus/modules/500-x/": "ee/se-plus/modules", + "/r/ee/fe/x": "", + "/r/external/x": "", + "/r/ee/x": "", + "modules/x": "", + } { + assert.Equal(t, want, editionOverlay(path), path) + } +} diff --git a/test/e2e/testdata/rbac/contract-cluster-scoped-in-namespace-capability/module/templates/rbacv2/use/view.yaml b/test/e2e/testdata/rbac/contract-cluster-scoped-in-namespace-capability/module/templates/rbacv2/use/view.yaml index 5b13c1cd..12b299d7 100644 --- a/test/e2e/testdata/rbac/contract-cluster-scoped-in-namespace-capability/module/templates/rbacv2/use/view.yaml +++ b/test/e2e/testdata/rbac/contract-cluster-scoped-in-namespace-capability/module/templates/rbacv2/use/view.yaml @@ -3,6 +3,7 @@ kind: ClusterRole metadata: name: d8:namespace-capability:e2e-rbac:view labels: + module: e2e-rbac rbac.deckhouse.io/kind: capability rbac.deckhouse.io/scope: namespace rbac.deckhouse.io/capability: namespace-capability.e2e-rbac.view diff --git a/test/e2e/testdata/rbac/contract-violations/module/templates/rbacv2/roles/viewer.yaml b/test/e2e/testdata/rbac/contract-violations/module/templates/rbacv2/roles/viewer.yaml index e07f0caf..d4e29cac 100644 --- a/test/e2e/testdata/rbac/contract-violations/module/templates/rbacv2/roles/viewer.yaml +++ b/test/e2e/testdata/rbac/contract-violations/module/templates/rbacv2/roles/viewer.yaml @@ -3,6 +3,7 @@ kind: ClusterRole metadata: name: d8:namespace:viewer labels: + module: e2e-rbac rbac.deckhouse.io/kind: role rbac.deckhouse.io/scope: namespace rbac.deckhouse.io/delegatable: "true" diff --git a/test/e2e/testdata/rbac/contract-violations/module/templates/rbacv2/use/view.yaml b/test/e2e/testdata/rbac/contract-violations/module/templates/rbacv2/use/view.yaml index f5a127d7..c24bb657 100644 --- a/test/e2e/testdata/rbac/contract-violations/module/templates/rbacv2/use/view.yaml +++ b/test/e2e/testdata/rbac/contract-violations/module/templates/rbacv2/use/view.yaml @@ -3,6 +3,7 @@ kind: ClusterRole metadata: name: d8:namespace-capability:e2e-rbac:view labels: + module: e2e-rbac rbac.deckhouse.io/kind: capability rbac.deckhouse.io/scope: namespace rbac.deckhouse.io/aggregate-to-namespace-as: viewer From 404685e2278e5f03911877c486daee9aaf619e72 Mon Sep 17 00:00:00 2001 From: Ivan Zvyagintsev Date: Mon, 21 Sep 2026 23:48:09 +0300 Subject: [PATCH 13/58] rbac: start the declaration rules at warn wherever nothing sets them contract, coverage and sync are new to every tree: a module without rbac.yaml sees only contract, and the platform tree still carries six dead rbac.yaml files of an older shape and rules the contract flags. Like the style rules of the documentation linter they now default to warn, whatever impact the rbac linter has, and a tree raises them to error in its root .dmtlint.yaml once its modules are clean. The four original rules keep the linter level. Signed-off-by: Ivan Zvyagintsev --- internal/modules/module.go | 14 ++++++++++---- internal/modules/rbac_rules_config_test.go | 4 ++-- pkg/linters/rbac/README.md | 6 +++++- .../rbac/contract-violations/expected.yaml | 8 ++++---- .../rbac/coverage-fix-keeps-finding/expected.yaml | 2 +- .../rbac/coverage-missing-entry/expected.yaml | 2 +- test/e2e/testdata/rbac/coverage-todo/expected.yaml | 2 +- .../testdata/rbac/sync-hand-edited/expected.yaml | 4 ++-- 8 files changed, 26 insertions(+), 16 deletions(-) diff --git a/internal/modules/module.go b/internal/modules/module.go index c12ddc61..7182b0d7 100644 --- a/internal/modules/module.go +++ b/internal/modules/module.go @@ -240,10 +240,16 @@ func mapRuleSettings(linterSettings *pkg.LintersSettings, configSettings *config // mapRBACRules configures the per-rule levels of the rbac rules added for the module RBAC // declaration: coverage, sync and contract read their impact from the root configuration and // fall back to the linter's. -func mapRBACRules(linterSettings *pkg.LintersSettings, configSettings *config.LintersSettings, globalConfig *global.Linters) { - linterSettings.RBAC.Rules.CoverageRule.SetLevel(globalConfig.Rbac.Rules.CoverageRule.Impact, configSettings.Rbac.Impact) - linterSettings.RBAC.Rules.SyncRule.SetLevel(globalConfig.Rbac.Rules.SyncRule.Impact, configSettings.Rbac.Impact) - linterSettings.RBAC.Rules.ContractRule.SetLevel(globalConfig.Rbac.Rules.ContractRule.Impact, configSettings.Rbac.Impact) +func mapRBACRules(linterSettings *pkg.LintersSettings, _ *config.LintersSettings, globalConfig *global.Linters) { + // The declaration rules are new to every tree: a module without rbac.yaml sees only contract, + // and the platform tree still carries six dead rbac.yaml files of an older shape and rules the + // contract flags. They therefore start at warn wherever nothing sets them -- like the style + // rules of the documentation linter -- and are raised to error per tree in its root + // .dmtlint.yaml once its modules are clean. The linter-level impact is intentionally not the + // fallback: impact: error on rbac means the four original rules, as it always did. + linterSettings.RBAC.Rules.CoverageRule.SetLevel(globalConfig.Rbac.Rules.CoverageRule.Impact, pkg.Warn.String()) + linterSettings.RBAC.Rules.SyncRule.SetLevel(globalConfig.Rbac.Rules.SyncRule.Impact, pkg.Warn.String()) + linterSettings.RBAC.Rules.ContractRule.SetLevel(globalConfig.Rbac.Rules.ContractRule.Impact, pkg.Warn.String()) } // mapContainerRules configures Container linter rules diff --git a/internal/modules/rbac_rules_config_test.go b/internal/modules/rbac_rules_config_test.go index 77698a12..8103de33 100644 --- a/internal/modules/rbac_rules_config_test.go +++ b/internal/modules/rbac_rules_config_test.go @@ -29,7 +29,7 @@ import ( // The rules added for the module RBAC declaration read their own impact from the root // configuration; the four original rbac rules keep the linter level whatever the root says. func TestRemapLinterSettings_RBACDeclarationRules(t *testing.T) { - t.Run("per-rule levels from the root configuration, linter level as fallback", func(t *testing.T) { + t.Run("per-rule levels from the root configuration, warn as the fallback", func(t *testing.T) { settings := remapLinterSettings( &config.LintersSettings{Rbac: config.RbacSettings{Impact: pkg.Error.String()}}, &global.Linters{Rbac: global.RbacLinterConfig{ @@ -43,7 +43,7 @@ func TestRemapLinterSettings_RBACDeclarationRules(t *testing.T) { require.Equal(t, pkg.Warn, *settings.RBAC.Rules.CoverageRule.GetLevel()) require.Equal(t, pkg.Ignored, *settings.RBAC.Rules.SyncRule.GetLevel()) - require.Equal(t, pkg.Error, *settings.RBAC.Rules.ContractRule.GetLevel(), "unset falls back to the linter level") + require.Equal(t, pkg.Warn, *settings.RBAC.Rules.ContractRule.GetLevel(), "unset starts at warn, whatever the linter level says") // SC5: the original rules are untouched by the per-rule block. for _, rule := range []*pkg.RuleConfig{ diff --git a/pkg/linters/rbac/README.md b/pkg/linters/rbac/README.md index aab73c72..d246915b 100644 --- a/pkg/linters/rbac/README.md +++ b/pkg/linters/rbac/README.md @@ -1521,7 +1521,7 @@ global: linters-settings: rbac: rules: - contract: {impact: warn} # the level of this rule alone; the four original rules keep the linter level + contract: {impact: error} # the level of this rule alone; unset it starts at warn, and the four original rules keep the linter level # module .dmtlint.yaml linters-settings: @@ -1655,6 +1655,10 @@ linters-settings: name: d8:user-authz:my-module:super-admin ``` +**Levels:** `contract`, `coverage` and `sync` start at `warn` wherever nothing sets them, whatever +`impact` the `rbac` linter has: they are new to every tree. A tree raises them to `error` in its root +`.dmtlint.yaml` (`global.linters-settings.rbac.rules..impact`) once its modules are clean. + **Limits worth knowing:** - A conditional rule is checked only where it renders: with the default values, `dmt lint --values-file` or `dmt lint --matrix`. diff --git a/test/e2e/testdata/rbac/contract-violations/expected.yaml b/test/e2e/testdata/rbac/contract-violations/expected.yaml index 5132e7bd..be747677 100644 --- a/test/e2e/testdata/rbac/contract-violations/expected.yaml +++ b/test/e2e/testdata/rbac/contract-violations/expected.yaml @@ -5,21 +5,21 @@ module: module expect: - linter: rbac rule: contract - level: error + level: warn textContains: "missing the ru.meta.deckhouse.io/title annotation" count: 1 - linter: rbac rule: contract - level: error + level: warn textContains: "missing the ru.meta.deckhouse.io/description annotation" count: 1 - linter: rbac rule: contract - level: error + level: warn textContains: 'capability "d8:namespace-capability:e2e-rbac:view" must carry the rbac.deckhouse.io/capability label' count: 1 - linter: rbac rule: contract - level: error + level: warn textContains: 'role "d8:namespace:viewer" must not define its own rules; move them into a capability' count: 1 diff --git a/test/e2e/testdata/rbac/coverage-fix-keeps-finding/expected.yaml b/test/e2e/testdata/rbac/coverage-fix-keeps-finding/expected.yaml index 7cbb433c..ac19b609 100644 --- a/test/e2e/testdata/rbac/coverage-fix-keeps-finding/expected.yaml +++ b/test/e2e/testdata/rbac/coverage-fix-keeps-finding/expected.yaml @@ -6,6 +6,6 @@ module: module expect: - linter: rbac rule: coverage - level: error + level: warn textContains: "CRD e2e.deckhouse.io/globals (crds/vendor/globals.yaml) has no entry in rbac.yaml" count: 1 diff --git a/test/e2e/testdata/rbac/coverage-missing-entry/expected.yaml b/test/e2e/testdata/rbac/coverage-missing-entry/expected.yaml index 6acdf665..261b4c33 100644 --- a/test/e2e/testdata/rbac/coverage-missing-entry/expected.yaml +++ b/test/e2e/testdata/rbac/coverage-missing-entry/expected.yaml @@ -5,6 +5,6 @@ module: module expect: - linter: rbac rule: coverage - level: error + level: warn textContains: "CRD e2e.deckhouse.io/globals (crds/vendor/globals.yaml) has no entry in rbac.yaml" count: 1 diff --git a/test/e2e/testdata/rbac/coverage-todo/expected.yaml b/test/e2e/testdata/rbac/coverage-todo/expected.yaml index d2086984..979618fd 100644 --- a/test/e2e/testdata/rbac/coverage-todo/expected.yaml +++ b/test/e2e/testdata/rbac/coverage-todo/expected.yaml @@ -5,7 +5,7 @@ module: module expect: - linter: rbac rule: coverage - level: error + level: warn textContains: 'e2e.deckhouse.io/widgets is still noAccess: "TODO" in rbac.yaml' count: 1 - linter: rbac diff --git a/test/e2e/testdata/rbac/sync-hand-edited/expected.yaml b/test/e2e/testdata/rbac/sync-hand-edited/expected.yaml index 550be9ef..c35fcb00 100644 --- a/test/e2e/testdata/rbac/sync-hand-edited/expected.yaml +++ b/test/e2e/testdata/rbac/sync-hand-edited/expected.yaml @@ -5,12 +5,12 @@ module: module expect: - linter: rbac rule: sync - level: error + level: warn textContains: 'templates/rbacv2/use/edit.yaml does not match rbac.yaml: ClusterRole/d8:namespace-capability:cert-manager:edit: get ""/secrets is in the render but not declared' count: 1 - linter: rbac rule: sync - level: error + level: warn textContains: "templates/user-authz-cluster-roles.yaml does not match rbac.yaml: ClusterRole/d8:user-authz:cert-manager:super-admin is in the render but rbac.yaml does not produce it" count: 1 expectPass: From 7693c75e625d72f51dc9d566a24befa9fdffc95e Mon Sep 17 00:00:00 2001 From: Ivan Zvyagintsev Date: Tue, 22 Sep 2026 00:07:38 +0300 Subject: [PATCH 14/58] rbac: recognize the RBACv2 scheme before DKP 1.78 and the version gate that serves both A module meets the new rules in one of three states: only the manage/use scheme that preceded the 1.78 role model (an external module not yet migrated), only the 1.78 scheme, or both behind the version gate that rbacv2-migrate-module.sh writes (include ".rbacv2_new_scheme"). - contract: a legacy object (kind: use or manage) gets one finding -- migrate -- instead of failing every check of the contract; a legacy object rendered from a gated template is not reported, the module serves both models on purpose. - sync: when a generated file's template renders the legacy scheme, the finding names that as the cause once instead of listing the absent objects as a mystery; the legacy objects are not reported as extra. - sync --fix: a gated template is never regenerated -- regenerating it would drop the legacy branch -- and the refusal says so; the gate is detected by its helper name, not only by a literal deckhouseVersion. The check runs before the header guard, so a gated file is not called hand-maintained. - e2e: one case per state (scheme-legacy-only, scheme-legacy-with-declaration, scheme-dual, the last written by the platform's migration script). Signed-off-by: Ivan Zvyagintsev --- pkg/linters/rbac/README.md | 4 +- pkg/linters/rbac/rules/contract.go | 13 ++++ pkg/linters/rbac/rules/contract_test.go | 24 +++++- pkg/linters/rbac/rules/fixstate.go | 15 ++++ .../rbac/rules/rbaccontract/contract.go | 20 ++++- pkg/linters/rbac/rules/sync.go | 35 +++++++-- pkg/linters/rbac/rules/sync_test.go | 59 +++++++++++++++ .../testdata/rbac/scheme-dual/expected.yaml | 14 ++++ .../rbac/scheme-dual/module/crds/widgets.yaml | 25 +++++++ .../rbac/scheme-dual/module/module.yaml | 3 + .../module/openapi/config-values.yaml | 2 + .../scheme-dual/module/openapi/values.yaml | 4 + .../rbac/scheme-dual/module/rbac.yaml | 12 +++ .../module/templates/_rbacv2_compat.tpl | 16 ++++ .../module/templates/rbacv2/manage/edit.yaml | 75 +++++++++++++++++++ .../module/templates/rbacv2/manage/view.yaml | 69 +++++++++++++++++ .../module/templates/rbacv2/use/edit.yaml | 50 +++++++++++++ .../module/templates/rbacv2/use/view.yaml | 46 ++++++++++++ .../rbac/scheme-legacy-only/expected.yaml | 25 +++++++ .../module/crds/widgets.yaml | 25 +++++++ .../scheme-legacy-only/module/module.yaml | 3 + .../module/openapi/config-values.yaml | 2 + .../module/openapi/values.yaml | 4 + .../module/templates/rbacv2/manage/edit.yaml | 33 ++++++++ .../module/templates/rbacv2/manage/view.yaml | 30 ++++++++ .../module/templates/rbacv2/use/edit.yaml | 20 +++++ .../module/templates/rbacv2/use/view.yaml | 18 +++++ .../expected.yaml | 31 ++++++++ .../module/crds/widgets.yaml | 25 +++++++ .../module/module.yaml | 3 + .../module/openapi/config-values.yaml | 2 + .../module/openapi/values.yaml | 4 + .../module/rbac.yaml | 12 +++ .../module/templates/rbacv2/manage/edit.yaml | 33 ++++++++ .../module/templates/rbacv2/manage/view.yaml | 30 ++++++++ .../module/templates/rbacv2/use/edit.yaml | 20 +++++ .../module/templates/rbacv2/use/view.yaml | 18 +++++ 37 files changed, 816 insertions(+), 8 deletions(-) create mode 100644 test/e2e/testdata/rbac/scheme-dual/expected.yaml create mode 100644 test/e2e/testdata/rbac/scheme-dual/module/crds/widgets.yaml create mode 100644 test/e2e/testdata/rbac/scheme-dual/module/module.yaml create mode 100644 test/e2e/testdata/rbac/scheme-dual/module/openapi/config-values.yaml create mode 100644 test/e2e/testdata/rbac/scheme-dual/module/openapi/values.yaml create mode 100644 test/e2e/testdata/rbac/scheme-dual/module/rbac.yaml create mode 100644 test/e2e/testdata/rbac/scheme-dual/module/templates/_rbacv2_compat.tpl create mode 100644 test/e2e/testdata/rbac/scheme-dual/module/templates/rbacv2/manage/edit.yaml create mode 100644 test/e2e/testdata/rbac/scheme-dual/module/templates/rbacv2/manage/view.yaml create mode 100644 test/e2e/testdata/rbac/scheme-dual/module/templates/rbacv2/use/edit.yaml create mode 100644 test/e2e/testdata/rbac/scheme-dual/module/templates/rbacv2/use/view.yaml create mode 100644 test/e2e/testdata/rbac/scheme-legacy-only/expected.yaml create mode 100644 test/e2e/testdata/rbac/scheme-legacy-only/module/crds/widgets.yaml create mode 100644 test/e2e/testdata/rbac/scheme-legacy-only/module/module.yaml create mode 100644 test/e2e/testdata/rbac/scheme-legacy-only/module/openapi/config-values.yaml create mode 100644 test/e2e/testdata/rbac/scheme-legacy-only/module/openapi/values.yaml create mode 100644 test/e2e/testdata/rbac/scheme-legacy-only/module/templates/rbacv2/manage/edit.yaml create mode 100644 test/e2e/testdata/rbac/scheme-legacy-only/module/templates/rbacv2/manage/view.yaml create mode 100644 test/e2e/testdata/rbac/scheme-legacy-only/module/templates/rbacv2/use/edit.yaml create mode 100644 test/e2e/testdata/rbac/scheme-legacy-only/module/templates/rbacv2/use/view.yaml create mode 100644 test/e2e/testdata/rbac/scheme-legacy-with-declaration/expected.yaml create mode 100644 test/e2e/testdata/rbac/scheme-legacy-with-declaration/module/crds/widgets.yaml create mode 100644 test/e2e/testdata/rbac/scheme-legacy-with-declaration/module/module.yaml create mode 100644 test/e2e/testdata/rbac/scheme-legacy-with-declaration/module/openapi/config-values.yaml create mode 100644 test/e2e/testdata/rbac/scheme-legacy-with-declaration/module/openapi/values.yaml create mode 100644 test/e2e/testdata/rbac/scheme-legacy-with-declaration/module/rbac.yaml create mode 100644 test/e2e/testdata/rbac/scheme-legacy-with-declaration/module/templates/rbacv2/manage/edit.yaml create mode 100644 test/e2e/testdata/rbac/scheme-legacy-with-declaration/module/templates/rbacv2/manage/view.yaml create mode 100644 test/e2e/testdata/rbac/scheme-legacy-with-declaration/module/templates/rbacv2/use/edit.yaml create mode 100644 test/e2e/testdata/rbac/scheme-legacy-with-declaration/module/templates/rbacv2/use/view.yaml diff --git a/pkg/linters/rbac/README.md b/pkg/linters/rbac/README.md index d246915b..27896cdb 100644 --- a/pkg/linters/rbac/README.md +++ b/pkg/linters/rbac/README.md @@ -1502,7 +1502,8 @@ Works on the rendered ClusterRoles from `templates/rbacv2/` (the compatibility a 4. A capability: its name starts with the prefix of its scope, it defines `rules` and no `aggregationRule`, carries at least one `aggregate-to--as` label and a valid `rbac.deckhouse.io/capability` marker (a label value, at most 63 characters). 5. Aggregation labels target a known lineage (`system`, `namespace`, `project` or one of the seven subsystems) with a level that lineage has. 6. `rbac.deckhouse.io/delegatable` appears only on namespace/project roles. -7. **Warning:** a cluster-scoped resource inside a namespace capability. Such a capability is bound through a RoleBinding, where the rule grants nothing. The scope comes from the module's CRDs or from its `rbac.yaml`; a resource the run knows nothing about is not judged. +7. An object of the RBACv2 scheme before DKP 1.78 (`rbac.deckhouse.io/kind: use` or `manage`, names `d8:use:capability:module::*` / `d8:manage:permission:module::*`) gets one finding -- migrate with `rbacv2-migrate-module.sh` from `modules/140-user-authz/docs/internal/` of the deckhouse repository, or describe the module in `rbac.yaml` and run `--fix` -- instead of failing every check above. A legacy object rendered from a template that carries the script's version gate (`include ".rbacv2_new_scheme"`) is not reported: the module serves both models on purpose. +8. **Warning:** a cluster-scoped resource inside a namespace capability. Such a capability is bound through a RoleBinding, where the rule grants nothing. The scope comes from the module's CRDs or from its `rbac.yaml`; a resource the run knows nothing about is not judged. What deliberately stays in the platform test: the levels of sensitive capabilities and the closure of aggregation across two modules, and the global uniqueness of the capability marker -- a rule sees one module. @@ -1662,6 +1663,7 @@ linters-settings: **Limits worth knowing:** - A conditional rule is checked only where it renders: with the default values, `dmt lint --values-file` or `dmt lint --matrix`. +- **The three states a module can be in when the new `dmt` first runs.** *Only the legacy scheme* (an external module not yet migrated): `contract` reports one "migrate" finding per object; with an `rbac.yaml`, `sync` reports the generated files as absent and names the cause -- the template renders the legacy scheme -- and `--fix` leaves the legacy file alone (no generator header) with the generated version beside it. *Only the 1.78 scheme*: the ordinary case described above. *Both schemes behind the version gate* (`rbacv2-migrate-module.sh` without `--replace`): the linter's values answer the gate with the 1.78 model, so `contract` and `sync` see exactly the new objects and the legacy branch is neither judged nor "extra"; `--fix` never rewrites a gated file -- regenerating it would drop the legacy branch -- and says so. The legacy branch itself is exercised with `dmt lint --values-file` setting `global.deckhouseVersion` below 1.78; `--matrix` varies module values only, not the platform version. - The declaration is one per module and describes the union of editions. Linting a single edition directory shows the edition-only objects as absent; lint the merged tree as CI does. An `rbac.yaml` inside an edition overlay (`ee/modules`, `ee/be/modules`, ...) is an error: CI merges the overlays over `modules/` before linting, so a copy there would shadow the base one or go unseen. - `impact: ignore` on a rule switches its autofix off with it: `--fix` never rewrites files on behalf of findings nobody sees. - A `when` condition must parse as a Helm expression (sprig and Helm functions are known); whether it holds under the linter's value stubs is decided by the render -- a condition that breaks the render is reported by the `helm-render` rule, and the module is not linted further. diff --git a/pkg/linters/rbac/rules/contract.go b/pkg/linters/rbac/rules/contract.go index 9a5ccdca..14a0072e 100644 --- a/pkg/linters/rbac/rules/contract.go +++ b/pkg/linters/rbac/rules/contract.go @@ -130,6 +130,19 @@ func (r *ContractRule) Check(_ context.Context) { continue } + // An object of the scheme before 1.78 is not held to the contract check by check -- every + // one of them would fail, and the finding that matters is "migrate". A module that serves + // both models renders the legacy object only when the values say the cluster is below 1.78 + // (rbacv2-migrate-module.sh gates the template), and that is not a finding at all. + if kind := role.Labels[rbaccontract.LabelKind]; rbaccontract.IsLegacyKind(kind) { + if !templateHasGate(r.module.GetPath(), object.ShortPath()) { + errorList.Errorf("ClusterRole %q is of the legacy RBACv2 scheme (%s: %s, the manage/use model before DKP 1.78); migrate the module with rbacv2-migrate-module.sh from modules/140-user-authz/docs/internal/ of the deckhouse repository, or describe it in %s and run `%s`", + role.Name, rbaccontract.LabelKind, kind, rbacyaml.Filename, FixCommand) + } + + continue + } + checkContract(role, r.module.GetName(), scopes, errorList) } } diff --git a/pkg/linters/rbac/rules/contract_test.go b/pkg/linters/rbac/rules/contract_test.go index a181c6b1..6df12872 100644 --- a/pkg/linters/rbac/rules/contract_test.go +++ b/pkg/linters/rbac/rules/contract_test.go @@ -57,7 +57,8 @@ func runContract(t *testing.T, modulePath string, objects ...rendered) []string m := mocks.NewModuleMock(minimock.NewController(t)) m.GetPathMock.Return(modulePath) - m.GetNameMock.Return("cert-manager") + // A legacy object from a gated template stops before the module name is needed. + m.GetNameMock.Optional().Return("cert-manager") m.GetStorageMock.Return(storeOf(t, objects...)) errorList := errors.NewLintRuleErrorsList() @@ -228,3 +229,24 @@ func TestContract_ClusterScopedResourceInNamespaceCapabilityIsAWarning(t *testin `warn: capability "d8:namespace-capability:cert-manager:view" grants external.io/globals, a cluster-scoped resource, in a namespace capability: bound through a RoleBinding the rule grants nothing; move it to a system capability`, }, got) } + +// A module still on the manage/use scheme gets one finding per object, not the whole contract; a +// module that serves both schemes behind the version gate gets none for the legacy branch. +func TestContract_LegacyScheme(t *testing.T) { + legacy := clusterRole("d8:use:capability:module:cert-manager:view", map[string]string{ + "module": "cert-manager", "rbac.deckhouse.io/kind": "use", "rbac.deckhouse.io/aggregate-to-kubernetes-as": "viewer", + }, "", "rules:\n- apiGroups: [cert-manager.io]\n resources: [certificates]\n verbs: [get]\n") + + t.Run("legacy object alone", func(t *testing.T) { + got := runContract(t, t.TempDir(), rendered{"templates/rbacv2/use/view.yaml", legacy}) + require.Len(t, got, 1, "got: %v", got) + assert.Contains(t, got[0], `error: ClusterRole "d8:use:capability:module:cert-manager:view" is of the legacy RBACv2 scheme (rbac.deckhouse.io/kind: use, the manage/use model before DKP 1.78); migrate the module with rbacv2-migrate-module.sh`) + }) + + t.Run("legacy object rendered from a gated template", func(t *testing.T) { + modulePath := writeModule(t, map[string]string{ + "templates/rbacv2/use/view.yaml": "{{- if eq (include \"cert-manager.rbacv2_new_scheme\" .) \"true\" }}\n# new\n{{- else }}\n# legacy\n{{- end }}\n", + }) + assert.Empty(t, runContract(t, modulePath, rendered{"templates/rbacv2/use/view.yaml", legacy})) + }) +} diff --git a/pkg/linters/rbac/rules/fixstate.go b/pkg/linters/rbac/rules/fixstate.go index 3769c345..f6698cc3 100644 --- a/pkg/linters/rbac/rules/fixstate.go +++ b/pkg/linters/rbac/rules/fixstate.go @@ -17,9 +17,12 @@ limitations under the License. package rules import ( + "os" "path/filepath" "strings" "sync" + + "github.com/deckhouse/dmt/pkg/linters/rbac/rules/rbaccontract" ) // fixState is what the autofixes of the coverage and sync rules share across render variants of @@ -123,3 +126,15 @@ func editionOverlay(modulePath string) string { return "" } } + +// templateHasGate reports whether the template a rendered object came from carries the version +// gate of rbacv2-migrate-module.sh, i.e. renders one of two role models depending on +// global.deckhouseVersion. An unreadable template counts as ungated. +func templateHasGate(modulePath, shortPath string) bool { + content, err := os.ReadFile(filepath.Join(modulePath, shortPath)) + if err != nil { + return false + } + + return strings.Contains(string(content), rbaccontract.GateMarker) +} diff --git a/pkg/linters/rbac/rules/rbaccontract/contract.go b/pkg/linters/rbac/rules/rbaccontract/contract.go index af5274f5..1ee7a320 100644 --- a/pkg/linters/rbac/rules/rbaccontract/contract.go +++ b/pkg/linters/rbac/rules/rbaccontract/contract.go @@ -36,7 +36,19 @@ const ( LabelDelegatable = "rbac.deckhouse.io/delegatable" LabelNamespace = "rbac.deckhouse.io/namespace" LabelModule = "module" - LabelHeritage = "heritage" + + // KindLegacyUse and KindLegacyManage are the kinds of the RBACv2 scheme before the DKP 1.78 role + // model: d8:use:capability:module:: aggregated into aggregate-to-kubernetes-as, and + // d8:manage:permission:module:: aggregated into a subsystem. An external module may + // still ship them, alone or beside the new objects behind the version gate. + KindLegacyUse = "use" + KindLegacyManage = "manage" + + // GateMarker is the helper rbacv2-migrate-module.sh defines when it keeps both schemes in one + // template: `include ".rbacv2_new_scheme"` answers which one the render is for from + // global.deckhouseVersion. A template that carries it renders exactly one of the two. + GateMarker = "rbacv2_new_scheme" + LabelHeritage = "heritage" // AggregationLabelPrefix and AggregationLabelSuffix frame the lineage in // rbac.deckhouse.io/aggregate-to--as. @@ -220,3 +232,9 @@ const ( // I18nAnnotations lists the four annotations every RBACv2 role and capability must carry. var I18nAnnotations = []string{AnnotationTitleEN, AnnotationTitleRU, AnnotationDescriptionEN, AnnotationDescriptionRU} + +// IsLegacyKind reports whether the kind label names the manage/use scheme that preceded the 1.78 +// role model. +func IsLegacyKind(kind string) bool { + return kind == KindLegacyUse || kind == KindLegacyManage +} diff --git a/pkg/linters/rbac/rules/sync.go b/pkg/linters/rbac/rules/sync.go index f258bc83..2ae1236e 100644 --- a/pkg/linters/rbac/rules/sync.go +++ b/pkg/linters/rbac/rules/sync.go @@ -133,10 +133,20 @@ func (r *SyncRule) Check(_ context.Context) { } actual := r.managedObjects(model) + legacy := r.legacyFiles() divergences := map[string][]string{} for _, file := range model.Files { - divergences[file.Path] = append(divergences[file.Path], compareFile(file, actual, r.module.GetName())...) + found := compareFile(file, actual, r.module.GetName()) + + // The template renders the scheme before 1.78 where the declaration produces the new one: + // the objects the declaration names cannot be there. Say so once instead of listing them. + if kind, isLegacy := legacy[file.Path]; isLegacy && len(found) > 0 { + found = append(found, fmt.Sprintf("the template renders the legacy RBACv2 scheme (%s: %s, the manage/use model before DKP 1.78) where the declaration produces the 1.78 model; migrate the module with rbacv2-migrate-module.sh to serve both, or delete the file and run `%s` to serve the new one only", + rbaccontract.LabelKind, kind, FixCommand)) + } + + divergences[file.Path] = append(divergences[file.Path], found...) } // A legacy role or a module capability the declaration does not produce is an object the @@ -200,6 +210,20 @@ func (r *SyncRule) Check(_ context.Context) { } } +// legacyFiles maps the templates that rendered an object of the scheme before 1.78 to its kind. +// Those objects belong to no class the declaration produces; they are the module's old model. +func (r *SyncRule) legacyFiles() map[string]string { + out := map[string]string{} + + for _, object := range r.module.GetStorage() { + if kind := object.Unstructured.GetLabels()[rbaccontract.LabelKind]; object.Unstructured.GetKind() == "ClusterRole" && rbaccontract.IsLegacyKind(kind) { + out[object.ShortPath()] = kind + } + } + + return out +} + // managedObject is a rendered object the sync rule owns, with the class it was recognized by. type managedObject struct { object storage.StoreObject @@ -446,6 +470,11 @@ func regenerateFix(modulePath string, file generate.File, actual map[string]mana } if exists { + if strings.Contains(string(existing), rbaccontract.GateMarker) || strings.Contains(string(existing), "deckhouseVersion") { + return fmt.Errorf("%s renders one of two role models depending on the platform version (the %s gate of rbacv2-migrate-module.sh); regenerating it would drop the legacy branch -- edit the new branch by hand, or drop the gate and the legacy object once clusters below DKP 1.78 are no longer served, then run `%s`", + file.Path, rbaccontract.GateMarker, FixCommand) + } + if generated, _ := generate.ParseHeader(string(existing)); !generated { aside := fullPath + ".generated" if err := os.WriteFile(aside, []byte(content), 0o600); err != nil { @@ -455,10 +484,6 @@ func regenerateFix(modulePath string, file generate.File, actual map[string]mana return fmt.Errorf("%s is maintained by hand (no generator header); the generated version is beside it as %s.generated -- compare, then either delete the file and run `%s` again, or keep maintaining it by hand", file.Path, file.Path, FixCommand) } - - if strings.Contains(string(existing), "deckhouseVersion") { - return fmt.Errorf("%s renders different objects depending on the platform version; regenerating it would drop one of the two schemes -- resolve the version condition by hand first", file.Path) - } } if dropped := sortedSetDiff(renderedRights(fullPath), expected); len(dropped) > 0 { diff --git a/pkg/linters/rbac/rules/sync_test.go b/pkg/linters/rbac/rules/sync_test.go index eae5369e..da08e450 100644 --- a/pkg/linters/rbac/rules/sync_test.go +++ b/pkg/linters/rbac/rules/sync_test.go @@ -534,3 +534,62 @@ func TestEditionOverlay(t *testing.T) { assert.Equal(t, want, editionOverlay(path), path) } } + +// A template that still renders the manage/use scheme where the declaration produces the 1.78 +// model: the declared objects are absent, and the finding says why. +func TestSync_LegacyTemplateIsNamed(t *testing.T) { + modulePath := syncModuleDir(t) + model := syncModel(t, modulePath) + store := renderedFrom(t, model, func(o *generate.Object) bool { return o.Name != "d8:namespace-capability:cert-manager:view" }) + + putObject(t, store, "templates/rbacv2/use/view.yaml", generate.Object{ + Kind: "ClusterRole", Name: "d8:use:capability:module:cert-manager:view", Class: generate.ClassCapability, + Labels: map[string]string{"rbac.deckhouse.io/kind": "use", "rbac.deckhouse.io/aggregate-to-kubernetes-as": "viewer"}, + Rules: []generate.Rule{{PolicyRule: rbacyaml.PolicyRule{APIGroups: []string{"cert-manager.io"}, Resources: []string{"certificates"}, Verbs: []string{"get"}}}}, + }) + + got := texts(runSync(t, modulePath, store)) + require.Len(t, got, 1, "got: %v", got) + assert.Contains(t, got[0], "ClusterRole/d8:namespace-capability:cert-manager:view is declared but absent from the render") + assert.Contains(t, got[0], "the template renders the legacy RBACv2 scheme (rbac.deckhouse.io/kind: use, the manage/use model before DKP 1.78) where the declaration produces the 1.78 model; migrate the module with rbacv2-migrate-module.sh") + assert.NotContains(t, got[0], "d8:use:capability", "the legacy object itself is not reported as extra") +} + +// R30: a template that serves both schemes behind the version gate is never regenerated, header or not. +func TestSync_GatedTemplateIsNotRegenerated(t *testing.T) { + resetFixState() + t.Cleanup(resetFixState) + + modulePath := syncModuleDir(t) + model := syncModel(t, modulePath) + store := renderedFrom(t, model, func(o *generate.Object) bool { + if o.Name == "d8:namespace-capability:cert-manager:view" { + o.Rules = o.Rules[:len(o.Rules)-1] + } + + return true + }) + + const gated = "{{- if eq (include \"cert-manager.rbacv2_new_scheme\" .) \"true\" }}\n# new\n{{- else }}\n# legacy\n{{- end }}\n" + + path := filepath.Join(modulePath, "templates/rbacv2/use/view.yaml") + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte(gated), 0o600)) + + errorList := runSync(t, modulePath, store) + for _, fix := range errorList.GetFixes() { + fix() + } + + remaining := errorList.GetErrors() + require.Len(t, remaining, 1) + require.Error(t, remaining[0].FixError) + assert.Contains(t, remaining[0].FixError.Error(), "renders one of two role models depending on the platform version (the rbacv2_new_scheme gate of rbacv2-migrate-module.sh); regenerating it would drop the legacy branch") + + unchanged, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, gated, string(unchanged)) + + _, err = os.Stat(path + ".generated") + assert.True(t, os.IsNotExist(err), "no .generated copy for a gated file") +} diff --git a/test/e2e/testdata/rbac/scheme-dual/expected.yaml b/test/e2e/testdata/rbac/scheme-dual/expected.yaml new file mode 100644 index 00000000..95f593d5 --- /dev/null +++ b/test/e2e/testdata/rbac/scheme-dual/expected.yaml @@ -0,0 +1,14 @@ +description: > + A module that serves both role models from one branch, as rbacv2-migrate-module.sh writes it: the + new object and the legacy one in one template behind the version gate. The linter's values answer + the gate with the 1.78 model, so contract and sync see exactly what the declaration produces and + report nothing; the legacy branch is neither judged nor "extra". +module: module +expectPass: + - linter: manager + - linter: rbac + rule: contract + - linter: rbac + rule: coverage + - linter: rbac + rule: sync diff --git a/test/e2e/testdata/rbac/scheme-dual/module/crds/widgets.yaml b/test/e2e/testdata/rbac/scheme-dual/module/crds/widgets.yaml new file mode 100644 index 00000000..aa1afc45 --- /dev/null +++ b/test/e2e/testdata/rbac/scheme-dual/module/crds/widgets.yaml @@ -0,0 +1,25 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: widgets.e2e.deckhouse.io + labels: + heritage: deckhouse + module: e2e-rbac +spec: + group: e2e.deckhouse.io + names: {plural: widgets, singular: widget, kind: Widget} + scope: Namespaced + versions: [{name: v1, served: true, storage: true, schema: {openAPIV3Schema: {type: object}}}] +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: daemons.e2e.deckhouse.io + labels: + heritage: deckhouse + module: e2e-rbac +spec: + group: e2e.deckhouse.io + names: {plural: daemons, singular: daemon, kind: Daemon} + scope: Cluster + versions: [{name: v1, served: true, storage: true, schema: {openAPIV3Schema: {type: object}}}] diff --git a/test/e2e/testdata/rbac/scheme-dual/module/module.yaml b/test/e2e/testdata/rbac/scheme-dual/module/module.yaml new file mode 100644 index 00000000..e55a2e3b --- /dev/null +++ b/test/e2e/testdata/rbac/scheme-dual/module/module.yaml @@ -0,0 +1,3 @@ +name: e2e-rbac +namespace: d8-e2e-rbac +subsystems: [security] diff --git a/test/e2e/testdata/rbac/scheme-dual/module/openapi/config-values.yaml b/test/e2e/testdata/rbac/scheme-dual/module/openapi/config-values.yaml new file mode 100644 index 00000000..03b0d8bf --- /dev/null +++ b/test/e2e/testdata/rbac/scheme-dual/module/openapi/config-values.yaml @@ -0,0 +1,2 @@ +type: object +properties: {} diff --git a/test/e2e/testdata/rbac/scheme-dual/module/openapi/values.yaml b/test/e2e/testdata/rbac/scheme-dual/module/openapi/values.yaml new file mode 100644 index 00000000..47180da5 --- /dev/null +++ b/test/e2e/testdata/rbac/scheme-dual/module/openapi/values.yaml @@ -0,0 +1,4 @@ +x-extend: + schema: config-values.yaml +type: object +properties: {} diff --git a/test/e2e/testdata/rbac/scheme-dual/module/rbac.yaml b/test/e2e/testdata/rbac/scheme-dual/module/rbac.yaml new file mode 100644 index 00000000..a867f4f5 --- /dev/null +++ b/test/e2e/testdata/rbac/scheme-dual/module/rbac.yaml @@ -0,0 +1,12 @@ +apiVersion: rbac.deckhouse.io/v1alpha1 +resources: + - group: e2e.deckhouse.io + resource: widgets + namespace: + viewer: [get, list, watch] + manager: [create, update, patch, delete, deletecollection] + - group: e2e.deckhouse.io + resource: daemons + system: + viewer: [get, list, watch] + manager: [create, update, patch, delete, deletecollection] diff --git a/test/e2e/testdata/rbac/scheme-dual/module/templates/_rbacv2_compat.tpl b/test/e2e/testdata/rbac/scheme-dual/module/templates/_rbacv2_compat.tpl new file mode 100644 index 00000000..833bbe84 --- /dev/null +++ b/test/e2e/testdata/rbac/scheme-dual/module/templates/_rbacv2_compat.tpl @@ -0,0 +1,16 @@ +{{- /* + Generated by rbacv2-migrate-module.sh. Tells the RBACv2 templates of this chart which role model + the cluster they are rendered for speaks: the model of DKP 1.78 and later, or the legacy + manage/use one. Remove it once the module no longer supports clusters below 1.78. +*/ -}} +{{- define "e2e-rbac.rbacv2_new_scheme" -}} + {{- $raw := (.Values.global).deckhouseVersion | default "dev" | toString -}} + {{- $mm := regexFind "^v?[0-9]+[.][0-9]+" $raw -}} + {{- if $mm -}} + {{- semverCompare ">= 1.78" (printf "%s.0" $mm) -}} + {{- else -}} + {{- /* "dev" or "unknown": a build off any branch says the same, so answer with the model + whose mistake only loses access. A dev stand below 1.78 flips this to false. */ -}} + true + {{- end -}} +{{- end -}} diff --git a/test/e2e/testdata/rbac/scheme-dual/module/templates/rbacv2/manage/edit.yaml b/test/e2e/testdata/rbac/scheme-dual/module/templates/rbacv2/manage/edit.yaml new file mode 100644 index 00000000..6467e640 --- /dev/null +++ b/test/e2e/testdata/rbac/scheme-dual/module/templates/rbacv2/manage/edit.yaml @@ -0,0 +1,75 @@ +{{- if eq (include "e2e-rbac.rbacv2_new_scheme" .) "true" }} +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + heritage: deckhouse + module: e2e-rbac + rbac.deckhouse.io/aggregate-to-security-as: manager + rbac.deckhouse.io/namespace: d8-e2e-rbac + rbac.deckhouse.io/kind: capability + rbac.deckhouse.io/capability: "system-capability.e2e-rbac.edit" + rbac.deckhouse.io/scope: system + name: d8:system-capability:e2e-rbac:edit + annotations: + en.meta.deckhouse.io/title: "Module e2e-rbac: edit configuration" + ru.meta.deckhouse.io/title: "Модуль e2e-rbac: управление конфигурацией" + en.meta.deckhouse.io/description: "Manage the e2e-rbac module configuration." + ru.meta.deckhouse.io/description: "Управление конфигурацией модуля e2e-rbac." +rules: +- apiGroups: + - e2e.deckhouse.io + resources: + - daemons + verbs: + - create + - update + - patch + - delete + - deletecollection +- apiGroups: + - deckhouse.io + resourceNames: + - e2e-rbac + resources: + - moduleconfigs + verbs: + - create + - update + - patch + - delete +{{- else }} +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + heritage: deckhouse + module: e2e-rbac + rbac.deckhouse.io/aggregate-to-security-as: manager + rbac.deckhouse.io/level: module + rbac.deckhouse.io/namespace: d8-e2e-rbac + rbac.deckhouse.io/kind: manage + name: d8:manage:permission:module:e2e-rbac:edit +rules: +- apiGroups: + - e2e.deckhouse.io + resources: + - daemons + verbs: + - create + - update + - patch + - delete + - deletecollection +- apiGroups: + - deckhouse.io + resourceNames: + - e2e-rbac + resources: + - moduleconfigs + verbs: + - create + - update + - patch + - delete +{{- end }} diff --git a/test/e2e/testdata/rbac/scheme-dual/module/templates/rbacv2/manage/view.yaml b/test/e2e/testdata/rbac/scheme-dual/module/templates/rbacv2/manage/view.yaml new file mode 100644 index 00000000..4130e4b9 --- /dev/null +++ b/test/e2e/testdata/rbac/scheme-dual/module/templates/rbacv2/manage/view.yaml @@ -0,0 +1,69 @@ +{{- if eq (include "e2e-rbac.rbacv2_new_scheme" .) "true" }} +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + heritage: deckhouse + module: e2e-rbac + rbac.deckhouse.io/aggregate-to-security-as: viewer + rbac.deckhouse.io/namespace: d8-e2e-rbac + rbac.deckhouse.io/kind: capability + rbac.deckhouse.io/capability: "system-capability.e2e-rbac.view" + rbac.deckhouse.io/scope: system + name: d8:system-capability:e2e-rbac:view + annotations: + en.meta.deckhouse.io/title: "Module e2e-rbac: view configuration" + ru.meta.deckhouse.io/title: "Модуль e2e-rbac: просмотр конфигурации" + en.meta.deckhouse.io/description: "Read-only access to the e2e-rbac module configuration." + ru.meta.deckhouse.io/description: "Доступ только на чтение к конфигурации модуля e2e-rbac." +rules: +- apiGroups: + - e2e.deckhouse.io + resources: + - daemons + verbs: + - get + - list + - watch +- apiGroups: + - deckhouse.io + resourceNames: + - e2e-rbac + resources: + - moduleconfigs + verbs: + - get + - list + - watch +{{- else }} +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + heritage: deckhouse + module: e2e-rbac + rbac.deckhouse.io/aggregate-to-security-as: viewer + rbac.deckhouse.io/level: module + rbac.deckhouse.io/namespace: d8-e2e-rbac + rbac.deckhouse.io/kind: manage + name: d8:manage:permission:module:e2e-rbac:view +rules: +- apiGroups: + - e2e.deckhouse.io + resources: + - daemons + verbs: + - get + - list + - watch +- apiGroups: + - deckhouse.io + resourceNames: + - e2e-rbac + resources: + - moduleconfigs + verbs: + - get + - list + - watch +{{- end }} diff --git a/test/e2e/testdata/rbac/scheme-dual/module/templates/rbacv2/use/edit.yaml b/test/e2e/testdata/rbac/scheme-dual/module/templates/rbacv2/use/edit.yaml new file mode 100644 index 00000000..cad3a23f --- /dev/null +++ b/test/e2e/testdata/rbac/scheme-dual/module/templates/rbacv2/use/edit.yaml @@ -0,0 +1,50 @@ +{{- if eq (include "e2e-rbac.rbacv2_new_scheme" .) "true" }} +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + heritage: deckhouse + module: e2e-rbac + rbac.deckhouse.io/aggregate-to-namespace-as: manager + rbac.deckhouse.io/kind: capability + rbac.deckhouse.io/capability: "namespace-capability.e2e-rbac.edit" + rbac.deckhouse.io/scope: namespace + name: d8:namespace-capability:e2e-rbac:edit + annotations: + en.meta.deckhouse.io/title: "Module e2e-rbac: edit" + ru.meta.deckhouse.io/title: "Модуль e2e-rbac: редактирование" + en.meta.deckhouse.io/description: "Manage e2e-rbac resources in a namespace." + ru.meta.deckhouse.io/description: "Управление ресурсами модуля e2e-rbac в пространстве имён." +rules: +- apiGroups: + - e2e.deckhouse.io + resources: + - widgets + verbs: + - create + - update + - patch + - delete + - deletecollection +{{- else }} +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + heritage: deckhouse + module: e2e-rbac + rbac.deckhouse.io/aggregate-to-kubernetes-as: manager + rbac.deckhouse.io/kind: use + name: d8:use:capability:module:e2e-rbac:edit +rules: +- apiGroups: + - e2e.deckhouse.io + resources: + - widgets + verbs: + - create + - update + - patch + - delete + - deletecollection +{{- end }} diff --git a/test/e2e/testdata/rbac/scheme-dual/module/templates/rbacv2/use/view.yaml b/test/e2e/testdata/rbac/scheme-dual/module/templates/rbacv2/use/view.yaml new file mode 100644 index 00000000..a4ebaf8d --- /dev/null +++ b/test/e2e/testdata/rbac/scheme-dual/module/templates/rbacv2/use/view.yaml @@ -0,0 +1,46 @@ +{{- if eq (include "e2e-rbac.rbacv2_new_scheme" .) "true" }} +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + heritage: deckhouse + module: e2e-rbac + rbac.deckhouse.io/aggregate-to-namespace-as: viewer + rbac.deckhouse.io/kind: capability + rbac.deckhouse.io/capability: "namespace-capability.e2e-rbac.view" + rbac.deckhouse.io/scope: namespace + name: d8:namespace-capability:e2e-rbac:view + annotations: + en.meta.deckhouse.io/title: "Module e2e-rbac: view" + ru.meta.deckhouse.io/title: "Модуль e2e-rbac: просмотр" + en.meta.deckhouse.io/description: "Read-only access to e2e-rbac resources in a namespace." + ru.meta.deckhouse.io/description: "Доступ только на чтение к ресурсам модуля e2e-rbac в пространстве имён." +rules: +- apiGroups: + - e2e.deckhouse.io + resources: + - widgets + verbs: + - get + - list + - watch +{{- else }} +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + heritage: deckhouse + module: e2e-rbac + rbac.deckhouse.io/aggregate-to-kubernetes-as: viewer + rbac.deckhouse.io/kind: use + name: d8:use:capability:module:e2e-rbac:view +rules: +- apiGroups: + - e2e.deckhouse.io + resources: + - widgets + verbs: + - get + - list + - watch +{{- end }} diff --git a/test/e2e/testdata/rbac/scheme-legacy-only/expected.yaml b/test/e2e/testdata/rbac/scheme-legacy-only/expected.yaml new file mode 100644 index 00000000..77146448 --- /dev/null +++ b/test/e2e/testdata/rbac/scheme-legacy-only/expected.yaml @@ -0,0 +1,25 @@ +description: > + A module still on the RBACv2 scheme before DKP 1.78 (kind: use / kind: manage, no rbac.yaml): the + contract rule reports one finding per object -- "migrate" -- instead of failing every check of the + contract on it; coverage and sync stay silent without a declaration. +module: module +expect: + - linter: rbac + rule: contract + level: warn + textContains: "is of the legacy RBACv2 scheme (rbac.deckhouse.io/kind: use, the manage/use model before DKP 1.78); migrate the module with rbacv2-migrate-module.sh" + count: 2 + - linter: rbac + rule: contract + level: warn + textContains: "is of the legacy RBACv2 scheme (rbac.deckhouse.io/kind: manage, the manage/use model before DKP 1.78); migrate the module with rbacv2-migrate-module.sh" + count: 2 +expectPass: + - linter: manager + - linter: rbac + rule: contract + textContains: "must" + - linter: rbac + rule: coverage + - linter: rbac + rule: sync diff --git a/test/e2e/testdata/rbac/scheme-legacy-only/module/crds/widgets.yaml b/test/e2e/testdata/rbac/scheme-legacy-only/module/crds/widgets.yaml new file mode 100644 index 00000000..aa1afc45 --- /dev/null +++ b/test/e2e/testdata/rbac/scheme-legacy-only/module/crds/widgets.yaml @@ -0,0 +1,25 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: widgets.e2e.deckhouse.io + labels: + heritage: deckhouse + module: e2e-rbac +spec: + group: e2e.deckhouse.io + names: {plural: widgets, singular: widget, kind: Widget} + scope: Namespaced + versions: [{name: v1, served: true, storage: true, schema: {openAPIV3Schema: {type: object}}}] +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: daemons.e2e.deckhouse.io + labels: + heritage: deckhouse + module: e2e-rbac +spec: + group: e2e.deckhouse.io + names: {plural: daemons, singular: daemon, kind: Daemon} + scope: Cluster + versions: [{name: v1, served: true, storage: true, schema: {openAPIV3Schema: {type: object}}}] diff --git a/test/e2e/testdata/rbac/scheme-legacy-only/module/module.yaml b/test/e2e/testdata/rbac/scheme-legacy-only/module/module.yaml new file mode 100644 index 00000000..e55a2e3b --- /dev/null +++ b/test/e2e/testdata/rbac/scheme-legacy-only/module/module.yaml @@ -0,0 +1,3 @@ +name: e2e-rbac +namespace: d8-e2e-rbac +subsystems: [security] diff --git a/test/e2e/testdata/rbac/scheme-legacy-only/module/openapi/config-values.yaml b/test/e2e/testdata/rbac/scheme-legacy-only/module/openapi/config-values.yaml new file mode 100644 index 00000000..03b0d8bf --- /dev/null +++ b/test/e2e/testdata/rbac/scheme-legacy-only/module/openapi/config-values.yaml @@ -0,0 +1,2 @@ +type: object +properties: {} diff --git a/test/e2e/testdata/rbac/scheme-legacy-only/module/openapi/values.yaml b/test/e2e/testdata/rbac/scheme-legacy-only/module/openapi/values.yaml new file mode 100644 index 00000000..47180da5 --- /dev/null +++ b/test/e2e/testdata/rbac/scheme-legacy-only/module/openapi/values.yaml @@ -0,0 +1,4 @@ +x-extend: + schema: config-values.yaml +type: object +properties: {} diff --git a/test/e2e/testdata/rbac/scheme-legacy-only/module/templates/rbacv2/manage/edit.yaml b/test/e2e/testdata/rbac/scheme-legacy-only/module/templates/rbacv2/manage/edit.yaml new file mode 100644 index 00000000..7881f828 --- /dev/null +++ b/test/e2e/testdata/rbac/scheme-legacy-only/module/templates/rbacv2/manage/edit.yaml @@ -0,0 +1,33 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + heritage: deckhouse + module: e2e-rbac + rbac.deckhouse.io/aggregate-to-security-as: manager + rbac.deckhouse.io/level: module + rbac.deckhouse.io/namespace: d8-e2e-rbac + rbac.deckhouse.io/kind: manage + name: d8:manage:permission:module:e2e-rbac:edit +rules: +- apiGroups: + - e2e.deckhouse.io + resources: + - daemons + verbs: + - create + - update + - patch + - delete + - deletecollection +- apiGroups: + - deckhouse.io + resourceNames: + - e2e-rbac + resources: + - moduleconfigs + verbs: + - create + - update + - patch + - delete diff --git a/test/e2e/testdata/rbac/scheme-legacy-only/module/templates/rbacv2/manage/view.yaml b/test/e2e/testdata/rbac/scheme-legacy-only/module/templates/rbacv2/manage/view.yaml new file mode 100644 index 00000000..a87a818e --- /dev/null +++ b/test/e2e/testdata/rbac/scheme-legacy-only/module/templates/rbacv2/manage/view.yaml @@ -0,0 +1,30 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + heritage: deckhouse + module: e2e-rbac + rbac.deckhouse.io/aggregate-to-security-as: viewer + rbac.deckhouse.io/level: module + rbac.deckhouse.io/namespace: d8-e2e-rbac + rbac.deckhouse.io/kind: manage + name: d8:manage:permission:module:e2e-rbac:view +rules: +- apiGroups: + - e2e.deckhouse.io + resources: + - daemons + verbs: + - get + - list + - watch +- apiGroups: + - deckhouse.io + resourceNames: + - e2e-rbac + resources: + - moduleconfigs + verbs: + - get + - list + - watch diff --git a/test/e2e/testdata/rbac/scheme-legacy-only/module/templates/rbacv2/use/edit.yaml b/test/e2e/testdata/rbac/scheme-legacy-only/module/templates/rbacv2/use/edit.yaml new file mode 100644 index 00000000..7febe055 --- /dev/null +++ b/test/e2e/testdata/rbac/scheme-legacy-only/module/templates/rbacv2/use/edit.yaml @@ -0,0 +1,20 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + heritage: deckhouse + module: e2e-rbac + rbac.deckhouse.io/aggregate-to-kubernetes-as: manager + rbac.deckhouse.io/kind: use + name: d8:use:capability:module:e2e-rbac:edit +rules: +- apiGroups: + - e2e.deckhouse.io + resources: + - widgets + verbs: + - create + - update + - patch + - delete + - deletecollection diff --git a/test/e2e/testdata/rbac/scheme-legacy-only/module/templates/rbacv2/use/view.yaml b/test/e2e/testdata/rbac/scheme-legacy-only/module/templates/rbacv2/use/view.yaml new file mode 100644 index 00000000..53d8a0bd --- /dev/null +++ b/test/e2e/testdata/rbac/scheme-legacy-only/module/templates/rbacv2/use/view.yaml @@ -0,0 +1,18 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + heritage: deckhouse + module: e2e-rbac + rbac.deckhouse.io/aggregate-to-kubernetes-as: viewer + rbac.deckhouse.io/kind: use + name: d8:use:capability:module:e2e-rbac:view +rules: +- apiGroups: + - e2e.deckhouse.io + resources: + - widgets + verbs: + - get + - list + - watch diff --git a/test/e2e/testdata/rbac/scheme-legacy-with-declaration/expected.yaml b/test/e2e/testdata/rbac/scheme-legacy-with-declaration/expected.yaml new file mode 100644 index 00000000..58878290 --- /dev/null +++ b/test/e2e/testdata/rbac/scheme-legacy-with-declaration/expected.yaml @@ -0,0 +1,31 @@ +description: > + A module on the scheme before DKP 1.78 that already has an rbac.yaml: the declaration produces the + 1.78 objects, the templates render the legacy ones, and sync names the cause once per file instead + of listing every absent object as a mystery. The legacy objects themselves are not "extra". +module: module +expect: + - linter: rbac + rule: sync + level: warn + textContains: "the template renders the legacy RBACv2 scheme (rbac.deckhouse.io/kind: use, the manage/use model before DKP 1.78) where the declaration produces the 1.78 model; migrate the module with rbacv2-migrate-module.sh" + count: 2 + - linter: rbac + rule: sync + level: warn + textContains: "the template renders the legacy RBACv2 scheme (rbac.deckhouse.io/kind: manage" + count: 2 + - linter: rbac + rule: contract + level: warn + textContains: "is of the legacy RBACv2 scheme" + count: 4 +expectPass: + - linter: manager + - linter: rbac + rule: coverage + - linter: rbac + rule: sync + textContains: "d8:use:capability" + - linter: rbac + rule: sync + textContains: "d8:manage:permission" diff --git a/test/e2e/testdata/rbac/scheme-legacy-with-declaration/module/crds/widgets.yaml b/test/e2e/testdata/rbac/scheme-legacy-with-declaration/module/crds/widgets.yaml new file mode 100644 index 00000000..aa1afc45 --- /dev/null +++ b/test/e2e/testdata/rbac/scheme-legacy-with-declaration/module/crds/widgets.yaml @@ -0,0 +1,25 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: widgets.e2e.deckhouse.io + labels: + heritage: deckhouse + module: e2e-rbac +spec: + group: e2e.deckhouse.io + names: {plural: widgets, singular: widget, kind: Widget} + scope: Namespaced + versions: [{name: v1, served: true, storage: true, schema: {openAPIV3Schema: {type: object}}}] +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: daemons.e2e.deckhouse.io + labels: + heritage: deckhouse + module: e2e-rbac +spec: + group: e2e.deckhouse.io + names: {plural: daemons, singular: daemon, kind: Daemon} + scope: Cluster + versions: [{name: v1, served: true, storage: true, schema: {openAPIV3Schema: {type: object}}}] diff --git a/test/e2e/testdata/rbac/scheme-legacy-with-declaration/module/module.yaml b/test/e2e/testdata/rbac/scheme-legacy-with-declaration/module/module.yaml new file mode 100644 index 00000000..e55a2e3b --- /dev/null +++ b/test/e2e/testdata/rbac/scheme-legacy-with-declaration/module/module.yaml @@ -0,0 +1,3 @@ +name: e2e-rbac +namespace: d8-e2e-rbac +subsystems: [security] diff --git a/test/e2e/testdata/rbac/scheme-legacy-with-declaration/module/openapi/config-values.yaml b/test/e2e/testdata/rbac/scheme-legacy-with-declaration/module/openapi/config-values.yaml new file mode 100644 index 00000000..03b0d8bf --- /dev/null +++ b/test/e2e/testdata/rbac/scheme-legacy-with-declaration/module/openapi/config-values.yaml @@ -0,0 +1,2 @@ +type: object +properties: {} diff --git a/test/e2e/testdata/rbac/scheme-legacy-with-declaration/module/openapi/values.yaml b/test/e2e/testdata/rbac/scheme-legacy-with-declaration/module/openapi/values.yaml new file mode 100644 index 00000000..47180da5 --- /dev/null +++ b/test/e2e/testdata/rbac/scheme-legacy-with-declaration/module/openapi/values.yaml @@ -0,0 +1,4 @@ +x-extend: + schema: config-values.yaml +type: object +properties: {} diff --git a/test/e2e/testdata/rbac/scheme-legacy-with-declaration/module/rbac.yaml b/test/e2e/testdata/rbac/scheme-legacy-with-declaration/module/rbac.yaml new file mode 100644 index 00000000..a867f4f5 --- /dev/null +++ b/test/e2e/testdata/rbac/scheme-legacy-with-declaration/module/rbac.yaml @@ -0,0 +1,12 @@ +apiVersion: rbac.deckhouse.io/v1alpha1 +resources: + - group: e2e.deckhouse.io + resource: widgets + namespace: + viewer: [get, list, watch] + manager: [create, update, patch, delete, deletecollection] + - group: e2e.deckhouse.io + resource: daemons + system: + viewer: [get, list, watch] + manager: [create, update, patch, delete, deletecollection] diff --git a/test/e2e/testdata/rbac/scheme-legacy-with-declaration/module/templates/rbacv2/manage/edit.yaml b/test/e2e/testdata/rbac/scheme-legacy-with-declaration/module/templates/rbacv2/manage/edit.yaml new file mode 100644 index 00000000..7881f828 --- /dev/null +++ b/test/e2e/testdata/rbac/scheme-legacy-with-declaration/module/templates/rbacv2/manage/edit.yaml @@ -0,0 +1,33 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + heritage: deckhouse + module: e2e-rbac + rbac.deckhouse.io/aggregate-to-security-as: manager + rbac.deckhouse.io/level: module + rbac.deckhouse.io/namespace: d8-e2e-rbac + rbac.deckhouse.io/kind: manage + name: d8:manage:permission:module:e2e-rbac:edit +rules: +- apiGroups: + - e2e.deckhouse.io + resources: + - daemons + verbs: + - create + - update + - patch + - delete + - deletecollection +- apiGroups: + - deckhouse.io + resourceNames: + - e2e-rbac + resources: + - moduleconfigs + verbs: + - create + - update + - patch + - delete diff --git a/test/e2e/testdata/rbac/scheme-legacy-with-declaration/module/templates/rbacv2/manage/view.yaml b/test/e2e/testdata/rbac/scheme-legacy-with-declaration/module/templates/rbacv2/manage/view.yaml new file mode 100644 index 00000000..a87a818e --- /dev/null +++ b/test/e2e/testdata/rbac/scheme-legacy-with-declaration/module/templates/rbacv2/manage/view.yaml @@ -0,0 +1,30 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + heritage: deckhouse + module: e2e-rbac + rbac.deckhouse.io/aggregate-to-security-as: viewer + rbac.deckhouse.io/level: module + rbac.deckhouse.io/namespace: d8-e2e-rbac + rbac.deckhouse.io/kind: manage + name: d8:manage:permission:module:e2e-rbac:view +rules: +- apiGroups: + - e2e.deckhouse.io + resources: + - daemons + verbs: + - get + - list + - watch +- apiGroups: + - deckhouse.io + resourceNames: + - e2e-rbac + resources: + - moduleconfigs + verbs: + - get + - list + - watch diff --git a/test/e2e/testdata/rbac/scheme-legacy-with-declaration/module/templates/rbacv2/use/edit.yaml b/test/e2e/testdata/rbac/scheme-legacy-with-declaration/module/templates/rbacv2/use/edit.yaml new file mode 100644 index 00000000..7febe055 --- /dev/null +++ b/test/e2e/testdata/rbac/scheme-legacy-with-declaration/module/templates/rbacv2/use/edit.yaml @@ -0,0 +1,20 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + heritage: deckhouse + module: e2e-rbac + rbac.deckhouse.io/aggregate-to-kubernetes-as: manager + rbac.deckhouse.io/kind: use + name: d8:use:capability:module:e2e-rbac:edit +rules: +- apiGroups: + - e2e.deckhouse.io + resources: + - widgets + verbs: + - create + - update + - patch + - delete + - deletecollection diff --git a/test/e2e/testdata/rbac/scheme-legacy-with-declaration/module/templates/rbacv2/use/view.yaml b/test/e2e/testdata/rbac/scheme-legacy-with-declaration/module/templates/rbacv2/use/view.yaml new file mode 100644 index 00000000..53d8a0bd --- /dev/null +++ b/test/e2e/testdata/rbac/scheme-legacy-with-declaration/module/templates/rbacv2/use/view.yaml @@ -0,0 +1,18 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + heritage: deckhouse + module: e2e-rbac + rbac.deckhouse.io/aggregate-to-kubernetes-as: viewer + rbac.deckhouse.io/kind: use + name: d8:use:capability:module:e2e-rbac:view +rules: +- apiGroups: + - e2e.deckhouse.io + resources: + - widgets + verbs: + - get + - list + - watch From 053bcad6df4454f07f46569bc011fe960035ea9e Mon Sep 17 00:00:00 2001 From: Ivan Zvyagintsev Date: Tue, 22 Sep 2026 00:16:30 +0300 Subject: [PATCH 15/58] no-cyrillic: do not judge the localized RBAC annotations The RBACv2 role model requires a Russian title and description on every role and capability (ru.meta.deckhouse.io/*), and the rbac contract rule enforces them; the files rule then reported the same lines as Cyrillic in source. 41 in-tree modules carry an identical exclude-rules entry for templates/rbacv2 to reconcile the two, and every external module would need one too. The files rule now skips those annotation lines; Cyrillic anywhere else in the same file is still reported. Signed-off-by: Ivan Zvyagintsev --- pkg/linters/no-cyrillic/rules/files.go | 43 +++++++++++++---- pkg/linters/no-cyrillic/rules/files_test.go | 52 +++++++++++++++++++++ 2 files changed, 86 insertions(+), 9 deletions(-) diff --git a/pkg/linters/no-cyrillic/rules/files.go b/pkg/linters/no-cyrillic/rules/files.go index a4bfb1d5..b7161618 100644 --- a/pkg/linters/no-cyrillic/rules/files.go +++ b/pkg/linters/no-cyrillic/rules/files.go @@ -39,6 +39,13 @@ var ( skipDocRe = `doc-ru-.+\.y[a]?ml$|\.ru\.y[a]?ml$|\.ru\.json$|\.ru\.md$|\.ru\.html$|_RU\.md$|_ru\.html$|docs/site/_.+|docs/documentation/_.+|tools/spelling/.+|openapi/conversions/.+|module.yaml|ru\..+` skipSelfRe = `no_cyrillic(_test)?.go$` skipI18NRe = `/i18n/` + + // localizedAnnotationRe matches the Russian title and description the RBACv2 role model requires + // on every role and capability (ru.meta.deckhouse.io/*, enforced by the rbac contract rule). That + // is product text the console shows to whoever grants access, not a source comment, so those + // lines are not judged -- otherwise every module would need the same exclusion for + // templates/rbacv2 in its own .dmtlint.yaml. + localizedAnnotationRe = `ru\.meta\.deckhouse\.io/(title|description)` ) func NewFilesRule(excludeFileRules []pkg.StringRuleExclude, @@ -52,11 +59,12 @@ func NewFilesRule(excludeFileRules []pkg.StringRuleExclude, ExcludeStringRules: excludeFileRules, ExcludeDirectoryRules: excludeDirectoryRules, }, - skipDocRe: regexp.MustCompile(skipDocRe), - skipI18NRe: regexp.MustCompile(skipI18NRe), - skipSelfRe: regexp.MustCompile(skipSelfRe), - module: m, - errorList: errorList.WithRule(FilesRuleName), + skipDocRe: regexp.MustCompile(skipDocRe), + skipI18NRe: regexp.MustCompile(skipI18NRe), + skipSelfRe: regexp.MustCompile(skipSelfRe), + localizedRe: regexp.MustCompile(localizedAnnotationRe), + module: m, + errorList: errorList.WithRule(FilesRuleName), } } @@ -64,9 +72,10 @@ type FilesRule struct { pkg.RuleMeta pkg.PathRule - skipDocRe *regexp.Regexp - skipI18NRe *regexp.Regexp - skipSelfRe *regexp.Regexp + skipDocRe *regexp.Regexp + skipI18NRe *regexp.Regexp + skipSelfRe *regexp.Regexp + localizedRe *regexp.Regexp module pkg.Module errorList *errors.LintRuleErrorsList @@ -117,7 +126,7 @@ func (r *FilesRule) checkFile(fileName string) { return } - cyrMsg, hasCyr := checkCyrillicLettersInArray(lines) + cyrMsg, hasCyr := checkCyrillicLettersInArray(r.withoutLocalizedAnnotations(lines)) if hasCyr { errorList.WithFilePath(fName).WithValue(cyrMsg). Error("has cyrillic letters") @@ -134,3 +143,19 @@ func getFileContent(filename string) ([]string, error) { return sliceData, nil } + +// withoutLocalizedAnnotations drops the ru.meta.deckhouse.io/title|description lines: Russian there is +// required by the role model, not a mistake. +func (r *FilesRule) withoutLocalizedAnnotations(lines []string) []string { + out := make([]string, 0, len(lines)) + + for _, line := range lines { + if r.localizedRe.MatchString(line) { + continue + } + + out = append(out, line) + } + + return out +} diff --git a/pkg/linters/no-cyrillic/rules/files_test.go b/pkg/linters/no-cyrillic/rules/files_test.go index 2f4669c5..fee3bf52 100644 --- a/pkg/linters/no-cyrillic/rules/files_test.go +++ b/pkg/linters/no-cyrillic/rules/files_test.go @@ -430,3 +430,55 @@ func TestFilesRule_CheckFile_directory_exclude_trailing_slash(t *testing.T) { t.Errorf("expected file under excluded directory (with trailing slash) to be skipped, got %d errors", len(errs)) } } + +// The Russian title and description the RBACv2 role model requires on every role and capability are +// product text, not a source comment: those lines are not judged, everything else in the file still is. +func TestFilesRule_CheckFile_LocalizedRBACAnnotationsAreNotJudged(t *testing.T) { + const capability = `apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: d8:namespace-capability:x:view + annotations: + en.meta.deckhouse.io/title: "Module x: view" + ru.meta.deckhouse.io/title: "Модуль x: просмотр" + en.meta.deckhouse.io/description: "Read-only access to x resources in a namespace." + ru.meta.deckhouse.io/description: "Доступ только на чтение к ресурсам модуля x в пространстве имён." +` + + run := func(t *testing.T, content string) []string { + t.Helper() + + mockModule := mocks.NewModuleMock(minimock.NewController(t)) + tempDir := t.TempDir() + mockModule.GetPathMock.Return(tempDir) + + path := filepath.Join(tempDir, "templates", "rbacv2", "use", "view.yaml") + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + + errorList := errors.NewLintRuleErrorsList() + NewFilesRule(nil, nil, mockModule, errorList).checkFile(path) + + errs := errorList.GetErrors() + + out := make([]string, 0, len(errs)) + for _, e := range errs { + out = append(out, e.Text) + } + + return out + } + + if got := run(t, capability); len(got) != 0 { + t.Errorf("the localized annotations must not be reported, got %v", got) + } + + if got := run(t, capability+"# Комментарий на русском\n"); len(got) != 1 { + t.Errorf("Cyrillic outside the annotations is still reported, got %v", got) + } +} From 94b62b9c2b55b3bc237e00bf87925229658d0e7f Mon Sep 17 00:00:00 2001 From: Ivan Zvyagintsev Date: Tue, 22 Sep 2026 00:41:23 +0300 Subject: [PATCH 16/58] rbac: close the gaps the module lifecycle scenarios showed Thirteen changes a module goes through were run against the rules: a CRD added, removed, renamed or rescoped; a verb dropped from the declaration; a rule added to a template by hand; a generated file or the declaration deleted; subsystems changed; a subresource and a whole-group entry; a rule under when. Three did not hold: - coverage: a noAccess entry whose group has no CRD left in the module was silent -- a removed CRD is indistinguishable from an external resource nobody grants. It is a warning now unless the entry names a scope. - sync: a rule under when whose condition is false today is absent from the render without being a divergence (D4), so it never reached the template through --fix. For a file that carries the generator header the text is now compared with what the declaration renders; a file without the header is still judged by its render only. A file of another contract version is the same check. - sync --fix: the copy written beside a hand-maintained file lived inside templates/, and Helm renders every file there whatever its extension, so the module rendered a second set of objects. The copy is _.generated now: an underscore-prefixed file is a partial to Helm. The drop-guard refusal names the way out: delete the file and run --fix again to regenerate it without the right. Signed-off-by: Ivan Zvyagintsev --- pkg/linters/rbac/README.md | 5 +- pkg/linters/rbac/rules/coverage.go | 10 ++++ pkg/linters/rbac/rules/coverage_test.go | 25 +++++++++ pkg/linters/rbac/rules/sync.go | 52 ++++++++++++++---- pkg/linters/rbac/rules/sync_test.go | 73 ++++++++++++++++++++++++- 5 files changed, 150 insertions(+), 15 deletions(-) diff --git a/pkg/linters/rbac/README.md b/pkg/linters/rbac/README.md index 27896cdb..55e41564 100644 --- a/pkg/linters/rbac/README.md +++ b/pkg/linters/rbac/README.md @@ -1552,6 +1552,7 @@ Runs only when the module has an `rbac.yaml`. Reads the CRDs under `crds/` at an 1. Every CRD (`spec.group` / `spec.names.plural`) has an entry in `resources` that grants levels or denies access with a reason -- **error**, with an autofix. 2. An entry left as `noAccess: "TODO"` -- **error**, no autofix: only a person can decide. 3. An entry naming a group the module ships CRDs for, but a resource none of them spells -- **warning** (a likely misspelling). Whole-group (`"*"`) and subresource (`/`) entries are exempt. +4. A `noAccess` entry of a group the module ships no CRD for, without a `scope` -- **warning**: a removed CRD is indistinguishable from an external resource nobody grants. Add `scope` to say the resource is external, or drop the entry if its CRD is gone. **Autofix:** appends an undecided stub for each CRD without an entry -- @@ -1618,13 +1619,13 @@ controller ClusterRoles with arbitrary names, objects with Helm-computed names). 2. A capability's aggregation edges (`aggregate-to--as`) match in both directions: rules may agree while a lineage is lost. Its `rbac.deckhouse.io/capability` marker, `module` and `rbac.deckhouse.io/namespace` labels are what the generator writes. 3. A binding's `roleRef` and subjects match. 4. Every rendered legacy role and module capability is produced by the declaration. -5. A generated file names the contract version it was generated under in its header; a file of another version is a divergence and is regenerated. +5. A file that carries the generator header is the generator's, and its text must be what the declaration renders now: a rule under `when` whose condition is false today is absent from the render without being a divergence, yet it still has to reach the template, so for generator-owned files the text is compared too. A file of another contract version is the same case. Remove the header to maintain a file by hand; then only its render is judged. Findings are one per template file and carry the fix command; the text does not depend on the render variant. **Autofix:** regenerates the file from `rbac.yaml`, with two safeguards -- -- a file without the generator header is maintained by hand: the generated text is written beside it as `.generated` and the finding stays (delete the file and run `--fix` again to hand it back to the generator); +- a file without the generator header is maintained by hand: the generated text is written beside it as `_.generated` (the underscore keeps Helm from rendering the copy) and the finding stays (delete the file and run `--fix` again to hand it back to the generator); - the regenerated file must grant everything the render of that file grants today, rules and aggregation edges alike; otherwise the file is left alone and the finding names what would be lost. Removing a right is always a person's decision: declare it in `rbac.yaml` or remove it from the template by hand. A missing file is created. A second `--fix` without changes to `rbac.yaml` changes nothing. Under diff --git a/pkg/linters/rbac/rules/coverage.go b/pkg/linters/rbac/rules/coverage.go index 5df4dd16..dc9910af 100644 --- a/pkg/linters/rbac/rules/coverage.go +++ b/pkg/linters/rbac/rules/coverage.go @@ -126,6 +126,16 @@ func (r *CoverageRule) Check(_ context.Context) { } if _, groupKnown := groups[res.Group]; !groupKnown { + // A denied resource of a group the module ships no CRD for: either an external resource + // nobody grants, or a CRD that was removed while its entry stayed. Only a scope tells + // the two apart (an external resource is declared with one), so ask for it. + if res.NoAccess != "" && res.Scope == "" { + errorList. + WithObjectID("rbac.yaml/"+res.Key()). + Warnf("%s is denied access but the module ships no CRD for it and the entry names no scope; if the resource is external, add scope: Namespaced|Cluster to say so, if its CRD was removed, drop the entry", + res.Key()) + } + continue } diff --git a/pkg/linters/rbac/rules/coverage_test.go b/pkg/linters/rbac/rules/coverage_test.go index a2b05922..10df27da 100644 --- a/pkg/linters/rbac/rules/coverage_test.go +++ b/pkg/linters/rbac/rules/coverage_test.go @@ -261,3 +261,28 @@ func TestCoverage_StubFixOncePerRun(t *testing.T) { require.NoError(t, err) assert.Equal(t, 1, strings.Count(string(after), "resource: alphas"), "one stub, not one per variant") } + +// A noAccess entry whose group has no CRD in the module and no scope: a removed CRD is +// indistinguishable from an external resource, so the entry is asked to say which. +func TestCoverage_DeniedEntryWithoutCRDNeedsScope(t *testing.T) { + modulePath := writeModule(t, map[string]string{ + "crds/a.yaml": crdYAML("a.io", "alphas", "Namespaced"), + rbacyaml.Filename: `apiVersion: rbac.deckhouse.io/v1alpha1 +resources: + - group: a.io + resource: alphas + noAccess: "internal" + - group: gone.io + resource: relics + noAccess: "the CRD left with the old controller" + - group: external.io + resource: things + scope: Cluster + noAccess: "documented denial of an external resource" +`, + }) + + got := texts(runCoverage(t, modulePath)) + require.Len(t, got, 1, "got: %v", got) + assert.Contains(t, got[0], "warn: gone.io/relics is denied access but the module ships no CRD for it and the entry names no scope") +} diff --git a/pkg/linters/rbac/rules/sync.go b/pkg/linters/rbac/rules/sync.go index 2ae1236e..0b5128fb 100644 --- a/pkg/linters/rbac/rules/sync.go +++ b/pkg/linters/rbac/rules/sync.go @@ -137,11 +137,21 @@ func (r *SyncRule) Check(_ context.Context) { divergences := map[string][]string{} for _, file := range model.Files { + kind, isLegacy := legacy[file.Path] + + // A template that serves both models behind the version gate rendered its legacy branch: + // the values of this run say the cluster is below 1.78. The 1.78 objects it declares are + // conditional on the gate and are compared in the run where the gate answers "new" -- the + // default one -- so this is not a divergence (the same reading as a rule under when, D4). + if isLegacy && templateHasGate(modulePath, file.Path) { + continue + } + found := compareFile(file, actual, r.module.GetName()) // The template renders the scheme before 1.78 where the declaration produces the new one: // the objects the declaration names cannot be there. Say so once instead of listing them. - if kind, isLegacy := legacy[file.Path]; isLegacy && len(found) > 0 { + if isLegacy && len(found) > 0 { found = append(found, fmt.Sprintf("the template renders the legacy RBACv2 scheme (%s: %s, the manage/use model before DKP 1.78) where the declaration produces the 1.78 model; migrate the module with rbacv2-migrate-module.sh to serve both, or delete the file and run `%s` to serve the new one only", rbaccontract.LabelKind, kind, FixCommand)) } @@ -168,17 +178,28 @@ func (r *SyncRule) Check(_ context.Context) { fmt.Sprintf("%s is in the render but rbac.yaml does not produce it: declare its rights in rbac.yaml or remove it from the template", identity)) } - // A generated file that names another contract version was written by a dmt of another - // contract; the objects may be labelled or named differently now, so it is regenerated (R40). + // A file that carries the generator header is the generator's: its text must be what the + // declaration renders now. The render alone cannot tell -- a rule under `when` whose condition + // is false today is absent from the render without being a divergence (D4), yet it still has + // to reach the template -- so for these files the text is compared too. A file of another + // contract version is the same case (R40). A file without the header is maintained by hand and + // is judged by its render only. for _, file := range model.Files { content, err := os.ReadFile(filepath.Join(modulePath, file.Path)) if err != nil { continue } - if generated, version := generate.ParseHeader(string(content)); generated && version != rbaccontract.ContractVersion { + generated, version := generate.ParseHeader(string(content)) + + switch { + case !generated: + case version != rbaccontract.ContractVersion: divergences[file.Path] = append(divergences[file.Path], fmt.Sprintf("the file was generated under contract version %q; the current contract is %q", version, rbaccontract.ContractVersion)) + case string(content) != generate.RenderFile(file): + divergences[file.Path] = append(divergences[file.Path], + "the file carries the generator header but is not what the declaration renders now (a rule under `when`, a text edit or an older generator); remove the header to maintain it by hand") } } @@ -445,7 +466,8 @@ func crdScopes(crds []crdInfo) rbacyaml.CRDScopes { // --fix runs (R32). Two safeguards decide whether the file is written at all: // // - a file without the generator header is maintained by hand: the generated text is written -// beside it as .generated and the finding stays (R16, US-F2); +// beside it as _.generated -- the underscore keeps Helm from rendering the copy as a +// second set of objects -- and the finding stays (R16, US-F2); // - the regenerated file must grant everything the current render of that file grants (rules and // aggregation edges); if anything would disappear the file is left alone and the finding names // what would be lost (R25, D3). Removing a right is always a person's decision. @@ -476,19 +498,19 @@ func regenerateFix(modulePath string, file generate.File, actual map[string]mana } if generated, _ := generate.ParseHeader(string(existing)); !generated { - aside := fullPath + ".generated" + aside := asidePath(fullPath) if err := os.WriteFile(aside, []byte(content), 0o600); err != nil { - return fmt.Errorf("write %s: %w", file.Path+".generated", err) + return fmt.Errorf("write %s: %w", asidePath(file.Path), err) } - return fmt.Errorf("%s is maintained by hand (no generator header); the generated version is beside it as %s.generated -- compare, then either delete the file and run `%s` again, or keep maintaining it by hand", - file.Path, file.Path, FixCommand) + return fmt.Errorf("%s is maintained by hand (no generator header); the generated version is beside it as %s -- compare, then either delete the file and run `%s` again, or keep maintaining it by hand", + file.Path, asidePath(file.Path), FixCommand) } } if dropped := sortedSetDiff(renderedRights(fullPath), expected); len(dropped) > 0 { - return fmt.Errorf("regenerating %s would drop rights the render grants today: %s; declare them in %s or remove them from the template by hand", - file.Path, strings.Join(dropped, ", "), rbacyaml.Filename) + return fmt.Errorf("regenerating %s would drop rights the render grants today: %s; declare them in %s, or delete the file and run `%s` again to regenerate it without them", + file.Path, strings.Join(dropped, ", "), rbacyaml.Filename, FixCommand) } if exists && string(existing) == content { @@ -570,3 +592,11 @@ func expectedRights(file generate.File) map[string]struct{} { return out } + +// asidePath is where the generated text of a hand-maintained file is written for comparison: +// _.generated in the same directory. Helm renders every file under templates/ whatever its +// extension, so a plain copy would render a second set of objects; a name starting with an +// underscore is a partial to Helm and produces no objects. +func asidePath(path string) string { + return filepath.Join(filepath.Dir(path), "_"+filepath.Base(path)+".generated") +} diff --git a/pkg/linters/rbac/rules/sync_test.go b/pkg/linters/rbac/rules/sync_test.go index da08e450..f55814de 100644 --- a/pkg/linters/rbac/rules/sync_test.go +++ b/pkg/linters/rbac/rules/sync_test.go @@ -406,7 +406,7 @@ func TestSync_Autofix(t *testing.T) { require.NoError(t, err) assert.Equal(t, "# hand-made\napiVersion: v1\n", string(unchanged)) - aside, err := os.ReadFile(path + ".generated") + aside, err := os.ReadFile(asidePath(path)) require.NoError(t, err) assert.True(t, strings.HasPrefix(string(aside), generate.Header())) }) @@ -590,6 +590,75 @@ func TestSync_GatedTemplateIsNotRegenerated(t *testing.T) { require.NoError(t, err) assert.Equal(t, gated, string(unchanged)) - _, err = os.Stat(path + ".generated") + _, err = os.Stat(asidePath(path)) assert.True(t, os.IsNotExist(err), "no .generated copy for a gated file") } + +// A gated template whose legacy branch rendered (values below 1.78) is not a divergence: the 1.78 +// objects it declares are compared in the run where the gate answers "new". +func TestSync_GatedTemplateRenderingLegacyBranchIsSilent(t *testing.T) { + modulePath := syncModuleDir(t) + model := syncModel(t, modulePath) + store := renderedFrom(t, model, func(o *generate.Object) bool { return o.Name != "d8:namespace-capability:cert-manager:view" }) + + putObject(t, store, "templates/rbacv2/use/view.yaml", generate.Object{ + Kind: "ClusterRole", Name: "d8:use:capability:module:cert-manager:view", Class: generate.ClassCapability, + Labels: map[string]string{"rbac.deckhouse.io/kind": "use", "rbac.deckhouse.io/aggregate-to-kubernetes-as": "viewer"}, + Rules: []generate.Rule{{PolicyRule: rbacyaml.PolicyRule{APIGroups: []string{"cert-manager.io"}, Resources: []string{"certificates"}, Verbs: []string{"get"}}}}, + }) + + path := filepath.Join(modulePath, "templates/rbacv2/use/view.yaml") + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte("{{- if eq (include \"cert-manager.rbacv2_new_scheme\" .) \"true\" }}\n# new\n{{- else }}\n# legacy\n{{- end }}\n"), 0o600)) + + assert.Empty(t, texts(runSync(t, modulePath, store))) +} + +// A generator-owned file must be the text the declaration renders now: a rule under `when` that is +// false today is invisible to the render, so only the text says whether it reached the template. +func TestSync_GeneratedFileTextIsCompared(t *testing.T) { + resetFixState() + t.Cleanup(resetFixState) + + modulePath := syncModuleDir(t) + model := syncModel(t, modulePath) + store := renderedFrom(t, model, nil) + + const rel = "templates/rbacv2/use/view.yaml" + + want := generate.RenderFile(*model.File(rel)) + path := filepath.Join(modulePath, rel) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + + t.Run("the exact text is not a divergence", func(t *testing.T) { + require.NoError(t, os.WriteFile(path, []byte(want), 0o600)) + assert.Empty(t, texts(runSync(t, modulePath, store))) + }) + + t.Run("a stale generated file is regenerated", func(t *testing.T) { + stale := strings.Replace(want, "\n{{- if .Values.certManager.internal.acmeEnabled }}", "\n# a conditional rule was declared after this file was generated\n{{- if .Values.certManager.internal.acmeEnabled }}", 1) + require.NotEqual(t, want, stale, "the fixture must carry a conditional rule") + require.NoError(t, os.WriteFile(path, []byte(stale), 0o600)) + + errorList := runSync(t, modulePath, store) + got := texts(errorList) + require.Len(t, got, 1, "got: %v", got) + assert.Contains(t, got[0], "the file carries the generator header but is not what the declaration renders now") + + for _, fix := range errorList.GetFixes() { + fix() + } + + assert.Empty(t, errorList.GetErrors()) + + written, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, want, string(written)) + }) + + t.Run("a hand-maintained file is judged by its render only", func(t *testing.T) { + _, body, _ := strings.Cut(want, "\n") + require.NoError(t, os.WriteFile(path, []byte("# hand-maintained\n"+body), 0o600)) + assert.Empty(t, texts(runSync(t, modulePath, store))) + }) +} From fed51cb57d7bb4e4cadd96c3764bb8ca4752a279 Mon Sep 17 00:00:00 2001 From: Ivan Zvyagintsev Date: Tue, 22 Sep 2026 09:51:53 +0300 Subject: [PATCH 17/58] rbac: a missing generated file whose objects did not render is a divergence The full loop on an external module -- every RBAC template deleted, --fix asked to write them back -- lost three ServiceAccounts and 88 rules without a finding: the files held only objects under when, the conditions were true and the objects rendered before, and once the files were gone their absence from the render read as "conditional, not rendered" (D4). The render cannot tell a false condition from a template nobody wrote; the text can. A file the declaration produces that does not exist while an object it holds is absent from the render is now reported and created by --fix. A file that exists is judged as before. The sync tests write the generated files to disk before simulating the render, as a module that rendered them would have them. Signed-off-by: Ivan Zvyagintsev --- pkg/linters/rbac/README.md | 2 +- pkg/linters/rbac/rules/sync.go | 20 +++++++++++ pkg/linters/rbac/rules/sync_test.go | 52 +++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 1 deletion(-) diff --git a/pkg/linters/rbac/README.md b/pkg/linters/rbac/README.md index 55e41564..d21b2fa2 100644 --- a/pkg/linters/rbac/README.md +++ b/pkg/linters/rbac/README.md @@ -1619,7 +1619,7 @@ controller ClusterRoles with arbitrary names, objects with Helm-computed names). 2. A capability's aggregation edges (`aggregate-to--as`) match in both directions: rules may agree while a lineage is lost. Its `rbac.deckhouse.io/capability` marker, `module` and `rbac.deckhouse.io/namespace` labels are what the generator writes. 3. A binding's `roleRef` and subjects match. 4. Every rendered legacy role and module capability is produced by the declaration. -5. A file that carries the generator header is the generator's, and its text must be what the declaration renders now: a rule under `when` whose condition is false today is absent from the render without being a divergence, yet it still has to reach the template, so for generator-owned files the text is compared too. A file of another contract version is the same case. Remove the header to maintain a file by hand; then only its render is judged. +5. A file the declaration produces that does not exist while an object it holds is absent from the render is a divergence, whether or not the object is under `when`: the render cannot tell a false condition from a template nobody wrote, the text can. A file that carries the generator header is the generator's, and its text must be what the declaration renders now: a rule under `when` whose condition is false today is absent from the render without being a divergence, yet it still has to reach the template, so for generator-owned files the text is compared too. A file of another contract version is the same case. Remove the header to maintain a file by hand; then only its render is judged. Findings are one per template file and carry the fix command; the text does not depend on the render variant. diff --git a/pkg/linters/rbac/rules/sync.go b/pkg/linters/rbac/rules/sync.go index 0b5128fb..742551ce 100644 --- a/pkg/linters/rbac/rules/sync.go +++ b/pkg/linters/rbac/rules/sync.go @@ -187,6 +187,15 @@ func (r *SyncRule) Check(_ context.Context) { for _, file := range model.Files { content, err := os.ReadFile(filepath.Join(modulePath, file.Path)) if err != nil { + // A file that does not exist while an object it holds is absent from the render: the + // render cannot tell a conditional object whose condition is false from one whose + // template was never written, but the text can -- nothing produces it (D4 covers the + // render, not the file). + if stderrors.Is(err, os.ErrNotExist) && hasAbsentObject(file, actual) { + divergences[file.Path] = append(divergences[file.Path], + "the file does not exist, and objects the declaration puts in it are absent from the render (objects under `when` included: no template produces them)") + } + continue } @@ -287,6 +296,17 @@ func (r *SyncRule) managedObjects(model *generate.Model) map[string]managedObjec return out } +// hasAbsentObject reports whether any object the file declares is missing from the render. +func hasAbsentObject(file generate.File, actual map[string]managedObject) bool { + for _, o := range file.Objects { + if _, ok := actual[o.Identity()]; !ok { + return true + } + } + + return false +} + // compareFile lists the divergences between the objects a generated file declares and the render. func compareFile(file generate.File, actual map[string]managedObject, module string) []string { var out []string diff --git a/pkg/linters/rbac/rules/sync_test.go b/pkg/linters/rbac/rules/sync_test.go index f55814de..4818b74d 100644 --- a/pkg/linters/rbac/rules/sync_test.go +++ b/pkg/linters/rbac/rules/sync_test.go @@ -138,6 +138,18 @@ func putObject(t *testing.T, store *storage.UnstructuredObjectStore, path string require.NoError(t, store.Put("/module/"+path, path, content, []byte(o.Identity()))) } +// writeGenerated puts every file of the model on disk as the generator writes it: the render the +// tests simulate came from somewhere, and a declared file that does not exist is a finding of its own. +func writeGenerated(t *testing.T, modulePath string, model *generate.Model) { + t.Helper() + + for _, r := range generate.Render(model) { + full := filepath.Join(modulePath, r.Path) + require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755)) + require.NoError(t, os.WriteFile(full, []byte(r.Content), 0o600)) + } +} + func runSync(t *testing.T, modulePath string, store *storage.UnstructuredObjectStore) *errors.LintRuleErrorsList { t.Helper() @@ -263,6 +275,7 @@ func TestSync_Divergences(t *testing.T) { t.Run(name, func(t *testing.T) { modulePath := syncModuleDir(t) model := syncModel(t, modulePath) + writeGenerated(t, modulePath, model) store := renderedFrom(t, model, tc.tweak) if tc.extra != nil { @@ -662,3 +675,42 @@ func TestSync_GeneratedFileTextIsCompared(t *testing.T) { assert.Empty(t, texts(runSync(t, modulePath, store))) }) } + +// A file whose objects are all under `when`, deleted: the render cannot miss them (D4), the text can. +func TestSync_MissingFileWithConditionalObjectsIsReported(t *testing.T) { + resetFixState() + t.Cleanup(resetFixState) + + modulePath := syncModuleDir(t) + model := syncModel(t, modulePath) + + const rel = "templates/cainjector/rbac-for-us.yaml" + + file := model.File(rel) + require.NotNil(t, file) + + for _, o := range file.Objects { + require.NotEmpty(t, o.When, "the fixture's cainjector objects are conditional") + } + + // Rendered as with the condition false: none of the cainjector objects, no file on disk. + store := renderedFrom(t, model, func(o *generate.Object) bool { return o.When == "" }) + + errorList := runSync(t, modulePath, store) + got := texts(errorList) + require.Len(t, got, 1, "got: %v", got) + assert.Contains(t, got[0], "templates/cainjector/rbac-for-us.yaml does not match rbac.yaml: the file does not exist, and objects the declaration puts in it are absent from the render (objects under `when` included") + + for _, fix := range errorList.GetFixes() { + fix() + } + + assert.Empty(t, errorList.GetErrors()) + + written, err := os.ReadFile(filepath.Join(modulePath, rel)) + require.NoError(t, err) + assert.Equal(t, generate.RenderFile(*file), string(written)) + + // With the file in place and the condition still false, nothing is reported. + assert.Empty(t, texts(runSync(t, modulePath, store))) +} From 1db0ec13f505e134e6bccef0608bcbff5bc4a485 Mon Sep 17 00:00:00 2001 From: Ivan Zvyagintsev Date: Tue, 22 Sep 2026 10:09:42 +0300 Subject: [PATCH 18/58] rbac: what the loop on cert-manager, user-authz and multitenancy-manager showed Running the whole cycle -- declaration from the rendered objects, every RBAC template regenerated -- on three platform modules found three things: - sync owned every capability that carried the module label, including the ones the declaration cannot produce: the project lineage of multitenancy-manager and the platform-wide capabilities of user-authz, which are named after a lineage rather than the module. They were about to be reported as objects the declaration does not produce. The capability class is now the module's own namespace and system capabilities by name; the rest stays hand-written and unreported (D2). - a generated file is written whole, so an object in it that the declaration does not produce -- a controller ClusterRole beside a declared ServiceAccount (cert-manager's webhook, user-authz's permission browser) -- would vanish with the rewrite, and the advice to delete the file and run --fix again would lose it too. The fix now refuses first of all when the file holds such objects and names them; every render variant's list is joined. - no-cyrillic judged rbac.yaml for the Russian titles and descriptions the declaration requires of capabilities outside the view/edit convention; it is the module's documentation, like module.yaml, and is skipped. Signed-off-by: Ivan Zvyagintsev --- pkg/linters/no-cyrillic/rules/files.go | 4 +- pkg/linters/no-cyrillic/rules/files_test.go | 20 +++++++ pkg/linters/rbac/rules/fixstate.go | 37 +++++++++++++ pkg/linters/rbac/rules/sync.go | 61 ++++++++++++++++++++- pkg/linters/rbac/rules/sync_test.go | 59 ++++++++++++++++++++ 5 files changed, 177 insertions(+), 4 deletions(-) diff --git a/pkg/linters/no-cyrillic/rules/files.go b/pkg/linters/no-cyrillic/rules/files.go index b7161618..5c37dede 100644 --- a/pkg/linters/no-cyrillic/rules/files.go +++ b/pkg/linters/no-cyrillic/rules/files.go @@ -36,7 +36,9 @@ var ( // what fsutils.FilterFileByExtensions compares against. fileExtensions = []string{".yaml", ".yml", ".json", ".go"} - skipDocRe = `doc-ru-.+\.y[a]?ml$|\.ru\.y[a]?ml$|\.ru\.json$|\.ru\.md$|\.ru\.html$|_RU\.md$|_ru\.html$|docs/site/_.+|docs/documentation/_.+|tools/spelling/.+|openapi/conversions/.+|module.yaml|ru\..+` + // module.yaml and rbac.yaml carry the module's localized texts by design (descriptions.ru, the + // ru titles and descriptions of capabilities the rbac declaration requires), so they are not judged. + skipDocRe = `doc-ru-.+\.y[a]?ml$|\.ru\.y[a]?ml$|\.ru\.json$|\.ru\.md$|\.ru\.html$|_RU\.md$|_ru\.html$|docs/site/_.+|docs/documentation/_.+|tools/spelling/.+|openapi/conversions/.+|module.yaml|(^|/)rbac\.yaml$|ru\..+` skipSelfRe = `no_cyrillic(_test)?.go$` skipI18NRe = `/i18n/` diff --git a/pkg/linters/no-cyrillic/rules/files_test.go b/pkg/linters/no-cyrillic/rules/files_test.go index fee3bf52..04211b70 100644 --- a/pkg/linters/no-cyrillic/rules/files_test.go +++ b/pkg/linters/no-cyrillic/rules/files_test.go @@ -482,3 +482,23 @@ metadata: t.Errorf("Cyrillic outside the annotations is still reported, got %v", got) } } + +// rbac.yaml holds the ru titles and descriptions the rbac declaration requires for capabilities +// outside the view/edit convention: it is documentation of the module, like module.yaml, not source. +func TestFilesRule_CheckFile_SkipRBACDeclaration(t *testing.T) { + mockModule := mocks.NewModuleMock(minimock.NewController(t)) + tempDir := t.TempDir() + mockModule.GetPathMock.Return(tempDir) + + path := filepath.Join(tempDir, "rbac.yaml") + if err := os.WriteFile(path, []byte("capabilities:\n namespace.admin:\n title:\n ru: \"Модуль x: администрирование\"\n"), 0o600); err != nil { + t.Fatal(err) + } + + errorList := errors.NewLintRuleErrorsList() + NewFilesRule(nil, nil, mockModule, errorList).checkFile(path) + + if errs := errorList.GetErrors(); len(errs) != 0 { + t.Errorf("rbac.yaml must not be judged, got %v", errs) + } +} diff --git a/pkg/linters/rbac/rules/fixstate.go b/pkg/linters/rbac/rules/fixstate.go index f6698cc3..029a1776 100644 --- a/pkg/linters/rbac/rules/fixstate.go +++ b/pkg/linters/rbac/rules/fixstate.go @@ -19,6 +19,7 @@ package rules import ( "os" "path/filepath" + "sort" "strings" "sync" @@ -39,9 +40,11 @@ var fixState = struct { sync.Mutex outcomes map[string]error rendered map[string]map[string]struct{} + foreign map[string]map[string]struct{} }{ outcomes: map[string]error{}, rendered: map[string]map[string]struct{}{}, + foreign: map[string]map[string]struct{}{}, } // fixOnce runs fix for the key the first time it is asked and returns that outcome on every later @@ -94,6 +97,39 @@ func renderedRights(file string) map[string]struct{} { return out } +// recordForeignObjects adds the objects one render variant placed in the file that the declaration +// does not produce. +func recordForeignObjects(file string, objects []string) { + fixState.Lock() + defer fixState.Unlock() + + known := fixState.foreign[file] + if known == nil { + known = map[string]struct{}{} + fixState.foreign[file] = known + } + + for _, o := range objects { + known[o] = struct{}{} + } +} + +// foreignObjectsOf returns, sorted, every object any render variant placed in the file that the +// declaration does not produce. +func foreignObjectsOf(file string) []string { + fixState.Lock() + defer fixState.Unlock() + + out := make([]string, 0, len(fixState.foreign[file])) + for o := range fixState.foreign[file] { + out = append(out, o) + } + + sort.Strings(out) + + return out +} + // resetFixState forgets everything; tests call it between runs. func resetFixState() { fixState.Lock() @@ -101,6 +137,7 @@ func resetFixState() { fixState.outcomes = map[string]error{} fixState.rendered = map[string]map[string]struct{}{} + fixState.foreign = map[string]map[string]struct{}{} } // editionOverlay returns the edition overlay a module directory lies in ("ee/modules", diff --git a/pkg/linters/rbac/rules/sync.go b/pkg/linters/rbac/rules/sync.go index 742551ce..f6e7d53a 100644 --- a/pkg/linters/rbac/rules/sync.go +++ b/pkg/linters/rbac/rules/sync.go @@ -228,7 +228,7 @@ func (r *SyncRule) Check(_ context.Context) { fileList := r.errorList.WithFilePath(path).WithObjectID(path) if file := model.File(path); file != nil { - fileList = fileList.WithFix(regenerateFix(modulePath, *file, actual)) + fileList = fileList.WithFix(regenerateFix(modulePath, *file, actual, r.foreignObjects(*file))) fileList.Errorf("%s does not match %s: %s. Run `%s` to regenerate the file from the declaration", path, rbacyaml.Filename, strings.Join(list, "; "), FixCommand) @@ -254,6 +254,39 @@ func (r *SyncRule) legacyFiles() map[string]string { return out } +// foreignObjects lists the RBAC objects the render placed in the file that the declaration does not +// produce -- a controller ClusterRole beside a declared ServiceAccount, a hand-written binding. The +// generator writes the whole file, so regenerating it would drop them; they are the reason a +// regeneration is refused until they are declared or moved. +func (r *SyncRule) foreignObjects(file generate.File) []string { + produced := map[string]struct{}{} + for _, o := range file.Objects { + produced[o.Identity()] = struct{}{} + } + + var out []string + + for index, object := range r.module.GetStorage() { + if object.ShortPath() != file.Path { + continue + } + + switch object.Unstructured.GetKind() { + case "ClusterRole", "Role", "ClusterRoleBinding", "RoleBinding", "ServiceAccount": + default: + continue + } + + if _, ok := produced[index.AsString()]; !ok { + out = append(out, index.AsString()) + } + } + + sort.Strings(out) + + return out +} + // managedObject is a rendered object the sync rule owns, with the class it was recognized by. type managedObject struct { object storage.StoreObject @@ -284,7 +317,13 @@ func (r *SyncRule) managedObjects(model *generate.Model) map[string]managedObjec switch { case object.Unstructured.GetKind() == "ClusterRole" && annotations[rbaccontract.AccessLevelAnnotation] != "": out[identity] = managedObject{object, generate.ClassLegacy} - case object.Unstructured.GetKind() == "ClusterRole" && labels[rbaccontract.LabelKind] == rbaccontract.KindCapability && labels[rbaccontract.LabelModule] == r.module.GetName(): + case object.Unstructured.GetKind() == "ClusterRole" && labels[rbaccontract.LabelKind] == rbaccontract.KindCapability && labels[rbaccontract.LabelModule] == r.module.GetName() && + isModuleCapabilityName(object.Unstructured.GetName(), r.module.GetName()): + // Only the capabilities the declaration can produce: the module's own, in the namespace + // and system lineages. A module may also ship capabilities of the project lineage or + // platform-wide ones named after a lineage rather than the module (user-authz, + // multitenancy-manager); the format has no place for them, so they stay hand-written + // and are neither generated nor "extra" (D2). out[identity] = managedObject{object, generate.ClassCapability} default: if _, ok := declared[identity]; ok { @@ -491,7 +530,7 @@ func crdScopes(crds []crdInfo) rbacyaml.CRDScopes { // - the regenerated file must grant everything the current render of that file grants (rules and // aggregation edges); if anything would disappear the file is left alone and the finding names // what would be lost (R25, D3). Removing a right is always a person's decision. -func regenerateFix(modulePath string, file generate.File, actual map[string]managedObject) errors.AutofixFunc { +func regenerateFix(modulePath string, file generate.File, actual map[string]managedObject, foreign []string) errors.AutofixFunc { content := generate.RenderFile(file) expected := expectedRights(file) fullPath := filepath.Join(modulePath, file.Path) @@ -501,6 +540,7 @@ func regenerateFix(modulePath string, file generate.File, actual map[string]mana // exists; the closure that runs first checks the union of them and writes, the others report // its outcome (R36). A right rendered only under some values is therefore not lost (D3). recordRenderedRights(fullPath, currentRights(file.Path, actual)) + recordForeignObjects(fullPath, foreign) return func() error { return fixOnce(fullPath, func() error { @@ -512,6 +552,15 @@ func regenerateFix(modulePath string, file generate.File, actual map[string]mana } if exists { + // Objects in the file that the declaration does not produce would vanish with the rewrite, + // header or not -- and "delete the file and run --fix again" would lose them too, so this + // comes before every other answer. (A foreign object under `when` that did not render this + // time is caught by the run where it renders; every variant's list is joined.) + if foreign := foreignObjectsOf(fullPath); len(foreign) > 0 { + return fmt.Errorf("%s also holds objects the declaration does not produce: %s; regenerating the file would drop them, and so would deleting it -- declare them in %s or move them to another template, then run `%s` again", + file.Path, strings.Join(foreign, ", "), rbacyaml.Filename, FixCommand) + } + if strings.Contains(string(existing), rbaccontract.GateMarker) || strings.Contains(string(existing), "deckhouseVersion") { return fmt.Errorf("%s renders one of two role models depending on the platform version (the %s gate of rbacv2-migrate-module.sh); regenerating it would drop the legacy branch -- edit the new branch by hand, or drop the gate and the legacy object once clusters below DKP 1.78 are no longer served, then run `%s`", file.Path, rbaccontract.GateMarker, FixCommand) @@ -620,3 +669,9 @@ func expectedRights(file generate.File) map[string]struct{} { func asidePath(path string) string { return filepath.Join(filepath.Dir(path), "_"+filepath.Base(path)+".generated") } + +// isModuleCapabilityName reports whether the name is one the generator builds for this module: +// d8:namespace-capability:: or d8:system-capability::. +func isModuleCapabilityName(name, module string) bool { + return strings.HasPrefix(name, "d8:namespace-capability:"+module+":") || strings.HasPrefix(name, "d8:system-capability:"+module+":") +} diff --git a/pkg/linters/rbac/rules/sync_test.go b/pkg/linters/rbac/rules/sync_test.go index 4818b74d..c80dc9f5 100644 --- a/pkg/linters/rbac/rules/sync_test.go +++ b/pkg/linters/rbac/rules/sync_test.go @@ -251,6 +251,21 @@ func TestSync_Divergences(t *testing.T) { }, want: []string{"error: templates/user-authz-cluster-roles.yaml does not match rbac.yaml: ClusterRole/d8:user-authz:cert-manager:super-admin is in the render but rbac.yaml does not produce it: declare its rights in rbac.yaml or remove it from the template. Run `dmt lint --linter rbac --fix` to regenerate the file from the declaration"}, }, + "D2: capabilities of the project lineage and platform-wide ones are not the declaration's": { + extra: func(t *testing.T, store *storage.UnstructuredObjectStore) { + putObject(t, store, "templates/rbacv2/project/capabilities/manage_rbac.yaml", generate.Object{ + Kind: "ClusterRole", Name: "d8:project-capability:cert-manager:manage_rbac", Class: generate.ClassCapability, + Labels: map[string]string{"rbac.deckhouse.io/kind": "capability", "rbac.deckhouse.io/scope": "project", "rbac.deckhouse.io/aggregate-to-project-as": "admin"}, + Rules: []generate.Rule{{PolicyRule: rbacyaml.PolicyRule{APIGroups: []string{"rbac.authorization.k8s.io"}, Resources: []string{"rolebindings"}, Verbs: []string{"create"}}}}, + }) + putObject(t, store, "templates/rbacv2/global/namespace/capabilities/view_logs.yaml", generate.Object{ + Kind: "ClusterRole", Name: "d8:namespace-capability:kubernetes:view_logs", Class: generate.ClassCapability, + Labels: map[string]string{"rbac.deckhouse.io/kind": "capability", "rbac.deckhouse.io/scope": "namespace", "rbac.deckhouse.io/aggregate-to-namespace-as": "viewer"}, + Rules: []generate.Rule{{PolicyRule: rbacyaml.PolicyRule{APIGroups: []string{""}, Resources: []string{"pods/log"}, Verbs: []string{"get"}}}}, + }) + }, + want: nil, + }, "D2: a module capability in a file the declaration does not produce": { extra: func(t *testing.T, store *storage.UnstructuredObjectStore) { putObject(t, store, "templates/rbacv2/use/superadmin.yaml", generate.Object{ @@ -714,3 +729,47 @@ func TestSync_MissingFileWithConditionalObjectsIsReported(t *testing.T) { // With the file in place and the condition still false, nothing is reported. assert.Empty(t, texts(runSync(t, modulePath, store))) } + +// A generated file that also holds an object the declaration does not produce is never rewritten: +// the generator writes the whole file, and the foreign object would vanish with it. +func TestSync_FileWithForeignObjectsIsNotRegenerated(t *testing.T) { + resetFixState() + t.Cleanup(resetFixState) + + modulePath := syncModuleDir(t) + model := syncModel(t, modulePath) + writeGenerated(t, modulePath, model) + + const rel = "templates/cainjector/rbac-for-us.yaml" + + // The render: the cainjector file lacks a declared rule and carries a controller ClusterRole + // of its own that nobody declared. + store := renderedFrom(t, model, func(o *generate.Object) bool { + if o.Kind == "ClusterRole" && o.Name == "d8:cert-manager:cainjector" { + o.Rules = o.Rules[:1] + } + + return true + }) + putObject(t, store, rel, generate.Object{ + Kind: "ClusterRole", Name: "d8:cert-manager:cainjector:requester", Class: generate.ClassDeclared, + Rules: []generate.Rule{{PolicyRule: rbacyaml.PolicyRule{APIGroups: []string{""}, Resources: []string{"secrets"}, Verbs: []string{"get"}}}}, + }) + + before, err := os.ReadFile(filepath.Join(modulePath, rel)) + require.NoError(t, err) + + errorList := runSync(t, modulePath, store) + for _, fix := range errorList.GetFixes() { + fix() + } + + remaining := errorList.GetErrors() + require.Len(t, remaining, 1) + require.Error(t, remaining[0].FixError) + assert.Contains(t, remaining[0].FixError.Error(), "templates/cainjector/rbac-for-us.yaml also holds objects the declaration does not produce: ClusterRole/d8:cert-manager:cainjector:requester; regenerating the file would drop them") + + after, err := os.ReadFile(filepath.Join(modulePath, rel)) + require.NoError(t, err) + assert.Equal(t, string(before), string(after), "the file is left alone") +} From 84b898fd62f28988192c73d8c6637d9d22a858ba Mon Sep 17 00:00:00 2001 From: Ivan Zvyagintsev Date: Tue, 22 Sep 2026 10:10:20 +0300 Subject: [PATCH 19/58] rbac: document the capability class and the foreign-objects safeguard Signed-off-by: Ivan Zvyagintsev --- pkg/linters/rbac/README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pkg/linters/rbac/README.md b/pkg/linters/rbac/README.md index d21b2fa2..8422f063 100644 --- a/pkg/linters/rbac/README.md +++ b/pkg/linters/rbac/README.md @@ -1607,7 +1607,7 @@ declaration produces and compares them with the render. `sync` owns exactly three classes of rendered objects: 1. legacy roles -- ClusterRoles with the `user-authz.deckhouse.io/access-level` annotation; -2. the module's RBACv2 capabilities -- ClusterRoles with `rbac.deckhouse.io/kind: capability` and `module: `; +2. the module's own RBACv2 capabilities -- ClusterRoles named `d8:namespace-capability::` or `d8:system-capability::` with `rbac.deckhouse.io/kind: capability` and `module: `. Capabilities of the project lineage and platform-wide ones named after a lineage rather than the module (user-authz, multitenancy-manager) are not the declaration's: the format has no place for them, so they stay hand-written; 3. declared objects -- those whose names the generator builds from `serviceAccounts`, `access` and `prometheusAccess`. Everything else in the render is unmanaged: not generated, not reported (`include "helm_lib_csi_controller_rbac"`, @@ -1623,8 +1623,9 @@ controller ClusterRoles with arbitrary names, objects with Helm-computed names). Findings are one per template file and carry the fix command; the text does not depend on the render variant. -**Autofix:** regenerates the file from `rbac.yaml`, with two safeguards -- +**Autofix:** regenerates the file from `rbac.yaml`, with three safeguards -- +- a file that also holds objects the declaration does not produce -- a controller ClusterRole beside a declared ServiceAccount, a hand-written binding -- is never rewritten, because the generator writes the whole file and they would vanish (and so they would if the file were deleted); the refusal names them: declare them or move them to another template first; - a file without the generator header is maintained by hand: the generated text is written beside it as `_.generated` (the underscore keeps Helm from rendering the copy) and the finding stays (delete the file and run `--fix` again to hand it back to the generator); - the regenerated file must grant everything the render of that file grants today, rules and aggregation edges alike; otherwise the file is left alone and the finding names what would be lost. Removing a right is always a person's decision: declare it in `rbac.yaml` or remove it from the template by hand. From 9fa2e86ad4633c36f95779820375cd8d38a64774 Mon Sep 17 00:00:00 2001 From: Ivan Zvyagintsev Date: Tue, 22 Sep 2026 10:47:44 +0300 Subject: [PATCH 20/58] rbac: the declaration is the source, and an existing module enters it from its render Decided with the platform owner on 2026-09-22, after the loop on four modules: - bootstrap: a module without rbac.yaml gets a sync finding, and --fix writes the declaration from the RBAC objects the module renders today -- the file a person would have transcribed from the templates, with a TODO wherever a decision is still theirs (a resource with no CRD and an unknown scope, a CRD nobody grants, a namespaced resource granted cluster-wide) and a note for every object the generator will name differently or cannot describe. From then on rbac.yaml is the source and the templates follow it. This replaces R22 ("contract only without a declaration") and the ADR's "coverage creates the file". - the declaration wins: a right it no longer names leaves the template on --fix, the finding that led there having listed it. This replaces D3 ("autofix never removes a right"), whose danger -- the first run deleting what nobody had declared yet -- is gone once the declaration starts from the render. - serviceAccounts[].extraClusterRoles: further ClusterRoles in the account's file, bound to it or not (cert-manager's per-controller roles, an aggregated apiserver's requester role), named d8::: or exactly as given when they start with d8:. - serviceAccounts[].automountServiceAccountToken: the generator no longer forces false; the import keeps true where the account mounted its token, so no pod loses it. - access[].path: cluster and namespace grants may live in a component's rbac-for-us.yaml / rbac-to-us.yaml, where the module kept them. - the foreign-objects safeguard recognizes an object the generator produces under another name -- a binding with the same roleRef and subjects, a role with the same rules -- as replaced, not lost. On operator-trivy, cert-manager, user-authz and multitenancy-manager the flow now runs end to end: every RBAC template regenerated from the written declaration, the rendered rights identical before and after up to the dead clusterissuers rule of cert-manager and the renames the generator's naming forces. Signed-off-by: Ivan Zvyagintsev --- pkg/linters/rbac/rules/bootstrap/bootstrap.go | 693 ++++++++++++++++++ .../rbac/rules/bootstrap/bootstrap_test.go | 221 ++++++ pkg/linters/rbac/rules/bootstrap/marshal.go | 63 ++ pkg/linters/rbac/rules/bootstrap/scopes.go | 52 ++ pkg/linters/rbac/rules/fixstate.go | 38 +- .../rbac/rules/generate/generate_test.go | 42 ++ pkg/linters/rbac/rules/generate/model.go | 24 +- pkg/linters/rbac/rules/rbacyaml/load_test.go | 44 ++ pkg/linters/rbac/rules/rbacyaml/types.go | 39 +- pkg/linters/rbac/rules/rbacyaml/validate.go | 27 + pkg/linters/rbac/rules/sync.go | 284 +++++-- pkg/linters/rbac/rules/sync_test.go | 127 +++- .../expected.yaml | 12 + .../charts/deckhouse_lib_helm-1.72.1.tgz | Bin 0 -> 45549 bytes .../module/crds/certificaterequests.yaml | 9 + .../module/crds/certificates.yaml | 9 + .../module/crds/challenges.yaml | 9 + .../module/crds/clusterissuers.yaml | 9 + .../module/crds/issuers.yaml | 9 + .../module/crds/orders.yaml | 9 + .../module/module.yaml | 3 + .../module/openapi/config-values.yaml | 2 + .../module/openapi/values.yaml | 10 + .../templates/cainjector/rbac-for-us.yaml | 115 +++ .../module/templates/rbac-for-us.yaml | 34 + .../module/templates/rbac-to-us.yaml | 67 ++ .../module/templates/rbacv2/manage/edit.yaml | 34 + .../module/templates/rbacv2/manage/view.yaml | 31 + .../module/templates/rbacv2/use/admin.yaml | 22 + .../module/templates/rbacv2/use/edit.yaml | 30 + .../module/templates/rbacv2/use/view.yaml | 55 ++ .../templates/user-authz-cluster-roles.yaml | 113 +++ .../rbac/scheme-legacy-only/expected.yaml | 7 +- 33 files changed, 2096 insertions(+), 147 deletions(-) create mode 100644 pkg/linters/rbac/rules/bootstrap/bootstrap.go create mode 100644 pkg/linters/rbac/rules/bootstrap/bootstrap_test.go create mode 100644 pkg/linters/rbac/rules/bootstrap/marshal.go create mode 100644 pkg/linters/rbac/rules/bootstrap/scopes.go create mode 100644 test/e2e/testdata/rbac/bootstrap-writes-declaration/expected.yaml create mode 100644 test/e2e/testdata/rbac/bootstrap-writes-declaration/module/charts/deckhouse_lib_helm-1.72.1.tgz create mode 100644 test/e2e/testdata/rbac/bootstrap-writes-declaration/module/crds/certificaterequests.yaml create mode 100644 test/e2e/testdata/rbac/bootstrap-writes-declaration/module/crds/certificates.yaml create mode 100644 test/e2e/testdata/rbac/bootstrap-writes-declaration/module/crds/challenges.yaml create mode 100644 test/e2e/testdata/rbac/bootstrap-writes-declaration/module/crds/clusterissuers.yaml create mode 100644 test/e2e/testdata/rbac/bootstrap-writes-declaration/module/crds/issuers.yaml create mode 100644 test/e2e/testdata/rbac/bootstrap-writes-declaration/module/crds/orders.yaml create mode 100644 test/e2e/testdata/rbac/bootstrap-writes-declaration/module/module.yaml create mode 100644 test/e2e/testdata/rbac/bootstrap-writes-declaration/module/openapi/config-values.yaml create mode 100644 test/e2e/testdata/rbac/bootstrap-writes-declaration/module/openapi/values.yaml create mode 100644 test/e2e/testdata/rbac/bootstrap-writes-declaration/module/templates/cainjector/rbac-for-us.yaml create mode 100644 test/e2e/testdata/rbac/bootstrap-writes-declaration/module/templates/rbac-for-us.yaml create mode 100644 test/e2e/testdata/rbac/bootstrap-writes-declaration/module/templates/rbac-to-us.yaml create mode 100644 test/e2e/testdata/rbac/bootstrap-writes-declaration/module/templates/rbacv2/manage/edit.yaml create mode 100644 test/e2e/testdata/rbac/bootstrap-writes-declaration/module/templates/rbacv2/manage/view.yaml create mode 100644 test/e2e/testdata/rbac/bootstrap-writes-declaration/module/templates/rbacv2/use/admin.yaml create mode 100644 test/e2e/testdata/rbac/bootstrap-writes-declaration/module/templates/rbacv2/use/edit.yaml create mode 100644 test/e2e/testdata/rbac/bootstrap-writes-declaration/module/templates/rbacv2/use/view.yaml create mode 100644 test/e2e/testdata/rbac/bootstrap-writes-declaration/module/templates/user-authz-cluster-roles.yaml diff --git a/pkg/linters/rbac/rules/bootstrap/bootstrap.go b/pkg/linters/rbac/rules/bootstrap/bootstrap.go new file mode 100644 index 00000000..64d36e75 --- /dev/null +++ b/pkg/linters/rbac/rules/bootstrap/bootstrap.go @@ -0,0 +1,693 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package bootstrap writes the first rbac.yaml of a module from the RBAC objects it renders today: +// the declaration a person would have transcribed from the templates by hand, with a note wherever +// a decision is still theirs. It is the entry point of an existing module into the declaration; +// from then on rbac.yaml is the source and the templates follow it. +package bootstrap + +import ( + "fmt" + "regexp" + "sort" + "strings" + + rbacv1 "k8s.io/api/rbac/v1" + + "github.com/deckhouse/dmt/pkg/linters/rbac/rules/rbaccontract" + "github.com/deckhouse/dmt/pkg/linters/rbac/rules/rbacyaml" +) + +// Object is one rendered RBAC object or ServiceAccount. +type Object struct { + Kind, Name, Namespace string + // Path is the template the object came from, relative to the module root. + Path string + Labels map[string]string + Annotations map[string]string + Rules []rbacv1.PolicyRule + RoleRef rbacv1.RoleRef + Subjects []rbacv1.Subject + Automount *bool +} + +// Input is what the render says about the module. +type Input struct { + Module, Namespace string + // Subsystems are the module.yaml subsystems; the declaration overrides them only when the + // rendered system capabilities aggregate into a different set. + Subsystems []string + Objects []Object + // CRDs maps group/plural to scope for the CRDs under crds/. + CRDs map[string]string +} + +// Result is the declaration with the reader's homework. +type Result struct { + Decl *rbacyaml.Declaration + // Notes are decisions the reader must check: scopes the linter could not tell, grants kept as + // TODO, objects the generator will name differently. + Notes []string + // Unmanaged are the RBAC objects the declaration cannot describe; they stay hand-written. + Unmanaged []string +} + +var capabilityRe = regexp.MustCompile(`^d8:(namespace|system)-capability:([a-z0-9-]+):([a-z0-9_]+)$`) + +type resourceAcc struct { + namespace, system, legacy map[string]map[string]struct{} +} + +func newAcc() *resourceAcc { + return &resourceAcc{namespace: map[string]map[string]struct{}{}, system: map[string]map[string]struct{}{}, legacy: map[string]map[string]struct{}{}} +} + +func addVerbs(into map[string]map[string]struct{}, level string, verbs []string) { + if into[level] == nil { + into[level] = map[string]struct{}{} + } + + for _, v := range verbs { + into[level][v] = struct{}{} + } +} + +func sortedLevels(m map[string]map[string]struct{}) map[string][]string { + if len(m) == 0 { + return nil + } + + out := make(map[string][]string, len(m)) + + for level, verbs := range m { + list := make([]string, 0, len(verbs)) + for v := range verbs { + list = append(list, v) + } + + sort.Strings(list) + out[level] = list + } + + return out +} + +type builder struct { + in Input + res map[[2]string]*resourceAcc + texts map[string]rbacyaml.CapabilityText + lineages map[string]struct{} + used map[string]struct{} + notes []string + unmanaged []string + decl *rbacyaml.Declaration +} + +func (b *builder) note(format string, args ...any) { + b.notes = append(b.notes, fmt.Sprintf(format, args...)) +} +func (b *builder) unmanage(o Object, why string) { + b.unmanaged = append(b.unmanaged, fmt.Sprintf("%s (%s): %s", o.identity(), o.Path, why)) +} +func (b *builder) mark(o Object) { b.used[o.identity()] = struct{}{} } +func (b *builder) isUsed(o Object) bool { _, ok := b.used[o.identity()]; return ok } +func (b *builder) ns(o Object) string { + if o.Namespace == "" { + return b.in.Namespace + } + + return o.Namespace +} +func (b *builder) rename(kind, from, to string) { + if from != to { + b.note("%s %s will be named %s by the generator", kind, from, to) + } +} + +func (o Object) identity() string { + if o.Namespace != "" { + return o.Namespace + "/" + o.Kind + "/" + o.Name + } + + return o.Kind + "/" + o.Name +} + +// Build derives the declaration. +func Build(in Input) Result { + b := &builder{in: in, res: map[[2]string]*resourceAcc{}, texts: map[string]rbacyaml.CapabilityText{}, lineages: map[string]struct{}{}, used: map[string]struct{}{}, + decl: &rbacyaml.Declaration{APIVersion: rbacyaml.APIVersionV1Alpha1}} + + b.capabilitiesAndLegacy() + b.serviceAccounts() + b.otherBindings() + b.leftovers() + b.resources() + + return Result{Decl: b.decl, Notes: b.notes, Unmanaged: b.unmanaged} +} + +func (b *builder) addRules(sectionName, level string, rules []rbacv1.PolicyRule, skipModuleConfigs bool) { + for _, r := range rules { + if len(r.NonResourceURLs) > 0 { + b.note("%s/%s: a nonResourceURLs rule (%s) has no place in resources[]; keep it in a ServiceAccount's clusterRules", sectionName, level, strings.Join(r.NonResourceURLs, ", ")) + continue + } + + groups := r.APIGroups + if len(groups) == 0 { + groups = []string{""} + } + + for _, g := range groups { + for _, rs := range r.Resources { + if skipModuleConfigs && g == "deckhouse.io" && rs == "moduleconfigs" { + continue // the generator adds it + } + + if len(r.ResourceNames) > 0 { + b.note("%s/%s: %s/%s was limited to resourceNames %v; the format has no resourceNames for capabilities, the grant is now on every object", sectionName, level, g, rs, r.ResourceNames) + } + + key := [2]string{g, rs} + if b.res[key] == nil { + b.res[key] = newAcc() + } + + switch sectionName { + case rbaccontract.LineageNamespace: + addVerbs(b.res[key].namespace, level, r.Verbs) + case rbaccontract.LineageSystem: + addVerbs(b.res[key].system, level, r.Verbs) + default: + addVerbs(b.res[key].legacy, level, r.Verbs) + } + } + } + } +} + +func (b *builder) capabilitiesAndLegacy() { + for _, o := range b.in.Objects { + if o.Kind != "ClusterRole" { + continue + } + + if level := o.Annotations[rbaccontract.AccessLevelAnnotation]; level != "" { + b.rename("ClusterRole", o.Name, "d8:user-authz:"+b.in.Module+":"+rbaccontract.LegacyKebab(level)) + b.addRules("legacy", level, o.Rules, false) + b.mark(o) + + continue + } + + switch o.Labels[rbaccontract.LabelKind] { + case rbaccontract.KindCapability: + m := capabilityRe.FindStringSubmatch(o.Name) + if m == nil || m[2] != b.in.Module { + b.unmanage(o, "a capability the declaration cannot produce (not d8:-capability:"+b.in.Module+":)") + b.mark(o) + + continue + } + + lineage, action := m[1], m[3] + level := action + + for lvl, act := range map[string]string{"viewer": "view", "manager": "edit"} { + if act == action { + level = lvl + } + } + + b.addRules(lineage, level, o.Rules, lineage == rbaccontract.LineageSystem) + b.mark(o) + + if lineage == rbaccontract.LineageSystem { + for key := range o.Labels { + if strings.HasPrefix(key, rbaccontract.AggregationLabelPrefix) && strings.HasSuffix(key, rbaccontract.AggregationLabelSuffix) { + b.lineages[strings.TrimSuffix(strings.TrimPrefix(key, rbaccontract.AggregationLabelPrefix), rbaccontract.AggregationLabelSuffix)] = struct{}{} + } + } + } + + if !rbaccontract.IsConventionalAction(action) { + b.texts[lineage+"."+action] = rbacyaml.CapabilityText{ + Title: rbacyaml.LocalizedText{EN: o.Annotations[rbaccontract.AnnotationTitleEN], RU: o.Annotations[rbaccontract.AnnotationTitleRU]}, + Description: rbacyaml.LocalizedText{EN: o.Annotations[rbaccontract.AnnotationDescriptionEN], RU: o.Annotations[rbaccontract.AnnotationDescriptionRU]}, + } + } + case rbaccontract.KindRole: + b.unmanage(o, "a role of the role model") + b.mark(o) + case rbaccontract.KindLegacyUse, rbaccontract.KindLegacyManage: + b.unmanage(o, "a capability of the RBACv2 scheme before DKP 1.78; run rbacv2-migrate-module.sh first") + b.mark(o) + } + } +} + +func (b *builder) byKind(kind string) []Object { + var out []Object + + for _, o := range b.in.Objects { + if o.Kind == kind { + out = append(out, o) + } + } + + sort.Slice(out, func(i, j int) bool { return out[i].identity() < out[j].identity() }) + + return out +} + +func (b *builder) clusterRole(name string) (Object, bool) { + for _, o := range b.in.Objects { + if o.Kind == "ClusterRole" && o.Name == name { + return o, true + } + } + + return Object{}, false +} + +func (b *builder) role(ns, name string) (Object, bool) { + for _, o := range b.in.Objects { + if o.Kind == "Role" && o.Name == name && b.ns(o) == ns { + return o, true + } + } + + return Object{}, false +} + +// ownClusterRole reports whether the ClusterRole is the module's own plain one: labelled with the +// module, neither a capability nor a role of the model nor a legacy role. +func (b *builder) ownClusterRole(o Object) bool { + return o.Kind == "ClusterRole" && o.Labels[rbaccontract.LabelModule] == b.in.Module && o.Labels[rbaccontract.LabelKind] == "" && o.Annotations[rbaccontract.AccessLevelAnnotation] == "" +} + +func (b *builder) bindingsOf(name string) []Object { + var out []Object + + for _, o := range b.in.Objects { + if o.Kind == "ClusterRoleBinding" && o.RoleRef.Name == name { + out = append(out, o) + } + } + + return out +} + +func subjectsAreOnly(o Object, saName, saNS string) bool { + if len(o.Subjects) == 0 { + return false + } + + for _, s := range o.Subjects { + if s.Kind != "ServiceAccount" || s.Name != saName || (s.Namespace != "" && s.Namespace != saNS) { + return false + } + } + + return true +} + +var pathRe = regexp.MustCompile(`^templates/(?:(.*)/)?rbac-for-us\.yaml$`) + +func (b *builder) serviceAccounts() { + for _, sa := range b.byKind("ServiceAccount") { + if b.ns(sa) != b.in.Namespace { + b.unmanage(sa, "outside the module namespace") + continue + } + + e := rbacyaml.ServiceAccount{Name: sa.Name} + + if m := pathRe.FindStringSubmatch(sa.Path); m != nil { + e.Path = m[1] + } else { + b.note("ServiceAccount %s lives in %s; the generator keeps accounts in templates/[/]rbac-for-us.yaml and will write it to templates/rbac-for-us.yaml", sa.Name, sa.Path) + } + + if app := sa.Labels["app"]; app != "" { + e.Labels = map[string]string{"app": app} + } + + if sa.Automount == nil || *sa.Automount { + yes := true + e.AutomountToken = &yes + + b.note("ServiceAccount %s mounted its token (automountServiceAccountToken unset or true); kept as true -- set false once its pods mount the token themselves", sa.Name) + } + + b.mark(sa) + + clusterName := "d8:" + b.in.Module + ":" + sa.Name + + for _, crb := range b.byKind("ClusterRoleBinding") { + if b.isUsed(crb) || !subjectsAreOnly(crb, sa.Name, b.in.Namespace) { + continue + } + + cr, found := b.clusterRole(crb.RoleRef.Name) + exclusive := found && b.ownClusterRole(cr) && len(b.bindingsOf(cr.Name)) == 1 + + switch { + case exclusive && cr.Name == clusterName && e.ClusterRules == nil: + e.ClusterRules = policyRules(cr.Rules) + + b.rename("ClusterRoleBinding", crb.Name, clusterName) + case exclusive: + extra := rbacyaml.ExtraClusterRole{Name: strings.TrimPrefix(cr.Name, clusterName+":"), Rules: policyRules(cr.Rules)} + if !strings.HasPrefix(cr.Name, clusterName+":") && !strings.HasPrefix(cr.Name, "d8:") { + b.rename("ClusterRole", cr.Name, extra.FullName(b.in.Module, sa.Name)) + } + + e.ExtraClusterRoles = append(e.ExtraClusterRoles, extra) + b.rename("ClusterRoleBinding", crb.Name, extra.FullName(b.in.Module, sa.Name)) + default: + e.BindClusterRoles = append(e.BindClusterRoles, crb.RoleRef.Name) + b.rename("ClusterRoleBinding", crb.Name, clusterName+":"+bindingSuffix(crb.RoleRef.Name)) + } + + if found && (exclusive) { + b.mark(cr) + } + + b.mark(crb) + } + + for _, rb := range b.byKind("RoleBinding") { + if b.isUsed(rb) || !subjectsAreOnly(rb, sa.Name, b.in.Namespace) { + continue + } + + if role, ok := b.role(b.ns(rb), rb.RoleRef.Name); ok && b.ns(rb) == b.in.Namespace && e.NamespaceRules == nil && rb.RoleRef.Kind == "Role" { + e.NamespaceRules = policyRules(role.Rules) + b.rename("Role", role.Name, sa.Name) + b.rename("RoleBinding", rb.Name, sa.Name) + b.mark(role) + } else { + e.BindRoles = append(e.BindRoles, rbacyaml.RoleRef{Namespace: b.ns(rb), Name: rb.RoleRef.Name}) + b.rename("RoleBinding", b.ns(rb)+"/"+rb.Name, b.ns(rb)+"/"+clusterName+":"+bindingSuffix(rb.RoleRef.Name)) + } + + b.mark(rb) + } + + // Unbound ClusterRoles of the module in the account's file: roles shipped for others to + // bind (an aggregated apiserver's requester). They travel with the account, unbound. + for _, cr := range b.byKind("ClusterRole") { + if b.isUsed(cr) || !b.ownClusterRole(cr) || cr.Path != sa.Path || len(b.bindingsOf(cr.Name)) != 0 { + continue + } + + no := false + extra := rbacyaml.ExtraClusterRole{Name: strings.TrimPrefix(cr.Name, clusterName+":"), Rules: policyRules(cr.Rules), Bind: &no} + + if !strings.HasPrefix(cr.Name, clusterName+":") && !strings.HasPrefix(cr.Name, "d8:") { + b.rename("ClusterRole", cr.Name, extra.FullName(b.in.Module, sa.Name)) + } + + e.ExtraClusterRoles = append(e.ExtraClusterRoles, extra) + + b.mark(cr) + } + + if n := len(e.ExtraClusterRoles); n > 1 { + b.note("ServiceAccount %s has %d ClusterRoles of its own besides d8:%s:%s; they are kept separate as extraClusterRoles -- merge them into clusterRules if nothing binds them separately", sa.Name, n, b.in.Module, sa.Name) + } + + b.decl.ServiceAccounts = append(b.decl.ServiceAccounts, e) + } +} + +// otherBindings turns bindings to subjects other than the module's accounts into access entries +// and the Prometheus scrape access. +func (b *builder) otherBindings() { + for _, crb := range b.byKind("ClusterRoleBinding") { + if b.isUsed(crb) { + continue + } + + cr, found := b.clusterRole(crb.RoleRef.Name) + if !found || !b.ownClusterRole(cr) { + b.unmanage(crb, fmt.Sprintf("binds %s, which is not a plain ClusterRole of this module", crb.RoleRef.Name)) + continue + } + + name := strings.TrimPrefix(cr.Name, "d8:"+b.in.Module+":") + b.decl.Access = append(b.decl.Access, rbacyaml.Access{Name: name, Path: componentOf(cr.Path, "rbac-for-us.yaml"), Subjects: subjects(crb.Subjects), ClusterRules: policyRules(cr.Rules)}) + b.rename("ClusterRoleBinding", crb.Name, "d8:"+b.in.Module+":"+name) + b.rename("ClusterRole", cr.Name, "d8:"+b.in.Module+":"+name) + b.mark(cr) + b.mark(crb) + } + + for _, rb := range b.byKind("RoleBinding") { + if b.isUsed(rb) { + continue + } + + role, ok := b.role(b.ns(rb), rb.RoleRef.Name) + if !ok || b.ns(rb) != b.in.Namespace || rb.RoleRef.Kind != "Role" { + b.unmanage(rb, fmt.Sprintf("binds %s/%s outside the module's own Roles", rb.RoleRef.Kind, rb.RoleRef.Name)) + continue + } + + if b.prometheus(role, rb) { + continue + } + + name := strings.TrimPrefix(rb.Name, "access-to-"+b.in.Module+"-") + b.decl.Access = append(b.decl.Access, rbacyaml.Access{Name: name, Path: componentOf(role.Path, "rbac-to-us.yaml"), Subjects: subjects(rb.Subjects), NamespaceRules: policyRules(role.Rules)}) + b.rename("Role", role.Name, "access-to-"+b.in.Module+"-"+name) + b.rename("RoleBinding", rb.Name, "access-to-"+b.in.Module+"-"+name) + b.mark(role) + b.mark(rb) + } +} + +// prometheus recognizes the scrape access: a Role of /prometheus-metrics rules bound to the +// scraper. Several such Roles fold into one prometheusAccess. +func (b *builder) prometheus(role, rb Object) bool { + scraper := false + + for _, s := range rb.Subjects { + if s.Name == "d8-monitoring:scraper" || (s.Kind == "ServiceAccount" && s.Name == "prometheus" && s.Namespace == "d8-monitoring") { + scraper = true + } + } + + if !scraper || len(role.Rules) == 0 { + return false + } + + for _, r := range role.Rules { + for _, res := range r.Resources { + if !strings.HasSuffix(res, "/prometheus-metrics") { + return false + } + } + } + + if b.decl.PrometheusAccess == nil { + b.decl.PrometheusAccess = &rbacyaml.PrometheusAccess{} + } else { + b.note("several Prometheus access Roles fold into one prometheusAccess (Role access-to-%s)", b.in.Module) + } + + pa := b.decl.PrometheusAccess + + for _, r := range role.Rules { + for _, res := range r.Resources { + switch strings.TrimSuffix(res, "/prometheus-metrics") { + case "deployments": + pa.Deployments = append(pa.Deployments, r.ResourceNames...) + case "daemonsets": + pa.DaemonSets = append(pa.DaemonSets, r.ResourceNames...) + case "statefulsets": + pa.StatefulSets = append(pa.StatefulSets, r.ResourceNames...) + } + } + } + + sort.Strings(pa.Deployments) + sort.Strings(pa.DaemonSets) + sort.Strings(pa.StatefulSets) + b.rename("Role", role.Name, "access-to-"+b.in.Module) + b.rename("RoleBinding", rb.Name, "access-to-"+b.in.Module) + b.mark(role) + b.mark(rb) + + return true +} + +func (b *builder) leftovers() { + for _, o := range b.in.Objects { + if b.isUsed(o) { + continue + } + + switch o.Kind { + case "ClusterRole": + b.unmanage(o, "bound to nothing the declaration describes") + case "Role": + b.unmanage(o, "bound to nothing the declaration describes") + case "ClusterRoleBinding", "RoleBinding", "ServiceAccount": + b.unmanage(o, "not described") + } + } +} + +func (b *builder) resources() { + keys := make([][2]string, 0, len(b.res)) + for k := range b.res { + keys = append(keys, k) + } + + sort.Slice(keys, func(i, j int) bool { + if keys[i][0] != keys[j][0] { + return keys[i][0] < keys[j][0] + } + + return keys[i][1] < keys[j][1] + }) + + for _, k := range keys { + group, resource := k[0], k[1] + acc := b.res[k] + e := rbacyaml.Resource{Group: group, Resource: resource} + + base := resource + if i := strings.IndexByte(base, '/'); i >= 0 { + base = base[:i] + } + + scope, fromCRD := b.in.CRDs[group+"/"+base] + + switch { + case fromCRD: + case resource == "*": + e.Scope, scope = rbacyaml.ScopeCluster, rbacyaml.ScopeCluster + e.Reason = "TODO: the templates grant the whole group; say why the resource names are not known statically" + default: + if s, ok := wellKnownScopes[group+"/"+base]; ok { + scope = s + } else { + b.note("%s/%s: the module ships no CRD and the scope is not known; fill scope: Namespaced|Cluster", group, resource) + } + + if !strings.Contains(resource, "/") { + e.Scope = scope + } + } + + if len(acc.namespace) > 0 && scope == rbacyaml.ScopeCluster { + b.note("%s/%s: cluster-scoped, yet granted in a namespace capability -- the rule granted nothing through a RoleBinding and is dropped from namespace", group, resource) + + acc.namespace = map[string]map[string]struct{}{} + } + + if len(acc.system) > 0 && scope == rbacyaml.ScopeNamespaced && e.Reason == "" { + e.Reason = "TODO: a namespaced resource granted cluster-wide, as the templates did; confirm or move it to namespace" + } + + e.Namespace, e.System, e.Legacy = sortedLevels(acc.namespace), sortedLevels(acc.system), sortedLevels(acc.legacy) + if !e.HasLevels() { + continue + } + + b.decl.Resources = append(b.decl.Resources, e) + } + + crdKeys := make([]string, 0, len(b.in.CRDs)) + for k := range b.in.CRDs { + crdKeys = append(crdKeys, k) + } + + sort.Strings(crdKeys) + + for _, k := range crdKeys { + group, plural, _ := strings.Cut(k, "/") + declared := false + + for _, r := range b.decl.Resources { + if r.Group == group && r.Resource == plural { + declared = true + } + } + + if !declared { + b.decl.Resources = append(b.decl.Resources, rbacyaml.Resource{Group: group, Resource: plural, NoAccess: "TODO: no user-facing access in the templates today; grant levels or say why users get none"}) + } + } + + if len(b.lineages) > 0 { + got := make([]string, 0, len(b.lineages)) + for l := range b.lineages { + got = append(got, l) + } + + sort.Strings(got) + + want := append([]string(nil), b.in.Subsystems...) + sort.Strings(want) + + if strings.Join(got, ",") != strings.Join(want, ",") { + b.decl.Subsystems = got + b.note("system capabilities aggregate into %v while module.yaml says %v; subsystems is set explicitly", got, want) + } + } + + if len(b.texts) > 0 { + b.decl.Capabilities = b.texts + } +} + +// componentOf returns the component directory of templates//, "" for the root file +// or any other template. +func componentOf(path, file string) string { + rest, ok := strings.CutPrefix(path, "templates/") + if !ok || !strings.HasSuffix(rest, "/"+file) { + return "" + } + + return strings.TrimSuffix(rest, "/"+file) +} + +func policyRules(rules []rbacv1.PolicyRule) []rbacyaml.PolicyRule { + out := make([]rbacyaml.PolicyRule, 0, len(rules)) + for _, r := range rules { + out = append(out, rbacyaml.PolicyRule{APIGroups: r.APIGroups, Resources: r.Resources, ResourceNames: r.ResourceNames, NonResourceURLs: r.NonResourceURLs, Verbs: r.Verbs}) + } + + return out +} + +func subjects(list []rbacv1.Subject) []rbacyaml.Subject { + out := make([]rbacyaml.Subject, 0, len(list)) + for _, s := range list { + out = append(out, rbacyaml.Subject{Kind: s.Kind, Name: s.Name, Namespace: s.Namespace}) + } + + return out +} + +func bindingSuffix(roleName string) string { + return strings.ReplaceAll(strings.TrimPrefix(roleName, "d8:"), ":", "-") +} diff --git a/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go b/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go new file mode 100644 index 00000000..ebc300d1 --- /dev/null +++ b/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go @@ -0,0 +1,221 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package bootstrap + +import ( + "os" + "path/filepath" + "sort" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + rbacv1 "k8s.io/api/rbac/v1" + + "github.com/deckhouse/dmt/pkg/linters/rbac/rules/generate" + "github.com/deckhouse/dmt/pkg/linters/rbac/rules/rbacyaml" +) + +// objectsOf simulates the render of a model: every object as the chart would produce it, with the +// module labels helm_lib adds and the module namespace stripped, as dmt renders it. +func objectsOf(model *generate.Model, module, namespace string) []Object { + var out []Object + + for _, file := range model.Files { + for _, o := range file.Objects { + labels := map[string]string{"heritage": "deckhouse", "module": module} + for k, v := range o.Labels { + labels[k] = v + } + + ns := o.Namespace + if ns == namespace { + ns = "" + } + + obj := Object{Kind: o.Kind, Name: o.Name, Namespace: ns, Path: file.Path, Labels: labels, Annotations: o.Annotations, Automount: o.AutomountToken} + + for _, r := range o.Rules { + obj.Rules = append(obj.Rules, rbacv1.PolicyRule{APIGroups: r.APIGroups, Resources: r.Resources, ResourceNames: r.ResourceNames, NonResourceURLs: r.NonResourceURLs, Verbs: r.Verbs}) + } + + if o.RoleRefKind != "" { + obj.RoleRef = rbacv1.RoleRef{APIGroup: "rbac.authorization.k8s.io", Kind: o.RoleRefKind, Name: o.RoleRefName} + } + + for _, s := range o.Subjects { + obj.Subjects = append(obj.Subjects, rbacv1.Subject{Kind: s.Kind, Name: s.Name, Namespace: s.Namespace}) + } + + out = append(out, obj) + } + } + + return out +} + +var certManagerCRDs = map[string]string{ + "cert-manager.io/certificates": "Namespaced", "cert-manager.io/certificaterequests": "Namespaced", "cert-manager.io/issuers": "Namespaced", + "cert-manager.io/clusterissuers": "Cluster", "acme.cert-manager.io/orders": "Namespaced", "acme.cert-manager.io/challenges": "Namespaced", +} + +// The declaration the generator renders comes back from its render: what the fixture declares is +// what the importer writes, up to what the render cannot show (when, reasons). +func TestBuild_RoundTripOnTheCertManagerFixture(t *testing.T) { + raw, err := os.ReadFile(filepath.Join("..", "generate", "testdata", "cert-manager", "rbac.yaml")) + require.NoError(t, err) + + want, err := rbacyaml.Parse(raw) + require.NoError(t, err) + + model, err := generate.Build(generate.Input{Module: "cert-manager", Namespace: "d8-cert-manager", Subsystems: []string{"security"}, Decl: want}) + require.NoError(t, err) + + got := Build(Input{Module: "cert-manager", Namespace: "d8-cert-manager", Subsystems: []string{"security"}, Objects: objectsOf(model, "cert-manager", "d8-cert-manager"), CRDs: certManagerCRDs}) + require.Empty(t, got.Unmanaged, "everything the generator wrote is described again") + + // resources: same keys and levels + keyOf := func(r rbacyaml.Resource) string { return r.Group + "/" + r.Resource } + + wantRes := map[string]rbacyaml.Resource{} + for _, r := range want.Resources { + wantRes[keyOf(r)] = r + } + + gotRes := map[string]rbacyaml.Resource{} + for _, r := range got.Decl.Resources { + gotRes[keyOf(r)] = r + } + + for k, w := range wantRes { + g, ok := gotRes[k] + require.True(t, ok, "resource %s missing", k) + assert.Equal(t, w.NoAccess != "", g.NoAccess != "", "%s: denied", k) + assert.Equal(t, w.Namespace, g.Namespace, "%s: namespace levels", k) + assert.Equal(t, w.System, g.System, "%s: system levels", k) + assert.Equal(t, w.Legacy, g.Legacy, "%s: legacy levels", k) + } + + assert.Len(t, gotRes, len(wantRes)) + + // capabilities texts for the non-conventional level + assert.Equal(t, want.Capabilities, got.Decl.Capabilities) + + // service accounts + byName := func(list []rbacyaml.ServiceAccount) map[string]rbacyaml.ServiceAccount { + m := map[string]rbacyaml.ServiceAccount{} + for _, sa := range list { + m[sa.Name] = sa + } + + return m + } + wantSA, gotSA := byName(want.ServiceAccounts), byName(got.Decl.ServiceAccounts) + require.Len(t, gotSA, len(wantSA)) + + for name, w := range wantSA { + g := gotSA[name] + assert.Equal(t, w.Path, g.Path, "%s: path", name) + assert.Equal(t, w.ClusterRules, g.ClusterRules, "%s: clusterRules", name) + assert.Equal(t, w.NamespaceRules, g.NamespaceRules, "%s: namespaceRules", name) + assert.ElementsMatch(t, w.BindClusterRoles, g.BindClusterRoles, "%s: bindClusterRoles", name) + assert.ElementsMatch(t, w.BindRoles, g.BindRoles, "%s: bindRoles", name) + assert.Equal(t, len(w.ExtraClusterRoles), len(g.ExtraClusterRoles), "%s: extraClusterRoles", name) + assert.Nil(t, g.AutomountToken, "%s: the generator wrote automount false, so the import leaves it unset", name) + } + + // access and prometheus + wantAccess := map[string]rbacyaml.Access{} + for _, a := range want.Access { + wantAccess[a.Name] = a + } + + for _, a := range got.Decl.Access { + w, ok := wantAccess[a.Name] + require.True(t, ok, "access %s unexpected", a.Name) + assert.Equal(t, w.ClusterRules, a.ClusterRules) + assert.Equal(t, w.NamespaceRules, a.NamespaceRules) + assert.ElementsMatch(t, w.Subjects, a.Subjects) + } + + assert.Len(t, got.Decl.Access, len(want.Access)) + require.NotNil(t, got.Decl.PrometheusAccess) + assert.ElementsMatch(t, want.PrometheusAccess.Deployments, got.Decl.PrometheusAccess.Deployments) + + // nothing to rename: the generator's names come back as themselves + for _, n := range got.Notes { + assert.NotContains(t, n, "will be named", "note: %s", n) + } + + // and the file it writes parses and validates + content, err := Marshal(got) + require.NoError(t, err) + again, err := rbacyaml.Parse(content) + require.NoError(t, err) + assert.Empty(t, rbacyaml.Validate(again, rbacyaml.CRDScopes(certManagerCRDs)), "the written declaration validates") +} + +// What the importer cannot decide is a TODO or a note, and objects outside the format stay listed. +func TestBuild_TODOsAndUnmanaged(t *testing.T) { + yes := true + objects := []Object{ + // a capability granting a resource without a CRD in the module and a well-known core one + {Kind: "ClusterRole", Name: "d8:namespace-capability:m:view", Path: "templates/rbacv2/use/view.yaml", + Labels: map[string]string{"module": "m", "rbac.deckhouse.io/kind": "capability", "rbac.deckhouse.io/scope": "namespace", "rbac.deckhouse.io/aggregate-to-namespace-as": "viewer"}, + Rules: []rbacv1.PolicyRule{{APIGroups: []string{"trivy.deckhouse.io"}, Resources: []string{"vulnerabilityreports"}, Verbs: []string{"get"}}, {APIGroups: []string{""}, Resources: []string{"pods"}, Verbs: []string{"get"}}}}, + // a legacy scheme capability and a platform capability + {Kind: "ClusterRole", Name: "d8:use:capability:module:m:view", Path: "templates/rbacv2/use/old.yaml", Labels: map[string]string{"module": "m", "rbac.deckhouse.io/kind": "use"}}, + {Kind: "ClusterRole", Name: "d8:namespace-capability:kubernetes:view_logs", Path: "templates/rbacv2/global/x.yaml", Labels: map[string]string{"module": "m", "rbac.deckhouse.io/kind": "capability"}}, + // an account that mounts its token, with an unbound role in its file + {Kind: "ServiceAccount", Name: "webhook", Path: "templates/webhook/rbac-for-us.yaml", Labels: map[string]string{"module": "m", "app": "webhook"}, Automount: &yes}, + {Kind: "ClusterRole", Name: "d8:m:webhook:requester", Path: "templates/webhook/rbac-for-us.yaml", Labels: map[string]string{"module": "m"}, Rules: []rbacv1.PolicyRule{{APIGroups: []string{"x"}, Resources: []string{"y"}, Verbs: []string{"create"}}}}, + } + + got := Build(Input{Module: "m", Namespace: "d8-m", Subsystems: []string{"security"}, Objects: objects, CRDs: map[string]string{"deckhouse.io/things": "Namespaced"}}) + + sort.Strings(got.Notes) + + joined := "" + for _, n := range got.Notes { + joined += n + "\n" + } + + assert.Contains(t, joined, "trivy.deckhouse.io/vulnerabilityreports: the module ships no CRD and the scope is not known; fill scope") + assert.Contains(t, joined, "ServiceAccount webhook mounted its token") + + byKey := map[string]rbacyaml.Resource{} + for _, r := range got.Decl.Resources { + byKey[r.Group+"/"+r.Resource] = r + } + + assert.Equal(t, "Namespaced", byKey["/pods"].Scope, "a well-known core resource gets its scope") + assert.Equal(t, "", byKey["trivy.deckhouse.io/vulnerabilityreports"].Scope, "an unknown one is left for the author") + assert.Contains(t, byKey["deckhouse.io/things"].NoAccess, "TODO", "a CRD nobody grants is an undecided entry") + + require.Len(t, got.Decl.ServiceAccounts, 1) + sa := got.Decl.ServiceAccounts[0] + assert.Equal(t, "webhook", sa.Path) + require.NotNil(t, sa.AutomountToken) + assert.True(t, *sa.AutomountToken) + require.Len(t, sa.ExtraClusterRoles, 1) + assert.Equal(t, "requester", sa.ExtraClusterRoles[0].Name) + assert.False(t, sa.ExtraClusterRoles[0].IsBound()) + + require.Len(t, got.Unmanaged, 2) + assert.Contains(t, got.Unmanaged[0]+got.Unmanaged[1], "d8:use:capability:module:m:view") + assert.Contains(t, got.Unmanaged[0]+got.Unmanaged[1], "d8:namespace-capability:kubernetes:view_logs") +} diff --git a/pkg/linters/rbac/rules/bootstrap/marshal.go b/pkg/linters/rbac/rules/bootstrap/marshal.go new file mode 100644 index 00000000..29906967 --- /dev/null +++ b/pkg/linters/rbac/rules/bootstrap/marshal.go @@ -0,0 +1,63 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package bootstrap + +import ( + "bytes" + "strings" + + "gopkg.in/yaml.v3" +) + +// Marshal renders the declaration as rbac.yaml, with the reader's homework as a comment on top. +func Marshal(r Result) ([]byte, error) { + var head strings.Builder + + head.WriteString("# Written by dmt (rbac/sync --fix) from the RBAC objects the module rendered. Review it, resolve every TODO\n") + head.WriteString("# and every note below, then run \"dmt lint --linter rbac --fix\" to regenerate the templates from it.\n") + + if len(r.Notes) > 0 { + head.WriteString("#\n# Notes:\n") + + for _, n := range r.Notes { + head.WriteString("# - " + n + "\n") + } + } + + if len(r.Unmanaged) > 0 { + head.WriteString("#\n# Not described by the declaration (stays hand-written, as it is):\n") + + for _, u := range r.Unmanaged { + head.WriteString("# - " + u + "\n") + } + } + + var body bytes.Buffer + + enc := yaml.NewEncoder(&body) + enc.SetIndent(2) + + if err := enc.Encode(r.Decl); err != nil { + return nil, err + } + + if err := enc.Close(); err != nil { + return nil, err + } + + return []byte(head.String() + body.String()), nil +} diff --git a/pkg/linters/rbac/rules/bootstrap/scopes.go b/pkg/linters/rbac/rules/bootstrap/scopes.go new file mode 100644 index 00000000..1aca4bcd --- /dev/null +++ b/pkg/linters/rbac/rules/bootstrap/scopes.go @@ -0,0 +1,52 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package bootstrap + +// wellKnownScopes is the scope of the built-in Kubernetes resources a module's RBAC commonly names, +// for the first declaration written from the render: the module ships no CRD for them and the +// linter has no cluster to ask. Anything not here is left without a scope for the author to fill. +var wellKnownScopes = map[string]string{ + // core + "/pods": "Namespaced", "/pods/log": "Namespaced", "/pods/exec": "Namespaced", "/pods/portforward": "Namespaced", "/pods/proxy": "Namespaced", "/pods/status": "Namespaced", + "/services": "Namespaced", "/services/proxy": "Namespaced", "/endpoints": "Namespaced", "/secrets": "Namespaced", "/configmaps": "Namespaced", + "/serviceaccounts": "Namespaced", "/serviceaccounts/token": "Namespaced", "/events": "Namespaced", "/limitranges": "Namespaced", "/resourcequotas": "Namespaced", + "/persistentvolumeclaims": "Namespaced", "/replicationcontrollers": "Namespaced", "/podtemplates": "Namespaced", "/bindings": "Namespaced", + "/namespaces": "Cluster", "/nodes": "Cluster", "/nodes/proxy": "Cluster", "/nodes/status": "Cluster", "/persistentvolumes": "Cluster", "/componentstatuses": "Cluster", + // apps, batch, autoscaling, policy + "apps/deployments": "Namespaced", "apps/daemonsets": "Namespaced", "apps/statefulsets": "Namespaced", "apps/replicasets": "Namespaced", "apps/controllerrevisions": "Namespaced", + "apps/deployments/scale": "Namespaced", "apps/statefulsets/scale": "Namespaced", "apps/replicasets/scale": "Namespaced", + "batch/jobs": "Namespaced", "batch/cronjobs": "Namespaced", + "autoscaling/horizontalpodautoscalers": "Namespaced", "policy/poddisruptionbudgets": "Namespaced", + // networking, discovery, coordination, events + "networking.k8s.io/ingresses": "Namespaced", "networking.k8s.io/networkpolicies": "Namespaced", "networking.k8s.io/ingressclasses": "Cluster", + "discovery.k8s.io/endpointslices": "Namespaced", "coordination.k8s.io/leases": "Namespaced", "events.k8s.io/events": "Namespaced", + // rbac, storage, scheduling, node, admission, apiextensions, apiregistration, certificates, flowcontrol + "rbac.authorization.k8s.io/roles": "Namespaced", "rbac.authorization.k8s.io/rolebindings": "Namespaced", + "rbac.authorization.k8s.io/clusterroles": "Cluster", "rbac.authorization.k8s.io/clusterrolebindings": "Cluster", + "storage.k8s.io/storageclasses": "Cluster", "storage.k8s.io/volumeattachments": "Cluster", "storage.k8s.io/csidrivers": "Cluster", "storage.k8s.io/csinodes": "Cluster", "storage.k8s.io/csistoragecapacities": "Namespaced", + "scheduling.k8s.io/priorityclasses": "Cluster", "node.k8s.io/runtimeclasses": "Cluster", + "admissionregistration.k8s.io/mutatingwebhookconfigurations": "Cluster", "admissionregistration.k8s.io/validatingwebhookconfigurations": "Cluster", + "admissionregistration.k8s.io/validatingadmissionpolicies": "Cluster", "admissionregistration.k8s.io/validatingadmissionpolicybindings": "Cluster", + "apiextensions.k8s.io/customresourcedefinitions": "Cluster", "apiregistration.k8s.io/apiservices": "Cluster", + "certificates.k8s.io/certificatesigningrequests": "Cluster", + "flowcontrol.apiserver.k8s.io/flowschemas": "Cluster", "flowcontrol.apiserver.k8s.io/prioritylevelconfigurations": "Cluster", + // authentication / authorization (virtual, cluster-scoped) + "authentication.k8s.io/tokenreviews": "Cluster", "authorization.k8s.io/subjectaccessreviews": "Cluster", "authorization.k8s.io/selfsubjectaccessreviews": "Cluster", + "authorization.k8s.io/selfsubjectrulesreviews": "Cluster", "authorization.k8s.io/localsubjectaccessreviews": "Namespaced", + // metrics + "metrics.k8s.io/pods": "Namespaced", "metrics.k8s.io/nodes": "Cluster", +} diff --git a/pkg/linters/rbac/rules/fixstate.go b/pkg/linters/rbac/rules/fixstate.go index 029a1776..dd67037c 100644 --- a/pkg/linters/rbac/rules/fixstate.go +++ b/pkg/linters/rbac/rules/fixstate.go @@ -33,17 +33,15 @@ import ( // - outcomes remembers the result of the first closure that ran for a target, so the others // return it instead of doing the work again, and every copy of the finding ends the run in // the same state; -// - rendered accumulates, at lint time, the rights every variant's render grants in a file, so -// the drop guard of the sync autofix judges the union rather than the render of whichever -// variant happened to run its closure first (D3). +// - foreign accumulates, at lint time, the objects every variant's render placed in a file that +// the declaration does not produce, so the refusal to rewrite such a file judges the union +// rather than the render of whichever variant happened to run its closure first. var fixState = struct { sync.Mutex outcomes map[string]error - rendered map[string]map[string]struct{} foreign map[string]map[string]struct{} }{ outcomes: map[string]error{}, - rendered: map[string]map[string]struct{}{}, foreign: map[string]map[string]struct{}{}, } @@ -68,35 +66,6 @@ func fixOnce(key string, fix func() error) error { return err } -// recordRenderedRights adds what one render variant grants in the file to what is known about it. -func recordRenderedRights(file string, rights map[string]struct{}) { - fixState.Lock() - defer fixState.Unlock() - - known := fixState.rendered[file] - if known == nil { - known = map[string]struct{}{} - fixState.rendered[file] = known - } - - for r := range rights { - known[r] = struct{}{} - } -} - -// renderedRights returns everything any render variant granted in the file. -func renderedRights(file string) map[string]struct{} { - fixState.Lock() - defer fixState.Unlock() - - out := make(map[string]struct{}, len(fixState.rendered[file])) - for r := range fixState.rendered[file] { - out[r] = struct{}{} - } - - return out -} - // recordForeignObjects adds the objects one render variant placed in the file that the declaration // does not produce. func recordForeignObjects(file string, objects []string) { @@ -136,7 +105,6 @@ func resetFixState() { defer fixState.Unlock() fixState.outcomes = map[string]error{} - fixState.rendered = map[string]map[string]struct{}{} fixState.foreign = map[string]map[string]struct{}{} } diff --git a/pkg/linters/rbac/rules/generate/generate_test.go b/pkg/linters/rbac/rules/generate/generate_test.go index 4506502f..9fc5feab 100644 --- a/pkg/linters/rbac/rules/generate/generate_test.go +++ b/pkg/linters/rbac/rules/generate/generate_test.go @@ -199,3 +199,45 @@ func TestParseHeader(t *testing.T) { generated, _ = ParseHeader("---\napiVersion: v1\n") assert.False(t, generated, "a file without the header is maintained by hand") } + +// extraClusterRoles land in the account's file, bound unless said otherwise; the account's +// automountServiceAccountToken follows the declaration and defaults to false. +func TestBuild_ExtraClusterRolesAndAutomount(t *testing.T) { + yes, no := true, false + decl := &rbacyaml.Declaration{APIVersion: rbacyaml.APIVersionV1Alpha1, ServiceAccounts: []rbacyaml.ServiceAccount{ + {Name: "webhook", Path: "webhook", AutomountToken: &yes, ExtraClusterRoles: []rbacyaml.ExtraClusterRole{ + {Name: "requester", Bind: &no, Rules: []rbacyaml.PolicyRule{{APIGroups: []string{"admission.cert-manager.io"}, Resources: []string{"certificates"}, Verbs: []string{"create"}}}}, + {Name: "approve", Rules: []rbacyaml.PolicyRule{{APIGroups: []string{"cert-manager.io"}, Resources: []string{"signers"}, Verbs: []string{"approve"}}}}, + {Name: "d8:cert-manager:legacy-name", Rules: []rbacyaml.PolicyRule{{APIGroups: []string{""}, Resources: []string{"secrets"}, Verbs: []string{"get"}}}}, + }}, + {Name: "controller"}, + }} + + model, err := Build(Input{Module: "cert-manager", Namespace: "d8-cert-manager", Subsystems: []string{"security"}, Decl: decl}) + require.NoError(t, err) + + file := model.File("templates/webhook/rbac-for-us.yaml") + require.NotNil(t, file) + + names := map[string]Object{} + for _, o := range file.Objects { + names[o.Kind+"/"+o.Name] = o + } + + assert.Contains(t, names, "ClusterRole/d8:cert-manager:webhook:requester") + assert.NotContains(t, names, "ClusterRoleBinding/d8:cert-manager:webhook:requester", "bind: false leaves the role unbound") + assert.Contains(t, names, "ClusterRole/d8:cert-manager:webhook:approve") + assert.Contains(t, names, "ClusterRoleBinding/d8:cert-manager:webhook:approve") + assert.Equal(t, "d8:cert-manager:webhook:approve", names["ClusterRoleBinding/d8:cert-manager:webhook:approve"].RoleRefName) + assert.Contains(t, names, "ClusterRole/d8:cert-manager:legacy-name", "a full d8: name is kept as given") + assert.True(t, *names["ServiceAccount/webhook"].AutomountToken) + + root := model.File("templates/rbac-for-us.yaml") + require.NotNil(t, root) + + for _, o := range root.Objects { + if o.Kind == "ServiceAccount" && o.Name == "controller" { + assert.False(t, *o.AutomountToken, "unset means false") + } + } +} diff --git a/pkg/linters/rbac/rules/generate/model.go b/pkg/linters/rbac/rules/generate/model.go index 03565fe7..2d3c2e51 100644 --- a/pkg/linters/rbac/rules/generate/model.go +++ b/pkg/linters/rbac/rules/generate/model.go @@ -360,7 +360,7 @@ func (b *builder) serviceAccounts() { labels[k] = v } - automount := false + automount := sa.AutomountToken != nil && *sa.AutomountToken b.add(path, Object{ Kind: "ServiceAccount", Name: sa.Name, Namespace: b.in.Namespace, Class: ClassDeclared, When: sa.When, Labels: labels, AutomountToken: &automount, @@ -379,6 +379,15 @@ func (b *builder) serviceAccounts() { b.add(path, Object{Kind: "RoleBinding", Name: sa.Name, Namespace: b.in.Namespace, Class: ClassDeclared, When: sa.When, Labels: labels, RoleRefKind: "Role", RoleRefName: sa.Name, Subjects: subject}) } + for _, extra := range sa.ExtraClusterRoles { + extraName := extra.FullName(b.in.Module, sa.Name) + b.add(path, Object{Kind: "ClusterRole", Name: extraName, Class: ClassDeclared, When: sa.When, Labels: labels, Rules: policyRules(extra.Rules)}) + + if extra.IsBound() { + b.add(path, Object{Kind: "ClusterRoleBinding", Name: extraName, Class: ClassDeclared, When: sa.When, Labels: labels, RoleRefKind: "ClusterRole", RoleRefName: extraName, Subjects: subject}) + } + } + for _, bound := range sa.BindClusterRoles { b.add(path, Object{ Kind: "ClusterRoleBinding", Name: clusterName + ":" + bindingSuffix(bound), Class: ClassDeclared, When: sa.When, Labels: labels, @@ -440,16 +449,21 @@ func (b *builder) access() { subjects = append(subjects, Subject{Kind: s.Kind, Name: s.Name, Namespace: s.Namespace}) } + dir := "templates/" + if a.Path != "" { + dir = "templates/" + a.Path + "/" + } + if len(a.ClusterRules) > 0 { name := "d8:" + b.in.Module + ":" + a.Name - b.add("templates/rbac-for-us.yaml", Object{Kind: "ClusterRole", Name: name, Class: ClassDeclared, Rules: policyRules(a.ClusterRules)}) - b.add("templates/rbac-for-us.yaml", Object{Kind: "ClusterRoleBinding", Name: name, Class: ClassDeclared, RoleRefKind: "ClusterRole", RoleRefName: name, Subjects: subjects}) + b.add(dir+"rbac-for-us.yaml", Object{Kind: "ClusterRole", Name: name, Class: ClassDeclared, Rules: policyRules(a.ClusterRules)}) + b.add(dir+"rbac-for-us.yaml", Object{Kind: "ClusterRoleBinding", Name: name, Class: ClassDeclared, RoleRefKind: "ClusterRole", RoleRefName: name, Subjects: subjects}) } if len(a.NamespaceRules) > 0 { name := "access-to-" + b.in.Module + "-" + a.Name - b.add("templates/rbac-to-us.yaml", Object{Kind: "Role", Name: name, Namespace: b.in.Namespace, Class: ClassDeclared, Rules: policyRules(a.NamespaceRules)}) - b.add("templates/rbac-to-us.yaml", Object{Kind: "RoleBinding", Name: name, Namespace: b.in.Namespace, Class: ClassDeclared, RoleRefKind: "Role", RoleRefName: name, Subjects: subjects}) + b.add(dir+"rbac-to-us.yaml", Object{Kind: "Role", Name: name, Namespace: b.in.Namespace, Class: ClassDeclared, Rules: policyRules(a.NamespaceRules)}) + b.add(dir+"rbac-to-us.yaml", Object{Kind: "RoleBinding", Name: name, Namespace: b.in.Namespace, Class: ClassDeclared, RoleRefKind: "Role", RoleRefName: name, Subjects: subjects}) } } } diff --git a/pkg/linters/rbac/rules/rbacyaml/load_test.go b/pkg/linters/rbac/rules/rbacyaml/load_test.go index a185c34e..1602462d 100644 --- a/pkg/linters/rbac/rules/rbacyaml/load_test.go +++ b/pkg/linters/rbac/rules/rbacyaml/load_test.go @@ -474,3 +474,47 @@ func TestLoad(t *testing.T) { assert.Equal(t, APIVersionV1Alpha1, decl.APIVersion) assert.Len(t, decl.Resources, 7) } + +// extraClusterRoles: named, unique, with rules; automountServiceAccountToken parses. +func TestValidate_ExtraClusterRoles(t *testing.T) { + decl, err := Parse([]byte(`apiVersion: rbac.deckhouse.io/v1alpha1 +serviceAccounts: + - name: webhook + automountServiceAccountToken: true + extraClusterRoles: + - name: requester + bind: false + rules: + - apiGroups: [admission.cert-manager.io] + resources: [certificates] + verbs: [create] + - name: requester + rules: + - apiGroups: [""] + resources: [secrets] + verbs: [get] + - name: "" + rules: [] + - name: d8:other:full-name + rules: [] +`)) + require.NoError(t, err) + require.NotNil(t, decl.ServiceAccounts[0].AutomountToken) + assert.True(t, *decl.ServiceAccounts[0].AutomountToken) + assert.False(t, decl.ServiceAccounts[0].ExtraClusterRoles[0].IsBound()) + assert.True(t, decl.ServiceAccounts[0].ExtraClusterRoles[1].IsBound()) + assert.Equal(t, "d8:cert-manager:webhook:requester", decl.ServiceAccounts[0].ExtraClusterRoles[0].FullName("cert-manager", "webhook")) + assert.Equal(t, "d8:other:full-name", decl.ServiceAccounts[0].ExtraClusterRoles[3].FullName("cert-manager", "webhook")) + + errs := Validate(decl, nil) + + msgs := make([]string, 0, len(errs)) + for _, e := range errs { + msgs = append(msgs, e.Error()) + } + + assert.Contains(t, msgs, `serviceAccounts[0] (webhook).extraClusterRoles[1]: duplicate name "requester"`) + assert.Contains(t, msgs, "serviceAccounts[0] (webhook).extraClusterRoles[2]: name is required") + assert.Contains(t, msgs, "serviceAccounts[0] (webhook).extraClusterRoles[3] (d8:other:full-name): rules is required") + assert.Len(t, msgs, 3, "got: %v", msgs) +} diff --git a/pkg/linters/rbac/rules/rbacyaml/types.go b/pkg/linters/rbac/rules/rbacyaml/types.go index bc35f4d4..686640a4 100644 --- a/pkg/linters/rbac/rules/rbacyaml/types.go +++ b/pkg/linters/rbac/rules/rbacyaml/types.go @@ -155,6 +155,38 @@ type ServiceAccount struct { NamespaceRules []PolicyRule `yaml:"namespaceRules,omitempty"` BindClusterRoles []string `yaml:"bindClusterRoles,omitempty"` BindRoles []RoleRef `yaml:"bindRoles,omitempty"` + + // ExtraClusterRoles are further ClusterRoles that live in the account's rbac-for-us.yaml: + // controller roles split by concern (cert-manager's approve, certificates, ...), or roles the + // module ships for other subjects to bind (an aggregated apiserver's requester role). Each is + // d8:::, or exactly the given name when it starts with d8:. + ExtraClusterRoles []ExtraClusterRole `yaml:"extraClusterRoles,omitempty"` + + // AutomountToken is the ServiceAccount's automountServiceAccountToken; unset means false, the + // platform convention. A pod that needs the token sets it true on the pod, or the account + // declares true here. + AutomountToken *bool `yaml:"automountServiceAccountToken,omitempty"` +} + +// ExtraClusterRole is one more ClusterRole in a ServiceAccount's file. Bind unset or true also +// produces the ClusterRoleBinding of the same name to the account; false leaves the role unbound. +type ExtraClusterRole struct { + Name string `yaml:"name"` + Rules []PolicyRule `yaml:"rules"` + Bind *bool `yaml:"bind,omitempty"` +} + +// IsBound reports whether the role is bound to its account (the default). +func (r ExtraClusterRole) IsBound() bool { return r.Bind == nil || *r.Bind } + +// FullName returns the ClusterRole name: the given one when it already starts with d8:, else +// d8:::. +func (r ExtraClusterRole) FullName(module, account string) string { + if len(r.Name) > 3 && r.Name[:3] == "d8:" { + return r.Name + } + + return "d8:" + module + ":" + account + ":" + r.Name } // RoleRef names an existing Role in a foreign namespace to bind a ServiceAccount to. @@ -176,8 +208,11 @@ type PrometheusAccess struct { // Role and RoleBinding access-to-- in templates/rbac-to-us.yaml. Exactly one of the // two must be set: the placement rule keeps cluster-scoped objects out of rbac-to-us.yaml. type Access struct { - Name string `yaml:"name"` - Subjects []Subject `yaml:"subjects"` + Name string `yaml:"name"` + Subjects []Subject `yaml:"subjects"` + // Path is the component directory under templates/ whose rbac-for-us.yaml (clusterRules) or + // rbac-to-us.yaml (namespaceRules) holds the objects; empty means the module root files. + Path string `yaml:"path,omitempty"` ClusterRules []PolicyRule `yaml:"clusterRules,omitempty"` NamespaceRules []PolicyRule `yaml:"namespaceRules,omitempty"` } diff --git a/pkg/linters/rbac/rules/rbacyaml/validate.go b/pkg/linters/rbac/rules/rbacyaml/validate.go index f39c16cf..ac6be237 100644 --- a/pkg/linters/rbac/rules/rbacyaml/validate.go +++ b/pkg/linters/rbac/rules/rbacyaml/validate.go @@ -277,6 +277,29 @@ func validateServiceAccounts(accounts []ServiceAccount, report reporter) { validatePolicyRules(sa.ClusterRules, where+".clusterRules", report) validatePolicyRules(sa.NamespaceRules, where+".namespaceRules", report) + extraNames := make(map[string]struct{}, len(sa.ExtraClusterRoles)) + + for j, extra := range sa.ExtraClusterRoles { + ewhere := fmt.Sprintf("%s.extraClusterRoles[%d]", where, j) + + if extra.Name == "" { + report("%s: name is required", ewhere) + continue + } + + if _, dup := extraNames[extra.Name]; dup { + report("%s: duplicate name %q", ewhere, extra.Name) + } + + extraNames[extra.Name] = struct{}{} + + if len(extra.Rules) == 0 { + report("%s (%s): rules is required", ewhere, extra.Name) + } + + validatePolicyRules(extra.Rules, ewhere+".rules", report) + } + for j, ref := range sa.BindRoles { if ref.Namespace == "" || ref.Name == "" { report("%s.bindRoles[%d]: namespace and name are required", where, j) @@ -312,6 +335,10 @@ func validateAccess(access []Access, report reporter) { report("%s: subjects is required", where) } + if strings.HasPrefix(a.Path, "/") || strings.HasSuffix(a.Path, "/") || strings.Contains(a.Path, "..") { + report("%s: path must be a directory under templates/ without leading or trailing slashes, got %q", where, a.Path) + } + for j, s := range a.Subjects { switch s.Kind { case "User", "Group": diff --git a/pkg/linters/rbac/rules/sync.go b/pkg/linters/rbac/rules/sync.go index f6e7d53a..f6fd94e3 100644 --- a/pkg/linters/rbac/rules/sync.go +++ b/pkg/linters/rbac/rules/sync.go @@ -25,6 +25,7 @@ import ( "sort" "strings" + corev1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" "k8s.io/apimachinery/pkg/runtime" "sigs.k8s.io/yaml" @@ -32,6 +33,7 @@ import ( "github.com/deckhouse/dmt/internal/storage" "github.com/deckhouse/dmt/pkg" "github.com/deckhouse/dmt/pkg/errors" + "github.com/deckhouse/dmt/pkg/linters/rbac/rules/bootstrap" "github.com/deckhouse/dmt/pkg/linters/rbac/rules/generate" "github.com/deckhouse/dmt/pkg/linters/rbac/rules/rbaccontract" "github.com/deckhouse/dmt/pkg/linters/rbac/rules/rbacyaml" @@ -92,6 +94,7 @@ func (r *SyncRule) Check(_ context.Context) { decl, err := rbacyaml.Load(modulePath) if stderrors.Is(err, rbacyaml.ErrNotFound) { + r.bootstrap(declList) return } @@ -228,7 +231,7 @@ func (r *SyncRule) Check(_ context.Context) { fileList := r.errorList.WithFilePath(path).WithObjectID(path) if file := model.File(path); file != nil { - fileList = fileList.WithFix(regenerateFix(modulePath, *file, actual, r.foreignObjects(*file))) + fileList = fileList.WithFix(regenerateFix(modulePath, *file, r.foreignObjects(*file))) fileList.Errorf("%s does not match %s: %s. Run `%s` to regenerate the file from the declaration", path, rbacyaml.Filename, strings.Join(list, "; "), FixCommand) @@ -277,9 +280,18 @@ func (r *SyncRule) foreignObjects(file generate.File) []string { continue } - if _, ok := produced[index.AsString()]; !ok { - out = append(out, index.AsString()) + if _, ok := produced[index.AsString()]; ok { + continue + } + + // An object the generator produces under another name -- a binding with the same roleRef + // and subjects, a role with the same rules -- is replaced, not lost; the declaration carries + // its rights on. Only what has no counterpart is foreign. + if replacedByProduced(object, file.Objects) { + continue } + + out = append(out, index.AsString()) } sort.Strings(out) @@ -287,6 +299,101 @@ func (r *SyncRule) foreignObjects(file generate.File) []string { return out } +// replacedByProduced reports whether a rendered object has a produced counterpart of the same kind +// and content under another name. +func replacedByProduced(object storage.StoreObject, produced []generate.Object) bool { + content := object.Unstructured.UnstructuredContent() + + switch object.Unstructured.GetKind() { + case "ClusterRoleBinding", "RoleBinding": + binding := new(rbacv1.RoleBinding) // the fields compared are shared by both kinds + if runtime.DefaultUnstructuredConverter.FromUnstructured(content, binding) != nil { + return false + } + + got := subjectSet(binding.Subjects) + + for _, o := range produced { + if o.Kind != object.Unstructured.GetKind() || o.Namespace != object.Unstructured.GetNamespace() || o.RoleRefKind != binding.RoleRef.Kind { + continue + } + + if roleRefMatches(o.RoleRefName, binding.RoleRef.Name, produced) && subjectSetOf(o.Subjects) == got { + return true + } + } + case "ClusterRole", "Role": + role := new(rbacv1.ClusterRole) + if runtime.DefaultUnstructuredConverter.FromUnstructured(content, role) != nil { + return false + } + + got := expandRenderedRules(role.Rules) + + for _, o := range produced { + if o.Kind != object.Unstructured.GetKind() || o.Namespace != object.Unstructured.GetNamespace() { + continue + } + + always, conditional := expandModelRules(o.Rules) + for t := range conditional { + always.add(t) + } + + if len(always.minus(got)) == 0 && len(got.minus(always)) == 0 { + return true + } + } + } + + return false +} + +// roleRefMatches accepts the produced roleRef itself or the rendered role it replaces by content. +func roleRefMatches(producedRef, renderedRef string, produced []generate.Object) bool { + if producedRef == renderedRef { + return true + } + + // The rendered binding pointed at a role the generator now produces under producedRef: accept + // when producedRef is a produced role and renderedRef is not. + for _, o := range produced { + if (o.Kind == "ClusterRole" || o.Kind == "Role") && o.Name == renderedRef { + return false + } + } + + for _, o := range produced { + if (o.Kind == "ClusterRole" || o.Kind == "Role") && o.Name == producedRef { + return true + } + } + + return false +} + +func subjectSet(list []rbacv1.Subject) string { + parts := make([]string, 0, len(list)) + for _, s := range list { + parts = append(parts, s.Kind+"/"+s.Namespace+"/"+s.Name) + } + + sort.Strings(parts) + + return strings.Join(parts, ",") +} + +func subjectSetOf(list []generate.Subject) string { + parts := make([]string, 0, len(list)) + for _, s := range list { + parts = append(parts, s.Kind+"/"+s.Namespace+"/"+s.Name) + } + + sort.Strings(parts) + + return strings.Join(parts, ",") +} + // managedObject is a rendered object the sync rule owns, with the class it was recognized by. type managedObject struct { object storage.StoreObject @@ -522,24 +629,23 @@ func crdScopes(crds []crdInfo) rbacyaml.CRDScopes { // regenerateFix returns the autofix for a generated file: write it from the declaration. Everything // the fix needs is captured now, while the render exists -- the object store is released before -// --fix runs (R32). Two safeguards decide whether the file is written at all: +// --fix runs (R32). The declaration is the source of truth: a right it no longer names leaves the +// template (decided 2026-09-22, replacing D3), and the finding that led here listed it. Three +// things are never written over: // -// - a file without the generator header is maintained by hand: the generated text is written -// beside it as _.generated -- the underscore keeps Helm from rendering the copy as a -// second set of objects -- and the finding stays (R16, US-F2); -// - the regenerated file must grant everything the current render of that file grants (rules and -// aggregation edges); if anything would disappear the file is left alone and the finding names -// what would be lost (R25, D3). Removing a right is always a person's decision. -func regenerateFix(modulePath string, file generate.File, actual map[string]managedObject, foreign []string) errors.AutofixFunc { +// - a file that also holds objects the declaration does not produce -- the generator writes the +// whole file and they would vanish; +// - a template that serves both role models behind the version gate (R30); +// - a file without the generator header, maintained by hand: the generated text is written +// beside it as _.generated and the finding stays (R16, US-F2). +func regenerateFix(modulePath string, file generate.File, foreign []string) errors.AutofixFunc { content := generate.RenderFile(file) - expected := expectedRights(file) fullPath := filepath.Join(modulePath, file.Path) // Under --matrix the module is linted once per render variant and every variant collects its // own finding with its own closure. Each records what its render grants now, while the store // exists; the closure that runs first checks the union of them and writes, the others report // its outcome (R36). A right rendered only under some values is therefore not lost (D3). - recordRenderedRights(fullPath, currentRights(file.Path, actual)) recordForeignObjects(fullPath, foreign) return func() error { @@ -577,11 +683,6 @@ func regenerateFix(modulePath string, file generate.File, actual map[string]mana } } - if dropped := sortedSetDiff(renderedRights(fullPath), expected); len(dropped) > 0 { - return fmt.Errorf("regenerating %s would drop rights the render grants today: %s; declare them in %s, or delete the file and run `%s` again to regenerate it without them", - file.Path, strings.Join(dropped, ", "), rbacyaml.Filename, FixCommand) - } - if exists && string(existing) == content { return nil } @@ -600,78 +701,117 @@ func regenerateFix(modulePath string, file generate.File, actual map[string]mana } } -// currentRights collects what the render grants from the objects of this file: every tuple and -// aggregation edge, keyed by object, from the objects the model declares for the file and from -// the managed objects the render placed in the same template. -func currentRights(path string, actual map[string]managedObject) map[string]struct{} { - out := map[string]struct{}{} +// asidePath is where the generated text of a hand-maintained file is written for comparison: +// _.generated in the same directory. Helm renders every file under templates/ whatever its +// extension, so a plain copy would render a second set of objects; a name starting with an +// underscore is a partial to Helm and produces no objects. +func asidePath(path string) string { + return filepath.Join(filepath.Dir(path), "_"+filepath.Base(path)+".generated") +} + +// isModuleCapabilityName reports whether the name is one the generator builds for this module: +// d8:namespace-capability:: or d8:system-capability::. +func isModuleCapabilityName(name, module string) bool { + return strings.HasPrefix(name, "d8:namespace-capability:"+module+":") || strings.HasPrefix(name, "d8:system-capability:"+module+":") +} - for identity, obj := range actual { - if obj.object.ShortPath() != path { - continue +// bootstrap is the entry of an existing module into the declaration: without rbac.yaml, the rule +// reports the file missing and --fix writes it from the RBAC objects the module renders today -- +// the declaration a person would have transcribed from the templates, with a TODO wherever a +// decision is still theirs (decided 2026-09-22; R22 said "contract only", the ADR said "coverage +// creates the file"). From then on rbac.yaml is the source and the templates follow it. +func (r *SyncRule) bootstrap(declList *errors.LintRuleErrorsList) { + modulePath := r.module.GetPath() + storage := r.module.GetStorage() + + if len(storage) == 0 { + return + } + + in := bootstrap.Input{Module: r.module.GetName(), Namespace: r.module.GetNamespace(), Subsystems: readModuleMetadata(modulePath).Subsystems, CRDs: map[string]string{}} + + if crds, err := moduleCRDs(modulePath); err == nil { + for _, crd := range crds { + in.CRDs[crd.Key()] = crd.Scope + } + } + + for _, object := range storage { + if o, ok := bootstrapObject(object); ok { + in.Objects = append(in.Objects, o) } + } - content := obj.object.Unstructured.UnstructuredContent() + if len(in.Objects) == 0 { + return + } - switch obj.object.Unstructured.GetKind() { - case "ClusterRole": - role := new(rbacv1.ClusterRole) - if runtime.DefaultUnstructuredConverter.FromUnstructured(content, role) == nil { - for t := range expandRenderedRules(role.Rules) { - out[identity+": "+t.String()] = struct{}{} - } + result := bootstrap.Build(in) + described := len(in.Objects) - len(result.Unmanaged) + path := rbacyaml.Path(modulePath) - for l := range lineagesOfLabels(role.Labels) { - out[identity+": aggregation into "+l] = struct{}{} - } + declList.WithFix(func() error { + return fixOnce(path, func() error { + if _, err := os.Stat(path); err == nil { + return nil } - case "Role": - role := new(rbacv1.Role) - if runtime.DefaultUnstructuredConverter.FromUnstructured(content, role) == nil { - for t := range expandRenderedRules(role.Rules) { - out[identity+": "+t.String()] = struct{}{} - } + + content, err := bootstrap.Marshal(result) + if err != nil { + return fmt.Errorf("render %s: %w", rbacyaml.Filename, err) } - } - } - return out + return os.WriteFile(path, content, 0o644) //nolint:gosec // a source file of the module + }) + }).Errorf("%s is missing: `%s` writes it from the RBAC objects the module renders today (%d of %d objects described, the rest listed in the file as hand-written); every TODO and note in it is a decision for a person before the templates are regenerated from it", + rbacyaml.Filename, FixCommand, described, len(in.Objects)) } -// expectedRights collects what the generated file will grant, conditional rules included: they -// are in the text, so they are not lost by regeneration. -func expectedRights(file generate.File) map[string]struct{} { - out := map[string]struct{}{} +// bootstrapObject converts a rendered object of RBAC interest for the importer. +func bootstrapObject(object storage.StoreObject) (bootstrap.Object, bool) { + u := object.Unstructured + o := bootstrap.Object{Kind: u.GetKind(), Name: u.GetName(), Namespace: u.GetNamespace(), Path: object.ShortPath(), Labels: u.GetLabels(), Annotations: u.GetAnnotations()} + content := u.UnstructuredContent() - for _, o := range file.Objects { - always, conditional := expandModelRules(o.Rules) + switch o.Kind { + case "ClusterRole": + role := new(rbacv1.ClusterRole) + if runtime.DefaultUnstructuredConverter.FromUnstructured(content, role) != nil { + return o, false + } - for t := range always { - out[o.Identity()+": "+t.String()] = struct{}{} + o.Rules = role.Rules + case "Role": + role := new(rbacv1.Role) + if runtime.DefaultUnstructuredConverter.FromUnstructured(content, role) != nil { + return o, false } - for t := range conditional { - out[o.Identity()+": "+t.String()] = struct{}{} + o.Rules = role.Rules + case "ClusterRoleBinding": + b := new(rbacv1.ClusterRoleBinding) + if runtime.DefaultUnstructuredConverter.FromUnstructured(content, b) != nil { + return o, false } - for l := range lineagesOfLabels(o.Labels) { - out[o.Identity()+": aggregation into "+l] = struct{}{} + o.RoleRef, o.Subjects = b.RoleRef, b.Subjects + case "RoleBinding": + b := new(rbacv1.RoleBinding) + if runtime.DefaultUnstructuredConverter.FromUnstructured(content, b) != nil { + return o, false } - } - return out -} + o.RoleRef, o.Subjects = b.RoleRef, b.Subjects + case "ServiceAccount": + sa := new(corev1.ServiceAccount) + if runtime.DefaultUnstructuredConverter.FromUnstructured(content, sa) != nil { + return o, false + } -// asidePath is where the generated text of a hand-maintained file is written for comparison: -// _.generated in the same directory. Helm renders every file under templates/ whatever its -// extension, so a plain copy would render a second set of objects; a name starting with an -// underscore is a partial to Helm and produces no objects. -func asidePath(path string) string { - return filepath.Join(filepath.Dir(path), "_"+filepath.Base(path)+".generated") -} + o.Automount = sa.AutomountServiceAccountToken + default: + return o, false + } -// isModuleCapabilityName reports whether the name is one the generator builds for this module: -// d8:namespace-capability:: or d8:system-capability::. -func isModuleCapabilityName(name, module string) bool { - return strings.HasPrefix(name, "d8:namespace-capability:"+module+":") || strings.HasPrefix(name, "d8:system-capability:"+module+":") + return o, true } diff --git a/pkg/linters/rbac/rules/sync_test.go b/pkg/linters/rbac/rules/sync_test.go index c80dc9f5..66e80b9b 100644 --- a/pkg/linters/rbac/rules/sync_test.go +++ b/pkg/linters/rbac/rules/sync_test.go @@ -321,11 +321,33 @@ func TestSync_InvalidDeclarationStopsEverything(t *testing.T) { assert.Contains(t, got[0], "nothing is compared or generated until the declaration is valid") } -func TestSync_WithoutDeclarationIsSilent(t *testing.T) { +// Without rbac.yaml the rule reports the declaration missing, and --fix writes it from the render: +// the file a person would have transcribed from the templates, ready to be read and corrected. +func TestSync_WithoutDeclarationBootstrapsIt(t *testing.T) { + resetFixState() + t.Cleanup(resetFixState) + modulePath := syncModuleDir(t) model := syncModel(t, modulePath) require.NoError(t, os.Remove(rbacyaml.Path(modulePath))) + errorList := runSync(t, modulePath, renderedFrom(t, model, nil)) + got := texts(errorList) + require.Len(t, got, 1, "got: %v", got) + assert.Contains(t, got[0], "rbac.yaml is missing: `dmt lint --linter rbac --fix` writes it from the RBAC objects the module renders today (22 of 22 objects described") + + for _, fix := range errorList.GetFixes() { + fix() + } + + assert.Empty(t, errorList.GetErrors()) + + written, err := rbacyaml.Load(modulePath) + require.NoError(t, err, "the written declaration parses") + assert.Len(t, written.ServiceAccounts, 1) + assert.NotEmpty(t, written.Resources) + + // The next run compares against it and, the render being what it declares, is silent. assert.Empty(t, texts(runSync(t, modulePath, renderedFrom(t, model, nil)))) } @@ -370,7 +392,7 @@ func TestSync_Autofix(t *testing.T) { assert.Equal(t, before.ModTime(), after.ModTime()) }) - t.Run("D3: refuses to drop a right the render grants", func(t *testing.T) { + t.Run("the declaration wins: a right it does not name leaves the template", func(t *testing.T) { resetFixState() modulePath := syncModuleDir(t) @@ -383,24 +405,27 @@ func TestSync_Autofix(t *testing.T) { return true }) - // The file exists with a generator header, so only the guard stands in the way. - path := filepath.Join(modulePath, "templates/user-authz-cluster-roles.yaml") + const rel = "templates/user-authz-cluster-roles.yaml" + + path := filepath.Join(modulePath, rel) require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) - require.NoError(t, os.WriteFile(path, []byte(generate.Header()+"\n# stale\n"), 0o600)) + require.NoError(t, os.WriteFile(path, []byte(generate.Header()+"\n# stale, with the secrets rule the declaration does not name\n"), 0o600)) errorList := runSync(t, modulePath, store) + got := texts(errorList) + require.Len(t, got, 1, "got: %v", got) + assert.Contains(t, got[0], `get ""/secrets is in the render but not declared`, "the finding names what the rewrite removes") + for _, fix := range errorList.GetFixes() { fix() } - remaining := errorList.GetErrors() - require.Len(t, remaining, 1) - require.Error(t, remaining[0].FixError) - assert.Contains(t, remaining[0].FixError.Error(), `would drop rights the render grants today: ClusterRole/d8:user-authz:cert-manager:user: get ""/secrets`) + assert.Empty(t, errorList.GetErrors()) - unchanged, err := os.ReadFile(path) + written, err := os.ReadFile(path) require.NoError(t, err) - assert.Equal(t, generate.Header()+"\n# stale\n", string(unchanged), "the file is left alone") + assert.Equal(t, generate.RenderFile(*model.File(rel)), string(written)) + assert.NotContains(t, string(written), "secrets") }) t.Run("US-F2: a file without the header is maintained by hand", func(t *testing.T) { @@ -475,39 +500,37 @@ func TestSync_ForeignContractVersionIsRegenerated(t *testing.T) { assert.Equal(t, want, string(written)) } -// R36/D3: under --matrix every variant records its render at lint time, so the closure that runs -// first refuses to drop a right only another variant rendered, and the other closures report the -// same outcome instead of writing. +// R36: under --matrix every variant records at lint time the objects it rendered into a file that the +// declaration does not produce; the closure that runs first judges the union, so a foreign object +// rendered only under some values still protects the file, and the other closures report the same. func TestSync_FixSeesEveryRenderVariant(t *testing.T) { resetFixState() t.Cleanup(resetFixState) modulePath := syncModuleDir(t) model := syncModel(t, modulePath) + writeGenerated(t, modulePath, model) + + const rel = "templates/rbacv2/use/view.yaml" - // Both variants lack a declared rule (the file is stale); variant A also grants a right that is - // not declared -- as a template with a hand-written {{ if }} would under some values. + // Both variants lack a declared rule (the file is stale); variant A also renders a foreign + // object from the same file, as a hand-added {{ if }} block would under some values. stale := func(o *generate.Object) bool { - if o.Name == "d8:user-authz:cert-manager:user" { + if o.Name == "d8:namespace-capability:cert-manager:view" { o.Rules = o.Rules[:len(o.Rules)-1] } return true } variantB := renderedFrom(t, model, stale) - variantA := renderedFrom(t, model, func(o *generate.Object) bool { - stale(o) - - if o.Name == "d8:user-authz:cert-manager:user" { - o.Rules = append(o.Rules, generate.Rule{PolicyRule: rbacyaml.PolicyRule{APIGroups: []string{""}, Resources: []string{"secrets"}, Verbs: []string{"get"}}}) - } - - return true + variantA := renderedFrom(t, model, stale) + putObject(t, variantA, rel, generate.Object{ + Kind: "ClusterRole", Name: "d8:cert-manager:only-sometimes", Class: generate.ClassDeclared, + Rules: []generate.Rule{{PolicyRule: rbacyaml.PolicyRule{APIGroups: []string{""}, Resources: []string{"secrets"}, Verbs: []string{"get"}}}}, }) - path := filepath.Join(modulePath, "templates/user-authz-cluster-roles.yaml") - require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) - require.NoError(t, os.WriteFile(path, []byte(generate.Header()+"\n# stale\n"), 0o600)) + before, err := os.ReadFile(filepath.Join(modulePath, rel)) + require.NoError(t, err) // Lint both variants first, as the manager does, then apply the fixes: B's closure runs first. listB := runSync(t, modulePath, variantB) @@ -525,12 +548,12 @@ func TestSync_FixSeesEveryRenderVariant(t *testing.T) { remaining := list.GetErrors() require.Len(t, remaining, 1, "variant %s", name) require.Error(t, remaining[0].FixError, "variant %s", name) - assert.Contains(t, remaining[0].FixError.Error(), `would drop rights the render grants today: ClusterRole/d8:user-authz:cert-manager:user: get ""/secrets`, "variant %s", name) + assert.Contains(t, remaining[0].FixError.Error(), "also holds objects the declaration does not produce: ClusterRole/d8:cert-manager:only-sometimes", "variant %s", name) } - unchanged, err := os.ReadFile(path) + unchanged, err := os.ReadFile(filepath.Join(modulePath, rel)) require.NoError(t, err) - assert.Equal(t, generate.Header()+"\n# stale\n", string(unchanged), "no variant wrote the file") + assert.Equal(t, string(before), string(unchanged), "no variant wrote the file") } // R8a/D7: a declaration in an edition overlay is reported by sync and ignored by coverage. @@ -773,3 +796,45 @@ func TestSync_FileWithForeignObjectsIsNotRegenerated(t *testing.T) { require.NoError(t, err) assert.Equal(t, string(before), string(after), "the file is left alone") } + +// A rendered object the generator produces under another name is replaced, not foreign: a binding +// with the same roleRef and subjects, a role with the same rules. +func TestSync_RenamedObjectsAreNotForeign(t *testing.T) { + resetFixState() + t.Cleanup(resetFixState) + + modulePath := syncModuleDir(t) + model := syncModel(t, modulePath) + writeGenerated(t, modulePath, model) + + const rel = "templates/rbac-to-us.yaml" + + // The render still carries the old names a hand-written module gave the metrics access: the + // Role and its RoleBinding, with the same rules, roleRef and subjects. + store := renderedFrom(t, model, func(o *generate.Object) bool { + if o.Name == "access-to-cert-manager" && (o.Kind == "Role" || o.Kind == "RoleBinding") { + o.Name = "access-to-cert-manager-prometheus-metrics" + + if o.Kind == "RoleBinding" { + o.RoleRefName = "access-to-cert-manager-prometheus-metrics" + } + } + + return true + }) + + errorList := runSync(t, modulePath, store) + got := texts(errorList) + require.Len(t, got, 1, "got: %v", got) + assert.Contains(t, got[0], "is declared but absent from the render") + + for _, fix := range errorList.GetFixes() { + fix() + } + + assert.Empty(t, errorList.GetErrors(), "the renamed bindings are replaced, so the file is regenerated") + + written, err := os.ReadFile(filepath.Join(modulePath, rel)) + require.NoError(t, err) + assert.Equal(t, generate.RenderFile(*model.File(rel)), string(written)) +} diff --git a/test/e2e/testdata/rbac/bootstrap-writes-declaration/expected.yaml b/test/e2e/testdata/rbac/bootstrap-writes-declaration/expected.yaml new file mode 100644 index 00000000..d062de35 --- /dev/null +++ b/test/e2e/testdata/rbac/bootstrap-writes-declaration/expected.yaml @@ -0,0 +1,12 @@ +description: > + A module with RBAC templates and no rbac.yaml: sync reports the declaration missing and --fix + writes it from the rendered objects, so an existing module enters the declaration with a file a + person can read and then correct; coverage and the comparison start on the next run. +kind: fix +module: module +expectPass: + - linter: manager + - linter: rbac + rule: sync + - linter: rbac + rule: coverage diff --git a/test/e2e/testdata/rbac/bootstrap-writes-declaration/module/charts/deckhouse_lib_helm-1.72.1.tgz b/test/e2e/testdata/rbac/bootstrap-writes-declaration/module/charts/deckhouse_lib_helm-1.72.1.tgz new file mode 100644 index 0000000000000000000000000000000000000000..3f56d38ec75c51bc2f29d3a7a75a4e3ff760550e GIT binary patch literal 45549 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 Date: Tue, 22 Sep 2026 10:48:32 +0300 Subject: [PATCH 21/58] rbac: document bootstrap, extraClusterRoles, automount, access path and the declaration as the source Signed-off-by: Ivan Zvyagintsev --- pkg/linters/rbac/README.md | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/pkg/linters/rbac/README.md b/pkg/linters/rbac/README.md index 8422f063..637685e9 100644 --- a/pkg/linters/rbac/README.md +++ b/pkg/linters/rbac/README.md @@ -1417,6 +1417,9 @@ capabilities: description: {en: "Manage cert-manager Issuers in a namespace.", ru: "Управление Issuer модуля cert-manager в пространстве имён."} # ServiceAccount rights -> templates/[/]rbac-for-us.yaml. Only declared accounts are managed. +# automountServiceAccountToken defaults to false; extraClusterRoles are further ClusterRoles in the +# account's file, d8::: (or exactly the name when it starts with d8:), bound +# to the account unless bind: false -- roles split by concern, or roles shipped for others to bind. serviceAccounts: - name: cainjector path: cainjector # templates/cainjector/rbac-for-us.yaml; omitted -> templates/rbac-for-us.yaml @@ -1441,7 +1444,7 @@ serviceAccounts: prometheusAccess: deployments: [cert-manager] -# Arbitrary subjects: clusterRules -> templates/rbac-for-us.yaml, namespaceRules -> templates/rbac-to-us.yaml +# Arbitrary subjects: clusterRules -> templates/[/]rbac-for-us.yaml, namespaceRules -> templates/[/]rbac-to-us.yaml access: - name: admin-kubeconfig subjects: @@ -1600,8 +1603,15 @@ a regeneration rather than a hand edit of every template. **Description:** -Runs only when the module has an `rbac.yaml`. First validates the declaration; a declaration with -errors is reported and nothing else is compared or generated. Then builds the objects the +Without `rbac.yaml` the rule reports the declaration missing, and `--fix` writes it from the RBAC +objects the module renders today: the declaration a person would have transcribed from the templates, +with a `TODO` wherever a decision is still theirs (a resource without a CRD whose scope the linter +cannot know, a CRD nobody grants, a namespaced resource granted cluster-wide) and a note on top for +every object the generator will name differently or cannot describe. Review it, resolve the TODOs, +then run `--fix` again to regenerate the templates from it. From then on `rbac.yaml` is the source. + +With `rbac.yaml` the rule first validates the declaration; a declaration with errors is reported and +nothing else is compared or generated. Then builds the objects the declaration produces and compares them with the render. `sync` owns exactly three classes of rendered objects: @@ -1623,11 +1633,13 @@ controller ClusterRoles with arbitrary names, objects with Helm-computed names). Findings are one per template file and carry the fix command; the text does not depend on the render variant. -**Autofix:** regenerates the file from `rbac.yaml`, with three safeguards -- +**Autofix:** regenerates the file from `rbac.yaml`. The declaration is the source of truth: a right it +no longer names leaves the template, and the finding that led there listed it. Three things are never +written over -- -- a file that also holds objects the declaration does not produce -- a controller ClusterRole beside a declared ServiceAccount, a hand-written binding -- is never rewritten, because the generator writes the whole file and they would vanish (and so they would if the file were deleted); the refusal names them: declare them or move them to another template first; +- a file that also holds objects the declaration does not produce -- a controller ClusterRole beside a declared ServiceAccount, a hand-written binding -- is never rewritten, because the generator writes the whole file and they would vanish (and so they would if the file were deleted); the refusal names them: declare them (`extraClusterRoles`, `access` with `path`) or move them first. An object the generator produces under another name -- a binding with the same roleRef and subjects, a role with the same rules -- is replaced, not foreign; - a file without the generator header is maintained by hand: the generated text is written beside it as `_.generated` (the underscore keeps Helm from rendering the copy) and the finding stays (delete the file and run `--fix` again to hand it back to the generator); -- the regenerated file must grant everything the render of that file grants today, rules and aggregation edges alike; otherwise the file is left alone and the finding names what would be lost. Removing a right is always a person's decision: declare it in `rbac.yaml` or remove it from the template by hand. +- a template that serves both role models behind the version gate (`rbacv2_new_scheme`) is never regenerated: the legacy branch would vanish; A missing file is created. A second `--fix` without changes to `rbac.yaml` changes nothing. Under `--matrix` every render variant reports the file, but the fix runs once: the variants record what From fd05e30ee7481cc3eb77a1dc4b22da7cc00ee196 Mon Sep 17 00:00:00 2001 From: Ivan Zvyagintsev Date: Tue, 22 Sep 2026 10:52:32 +0300 Subject: [PATCH 22/58] rbac: preallocate in the bootstrap object filter (golangci-lint 2.8 in CI) Signed-off-by: Ivan Zvyagintsev --- pkg/linters/rbac/rules/bootstrap/bootstrap.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/linters/rbac/rules/bootstrap/bootstrap.go b/pkg/linters/rbac/rules/bootstrap/bootstrap.go index 64d36e75..7ba81964 100644 --- a/pkg/linters/rbac/rules/bootstrap/bootstrap.go +++ b/pkg/linters/rbac/rules/bootstrap/bootstrap.go @@ -261,7 +261,7 @@ func (b *builder) capabilitiesAndLegacy() { } func (b *builder) byKind(kind string) []Object { - var out []Object + out := make([]Object, 0, len(b.in.Objects)) for _, o := range b.in.Objects { if o.Kind == kind { @@ -301,7 +301,7 @@ func (b *builder) ownClusterRole(o Object) bool { } func (b *builder) bindingsOf(name string) []Object { - var out []Object + out := make([]Object, 0, len(b.in.Objects)) for _, o := range b.in.Objects { if o.Kind == "ClusterRoleBinding" && o.RoleRef.Name == name { From daa03e2f6f595c445f0fefeaf37a3fdc0bf1faad Mon Sep 17 00:00:00 2001 From: Ivan Zvyagintsev Date: Tue, 22 Sep 2026 10:56:10 +0300 Subject: [PATCH 23/58] rbac: preallocate the remaining object slices the CI linter flags Signed-off-by: Ivan Zvyagintsev --- pkg/linters/rbac/rules/bootstrap/bootstrap_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go b/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go index ebc300d1..d9cb3a7c 100644 --- a/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go +++ b/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go @@ -33,7 +33,7 @@ import ( // objectsOf simulates the render of a model: every object as the chart would produce it, with the // module labels helm_lib adds and the module namespace stripped, as dmt renders it. func objectsOf(model *generate.Model, module, namespace string) []Object { - var out []Object + out := make([]Object, 0, len(model.Files)) for _, file := range model.Files { for _, o := range file.Objects { From 77a5017f631fd5ea3d0fb956280a8d4bfb8281bf Mon Sep 17 00:00:00 2001 From: Ivan Zvyagintsev Date: Tue, 22 Sep 2026 12:59:02 +0300 Subject: [PATCH 24/58] rbac: what the corner cases showed Fifteen corner cases were run against the rules on a synthetic module (a module without subsystems or with a non-d8 namespace, a marker past 63 characters, an account named unlike its directory and nested directories, a declared object rendered from another file, when conditions with braces, core-group resources and wildcard verbs, BOM and CRLF in rbac.yaml and in a generated file, an rbac.yaml of the earlier shape, duplicate objects, a bootstrap with a conditional object off by default, a read-only template, duplicate subjects). Nine did not hold: - system levels on a module that aggregates into no subsystem produced capabilities nobody aggregates; the generator refuses and asks for subsystems in rbac.yaml. - a capability marker longer than a label value (a module name of 32 characters and up with a namespace superadmin level) was written and would fail the contract; the generator refuses. - an account named unlike its directory, or in a nested directory, produced objects the placement rule rejects; the generator refuses with the names placement wants. - an object the declaration produces but rendered from another file was called foreign without saying where it belongs; the refusal now names the file the declaration puts it in. - built-in Kubernetes resources (""/configmaps, apps/deployments, ...) needed an explicit scope; the validator and the importer share one table of well-known scopes, and an unknown resource still asks for one. - an rbac.yaml of the earlier, never consumed shape gave a bare parse error; it is named for what it is, with the way out. - under --matrix the first declaration was written from whichever variant ran its fix first; it is written from the union of every variant, and the file's header says that objects off by default need a run with --values-file before the first regeneration. - a generated file with CRLF line endings lost its header and became hand-maintained in silence; the header is recognized and the file regenerated. - duplicate subjects of an access entry passed validation. Signed-off-by: Ivan Zvyagintsev --- pkg/linters/rbac/rules/bootstrap/bootstrap.go | 2 +- pkg/linters/rbac/rules/bootstrap/marshal.go | 2 + pkg/linters/rbac/rules/fixstate.go | 49 ++++++++++++- .../rbac/rules/generate/generate_test.go | 62 ++++++++++++++++ pkg/linters/rbac/rules/generate/model.go | 43 +++++++++++ pkg/linters/rbac/rules/generate/render.go | 2 + pkg/linters/rbac/rules/rbacyaml/load_test.go | 41 +++++++++++ .../rules/{bootstrap => rbacyaml}/scopes.go | 31 +++++++- pkg/linters/rbac/rules/rbacyaml/validate.go | 16 ++++ pkg/linters/rbac/rules/sync.go | 31 +++++++- pkg/linters/rbac/rules/sync_test.go | 73 +++++++++++++++++++ 11 files changed, 341 insertions(+), 11 deletions(-) rename pkg/linters/rbac/rules/{bootstrap => rbacyaml}/scopes.go (84%) diff --git a/pkg/linters/rbac/rules/bootstrap/bootstrap.go b/pkg/linters/rbac/rules/bootstrap/bootstrap.go index 7ba81964..24d2fd48 100644 --- a/pkg/linters/rbac/rules/bootstrap/bootstrap.go +++ b/pkg/linters/rbac/rules/bootstrap/bootstrap.go @@ -586,7 +586,7 @@ func (b *builder) resources() { e.Scope, scope = rbacyaml.ScopeCluster, rbacyaml.ScopeCluster e.Reason = "TODO: the templates grant the whole group; say why the resource names are not known statically" default: - if s, ok := wellKnownScopes[group+"/"+base]; ok { + if s, ok := rbacyaml.WellKnownScope(group, base); ok { scope = s } else { b.note("%s/%s: the module ships no CRD and the scope is not known; fill scope: Namespaced|Cluster", group, resource) diff --git a/pkg/linters/rbac/rules/bootstrap/marshal.go b/pkg/linters/rbac/rules/bootstrap/marshal.go index 29906967..2dfb8ea8 100644 --- a/pkg/linters/rbac/rules/bootstrap/marshal.go +++ b/pkg/linters/rbac/rules/bootstrap/marshal.go @@ -29,6 +29,8 @@ func Marshal(r Result) ([]byte, error) { head.WriteString("# Written by dmt (rbac/sync --fix) from the RBAC objects the module rendered. Review it, resolve every TODO\n") head.WriteString("# and every note below, then run \"dmt lint --linter rbac --fix\" to regenerate the templates from it.\n") + head.WriteString("# Objects rendered only under values other than the defaults are not here: lint with --values-file (or\n") + head.WriteString("# --matrix) before the first regeneration if the module has such templates, and declare them with when.\n") if len(r.Notes) > 0 { head.WriteString("#\n# Notes:\n") diff --git a/pkg/linters/rbac/rules/fixstate.go b/pkg/linters/rbac/rules/fixstate.go index dd67037c..34663469 100644 --- a/pkg/linters/rbac/rules/fixstate.go +++ b/pkg/linters/rbac/rules/fixstate.go @@ -23,6 +23,7 @@ import ( "strings" "sync" + "github.com/deckhouse/dmt/pkg/linters/rbac/rules/bootstrap" "github.com/deckhouse/dmt/pkg/linters/rbac/rules/rbaccontract" ) @@ -38,11 +39,13 @@ import ( // rather than the render of whichever variant happened to run its closure first. var fixState = struct { sync.Mutex - outcomes map[string]error - foreign map[string]map[string]struct{} + outcomes map[string]error + foreign map[string]map[string]struct{} + bootstrap map[string]map[string]bootstrap.Object }{ - outcomes: map[string]error{}, - foreign: map[string]map[string]struct{}{}, + outcomes: map[string]error{}, + foreign: map[string]map[string]struct{}{}, + bootstrap: map[string]map[string]bootstrap.Object{}, } // fixOnce runs fix for the key the first time it is asked and returns that outcome on every later @@ -106,6 +109,44 @@ func resetFixState() { fixState.outcomes = map[string]error{} fixState.foreign = map[string]map[string]struct{}{} + fixState.bootstrap = map[string]map[string]bootstrap.Object{} +} + +// recordBootstrapObjects adds the RBAC objects one render variant produced, for the first +// declaration to be written from the union of every variant. +func recordBootstrapObjects(path string, objects []bootstrap.Object) { + fixState.Lock() + defer fixState.Unlock() + + known := fixState.bootstrap[path] + if known == nil { + known = map[string]bootstrap.Object{} + fixState.bootstrap[path] = known + } + + for _, o := range objects { + known[o.Kind+"/"+o.Namespace+"/"+o.Name] = o + } +} + +// bootstrapObjectsOf returns, sorted by identity, every object any variant rendered. +func bootstrapObjectsOf(path string) []bootstrap.Object { + fixState.Lock() + defer fixState.Unlock() + + keys := make([]string, 0, len(fixState.bootstrap[path])) + for k := range fixState.bootstrap[path] { + keys = append(keys, k) + } + + sort.Strings(keys) + + out := make([]bootstrap.Object, 0, len(keys)) + for _, k := range keys { + out = append(out, fixState.bootstrap[path][k]) + } + + return out } // editionOverlay returns the edition overlay a module directory lies in ("ee/modules", diff --git a/pkg/linters/rbac/rules/generate/generate_test.go b/pkg/linters/rbac/rules/generate/generate_test.go index 9fc5feab..7c5be4f0 100644 --- a/pkg/linters/rbac/rules/generate/generate_test.go +++ b/pkg/linters/rbac/rules/generate/generate_test.go @@ -241,3 +241,65 @@ func TestBuild_ExtraClusterRolesAndAutomount(t *testing.T) { } } } + +// What the declaration alone cannot know is wrong, the generator refuses against the module. +func TestBuild_RefusesWhatTheModuleCannotCarry(t *testing.T) { + base := func() *rbacyaml.Declaration { + return &rbacyaml.Declaration{APIVersion: rbacyaml.APIVersionV1Alpha1, Resources: []rbacyaml.Resource{ + {Group: "x.io", Resource: "things", Scope: "Cluster", System: map[string][]string{"viewer": {"get"}}}, + }} + } + + t.Run("system levels without any subsystem", func(t *testing.T) { + _, err := Build(Input{Module: "m", Namespace: "d8-m", Decl: base()}) + require.Error(t, err) + assert.Contains(t, err.Error(), "system levels are declared but the module aggregates into no subsystem") + + _, err = Build(Input{Module: "m", Namespace: "d8-m", Subsystems: []string{"security"}, Decl: base()}) + require.NoError(t, err, "module.yaml subsystems suffice") + + decl := base() + decl.Subsystems = []string{"storage"} + _, err = Build(Input{Module: "m", Namespace: "d8-m", Decl: decl}) + require.NoError(t, err, "the declaration's own subsystems suffice") + }) + + t.Run("an account whose name does not follow its directory", func(t *testing.T) { + decl := base() + decl.ServiceAccounts = []rbacyaml.ServiceAccount{{Name: "helper", Path: "cainjector"}} + _, err := Build(Input{Module: "m", Namespace: "d8-m", Subsystems: []string{"security"}, Decl: decl}) + require.Error(t, err) + assert.Contains(t, err.Error(), `the placement rule wants the account named "cainjector" or "m-cainjector"`) + + decl.ServiceAccounts = []rbacyaml.ServiceAccount{{Name: "m-cainjector", Path: "cainjector"}, {Name: "webhook", Path: "webhook"}, {Name: "anything", Path: ""}} + _, err = Build(Input{Module: "m", Namespace: "d8-m", Subsystems: []string{"security"}, Decl: decl}) + require.NoError(t, err) + + decl.ServiceAccounts = []rbacyaml.ServiceAccount{{Name: "dir", Path: "some/nested/dir"}} + _, err = Build(Input{Module: "m", Namespace: "d8-m", Subsystems: []string{"security"}, Decl: decl}) + require.Error(t, err) + assert.Contains(t, err.Error(), "one directory under templates/ only") + }) + + t.Run("a capability marker longer than a label value", func(t *testing.T) { + // The levels are a fixed set, so only the module name can push the marker + // namespace-capability..superadmin past 63 characters: at 32 characters it does. + decl := &rbacyaml.Declaration{APIVersion: rbacyaml.APIVersionV1Alpha1, + Resources: []rbacyaml.Resource{{Group: "x.io", Resource: "things", Scope: "Namespaced", Namespace: map[string][]string{"superadmin": {"get"}}}}, + Capabilities: map[string]rbacyaml.CapabilityText{"namespace.superadmin": {Title: rbacyaml.LocalizedText{EN: "t", RU: "т"}, Description: rbacyaml.LocalizedText{EN: "d", RU: "д"}}}, + } + _, err := Build(Input{Module: "a-module-name-of-thirty-two-char", Namespace: "d8-m", Decl: decl}) + require.Error(t, err) + assert.Contains(t, err.Error(), "a label value holds 63") + + _, err = Build(Input{Module: "a-module-name-of-thirtyone-char", Namespace: "d8-m", Decl: decl}) + require.NoError(t, err) + }) +} + +// A generated file that acquired CRLF line endings is still the generator's. +func TestParseHeader_CRLF(t *testing.T) { + generated, version := ParseHeader(Header() + "\r\n---\r\n") + assert.True(t, generated) + assert.Equal(t, "1", version) +} diff --git a/pkg/linters/rbac/rules/generate/model.go b/pkg/linters/rbac/rules/generate/model.go index 2d3c2e51..f5c17973 100644 --- a/pkg/linters/rbac/rules/generate/model.go +++ b/pkg/linters/rbac/rules/generate/model.go @@ -174,6 +174,10 @@ func Build(in Input) (*Model, error) { return nil, fmt.Errorf("the module name is required") } + if err := checkAgainstModule(in); err != nil { + return nil, err + } + b := &builder{in: in, files: map[string]*File{}} b.capabilities() @@ -188,9 +192,48 @@ func Build(in Input) (*Model, error) { sort.Slice(model.Files, func(i, j int) bool { return model.Files[i].Path < model.Files[j].Path }) + for _, f := range model.Files { + for _, o := range f.Objects { + if marker := o.Labels[rbaccontract.LabelCapability]; len(marker) > 63 { + return nil, fmt.Errorf("capability marker %q is %d characters, a label value holds 63: the module name and the level name together are too long for %s", marker, len(marker), o.Name) + } + } + } + return model, nil } +// checkAgainstModule refuses what the declaration alone cannot know is wrong: it needs the module's +// metadata, and the objects it would produce would fail the platform's other rules. +func checkAgainstModule(in Input) error { + systemLevels := false + for _, r := range in.Decl.Resources { + if len(r.System) > 0 { + systemLevels = true + } + } + + if systemLevels && len(in.Decl.Subsystems) == 0 && len(in.Subsystems) == 0 { + return fmt.Errorf("system levels are declared but the module aggregates into no subsystem: module.yaml declares none, so set subsystems in %s", rbacyaml.Filename) + } + + for _, sa := range in.Decl.ServiceAccounts { + if sa.Path == "" { + continue + } + + if strings.Contains(sa.Path, "/") { + return fmt.Errorf("serviceAccounts[%s].path %q: one directory under templates/ only; the placement rule names the objects of a nested directory in a way the generator cannot follow", sa.Name, sa.Path) + } + + if sa.Name != sa.Path && sa.Name != in.Module+"-"+sa.Path { + return fmt.Errorf("serviceAccounts[%s].path %q: the placement rule wants the account named %q or %q after its directory; rename the account or move it to the module root", sa.Name, sa.Path, sa.Path, in.Module+"-"+sa.Path) + } + } + + return nil +} + type builder struct { in Input files map[string]*File diff --git a/pkg/linters/rbac/rules/generate/render.go b/pkg/linters/rbac/rules/generate/render.go index 7f583e94..d86338f5 100644 --- a/pkg/linters/rbac/rules/generate/render.go +++ b/pkg/linters/rbac/rules/generate/render.go @@ -40,6 +40,8 @@ func Header() string { // newer) contract. func ParseHeader(content string) (bool, string) { line, _, _ := strings.Cut(content, "\n") + line = strings.TrimSuffix(line, "\r") + if !strings.HasPrefix(line, headerPrefix) || !strings.HasSuffix(line, headerSuffix) { return false, "" } diff --git a/pkg/linters/rbac/rules/rbacyaml/load_test.go b/pkg/linters/rbac/rules/rbacyaml/load_test.go index 1602462d..2bc970c0 100644 --- a/pkg/linters/rbac/rules/rbacyaml/load_test.go +++ b/pkg/linters/rbac/rules/rbacyaml/load_test.go @@ -518,3 +518,44 @@ serviceAccounts: assert.Contains(t, msgs, "serviceAccounts[0] (webhook).extraClusterRoles[3] (d8:other:full-name): rules is required") assert.Len(t, msgs, 3, "got: %v", msgs) } + +// Built-in Kubernetes resources need no scope of their own; a duplicate subject is an error. +func TestValidate_WellKnownScopesAndDuplicateSubjects(t *testing.T) { + decl, err := Parse([]byte(`apiVersion: rbac.deckhouse.io/v1alpha1 +resources: + - group: "" + resource: configmaps + namespace: + viewer: [get] + - group: "" + resource: namespaces + namespace: + viewer: [get] + - group: apps + resource: deployments/scale + system: + manager: [update] + reason: "cluster-wide scaling" +access: + - name: dup + subjects: + - kind: Group + name: g + - kind: Group + name: g + clusterRules: + - apiGroups: [""] + resources: [pods] + verbs: [get] +`)) + require.NoError(t, err) + + msgs := make([]string, 0) + for _, e := range Validate(decl, nil) { + msgs = append(msgs, e.Error()) + } + + assert.Contains(t, msgs, "resources[1] (/namespaces): namespace levels are not allowed for a cluster-scoped resource: a namespace capability is granted through a RoleBinding, where such a rule grants nothing; use system", "the built-in scope is known and applied") + assert.Contains(t, msgs, "access[0] (dup).subjects[1]: duplicate subject Group g") + assert.Len(t, msgs, 2, "configmaps and deployments/scale need no scope: %v", msgs) +} diff --git a/pkg/linters/rbac/rules/bootstrap/scopes.go b/pkg/linters/rbac/rules/rbacyaml/scopes.go similarity index 84% rename from pkg/linters/rbac/rules/bootstrap/scopes.go rename to pkg/linters/rbac/rules/rbacyaml/scopes.go index 1aca4bcd..be3f6554 100644 --- a/pkg/linters/rbac/rules/bootstrap/scopes.go +++ b/pkg/linters/rbac/rules/rbacyaml/scopes.go @@ -14,11 +14,11 @@ See the License for the specific language governing permissions and limitations under the License. */ -package bootstrap +package rbacyaml -// wellKnownScopes is the scope of the built-in Kubernetes resources a module's RBAC commonly names, -// for the first declaration written from the render: the module ships no CRD for them and the -// linter has no cluster to ask. Anything not here is left without a scope for the author to fill. +// wellKnownScopes is the scope of the built-in Kubernetes resources a module's RBAC commonly names: +// the module ships no CRD for them and the linter has no cluster to ask, so an entry for them +// needs no scope of its own. Anything not here is declared with an explicit scope. var wellKnownScopes = map[string]string{ // core "/pods": "Namespaced", "/pods/log": "Namespaced", "/pods/exec": "Namespaced", "/pods/portforward": "Namespaced", "/pods/proxy": "Namespaced", "/pods/status": "Namespaced", @@ -50,3 +50,26 @@ var wellKnownScopes = map[string]string{ // metrics "metrics.k8s.io/pods": "Namespaced", "metrics.k8s.io/nodes": "Cluster", } + +// WellKnownScope returns the scope of a built-in Kubernetes resource, keyed group/resource ("" for +// the core group); a subresource inherits its base resource's. +func WellKnownScope(group, resource string) (string, bool) { + base := resource + if i := indexByte(base, '/'); i >= 0 { + base = base[:i] + } + + scope, ok := wellKnownScopes[group+"/"+base] + + return scope, ok +} + +func indexByte(s string, c byte) int { + for i := 0; i < len(s); i++ { + if s[i] == c { + return i + } + } + + return -1 +} diff --git a/pkg/linters/rbac/rules/rbacyaml/validate.go b/pkg/linters/rbac/rules/rbacyaml/validate.go index ac6be237..fd4135ba 100644 --- a/pkg/linters/rbac/rules/rbacyaml/validate.go +++ b/pkg/linters/rbac/rules/rbacyaml/validate.go @@ -172,6 +172,13 @@ func resolveScope(r *Resource, crds CRDScopes) (string, string) { return fromCRD, "" case r.Scope != "": return r.Scope, "" + default: + if scope, ok := WellKnownScope(r.Group, r.Resource); ok { + return scope, "" + } + } + + switch { case r.NoAccess != "": // A denied external resource needs no scope: nothing is generated for it. return "", "" @@ -339,7 +346,16 @@ func validateAccess(access []Access, report reporter) { report("%s: path must be a directory under templates/ without leading or trailing slashes, got %q", where, a.Path) } + seenSubjects := make(map[string]struct{}, len(a.Subjects)) + for j, s := range a.Subjects { + key := s.Kind + "/" + s.Namespace + "/" + s.Name + if _, dup := seenSubjects[key]; dup { + report("%s.subjects[%d]: duplicate subject %s %s", where, j, s.Kind, s.Name) + } + + seenSubjects[key] = struct{}{} + switch s.Kind { case "User", "Group": if s.Namespace != "" { diff --git a/pkg/linters/rbac/rules/sync.go b/pkg/linters/rbac/rules/sync.go index f6fd94e3..4edc9161 100644 --- a/pkg/linters/rbac/rules/sync.go +++ b/pkg/linters/rbac/rules/sync.go @@ -106,7 +106,13 @@ func (r *SyncRule) Check(_ context.Context) { } if err != nil { + if content, readErr := os.ReadFile(rbacyaml.Path(modulePath)); readErr == nil && !strings.Contains(string(content), "apiVersion:") { + declList.Errorf("%s is not a declaration (no apiVersion): an rbac.yaml of an earlier shape that nothing reads; delete it and run `%s` to write the declaration from the render", rbacyaml.Filename, FixCommand) + return + } + declList.Errorf("%v; nothing is compared or generated until the declaration parses", err) + return } @@ -231,7 +237,7 @@ func (r *SyncRule) Check(_ context.Context) { fileList := r.errorList.WithFilePath(path).WithObjectID(path) if file := model.File(path); file != nil { - fileList = fileList.WithFix(regenerateFix(modulePath, *file, r.foreignObjects(*file))) + fileList = fileList.WithFix(regenerateFix(modulePath, *file, r.foreignObjects(*file, model))) fileList.Errorf("%s does not match %s: %s. Run `%s` to regenerate the file from the declaration", path, rbacyaml.Filename, strings.Join(list, "; "), FixCommand) @@ -261,12 +267,21 @@ func (r *SyncRule) legacyFiles() map[string]string { // produce -- a controller ClusterRole beside a declared ServiceAccount, a hand-written binding. The // generator writes the whole file, so regenerating it would drop them; they are the reason a // regeneration is refused until they are declared or moved. -func (r *SyncRule) foreignObjects(file generate.File) []string { +func (r *SyncRule) foreignObjects(file generate.File, model *generate.Model) []string { produced := map[string]struct{}{} for _, o := range file.Objects { produced[o.Identity()] = struct{}{} } + // Where the declaration puts every object it produces: an object rendered from another file + // than that is misplaced rather than unknown, and the refusal says so. + placed := map[string]string{} + for _, f := range model.Files { + for _, o := range f.Objects { + placed[o.Identity()] = f.Path + } + } + var out []string for index, object := range r.module.GetStorage() { @@ -291,6 +306,11 @@ func (r *SyncRule) foreignObjects(file generate.File) []string { continue } + if path, declared := placed[index.AsString()]; declared { + out = append(out, index.AsString()+" (the declaration puts it in "+path+"; move it there or delete both files and run the fix)") + continue + } + out = append(out, index.AsString()) } @@ -750,12 +770,19 @@ func (r *SyncRule) bootstrap(declList *errors.LintRuleErrorsList) { described := len(in.Objects) - len(result.Unmanaged) path := rbacyaml.Path(modulePath) + // Under --matrix every variant renders its own set of objects; the fix builds from their union, + // so an object rendered only under some values still reaches the first declaration. + recordBootstrapObjects(path, in.Objects) + declList.WithFix(func() error { return fixOnce(path, func() error { if _, err := os.Stat(path); err == nil { return nil } + in.Objects = bootstrapObjectsOf(path) + result := bootstrap.Build(in) + content, err := bootstrap.Marshal(result) if err != nil { return fmt.Errorf("render %s: %w", rbacyaml.Filename, err) diff --git a/pkg/linters/rbac/rules/sync_test.go b/pkg/linters/rbac/rules/sync_test.go index 66e80b9b..d1185a7a 100644 --- a/pkg/linters/rbac/rules/sync_test.go +++ b/pkg/linters/rbac/rules/sync_test.go @@ -838,3 +838,76 @@ func TestSync_RenamedObjectsAreNotForeign(t *testing.T) { require.NoError(t, err) assert.Equal(t, generate.RenderFile(*model.File(rel)), string(written)) } + +// An rbac.yaml of the earlier, never consumed shape is named for what it is. +func TestSync_OldShapeFileIsNamed(t *testing.T) { + modulePath := syncModuleDir(t) + require.NoError(t, os.WriteFile(rbacyaml.Path(modulePath), []byte("crds:\n - certificates\n"), 0o600)) + + got := texts(runSync(t, modulePath, storage.NewUnstructuredObjectStore())) + require.Len(t, got, 1, "got: %v", got) + assert.Contains(t, got[0], "rbac.yaml is not a declaration (no apiVersion): an rbac.yaml of an earlier shape that nothing reads; delete it and run `dmt lint --linter rbac --fix`") +} + +// A declared object rendered from another file than the declaration places it is refused with the +// place it belongs to. +func TestSync_MisplacedObjectIsNamed(t *testing.T) { + resetFixState() + t.Cleanup(resetFixState) + + modulePath := syncModuleDir(t) + model := syncModel(t, modulePath) + writeGenerated(t, modulePath, model) + + // The edit capability renders from view.yaml. + store := renderedFrom(t, model, func(o *generate.Object) bool { return o.Name != "d8:namespace-capability:cert-manager:edit" }) + edit := *model.File("templates/rbacv2/use/edit.yaml") + putObject(t, store, "templates/rbacv2/use/view.yaml", edit.Objects[0]) + + // Make view.yaml's text stale so its fix is asked for. + viewPath := filepath.Join(modulePath, "templates/rbacv2/use/view.yaml") + require.NoError(t, os.WriteFile(viewPath, []byte(generate.Header()+"\n# stale\n"), 0o600)) + + errorList := runSync(t, modulePath, store) + for _, fix := range errorList.GetFixes() { + fix() + } + + var messages []string + for _, e := range errorList.GetErrors() { + if e.FixError != nil { + messages = append(messages, e.FixError.Error()) + } + } + + require.Len(t, messages, 1, "got: %v", messages) + assert.Contains(t, messages[0], "ClusterRole/d8:namespace-capability:cert-manager:edit (the declaration puts it in templates/rbacv2/use/edit.yaml; move it there or delete both files and run the fix)") +} + +// Under --matrix the first declaration is written from the union of every variant's render. +func TestSync_BootstrapUnitesRenderVariants(t *testing.T) { + resetFixState() + t.Cleanup(resetFixState) + + modulePath := syncModuleDir(t) + model := syncModel(t, modulePath) + require.NoError(t, os.Remove(rbacyaml.Path(modulePath))) + + // Variant B rendered with the cainjector disabled, variant A with it enabled; B lints first. + variantB := renderedFrom(t, model, func(o *generate.Object) bool { return o.When == "" }) + variantA := renderedFrom(t, model, nil) + + listB := runSync(t, modulePath, variantB) + listA := runSync(t, modulePath, variantA) + + for _, list := range []*errors.LintRuleErrorsList{listB, listA} { + for _, fix := range list.GetFixes() { + fix() + } + } + + written, err := rbacyaml.Load(modulePath) + require.NoError(t, err) + require.Len(t, written.ServiceAccounts, 1, "the cainjector account, seen only by variant A, is in the declaration") + assert.Equal(t, "cainjector", written.ServiceAccounts[0].Name) +} From 9161c2a38735f38e7028a17e485b0275a60c60c3 Mon Sep 17 00:00:00 2001 From: Ivan Zvyagintsev Date: Tue, 22 Sep 2026 13:03:55 +0300 Subject: [PATCH 25/58] rbac: whitespace and preallocation the CI linter asks for; document the corner-case behaviour Signed-off-by: Ivan Zvyagintsev --- pkg/linters/rbac/README.md | 4 ++++ pkg/linters/rbac/rules/generate/model.go | 2 ++ pkg/linters/rbac/rules/rbacyaml/load_test.go | 6 ++++-- pkg/linters/rbac/rules/sync_test.go | 1 + 4 files changed, 11 insertions(+), 2 deletions(-) diff --git a/pkg/linters/rbac/README.md b/pkg/linters/rbac/README.md index 637685e9..6032b46b 100644 --- a/pkg/linters/rbac/README.md +++ b/pkg/linters/rbac/README.md @@ -1679,6 +1679,10 @@ linters-settings: - A conditional rule is checked only where it renders: with the default values, `dmt lint --values-file` or `dmt lint --matrix`. - **The three states a module can be in when the new `dmt` first runs.** *Only the legacy scheme* (an external module not yet migrated): `contract` reports one "migrate" finding per object; with an `rbac.yaml`, `sync` reports the generated files as absent and names the cause -- the template renders the legacy scheme -- and `--fix` leaves the legacy file alone (no generator header) with the generated version beside it. *Only the 1.78 scheme*: the ordinary case described above. *Both schemes behind the version gate* (`rbacv2-migrate-module.sh` without `--replace`): the linter's values answer the gate with the 1.78 model, so `contract` and `sync` see exactly the new objects and the legacy branch is neither judged nor "extra"; `--fix` never rewrites a gated file -- regenerating it would drop the legacy branch -- and says so. The legacy branch itself is exercised with `dmt lint --values-file` setting `global.deckhouseVersion` below 1.78; `--matrix` varies module values only, not the platform version. - The declaration is one per module and describes the union of editions. Linting a single edition directory shows the edition-only objects as absent; lint the merged tree as CI does. An `rbac.yaml` inside an edition overlay (`ee/modules`, `ee/be/modules`, ...) is an error: CI merges the overlays over `modules/` before linting, so a copy there would shadow the base one or go unseen. +- The generator refuses what the declaration alone cannot know is wrong: system levels on a module whose `module.yaml` names no `subsystems` (set `subsystems` in `rbac.yaml`); an account in a component directory named unlike it (the placement rule wants `` or `-`) or in a nested directory; a capability marker past 63 characters (a module name of 32 characters and up with a `superadmin` level). +- Built-in Kubernetes resources (`""`/configmaps, `apps`/deployments, `rbac.authorization.k8s.io`/clusterroles, ...) need no `scope`: the validator knows them. Anything else without a CRD in the module declares its scope. +- An `rbac.yaml` of the earlier, never consumed shape (no `apiVersion`) is named for what it is: delete it and run `--fix` to write the declaration from the render. +- Under `--matrix` the first declaration is written from the union of every variant's render; objects rendered only under values other than the defaults are still invisible to a default run, so lint with `--values-file` before the first regeneration if the module has such templates. - `impact: ignore` on a rule switches its autofix off with it: `--fix` never rewrites files on behalf of findings nobody sees. - A `when` condition must parse as a Helm expression (sprig and Helm functions are known); whether it holds under the linter's value stubs is decided by the render -- a condition that breaks the render is reported by the `helm-render` rule, and the module is not linted further. - `dmt lint remote` does not run these rules: a published image carries no chart to render. diff --git a/pkg/linters/rbac/rules/generate/model.go b/pkg/linters/rbac/rules/generate/model.go index f5c17973..972c24fc 100644 --- a/pkg/linters/rbac/rules/generate/model.go +++ b/pkg/linters/rbac/rules/generate/model.go @@ -192,6 +192,7 @@ func Build(in Input) (*Model, error) { sort.Slice(model.Files, func(i, j int) bool { return model.Files[i].Path < model.Files[j].Path }) + // A marker past 63 characters fails the contract; only a long module name can cause it. for _, f := range model.Files { for _, o := range f.Objects { if marker := o.Labels[rbaccontract.LabelCapability]; len(marker) > 63 { @@ -207,6 +208,7 @@ func Build(in Input) (*Model, error) { // metadata, and the objects it would produce would fail the platform's other rules. func checkAgainstModule(in Input) error { systemLevels := false + for _, r := range in.Decl.Resources { if len(r.System) > 0 { systemLevels = true diff --git a/pkg/linters/rbac/rules/rbacyaml/load_test.go b/pkg/linters/rbac/rules/rbacyaml/load_test.go index 2bc970c0..ccc6d610 100644 --- a/pkg/linters/rbac/rules/rbacyaml/load_test.go +++ b/pkg/linters/rbac/rules/rbacyaml/load_test.go @@ -550,8 +550,10 @@ access: `)) require.NoError(t, err) - msgs := make([]string, 0) - for _, e := range Validate(decl, nil) { + errs := Validate(decl, nil) + + msgs := make([]string, 0, len(errs)) + for _, e := range errs { msgs = append(msgs, e.Error()) } diff --git a/pkg/linters/rbac/rules/sync_test.go b/pkg/linters/rbac/rules/sync_test.go index d1185a7a..7af75b27 100644 --- a/pkg/linters/rbac/rules/sync_test.go +++ b/pkg/linters/rbac/rules/sync_test.go @@ -874,6 +874,7 @@ func TestSync_MisplacedObjectIsNamed(t *testing.T) { } var messages []string + for _, e := range errorList.GetErrors() { if e.FixError != nil { messages = append(messages, e.FixError.Error()) From 92fcfd86ac11d6fd8a534dc3877483db7b98d540 Mon Sep 17 00:00:00 2001 From: Ivan Zvyagintsev Date: Tue, 22 Sep 2026 13:10:46 +0300 Subject: [PATCH 26/58] rbac: a blank line the CI linter asks for Signed-off-by: Ivan Zvyagintsev --- pkg/linters/rbac/rules/sync.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/linters/rbac/rules/sync.go b/pkg/linters/rbac/rules/sync.go index 4edc9161..b58bd8cc 100644 --- a/pkg/linters/rbac/rules/sync.go +++ b/pkg/linters/rbac/rules/sync.go @@ -276,6 +276,7 @@ func (r *SyncRule) foreignObjects(file generate.File, model *generate.Model) []s // Where the declaration puts every object it produces: an object rendered from another file // than that is misplaced rather than unknown, and the refusal says so. placed := map[string]string{} + for _, f := range model.Files { for _, o := range f.Objects { placed[o.Identity()] = f.Path From be574060616585701d70a141adaf7dc314022230 Mon Sep 17 00:00:00 2001 From: Ivan Zvyagintsev Date: Tue, 22 Sep 2026 15:34:16 +0300 Subject: [PATCH 27/58] rbac: log what a regeneration removes The ADR (ccd35e0) names it among the drawbacks: a --fix run without a preceding dmt lint would remove rights the declaration no longer names with nothing said. The autofix now logs, per regenerated file, the rights and objects the render had and the declaration did not name, and logs the plain regeneration otherwise. Signed-off-by: Ivan Zvyagintsev --- pkg/linters/rbac/README.md | 3 ++- pkg/linters/rbac/rules/sync.go | 38 +++++++++++++++++++++++++++++++--- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/pkg/linters/rbac/README.md b/pkg/linters/rbac/README.md index 6032b46b..9cfd3afe 100644 --- a/pkg/linters/rbac/README.md +++ b/pkg/linters/rbac/README.md @@ -1634,7 +1634,8 @@ controller ClusterRoles with arbitrary names, objects with Helm-computed names). Findings are one per template file and carry the fix command; the text does not depend on the render variant. **Autofix:** regenerates the file from `rbac.yaml`. The declaration is the source of truth: a right it -no longer names leaves the template, and the finding that led there listed it. Three things are never +no longer names leaves the template; the finding that led there listed it, and the autofix logs what it +removed, so a `--fix` run without a preceding `dmt lint` does not remove rights in silence. Three things are never written over -- - a file that also holds objects the declaration does not produce -- a controller ClusterRole beside a declared ServiceAccount, a hand-written binding -- is never rewritten, because the generator writes the whole file and they would vanish (and so they would if the file were deleted); the refusal names them: declare them (`extraClusterRoles`, `access` with `path`) or move them first. An object the generator produces under another name -- a binding with the same roleRef and subjects, a role with the same rules -- is replaced, not foreign; diff --git a/pkg/linters/rbac/rules/sync.go b/pkg/linters/rbac/rules/sync.go index b58bd8cc..a9294628 100644 --- a/pkg/linters/rbac/rules/sync.go +++ b/pkg/linters/rbac/rules/sync.go @@ -20,6 +20,7 @@ import ( "context" stderrors "errors" "fmt" + "log/slog" "os" "path/filepath" "sort" @@ -30,6 +31,8 @@ import ( "k8s.io/apimachinery/pkg/runtime" "sigs.k8s.io/yaml" + "github.com/deckhouse/deckhouse/pkg/log" + "github.com/deckhouse/dmt/internal/storage" "github.com/deckhouse/dmt/pkg" "github.com/deckhouse/dmt/pkg/errors" @@ -237,7 +240,7 @@ func (r *SyncRule) Check(_ context.Context) { fileList := r.errorList.WithFilePath(path).WithObjectID(path) if file := model.File(path); file != nil { - fileList = fileList.WithFix(regenerateFix(modulePath, *file, r.foreignObjects(*file, model))) + fileList = fileList.WithFix(regenerateFix(modulePath, *file, r.foreignObjects(*file, model), removalsOf(list))) fileList.Errorf("%s does not match %s: %s. Run `%s` to regenerate the file from the declaration", path, rbacyaml.Filename, strings.Join(list, "; "), FixCommand) @@ -659,7 +662,23 @@ func crdScopes(crds []crdInfo) rbacyaml.CRDScopes { // - a template that serves both role models behind the version gate (R30); // - a file without the generator header, maintained by hand: the generated text is written // beside it as _.generated and the finding stays (R16, US-F2). -func regenerateFix(modulePath string, file generate.File, foreign []string) errors.AutofixFunc { +// +// removalsOf picks, from a file's divergences, what a regeneration takes away: rights and objects +// the render has and the declaration does not name. They are logged when the file is written, so a +// --fix run without a preceding dmt lint does not remove rights in silence. +func removalsOf(divergences []string) []string { + var out []string + + for _, d := range divergences { + if strings.Contains(d, "is in the render but not declared") || strings.Contains(d, "is in the render but rbac.yaml does not produce it") { + out = append(out, d) + } + } + + return out +} + +func regenerateFix(modulePath string, file generate.File, foreign, removals []string) errors.AutofixFunc { content := generate.RenderFile(file) fullPath := filepath.Join(modulePath, file.Path) @@ -717,7 +736,20 @@ func regenerateFix(modulePath string, file generate.File, foreign []string) erro perm = info.Mode().Perm() } - return os.WriteFile(fullPath, []byte(content), perm) + if err := os.WriteFile(fullPath, []byte(content), perm); err != nil { + return err + } + + // The declaration is the source: what it no longer names left the file. Say so where a + // --fix run without a preceding lint would otherwise remove it in silence. + if len(removals) > 0 { + log.Warn("rbac autofix regenerated a template and removed what the declaration does not name", + slog.String("file", file.Path), slog.Any("removed", removals)) + } else { + log.Info("rbac autofix regenerated a template from rbac.yaml", slog.String("file", file.Path)) + } + + return nil }) } } From 317f3dc29d1e16a268f0e3a7aa41703fdb031e57 Mon Sep 17 00:00:00 2001 From: Ivan Zvyagintsev Date: Tue, 22 Sep 2026 16:12:16 +0300 Subject: [PATCH 28/58] rbac: orphaned generated files are deleted, scopes are written out at bootstrap A third round of corner cases on a synthetic module and on the edition overlay of admission-policy-engine: - a generated file the declaration produces nothing for any more -- every namespace level dropped, the legacy section gone -- stayed with a finding only a person could close. It is the generator's file and the declaration wins: --fix deletes it when it holds nothing but objects of the owned classes, and logs the deletion; a foreign object or the version gate keeps it, and the finding says which. - the bootstrap left the scope implicit for resources whose CRD is in the module. A lint of one edition directory does not see the CRDs of the other editions (ratify of admission-policy-engine lives in ee/se-plus), and an entry without a scope then stopped the whole validation instead of raising one coverage warning. Every entry now carries its scope, denied ones too. - a generator header with trailing whitespace handed the file over to a person in silence; the header is recognized. Signed-off-by: Ivan Zvyagintsev --- pkg/linters/rbac/README.md | 6 +- pkg/linters/rbac/rules/bootstrap/bootstrap.go | 12 +++- .../rbac/rules/bootstrap/bootstrap_test.go | 6 ++ .../rbac/rules/generate/generate_test.go | 3 + pkg/linters/rbac/rules/generate/render.go | 2 +- pkg/linters/rbac/rules/sync.go | 72 +++++++++++++++++++ pkg/linters/rbac/rules/sync_test.go | 55 ++++++++++++++ 7 files changed, 152 insertions(+), 4 deletions(-) diff --git a/pkg/linters/rbac/README.md b/pkg/linters/rbac/README.md index 9cfd3afe..838f6dd7 100644 --- a/pkg/linters/rbac/README.md +++ b/pkg/linters/rbac/README.md @@ -1607,7 +1607,9 @@ Without `rbac.yaml` the rule reports the declaration missing, and `--fix` writes objects the module renders today: the declaration a person would have transcribed from the templates, with a `TODO` wherever a decision is still theirs (a resource without a CRD whose scope the linter cannot know, a CRD nobody grants, a namespaced resource granted cluster-wide) and a note on top for -every object the generator will name differently or cannot describe. Review it, resolve the TODOs, +every object the generator will name differently or cannot describe. Every entry gets its `scope` +written out, CRD or not, so a lint of one edition directory that lacks the other editions' CRDs still +validates the declaration. Review it, resolve the TODOs, then run `--fix` again to regenerate the templates from it. From then on `rbac.yaml` is the source. With `rbac.yaml` the rule first validates the declaration; a declaration with errors is reported and @@ -1642,7 +1644,7 @@ written over -- - a file without the generator header is maintained by hand: the generated text is written beside it as `_.generated` (the underscore keeps Helm from rendering the copy) and the finding stays (delete the file and run `--fix` again to hand it back to the generator); - a template that serves both role models behind the version gate (`rbacv2_new_scheme`) is never regenerated: the legacy branch would vanish; -A missing file is created. A second `--fix` without changes to `rbac.yaml` changes nothing. Under +A missing file is created. A generated file the declaration produces nothing for any more -- every namespace level dropped, the `legacy` section gone -- is deleted, as long as it holds nothing but objects of the owned classes; the deletion is logged. A second `--fix` without changes to `rbac.yaml` changes nothing. Under `--matrix` every render variant reports the file, but the fix runs once: the variants record what their renders grant while they exist, the first closure checks the union and writes, the others report its outcome -- so a right rendered only under some values is never dropped. diff --git a/pkg/linters/rbac/rules/bootstrap/bootstrap.go b/pkg/linters/rbac/rules/bootstrap/bootstrap.go index 24d2fd48..354582f4 100644 --- a/pkg/linters/rbac/rules/bootstrap/bootstrap.go +++ b/pkg/linters/rbac/rules/bootstrap/bootstrap.go @@ -582,6 +582,12 @@ func (b *builder) resources() { switch { case fromCRD: + // Written out even though the CRD says it: a lint of one edition directory does not see + // the CRDs of the other editions, and an entry without a scope would then stop the + // whole validation instead of raising one coverage warning. + if !strings.Contains(resource, "/") { + e.Scope = scope + } case resource == "*": e.Scope, scope = rbacyaml.ScopeCluster, rbacyaml.ScopeCluster e.Reason = "TODO: the templates grant the whole group; say why the resource names are not known statically" @@ -633,7 +639,11 @@ func (b *builder) resources() { } if !declared { - b.decl.Resources = append(b.decl.Resources, rbacyaml.Resource{Group: group, Resource: plural, NoAccess: "TODO: no user-facing access in the templates today; grant levels or say why users get none"}) + // The scope is written out for the same reason as above: a lint of one edition directory + // that lacks this CRD must read the entry as a documented external resource, not as a + // stale one. + b.decl.Resources = append(b.decl.Resources, rbacyaml.Resource{Group: group, Resource: plural, Scope: b.in.CRDs[k], + NoAccess: "TODO: no user-facing access in the templates today; grant levels or say why users get none"}) } } diff --git a/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go b/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go index d9cb3a7c..bbec89c4 100644 --- a/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go +++ b/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go @@ -20,6 +20,7 @@ import ( "os" "path/filepath" "sort" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -105,6 +106,11 @@ func TestBuild_RoundTripOnTheCertManagerFixture(t *testing.T) { g, ok := gotRes[k] require.True(t, ok, "resource %s missing", k) assert.Equal(t, w.NoAccess != "", g.NoAccess != "", "%s: denied", k) + + if g.NoAccess == "" && !strings.Contains(g.Resource, "/") { + assert.Equal(t, certManagerCRDs[k], g.Scope, "%s: the scope is written out even when the CRD says it", k) + } + assert.Equal(t, w.Namespace, g.Namespace, "%s: namespace levels", k) assert.Equal(t, w.System, g.System, "%s: system levels", k) assert.Equal(t, w.Legacy, g.Legacy, "%s: legacy levels", k) diff --git a/pkg/linters/rbac/rules/generate/generate_test.go b/pkg/linters/rbac/rules/generate/generate_test.go index 7c5be4f0..fb7d86ff 100644 --- a/pkg/linters/rbac/rules/generate/generate_test.go +++ b/pkg/linters/rbac/rules/generate/generate_test.go @@ -302,4 +302,7 @@ func TestParseHeader_CRLF(t *testing.T) { generated, version := ParseHeader(Header() + "\r\n---\r\n") assert.True(t, generated) assert.Equal(t, "1", version) + + generated, _ = ParseHeader(Header() + " \n---\n") + assert.True(t, generated, "trailing spaces do not hand the file over to a person") } diff --git a/pkg/linters/rbac/rules/generate/render.go b/pkg/linters/rbac/rules/generate/render.go index d86338f5..9b71d024 100644 --- a/pkg/linters/rbac/rules/generate/render.go +++ b/pkg/linters/rbac/rules/generate/render.go @@ -40,7 +40,7 @@ func Header() string { // newer) contract. func ParseHeader(content string) (bool, string) { line, _, _ := strings.Cut(content, "\n") - line = strings.TrimSuffix(line, "\r") + line = strings.TrimRight(line, " \t\r") if !strings.HasPrefix(line, headerPrefix) || !strings.HasSuffix(line, headerSuffix) { return false, "" diff --git a/pkg/linters/rbac/rules/sync.go b/pkg/linters/rbac/rules/sync.go index a9294628..e2d8fbe7 100644 --- a/pkg/linters/rbac/rules/sync.go +++ b/pkg/linters/rbac/rules/sync.go @@ -247,11 +247,83 @@ func (r *SyncRule) Check(_ context.Context) { continue } + // A file the generator wrote earlier that the declaration produces nothing for any more -- a + // legacy section dropped, every namespace level gone -- is an orphan: the declaration wins, + // and the fix deletes it, as long as it holds nothing but objects of the owned classes. + if orphan, reason := r.orphanGeneratedFile(path, model); orphan { + fileList.WithFix(removeFileFix(modulePath, path, list)).Errorf("%s does not match %s: %s. The file carries the generator header and the declaration produces nothing for it; `%s` deletes it", + path, rbacyaml.Filename, strings.Join(list, "; "), FixCommand) + + continue + } else if reason != "" { + list = append(list, reason) + } + fileList.Errorf("%s does not match %s: %s. Only a person can close this: the declaration does not produce this file", path, rbacyaml.Filename, strings.Join(list, "; ")) } } +// orphanGeneratedFile reports whether a template the declaration produces nothing for is the +// generator's (header present) and holds only objects of the owned classes, so deleting it loses +// nothing the declaration does not know about. Otherwise it returns why the file stays. +func (r *SyncRule) orphanGeneratedFile(path string, model *generate.Model) (bool, string) { + if model.File(path) != nil { + return false, "" + } + + content, err := os.ReadFile(filepath.Join(r.module.GetPath(), path)) + if err != nil { + return false, "" + } + + if generated, _ := generate.ParseHeader(string(content)); !generated { + return false, "" + } + + if strings.Contains(string(content), rbaccontract.GateMarker) { + return false, "the file serves both role models behind the version gate" + } + + managed := r.managedObjects(model) + + for index, object := range r.module.GetStorage() { + if object.ShortPath() != path { + continue + } + + switch object.Unstructured.GetKind() { + case "ClusterRole", "Role", "ClusterRoleBinding", "RoleBinding", "ServiceAccount": + default: + continue + } + + if _, owned := managed[index.AsString()]; !owned { + return false, "the file also holds " + index.AsString() + ", which the declaration does not describe" + } + } + + return true, "" +} + +// removeFileFix deletes an orphaned generated file and logs what went with it. +func removeFileFix(modulePath, path string, removed []string) errors.AutofixFunc { + fullPath := filepath.Join(modulePath, path) + + return func() error { + return fixOnce(fullPath, func() error { + if err := os.Remove(fullPath); err != nil && !stderrors.Is(err, os.ErrNotExist) { + return fmt.Errorf("delete %s: %w", path, err) + } + + log.Warn("rbac autofix deleted a generated template the declaration produces nothing for", + slog.String("file", path), slog.Any("removed", removed)) + + return nil + }) + } +} + // legacyFiles maps the templates that rendered an object of the scheme before 1.78 to its kind. // Those objects belong to no class the declaration produces; they are the module's old model. func (r *SyncRule) legacyFiles() map[string]string { diff --git a/pkg/linters/rbac/rules/sync_test.go b/pkg/linters/rbac/rules/sync_test.go index 7af75b27..72109d13 100644 --- a/pkg/linters/rbac/rules/sync_test.go +++ b/pkg/linters/rbac/rules/sync_test.go @@ -26,6 +26,7 @@ import ( "github.com/gojuno/minimock/v3" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + yaml "gopkg.in/yaml.v3" corev1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -912,3 +913,57 @@ func TestSync_BootstrapUnitesRenderVariants(t *testing.T) { require.Len(t, written.ServiceAccounts, 1, "the cainjector account, seen only by variant A, is in the declaration") assert.Equal(t, "cainjector", written.ServiceAccounts[0].Name) } + +// A generated file the declaration produces nothing for any more is an orphan: --fix deletes it. +// One that also holds an object outside the owned classes, or serves both models, stays. +func TestSync_OrphanGeneratedFileIsDeleted(t *testing.T) { + resetFixState() + t.Cleanup(resetFixState) + + modulePath := syncModuleDir(t) + model := syncModel(t, modulePath) + writeGenerated(t, modulePath, model) + store := renderedFrom(t, model, nil) + + // The legacy section leaves the declaration; the render still has the roles from the file. + decl, err := rbacyaml.Load(modulePath) + require.NoError(t, err) + + for i := range decl.Resources { + decl.Resources[i].Legacy = nil + } + + raw, err := yaml.Marshal(decl) + require.NoError(t, err) + require.NoError(t, os.WriteFile(rbacyaml.Path(modulePath), raw, 0o600)) + + const rel = "templates/user-authz-cluster-roles.yaml" + + errorList := runSync(t, modulePath, store) + got := texts(errorList) + require.Len(t, got, 1, "got: %v", got) + assert.Contains(t, got[0], "The file carries the generator header and the declaration produces nothing for it; `dmt lint --linter rbac --fix` deletes it") + + for _, fix := range errorList.GetFixes() { + fix() + } + + assert.Empty(t, errorList.GetErrors()) + + _, err = os.Stat(filepath.Join(modulePath, rel)) + assert.True(t, os.IsNotExist(err), "the orphan is gone") + + // The same file with a foreign object beside the legacy roles is not deleted. + resetFixState() + writeGenerated(t, modulePath, model) + putObject(t, store, rel, generate.Object{ + Kind: "ClusterRole", Name: "d8:cert-manager:something-else", Class: generate.ClassDeclared, + Rules: []generate.Rule{{PolicyRule: rbacyaml.PolicyRule{APIGroups: []string{""}, Resources: []string{"pods"}, Verbs: []string{"get"}}}}, + }) + + errorList = runSync(t, modulePath, store) + got = texts(errorList) + require.Len(t, got, 1, "got: %v", got) + assert.Contains(t, got[0], "the file also holds ClusterRole/d8:cert-manager:something-else, which the declaration does not describe. Only a person can close this") + assert.Empty(t, errorList.GetFixes()) +} From bc99053ba138d5ef97b131a5ec9a309dec34d5d3 Mon Sep 17 00:00:00 2001 From: Ivan Zvyagintsev Date: Tue, 22 Sep 2026 16:35:45 +0300 Subject: [PATCH 29/58] rbac: review fixes -- exclusions, automount, rename check, orphan safety, quoting sync - exclude-rules.sync silences an object's findings without forgetting the object: the exclusion is applied when findings are emitted, so an excluded declared object is no longer reported as absent. - ServiceAccount objects are compared: a token the render mounts and the declaration does not is a divergence, since the regeneration would drop it. - A rendered binding under another name counts as a rename only when it points at the same role, or at a role rendered in the same file whose rules the produced role carries. A binding to cluster-admin is a foreign object. - The orphan-file fix judges the union of render variants and re-reads the header and the gate before deleting; a file another variant placed a foreign object in stays. - The gate is detected by the migration helper or a deckhouseVersion test in a template action that the declaration itself did not produce, so a declared `when` on the platform version no longer blocks regeneration forever. - Files are written through a temporary file and a rename; the aside copy gets the permissions of a source file. validation and generation - resources require apiGroups (the core group is ""). - Generated names are quoted unless they are plain scalars; YAML 1.1 words (no, yes, on, off, null) and numbers are quoted. - Two declared roles that map to one generated binding name are refused. - Validation errors, CRD ordering and the bootstrap input have one order. contract - The level in a role name is checked against the lineage of its scope. - One warning per cluster-scoped group/resource in a namespace capability. - ProjectLevels no longer shares its backing array with NamespaceLevels. bootstrap - A resource every grant of which carried resourceNames is written as an undecided noAccess entry instead of being widened to every object. coverage - exclude-rules.coverage also silences the misspelling warning of the entry. Tests cover each change; the READMEs describe the current behaviour. Signed-off-by: Ivan Zvyagintsev --- pkg/linters/rbac/README.md | 11 +- pkg/linters/rbac/rules/bootstrap/bootstrap.go | 46 +++- .../rbac/rules/bootstrap/bootstrap_test.go | 62 ++++- pkg/linters/rbac/rules/contract.go | 18 +- pkg/linters/rbac/rules/contract_test.go | 9 + pkg/linters/rbac/rules/coverage.go | 4 +- pkg/linters/rbac/rules/coverage_test.go | 43 +++- pkg/linters/rbac/rules/crds.go | 8 +- pkg/linters/rbac/rules/fixstate.go | 54 +++- .../rbac/rules/generate/generate_test.go | 38 ++- pkg/linters/rbac/rules/generate/model.go | 10 + pkg/linters/rbac/rules/generate/render.go | 29 ++- .../rbac/rules/rbaccontract/contract.go | 2 +- pkg/linters/rbac/rules/rbacyaml/load_test.go | 4 + pkg/linters/rbac/rules/rbacyaml/validate.go | 8 + pkg/linters/rbac/rules/sync.go | 160 +++++++++--- pkg/linters/rbac/rules/sync_test.go | 240 +++++++++++++++++- test/e2e/README.md | 4 + 18 files changed, 675 insertions(+), 75 deletions(-) diff --git a/pkg/linters/rbac/README.md b/pkg/linters/rbac/README.md index 838f6dd7..12a33719 100644 --- a/pkg/linters/rbac/README.md +++ b/pkg/linters/rbac/README.md @@ -16,7 +16,7 @@ Proper RBAC configuration is critical for Kubernetes security, ensuring least-pr | [wildcards](#wildcards) | Validates Roles/ClusterRoles don't use wildcard permissions | ✅ | enabled | | [contract](#contract) | Holds the module's RBACv2 roles and capabilities to the platform's label and naming contract | ✅ | enabled | | [coverage](#coverage) | Requires a decision in `rbac.yaml` on the user access to every CRD the module ships | ✅ | enabled when `rbac.yaml` exists | -| [sync](#sync) | Compares the rendered RBAC objects with `rbac.yaml` in both directions; `--fix` regenerates the templates | ✅ | enabled when `rbac.yaml` exists | +| [sync](#sync) | Compares the rendered RBAC objects with `rbac.yaml` in both directions; `--fix` regenerates the templates, and writes the first `rbac.yaml` from the render of a module that has none | ✅ | always on | "Configurable" means that this rule can be configured using the `.dmtlint.yaml` file, including customizing the rule's parameters and/or disabling the rule. @@ -1492,6 +1492,9 @@ platform's label and naming contract, so that a module outside the platform repo the same way the platform's own test (`testing/rbacv2`) checks in-tree modules. Role aggregation relies on labels the API server cannot validate; a divergent module silently breaks it. +The standalone helper role `d8:dict`, which the `handle_dict_bindings` hook binds, is not an RBACv2 +role or capability and is exempt from the naming and label checks. + **Description:** Works on the rendered ClusterRoles from `templates/rbacv2/` (the compatibility aliases under @@ -1567,8 +1570,8 @@ Runs only when the module has an `rbac.yaml`. Reads the CRDs under `crds/` at an -- and then still reports the finding: the stub is not a decision, and a `--fix` run that wrote stubs does not end green. Existing entries and comments are left as they are; a second `--fix` changes nothing. -The rule does not create `rbac.yaml`: a module adopts the declaration by creating the file with the -`apiVersion` line and running `--fix`. +The rule does not create `rbac.yaml`: a module without the file is a `sync` finding, and `--fix` of +that rule writes the first declaration from the render (see [sync](#sync)). **Example finding:** @@ -1689,5 +1692,5 @@ linters-settings: - `impact: ignore` on a rule switches its autofix off with it: `--fix` never rewrites files on behalf of findings nobody sees. - A `when` condition must parse as a Helm expression (sprig and Helm functions are known); whether it holds under the linter's value stubs is decided by the render -- a condition that breaks the render is reported by the `helm-render` rule, and the module is not linted further. - `dmt lint remote` does not run these rules: a published image carries no chart to render. -- The keys of the `rbac` configuration blocks are checked: an unknown key is an error, not a silent no-op. +- The keys of the `rbac` configuration blocks (`linters-settings.rbac`, its `exclude-rules`, the global `rbac` settings and its `rules`) are checked: an unknown key at those levels is an error, not a silent no-op. Keys inside a rule's level or an exclusion entry are viper's as before. diff --git a/pkg/linters/rbac/rules/bootstrap/bootstrap.go b/pkg/linters/rbac/rules/bootstrap/bootstrap.go index 354582f4..069f43d3 100644 --- a/pkg/linters/rbac/rules/bootstrap/bootstrap.go +++ b/pkg/linters/rbac/rules/bootstrap/bootstrap.go @@ -23,6 +23,7 @@ package bootstrap import ( "fmt" "regexp" + "slices" "sort" "strings" @@ -107,14 +108,18 @@ func sortedLevels(m map[string]map[string]struct{}) map[string][]string { } type builder struct { - in Input - res map[[2]string]*resourceAcc - texts map[string]rbacyaml.CapabilityText - lineages map[string]struct{} - used map[string]struct{} - notes []string - unmanaged []string - decl *rbacyaml.Declaration + in Input + res map[[2]string]*resourceAcc + // restricted marks resources every grant of which carried resourceNames: the format cannot + // keep that limit on a capability, and widening a grant is not the importer's call. + restricted map[[2]string]bool + unrestricted map[[2]string]bool + texts map[string]rbacyaml.CapabilityText + lineages map[string]struct{} + used map[string]struct{} + notes []string + unmanaged []string + decl *rbacyaml.Declaration } func (b *builder) note(format string, args ...any) { @@ -148,7 +153,14 @@ func (o Object) identity() string { // Build derives the declaration. func Build(in Input) Result { - b := &builder{in: in, res: map[[2]string]*resourceAcc{}, texts: map[string]rbacyaml.CapabilityText{}, lineages: map[string]struct{}{}, used: map[string]struct{}{}, + // The lint path fills Objects from a map; the notes and the unmanaged list go into the file + // header in this order, so it is fixed here rather than at every caller. + in.Objects = slices.Clone(in.Objects) + sort.SliceStable(in.Objects, func(i, j int) bool { + return in.Objects[i].identity() < in.Objects[j].identity() + }) + + b := &builder{in: in, res: map[[2]string]*resourceAcc{}, restricted: map[[2]string]bool{}, unrestricted: map[[2]string]bool{}, texts: map[string]rbacyaml.CapabilityText{}, lineages: map[string]struct{}{}, used: map[string]struct{}{}, decl: &rbacyaml.Declaration{APIVersion: rbacyaml.APIVersionV1Alpha1}} b.capabilitiesAndLegacy() @@ -178,11 +190,14 @@ func (b *builder) addRules(sectionName, level string, rules []rbacv1.PolicyRule, continue // the generator adds it } + key := [2]string{g, rs} + if len(r.ResourceNames) > 0 { - b.note("%s/%s: %s/%s was limited to resourceNames %v; the format has no resourceNames for capabilities, the grant is now on every object", sectionName, level, g, rs, r.ResourceNames) + b.restricted[key] = true + } else { + b.unrestricted[key] = true } - key := [2]string{g, rs} if b.res[key] == nil { b.res[key] = newAcc() } @@ -618,6 +633,15 @@ func (b *builder) resources() { continue } + // Every grant of this resource named specific objects (resourceNames); a declaration entry + // would grant every object. That widening is a person's decision, so the entry is left + // undecided and coverage keeps the run red until it is made. + if b.restricted[k] && !b.unrestricted[k] { + e = rbacyaml.Resource{Group: group, Resource: resource, Scope: e.Scope, + NoAccess: "TODO: the templates limited this grant to specific resourceNames, which the format cannot express; grant the levels to every object or keep denying"} + b.note("%s/%s: every grant carried resourceNames; left as noAccess TODO instead of widening it to every object", group, resource) + } + b.decl.Resources = append(b.decl.Resources, e) } diff --git a/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go b/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go index bbec89c4..834ee4bb 100644 --- a/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go +++ b/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go @@ -176,6 +176,64 @@ func TestBuild_RoundTripOnTheCertManagerFixture(t *testing.T) { } // What the importer cannot decide is a TODO or a note, and objects outside the format stay listed. +// A grant limited to resourceNames cannot be kept on a capability. When every grant of the +// resource is limited, the entry is left undecided rather than widened; when one grant is +// unrestricted, the levels stand. +func TestBuild_ResourceNamesAreNotWidened(t *testing.T) { + labels := map[string]string{"module": "m", "rbac.deckhouse.io/kind": "capability", "rbac.deckhouse.io/scope": "namespace", "rbac.deckhouse.io/aggregate-to-namespace-as": "viewer"} + objects := []Object{ + {Kind: "ClusterRole", Name: "d8:namespace-capability:m:view", Path: "templates/rbacv2/use/view.yaml", Labels: labels, + Rules: []rbacv1.PolicyRule{ + {APIGroups: []string{""}, Resources: []string{"configmaps"}, ResourceNames: []string{"m-config"}, Verbs: []string{"get"}}, + {APIGroups: []string{""}, Resources: []string{"secrets"}, ResourceNames: []string{"m-token"}, Verbs: []string{"get"}}, + {APIGroups: []string{""}, Resources: []string{"secrets"}, Verbs: []string{"list"}}, + }}, + } + + got := Build(Input{Module: "m", Namespace: "d8-m", Objects: objects}) + + byKey := map[string]rbacyaml.Resource{} + for _, r := range got.Decl.Resources { + byKey[r.Group+"/"+r.Resource] = r + } + + require.Contains(t, byKey, "/configmaps") + assert.Contains(t, byKey["/configmaps"].NoAccess, "TODO") + assert.Empty(t, byKey["/configmaps"].Namespace) + assert.Equal(t, "Namespaced", byKey["/configmaps"].Scope) + + require.Contains(t, byKey, "/secrets") + assert.Empty(t, byKey["/secrets"].NoAccess) + assert.ElementsMatch(t, []string{"get", "list"}, byKey["/secrets"].Namespace["viewer"]) + + assert.Contains(t, strings.Join(got.Notes, "\n"), "/configmaps: every grant carried resourceNames") +} + +// The lint path fills the input from a map; the result must not depend on that order. +func TestBuild_IsIndependentOfInputOrder(t *testing.T) { + yes := true + objects := []Object{ + {Kind: "ClusterRole", Name: "d8:namespace-capability:m:view", Path: "templates/rbacv2/use/view.yaml", + Labels: map[string]string{"module": "m", "rbac.deckhouse.io/kind": "capability", "rbac.deckhouse.io/scope": "namespace", "rbac.deckhouse.io/aggregate-to-namespace-as": "viewer"}, + Rules: []rbacv1.PolicyRule{{APIGroups: []string{"trivy.deckhouse.io"}, Resources: []string{"vulnerabilityreports"}, Verbs: []string{"get"}}}}, + {Kind: "ClusterRole", Name: "d8:use:capability:module:m:view", Path: "templates/rbacv2/use/old.yaml", Labels: map[string]string{"module": "m", "rbac.deckhouse.io/kind": "use"}}, + {Kind: "ClusterRole", Name: "d8:namespace-capability:kubernetes:view_logs", Path: "templates/rbacv2/global/x.yaml", Labels: map[string]string{"module": "m", "rbac.deckhouse.io/kind": "capability"}}, + {Kind: "ServiceAccount", Name: "webhook", Path: "templates/webhook/rbac-for-us.yaml", Labels: map[string]string{"module": "m", "app": "webhook"}, Automount: &yes}, + {Kind: "ClusterRole", Name: "d8:m:webhook:requester", Path: "templates/webhook/rbac-for-us.yaml", Labels: map[string]string{"module": "m"}, Rules: []rbacv1.PolicyRule{{APIGroups: []string{"x"}, Resources: []string{"y"}, Verbs: []string{"create"}}}}, + } + + forward := Build(Input{Module: "m", Namespace: "d8-m", Subsystems: []string{"security"}, Objects: objects, CRDs: map[string]string{"deckhouse.io/things": "Namespaced"}}) + + reversed := make([]Object, 0, len(objects)) + for i := len(objects) - 1; i >= 0; i-- { + reversed = append(reversed, objects[i]) + } + + backward := Build(Input{Module: "m", Namespace: "d8-m", Subsystems: []string{"security"}, Objects: reversed, CRDs: map[string]string{"deckhouse.io/things": "Namespaced"}}) + + assert.Equal(t, forward, backward) +} + func TestBuild_TODOsAndUnmanaged(t *testing.T) { yes := true objects := []Object{ @@ -222,6 +280,6 @@ func TestBuild_TODOsAndUnmanaged(t *testing.T) { assert.False(t, sa.ExtraClusterRoles[0].IsBound()) require.Len(t, got.Unmanaged, 2) - assert.Contains(t, got.Unmanaged[0]+got.Unmanaged[1], "d8:use:capability:module:m:view") - assert.Contains(t, got.Unmanaged[0]+got.Unmanaged[1], "d8:namespace-capability:kubernetes:view_logs") + assert.Contains(t, got.Unmanaged[0], "d8:namespace-capability:kubernetes:view_logs") + assert.Contains(t, got.Unmanaged[1], "d8:use:capability:module:m:view") } diff --git a/pkg/linters/rbac/rules/contract.go b/pkg/linters/rbac/rules/contract.go index 14a0072e..9f5f6604 100644 --- a/pkg/linters/rbac/rules/contract.go +++ b/pkg/linters/rbac/rules/contract.go @@ -256,9 +256,15 @@ func checkRole(role *rbacv1.ClusterRole, scope string, errorList *errors.LintRul return } + // Subsystem roles carry the system lineage's levels; LevelsOf keys subsystems by name. + lineage := scope + if scope == "subsystem" { + lineage = rbaccontract.LineageSystem + } + level := m[len(m)-1] - if !slices.Contains(rbaccontract.NamespaceLevels, level) { - errorList.Errorf("role name %q has invalid level %q", name, level) + if levels := rbaccontract.LevelsOf(lineage); !slices.Contains(levels, level) { + errorList.Errorf("role name %q has invalid level %q; the %s lineage has %s", name, level, lineage, strings.Join(levels, ", ")) } if scope == "subsystem" { @@ -346,9 +352,17 @@ func checkCapability(role *rbacv1.ClusterRole, scope string, scopes rbacyaml.CRD // A namespace capability is granted through a RoleBinding; a cluster-scoped resource in it // grants nothing. Warn for now (D8): three in-tree modules carry such rules. if scope == "namespace" { + warned := map[string]struct{}{} + for _, rule := range role.Rules { for _, group := range rule.APIGroups { for _, resource := range rule.Resources { + if _, done := warned[group+"/"+resource]; done { + continue + } + + warned[group+"/"+resource] = struct{}{} + if scopes[group+"/"+resource] == rbacyaml.ScopeCluster { errorList.Warnf("capability %q grants %s/%s, a cluster-scoped resource, in a namespace capability: bound through a RoleBinding the rule grants nothing; move it to a system capability", name, group, resource) } diff --git a/pkg/linters/rbac/rules/contract_test.go b/pkg/linters/rbac/rules/contract_test.go index 6df12872..49190940 100644 --- a/pkg/linters/rbac/rules/contract_test.go +++ b/pkg/linters/rbac/rules/contract_test.go @@ -183,6 +183,15 @@ func TestContract_Findings(t *testing.T) { `error: aggregation label "rbac.deckhouse.io/aggregate-to-system-as" has invalid level "admin"; the system lineage has viewer, manager, superadmin`, }, }, + "R29: a system role named with a namespace level": { + object: clusterRole("d8:system:admin", map[string]string{"module": "cert-manager", + "rbac.deckhouse.io/kind": "role", "rbac.deckhouse.io/scope": "system", "rbac.deckhouse.io/use-role": "admin", + }, i18n, "aggregationRule:\n clusterRoleSelectors:\n - matchLabels:\n rbac.deckhouse.io/aggregate-to-system-as: admin\n"), + wantErrs: []string{ + `error: role name "d8:system:admin" has invalid level "admin"; the system lineage has viewer, manager, superadmin`, + `error: role "d8:system:admin" aggregation selector has invalid level "admin"`, + }, + }, "R21: the module label names another module": { object: clusterRole("d8:namespace-capability:x:view", map[string]string{"module": "other", "rbac.deckhouse.io/kind": "capability", "rbac.deckhouse.io/scope": "namespace", diff --git a/pkg/linters/rbac/rules/coverage.go b/pkg/linters/rbac/rules/coverage.go index dc9910af..af5363a4 100644 --- a/pkg/linters/rbac/rules/coverage.go +++ b/pkg/linters/rbac/rules/coverage.go @@ -121,7 +121,7 @@ func (r *CoverageRule) Check(_ context.Context) { // A resource of a group the module ships CRDs for, but not one of them, is most likely a // misspelling (R11). Whole-group and subresource entries are exempt: CRDs describe neither. for _, res := range decl.Resources { - if res.IsWildcard() || res.IsSubresource() { + if res.IsWildcard() || res.IsSubresource() || !r.Enabled(res.Key()) { continue } @@ -241,7 +241,7 @@ func appendStub(path, group, resource string) (bool, error) { return false, err } - return true, os.WriteFile(path, buf.Bytes(), info.Mode().Perm()) + return true, writeFileAtomic(path, buf.Bytes(), info.Mode().Perm()) } // mappingValue returns the value node of key in a mapping node, or nil. diff --git a/pkg/linters/rbac/rules/coverage_test.go b/pkg/linters/rbac/rules/coverage_test.go index 10df27da..f24aa800 100644 --- a/pkg/linters/rbac/rules/coverage_test.go +++ b/pkg/linters/rbac/rules/coverage_test.go @@ -196,17 +196,52 @@ resources: assert.NotContains(t, strings.Join(second, "\n"), "has no entry") assert.Equal(t, 3, strings.Count(strings.Join(second, "\n"), `is still noAccess: "TODO"`)) - // Idempotency (R17): fixes of a run that has nothing to add do not touch the file. + // Idempotency (R17): a run that has nothing to add carries no fixes, and appendStub itself + // leaves a present entry alone byte for byte. third := runCoverage(t, modulePath) - for _, fix := range third.GetFixes() { - fix() - } + assert.Empty(t, third.GetFixes()) + + added, err := appendStub(rbacyaml.Path(modulePath), "a.io", "alphas") + require.NoError(t, err) + assert.False(t, added) unchanged, err := os.ReadFile(rbacyaml.Path(modulePath)) require.NoError(t, err) assert.Equal(t, string(after), string(unchanged)) } +// exclude-rules.coverage names a resource: neither the missing-entry finding nor the misspelling +// warning of an entry with that key is reported. +func TestCoverage_ExcludedResourceSilencesTheSpellingWarning(t *testing.T) { + modulePath := writeModule(t, map[string]string{ + "crds/a.yaml": crdYAML("a.io", "alphas", "Namespaced"), + rbacyaml.Filename: "apiVersion: rbac.deckhouse.io/v1alpha1\nresources:\n - group: a.io\n resource: alphaz\n scope: Namespaced\n namespace: {viewer: [get]}\n", + }) + + got := texts(runCoverage(t, modulePath, "a.io/alphas", "a.io/alphaz")) + assert.Empty(t, got, "got: %v", got) + + got = texts(runCoverage(t, modulePath, "a.io/alphas")) + require.Len(t, got, 1, "got: %v", got) + assert.Contains(t, got[0], "alphaz names a resource the module's CRDs of group a.io do not have") +} + +func TestAppendStub_ScalarResources(t *testing.T) { + modulePath := writeModule(t, map[string]string{ + rbacyaml.Filename: "apiVersion: rbac.deckhouse.io/v1alpha1\nresources: null\n", + }) + + added, err := appendStub(rbacyaml.Path(modulePath), "a.io", "alphas") + require.NoError(t, err) + assert.True(t, added) + + content, err := os.ReadFile(rbacyaml.Path(modulePath)) + require.NoError(t, err) + assert.Contains(t, string(content), "resources:\n") + assert.Contains(t, string(content), "resource: alphas") + assert.NotContains(t, string(content), "resources: null") +} + func TestCoverage_ExcludedCRDAndDeclarationWithoutResources(t *testing.T) { modulePath := writeModule(t, map[string]string{ "crds/a.yaml": crdYAML("a.io", "alphas", "Namespaced") + "---\n" + crdYAML("a.io", "betas", "Cluster"), diff --git a/pkg/linters/rbac/rules/crds.go b/pkg/linters/rbac/rules/crds.go index 4da4622e..8aa5fe84 100644 --- a/pkg/linters/rbac/rules/crds.go +++ b/pkg/linters/rbac/rules/crds.go @@ -107,12 +107,16 @@ func moduleCRDs(modulePath string) ([]crdInfo, error) { } } - sort.Slice(out, func(i, j int) bool { + sort.SliceStable(out, func(i, j int) bool { if out[i].Group != out[j].Group { return out[i].Group < out[j].Group } - return out[i].Plural < out[j].Plural + if out[i].Plural != out[j].Plural { + return out[i].Plural < out[j].Plural + } + + return out[i].File < out[j].File }) return out, nil diff --git a/pkg/linters/rbac/rules/fixstate.go b/pkg/linters/rbac/rules/fixstate.go index 34663469..9e6c24da 100644 --- a/pkg/linters/rbac/rules/fixstate.go +++ b/pkg/linters/rbac/rules/fixstate.go @@ -19,6 +19,7 @@ package rules import ( "os" "path/filepath" + "regexp" "sort" "strings" "sync" @@ -182,5 +183,56 @@ func templateHasGate(modulePath, shortPath string) bool { return false } - return strings.Contains(string(content), rbaccontract.GateMarker) + return templateGated(string(content), "") +} + +// gateActionRe matches a template action that tests the platform version. A mention of the word +// in a comment or a value does not count. +var gateActionRe = regexp.MustCompile(`\{\{[^}]*deckhouseVersion`) + +// templateGated reports whether a template chooses between two role models by platform version: +// it calls the helper rbacv2-migrate-module.sh writes, or tests deckhouseVersion in an action that +// the declaration itself did not produce. A `when` on a declared resource may test the version too; +// that action appears in the produced content as well and is not a gate. +func templateGated(existing, produced string) bool { + if strings.Contains(existing, rbaccontract.GateMarker) { + return true + } + + return gateActionRe.MatchString(existing) && !gateActionRe.MatchString(produced) +} + +// writeFileAtomic writes content to path through a temporary file in the same directory and a +// rename, so an interrupted --fix never leaves rbac.yaml or a template truncated. +func writeFileAtomic(path string, content []byte, perm os.FileMode) error { + tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp") + if err != nil { + return err + } + + tmpName := tmp.Name() + + if _, err := tmp.Write(content); err != nil { + _ = tmp.Close() + _ = os.Remove(tmpName) + + return err + } + + if err := tmp.Close(); err != nil { + _ = os.Remove(tmpName) + return err + } + + if err := os.Chmod(tmpName, perm); err != nil { + _ = os.Remove(tmpName) + return err + } + + if err := os.Rename(tmpName, path); err != nil { + _ = os.Remove(tmpName) + return err + } + + return nil } diff --git a/pkg/linters/rbac/rules/generate/generate_test.go b/pkg/linters/rbac/rules/generate/generate_test.go index fb7d86ff..619bd816 100644 --- a/pkg/linters/rbac/rules/generate/generate_test.go +++ b/pkg/linters/rbac/rules/generate/generate_test.go @@ -190,7 +190,8 @@ func TestRender_GoldenAndIdempotent(t *testing.T) { } func TestParseHeader(t *testing.T) { - assert.True(t, func() bool { g, _ := ParseHeader(Header() + "\n---\n"); return g }()) + generatedByHeader, _ := ParseHeader(Header() + "\n---\n") + assert.True(t, generatedByHeader) generated, version := ParseHeader("# Generated by dmt (rbac/sync) from rbac.yaml, contract 0. Edit rbac.yaml and run \"dmt lint --linter rbac --fix\", or remove this line to maintain the file by hand.\n") assert.True(t, generated) @@ -297,6 +298,41 @@ func TestBuild_RefusesWhatTheModuleCannotCarry(t *testing.T) { }) } +func TestYAMLScalar(t *testing.T) { + for in, want := range map[string]string{ + "d8:cert-manager:cainjector": "d8:cert-manager:cainjector", + "/metrics": "/metrics", + "*": "*", + "pods/log": "pods/log", + "cert-manager.io": "cert-manager.io", + "": `""`, + "no": `"no"`, + "Yes": `"Yes"`, + "off": `"off"`, + "null": `"null"`, + "123": `"123"`, + "foo:": `"foo:"`, + "a: b": `"a: b"`, + "-lead": `"-lead"`, + "a #b": `"a #b"`, + } { + assert.Equal(t, want, yamlScalar(in), "input %q", in) + } +} + +// Two declared roles whose names differ only by the separator produce one binding name; the +// model refuses instead of writing a file with two objects of one name. +func TestBuild_RefusesDuplicateGeneratedNames(t *testing.T) { + decl := &rbacyaml.Declaration{APIVersion: rbacyaml.APIVersionV1Alpha1, + Resources: []rbacyaml.Resource{{Group: "x.io", Resource: "things", Scope: "Cluster", System: map[string][]string{"viewer": {"get"}}}}, + ServiceAccounts: []rbacyaml.ServiceAccount{{Name: "worker", BindClusterRoles: []string{"d8:a:b", "d8:a-b"}}}, + } + + _, err := Build(Input{Module: "m", Namespace: "d8-m", Subsystems: []string{"security"}, Decl: decl}) + require.Error(t, err) + assert.Contains(t, err.Error(), "would hold two objects named ClusterRoleBinding/d8:m:worker:a-b") +} + // A generated file that acquired CRLF line endings is still the generator's. func TestParseHeader_CRLF(t *testing.T) { generated, version := ParseHeader(Header() + "\r\n---\r\n") diff --git a/pkg/linters/rbac/rules/generate/model.go b/pkg/linters/rbac/rules/generate/model.go index 972c24fc..3e7f17e5 100644 --- a/pkg/linters/rbac/rules/generate/model.go +++ b/pkg/linters/rbac/rules/generate/model.go @@ -194,6 +194,16 @@ func Build(in Input) (*Model, error) { // A marker past 63 characters fails the contract; only a long module name can cause it. for _, f := range model.Files { + seen := make(map[string]struct{}, len(f.Objects)) + + for _, o := range f.Objects { + if _, dup := seen[o.Identity()]; dup { + return nil, fmt.Errorf("%s would hold two objects named %s: two declared roles or bindings map to the same generated name", f.Path, o.Identity()) + } + + seen[o.Identity()] = struct{}{} + } + for _, o := range f.Objects { if marker := o.Labels[rbaccontract.LabelCapability]; len(marker) > 63 { return nil, fmt.Errorf("capability marker %q is %d characters, a label value holds 63: the module name and the level name together are too long for %s", marker, len(marker), o.Name) diff --git a/pkg/linters/rbac/rules/generate/render.go b/pkg/linters/rbac/rules/generate/render.go index 9b71d024..b219ce4a 100644 --- a/pkg/linters/rbac/rules/generate/render.go +++ b/pkg/linters/rbac/rules/generate/render.go @@ -17,6 +17,7 @@ limitations under the License. package generate import ( + "regexp" "sort" "strconv" "strings" @@ -110,10 +111,10 @@ func renderObject(b *strings.Builder, o Object) { b.WriteString("apiVersion: " + apiVersion + "\n") b.WriteString("kind: " + o.Kind + "\n") b.WriteString("metadata:\n") - b.WriteString(" name: " + o.Name + "\n") + b.WriteString(" name: " + yamlScalar(o.Name) + "\n") if o.Namespace != "" { - b.WriteString(" namespace: " + o.Namespace + "\n") + b.WriteString(" namespace: " + yamlScalar(o.Namespace) + "\n") } b.WriteString(" " + labelsInclude(o.Labels) + "\n") @@ -134,15 +135,15 @@ func renderObject(b *strings.Builder, o Object) { case "ClusterRole", "Role": renderRules(b, o.Rules) case "ClusterRoleBinding", "RoleBinding": - b.WriteString("roleRef:\n apiGroup: rbac.authorization.k8s.io\n kind: " + o.RoleRefKind + "\n name: " + o.RoleRefName + "\n") + b.WriteString("roleRef:\n apiGroup: rbac.authorization.k8s.io\n kind: " + o.RoleRefKind + "\n name: " + yamlScalar(o.RoleRefName) + "\n") b.WriteString("subjects:\n") for _, s := range o.Subjects { switch s.Kind { case "ServiceAccount": - b.WriteString("- kind: ServiceAccount\n name: " + s.Name + "\n namespace: " + s.Namespace + "\n") + b.WriteString("- kind: ServiceAccount\n name: " + yamlScalar(s.Name) + "\n namespace: " + yamlScalar(s.Namespace) + "\n") default: - b.WriteString("- apiGroup: rbac.authorization.k8s.io\n kind: " + s.Kind + "\n name: " + s.Name + "\n") + b.WriteString("- apiGroup: rbac.authorization.k8s.io\n kind: " + s.Kind + "\n name: " + yamlScalar(s.Name) + "\n") } } } @@ -214,11 +215,23 @@ func renderRules(b *strings.Builder, rules []Rule) { // yamlScalar quotes the values YAML would otherwise misread: the empty core API group, the // wildcard, anything starting with an indicator character, and anything with a ": " or " #" inside. func yamlScalar(v string) string { - if v == "" || strings.ContainsAny(v[:1], "-?:,[]{}#&*!|>'\"%@`") || strings.Contains(v, ": ") || strings.Contains(v, " #") { - return strconv.Quote(v) + if plainScalarRe.MatchString(v) && !yaml11Reserved[strings.ToLower(v)] { + return v } - return v + return strconv.Quote(v) +} + +// plainScalarRe is the shape a value may take unquoted: a letter, underscore, slash or star, +// then the characters of a Kubernetes name, URL path, API group or verb list. Anything else -- an +// empty string, a leading indicator, a space or a digit first, a trailing colon -- is quoted. +var plainScalarRe = regexp.MustCompile(`^[A-Za-z_/][A-Za-z0-9._/:*-]*[A-Za-z0-9_/*]$|^[A-Za-z_*]$`) + +// yaml11Reserved lists the words Helm's YAML 1.1 reader turns into booleans or null; "no" as a +// resource name would render as false. +var yaml11Reserved = map[string]bool{ + "y": true, "yes": true, "n": true, "no": true, "true": true, "false": true, + "on": true, "off": true, "null": true, "~": true, } func sortedKeys(m map[string]string) []string { diff --git a/pkg/linters/rbac/rules/rbaccontract/contract.go b/pkg/linters/rbac/rules/rbaccontract/contract.go index 1ee7a320..a97e509c 100644 --- a/pkg/linters/rbac/rules/rbaccontract/contract.go +++ b/pkg/linters/rbac/rules/rbaccontract/contract.go @@ -97,7 +97,7 @@ var ( // ProjectLevels are the levels of the project lineage; a module capability never aggregates // there directly (project roles aggregate namespace roles), but the contract check on // platform roles needs the set. - ProjectLevels = NamespaceLevels + ProjectLevels = slices.Clone(NamespaceLevels) ) // ContractVersion is the version of the platform contract the generator writes templates for. It is diff --git a/pkg/linters/rbac/rules/rbacyaml/load_test.go b/pkg/linters/rbac/rules/rbacyaml/load_test.go index ccc6d610..7b52a956 100644 --- a/pkg/linters/rbac/rules/rbacyaml/load_test.go +++ b/pkg/linters/rbac/rules/rbacyaml/load_test.go @@ -441,6 +441,10 @@ func TestValidate_TopLevel(t *testing.T) { yaml: "apiVersion: rbac.deckhouse.io/v1alpha1\nserviceAccounts:\n - name: a\n - name: a\n", wantErr: "serviceAccounts[1] (a): duplicate name", }, + "serviceAccounts: resources without apiGroups": { + yaml: "apiVersion: rbac.deckhouse.io/v1alpha1\nserviceAccounts:\n - name: a\n clusterRules: [{resources: [pods], verbs: [get]}]\n", + wantErr: `serviceAccounts[0] (a).clusterRules[0]: resources require apiGroups; the core group is ""`, + }, "serviceAccounts: rule without verbs": { yaml: "apiVersion: rbac.deckhouse.io/v1alpha1\nserviceAccounts:\n - name: a\n clusterRules: [{apiGroups: [x], resources: [y]}]\n", wantErr: "serviceAccounts[0] (a).clusterRules[0]: verbs is required", diff --git a/pkg/linters/rbac/rules/rbacyaml/validate.go b/pkg/linters/rbac/rules/rbacyaml/validate.go index fd4135ba..b46410a8 100644 --- a/pkg/linters/rbac/rules/rbacyaml/validate.go +++ b/pkg/linters/rbac/rules/rbacyaml/validate.go @@ -19,6 +19,7 @@ package rbacyaml import ( "fmt" "slices" + "sort" "strings" "text/template" @@ -101,6 +102,9 @@ func Validate(d *Declaration, crds CRDScopes) []error { report("prometheusAccess: names no workload; remove the section or list deployments, daemonsets or statefulsets") } + // Several checks walk maps; the reader and the e2e expectations get one order. + sort.SliceStable(errs, func(i, j int) bool { return errs[i].Error() < errs[j].Error() }) + return errs } @@ -399,6 +403,10 @@ func validatePolicyRules(rules []PolicyRule, where string, report reporter) { if len(rule.NonResourceURLs) == 0 && len(rule.Resources) == 0 { report("%s[%d]: resources (with apiGroups) or nonResourceURLs is required", where, i) } + + if len(rule.Resources) > 0 && len(rule.APIGroups) == 0 { + report("%s[%d]: resources require apiGroups; the core group is \"\"", where, i) + } } } diff --git a/pkg/linters/rbac/rules/sync.go b/pkg/linters/rbac/rules/sync.go index e2d8fbe7..e7c8c367 100644 --- a/pkg/linters/rbac/rules/sync.go +++ b/pkg/linters/rbac/rules/sync.go @@ -159,7 +159,7 @@ func (r *SyncRule) Check(_ context.Context) { continue } - found := compareFile(file, actual, r.module.GetName()) + found := compareFile(r.enabledObjects(file), actual, r.module.GetName()) // The template renders the scheme before 1.78 where the declaration produces the new one: // the objects the declaration names cannot be there. Say so once instead of listing them. @@ -186,6 +186,12 @@ func (r *SyncRule) Check(_ context.Context) { continue } + // exclude-rules.sync silences the finding, not the object: an excluded object is still + // known to the rule, so a declared counterpart is not reported as absent either. + if !r.Enabled(obj.object.Unstructured.GetKind(), obj.object.Unstructured.GetName()) { + continue + } + divergences[obj.object.ShortPath()] = append(divergences[obj.object.ShortPath()], fmt.Sprintf("%s is in the render but rbac.yaml does not produce it: declare its rights in rbac.yaml or remove it from the template", identity)) } @@ -251,6 +257,10 @@ func (r *SyncRule) Check(_ context.Context) { // legacy section dropped, every namespace level gone -- is an orphan: the declaration wins, // and the fix deletes it, as long as it holds nothing but objects of the owned classes. if orphan, reason := r.orphanGeneratedFile(path, model); orphan { + // Another render variant may place an object in the file that this one does not see; + // the fix judges the union, as the regeneration does. + recordForeignObjects(filepath.Join(modulePath, path), nil) + fileList.WithFix(removeFileFix(modulePath, path, list)).Errorf("%s does not match %s: %s. The file carries the generator header and the declaration produces nothing for it; `%s` deletes it", path, rbacyaml.Filename, strings.Join(list, "; "), FixCommand) @@ -266,13 +276,16 @@ func (r *SyncRule) Check(_ context.Context) { // orphanGeneratedFile reports whether a template the declaration produces nothing for is the // generator's (header present) and holds only objects of the owned classes, so deleting it loses -// nothing the declaration does not know about. Otherwise it returns why the file stays. +// nothing the declaration does not know about. Otherwise it returns why the file stays. Objects +// outside the owned classes are recorded for the fix, which judges the union over render variants. func (r *SyncRule) orphanGeneratedFile(path string, model *generate.Model) (bool, string) { if model.File(path) != nil { return false, "" } - content, err := os.ReadFile(filepath.Join(r.module.GetPath(), path)) + fullPath := filepath.Join(r.module.GetPath(), path) + + content, err := os.ReadFile(fullPath) if err != nil { return false, "" } @@ -281,12 +294,14 @@ func (r *SyncRule) orphanGeneratedFile(path string, model *generate.Model) (bool return false, "" } - if strings.Contains(string(content), rbaccontract.GateMarker) { + if templateGated(string(content), "") { return false, "the file serves both role models behind the version gate" } managed := r.managedObjects(model) + var foreign []string + for index, object := range r.module.GetStorage() { if object.ShortPath() != path { continue @@ -299,20 +314,48 @@ func (r *SyncRule) orphanGeneratedFile(path string, model *generate.Model) (bool } if _, owned := managed[index.AsString()]; !owned { - return false, "the file also holds " + index.AsString() + ", which the declaration does not describe" + foreign = append(foreign, index.AsString()) } } + if len(foreign) > 0 { + sort.Strings(foreign) + recordForeignObjects(fullPath, foreign) + + return false, "the file also holds " + strings.Join(foreign, ", ") + ", which the declaration does not describe" + } + return true, "" } -// removeFileFix deletes an orphaned generated file and logs what went with it. +// removeFileFix deletes an orphaned generated file and logs what went with it. It re-reads the +// file when it runs and refuses when any render variant placed an object in it that the +// declaration does not describe, or when the header or the gate say the file is not the +// generator's to delete. func removeFileFix(modulePath, path string, removed []string) errors.AutofixFunc { fullPath := filepath.Join(modulePath, path) return func() error { return fixOnce(fullPath, func() error { - if err := os.Remove(fullPath); err != nil && !stderrors.Is(err, os.ErrNotExist) { + if foreign := foreignObjectsOf(fullPath); len(foreign) > 0 { + return fmt.Errorf("%s also holds objects the declaration does not describe (%s), some only under other values; it is not deleted -- declare them in %s or move them to another template", + path, strings.Join(foreign, ", "), rbacyaml.Filename) + } + + content, err := os.ReadFile(fullPath) + if err != nil { + if stderrors.Is(err, os.ErrNotExist) { + return nil + } + + return fmt.Errorf("read %s: %w", path, err) + } + + if generated, _ := generate.ParseHeader(string(content)); !generated || templateGated(string(content), "") { + return fmt.Errorf("%s is not the generator's to delete any more (no header, or the version gate); remove it by hand if that is the intent", path) + } + + if err := os.Remove(fullPath); err != nil { return fmt.Errorf("delete %s: %w", path, err) } @@ -324,6 +367,22 @@ func removeFileFix(modulePath, path string, removed []string) errors.AutofixFunc } } +// enabledObjects returns the file with the objects exclude-rules.sync names left out: they are +// neither compared nor reported as absent. +func (r *SyncRule) enabledObjects(file generate.File) generate.File { + kept := make([]generate.Object, 0, len(file.Objects)) + + for _, o := range file.Objects { + if r.Enabled(o.Kind, o.Name) { + kept = append(kept, o) + } + } + + file.Objects = kept + + return file +} + // legacyFiles maps the templates that rendered an object of the scheme before 1.78 to its kind. // Those objects belong to no class the declaration produces; they are the module's old model. func (r *SyncRule) legacyFiles() map[string]string { @@ -378,7 +437,7 @@ func (r *SyncRule) foreignObjects(file generate.File, model *generate.Model) []s // An object the generator produces under another name -- a binding with the same roleRef // and subjects, a role with the same rules -- is replaced, not lost; the declaration carries // its rights on. Only what has no counterpart is foreign. - if replacedByProduced(object, file.Objects) { + if replacedByProduced(object, file.Objects, renderedRolesOf(r.module.GetStorage(), file.Path)) { continue } @@ -397,7 +456,7 @@ func (r *SyncRule) foreignObjects(file generate.File, model *generate.Model) []s // replacedByProduced reports whether a rendered object has a produced counterpart of the same kind // and content under another name. -func replacedByProduced(object storage.StoreObject, produced []generate.Object) bool { +func replacedByProduced(object storage.StoreObject, produced []generate.Object, renderedRoles map[string]tupleSet) bool { content := object.Unstructured.UnstructuredContent() switch object.Unstructured.GetKind() { @@ -414,7 +473,7 @@ func replacedByProduced(object storage.StoreObject, produced []generate.Object) continue } - if roleRefMatches(o.RoleRefName, binding.RoleRef.Name, produced) && subjectSetOf(o.Subjects) == got { + if roleRefMatches(o, binding.RoleRef, produced, renderedRoles) && subjectSetOf(o.Subjects) == got { return true } } @@ -445,24 +504,55 @@ func replacedByProduced(object storage.StoreObject, produced []generate.Object) return false } -// roleRefMatches accepts the produced roleRef itself or the rendered role it replaces by content. -func roleRefMatches(producedRef, renderedRef string, produced []generate.Object) bool { - if producedRef == renderedRef { +// renderedRolesOf collects the rules of every role rendered from the file, by kind and name, for +// the rename check of bindings. +func renderedRolesOf(objects map[storage.ResourceIndex]storage.StoreObject, path string) map[string]tupleSet { + out := map[string]tupleSet{} + + for _, object := range objects { + if object.ShortPath() != path { + continue + } + + kind := object.Unstructured.GetKind() + if kind != "ClusterRole" && kind != "Role" { + continue + } + + role := new(rbacv1.ClusterRole) + if runtime.DefaultUnstructuredConverter.FromUnstructured(object.Unstructured.UnstructuredContent(), role) == nil { + out[kind+"/"+object.Unstructured.GetName()] = expandRenderedRules(role.Rules) + } + } + + return out +} + +// roleRefMatches accepts the produced roleRef itself, or the role the rendered binding pointed at +// when that role is rendered in the same file and the produced role carries exactly its rules: a +// renamed pair. A binding to anything else -- cluster-admin, a role of another file -- is not a +// rename, whatever its subjects, and stays a foreign object. +func roleRefMatches(producedBinding generate.Object, renderedRef rbacv1.RoleRef, produced []generate.Object, renderedRoles map[string]tupleSet) bool { + if producedBinding.RoleRefName == renderedRef.Name { return true } - // The rendered binding pointed at a role the generator now produces under producedRef: accept - // when producedRef is a produced role and renderedRef is not. - for _, o := range produced { - if (o.Kind == "ClusterRole" || o.Kind == "Role") && o.Name == renderedRef { - return false - } + rendered, ok := renderedRoles[renderedRef.Kind+"/"+renderedRef.Name] + if !ok { + return false } for _, o := range produced { - if (o.Kind == "ClusterRole" || o.Kind == "Role") && o.Name == producedRef { - return true + if o.Kind != renderedRef.Kind || o.Name != producedBinding.RoleRefName { + continue + } + + always, conditional := expandModelRules(o.Rules) + for t := range conditional { + always.add(t) } + + return len(always.minus(rendered)) == 0 && len(rendered.minus(always)) == 0 } return false @@ -509,10 +599,6 @@ func (r *SyncRule) managedObjects(model *generate.Model) map[string]managedObjec out := map[string]managedObject{} for index, object := range r.module.GetStorage() { - if !r.Enabled(object.Unstructured.GetKind(), object.Unstructured.GetName()) { - continue - } - identity := index.AsString() labels := object.Unstructured.GetLabels() annotations := object.Unstructured.GetAnnotations() @@ -615,6 +701,20 @@ func compareObject(expected generate.Object, actual storage.StoreObject, module if expected.Class == generate.ClassCapability { out = append(out, compareCapabilityLabels(expected, actual, module, aggregation)...) } + case "ServiceAccount": + sa := new(corev1.ServiceAccount) + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(content, sa); err != nil { + return []string{fmt.Sprintf("%s cannot be read as a ServiceAccount: %v", id, err)} + } + + // Kubernetes mounts the token unless told otherwise; the generator writes what the + // declaration says, false by default. A regeneration must not take a token away unnoticed. + rendered := sa.AutomountServiceAccountToken == nil || *sa.AutomountServiceAccountToken + declared := expected.AutomountToken != nil && *expected.AutomountToken + + if rendered != declared { + out = append(out, fmt.Sprintf("%s: automountServiceAccountToken is %t in the render, the declaration produces %t", id, rendered, declared)) + } case "ClusterRoleBinding", "RoleBinding": var roleRef rbacv1.RoleRef @@ -779,14 +879,14 @@ func regenerateFix(modulePath string, file generate.File, foreign, removals []st file.Path, strings.Join(foreign, ", "), rbacyaml.Filename, FixCommand) } - if strings.Contains(string(existing), rbaccontract.GateMarker) || strings.Contains(string(existing), "deckhouseVersion") { - return fmt.Errorf("%s renders one of two role models depending on the platform version (the %s gate of rbacv2-migrate-module.sh); regenerating it would drop the legacy branch -- edit the new branch by hand, or drop the gate and the legacy object once clusters below DKP 1.78 are no longer served, then run `%s`", + if templateGated(string(existing), content) { + return fmt.Errorf("%s renders one of two role models depending on the platform version (the %s gate of rbacv2-migrate-module.sh, or a deckhouseVersion test the declaration did not produce); regenerating it would drop the legacy branch -- edit the new branch by hand, or drop the gate and the legacy object once clusters below DKP 1.78 are no longer served, then run `%s`", file.Path, rbaccontract.GateMarker, FixCommand) } if generated, _ := generate.ParseHeader(string(existing)); !generated { aside := asidePath(fullPath) - if err := os.WriteFile(aside, []byte(content), 0o600); err != nil { + if err := writeFileAtomic(aside, []byte(content), 0o644); err != nil { //nolint:gosec // a source file of the module return fmt.Errorf("write %s: %w", asidePath(file.Path), err) } @@ -808,7 +908,7 @@ func regenerateFix(modulePath string, file generate.File, foreign, removals []st perm = info.Mode().Perm() } - if err := os.WriteFile(fullPath, []byte(content), perm); err != nil { + if err := writeFileAtomic(fullPath, []byte(content), perm); err != nil { return err } @@ -893,7 +993,7 @@ func (r *SyncRule) bootstrap(declList *errors.LintRuleErrorsList) { return fmt.Errorf("render %s: %w", rbacyaml.Filename, err) } - return os.WriteFile(path, content, 0o644) //nolint:gosec // a source file of the module + return writeFileAtomic(path, content, 0o644) //nolint:gosec // a source file of the module }) }).Errorf("%s is missing: `%s` writes it from the RBAC objects the module renders today (%d of %d objects described, the rest listed in the file as hand-written); every TODO and note in it is a decision for a person before the templates are regenerated from it", rbacyaml.Filename, FixCommand, described, len(in.Objects)) diff --git a/pkg/linters/rbac/rules/sync_test.go b/pkg/linters/rbac/rules/sync_test.go index 72109d13..af81f372 100644 --- a/pkg/linters/rbac/rules/sync_test.go +++ b/pkg/linters/rbac/rules/sync_test.go @@ -34,6 +34,7 @@ import ( "github.com/deckhouse/dmt/internal/mocks" "github.com/deckhouse/dmt/internal/storage" + "github.com/deckhouse/dmt/pkg" "github.com/deckhouse/dmt/pkg/errors" "github.com/deckhouse/dmt/pkg/linters/rbac/rules/generate" "github.com/deckhouse/dmt/pkg/linters/rbac/rules/rbacyaml" @@ -151,7 +152,7 @@ func writeGenerated(t *testing.T, modulePath string, model *generate.Model) { } } -func runSync(t *testing.T, modulePath string, store *storage.UnstructuredObjectStore) *errors.LintRuleErrorsList { +func runSync(t *testing.T, modulePath string, store *storage.UnstructuredObjectStore, excludes ...pkg.KindRuleExclude) *errors.LintRuleErrorsList { t.Helper() m := mocks.NewModuleMock(minimock.NewController(t)) @@ -162,7 +163,7 @@ func runSync(t *testing.T, modulePath string, store *storage.UnstructuredObjectS m.GetStorageMock.Optional().Return(store.Storage) errorList := errors.NewLintRuleErrorsList() - NewSyncRule(nil, m, errorList).Check(context.Background()) + NewSyncRule(excludes, m, errorList).Check(context.Background()) return errorList } @@ -384,13 +385,15 @@ func TestSync_Autofix(t *testing.T) { // Running the same fix again, in a new run, changes nothing. resetFixState() - before, _ := os.Stat(filepath.Join(modulePath, "templates/rbacv2/use/view.yaml")) - for _, fix := range runSync(t, modulePath, store).GetFixes() { + list := runSync(t, modulePath, store) + for _, fix := range list.GetFixes() { fix() } - after, _ := os.Stat(filepath.Join(modulePath, "templates/rbacv2/use/view.yaml")) - assert.Equal(t, before.ModTime(), after.ModTime()) + after, err := os.ReadFile(filepath.Join(modulePath, "templates/rbacv2/use/view.yaml")) + require.NoError(t, err) + assert.Equal(t, string(written), string(after)) + assert.Empty(t, list.GetErrors(), "a no-op run reports no fix error") }) t.Run("the declaration wins: a right it does not name leaves the template", func(t *testing.T) { @@ -636,7 +639,7 @@ func TestSync_GatedTemplateIsNotRegenerated(t *testing.T) { remaining := errorList.GetErrors() require.Len(t, remaining, 1) require.Error(t, remaining[0].FixError) - assert.Contains(t, remaining[0].FixError.Error(), "renders one of two role models depending on the platform version (the rbacv2_new_scheme gate of rbacv2-migrate-module.sh); regenerating it would drop the legacy branch") + assert.Contains(t, remaining[0].FixError.Error(), "renders one of two role models depending on the platform version (the rbacv2_new_scheme gate of rbacv2-migrate-module.sh, or a deckhouseVersion test the declaration did not produce); regenerating it would drop the legacy branch") unchanged, err := os.ReadFile(path) require.NoError(t, err) @@ -967,3 +970,226 @@ func TestSync_OrphanGeneratedFileIsDeleted(t *testing.T) { assert.Contains(t, got[0], "the file also holds ClusterRole/d8:cert-manager:something-else, which the declaration does not describe. Only a person can close this") assert.Empty(t, errorList.GetFixes()) } + +// exclude-rules.sync silences an object's findings without forgetting the object: a declared +// object that is excluded is neither compared nor reported as absent. +func TestSync_ExcludedObjectIsSilentButKnown(t *testing.T) { + resetFixState() + t.Cleanup(resetFixState) + + modulePath := syncModuleDir(t) + model := syncModel(t, modulePath) + writeGenerated(t, modulePath, model) + + const capability = "d8:namespace-capability:cert-manager:view" + + exclude := pkg.KindRuleExclude{Kind: "ClusterRole", Name: capability} + + // The capability rendered with a rule the declaration does not name. + store := renderedFrom(t, model, func(o *generate.Object) bool { + if o.Kind == "ClusterRole" && o.Name == capability { + o.Rules = append(o.Rules, generate.Rule{PolicyRule: rbacyaml.PolicyRule{APIGroups: []string{""}, Resources: []string{"pods"}, Verbs: []string{"get"}}}) + } + + return true + }) + got := texts(runSync(t, modulePath, store, exclude)) + assert.Empty(t, got, "got: %v", got) + + // The capability is not rendered at all. + store = renderedFrom(t, model, func(o *generate.Object) bool { return o.Name != capability }) + got = texts(runSync(t, modulePath, store, exclude)) + assert.Empty(t, got, "got: %v", got) + + // Without the exclusion the same render is a finding. + got = texts(runSync(t, modulePath, store)) + require.Len(t, got, 1, "got: %v", got) + assert.Contains(t, got[0], "is declared but absent from the render") +} + +// A ServiceAccount is compared like every other declared object: a token the render mounts but +// the declaration does not is a divergence, since the regeneration would take it away. +func TestSync_ServiceAccountAutomountIsCompared(t *testing.T) { + resetFixState() + t.Cleanup(resetFixState) + + modulePath := syncModuleDir(t) + model := syncModel(t, modulePath) + writeGenerated(t, modulePath, model) + + yes := true + store := renderedFrom(t, model, func(o *generate.Object) bool { + if o.Kind == "ServiceAccount" { + o.AutomountToken = &yes + } + + return true + }) + + got := texts(runSync(t, modulePath, store)) + require.Len(t, got, 1, "got: %v", got) + assert.Contains(t, got[0], "ServiceAccount/cainjector: automountServiceAccountToken is true in the render, the declaration produces false") +} + +// A rendered binding under another name is a rename only when it points at the role the produced +// binding replaces. One that binds the same subjects to cluster-admin is a foreign object, and +// the file it lives in is not regenerated. +func TestSync_BindingToAnotherRoleIsNotARename(t *testing.T) { + resetFixState() + t.Cleanup(resetFixState) + + modulePath := syncModuleDir(t) + model := syncModel(t, modulePath) + writeGenerated(t, modulePath, model) + + const rel = "templates/rbac-to-us.yaml" + + store := renderedFrom(t, model, func(o *generate.Object) bool { + if o.Kind == "RoleBinding" && o.Name == "access-to-cert-manager" { + o.Name = "access-to-cert-manager-prometheus-metrics" + o.RoleRefKind = "ClusterRole" + o.RoleRefName = "cluster-admin" + } + + return true + }) + + before, err := os.ReadFile(filepath.Join(modulePath, rel)) + require.NoError(t, err) + + errorList := runSync(t, modulePath, store) + for _, fix := range errorList.GetFixes() { + fix() + } + + remaining := errorList.GetErrors() + require.Len(t, remaining, 1, "got: %v", texts(errorList)) + require.Error(t, remaining[0].FixError) + assert.Contains(t, remaining[0].FixError.Error(), "also holds objects the declaration does not produce: d8-cert-manager/RoleBinding/access-to-cert-manager-prometheus-metrics") + + after, err := os.ReadFile(filepath.Join(modulePath, rel)) + require.NoError(t, err) + assert.Equal(t, string(before), string(after), "the file is left alone") +} + +// The orphan fix judges the union of render variants: an object another variant placed in the +// file keeps the file, and a header that vanished between lint and fix keeps it too. +func TestSync_OrphanDeletionRefusedByOtherVariantsAndByHand(t *testing.T) { + resetFixState() + t.Cleanup(resetFixState) + + modulePath := syncModuleDir(t) + model := syncModel(t, modulePath) + writeGenerated(t, modulePath, model) + store := renderedFrom(t, model, nil) + + decl, err := rbacyaml.Load(modulePath) + require.NoError(t, err) + + for i := range decl.Resources { + decl.Resources[i].Legacy = nil + } + + raw, err := yaml.Marshal(decl) + require.NoError(t, err) + require.NoError(t, os.WriteFile(rbacyaml.Path(modulePath), raw, 0o600)) + + const rel = "templates/user-authz-cluster-roles.yaml" + + fullPath := filepath.Join(modulePath, rel) + + t.Run("another variant holds a foreign object", func(t *testing.T) { + errorList := runSync(t, modulePath, store) + fixes := errorList.GetFixes() + require.Len(t, fixes, 1) + + // What the run under other values found in the same file. + recordForeignObjects(fullPath, []string{"ClusterRole/d8:cert-manager:only-under-other-values"}) + + fixes[0]() + + remaining := errorList.GetErrors() + require.Len(t, remaining, 1) + require.Error(t, remaining[0].FixError) + assert.Contains(t, remaining[0].FixError.Error(), "also holds objects the declaration does not describe (ClusterRole/d8:cert-manager:only-under-other-values), some only under other values; it is not deleted") + + _, err := os.Stat(fullPath) + require.NoError(t, err, "the file stays") + }) + + t.Run("the header left the file before the fix ran", func(t *testing.T) { + resetFixState() + + errorList := runSync(t, modulePath, store) + fixes := errorList.GetFixes() + require.Len(t, fixes, 1) + + content, err := os.ReadFile(fullPath) + require.NoError(t, err) + + _, rest, _ := strings.Cut(string(content), "\n") + require.NoError(t, os.WriteFile(fullPath, []byte(rest), 0o600)) + + fixes[0]() + + remaining := errorList.GetErrors() + require.Len(t, remaining, 1) + require.Error(t, remaining[0].FixError) + assert.Contains(t, remaining[0].FixError.Error(), "is not the generator's to delete any more") + + _, err = os.Stat(fullPath) + require.NoError(t, err, "the file stays") + }) +} + +// A `when` that tests the platform version is the declaration's own; the produced file carries +// the same action, so it is not mistaken for the migration gate and is regenerated. +func TestSync_WhenOnDeckhouseVersionIsNotAGate(t *testing.T) { + resetFixState() + t.Cleanup(resetFixState) + + modulePath := syncModuleDir(t) + + decl, err := rbacyaml.Load(modulePath) + require.NoError(t, err) + + decl.Resources[0].When = `semverCompare ">= 1.80" .Values.global.deckhouseVersion` + + raw, err := yaml.Marshal(decl) + require.NoError(t, err) + require.NoError(t, os.WriteFile(rbacyaml.Path(modulePath), raw, 0o600)) + + model := syncModel(t, modulePath) + writeGenerated(t, modulePath, model) + + var rel string + + for _, f := range model.Files { + if strings.Contains(generate.RenderFile(f), "deckhouseVersion") { + rel = f.Path + } + } + + require.NotEmpty(t, rel, "a file renders the version test") + + // The file is stale: a comment was appended by hand. + fullPath := filepath.Join(modulePath, rel) + content, err := os.ReadFile(fullPath) + require.NoError(t, err) + require.NoError(t, os.WriteFile(fullPath, append(content, []byte("# a stray edit\n")...), 0o600)) + + errorList := runSync(t, modulePath, renderedFrom(t, model, nil)) + got := texts(errorList) + require.Len(t, got, 1, "got: %v", got) + assert.Contains(t, got[0], "does not match rbac.yaml") + + for _, fix := range errorList.GetFixes() { + fix() + } + + assert.Empty(t, errorList.GetErrors(), "the file is regenerated, not mistaken for a gated template") + + after, err := os.ReadFile(fullPath) + require.NoError(t, err) + assert.Equal(t, generate.RenderFile(*model.File(rel)), string(after)) +} diff --git a/test/e2e/README.md b/test/e2e/README.md index 73442067..d209e130 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -117,6 +117,10 @@ go test ./test/e2e/ -run 'TestE2E//' -v | `rbac/sync-clean` | rbac linter `sync` (templates generated from rbac.yaml render exactly the declaration; the generated files also pass placement, contract and coverage; renders `helm_lib_module_labels` from the vendored `deckhouse_lib_helm` chart) | | `rbac/sync-hand-edited` | rbac linter `sync` (a rule added by hand to a generated capability, and a legacy role the declaration does not produce -- one finding per template) | | `rbac/sync-fix-regenerates` | rbac linter `sync` with `--fix` (a missing generated capability file is written from rbac.yaml and the finding is resolved) | +| `rbac/bootstrap-writes-declaration` | rbac linter `sync` with `--fix` on a module without rbac.yaml (the first declaration is written from the render) | +| `rbac/scheme-legacy-only` | rbac linter `contract` on a module with the pre-1.78 use/manage scheme only (one finding naming the migration script) | +| `rbac/scheme-legacy-with-declaration` | rbac linters `contract` and `sync` on a module that has rbac.yaml and still renders the legacy scheme (the legacy files are named) | +| `rbac/scheme-dual` | rbac linters on a module whose templates carry both schemes behind the version gate of `rbacv2-migrate-module.sh` (silent; the gated files are not regenerated) | | `hooks/ingress` | hooks linter (Ingress without copy_custom_certificate hook) | | `openapi/bilingual` | openapi linter (missing doc-ru- translation, missing CRD module label) | | `images/werf` | images linter (werf fromImage not under base/) | From 3d5b54e60a8c67770a30a15088fb1787fa399f61 Mon Sep 17 00:00:00 2001 From: Ivan Zvyagintsev Date: Tue, 22 Sep 2026 16:51:02 +0300 Subject: [PATCH 30/58] rbac: review leftovers -- config keys, shared helpers, Check split, nolintlint - The rbac config blocks are checked down to a rule's impact and the kind/name of an exclusion entry, and every unknown key is reported in one error. - global.RbacLinterConfig/RbacRules follow the RBAC initialism of pkg/config. - BindingSuffix and LevelOfAction live in rbaccontract; bootstrap and generate share them. ResourceVerbs is the verb list without the wildcard. - sortedKeys, indexByte, IsSubresource and FullName use the standard library. - SyncRule.Check is three steps: compareRender, compareText, report. - fixOnce holds its own lock while the fix runs; the records keep theirs. - legacyFiles names one kind per file whatever order the storage yields. - The bootstrap builder has one blank line between methods; a redundant condition is gone; fmt.Errorf without a format is errors.New. - no-cyrillic skips the module's own rbac.yaml only, not every file of that name. - nolintlint requires a named linter and a reason on every directive; the unused check stays off because it depends on the golangci-lint version. - rbaccontract has unit tests; the loader tests cover the nested keys. Signed-off-by: Ivan Zvyagintsev --- .golangci.yml | 7 ++ internal/metrics/metrics_test.go | 2 +- internal/modules/rbac_rules_config_test.go | 4 +- pkg/config/global/global.go | 10 +-- pkg/config/loader.go | 85 ++++++++++++------- pkg/config/rbac_keys_test.go | 19 +++++ pkg/linters/no-cyrillic/rules/files.go | 4 +- pkg/linters/rbac/README.md | 2 +- pkg/linters/rbac/rules/bootstrap/bootstrap.go | 25 +++--- pkg/linters/rbac/rules/contract.go | 19 ++--- pkg/linters/rbac/rules/contract_test.go | 6 +- pkg/linters/rbac/rules/fixstate.go | 34 +++++--- pkg/linters/rbac/rules/generate/model.go | 15 ++-- pkg/linters/rbac/rules/generate/render.go | 18 +--- .../rbac/rules/rbaccontract/contract.go | 30 ++++++- .../rbac/rules/rbaccontract/contract_test.go | 79 +++++++++++++++++ pkg/linters/rbac/rules/rbacyaml/scopes.go | 14 +-- pkg/linters/rbac/rules/rbacyaml/types.go | 12 +-- pkg/linters/rbac/rules/rbacyaml/validate.go | 19 +++-- pkg/linters/rbac/rules/sync.go | 47 +++++++--- 20 files changed, 300 insertions(+), 151 deletions(-) create mode 100644 pkg/linters/rbac/rules/rbaccontract/contract_test.go diff --git a/.golangci.yml b/.golangci.yml index 7c829664..2aaaf0ed 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -10,6 +10,7 @@ linters: - ineffassign - misspell - musttag + - nolintlint - nonamedreturns - prealloc - revive @@ -22,6 +23,12 @@ linters: - whitespace - wsl_v5 settings: + nolintlint: + # Whether a directive is still needed depends on the golangci-lint version (CI and + # developers differ); what every directive must have is a named linter and a reason. + allow-unused: true + require-explanation: true + require-specific: true depguard: rules: logger: diff --git a/internal/metrics/metrics_test.go b/internal/metrics/metrics_test.go index f39dfa41..95017959 100644 --- a/internal/metrics/metrics_test.go +++ b/internal/metrics/metrics_test.go @@ -22,7 +22,7 @@ func Test_SetLinterWarningsMetrics_AddsWarningsForAllLinters(t *testing.T) { Module: global.ModuleLinterConfig{}, NoCyrillic: global.LinterConfig{Impact: pkg.Warn.String()}, OpenAPI: global.OpenAPILinterConfig{LinterConfig: global.LinterConfig{Impact: pkg.Warn.String()}}, - Rbac: global.RbacLinterConfig{LinterConfig: global.LinterConfig{Impact: pkg.Warn.String()}}, + Rbac: global.RBACLinterConfig{LinterConfig: global.LinterConfig{Impact: pkg.Warn.String()}}, Templates: global.TemplatesLinterConfig{}, Documentation: global.DocumentationLinterConfig{}, }, diff --git a/internal/modules/rbac_rules_config_test.go b/internal/modules/rbac_rules_config_test.go index 8103de33..abb861ea 100644 --- a/internal/modules/rbac_rules_config_test.go +++ b/internal/modules/rbac_rules_config_test.go @@ -32,9 +32,9 @@ func TestRemapLinterSettings_RBACDeclarationRules(t *testing.T) { t.Run("per-rule levels from the root configuration, warn as the fallback", func(t *testing.T) { settings := remapLinterSettings( &config.LintersSettings{Rbac: config.RbacSettings{Impact: pkg.Error.String()}}, - &global.Linters{Rbac: global.RbacLinterConfig{ + &global.Linters{Rbac: global.RBACLinterConfig{ LinterConfig: global.LinterConfig{Impact: pkg.Error.String()}, - Rules: global.RbacRules{ + Rules: global.RBACRules{ CoverageRule: global.RuleConfig{Impact: pkg.Warn.String()}, SyncRule: global.RuleConfig{Impact: pkg.Ignored.String()}, }, diff --git a/pkg/config/global/global.go b/pkg/config/global/global.go index 0e372a65..e79f3649 100644 --- a/pkg/config/global/global.go +++ b/pkg/config/global/global.go @@ -30,7 +30,7 @@ type Linters struct { Module ModuleLinterConfig `mapstructure:"module"` NoCyrillic LinterConfig `mapstructure:"no-cyrillic"` OpenAPI OpenAPILinterConfig `mapstructure:"openapi"` - Rbac RbacLinterConfig `mapstructure:"rbac"` + Rbac RBACLinterConfig `mapstructure:"rbac"` Templates TemplatesLinterConfig `mapstructure:"templates"` Documentation DocumentationLinterConfig `mapstructure:"documentation"` } @@ -71,16 +71,16 @@ type ContainerRules struct { SysCgroupMountRule RuleConfig `mapstructure:"sys-cgroup-mount"` } -// RbacLinterConfig carries the linter-level impact of rbac and the per-rule impacts of the rules +// RBACLinterConfig carries the linter-level impact of rbac and the per-rule impacts of the rules // added for the module RBAC declaration. The four original rules (user-authz, binding-subject, // placement, wildcards) have never had per-rule levels and keep the linter's: wiring them up // would change the severity of existing findings. -type RbacLinterConfig struct { +type RBACLinterConfig struct { LinterConfig `mapstructure:",squash"` - Rules RbacRules `mapstructure:"rules"` + Rules RBACRules `mapstructure:"rules"` } -type RbacRules struct { +type RBACRules struct { CoverageRule RuleConfig `mapstructure:"coverage"` SyncRule RuleConfig `mapstructure:"sync"` ContractRule RuleConfig `mapstructure:"contract"` diff --git a/pkg/config/loader.go b/pkg/config/loader.go index 6019d44c..3d53458b 100644 --- a/pkg/config/loader.go +++ b/pkg/config/loader.go @@ -20,6 +20,7 @@ import ( "errors" "fmt" "log/slog" + "maps" "os" "path/filepath" "slices" @@ -155,60 +156,84 @@ func (l *Loader) parseConfig() error { return validateRbacKeys(l.viper) } -// rbacKnownKeys lists the keys the rbac blocks accept. viper drops an unknown key without a word, -// and for these blocks silence is expensive: a misspelled per-rule level or exclusion would leave -// a rule at full strength -- or off -- with nobody noticing. Only the rbac blocks are held to +// rbacKnownKeys lists the keys the rbac blocks accept, by path. viper drops an unknown key without +// a word, and for these blocks silence is expensive: a misspelled per-rule level or exclusion would +// leave a rule at full strength -- or off -- with nobody noticing. Only the rbac blocks are held to // this; the other linters keep viper's lenient behaviour. var rbacKnownKeys = map[string]map[string]struct{}{ - "global.linters-settings.rbac": {"impact": {}, "rules": {}}, - "global.linters-settings.rbac.rules": {"coverage": {}, "sync": {}, "contract": {}}, - "linters-settings.rbac": {"impact": {}, "exclude-rules": {}}, + "global.linters-settings.rbac": {"impact": {}, "rules": {}}, + "global.linters-settings.rbac.rules": {"coverage": {}, "sync": {}, "contract": {}}, + "global.linters-settings.rbac.rules.coverage": {"impact": {}}, + "global.linters-settings.rbac.rules.sync": {"impact": {}}, + "global.linters-settings.rbac.rules.contract": {"impact": {}}, + "linters-settings.rbac": {"impact": {}, "exclude-rules": {}}, "linters-settings.rbac.exclude-rules": { "binding-subject": {}, "placement": {}, "wildcards": {}, "coverage": {}, "contract": {}, "sync": {}, }, } +// rbacKnownListKeys lists the exclusion lists whose entries are kind/name pairs; the other lists +// hold plain strings and have no keys to misspell. +var rbacKnownListKeys = map[string]map[string]struct{}{ + "linters-settings.rbac.exclude-rules.placement": {"kind": {}, "name": {}}, + "linters-settings.rbac.exclude-rules.wildcards": {"kind": {}, "name": {}}, + "linters-settings.rbac.exclude-rules.contract": {"kind": {}, "name": {}}, + "linters-settings.rbac.exclude-rules.sync": {"kind": {}, "name": {}}, +} + +// validateRbacKeys reports every unknown key of the rbac blocks in one error, so that a config +// with several misspellings is fixed in one round. func validateRbacKeys(v *viper.Viper) error { - paths := make([]string, 0, len(rbacKnownKeys)) - for path := range rbacKnownKeys { - paths = append(paths, path) - } + var problems []string - sort.Strings(paths) + for _, path := range slices.Sorted(maps.Keys(rbacKnownKeys)) { + if block, ok := v.Get(path).(map[string]any); ok { + problems = append(problems, unknownKeys(block, rbacKnownKeys[path], path)...) + } + } - for _, path := range paths { - block, ok := v.Get(path).(map[string]any) + for _, path := range slices.Sorted(maps.Keys(rbacKnownListKeys)) { + list, ok := v.Get(path).([]any) if !ok { continue } - keys := make([]string, 0, len(block)) - for key := range block { - if _, known := rbacKnownKeys[path][key]; !known { - keys = append(keys, key) + for i, item := range list { + entry, ok := item.(map[string]any) + if !ok { + problems = append(problems, fmt.Sprintf("entry %d under %q is not a kind/name pair", i, path)) + continue } - } - if len(keys) > 0 { - sort.Strings(keys) - - return fmt.Errorf("unknown key(s) %s under %q in %s: the accepted keys are %s", - strings.Join(keys, ", "), path, v.ConfigFileUsed(), strings.Join(sortedKeysOf(rbacKnownKeys[path]), ", ")) + problems = append(problems, unknownKeys(entry, rbacKnownListKeys[path], fmt.Sprintf("%s[%d]", path, i))...) } } - return nil + if len(problems) == 0 { + return nil + } + + return fmt.Errorf("%s in %s", strings.Join(problems, "; "), v.ConfigFileUsed()) } -func sortedKeysOf(m map[string]struct{}) []string { - out := make([]string, 0, len(m)) - for k := range m { - out = append(out, k) +// unknownKeys names the keys of block that known does not list, with the accepted ones. +func unknownKeys(block map[string]any, known map[string]struct{}, path string) []string { + var unknown []string + + for key := range block { + if _, ok := known[key]; !ok { + unknown = append(unknown, key) + } + } + + if len(unknown) == 0 { + return nil } - sort.Strings(out) + sort.Strings(unknown) - return out + return []string{fmt.Sprintf("unknown key(s) %s under %q: the accepted keys are %s", + strings.Join(unknown, ", "), path, strings.Join(slices.Sorted(maps.Keys(known)), ", "))} } func (l *Loader) setConfigDir() error { diff --git a/pkg/config/rbac_keys_test.go b/pkg/config/rbac_keys_test.go index 5324c7ff..7cd554d0 100644 --- a/pkg/config/rbac_keys_test.go +++ b/pkg/config/rbac_keys_test.go @@ -85,6 +85,25 @@ linters-settings: assert.Contains(t, err.Error(), `unknown key(s) coverage-rule under "linters-settings.rbac.exclude-rules"`) }) + t.Run("a misspelled impact of one rule is an error", func(t *testing.T) { + err := loadFrom(t, "global:\n linters-settings:\n rbac:\n rules:\n coverage: {impakt: warn}\n") + require.Error(t, err) + assert.Contains(t, err.Error(), `unknown key(s) impakt under "global.linters-settings.rbac.rules.coverage": the accepted keys are impact`) + }) + + t.Run("a misspelled key of an exclusion entry is an error", func(t *testing.T) { + err := loadFrom(t, "linters-settings:\n rbac:\n exclude-rules:\n contract:\n - kidn: ClusterRole\n name: x\n") + require.Error(t, err) + assert.Contains(t, err.Error(), `unknown key(s) kidn under "linters-settings.rbac.exclude-rules.contract[0]": the accepted keys are kind, name`) + }) + + t.Run("every problem is reported at once", func(t *testing.T) { + err := loadFrom(t, "global:\n linters-settings:\n rbac:\n rules:\n coverge: {impact: warn}\nlinters-settings:\n rbac:\n exclude-rules:\n sync: [just-a-string]\n") + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown key(s) coverge") + assert.Contains(t, err.Error(), `entry 0 under "linters-settings.rbac.exclude-rules.sync" is not a kind/name pair`) + }) + t.Run("other linters keep the lenient behaviour", func(t *testing.T) { require.NoError(t, loadFrom(t, "linters-settings:\n container:\n impakt: warn\n")) }) diff --git a/pkg/linters/no-cyrillic/rules/files.go b/pkg/linters/no-cyrillic/rules/files.go index 5c37dede..55c4626b 100644 --- a/pkg/linters/no-cyrillic/rules/files.go +++ b/pkg/linters/no-cyrillic/rules/files.go @@ -38,7 +38,7 @@ var ( // module.yaml and rbac.yaml carry the module's localized texts by design (descriptions.ru, the // ru titles and descriptions of capabilities the rbac declaration requires), so they are not judged. - skipDocRe = `doc-ru-.+\.y[a]?ml$|\.ru\.y[a]?ml$|\.ru\.json$|\.ru\.md$|\.ru\.html$|_RU\.md$|_ru\.html$|docs/site/_.+|docs/documentation/_.+|tools/spelling/.+|openapi/conversions/.+|module.yaml|(^|/)rbac\.yaml$|ru\..+` + skipDocRe = `doc-ru-.+\.y[a]?ml$|\.ru\.y[a]?ml$|\.ru\.json$|\.ru\.md$|\.ru\.html$|_RU\.md$|_ru\.html$|docs/site/_.+|docs/documentation/_.+|tools/spelling/.+|openapi/conversions/.+|module.yaml|ru\..+` skipSelfRe = `no_cyrillic(_test)?.go$` skipI18NRe = `/i18n/` @@ -109,7 +109,7 @@ func (r *FilesRule) checkFile(fileName string) { return } - if r.skipDocRe.MatchString(fileName) { + if r.skipDocRe.MatchString(fileName) || fName == "rbac.yaml" { return } diff --git a/pkg/linters/rbac/README.md b/pkg/linters/rbac/README.md index 12a33719..0f84965f 100644 --- a/pkg/linters/rbac/README.md +++ b/pkg/linters/rbac/README.md @@ -1692,5 +1692,5 @@ linters-settings: - `impact: ignore` on a rule switches its autofix off with it: `--fix` never rewrites files on behalf of findings nobody sees. - A `when` condition must parse as a Helm expression (sprig and Helm functions are known); whether it holds under the linter's value stubs is decided by the render -- a condition that breaks the render is reported by the `helm-render` rule, and the module is not linted further. - `dmt lint remote` does not run these rules: a published image carries no chart to render. -- The keys of the `rbac` configuration blocks (`linters-settings.rbac`, its `exclude-rules`, the global `rbac` settings and its `rules`) are checked: an unknown key at those levels is an error, not a silent no-op. Keys inside a rule's level or an exclusion entry are viper's as before. +- The keys of the `rbac` configuration blocks are checked down to a rule's `impact` and the `kind`/`name` of an exclusion entry: every unknown key is reported in one error, not dropped in silence. diff --git a/pkg/linters/rbac/rules/bootstrap/bootstrap.go b/pkg/linters/rbac/rules/bootstrap/bootstrap.go index 069f43d3..9bf11aaf 100644 --- a/pkg/linters/rbac/rules/bootstrap/bootstrap.go +++ b/pkg/linters/rbac/rules/bootstrap/bootstrap.go @@ -125,11 +125,15 @@ type builder struct { func (b *builder) note(format string, args ...any) { b.notes = append(b.notes, fmt.Sprintf(format, args...)) } + func (b *builder) unmanage(o Object, why string) { b.unmanaged = append(b.unmanaged, fmt.Sprintf("%s (%s): %s", o.identity(), o.Path, why)) } -func (b *builder) mark(o Object) { b.used[o.identity()] = struct{}{} } + +func (b *builder) mark(o Object) { b.used[o.identity()] = struct{}{} } + func (b *builder) isUsed(o Object) bool { _, ok := b.used[o.identity()]; return ok } + func (b *builder) ns(o Object) string { if o.Namespace == "" { return b.in.Namespace @@ -137,6 +141,7 @@ func (b *builder) ns(o Object) string { return o.Namespace } + func (b *builder) rename(kind, from, to string) { if from != to { b.note("%s %s will be named %s by the generator", kind, from, to) @@ -240,13 +245,7 @@ func (b *builder) capabilitiesAndLegacy() { } lineage, action := m[1], m[3] - level := action - - for lvl, act := range map[string]string{"viewer": "view", "manager": "edit"} { - if act == action { - level = lvl - } - } + level := rbaccontract.LevelOfAction(action) b.addRules(lineage, level, o.Rules, lineage == rbaccontract.LineageSystem) b.mark(o) @@ -396,10 +395,10 @@ func (b *builder) serviceAccounts() { b.rename("ClusterRoleBinding", crb.Name, extra.FullName(b.in.Module, sa.Name)) default: e.BindClusterRoles = append(e.BindClusterRoles, crb.RoleRef.Name) - b.rename("ClusterRoleBinding", crb.Name, clusterName+":"+bindingSuffix(crb.RoleRef.Name)) + b.rename("ClusterRoleBinding", crb.Name, clusterName+":"+rbaccontract.BindingSuffix(crb.RoleRef.Name)) } - if found && (exclusive) { + if exclusive { b.mark(cr) } @@ -418,7 +417,7 @@ func (b *builder) serviceAccounts() { b.mark(role) } else { e.BindRoles = append(e.BindRoles, rbacyaml.RoleRef{Namespace: b.ns(rb), Name: rb.RoleRef.Name}) - b.rename("RoleBinding", b.ns(rb)+"/"+rb.Name, b.ns(rb)+"/"+clusterName+":"+bindingSuffix(rb.RoleRef.Name)) + b.rename("RoleBinding", b.ns(rb)+"/"+rb.Name, b.ns(rb)+"/"+clusterName+":"+rbaccontract.BindingSuffix(rb.RoleRef.Name)) } b.mark(rb) @@ -721,7 +720,3 @@ func subjects(list []rbacv1.Subject) []rbacyaml.Subject { return out } - -func bindingSuffix(roleName string) string { - return strings.ReplaceAll(strings.TrimPrefix(roleName, "d8:"), ":", "-") -} diff --git a/pkg/linters/rbac/rules/contract.go b/pkg/linters/rbac/rules/contract.go index 9f5f6604..0e4a56eb 100644 --- a/pkg/linters/rbac/rules/contract.go +++ b/pkg/linters/rbac/rules/contract.go @@ -18,6 +18,7 @@ package rules import ( "context" + "maps" "regexp" "slices" "sort" @@ -65,7 +66,8 @@ var ( "project": "d8:project-capability:", } - validScopes = []string{"system", "subsystem", "namespace", "project"} + // validScopes follows roleNameRe: one table decides which scopes exist. + validScopes = []string{"namespace", "project", "subsystem", "system"} ) // ContractRule checks the rendered RBACv2 ClusterRoles of a module against the platform's label @@ -219,7 +221,7 @@ func checkContract(role *rbacv1.ClusterRole, module string, scopes rbacyaml.CRDS } // Aggregation labels: the lineage must exist and the level must be one of that lineage (R29). - for _, key := range sortedKeys(labels) { + for _, key := range slices.Sorted(maps.Keys(labels)) { m := aggregateLabelRe.FindStringSubmatch(key) if m == nil { continue @@ -293,7 +295,7 @@ func checkRole(role *rbacv1.ClusterRole, scope string, errorList *errors.LintRul } for _, selector := range role.AggregationRule.ClusterRoleSelectors { - for _, key := range sortedKeys(selector.MatchLabels) { + for _, key := range slices.Sorted(maps.Keys(selector.MatchLabels)) { value := selector.MatchLabels[key] m := aggregateLabelRe.FindStringSubmatch(key) @@ -371,14 +373,3 @@ func checkCapability(role *rbacv1.ClusterRole, scope string, scopes rbacyaml.CRD } } } - -func sortedKeys(m map[string]string) []string { - keys := make([]string, 0, len(m)) - for k := range m { - keys = append(keys, k) - } - - sort.Strings(keys) - - return keys -} diff --git a/pkg/linters/rbac/rules/contract_test.go b/pkg/linters/rbac/rules/contract_test.go index 49190940..fbb953b4 100644 --- a/pkg/linters/rbac/rules/contract_test.go +++ b/pkg/linters/rbac/rules/contract_test.go @@ -18,6 +18,8 @@ package rules import ( "context" + "maps" + "slices" "strings" "testing" @@ -78,7 +80,7 @@ func clusterRole(name string, labels map[string]string, annotations, body string b.WriteString("apiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRole\nmetadata:\n name: \"" + name + "\"\n labels:\n") - for _, k := range sortedKeys(labels) { + for _, k := range slices.Sorted(maps.Keys(labels)) { b.WriteString(" " + k + ": \"" + labels[k] + "\"\n") } @@ -206,7 +208,7 @@ func TestContract_Findings(t *testing.T) { "rbac.deckhouse.io/kind": "capability", "rbac.deckhouse.io/scope": "tenant", }, i18n, "rules:\n- apiGroups: [x.io]\n resources: [ys]\n verbs: [get]\n"), wantErrs: []string{ - `error: label rbac.deckhouse.io/scope must be one of system/subsystem/namespace/project, got "tenant"`, + `error: label rbac.deckhouse.io/scope must be one of namespace/project/subsystem/system, got "tenant"`, }, }, } { diff --git a/pkg/linters/rbac/rules/fixstate.go b/pkg/linters/rbac/rules/fixstate.go index 9e6c24da..1c04f34e 100644 --- a/pkg/linters/rbac/rules/fixstate.go +++ b/pkg/linters/rbac/rules/fixstate.go @@ -40,32 +40,34 @@ import ( // rather than the render of whichever variant happened to run its closure first. var fixState = struct { sync.Mutex - outcomes map[string]error foreign map[string]map[string]struct{} bootstrap map[string]map[string]bootstrap.Object }{ - outcomes: map[string]error{}, foreign: map[string]map[string]struct{}{}, bootstrap: map[string]map[string]bootstrap.Object{}, } +// fixOutcomes remembers the result of every fix that ran, by file. It has a lock of its own, held +// while the fix runs: a fix reads fixState, so the two must not share a mutex, and holding this one +// is what makes "once" hold under concurrent callers too, not only under the sequential +// Manager.ApplyFixes. +var fixOutcomes = struct { + sync.Mutex + done map[string]error +}{done: map[string]error{}} + // fixOnce runs fix for the key the first time it is asked and returns that outcome on every later -// call. Fixes run one at a time (Manager.ApplyFixes is sequential), and the fix itself reads the -// state, so it runs outside the lock. +// call. func fixOnce(key string, fix func() error) error { - fixState.Lock() - err, done := fixState.outcomes[key] - fixState.Unlock() + fixOutcomes.Lock() + defer fixOutcomes.Unlock() - if done { + if err, done := fixOutcomes.done[key]; done { return err } - err = fix() - - fixState.Lock() - fixState.outcomes[key] = err - fixState.Unlock() + err := fix() + fixOutcomes.done[key] = err return err } @@ -108,9 +110,13 @@ func resetFixState() { fixState.Lock() defer fixState.Unlock() - fixState.outcomes = map[string]error{} fixState.foreign = map[string]map[string]struct{}{} fixState.bootstrap = map[string]map[string]bootstrap.Object{} + + fixOutcomes.Lock() + defer fixOutcomes.Unlock() + + fixOutcomes.done = map[string]error{} } // recordBootstrapObjects adds the RBAC objects one render variant produced, for the first diff --git a/pkg/linters/rbac/rules/generate/model.go b/pkg/linters/rbac/rules/generate/model.go index 3e7f17e5..1373f5bc 100644 --- a/pkg/linters/rbac/rules/generate/model.go +++ b/pkg/linters/rbac/rules/generate/model.go @@ -26,6 +26,7 @@ limitations under the License. package generate import ( + "errors" "fmt" "sort" "strings" @@ -167,11 +168,11 @@ func (m *Model) Paths() []string { // rbacyaml.Validate: Build trusts it. func Build(in Input) (*Model, error) { if in.Decl == nil { - return nil, fmt.Errorf("no declaration") + return nil, errors.New("no declaration") } if in.Module == "" { - return nil, fmt.Errorf("the module name is required") + return nil, errors.New("the module name is required") } if err := checkAgainstModule(in); err != nil { @@ -445,26 +446,20 @@ func (b *builder) serviceAccounts() { for _, bound := range sa.BindClusterRoles { b.add(path, Object{ - Kind: "ClusterRoleBinding", Name: clusterName + ":" + bindingSuffix(bound), Class: ClassDeclared, When: sa.When, Labels: labels, + Kind: "ClusterRoleBinding", Name: clusterName + ":" + rbaccontract.BindingSuffix(bound), Class: ClassDeclared, When: sa.When, Labels: labels, RoleRefKind: "ClusterRole", RoleRefName: bound, Subjects: subject, }) } for _, ref := range sa.BindRoles { b.add(path, Object{ - Kind: "RoleBinding", Name: clusterName + ":" + bindingSuffix(ref.Name), Namespace: ref.Namespace, Class: ClassDeclared, When: sa.When, Labels: labels, + Kind: "RoleBinding", Name: clusterName + ":" + rbaccontract.BindingSuffix(ref.Name), Namespace: ref.Namespace, Class: ClassDeclared, When: sa.When, Labels: labels, RoleRefKind: "Role", RoleRefName: ref.Name, Subjects: subject, }) } } } -// bindingSuffix names the binding to an existing role after that role: d8:rbac-proxy -> rbac-proxy, -// extension-apiserver-authentication-reader stays as it is. -func bindingSuffix(roleName string) string { - return strings.ReplaceAll(strings.TrimPrefix(roleName, "d8:"), ":", "-") -} - // access produces the Prometheus access and the arbitrary-subject grants: cluster rules go to // templates/rbac-for-us.yaml (the placement rule keeps ClusterRoles there), namespace rules and the // metrics access to templates/rbac-to-us.yaml. diff --git a/pkg/linters/rbac/rules/generate/render.go b/pkg/linters/rbac/rules/generate/render.go index b219ce4a..431291d4 100644 --- a/pkg/linters/rbac/rules/generate/render.go +++ b/pkg/linters/rbac/rules/generate/render.go @@ -17,8 +17,9 @@ limitations under the License. package generate import ( + "maps" "regexp" - "sort" + "slices" "strconv" "strings" @@ -122,7 +123,7 @@ func renderObject(b *strings.Builder, o Object) { if len(o.Annotations) > 0 { b.WriteString(" annotations:\n") - for _, key := range sortedKeys(o.Annotations) { + for _, key := range slices.Sorted(maps.Keys(o.Annotations)) { b.WriteString(" " + key + ": " + strconv.Quote(o.Annotations[key]) + "\n") } } @@ -157,7 +158,7 @@ func labelsInclude(labels map[string]string) string { } pairs := make([]string, 0, len(labels)) - for _, key := range sortedKeys(labels) { + for _, key := range slices.Sorted(maps.Keys(labels)) { pairs = append(pairs, strconv.Quote(key)+" "+strconv.Quote(labels[key])) } @@ -233,14 +234,3 @@ var yaml11Reserved = map[string]bool{ "y": true, "yes": true, "n": true, "no": true, "true": true, "false": true, "on": true, "off": true, "null": true, "~": true, } - -func sortedKeys(m map[string]string) []string { - keys := make([]string, 0, len(m)) - for k := range m { - keys = append(keys, k) - } - - sort.Strings(keys) - - return keys -} diff --git a/pkg/linters/rbac/rules/rbaccontract/contract.go b/pkg/linters/rbac/rules/rbaccontract/contract.go index a97e509c..eeee04f4 100644 --- a/pkg/linters/rbac/rules/rbaccontract/contract.go +++ b/pkg/linters/rbac/rules/rbaccontract/contract.go @@ -24,7 +24,10 @@ limitations under the License. // in-tree by testing/rbacv2/rbacv2_templates_validation_test.go. package rbaccontract -import "slices" +import ( + "slices" + "strings" +) // Label and annotation keys of the role model // (modules/140-user-authz/docs/internal/RBACV2_MODULE_MIGRATION.md, "Reference of role labels and annotations"). @@ -133,7 +136,10 @@ var LegacyLevels = []string{"User", "PrivilegedUser", "Editor", "Admin", "Cluste // Verbs are the resource verbs Kubernetes RBAC knows. rbac.yaml lists verbs explicitly; there // are no aliases (spec 005 R2). "*" is accepted here and judged by the wildcards rule. -var Verbs = []string{"get", "list", "watch", "create", "update", "patch", "delete", "deletecollection", "*"} +var Verbs = append(slices.Clone(ResourceVerbs), "*") + +// ResourceVerbs are the verbs a rule may list, without the wildcard. +var ResourceVerbs = []string{"get", "list", "watch", "create", "update", "patch", "delete", "deletecollection"} // AllLineages returns every lineage a capability label may name: the three base lineages and // the seven subsystems. @@ -184,6 +190,26 @@ func CapabilityAction(level string) string { return level } +// LevelOfAction is the inverse of CapabilityAction: view -> viewer, edit -> manager, the rest as +// they are. +func LevelOfAction(action string) string { + switch action { + case "view": + return "viewer" + case "edit": + return "manager" + } + + return action +} + +// BindingSuffix turns a role name into the last segment of the binding the generator names after +// it: the d8: prefix goes, the colons become dashes (d8:rbac-proxy -> rbac-proxy, +// system:auth-delegator -> system-auth-delegator). +func BindingSuffix(roleName string) string { + return strings.ReplaceAll(strings.TrimPrefix(roleName, "d8:"), ":", "-") +} + // ConventionalActions are the capability actions whose localized texts come from the platform // convention and need no capabilities entry in rbac.yaml. var ConventionalActions = []string{"view", "edit"} diff --git a/pkg/linters/rbac/rules/rbaccontract/contract_test.go b/pkg/linters/rbac/rules/rbaccontract/contract_test.go new file mode 100644 index 00000000..dfa41b4c --- /dev/null +++ b/pkg/linters/rbac/rules/rbaccontract/contract_test.go @@ -0,0 +1,79 @@ +/* +Copyright 2025 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rbaccontract + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestLegacyKebab(t *testing.T) { + for level, want := range map[string]string{ + "User": "user", + "PrivilegedUser": "privileged-user", + "Editor": "editor", + "Admin": "admin", + "ClusterEditor": "cluster-editor", + "ClusterAdmin": "cluster-admin", + "SuperAdmin": "super-admin", + } { + assert.Equal(t, want, LegacyKebab(level), level) + } +} + +func TestLevelsOf(t *testing.T) { + assert.Equal(t, NamespaceLevels, LevelsOf(LineageNamespace)) + assert.Equal(t, NamespaceLevels, LevelsOf(LineageProject)) + assert.Equal(t, SystemLevels, LevelsOf(LineageSystem)) + assert.Equal(t, SystemLevels, LevelsOf("networking"), "a subsystem carries the system levels") + assert.Nil(t, LevelsOf("tenant")) + + // The lineages are separate slices: reordering one must not reorder another. + swapFirstTwo(ProjectLevels) + defer swapFirstTwo(ProjectLevels) + + assert.Equal(t, "viewer", NamespaceLevels[0]) +} + +func swapFirstTwo(levels []string) { levels[0], levels[1] = levels[1], levels[0] } + +func TestCapabilityActionRoundTrip(t *testing.T) { + for _, level := range NamespaceLevels { + assert.Equal(t, level, LevelOfAction(CapabilityAction(level)), level) + } + + assert.Equal(t, "view", CapabilityAction("viewer")) + assert.Equal(t, "edit", CapabilityAction("manager")) + assert.True(t, IsConventionalAction("view")) + assert.False(t, IsConventionalAction("admin")) +} + +func TestBindingSuffix(t *testing.T) { + assert.Equal(t, "rbac-proxy", BindingSuffix("d8:rbac-proxy")) + assert.Equal(t, "system-auth-delegator", BindingSuffix("system:auth-delegator")) + assert.Equal(t, "cluster-admin", BindingSuffix("cluster-admin")) +} + +func TestVerbsAndKinds(t *testing.T) { + assert.Equal(t, append(append([]string{}, ResourceVerbs...), "*"), Verbs) + assert.True(t, IsLegacyKind(KindLegacyUse)) + assert.True(t, IsLegacyKind(KindLegacyManage)) + assert.False(t, IsLegacyKind(KindCapability)) + assert.True(t, IsSubsystem("security")) + assert.False(t, IsSubsystem("tenant")) +} diff --git a/pkg/linters/rbac/rules/rbacyaml/scopes.go b/pkg/linters/rbac/rules/rbacyaml/scopes.go index be3f6554..f12eeee6 100644 --- a/pkg/linters/rbac/rules/rbacyaml/scopes.go +++ b/pkg/linters/rbac/rules/rbacyaml/scopes.go @@ -16,6 +16,8 @@ limitations under the License. package rbacyaml +import "strings" + // wellKnownScopes is the scope of the built-in Kubernetes resources a module's RBAC commonly names: // the module ships no CRD for them and the linter has no cluster to ask, so an entry for them // needs no scope of its own. Anything not here is declared with an explicit scope. @@ -55,7 +57,7 @@ var wellKnownScopes = map[string]string{ // the core group); a subresource inherits its base resource's. func WellKnownScope(group, resource string) (string, bool) { base := resource - if i := indexByte(base, '/'); i >= 0 { + if i := strings.IndexByte(base, '/'); i >= 0 { base = base[:i] } @@ -63,13 +65,3 @@ func WellKnownScope(group, resource string) (string, bool) { return scope, ok } - -func indexByte(s string, c byte) int { - for i := 0; i < len(s); i++ { - if s[i] == c { - return i - } - } - - return -1 -} diff --git a/pkg/linters/rbac/rules/rbacyaml/types.go b/pkg/linters/rbac/rules/rbacyaml/types.go index 686640a4..ca849b3a 100644 --- a/pkg/linters/rbac/rules/rbacyaml/types.go +++ b/pkg/linters/rbac/rules/rbacyaml/types.go @@ -21,6 +21,8 @@ limitations under the License. // rbac.deckhouse.io/v1alpha1. package rbacyaml +import "strings" + // Filename is the declaration's name in the module root. const Filename = "rbac.yaml" @@ -102,13 +104,7 @@ func (r Resource) IsWildcard() bool { return r.Resource == "*" } // IsSubresource reports whether the entry names a subresource (a "/" in the name). func (r Resource) IsSubresource() bool { - for i := 0; i < len(r.Resource); i++ { - if r.Resource[i] == '/' { - return true - } - } - - return false + return strings.Contains(r.Resource, "/") } // HasLevels reports whether any role model grants something on the resource. @@ -182,7 +178,7 @@ func (r ExtraClusterRole) IsBound() bool { return r.Bind == nil || *r.Bind } // FullName returns the ClusterRole name: the given one when it already starts with d8:, else // d8:::. func (r ExtraClusterRole) FullName(module, account string) string { - if len(r.Name) > 3 && r.Name[:3] == "d8:" { + if strings.HasPrefix(r.Name, "d8:") { return r.Name } diff --git a/pkg/linters/rbac/rules/rbacyaml/validate.go b/pkg/linters/rbac/rules/rbacyaml/validate.go index b46410a8..9fb7522a 100644 --- a/pkg/linters/rbac/rules/rbacyaml/validate.go +++ b/pkg/linters/rbac/rules/rbacyaml/validate.go @@ -132,7 +132,7 @@ func validateResource(r *Resource, where string, crds CRDScopes, usedCapabilitie } if r.IsWildcard() { - if _, known := crds.groupKnown(r.Group); known { + if crds.groupKnown(r.Group) { report("%s: resource \"*\" is allowed only for a group the module ships no CRD for; list the resources of %q", where, r.Group) } @@ -192,14 +192,14 @@ func resolveScope(r *Resource, crds CRDScopes) (string, string) { } // groupKnown reports whether any CRD of the tree belongs to the group. -func (c CRDScopes) groupKnown(group string) (string, bool) { +func (c CRDScopes) groupKnown(group string) bool { for key := range c { if strings.HasPrefix(key, group+"/") { - return key, true + return true } } - return "", false + return false } func validateLevels(levels map[string][]string, lineage string, allowed []string, where string, usedCapabilities map[string]struct{}, report reporter) { @@ -215,7 +215,7 @@ func validateLevels(levels map[string][]string, lineage string, allowed []string for _, verb := range verbs { if !slices.Contains(rbaccontract.Verbs, verb) { - report("%s: %s.%s: %q is not a verb; verbs are listed explicitly (%s), there are no aliases", where, lineage, level, verb, strings.Join(rbaccontract.Verbs[:len(rbaccontract.Verbs)-1], ", ")) + report("%s: %s.%s: %q is not a verb; verbs are listed explicitly (%s), there are no aliases", where, lineage, level, verb, strings.Join(rbaccontract.ResourceVerbs, ", ")) } } @@ -243,9 +243,12 @@ func validateCapabilities(texts map[string]CapabilityText, used map[string]struc report("capabilities: %q needs no texts: view and edit capabilities take the platform's conventional texts", key) } - for field, value := range map[string]LocalizedText{"title": text.Title, "description": text.Description} { - if value.EN == "" || value.RU == "" { - report("capabilities: %s.%s requires both en and ru", key, field) + for _, field := range []struct { + name string + value LocalizedText + }{{"title", text.Title}, {"description", text.Description}} { + if field.value.EN == "" || field.value.RU == "" { + report("capabilities: %s.%s requires both en and ru", key, field.name) } } } diff --git a/pkg/linters/rbac/rules/sync.go b/pkg/linters/rbac/rules/sync.go index e7c8c367..c87949fe 100644 --- a/pkg/linters/rbac/rules/sync.go +++ b/pkg/linters/rbac/rules/sync.go @@ -145,6 +145,16 @@ func (r *SyncRule) Check(_ context.Context) { } actual := r.managedObjects(model) + divergences := r.compareRender(model, actual) + r.compareText(modulePath, model, actual, divergences) + r.report(modulePath, model, divergences) +} + +// compareRender judges the declaration against the rendered objects, both ways: every produced +// object must be rendered as produced, and every legacy role or module capability rendered must be +// produced. The findings are collected per template. +func (r *SyncRule) compareRender(model *generate.Model, actual map[string]managedObject) map[string][]string { + modulePath := r.module.GetPath() legacy := r.legacyFiles() divergences := map[string][]string{} @@ -196,12 +206,17 @@ func (r *SyncRule) Check(_ context.Context) { fmt.Sprintf("%s is in the render but rbac.yaml does not produce it: declare its rights in rbac.yaml or remove it from the template", identity)) } - // A file that carries the generator header is the generator's: its text must be what the - // declaration renders now. The render alone cannot tell -- a rule under `when` whose condition - // is false today is absent from the render without being a divergence (D4), yet it still has - // to reach the template -- so for these files the text is compared too. A file of another - // contract version is the same case (R40). A file without the header is maintained by hand and - // is judged by its render only. + return divergences +} + +// compareText judges the generated files by their text: a file that carries the generator header +// must be what the declaration renders now, and a file that does not exist while an object it +// holds is absent from the render was never written. +func (r *SyncRule) compareText(modulePath string, model *generate.Model, actual map[string]managedObject, divergences map[string][]string) { + // A rule under `when` whose condition is false today is absent from the render without being + // a divergence (D4), yet it still has to reach the template -- so for these files the text is + // compared too. A file of another contract version is the same case (R40). A file without the + // header is maintained by hand and is judged by its render only. for _, file := range model.Files { content, err := os.ReadFile(filepath.Join(modulePath, file.Path)) if err != nil { @@ -229,7 +244,11 @@ func (r *SyncRule) Check(_ context.Context) { "the file carries the generator header but is not what the declaration renders now (a rule under `when`, a text edit or an older generator); remove the header to maintain it by hand") } } +} +// report emits one finding per template, with the fix that closes it when one exists: the +// regeneration of a produced file, the deletion of an orphaned generated file, or none. +func (r *SyncRule) report(modulePath string, model *generate.Model, divergences map[string][]string) { paths := make([]string, 0, len(divergences)) for path, list := range divergences { if len(list) > 0 { @@ -389,7 +408,13 @@ func (r *SyncRule) legacyFiles() map[string]string { out := map[string]string{} for _, object := range r.module.GetStorage() { - if kind := object.Unstructured.GetLabels()[rbaccontract.LabelKind]; object.Unstructured.GetKind() == "ClusterRole" && rbaccontract.IsLegacyKind(kind) { + kind := object.Unstructured.GetLabels()[rbaccontract.LabelKind] + if object.Unstructured.GetKind() != "ClusterRole" || !rbaccontract.IsLegacyKind(kind) { + continue + } + + // A file with both kinds names the smaller one, whatever order the storage yields. + if prev, seen := out[object.ShortPath()]; !seen || kind < prev { out[object.ShortPath()] = kind } } @@ -570,14 +595,12 @@ func subjectSet(list []rbacv1.Subject) string { } func subjectSetOf(list []generate.Subject) string { - parts := make([]string, 0, len(list)) + subjects := make([]rbacv1.Subject, 0, len(list)) for _, s := range list { - parts = append(parts, s.Kind+"/"+s.Namespace+"/"+s.Name) + subjects = append(subjects, rbacv1.Subject{Kind: s.Kind, Namespace: s.Namespace, Name: s.Name}) } - sort.Strings(parts) - - return strings.Join(parts, ",") + return subjectSet(subjects) } // managedObject is a rendered object the sync rule owns, with the class it was recognized by. From 433eab00dba42ab4c174a20ec3755e8d99e0d7af Mon Sep 17 00:00:00 2001 From: Ivan Zvyagintsev Date: Wed, 23 Sep 2026 12:49:11 +0300 Subject: [PATCH 31/58] rbac: prometheusAccess.when gates the scraper binding; bootstrap leaves the scope to the CRD The modules gate the RoleBinding of the Prometheus scraper on the prometheus module (14 of 61 rbac-to-us.yaml in the tree) and leave the Role unconditional. prometheusAccess.when reproduces that shape; the render cannot show the gate, so the bootstrap notes that the author has to add it. The bootstrap no longer writes scope on an entry whose CRD is in crds/: the CRD is the source and validation compares the two, so the copy only invited drift. A CRD that only an edition overlay ships still asks for scope in a lint of the base directory, and the validation message says so. Signed-off-by: Ivan Zvyagintsev --- pkg/linters/rbac/README.md | 4 ++- pkg/linters/rbac/rules/bootstrap/bootstrap.go | 17 ++++++------- .../rbac/rules/bootstrap/bootstrap_test.go | 4 +-- .../rbac/rules/generate/generate_test.go | 25 +++++++++++++++++++ pkg/linters/rbac/rules/generate/model.go | 4 ++- pkg/linters/rbac/rules/rbacyaml/load_test.go | 4 +++ pkg/linters/rbac/rules/rbacyaml/types.go | 4 +++ pkg/linters/rbac/rules/rbacyaml/validate.go | 9 ++++--- 8 files changed, 54 insertions(+), 17 deletions(-) diff --git a/pkg/linters/rbac/README.md b/pkg/linters/rbac/README.md index 0f84965f..d923afc6 100644 --- a/pkg/linters/rbac/README.md +++ b/pkg/linters/rbac/README.md @@ -1440,9 +1440,11 @@ serviceAccounts: - namespace: kube-system name: extension-apiserver-authentication-reader -# Metrics access -> templates/rbac-to-us.yaml (Role/RoleBinding access-to-) +# Metrics access -> templates/rbac-to-us.yaml (Role/RoleBinding access-to-); `when` gates the +# RoleBinding to the scraper only, the Role is unconditional, as the modules write it today prometheusAccess: deployments: [cert-manager] + when: .Values.global.enabledModules | has "prometheus" # Arbitrary subjects: clusterRules -> templates/[/]rbac-for-us.yaml, namespaceRules -> templates/[/]rbac-to-us.yaml access: diff --git a/pkg/linters/rbac/rules/bootstrap/bootstrap.go b/pkg/linters/rbac/rules/bootstrap/bootstrap.go index 9bf11aaf..75365e8e 100644 --- a/pkg/linters/rbac/rules/bootstrap/bootstrap.go +++ b/pkg/linters/rbac/rules/bootstrap/bootstrap.go @@ -523,6 +523,7 @@ func (b *builder) prometheus(role, rb Object) bool { b.decl.PrometheusAccess = &rbacyaml.PrometheusAccess{} } else { b.note("several Prometheus access Roles fold into one prometheusAccess (Role access-to-%s)", b.in.Module) + b.note("prometheusAccess: the render does not show whether the template gated the scraper binding on the prometheus module; add `when: .Values.global.enabledModules | has \"prometheus\"` if it did") } pa := b.decl.PrometheusAccess @@ -596,12 +597,10 @@ func (b *builder) resources() { switch { case fromCRD: - // Written out even though the CRD says it: a lint of one edition directory does not see - // the CRDs of the other editions, and an entry without a scope would then stop the - // whole validation instead of raising one coverage warning. - if !strings.Contains(resource, "/") { - e.Scope = scope - } + // The CRD in crds/ is the source of the scope (ADR); writing it out would be a second + // copy that validation has to keep in step. A CRD that only an edition overlay ships + // is the one case where a lint of the base directory asks for scope: the author adds + // it then, as the validation message says. case resource == "*": e.Scope, scope = rbacyaml.ScopeCluster, rbacyaml.ScopeCluster e.Reason = "TODO: the templates grant the whole group; say why the resource names are not known statically" @@ -662,10 +661,8 @@ func (b *builder) resources() { } if !declared { - // The scope is written out for the same reason as above: a lint of one edition directory - // that lacks this CRD must read the entry as a documented external resource, not as a - // stale one. - b.decl.Resources = append(b.decl.Resources, rbacyaml.Resource{Group: group, Resource: plural, Scope: b.in.CRDs[k], + // No scope: the CRD in crds/ carries it, as for every CRD-backed entry above. + b.decl.Resources = append(b.decl.Resources, rbacyaml.Resource{Group: group, Resource: plural, NoAccess: "TODO: no user-facing access in the templates today; grant levels or say why users get none"}) } } diff --git a/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go b/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go index 834ee4bb..be9bdb11 100644 --- a/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go +++ b/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go @@ -107,8 +107,8 @@ func TestBuild_RoundTripOnTheCertManagerFixture(t *testing.T) { require.True(t, ok, "resource %s missing", k) assert.Equal(t, w.NoAccess != "", g.NoAccess != "", "%s: denied", k) - if g.NoAccess == "" && !strings.Contains(g.Resource, "/") { - assert.Equal(t, certManagerCRDs[k], g.Scope, "%s: the scope is written out even when the CRD says it", k) + if _, backed := certManagerCRDs[k]; backed { + assert.Empty(t, g.Scope, "%s: the CRD carries the scope, the entry does not repeat it", k) } assert.Equal(t, w.Namespace, g.Namespace, "%s: namespace levels", k) diff --git a/pkg/linters/rbac/rules/generate/generate_test.go b/pkg/linters/rbac/rules/generate/generate_test.go index 619bd816..2b496579 100644 --- a/pkg/linters/rbac/rules/generate/generate_test.go +++ b/pkg/linters/rbac/rules/generate/generate_test.go @@ -19,6 +19,7 @@ package generate import ( "os" "path/filepath" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -333,6 +334,30 @@ func TestBuild_RefusesDuplicateGeneratedNames(t *testing.T) { assert.Contains(t, err.Error(), "would hold two objects named ClusterRoleBinding/d8:m:worker:a-b") } +// prometheusAccess.when gates the binding to the scraper only; the Role stays unconditional, as +// the modules write it today. +func TestBuild_PrometheusAccessWhen(t *testing.T) { + decl := &rbacyaml.Declaration{APIVersion: rbacyaml.APIVersionV1Alpha1, + Resources: []rbacyaml.Resource{{Group: "x.io", Resource: "things", Scope: "Cluster", System: map[string][]string{"viewer": {"get"}}}}, + PrometheusAccess: &rbacyaml.PrometheusAccess{Deployments: []string{"m"}, When: `.Values.global.enabledModules | has "prometheus"`}, + } + + model, err := Build(Input{Module: "m", Namespace: "d8-m", Subsystems: []string{"security"}, Decl: decl}) + require.NoError(t, err) + + file := model.File("templates/rbac-to-us.yaml") + require.NotNil(t, file) + require.Len(t, file.Objects, 2) + assert.Equal(t, "Role", file.Objects[0].Kind) + assert.Empty(t, file.Objects[0].When) + assert.Equal(t, "RoleBinding", file.Objects[1].Kind) + assert.Equal(t, `.Values.global.enabledModules | has "prometheus"`, file.Objects[1].When) + + rendered := RenderFile(*file) + assert.Equal(t, 1, strings.Count(rendered, `{{- if .Values.global.enabledModules | has "prometheus" }}`)) + assert.Less(t, strings.Index(rendered, "kind: Role\n"), strings.Index(rendered, "{{- if"), "the Role comes before the gate") +} + // A generated file that acquired CRLF line endings is still the generator's. func TestParseHeader_CRLF(t *testing.T) { generated, version := ParseHeader(Header() + "\r\n---\r\n") diff --git a/pkg/linters/rbac/rules/generate/model.go b/pkg/linters/rbac/rules/generate/model.go index 1373f5bc..0f3d8408 100644 --- a/pkg/linters/rbac/rules/generate/model.go +++ b/pkg/linters/rbac/rules/generate/model.go @@ -484,8 +484,10 @@ func (b *builder) access() { name := "access-to-" + b.in.Module b.add("templates/rbac-to-us.yaml", Object{Kind: "Role", Name: name, Namespace: b.in.Namespace, Class: ClassDeclared, Rules: rules}) + // The Role is unconditional and only the binding to the scraper is gated: that is how the + // modules write it today, and a Role nobody is bound to grants nothing. b.add("templates/rbac-to-us.yaml", Object{ - Kind: "RoleBinding", Name: name, Namespace: b.in.Namespace, Class: ClassDeclared, RoleRefKind: "Role", RoleRefName: name, + Kind: "RoleBinding", Name: name, Namespace: b.in.Namespace, Class: ClassDeclared, RoleRefKind: "Role", RoleRefName: name, When: pa.When, Subjects: []Subject{{Kind: "User", Name: "d8-monitoring:scraper"}, {Kind: "ServiceAccount", Name: "prometheus", Namespace: "d8-monitoring"}}, }) } diff --git a/pkg/linters/rbac/rules/rbacyaml/load_test.go b/pkg/linters/rbac/rules/rbacyaml/load_test.go index 7b52a956..4516eefd 100644 --- a/pkg/linters/rbac/rules/rbacyaml/load_test.go +++ b/pkg/linters/rbac/rules/rbacyaml/load_test.go @@ -449,6 +449,10 @@ func TestValidate_TopLevel(t *testing.T) { yaml: "apiVersion: rbac.deckhouse.io/v1alpha1\nserviceAccounts:\n - name: a\n clusterRules: [{apiGroups: [x], resources: [y]}]\n", wantErr: "serviceAccounts[0] (a).clusterRules[0]: verbs is required", }, + "prometheusAccess: when that is not a Helm expression": { + yaml: "apiVersion: rbac.deckhouse.io/v1alpha1\nprometheusAccess:\n deployments: [a]\n when: 'and (.Values.x'\n", + wantErr: "prometheusAccess: when", + }, "prometheusAccess: empty": { yaml: "apiVersion: rbac.deckhouse.io/v1alpha1\nprometheusAccess: {}\n", wantErr: "prometheusAccess: names no workload", diff --git a/pkg/linters/rbac/rules/rbacyaml/types.go b/pkg/linters/rbac/rules/rbacyaml/types.go index ca849b3a..300f7f47 100644 --- a/pkg/linters/rbac/rules/rbacyaml/types.go +++ b/pkg/linters/rbac/rules/rbacyaml/types.go @@ -197,6 +197,10 @@ type PrometheusAccess struct { Deployments []string `yaml:"deployments,omitempty"` DaemonSets []string `yaml:"daemonsets,omitempty"` StatefulSets []string `yaml:"statefulsets,omitempty"` + // When gates the RoleBinding to the scraper, the way the modules gate it today: + // `.Values.global.enabledModules | has "prometheus"`. The Role stays unconditional, so the + // generated file keeps the shape of the hand-written ones. + When string `yaml:"when,omitempty"` } // Access grants arbitrary subjects rights on the module. ClusterRules produce a ClusterRole and diff --git a/pkg/linters/rbac/rules/rbacyaml/validate.go b/pkg/linters/rbac/rules/rbacyaml/validate.go index 9fb7522a..27feddac 100644 --- a/pkg/linters/rbac/rules/rbacyaml/validate.go +++ b/pkg/linters/rbac/rules/rbacyaml/validate.go @@ -97,9 +97,12 @@ func Validate(d *Declaration, crds CRDScopes) []error { validateServiceAccounts(d.ServiceAccounts, report) validateAccess(d.Access, report) - if d.PrometheusAccess != nil && - len(d.PrometheusAccess.Deployments)+len(d.PrometheusAccess.DaemonSets)+len(d.PrometheusAccess.StatefulSets) == 0 { - report("prometheusAccess: names no workload; remove the section or list deployments, daemonsets or statefulsets") + if d.PrometheusAccess != nil { + if len(d.PrometheusAccess.Deployments)+len(d.PrometheusAccess.DaemonSets)+len(d.PrometheusAccess.StatefulSets) == 0 { + report("prometheusAccess: names no workload; remove the section or list deployments, daemonsets or statefulsets") + } + + validateWhen(d.PrometheusAccess.When, "prometheusAccess", report) } // Several checks walk maps; the reader and the e2e expectations get one order. From fc4028e35016ce1b0d0cdfcea09f6e5adf00dac8 Mon Sep 17 00:00:00 2001 From: Ivan Zvyagintsev Date: Wed, 23 Sep 2026 13:22:22 +0300 Subject: [PATCH 32/58] rbac: review of #479 -- ownership, wildcards, injection, cross-file names - The bare wildcard is quoted again: yamlScalar("*") returned `*`, which YAML reads as an alias, so every generated file with a wildcard failed to parse (finding 1; a regression of the stricter quoting, now round-trip tested). - A generated file lists the objects the generator wrote into it under the header ("# dmt:owns ..."; contract 2). An owned object the declaration no longer produces is a removal: dropping a legacy level, a ServiceAccount or an access entry now regenerates the file and names the object in the finding (finding 4). Every other object in the file is someone else's, of any kind: a ConfigMap or Secret beside the roles makes the fix refuse instead of dropping it, on a regeneration and on an orphan deletion (finding 2). - In a contract 1 file (no list) a rendered object counts as renamed only when its produced counterpart is absent from the render; a duplicate kept next to it is foreign (finding 3). - The access-level annotation of a legacy role is compared, and an aggregationRule on a role the generator writes without one is a divergence (finding 5). - The wildcard verb is refused at every user-facing level, group "*" and partial wildcards in resource names are refused, and the contract rule reports "*" verbs and apiGroups in a rendered capability (finding 6). - Generated names are checked across the whole model, not per file (finding 10). - Template delimiters are refused in every value the generator writes and in when (findings 13a, 13b). - The removals log of a fix is the union over render variants (finding 13j). The e2e fixtures and the golden files are regenerated for contract 2; the hand edits of sync-hand-edited are carried over. Signed-off-by: Ivan Zvyagintsev --- pkg/linters/rbac/rules/contract.go | 13 ++ pkg/linters/rbac/rules/contract_test.go | 10 + pkg/linters/rbac/rules/fixstate.go | 35 ++++ .../rbac/rules/generate/generate_test.go | 72 ++++++- pkg/linters/rbac/rules/generate/model.go | 19 +- pkg/linters/rbac/rules/generate/render.go | 49 ++++- .../templates/cainjector/rbac-for-us.yaml | 9 +- .../expected/templates/rbac-for-us.yaml | 4 +- .../expected/templates/rbac-to-us.yaml | 6 +- .../templates/rbacv2/manage/edit.yaml | 3 +- .../templates/rbacv2/manage/view.yaml | 3 +- .../expected/templates/rbacv2/use/admin.yaml | 3 +- .../expected/templates/rbacv2/use/edit.yaml | 3 +- .../expected/templates/rbacv2/use/view.yaml | 3 +- .../templates/user-authz-cluster-roles.yaml | 6 +- .../rbac/rules/rbaccontract/contract.go | 8 +- pkg/linters/rbac/rules/rbacyaml/load_test.go | 37 ++++ pkg/linters/rbac/rules/rbacyaml/validate.go | 73 ++++++- pkg/linters/rbac/rules/sync.go | 197 ++++++++++++++---- pkg/linters/rbac/rules/sync_test.go | 184 +++++++++++++++- .../templates/cainjector/rbac-for-us.yaml | 9 +- .../module/templates/rbac-for-us.yaml | 4 +- .../module/templates/rbac-to-us.yaml | 6 +- .../module/templates/rbacv2/manage/edit.yaml | 3 +- .../module/templates/rbacv2/manage/view.yaml | 3 +- .../module/templates/rbacv2/use/admin.yaml | 3 +- .../module/templates/rbacv2/use/edit.yaml | 3 +- .../module/templates/rbacv2/use/view.yaml | 3 +- .../templates/user-authz-cluster-roles.yaml | 6 +- .../templates/cainjector/rbac-for-us.yaml | 9 +- .../module/templates/rbac-for-us.yaml | 4 +- .../module/templates/rbac-to-us.yaml | 6 +- .../module/templates/rbacv2/manage/edit.yaml | 3 +- .../module/templates/rbacv2/manage/view.yaml | 3 +- .../module/templates/rbacv2/use/edit.yaml | 3 +- .../module/templates/rbacv2/use/view.yaml | 3 +- .../templates/user-authz-cluster-roles.yaml | 6 +- .../templates/cainjector/rbac-for-us.yaml | 9 +- .../module/templates/rbac-for-us.yaml | 4 +- .../module/templates/rbac-to-us.yaml | 6 +- .../module/templates/rbacv2/manage/edit.yaml | 3 +- .../module/templates/rbacv2/manage/view.yaml | 3 +- .../module/templates/rbacv2/use/admin.yaml | 3 +- .../module/templates/rbacv2/use/edit.yaml | 3 +- .../module/templates/rbacv2/use/view.yaml | 3 +- .../templates/user-authz-cluster-roles.yaml | 6 +- 46 files changed, 760 insertions(+), 94 deletions(-) diff --git a/pkg/linters/rbac/rules/contract.go b/pkg/linters/rbac/rules/contract.go index 0e4a56eb..ff95b8f5 100644 --- a/pkg/linters/rbac/rules/contract.go +++ b/pkg/linters/rbac/rules/contract.go @@ -325,6 +325,19 @@ func checkCapability(role *rbacv1.ClusterRole, scope string, scopes rbacyaml.CRD errorList.Errorf("capability %q must define rules", name) } + // A capability is what users are granted; nothing else checks its wildcards (the wildcards + // rule reads a ServiceAccount's templates only). resources: ["*"] stays possible for a group + // whose resources are not known statically; rbac.yaml asks for a reason there. + for _, rule := range role.Rules { + if slices.Contains(rule.Verbs, "*") { + errorList.Errorf("capability %q grants verb \"*\" on %s; list the verbs", name, strings.Join(append(append([]string{}, rule.APIGroups...), rule.Resources...), ", ")) + } + + if slices.Contains(rule.APIGroups, "*") { + errorList.Errorf("capability %q grants on every API group (apiGroups: [\"*\"]); name the groups", name) + } + } + if role.AggregationRule != nil { errorList.Errorf("capability %q must not define aggregationRule", name) } diff --git a/pkg/linters/rbac/rules/contract_test.go b/pkg/linters/rbac/rules/contract_test.go index fbb953b4..e2a3e373 100644 --- a/pkg/linters/rbac/rules/contract_test.go +++ b/pkg/linters/rbac/rules/contract_test.go @@ -194,6 +194,16 @@ func TestContract_Findings(t *testing.T) { `error: role "d8:system:admin" aggregation selector has invalid level "admin"`, }, }, + "review 6: a capability with wildcard verbs and groups": { + object: clusterRole("d8:namespace-capability:x:view", map[string]string{"module": "cert-manager", + "rbac.deckhouse.io/kind": "capability", "rbac.deckhouse.io/scope": "namespace", + "rbac.deckhouse.io/capability": "namespace-capability.x.view", "rbac.deckhouse.io/aggregate-to-namespace-as": "viewer", + }, i18n, "rules:\n- apiGroups: [\"*\"]\n resources: [secrets]\n verbs: [\"*\"]\n"), + wantErrs: []string{ + `error: capability "d8:namespace-capability:x:view" grants verb "*" on *, secrets; list the verbs`, + `error: capability "d8:namespace-capability:x:view" grants on every API group (apiGroups: ["*"]); name the groups`, + }, + }, "R21: the module label names another module": { object: clusterRole("d8:namespace-capability:x:view", map[string]string{"module": "other", "rbac.deckhouse.io/kind": "capability", "rbac.deckhouse.io/scope": "namespace", diff --git a/pkg/linters/rbac/rules/fixstate.go b/pkg/linters/rbac/rules/fixstate.go index 1c04f34e..dbd2f8b1 100644 --- a/pkg/linters/rbac/rules/fixstate.go +++ b/pkg/linters/rbac/rules/fixstate.go @@ -41,9 +41,11 @@ import ( var fixState = struct { sync.Mutex foreign map[string]map[string]struct{} + removals map[string]map[string]struct{} bootstrap map[string]map[string]bootstrap.Object }{ foreign: map[string]map[string]struct{}{}, + removals: map[string]map[string]struct{}{}, bootstrap: map[string]map[string]bootstrap.Object{}, } @@ -111,6 +113,7 @@ func resetFixState() { defer fixState.Unlock() fixState.foreign = map[string]map[string]struct{}{} + fixState.removals = map[string]map[string]struct{}{} fixState.bootstrap = map[string]map[string]bootstrap.Object{} fixOutcomes.Lock() @@ -242,3 +245,35 @@ func writeFileAtomic(path string, content []byte, perm os.FileMode) error { return nil } + +// recordRemovals adds what one render variant says a fix of the file takes away, so that the log +// of the fix names the removals of every variant, not only of the one whose closure runs. +func recordRemovals(file string, removals []string) { + fixState.Lock() + defer fixState.Unlock() + + known := fixState.removals[file] + if known == nil { + known = map[string]struct{}{} + fixState.removals[file] = known + } + + for _, r := range removals { + known[r] = struct{}{} + } +} + +// recordedRemovals returns the union of the removals every variant recorded for the file, sorted. +func recordedRemovals(file string) []string { + fixState.Lock() + defer fixState.Unlock() + + out := make([]string, 0, len(fixState.removals[file])) + for r := range fixState.removals[file] { + out = append(out, r) + } + + sort.Strings(out) + + return out +} diff --git a/pkg/linters/rbac/rules/generate/generate_test.go b/pkg/linters/rbac/rules/generate/generate_test.go index 2b496579..7e3e0cba 100644 --- a/pkg/linters/rbac/rules/generate/generate_test.go +++ b/pkg/linters/rbac/rules/generate/generate_test.go @@ -19,12 +19,15 @@ package generate import ( "os" "path/filepath" + "regexp" "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + sigsyaml "sigs.k8s.io/yaml" + "github.com/deckhouse/dmt/pkg/linters/rbac/rules/rbaccontract" "github.com/deckhouse/dmt/pkg/linters/rbac/rules/rbacyaml" ) @@ -186,7 +189,7 @@ func TestRender_GoldenAndIdempotent(t *testing.T) { generated, version := ParseHeader(f.Content) assert.True(t, generated) - assert.Equal(t, "1", version) + assert.Equal(t, rbaccontract.ContractVersion, version) } } @@ -303,7 +306,9 @@ func TestYAMLScalar(t *testing.T) { for in, want := range map[string]string{ "d8:cert-manager:cainjector": "d8:cert-manager:cainjector", "/metrics": "/metrics", - "*": "*", + "*": `"*"`, + "*foo": `"*foo"`, + "pods/*": "pods/*", "pods/log": "pods/log", "cert-manager.io": "cert-manager.io", "": `""`, @@ -321,6 +326,58 @@ func TestYAMLScalar(t *testing.T) { } } +// Every value the generator writes must read back as itself: a wildcard in a service account's +// rules renders a file that parses and carries the "*". +func TestRender_WildcardRoundTrip(t *testing.T) { + decl := &rbacyaml.Declaration{APIVersion: rbacyaml.APIVersionV1Alpha1, + Resources: []rbacyaml.Resource{{Group: "x.io", Resource: "things", Scope: "Cluster", System: map[string][]string{"viewer": {"get"}}}}, + ServiceAccounts: []rbacyaml.ServiceAccount{{Name: "worker", ClusterRules: []rbacyaml.PolicyRule{ + {APIGroups: []string{"*"}, Resources: []string{"*"}, Verbs: []string{"*"}}, + }}}, + } + + model, err := Build(Input{Module: "m", Namespace: "d8-m", Subsystems: []string{"security"}, Decl: decl}) + require.NoError(t, err) + + file := model.File("templates/rbac-for-us.yaml") + require.NotNil(t, file) + + for _, doc := range strings.Split(RenderFile(*file), "\n---\n") { + if !strings.HasPrefix(doc, "apiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRole\n") { + continue + } + + var role struct { + Rules []struct { + APIGroups []string `json:"apiGroups"` + Verbs []string `json:"verbs"` + } `json:"rules"` + } + require.NoError(t, sigsyaml.Unmarshal([]byte(helmTemplateLines.ReplaceAllString(doc, "")), &role), doc) + require.Len(t, role.Rules, 1) + assert.Equal(t, []string{"*"}, role.Rules[0].APIGroups) + assert.Equal(t, []string{"*"}, role.Rules[0].Verbs) + } +} + +// helmTemplateLines drops the Helm actions of a generated document so it parses as plain YAML. +var helmTemplateLines = regexp.MustCompile(`(?m)^.*\{\{.*\}\}.*$`) + +// A ServiceAccount and an access entry of one name map to the same ClusterRole in two templates; +// Helm would refuse the release, so the model does (review of #479, finding 10). +func TestBuild_RefusesNamesCollidingAcrossFiles(t *testing.T) { + decl := &rbacyaml.Declaration{APIVersion: rbacyaml.APIVersionV1Alpha1, + Resources: []rbacyaml.Resource{{Group: "x.io", Resource: "things", Scope: "Cluster", System: map[string][]string{"viewer": {"get"}}}}, + ServiceAccounts: []rbacyaml.ServiceAccount{{Name: "webhook", Path: "webhook", ClusterRules: []rbacyaml.PolicyRule{{APIGroups: []string{""}, Resources: []string{"pods"}, Verbs: []string{"get"}}}}}, + Access: []rbacyaml.Access{{Name: "webhook", Subjects: []rbacyaml.Subject{{Kind: "Group", Name: "g"}}, + ClusterRules: []rbacyaml.PolicyRule{{APIGroups: []string{""}, Resources: []string{"pods"}, Verbs: []string{"list"}}}}}, + } + + _, err := Build(Input{Module: "m", Namespace: "d8-m", Subsystems: []string{"security"}, Decl: decl}) + require.Error(t, err) + assert.Contains(t, err.Error(), "would both hold ClusterRole/d8:m:webhook") +} + // Two declared roles whose names differ only by the separator produce one binding name; the // model refuses instead of writing a file with two objects of one name. func TestBuild_RefusesDuplicateGeneratedNames(t *testing.T) { @@ -362,8 +419,17 @@ func TestBuild_PrometheusAccessWhen(t *testing.T) { func TestParseHeader_CRLF(t *testing.T) { generated, version := ParseHeader(Header() + "\r\n---\r\n") assert.True(t, generated) - assert.Equal(t, "1", version) + assert.Equal(t, rbaccontract.ContractVersion, version) generated, _ = ParseHeader(Header() + " \n---\n") assert.True(t, generated, "trailing spaces do not hand the file over to a person") } + +func TestParseOwned(t *testing.T) { + owned, listed := ParseOwned(Header() + "\n# dmt:owns ClusterRole/a\n# dmt:owns d8-m/Role/b\r\n---\n# dmt:owns not-a-header-line\n") + assert.True(t, listed) + assert.Equal(t, map[string]struct{}{"ClusterRole/a": {}, "d8-m/Role/b": {}}, owned) + + _, listed = ParseOwned(Header() + "\n---\n") + assert.False(t, listed, "a contract 1 file lists nothing") +} diff --git a/pkg/linters/rbac/rules/generate/model.go b/pkg/linters/rbac/rules/generate/model.go index 0f3d8408..367bf5e3 100644 --- a/pkg/linters/rbac/rules/generate/model.go +++ b/pkg/linters/rbac/rules/generate/model.go @@ -194,17 +194,26 @@ func Build(in Input) (*Model, error) { sort.Slice(model.Files, func(i, j int) bool { return model.Files[i].Path < model.Files[j].Path }) // A marker past 63 characters fails the contract; only a long module name can cause it. - for _, f := range model.Files { - seen := make(map[string]struct{}, len(f.Objects)) + // Helm refuses two objects of one name in a release, whichever templates they come from: a + // ServiceAccount and an access entry of the same name, an extra role with an absolute name + // equal to another account's role. + seen := map[string]string{} + for _, f := range model.Files { for _, o := range f.Objects { - if _, dup := seen[o.Identity()]; dup { - return nil, fmt.Errorf("%s would hold two objects named %s: two declared roles or bindings map to the same generated name", f.Path, o.Identity()) + if where, dup := seen[o.Identity()]; dup { + if where == f.Path { + return nil, fmt.Errorf("%s would hold two objects named %s: two declared roles or bindings map to the same generated name", f.Path, o.Identity()) + } + + return nil, fmt.Errorf("%s and %s would both hold %s: two declared entries map to the same generated name", where, f.Path, o.Identity()) } - seen[o.Identity()] = struct{}{} + seen[o.Identity()] = f.Path } + } + for _, f := range model.Files { for _, o := range f.Objects { if marker := o.Labels[rbaccontract.LabelCapability]; len(marker) > 63 { return nil, fmt.Errorf("capability marker %q is %d characters, a label value holds 63: the module name and the level name together are too long for %s", marker, len(marker), o.Name) diff --git a/pkg/linters/rbac/rules/generate/render.go b/pkg/linters/rbac/rules/generate/render.go index 431291d4..e0589609 100644 --- a/pkg/linters/rbac/rules/generate/render.go +++ b/pkg/linters/rbac/rules/generate/render.go @@ -20,6 +20,7 @@ import ( "maps" "regexp" "slices" + "sort" "strconv" "strings" @@ -29,6 +30,10 @@ import ( const ( headerPrefix = "# Generated by dmt (rbac/sync) from rbac.yaml, contract " headerSuffix = `. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand.` + // ownsPrefix starts one header line per object the generator wrote into the file (contract 2). + // The next regeneration removes an owned object the declaration no longer produces, and treats + // every other object in the file -- of any kind -- as someone else's. + ownsPrefix = "# dmt:owns " ) // Header is the first line of every generated file. The sync autofix rewrites a file only when it @@ -37,6 +42,30 @@ func Header() string { return headerPrefix + rbaccontract.ContractVersion + headerSuffix } +// ParseOwned returns the identities the header of a generated file lists as the generator's, and +// whether the header lists them at all (files of contract 1 do not). +func ParseOwned(content string) (map[string]struct{}, bool) { + lines := strings.Split(content, "\n") + if len(lines) < 2 { + return nil, false + } + + owned := map[string]struct{}{} + listed := false + + for _, line := range lines[1:] { + line = strings.TrimRight(line, " \t\r") + if !strings.HasPrefix(line, ownsPrefix) { + break + } + + listed = true + owned[strings.TrimPrefix(line, ownsPrefix)] = struct{}{} + } + + return owned, listed +} + // ParseHeader reports whether the content starts with a generator header and, if so, the contract // version it names. A header of another version means the file was generated under an older (or // newer) contract. @@ -76,6 +105,17 @@ func RenderFile(f File) string { b.WriteString(Header()) b.WriteByte('\n') + owns := make([]string, 0, len(f.Objects)) + for _, o := range f.Objects { + owns = append(owns, o.Identity()) + } + + sort.Strings(owns) + + for _, id := range owns { + b.WriteString(ownsPrefix + id + "\n") + } + // Consecutive objects under the same condition share one {{- if }} block. open := "" @@ -223,10 +263,11 @@ func yamlScalar(v string) string { return strconv.Quote(v) } -// plainScalarRe is the shape a value may take unquoted: a letter, underscore, slash or star, -// then the characters of a Kubernetes name, URL path, API group or verb list. Anything else -- an -// empty string, a leading indicator, a space or a digit first, a trailing colon -- is quoted. -var plainScalarRe = regexp.MustCompile(`^[A-Za-z_/][A-Za-z0-9._/:*-]*[A-Za-z0-9_/*]$|^[A-Za-z_*]$`) +// plainScalarRe is the shape a value may take unquoted: a letter, underscore or slash, then the +// characters of a Kubernetes name, URL path or API group. Anything else -- an empty string, a +// leading indicator (a leading `*` is an alias, so the wildcard `*` itself is quoted), a space or a +// digit first, a trailing colon -- is quoted. +var plainScalarRe = regexp.MustCompile(`^[A-Za-z_/][A-Za-z0-9._/:*-]*[A-Za-z0-9_/*]$|^[A-Za-z_]$`) // yaml11Reserved lists the words Helm's YAML 1.1 reader turns into booleans or null; "no" as a // resource name would render as false. diff --git a/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/cainjector/rbac-for-us.yaml b/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/cainjector/rbac-for-us.yaml index 6cf3648d..c326d803 100644 --- a/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/cainjector/rbac-for-us.yaml +++ b/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/cainjector/rbac-for-us.yaml @@ -1,4 +1,11 @@ -# Generated by dmt (rbac/sync) from rbac.yaml, contract 1. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +# Generated by dmt (rbac/sync) from rbac.yaml, contract 2. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +# dmt:owns ClusterRole/d8:cert-manager:cainjector +# dmt:owns ClusterRoleBinding/d8:cert-manager:cainjector +# dmt:owns ClusterRoleBinding/d8:cert-manager:cainjector:rbac-proxy +# dmt:owns d8-cert-manager/Role/cainjector +# dmt:owns d8-cert-manager/RoleBinding/cainjector +# dmt:owns d8-cert-manager/ServiceAccount/cainjector +# dmt:owns kube-system/RoleBinding/d8:cert-manager:cainjector:extension-apiserver-authentication-reader {{- if .Values.certManager.internal.enableCAInjector }} --- apiVersion: v1 diff --git a/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/rbac-for-us.yaml b/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/rbac-for-us.yaml index 9b7a734a..174967ef 100644 --- a/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/rbac-for-us.yaml +++ b/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/rbac-for-us.yaml @@ -1,4 +1,6 @@ -# Generated by dmt (rbac/sync) from rbac.yaml, contract 1. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +# Generated by dmt (rbac/sync) from rbac.yaml, contract 2. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +# dmt:owns ClusterRole/d8:cert-manager:admin-kubeconfig +# dmt:owns ClusterRoleBinding/d8:cert-manager:admin-kubeconfig --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole diff --git a/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/rbac-to-us.yaml b/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/rbac-to-us.yaml index 5f4dce11..2685760a 100644 --- a/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/rbac-to-us.yaml +++ b/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/rbac-to-us.yaml @@ -1,4 +1,8 @@ -# Generated by dmt (rbac/sync) from rbac.yaml, contract 1. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +# Generated by dmt (rbac/sync) from rbac.yaml, contract 2. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +# dmt:owns d8-cert-manager/Role/access-to-cert-manager +# dmt:owns d8-cert-manager/Role/access-to-cert-manager-auth +# dmt:owns d8-cert-manager/RoleBinding/access-to-cert-manager +# dmt:owns d8-cert-manager/RoleBinding/access-to-cert-manager-auth --- apiVersion: rbac.authorization.k8s.io/v1 kind: Role diff --git a/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/rbacv2/manage/edit.yaml b/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/rbacv2/manage/edit.yaml index a490c211..f48477b7 100644 --- a/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/rbacv2/manage/edit.yaml +++ b/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/rbacv2/manage/edit.yaml @@ -1,4 +1,5 @@ -# Generated by dmt (rbac/sync) from rbac.yaml, contract 1. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +# Generated by dmt (rbac/sync) from rbac.yaml, contract 2. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +# dmt:owns ClusterRole/d8:system-capability:cert-manager:edit --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole diff --git a/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/rbacv2/manage/view.yaml b/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/rbacv2/manage/view.yaml index 7013011b..8df6c345 100644 --- a/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/rbacv2/manage/view.yaml +++ b/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/rbacv2/manage/view.yaml @@ -1,4 +1,5 @@ -# Generated by dmt (rbac/sync) from rbac.yaml, contract 1. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +# Generated by dmt (rbac/sync) from rbac.yaml, contract 2. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +# dmt:owns ClusterRole/d8:system-capability:cert-manager:view --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole diff --git a/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/rbacv2/use/admin.yaml b/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/rbacv2/use/admin.yaml index 31792698..ed18c3e6 100644 --- a/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/rbacv2/use/admin.yaml +++ b/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/rbacv2/use/admin.yaml @@ -1,4 +1,5 @@ -# Generated by dmt (rbac/sync) from rbac.yaml, contract 1. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +# Generated by dmt (rbac/sync) from rbac.yaml, contract 2. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +# dmt:owns ClusterRole/d8:namespace-capability:cert-manager:admin --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole diff --git a/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/rbacv2/use/edit.yaml b/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/rbacv2/use/edit.yaml index 22d5ecb6..d8eb317d 100644 --- a/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/rbacv2/use/edit.yaml +++ b/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/rbacv2/use/edit.yaml @@ -1,4 +1,5 @@ -# Generated by dmt (rbac/sync) from rbac.yaml, contract 1. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +# Generated by dmt (rbac/sync) from rbac.yaml, contract 2. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +# dmt:owns ClusterRole/d8:namespace-capability:cert-manager:edit --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole diff --git a/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/rbacv2/use/view.yaml b/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/rbacv2/use/view.yaml index 92c9568c..04c4b92f 100644 --- a/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/rbacv2/use/view.yaml +++ b/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/rbacv2/use/view.yaml @@ -1,4 +1,5 @@ -# Generated by dmt (rbac/sync) from rbac.yaml, contract 1. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +# Generated by dmt (rbac/sync) from rbac.yaml, contract 2. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +# dmt:owns ClusterRole/d8:namespace-capability:cert-manager:view --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole diff --git a/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/user-authz-cluster-roles.yaml b/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/user-authz-cluster-roles.yaml index 371a61bb..59b8e276 100644 --- a/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/user-authz-cluster-roles.yaml +++ b/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/user-authz-cluster-roles.yaml @@ -1,4 +1,8 @@ -# Generated by dmt (rbac/sync) from rbac.yaml, contract 1. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +# Generated by dmt (rbac/sync) from rbac.yaml, contract 2. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +# dmt:owns ClusterRole/d8:user-authz:cert-manager:admin +# dmt:owns ClusterRole/d8:user-authz:cert-manager:cluster-editor +# dmt:owns ClusterRole/d8:user-authz:cert-manager:editor +# dmt:owns ClusterRole/d8:user-authz:cert-manager:user --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole diff --git a/pkg/linters/rbac/rules/rbaccontract/contract.go b/pkg/linters/rbac/rules/rbaccontract/contract.go index eeee04f4..efcb8363 100644 --- a/pkg/linters/rbac/rules/rbaccontract/contract.go +++ b/pkg/linters/rbac/rules/rbaccontract/contract.go @@ -106,7 +106,7 @@ var ( // ContractVersion is the version of the platform contract the generator writes templates for. It is // recorded in the header of every generated file, so that a file produced under an older contract // is recognizable after the contract changes. Bump it when the generated shape changes. -const ContractVersion = "1" +const ContractVersion = "2" // LegacyKebab returns the name suffix of the legacy ClusterRole for an access level, as the // modules spell it today (d8:user-authz::cluster-editor for ClusterEditor). @@ -134,8 +134,10 @@ func LegacyKebab(level string) string { // first four. var LegacyLevels = []string{"User", "PrivilegedUser", "Editor", "Admin", "ClusterEditor", "ClusterAdmin", "SuperAdmin"} -// Verbs are the resource verbs Kubernetes RBAC knows. rbac.yaml lists verbs explicitly; there -// are no aliases (spec 005 R2). "*" is accepted here and judged by the wildcards rule. +// Verbs are the resource verbs Kubernetes RBAC knows, with the wildcard. rbac.yaml lists verbs +// explicitly and has no aliases (spec 005 R2); the wildcard is refused at every user-facing level +// (rbacyaml.Validate) and in a rendered capability (the contract rule), and judged by the wildcards +// rule in a ServiceAccount's own rules. var Verbs = append(slices.Clone(ResourceVerbs), "*") // ResourceVerbs are the verbs a rule may list, without the wildcard. diff --git a/pkg/linters/rbac/rules/rbacyaml/load_test.go b/pkg/linters/rbac/rules/rbacyaml/load_test.go index 4516eefd..5dfa9de7 100644 --- a/pkg/linters/rbac/rules/rbacyaml/load_test.go +++ b/pkg/linters/rbac/rules/rbacyaml/load_test.go @@ -309,6 +309,39 @@ namespace: crds: certManagerCRDs, wantErr: "", }, + "review 6: the wildcard verb at a user-facing level": { + yaml: entry(`group: cert-manager.io +resource: issuers +namespace: + viewer: ["*"]`), + crds: certManagerCRDs, + wantErr: `namespace.viewer grants "*", every verb`, + }, + "review 6: every API group": { + yaml: entry(`group: "*" +resource: things +scope: Namespaced +noAccess: nobody`), + crds: certManagerCRDs, + wantErr: `group "*" grants the resource in every API group`, + }, + "review 6: a partial wildcard in the resource name": { + yaml: entry(`group: external.io +resource: "secret*" +scope: Namespaced +noAccess: nobody`), + crds: certManagerCRDs, + wantErr: `RBAC matches "*" only as a whole name`, + }, + "review 13b: when with a template delimiter": { + yaml: entry(`group: cert-manager.io +resource: issuers +when: 'true }}{{ include "x" . }}{{ if true' +namespace: + viewer: [get]`), + crds: certManagerCRDs, + wantErr: "holds a template delimiter; write the condition only", + }, "R26: namespaced resource at a system level without reason": { yaml: entry(`group: cert-manager.io resource: issuers @@ -453,6 +486,10 @@ func TestValidate_TopLevel(t *testing.T) { yaml: "apiVersion: rbac.deckhouse.io/v1alpha1\nprometheusAccess:\n deployments: [a]\n when: 'and (.Values.x'\n", wantErr: "prometheusAccess: when", }, + "review 13a: a template delimiter in a capability text": { + yaml: "apiVersion: rbac.deckhouse.io/v1alpha1\ncapabilities:\n namespace.admin: {title: {en: 'Use {{ .Values.x }}', ru: b}, description: {en: c, ru: d}}\n", + wantErr: `capabilities.namespace.admin.title.en: "Use {{ .Values.x }}" holds a template delimiter`, + }, "prometheusAccess: empty": { yaml: "apiVersion: rbac.deckhouse.io/v1alpha1\nprometheusAccess: {}\n", wantErr: "prometheusAccess: names no workload", diff --git a/pkg/linters/rbac/rules/rbacyaml/validate.go b/pkg/linters/rbac/rules/rbacyaml/validate.go index 27feddac..dc89e199 100644 --- a/pkg/linters/rbac/rules/rbacyaml/validate.go +++ b/pkg/linters/rbac/rules/rbacyaml/validate.go @@ -18,6 +18,7 @@ package rbacyaml import ( "fmt" + "reflect" "slices" "sort" "strings" @@ -48,6 +49,13 @@ func validateWhen(when, where string, report reporter) { return } + // The condition is written between "{{- if " and " }}"; a brace pair inside would close that + // action and put the rest of the value into the file as template text of its own. + if strings.Contains(when, "{{") || strings.Contains(when, "}}") { + report("%s: when %q holds a template delimiter; write the condition only, without {{ and }}", where, when) + return + } + if _, err := template.New("when").Funcs(helmFuncs).Parse("{{ if " + when + " }}{{ end }}"); err != nil { report("%s: when %q is not a Helm expression: %v", where, when, err) } @@ -71,6 +79,8 @@ func Validate(d *Declaration, crds CRDScopes) []error { report("apiVersion must be %q, got %q", APIVersionV1Alpha1, d.APIVersion) } + validateNoTemplateText(reflect.ValueOf(d).Elem(), "", report) + for _, s := range d.Subsystems { if !rbaccontract.IsSubsystem(s) { report("subsystems: %q is not a subsystem of the role model (%s)", s, strings.Join(rbaccontract.Subsystems, ", ")) @@ -129,6 +139,14 @@ func validateResource(r *Resource, where string, crds CRDScopes, usedCapabilitie validateWhen(r.When, where, report) + if r.Group == "*" { + report("%s: group \"*\" grants the resource in every API group; name the group", where) + } + + if strings.Contains(r.Resource, "*") && r.Resource != "*" { + report("%s: resource %q: RBAC matches \"*\" only as a whole name; list the resources or use \"*\" with reason", where, r.Resource) + } + scope, scopeErr := resolveScope(r, crds) if scopeErr != "" { report("%s: %s", where, scopeErr) @@ -217,7 +235,12 @@ func validateLevels(levels map[string][]string, lineage string, allowed []string } for _, verb := range verbs { - if !slices.Contains(rbaccontract.Verbs, verb) { + switch { + case verb == "*": + // No other rule sees a user-facing capability's wildcard: the wildcards rule reads a + // ServiceAccount's own templates only. + report("%s: %s.%s grants \"*\", every verb including the ones Kubernetes adds later; list the verbs (%s)", where, lineage, level, strings.Join(rbaccontract.ResourceVerbs, ", ")) + case !slices.Contains(rbaccontract.Verbs, verb): report("%s: %s.%s: %q is not a verb; verbs are listed explicitly (%s), there are no aliases", where, lineage, level, verb, strings.Join(rbaccontract.ResourceVerbs, ", ")) } } @@ -429,3 +452,51 @@ func firstDuplicate(values []string) string { return "" } + +// validateNoTemplateText refuses a template delimiter in any value the generator writes into a +// template, apart from the `when` conditions (validateWhen judges those). Helm would evaluate it: +// a title like "Use {{ .Values.x }}" breaks the render or renders something the declaration does +// not say, and the sync rule then diverges forever. +func validateNoTemplateText(v reflect.Value, path string, report reporter) { + switch v.Kind() { + case reflect.String: + if s := v.String(); strings.Contains(s, "{{") || strings.Contains(s, "}}") { + report("%s: %q holds a template delimiter; the value is written into a Helm template as it is", strings.TrimPrefix(path, "."), s) + } + case reflect.Pointer, reflect.Interface: + if !v.IsNil() { + validateNoTemplateText(v.Elem(), path, report) + } + case reflect.Struct: + t := v.Type() + for i := range v.NumField() { + if t.Field(i).Name == "When" || !t.Field(i).IsExported() { + continue + } + + validateNoTemplateText(v.Field(i), path+"."+yamlName(t.Field(i)), report) + } + case reflect.Slice, reflect.Array: + for i := range v.Len() { + validateNoTemplateText(v.Index(i), fmt.Sprintf("%s[%d]", path, i), report) + } + case reflect.Map: + keys := v.MapKeys() + sort.Slice(keys, func(i, j int) bool { return fmt.Sprint(keys[i]) < fmt.Sprint(keys[j]) }) + + for _, k := range keys { + validateNoTemplateText(k, path, report) + validateNoTemplateText(v.MapIndex(k), fmt.Sprintf("%s.%v", path, k), report) + } + } +} + +// yamlName is the key a field has in rbac.yaml, for the messages. +func yamlName(f reflect.StructField) string { + name, _, _ := strings.Cut(f.Tag.Get("yaml"), ",") + if name == "" { + return f.Name + } + + return name +} diff --git a/pkg/linters/rbac/rules/sync.go b/pkg/linters/rbac/rules/sync.go index c87949fe..dbb0f285 100644 --- a/pkg/linters/rbac/rules/sync.go +++ b/pkg/linters/rbac/rules/sync.go @@ -243,9 +243,67 @@ func (r *SyncRule) compareText(modulePath string, model *generate.Model, actual divergences[file.Path] = append(divergences[file.Path], "the file carries the generator header but is not what the declaration renders now (a rule under `when`, a text edit or an older generator); remove the header to maintain it by hand") } + + if generated { + produced := make(map[string]struct{}, len(file.Objects)) + for _, o := range file.Objects { + produced[o.Identity()] = struct{}{} + } + + divergences[file.Path] = append(divergences[file.Path], noLongerProduced(string(content), produced)...) + } + } + + // A generated file the declaration produces nothing for any more renders objects no model + // file names; it is reported under its own path so that the orphan fix can judge it. + inModel := make(map[string]struct{}, len(model.Files)) + for _, f := range model.Files { + inModel[f.Path] = struct{}{} + } + + seen := map[string]struct{}{} + + for _, object := range r.module.GetStorage() { + path := object.ShortPath() + if _, ok := inModel[path]; ok { + continue + } + + if _, ok := seen[path]; ok { + continue + } + + seen[path] = struct{}{} + + content, err := os.ReadFile(filepath.Join(modulePath, path)) + if err != nil { + continue + } + + if generated, _ := generate.ParseHeader(string(content)); generated { + divergences[path] = append(divergences[path], noLongerProduced(string(content), nil)...) + } } } +// noLongerProduced lists, as divergences, the objects the header of a generated file names as the +// generator's that the declaration no longer produces there. +func noLongerProduced(content string, produced map[string]struct{}) []string { + owned, _ := generate.ParseOwned(content) + + out := make([]string, 0, len(owned)) + + for id := range owned { + if _, ok := produced[id]; !ok { + out = append(out, id+" was generated into this file and the declaration no longer produces it") + } + } + + sort.Strings(out) + + return out +} + // report emits one finding per template, with the fix that closes it when one exists: the // regeneration of a produced file, the deletion of an orphaned generated file, or none. func (r *SyncRule) report(modulePath string, model *generate.Model, divergences map[string][]string) { @@ -317,25 +375,7 @@ func (r *SyncRule) orphanGeneratedFile(path string, model *generate.Model) (bool return false, "the file serves both role models behind the version gate" } - managed := r.managedObjects(model) - - var foreign []string - - for index, object := range r.module.GetStorage() { - if object.ShortPath() != path { - continue - } - - switch object.Unstructured.GetKind() { - case "ClusterRole", "Role", "ClusterRoleBinding", "RoleBinding", "ServiceAccount": - default: - continue - } - - if _, owned := managed[index.AsString()]; !owned { - foreign = append(foreign, index.AsString()) - } - } + foreign := r.foreignIn(path, nil, nil, model) if len(foreign) > 0 { sort.Strings(foreign) @@ -353,9 +393,12 @@ func (r *SyncRule) orphanGeneratedFile(path string, model *generate.Model) (bool // generator's to delete. func removeFileFix(modulePath, path string, removed []string) errors.AutofixFunc { fullPath := filepath.Join(modulePath, path) + recordRemovals(fullPath, removed) return func() error { return fixOnce(fullPath, func() error { + removed := recordedRemovals(fullPath) + if foreign := foreignObjectsOf(fullPath); len(foreign) > 0 { return fmt.Errorf("%s also holds objects the declaration does not describe (%s), some only under other values; it is not deleted -- declare them in %s or move them to another template", path, strings.Join(foreign, ", "), rbacyaml.Filename) @@ -422,16 +465,27 @@ func (r *SyncRule) legacyFiles() map[string]string { return out } -// foreignObjects lists the RBAC objects the render placed in the file that the declaration does not -// produce -- a controller ClusterRole beside a declared ServiceAccount, a hand-written binding. The -// generator writes the whole file, so regenerating it would drop them; they are the reason a -// regeneration is refused until they are declared or moved. +// foreignObjects lists the rendered objects of the file that a regeneration would drop without the +// declaration knowing them: everything that is neither produced now nor the generator's own. func (r *SyncRule) foreignObjects(file generate.File, model *generate.Model) []string { - produced := map[string]struct{}{} + produced := make(map[string]struct{}, len(file.Objects)) for _, o := range file.Objects { produced[o.Identity()] = struct{}{} } + return r.foreignIn(file.Path, produced, file.Objects, model) +} + +// foreignIn judges every rendered object of the template at path, of any kind. An object the +// declaration produces is kept. An object the header lists as the generator's (contract 2) is a +// removal: the declaration no longer names it and wins (D14). In a file whose header lists no +// objects -- a contract 1 file or one maintained by hand -- a legacy role or a module capability +// the declaration does not produce is a removal for the same reason, and an RBAC object the +// generator now produces under another name is a rename. Everything else is someone else's: a +// ConfigMap, a Secret, a hand-written role -- and the fix refuses to drop it. +func (r *SyncRule) foreignIn(path string, produced map[string]struct{}, producedObjects []generate.Object, model *generate.Model) []string { + owned, listed := ownedBy(filepath.Join(r.module.GetPath(), path)) + // Where the declaration puts every object it produces: an object rendered from another file // than that is misplaced rather than unknown, and the refusal says so. placed := map[string]string{} @@ -442,36 +496,47 @@ func (r *SyncRule) foreignObjects(file generate.File, model *generate.Model) []s } } + managed := r.managedObjects(model) + storage := r.module.GetStorage() + + rendered := make(map[string]struct{}, len(storage)) + for index := range storage { + rendered[index.AsString()] = struct{}{} + } + var out []string - for index, object := range r.module.GetStorage() { - if object.ShortPath() != file.Path { + for index, object := range storage { + if object.ShortPath() != path { continue } - switch object.Unstructured.GetKind() { - case "ClusterRole", "Role", "ClusterRoleBinding", "RoleBinding", "ServiceAccount": - default: + id := index.AsString() + + if _, ok := produced[id]; ok { continue } - if _, ok := produced[index.AsString()]; ok { + if _, ok := owned[id]; ok { continue } - // An object the generator produces under another name -- a binding with the same roleRef - // and subjects, a role with the same rules -- is replaced, not lost; the declaration carries - // its rights on. Only what has no counterpart is foreign. - if replacedByProduced(object, file.Objects, renderedRolesOf(r.module.GetStorage(), file.Path)) { + if where, declared := placed[id]; declared { + out = append(out, id+" (the declaration puts it in "+where+"; move it there or delete both files and run the fix)") continue } - if path, declared := placed[index.AsString()]; declared { - out = append(out, index.AsString()+" (the declaration puts it in "+path+"; move it there or delete both files and run the fix)") - continue + if !listed && isRBACKind(object.Unstructured.GetKind()) { + if m, ok := managed[id]; ok && m.class != generate.ClassDeclared { + continue + } + + if replacedByProduced(object, producedObjects, renderedRolesOf(storage, path), rendered) { + continue + } } - out = append(out, index.AsString()) + out = append(out, id) } sort.Strings(out) @@ -479,9 +544,34 @@ func (r *SyncRule) foreignObjects(file generate.File, model *generate.Model) []s return out } +// ownedBy reads the objects the header of a generated file lists as the generator's, and whether +// it lists any at all. +func ownedBy(fullPath string) (map[string]struct{}, bool) { + content, err := os.ReadFile(fullPath) + if err != nil { + return nil, false + } + + if generated, _ := generate.ParseHeader(string(content)); !generated { + return nil, false + } + + return generate.ParseOwned(string(content)) +} + +// isRBACKind reports whether the kind is one the generator produces. +func isRBACKind(kind string) bool { + switch kind { + case "ClusterRole", "Role", "ClusterRoleBinding", "RoleBinding", "ServiceAccount": + return true + } + + return false +} + // replacedByProduced reports whether a rendered object has a produced counterpart of the same kind // and content under another name. -func replacedByProduced(object storage.StoreObject, produced []generate.Object, renderedRoles map[string]tupleSet) bool { +func replacedByProduced(object storage.StoreObject, produced []generate.Object, renderedRoles map[string]tupleSet, rendered map[string]struct{}) bool { content := object.Unstructured.UnstructuredContent() switch object.Unstructured.GetKind() { @@ -498,6 +588,12 @@ func replacedByProduced(object storage.StoreObject, produced []generate.Object, continue } + // A counterpart already in the render is not what this object became: this one is a + // duplicate someone keeps for its own reasons, not an old name. + if _, present := rendered[o.Identity()]; present { + continue + } + if roleRefMatches(o, binding.RoleRef, produced, renderedRoles) && subjectSetOf(o.Subjects) == got { return true } @@ -515,6 +611,10 @@ func replacedByProduced(object storage.StoreObject, produced []generate.Object, continue } + if _, present := rendered[o.Identity()]; present { + continue + } + always, conditional := expandModelRules(o.Rules) for t := range conditional { always.add(t) @@ -723,6 +823,19 @@ func compareObject(expected generate.Object, actual storage.StoreObject, module if expected.Class == generate.ClassCapability { out = append(out, compareCapabilityLabels(expected, actual, module, aggregation)...) + } else if aggregation != nil { + // The generator writes no aggregationRule on any other role; one in the render collects + // rights the declaration does not name, and a regeneration would drop it. + out = append(out, fmt.Sprintf("%s: an aggregationRule is in the render but the declaration produces none", id)) + } + + // The access level decides which user-authz level a legacy role aggregates into; the same + // rules under another level are other rights. + if expected.Class == generate.ClassLegacy { + want := expected.Annotations[rbaccontract.AccessLevelAnnotation] + if got := actual.Unstructured.GetAnnotations()[rbaccontract.AccessLevelAnnotation]; got != want { + out = append(out, fmt.Sprintf("%s: the %s annotation is %q in the render, the declaration produces %q", id, rbaccontract.AccessLevelAnnotation, got, want)) + } } case "ServiceAccount": sa := new(corev1.ServiceAccount) @@ -865,7 +978,8 @@ func removalsOf(divergences []string) []string { var out []string for _, d := range divergences { - if strings.Contains(d, "is in the render but not declared") || strings.Contains(d, "is in the render but rbac.yaml does not produce it") { + if strings.Contains(d, "is in the render but not declared") || strings.Contains(d, "is in the render but rbac.yaml does not produce it") || + strings.Contains(d, "the declaration no longer produces it") { out = append(out, d) } } @@ -882,9 +996,12 @@ func regenerateFix(modulePath string, file generate.File, foreign, removals []st // exists; the closure that runs first checks the union of them and writes, the others report // its outcome (R36). A right rendered only under some values is therefore not lost (D3). recordForeignObjects(fullPath, foreign) + recordRemovals(fullPath, removals) return func() error { return fixOnce(fullPath, func() error { + removals := recordedRemovals(fullPath) + existing, err := os.ReadFile(fullPath) exists := err == nil diff --git a/pkg/linters/rbac/rules/sync_test.go b/pkg/linters/rbac/rules/sync_test.go index af81f372..44c6190d 100644 --- a/pkg/linters/rbac/rules/sync_test.go +++ b/pkg/linters/rbac/rules/sync_test.go @@ -37,6 +37,7 @@ import ( "github.com/deckhouse/dmt/pkg" "github.com/deckhouse/dmt/pkg/errors" "github.com/deckhouse/dmt/pkg/linters/rbac/rules/generate" + "github.com/deckhouse/dmt/pkg/linters/rbac/rules/rbaccontract" "github.com/deckhouse/dmt/pkg/linters/rbac/rules/rbacyaml" ) @@ -380,7 +381,7 @@ func TestSync_Autofix(t *testing.T) { generated, version := generate.ParseHeader(string(written)) assert.True(t, generated) - assert.Equal(t, "1", version) + assert.Equal(t, rbaccontract.ContractVersion, version) // Running the same fix again, in a new run, changes nothing. resetFixState() @@ -482,7 +483,7 @@ func TestSync_ForeignContractVersionIsRegenerated(t *testing.T) { want := generate.RenderFile(*model.File(rel)) _, body, _ := strings.Cut(want, "\n") - stale := strings.Replace(generate.Header(), "contract 1.", "contract 0.", 1) + "\n" + body + stale := strings.Replace(generate.Header(), "contract "+rbaccontract.ContractVersion+".", "contract 0.", 1) + "\n" + body path := filepath.Join(modulePath, rel) require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) @@ -491,7 +492,7 @@ func TestSync_ForeignContractVersionIsRegenerated(t *testing.T) { errorList := runSync(t, modulePath, store) got := texts(errorList) require.Len(t, got, 1, "got: %v", got) - assert.Contains(t, got[0], `templates/rbacv2/use/view.yaml does not match rbac.yaml: the file was generated under contract version "0"; the current contract is "1". Run `+"`dmt lint --linter rbac --fix`") + assert.Contains(t, got[0], `templates/rbacv2/use/view.yaml does not match rbac.yaml: the file was generated under contract version "0"; the current contract is "`+rbaccontract.ContractVersion+`". Run `+"`dmt lint --linter rbac --fix`") for _, fix := range errorList.GetFixes() { fix() @@ -813,6 +814,9 @@ func TestSync_RenamedObjectsAreNotForeign(t *testing.T) { const rel = "templates/rbac-to-us.yaml" + // A file of contract 1 lists no owned objects; only there does a rename apply. + asContractOne(t, filepath.Join(modulePath, rel)) + // The render still carries the old names a hand-written module gave the metrics access: the // Role and its RoleBinding, with the same rules, roleRef and subjects. store := renderedFrom(t, model, func(o *generate.Object) bool { @@ -1193,3 +1197,177 @@ func TestSync_WhenOnDeckhouseVersionIsNotAGate(t *testing.T) { require.NoError(t, err) assert.Equal(t, generate.RenderFile(*model.File(rel)), string(after)) } + +// asContractOne rewrites a generated file the way contract 1 wrote it: the header without the list +// of owned objects. +func asContractOne(t *testing.T, path string) { + t.Helper() + + content, err := os.ReadFile(path) + require.NoError(t, err) + + lines := strings.Split(string(content), "\n") + kept := lines[:1] + kept[0] = strings.Replace(kept[0], "contract "+rbaccontract.ContractVersion+".", "contract 1.", 1) + + for _, l := range lines[1:] { + if !strings.HasPrefix(l, "# dmt:owns ") { + kept = append(kept, l) + } + } + + require.NoError(t, os.WriteFile(path, []byte(strings.Join(kept, "\n")), 0o600)) +} + +// A generated file may carry objects of other kinds; the fix never drops them (review of #479, +// finding 2): neither on a regeneration nor when the declaration stops producing the file. +func TestSync_NonRBACObjectsInGeneratedFilesAreKept(t *testing.T) { + resetFixState() + t.Cleanup(resetFixState) + + modulePath := syncModuleDir(t) + model := syncModel(t, modulePath) + writeGenerated(t, modulePath, model) + + const rel = "templates/cainjector/rbac-for-us.yaml" + + store := renderedFrom(t, model, func(o *generate.Object) bool { + if o.Kind == "ClusterRole" && o.Name == "d8:cert-manager:cainjector" { + o.Rules = o.Rules[:1] + } + + return true + }) + putConfigMap(t, store, rel, "cainjector-extra") + + before, err := os.ReadFile(filepath.Join(modulePath, rel)) + require.NoError(t, err) + + errorList := runSync(t, modulePath, store) + for _, fix := range errorList.GetFixes() { + fix() + } + + remaining := errorList.GetErrors() + require.Len(t, remaining, 1, "got: %v", texts(errorList)) + require.Error(t, remaining[0].FixError) + assert.Contains(t, remaining[0].FixError.Error(), "also holds objects the declaration does not produce: d8-cert-manager/ConfigMap/cainjector-extra") + + after, err := os.ReadFile(filepath.Join(modulePath, rel)) + require.NoError(t, err) + assert.Equal(t, string(before), string(after)) +} + +// An object the generator wrote earlier and the declaration no longer names leaves the file with +// the regeneration and is named in the finding, even when the file holds other objects (review of +// #479, finding 4): dropping the legacy Admin level rewrites the legacy file. +func TestSync_DroppedLegacyLevelIsRemoved(t *testing.T) { + resetFixState() + t.Cleanup(resetFixState) + + modulePath := syncModuleDir(t) + model := syncModel(t, modulePath) + writeGenerated(t, modulePath, model) + store := renderedFrom(t, model, nil) + + decl, err := rbacyaml.Load(modulePath) + require.NoError(t, err) + + for i := range decl.Resources { + delete(decl.Resources[i].Legacy, "Admin") + } + + raw, err := yaml.Marshal(decl) + require.NoError(t, err) + require.NoError(t, os.WriteFile(rbacyaml.Path(modulePath), raw, 0o600)) + + const rel = "templates/user-authz-cluster-roles.yaml" + + errorList := runSync(t, modulePath, store) + got := texts(errorList) + require.Len(t, got, 1, "got: %v", got) + assert.Contains(t, got[0], "ClusterRole/d8:user-authz:cert-manager:admin") + + for _, fix := range errorList.GetFixes() { + fix() + } + + assert.Empty(t, errorList.GetErrors(), "the fix applies") + + written, err := os.ReadFile(filepath.Join(modulePath, rel)) + require.NoError(t, err) + assert.NotContains(t, string(written), "d8:user-authz:cert-manager:admin") + assert.Contains(t, string(written), "d8:user-authz:cert-manager:user") +} + +// A hand-written role with the same rules as a produced one is a duplicate, not an old name, when +// the produced one is rendered too (review of #479, finding 3): the fix refuses instead of dropping it. +func TestSync_DuplicateOfARenderedObjectIsNotARename(t *testing.T) { + resetFixState() + t.Cleanup(resetFixState) + + modulePath := syncModuleDir(t) + model := syncModel(t, modulePath) + writeGenerated(t, modulePath, model) + + const rel = "templates/rbac-to-us.yaml" + + asContractOne(t, filepath.Join(modulePath, rel)) + + store := renderedFrom(t, model, nil) + + var produced generate.Object + + for _, o := range model.File(rel).Objects { + if o.Kind == "Role" { + produced = o + } + } + + duplicate := produced + duplicate.Name = "extra-reader-bound-elsewhere" + putObject(t, store, rel, duplicate) + + errorList := runSync(t, modulePath, store) + for _, fix := range errorList.GetFixes() { + fix() + } + + remaining := errorList.GetErrors() + require.Len(t, remaining, 1, "got: %v", texts(errorList)) + require.Error(t, remaining[0].FixError) + assert.Contains(t, remaining[0].FixError.Error(), "d8-cert-manager/Role/extra-reader-bound-elsewhere") +} + +func putConfigMap(t *testing.T, store *storage.UnstructuredObjectStore, path, name string) { + t.Helper() + + cm := &corev1.ConfigMap{TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "ConfigMap"}, + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "d8-cert-manager", Labels: map[string]string{"heritage": "deckhouse", "module": syncModule}}} + content, err := runtime.DefaultUnstructuredConverter.ToUnstructured(cm) + require.NoError(t, err) + require.NoError(t, store.Put("/module/"+path, path, content, []byte("d8-cert-manager/ConfigMap/"+name))) +} + +// The access level of a legacy role and an aggregationRule on a declared role are compared too +// (review of #479, finding 5): a render that differs there is a divergence, not silence. +func TestSync_AccessLevelAndAggregationAreCompared(t *testing.T) { + resetFixState() + t.Cleanup(resetFixState) + + modulePath := syncModuleDir(t) + model := syncModel(t, modulePath) + writeGenerated(t, modulePath, model) + + store := renderedFrom(t, model, func(o *generate.Object) bool { + if o.Name == "d8:user-authz:cert-manager:user" { + o.Annotations = map[string]string{rbaccontract.AccessLevelAnnotation: "SuperAdmin"} + } + + return true + }) + + got := texts(runSync(t, modulePath, store)) + require.Len(t, got, 1, "got: %v", got) + assert.Contains(t, got[0], `ClusterRole/d8:user-authz:cert-manager:user: the user-authz.deckhouse.io/access-level annotation is "SuperAdmin" in the render, the declaration produces "User"`) +} diff --git a/test/e2e/testdata/rbac/sync-clean/module/templates/cainjector/rbac-for-us.yaml b/test/e2e/testdata/rbac/sync-clean/module/templates/cainjector/rbac-for-us.yaml index 6cf3648d..c326d803 100644 --- a/test/e2e/testdata/rbac/sync-clean/module/templates/cainjector/rbac-for-us.yaml +++ b/test/e2e/testdata/rbac/sync-clean/module/templates/cainjector/rbac-for-us.yaml @@ -1,4 +1,11 @@ -# Generated by dmt (rbac/sync) from rbac.yaml, contract 1. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +# Generated by dmt (rbac/sync) from rbac.yaml, contract 2. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +# dmt:owns ClusterRole/d8:cert-manager:cainjector +# dmt:owns ClusterRoleBinding/d8:cert-manager:cainjector +# dmt:owns ClusterRoleBinding/d8:cert-manager:cainjector:rbac-proxy +# dmt:owns d8-cert-manager/Role/cainjector +# dmt:owns d8-cert-manager/RoleBinding/cainjector +# dmt:owns d8-cert-manager/ServiceAccount/cainjector +# dmt:owns kube-system/RoleBinding/d8:cert-manager:cainjector:extension-apiserver-authentication-reader {{- if .Values.certManager.internal.enableCAInjector }} --- apiVersion: v1 diff --git a/test/e2e/testdata/rbac/sync-clean/module/templates/rbac-for-us.yaml b/test/e2e/testdata/rbac/sync-clean/module/templates/rbac-for-us.yaml index 9b7a734a..174967ef 100644 --- a/test/e2e/testdata/rbac/sync-clean/module/templates/rbac-for-us.yaml +++ b/test/e2e/testdata/rbac/sync-clean/module/templates/rbac-for-us.yaml @@ -1,4 +1,6 @@ -# Generated by dmt (rbac/sync) from rbac.yaml, contract 1. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +# Generated by dmt (rbac/sync) from rbac.yaml, contract 2. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +# dmt:owns ClusterRole/d8:cert-manager:admin-kubeconfig +# dmt:owns ClusterRoleBinding/d8:cert-manager:admin-kubeconfig --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole diff --git a/test/e2e/testdata/rbac/sync-clean/module/templates/rbac-to-us.yaml b/test/e2e/testdata/rbac/sync-clean/module/templates/rbac-to-us.yaml index 5f4dce11..2685760a 100644 --- a/test/e2e/testdata/rbac/sync-clean/module/templates/rbac-to-us.yaml +++ b/test/e2e/testdata/rbac/sync-clean/module/templates/rbac-to-us.yaml @@ -1,4 +1,8 @@ -# Generated by dmt (rbac/sync) from rbac.yaml, contract 1. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +# Generated by dmt (rbac/sync) from rbac.yaml, contract 2. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +# dmt:owns d8-cert-manager/Role/access-to-cert-manager +# dmt:owns d8-cert-manager/Role/access-to-cert-manager-auth +# dmt:owns d8-cert-manager/RoleBinding/access-to-cert-manager +# dmt:owns d8-cert-manager/RoleBinding/access-to-cert-manager-auth --- apiVersion: rbac.authorization.k8s.io/v1 kind: Role diff --git a/test/e2e/testdata/rbac/sync-clean/module/templates/rbacv2/manage/edit.yaml b/test/e2e/testdata/rbac/sync-clean/module/templates/rbacv2/manage/edit.yaml index a490c211..f48477b7 100644 --- a/test/e2e/testdata/rbac/sync-clean/module/templates/rbacv2/manage/edit.yaml +++ b/test/e2e/testdata/rbac/sync-clean/module/templates/rbacv2/manage/edit.yaml @@ -1,4 +1,5 @@ -# Generated by dmt (rbac/sync) from rbac.yaml, contract 1. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +# Generated by dmt (rbac/sync) from rbac.yaml, contract 2. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +# dmt:owns ClusterRole/d8:system-capability:cert-manager:edit --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole diff --git a/test/e2e/testdata/rbac/sync-clean/module/templates/rbacv2/manage/view.yaml b/test/e2e/testdata/rbac/sync-clean/module/templates/rbacv2/manage/view.yaml index 7013011b..8df6c345 100644 --- a/test/e2e/testdata/rbac/sync-clean/module/templates/rbacv2/manage/view.yaml +++ b/test/e2e/testdata/rbac/sync-clean/module/templates/rbacv2/manage/view.yaml @@ -1,4 +1,5 @@ -# Generated by dmt (rbac/sync) from rbac.yaml, contract 1. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +# Generated by dmt (rbac/sync) from rbac.yaml, contract 2. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +# dmt:owns ClusterRole/d8:system-capability:cert-manager:view --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole diff --git a/test/e2e/testdata/rbac/sync-clean/module/templates/rbacv2/use/admin.yaml b/test/e2e/testdata/rbac/sync-clean/module/templates/rbacv2/use/admin.yaml index 31792698..ed18c3e6 100644 --- a/test/e2e/testdata/rbac/sync-clean/module/templates/rbacv2/use/admin.yaml +++ b/test/e2e/testdata/rbac/sync-clean/module/templates/rbacv2/use/admin.yaml @@ -1,4 +1,5 @@ -# Generated by dmt (rbac/sync) from rbac.yaml, contract 1. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +# Generated by dmt (rbac/sync) from rbac.yaml, contract 2. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +# dmt:owns ClusterRole/d8:namespace-capability:cert-manager:admin --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole diff --git a/test/e2e/testdata/rbac/sync-clean/module/templates/rbacv2/use/edit.yaml b/test/e2e/testdata/rbac/sync-clean/module/templates/rbacv2/use/edit.yaml index 22d5ecb6..d8eb317d 100644 --- a/test/e2e/testdata/rbac/sync-clean/module/templates/rbacv2/use/edit.yaml +++ b/test/e2e/testdata/rbac/sync-clean/module/templates/rbacv2/use/edit.yaml @@ -1,4 +1,5 @@ -# Generated by dmt (rbac/sync) from rbac.yaml, contract 1. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +# Generated by dmt (rbac/sync) from rbac.yaml, contract 2. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +# dmt:owns ClusterRole/d8:namespace-capability:cert-manager:edit --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole diff --git a/test/e2e/testdata/rbac/sync-clean/module/templates/rbacv2/use/view.yaml b/test/e2e/testdata/rbac/sync-clean/module/templates/rbacv2/use/view.yaml index 92c9568c..04c4b92f 100644 --- a/test/e2e/testdata/rbac/sync-clean/module/templates/rbacv2/use/view.yaml +++ b/test/e2e/testdata/rbac/sync-clean/module/templates/rbacv2/use/view.yaml @@ -1,4 +1,5 @@ -# Generated by dmt (rbac/sync) from rbac.yaml, contract 1. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +# Generated by dmt (rbac/sync) from rbac.yaml, contract 2. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +# dmt:owns ClusterRole/d8:namespace-capability:cert-manager:view --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole diff --git a/test/e2e/testdata/rbac/sync-clean/module/templates/user-authz-cluster-roles.yaml b/test/e2e/testdata/rbac/sync-clean/module/templates/user-authz-cluster-roles.yaml index 371a61bb..59b8e276 100644 --- a/test/e2e/testdata/rbac/sync-clean/module/templates/user-authz-cluster-roles.yaml +++ b/test/e2e/testdata/rbac/sync-clean/module/templates/user-authz-cluster-roles.yaml @@ -1,4 +1,8 @@ -# Generated by dmt (rbac/sync) from rbac.yaml, contract 1. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +# Generated by dmt (rbac/sync) from rbac.yaml, contract 2. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +# dmt:owns ClusterRole/d8:user-authz:cert-manager:admin +# dmt:owns ClusterRole/d8:user-authz:cert-manager:cluster-editor +# dmt:owns ClusterRole/d8:user-authz:cert-manager:editor +# dmt:owns ClusterRole/d8:user-authz:cert-manager:user --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole diff --git a/test/e2e/testdata/rbac/sync-fix-regenerates/module/templates/cainjector/rbac-for-us.yaml b/test/e2e/testdata/rbac/sync-fix-regenerates/module/templates/cainjector/rbac-for-us.yaml index 6cf3648d..c326d803 100644 --- a/test/e2e/testdata/rbac/sync-fix-regenerates/module/templates/cainjector/rbac-for-us.yaml +++ b/test/e2e/testdata/rbac/sync-fix-regenerates/module/templates/cainjector/rbac-for-us.yaml @@ -1,4 +1,11 @@ -# Generated by dmt (rbac/sync) from rbac.yaml, contract 1. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +# Generated by dmt (rbac/sync) from rbac.yaml, contract 2. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +# dmt:owns ClusterRole/d8:cert-manager:cainjector +# dmt:owns ClusterRoleBinding/d8:cert-manager:cainjector +# dmt:owns ClusterRoleBinding/d8:cert-manager:cainjector:rbac-proxy +# dmt:owns d8-cert-manager/Role/cainjector +# dmt:owns d8-cert-manager/RoleBinding/cainjector +# dmt:owns d8-cert-manager/ServiceAccount/cainjector +# dmt:owns kube-system/RoleBinding/d8:cert-manager:cainjector:extension-apiserver-authentication-reader {{- if .Values.certManager.internal.enableCAInjector }} --- apiVersion: v1 diff --git a/test/e2e/testdata/rbac/sync-fix-regenerates/module/templates/rbac-for-us.yaml b/test/e2e/testdata/rbac/sync-fix-regenerates/module/templates/rbac-for-us.yaml index 9b7a734a..174967ef 100644 --- a/test/e2e/testdata/rbac/sync-fix-regenerates/module/templates/rbac-for-us.yaml +++ b/test/e2e/testdata/rbac/sync-fix-regenerates/module/templates/rbac-for-us.yaml @@ -1,4 +1,6 @@ -# Generated by dmt (rbac/sync) from rbac.yaml, contract 1. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +# Generated by dmt (rbac/sync) from rbac.yaml, contract 2. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +# dmt:owns ClusterRole/d8:cert-manager:admin-kubeconfig +# dmt:owns ClusterRoleBinding/d8:cert-manager:admin-kubeconfig --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole diff --git a/test/e2e/testdata/rbac/sync-fix-regenerates/module/templates/rbac-to-us.yaml b/test/e2e/testdata/rbac/sync-fix-regenerates/module/templates/rbac-to-us.yaml index 5f4dce11..2685760a 100644 --- a/test/e2e/testdata/rbac/sync-fix-regenerates/module/templates/rbac-to-us.yaml +++ b/test/e2e/testdata/rbac/sync-fix-regenerates/module/templates/rbac-to-us.yaml @@ -1,4 +1,8 @@ -# Generated by dmt (rbac/sync) from rbac.yaml, contract 1. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +# Generated by dmt (rbac/sync) from rbac.yaml, contract 2. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +# dmt:owns d8-cert-manager/Role/access-to-cert-manager +# dmt:owns d8-cert-manager/Role/access-to-cert-manager-auth +# dmt:owns d8-cert-manager/RoleBinding/access-to-cert-manager +# dmt:owns d8-cert-manager/RoleBinding/access-to-cert-manager-auth --- apiVersion: rbac.authorization.k8s.io/v1 kind: Role diff --git a/test/e2e/testdata/rbac/sync-fix-regenerates/module/templates/rbacv2/manage/edit.yaml b/test/e2e/testdata/rbac/sync-fix-regenerates/module/templates/rbacv2/manage/edit.yaml index a490c211..f48477b7 100644 --- a/test/e2e/testdata/rbac/sync-fix-regenerates/module/templates/rbacv2/manage/edit.yaml +++ b/test/e2e/testdata/rbac/sync-fix-regenerates/module/templates/rbacv2/manage/edit.yaml @@ -1,4 +1,5 @@ -# Generated by dmt (rbac/sync) from rbac.yaml, contract 1. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +# Generated by dmt (rbac/sync) from rbac.yaml, contract 2. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +# dmt:owns ClusterRole/d8:system-capability:cert-manager:edit --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole diff --git a/test/e2e/testdata/rbac/sync-fix-regenerates/module/templates/rbacv2/manage/view.yaml b/test/e2e/testdata/rbac/sync-fix-regenerates/module/templates/rbacv2/manage/view.yaml index 7013011b..8df6c345 100644 --- a/test/e2e/testdata/rbac/sync-fix-regenerates/module/templates/rbacv2/manage/view.yaml +++ b/test/e2e/testdata/rbac/sync-fix-regenerates/module/templates/rbacv2/manage/view.yaml @@ -1,4 +1,5 @@ -# Generated by dmt (rbac/sync) from rbac.yaml, contract 1. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +# Generated by dmt (rbac/sync) from rbac.yaml, contract 2. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +# dmt:owns ClusterRole/d8:system-capability:cert-manager:view --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole diff --git a/test/e2e/testdata/rbac/sync-fix-regenerates/module/templates/rbacv2/use/edit.yaml b/test/e2e/testdata/rbac/sync-fix-regenerates/module/templates/rbacv2/use/edit.yaml index 22d5ecb6..d8eb317d 100644 --- a/test/e2e/testdata/rbac/sync-fix-regenerates/module/templates/rbacv2/use/edit.yaml +++ b/test/e2e/testdata/rbac/sync-fix-regenerates/module/templates/rbacv2/use/edit.yaml @@ -1,4 +1,5 @@ -# Generated by dmt (rbac/sync) from rbac.yaml, contract 1. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +# Generated by dmt (rbac/sync) from rbac.yaml, contract 2. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +# dmt:owns ClusterRole/d8:namespace-capability:cert-manager:edit --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole diff --git a/test/e2e/testdata/rbac/sync-fix-regenerates/module/templates/rbacv2/use/view.yaml b/test/e2e/testdata/rbac/sync-fix-regenerates/module/templates/rbacv2/use/view.yaml index 92c9568c..04c4b92f 100644 --- a/test/e2e/testdata/rbac/sync-fix-regenerates/module/templates/rbacv2/use/view.yaml +++ b/test/e2e/testdata/rbac/sync-fix-regenerates/module/templates/rbacv2/use/view.yaml @@ -1,4 +1,5 @@ -# Generated by dmt (rbac/sync) from rbac.yaml, contract 1. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +# Generated by dmt (rbac/sync) from rbac.yaml, contract 2. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +# dmt:owns ClusterRole/d8:namespace-capability:cert-manager:view --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole diff --git a/test/e2e/testdata/rbac/sync-fix-regenerates/module/templates/user-authz-cluster-roles.yaml b/test/e2e/testdata/rbac/sync-fix-regenerates/module/templates/user-authz-cluster-roles.yaml index 371a61bb..59b8e276 100644 --- a/test/e2e/testdata/rbac/sync-fix-regenerates/module/templates/user-authz-cluster-roles.yaml +++ b/test/e2e/testdata/rbac/sync-fix-regenerates/module/templates/user-authz-cluster-roles.yaml @@ -1,4 +1,8 @@ -# Generated by dmt (rbac/sync) from rbac.yaml, contract 1. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +# Generated by dmt (rbac/sync) from rbac.yaml, contract 2. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +# dmt:owns ClusterRole/d8:user-authz:cert-manager:admin +# dmt:owns ClusterRole/d8:user-authz:cert-manager:cluster-editor +# dmt:owns ClusterRole/d8:user-authz:cert-manager:editor +# dmt:owns ClusterRole/d8:user-authz:cert-manager:user --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole diff --git a/test/e2e/testdata/rbac/sync-hand-edited/module/templates/cainjector/rbac-for-us.yaml b/test/e2e/testdata/rbac/sync-hand-edited/module/templates/cainjector/rbac-for-us.yaml index 6cf3648d..c326d803 100644 --- a/test/e2e/testdata/rbac/sync-hand-edited/module/templates/cainjector/rbac-for-us.yaml +++ b/test/e2e/testdata/rbac/sync-hand-edited/module/templates/cainjector/rbac-for-us.yaml @@ -1,4 +1,11 @@ -# Generated by dmt (rbac/sync) from rbac.yaml, contract 1. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +# Generated by dmt (rbac/sync) from rbac.yaml, contract 2. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +# dmt:owns ClusterRole/d8:cert-manager:cainjector +# dmt:owns ClusterRoleBinding/d8:cert-manager:cainjector +# dmt:owns ClusterRoleBinding/d8:cert-manager:cainjector:rbac-proxy +# dmt:owns d8-cert-manager/Role/cainjector +# dmt:owns d8-cert-manager/RoleBinding/cainjector +# dmt:owns d8-cert-manager/ServiceAccount/cainjector +# dmt:owns kube-system/RoleBinding/d8:cert-manager:cainjector:extension-apiserver-authentication-reader {{- if .Values.certManager.internal.enableCAInjector }} --- apiVersion: v1 diff --git a/test/e2e/testdata/rbac/sync-hand-edited/module/templates/rbac-for-us.yaml b/test/e2e/testdata/rbac/sync-hand-edited/module/templates/rbac-for-us.yaml index 9b7a734a..174967ef 100644 --- a/test/e2e/testdata/rbac/sync-hand-edited/module/templates/rbac-for-us.yaml +++ b/test/e2e/testdata/rbac/sync-hand-edited/module/templates/rbac-for-us.yaml @@ -1,4 +1,6 @@ -# Generated by dmt (rbac/sync) from rbac.yaml, contract 1. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +# Generated by dmt (rbac/sync) from rbac.yaml, contract 2. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +# dmt:owns ClusterRole/d8:cert-manager:admin-kubeconfig +# dmt:owns ClusterRoleBinding/d8:cert-manager:admin-kubeconfig --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole diff --git a/test/e2e/testdata/rbac/sync-hand-edited/module/templates/rbac-to-us.yaml b/test/e2e/testdata/rbac/sync-hand-edited/module/templates/rbac-to-us.yaml index 5f4dce11..2685760a 100644 --- a/test/e2e/testdata/rbac/sync-hand-edited/module/templates/rbac-to-us.yaml +++ b/test/e2e/testdata/rbac/sync-hand-edited/module/templates/rbac-to-us.yaml @@ -1,4 +1,8 @@ -# Generated by dmt (rbac/sync) from rbac.yaml, contract 1. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +# Generated by dmt (rbac/sync) from rbac.yaml, contract 2. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +# dmt:owns d8-cert-manager/Role/access-to-cert-manager +# dmt:owns d8-cert-manager/Role/access-to-cert-manager-auth +# dmt:owns d8-cert-manager/RoleBinding/access-to-cert-manager +# dmt:owns d8-cert-manager/RoleBinding/access-to-cert-manager-auth --- apiVersion: rbac.authorization.k8s.io/v1 kind: Role diff --git a/test/e2e/testdata/rbac/sync-hand-edited/module/templates/rbacv2/manage/edit.yaml b/test/e2e/testdata/rbac/sync-hand-edited/module/templates/rbacv2/manage/edit.yaml index a490c211..f48477b7 100644 --- a/test/e2e/testdata/rbac/sync-hand-edited/module/templates/rbacv2/manage/edit.yaml +++ b/test/e2e/testdata/rbac/sync-hand-edited/module/templates/rbacv2/manage/edit.yaml @@ -1,4 +1,5 @@ -# Generated by dmt (rbac/sync) from rbac.yaml, contract 1. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +# Generated by dmt (rbac/sync) from rbac.yaml, contract 2. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +# dmt:owns ClusterRole/d8:system-capability:cert-manager:edit --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole diff --git a/test/e2e/testdata/rbac/sync-hand-edited/module/templates/rbacv2/manage/view.yaml b/test/e2e/testdata/rbac/sync-hand-edited/module/templates/rbacv2/manage/view.yaml index 7013011b..8df6c345 100644 --- a/test/e2e/testdata/rbac/sync-hand-edited/module/templates/rbacv2/manage/view.yaml +++ b/test/e2e/testdata/rbac/sync-hand-edited/module/templates/rbacv2/manage/view.yaml @@ -1,4 +1,5 @@ -# Generated by dmt (rbac/sync) from rbac.yaml, contract 1. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +# Generated by dmt (rbac/sync) from rbac.yaml, contract 2. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +# dmt:owns ClusterRole/d8:system-capability:cert-manager:view --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole diff --git a/test/e2e/testdata/rbac/sync-hand-edited/module/templates/rbacv2/use/admin.yaml b/test/e2e/testdata/rbac/sync-hand-edited/module/templates/rbacv2/use/admin.yaml index 31792698..ed18c3e6 100644 --- a/test/e2e/testdata/rbac/sync-hand-edited/module/templates/rbacv2/use/admin.yaml +++ b/test/e2e/testdata/rbac/sync-hand-edited/module/templates/rbacv2/use/admin.yaml @@ -1,4 +1,5 @@ -# Generated by dmt (rbac/sync) from rbac.yaml, contract 1. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +# Generated by dmt (rbac/sync) from rbac.yaml, contract 2. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +# dmt:owns ClusterRole/d8:namespace-capability:cert-manager:admin --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole diff --git a/test/e2e/testdata/rbac/sync-hand-edited/module/templates/rbacv2/use/edit.yaml b/test/e2e/testdata/rbac/sync-hand-edited/module/templates/rbacv2/use/edit.yaml index d52e38bd..c530a64b 100644 --- a/test/e2e/testdata/rbac/sync-hand-edited/module/templates/rbacv2/use/edit.yaml +++ b/test/e2e/testdata/rbac/sync-hand-edited/module/templates/rbacv2/use/edit.yaml @@ -1,4 +1,5 @@ -# Generated by dmt (rbac/sync) from rbac.yaml, contract 1. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +# Generated by dmt (rbac/sync) from rbac.yaml, contract 2. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +# dmt:owns ClusterRole/d8:namespace-capability:cert-manager:edit --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole diff --git a/test/e2e/testdata/rbac/sync-hand-edited/module/templates/rbacv2/use/view.yaml b/test/e2e/testdata/rbac/sync-hand-edited/module/templates/rbacv2/use/view.yaml index 92c9568c..04c4b92f 100644 --- a/test/e2e/testdata/rbac/sync-hand-edited/module/templates/rbacv2/use/view.yaml +++ b/test/e2e/testdata/rbac/sync-hand-edited/module/templates/rbacv2/use/view.yaml @@ -1,4 +1,5 @@ -# Generated by dmt (rbac/sync) from rbac.yaml, contract 1. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +# Generated by dmt (rbac/sync) from rbac.yaml, contract 2. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +# dmt:owns ClusterRole/d8:namespace-capability:cert-manager:view --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole diff --git a/test/e2e/testdata/rbac/sync-hand-edited/module/templates/user-authz-cluster-roles.yaml b/test/e2e/testdata/rbac/sync-hand-edited/module/templates/user-authz-cluster-roles.yaml index 659d8040..b8be3aa5 100644 --- a/test/e2e/testdata/rbac/sync-hand-edited/module/templates/user-authz-cluster-roles.yaml +++ b/test/e2e/testdata/rbac/sync-hand-edited/module/templates/user-authz-cluster-roles.yaml @@ -1,4 +1,8 @@ -# Generated by dmt (rbac/sync) from rbac.yaml, contract 1. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +# Generated by dmt (rbac/sync) from rbac.yaml, contract 2. Edit rbac.yaml and run "dmt lint --linter rbac --fix", or remove this line to maintain the file by hand. +# dmt:owns ClusterRole/d8:user-authz:cert-manager:admin +# dmt:owns ClusterRole/d8:user-authz:cert-manager:cluster-editor +# dmt:owns ClusterRole/d8:user-authz:cert-manager:editor +# dmt:owns ClusterRole/d8:user-authz:cert-manager:user --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole From 5f1b34befb31909bcf891b161831fa27cab31bf4 Mon Sep 17 00:00:00 2001 From: Ivan Zvyagintsev Date: Wed, 23 Sep 2026 13:32:33 +0300 Subject: [PATCH 33/58] rbac: review of #479 -- levels, bootstrap, conditions, robustness - The linter's impact caps the declaration rules (impact: ignored on rbac silences them), a module sets their levels in its own .dmtlint.yaml (linters-settings.rbac.rules..impact, winning over the root's), and a level other than ignored/warn/error/critical is a configuration error instead of a silent "error" (finding 7). - Bootstrap leaves a grant limited to resourceNames out of every level and names it, instead of widening it whenever another level grants the resource unrestricted (finding 8). - Bootstrap keeps its finding and the non-zero exit while the written rbac.yaml has TODO values, and writes nothing into an edition overlay; ee/modules is the base directory of an EE-only module (finding 9). - A role whose every rule is under when is conditional as a whole, so it is not rendered with an empty rules list (finding 11). - Bootstrap keeps a ServiceAccount's RoleBinding to a ClusterRole hand-written instead of turning it into a binding to a Role, and names a moduleconfigs rule the generator does not produce instead of dropping it (13c, 13d). - legacy.SuperAdmin is a warning: user-authz aggregates User..ClusterAdmin (13e). Two capabilities of a module with one marker are refused (13f). The cluster-scope warning knows the built-in resources (13g). - no-cyrillic exempts ru.meta.deckhouse.io/{title,description} only as a YAML key in YAML templates, block scalars included (13h). - One CRD document that does not parse is skipped with a warning instead of stopping coverage and losing every scope in contract (13i). - A module.yaml that does not parse stops sync, an unreadable template is a divergence, and a template the tolerant render dropped is neither compared nor regenerated in that run (13k). Signed-off-by: Ivan Zvyagintsev --- internal/modules/module.go | 26 ++++- internal/modules/rbac_rules_config_test.go | 15 +++ internal/modules/render.go | 1 + internal/storage/storage.go | 15 ++- pkg/config/linters_settings.go | 10 ++ pkg/config/loader.go | 24 +++- pkg/config/rbac_keys_test.go | 14 ++- pkg/errors/lint_errors.go | 2 +- pkg/linters/no-cyrillic/rules/files.go | 39 ++++++- pkg/linters/no-cyrillic/rules/files_test.go | 34 ++++++ pkg/linters/rbac/README.md | 36 +++--- pkg/linters/rbac/rbac.go | 15 ++- pkg/linters/rbac/rbac_test.go | 35 ++++++ pkg/linters/rbac/rules/bootstrap/bootstrap.go | 96 ++++++++++----- .../rbac/rules/bootstrap/bootstrap_test.go | 64 +++++++++- pkg/linters/rbac/rules/contract.go | 35 +++++- pkg/linters/rbac/rules/contract_test.go | 20 ++++ pkg/linters/rbac/rules/coverage.go | 7 +- pkg/linters/rbac/rules/coverage_test.go | 18 ++- pkg/linters/rbac/rules/crds.go | 17 ++- pkg/linters/rbac/rules/fixstate.go | 7 ++ .../rbac/rules/generate/generate_test.go | 38 ++++++ pkg/linters/rbac/rules/generate/model.go | 48 ++++++++ pkg/linters/rbac/rules/rbacyaml/load_test.go | 14 +++ pkg/linters/rbac/rules/rbacyaml/validate.go | 17 +++ pkg/linters/rbac/rules/sync.go | 100 +++++++++++++--- pkg/linters/rbac/rules/sync_test.go | 109 +++++++++++++++++- 27 files changed, 761 insertions(+), 95 deletions(-) create mode 100644 pkg/linters/rbac/rbac_test.go diff --git a/internal/modules/module.go b/internal/modules/module.go index 7182b0d7..2b64cace 100644 --- a/internal/modules/module.go +++ b/internal/modules/module.go @@ -238,18 +238,32 @@ func mapRuleSettings(linterSettings *pkg.LintersSettings, configSettings *config } // mapRBACRules configures the per-rule levels of the rbac rules added for the module RBAC -// declaration: coverage, sync and contract read their impact from the root configuration and -// fall back to the linter's. -func mapRBACRules(linterSettings *pkg.LintersSettings, _ *config.LintersSettings, globalConfig *global.Linters) { +// declaration: coverage, sync and contract read their impact from the module's configuration, +// then from the root one, and start at warn. The linter's own impact still caps them (see +// rbac.New): impact: ignored on the linter silences every rbac rule. +func mapRBACRules(linterSettings *pkg.LintersSettings, configSettings *config.LintersSettings, globalConfig *global.Linters) { // The declaration rules are new to every tree: a module without rbac.yaml sees only contract, // and the platform tree still carries six dead rbac.yaml files of an older shape and rules the // contract flags. They therefore start at warn wherever nothing sets them -- like the style // rules of the documentation linter -- and are raised to error per tree in its root // .dmtlint.yaml once its modules are clean. The linter-level impact is intentionally not the // fallback: impact: error on rbac means the four original rules, as it always did. - linterSettings.RBAC.Rules.CoverageRule.SetLevel(globalConfig.Rbac.Rules.CoverageRule.Impact, pkg.Warn.String()) - linterSettings.RBAC.Rules.SyncRule.SetLevel(globalConfig.Rbac.Rules.SyncRule.Impact, pkg.Warn.String()) - linterSettings.RBAC.Rules.ContractRule.SetLevel(globalConfig.Rbac.Rules.ContractRule.Impact, pkg.Warn.String()) + module := configSettings.Rbac.Rules + + linterSettings.RBAC.Rules.CoverageRule.SetLevel(firstSet(module.CoverageRule.Impact, globalConfig.Rbac.Rules.CoverageRule.Impact), pkg.Warn.String()) + linterSettings.RBAC.Rules.SyncRule.SetLevel(firstSet(module.SyncRule.Impact, globalConfig.Rbac.Rules.SyncRule.Impact), pkg.Warn.String()) + linterSettings.RBAC.Rules.ContractRule.SetLevel(firstSet(module.ContractRule.Impact, globalConfig.Rbac.Rules.ContractRule.Impact), pkg.Warn.String()) +} + +// firstSet returns the first non-empty level. +func firstSet(levels ...string) string { + for _, l := range levels { + if l != "" { + return l + } + } + + return "" } // mapContainerRules configures Container linter rules diff --git a/internal/modules/rbac_rules_config_test.go b/internal/modules/rbac_rules_config_test.go index abb861ea..82e9848c 100644 --- a/internal/modules/rbac_rules_config_test.go +++ b/internal/modules/rbac_rules_config_test.go @@ -53,6 +53,21 @@ func TestRemapLinterSettings_RBACDeclarationRules(t *testing.T) { } }) + t.Run("a module's own levels win over the root's", func(t *testing.T) { + settings := remapLinterSettings( + &config.LintersSettings{Rbac: config.RbacSettings{Rules: config.RbacModuleRules{ + SyncRule: config.RuleConfig{Impact: pkg.Ignored.String()}, + }}}, + &global.Linters{Rbac: global.RBACLinterConfig{Rules: global.RBACRules{ + SyncRule: global.RuleConfig{Impact: pkg.Error.String()}, + CoverageRule: global.RuleConfig{Impact: pkg.Error.String()}, + }}}, + ) + + require.Equal(t, pkg.Ignored, *settings.RBAC.Rules.SyncRule.GetLevel()) + require.Equal(t, pkg.Error, *settings.RBAC.Rules.CoverageRule.GetLevel()) + }) + t.Run("module-level exclusions for the three rules", func(t *testing.T) { settings := remapLinterSettings( &config.LintersSettings{Rbac: config.RbacSettings{ExcludeRules: config.RBACExcludeRules{ diff --git a/internal/modules/render.go b/internal/modules/render.go index 298939cf..4992fc89 100644 --- a/internal/modules/render.go +++ b/internal/modules/render.go @@ -46,6 +46,7 @@ func RunRender(m *Module, vals chartutil.Values, objectStore *storage.Unstructur Values: vals, ExtraAPIVersions: render.ExtraAPIVersions(), OnDrop: func(templatePath, cause string) { + objectStore.MarkDropped(templatePath, cause) errorList.WithModule(m.GetName()).WithFilePath(templatePath).WithValue(cause). Warnf("template %q failed to render and was skipped; the rest of the chart was still linted", templatePath) }, diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 1b8988a2..5593bdfb 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -383,10 +383,22 @@ func (s *StoreObject) Identity() string { type UnstructuredObjectStore struct { Storage map[ResourceIndex]StoreObject + // Dropped holds the templates the tolerant render skipped, by path relative to the module, with + // the render error. Their objects are missing from Storage without being absent from the chart. + Dropped map[string]string } func NewUnstructuredObjectStore() *UnstructuredObjectStore { - return &UnstructuredObjectStore{Storage: make(map[ResourceIndex]StoreObject)} + return &UnstructuredObjectStore{Storage: make(map[ResourceIndex]StoreObject), Dropped: make(map[string]string)} +} + +// MarkDropped records a template the render skipped. +func (s *UnstructuredObjectStore) MarkDropped(path, cause string) { + if s.Dropped == nil { + s.Dropped = make(map[string]string) + } + + s.Dropped[path] = cause } func (s *UnstructuredObjectStore) Put(path, shortPath string, object map[string]any, raw []byte) error { @@ -431,6 +443,7 @@ func (s *UnstructuredObjectStore) Close() { // does not drop the backing map, which is what makes reuse cheap. func (s *UnstructuredObjectStore) Reset() { clear(s.Storage) + clear(s.Dropped) } func NewSHA256(data []byte) string { diff --git a/pkg/config/linters_settings.go b/pkg/config/linters_settings.go index 00aad918..08dd2204 100644 --- a/pkg/config/linters_settings.go +++ b/pkg/config/linters_settings.go @@ -214,10 +214,20 @@ type OpenAPIExcludeRules struct { type RbacSettings struct { ExcludeRules RBACExcludeRules `mapstructure:"exclude-rules"` + // Rules sets the levels of the declaration rules for this module; a module that is not ready + // for the declaration silences them here without touching the rest of the tree. + Rules RbacModuleRules `mapstructure:"rules"` Impact string `mapstructure:"impact"` } +// RbacModuleRules are the per-module levels of the rules added for the module RBAC declaration. +type RbacModuleRules struct { + CoverageRule RuleConfig `mapstructure:"coverage"` + SyncRule RuleConfig `mapstructure:"sync"` + ContractRule RuleConfig `mapstructure:"contract"` +} + type RBACExcludeRules struct { BindingSubject StringRuleExcludeList `mapstructure:"binding-subject"` Placement KindRuleExcludeList `mapstructure:"placement"` diff --git a/pkg/config/loader.go b/pkg/config/loader.go index 3d53458b..c3fc53e9 100644 --- a/pkg/config/loader.go +++ b/pkg/config/loader.go @@ -166,7 +166,11 @@ var rbacKnownKeys = map[string]map[string]struct{}{ "global.linters-settings.rbac.rules.coverage": {"impact": {}}, "global.linters-settings.rbac.rules.sync": {"impact": {}}, "global.linters-settings.rbac.rules.contract": {"impact": {}}, - "linters-settings.rbac": {"impact": {}, "exclude-rules": {}}, + "linters-settings.rbac": {"impact": {}, "exclude-rules": {}, "rules": {}}, + "linters-settings.rbac.rules": {"coverage": {}, "sync": {}, "contract": {}}, + "linters-settings.rbac.rules.coverage": {"impact": {}}, + "linters-settings.rbac.rules.sync": {"impact": {}}, + "linters-settings.rbac.rules.contract": {"impact": {}}, "linters-settings.rbac.exclude-rules": { "binding-subject": {}, "placement": {}, "wildcards": {}, "coverage": {}, "contract": {}, "sync": {}, }, @@ -187,8 +191,19 @@ func validateRbacKeys(v *viper.Viper) error { var problems []string for _, path := range slices.Sorted(maps.Keys(rbacKnownKeys)) { - if block, ok := v.Get(path).(map[string]any); ok { - problems = append(problems, unknownKeys(block, rbacKnownKeys[path], path)...) + block, ok := v.Get(path).(map[string]any) + if !ok { + continue + } + + problems = append(problems, unknownKeys(block, rbacKnownKeys[path], path)...) + + // A level that is not one of the known ones is read as error: "ignore" for "ignored" would + // raise a rule instead of silencing it. + if impact, set := block["impact"]; set { + if s, isString := impact.(string); !isString || !knownLevels[s] { + problems = append(problems, fmt.Sprintf("%s.impact is %v: the levels are ignored, warn, error, critical", path, impact)) + } } } @@ -216,6 +231,9 @@ func validateRbacKeys(v *viper.Viper) error { return fmt.Errorf("%s in %s", strings.Join(problems, "; "), v.ConfigFileUsed()) } +// knownLevels are the impact values pkg.ParseStringToLevel knows. +var knownLevels = map[string]bool{"ignored": true, "warn": true, "error": true, "critical": true} + // unknownKeys names the keys of block that known does not list, with the accepted ones. func unknownKeys(block map[string]any, known map[string]struct{}, path string) []string { var unknown []string diff --git a/pkg/config/rbac_keys_test.go b/pkg/config/rbac_keys_test.go index 7cd554d0..a8583856 100644 --- a/pkg/config/rbac_keys_test.go +++ b/pkg/config/rbac_keys_test.go @@ -73,10 +73,18 @@ linters-settings: assert.Contains(t, err.Error(), "the accepted keys are contract, coverage, sync") }) - t.Run("per-rule levels do not belong to the module block", func(t *testing.T) { - err := loadFrom(t, "linters-settings:\n rbac:\n rules:\n coverage: {impact: warn}\n") + t.Run("a module sets the levels of the declaration rules", func(t *testing.T) { + require.NoError(t, loadFrom(t, "linters-settings:\n rbac:\n rules:\n coverage: {impact: ignored}\n sync: {impact: warn}\n")) + + err := loadFrom(t, "linters-settings:\n rbac:\n rules:\n placement: {impact: warn}\n") + require.Error(t, err) + assert.Contains(t, err.Error(), `unknown key(s) placement under "linters-settings.rbac.rules"`) + }) + + t.Run("an unknown level is an error, not a silent error level", func(t *testing.T) { + err := loadFrom(t, "global:\n linters-settings:\n rbac:\n rules:\n sync: {impact: ignore}\n") require.Error(t, err) - assert.Contains(t, err.Error(), `unknown key(s) rules under "linters-settings.rbac"`) + assert.Contains(t, err.Error(), `global.linters-settings.rbac.rules.sync.impact is ignore: the levels are ignored, warn, error, critical`) }) t.Run("an unknown exclusion key is an error", func(t *testing.T) { diff --git a/pkg/errors/lint_errors.go b/pkg/errors/lint_errors.go index 5cf1dc4d..3eeeb24a 100644 --- a/pkg/errors/lint_errors.go +++ b/pkg/errors/lint_errors.go @@ -255,7 +255,7 @@ func (l *LintRuleErrorsList) GetFixes() []func() { for idx := range l.storage.errList { // A finding at the ignored level is neither shown nor acted on: a rule switched off with - // impact: ignore keeps its autofix off with it. + // impact: ignored keeps its autofix off with it. if l.storage.errList[idx].fix == nil || l.storage.errList[idx].Level == pkg.Ignored { continue } diff --git a/pkg/linters/no-cyrillic/rules/files.go b/pkg/linters/no-cyrillic/rules/files.go index 55c4626b..b52e58ce 100644 --- a/pkg/linters/no-cyrillic/rules/files.go +++ b/pkg/linters/no-cyrillic/rules/files.go @@ -19,6 +19,7 @@ package rules import ( "context" "os" + "path/filepath" "regexp" "strings" @@ -47,7 +48,9 @@ var ( // is product text the console shows to whoever grants access, not a source comment, so those // lines are not judged -- otherwise every module would need the same exclusion for // templates/rbacv2 in its own .dmtlint.yaml. - localizedAnnotationRe = `ru\.meta\.deckhouse\.io/(title|description)` + // Only a YAML key at the start of a line counts, quoted or not; a block scalar value (|, >) is + // followed on the next, deeper-indented lines. + localizedAnnotationRe = `^(\s*)["']?ru\.meta\.deckhouse\.io/(title|description)["']?\s*:(.*)$` ) func NewFilesRule(excludeFileRules []pkg.StringRuleExclude, @@ -128,7 +131,11 @@ func (r *FilesRule) checkFile(fileName string) { return } - cyrMsg, hasCyr := checkCyrillicLettersInArray(r.withoutLocalizedAnnotations(lines)) + if isYAMLTemplate(fileName) { + lines = r.withoutLocalizedAnnotations(lines) + } + + cyrMsg, hasCyr := checkCyrillicLettersInArray(lines) if hasCyr { errorList.WithFilePath(fName).WithValue(cyrMsg). Error("has cyrillic letters") @@ -151,8 +158,24 @@ func getFileContent(filename string) ([]string, error) { func (r *FilesRule) withoutLocalizedAnnotations(lines []string) []string { out := make([]string, 0, len(lines)) + // blockIndent is the indentation of a localized key whose value is a block scalar, -1 outside + // one: the lines below it that are indented deeper belong to the value. + blockIndent := -1 + for _, line := range lines { - if r.localizedRe.MatchString(line) { + if blockIndent >= 0 { + if strings.TrimSpace(line) == "" || len(line)-len(strings.TrimLeft(line, " ")) > blockIndent { + continue + } + + blockIndent = -1 + } + + if m := r.localizedRe.FindStringSubmatch(line); m != nil { + if value := strings.TrimSpace(m[3]); value != "" && strings.ContainsAny(value[:1], "|>") { + blockIndent = len(m[1]) + } + continue } @@ -161,3 +184,13 @@ func (r *FilesRule) withoutLocalizedAnnotations(lines []string) []string { return out } + +// isYAMLTemplate reports whether the file is one the localized annotations can live in. +func isYAMLTemplate(fileName string) bool { + switch filepath.Ext(fileName) { + case ".yaml", ".yml", ".tpl": + return true + } + + return false +} diff --git a/pkg/linters/no-cyrillic/rules/files_test.go b/pkg/linters/no-cyrillic/rules/files_test.go index 04211b70..0e38468d 100644 --- a/pkg/linters/no-cyrillic/rules/files_test.go +++ b/pkg/linters/no-cyrillic/rules/files_test.go @@ -481,6 +481,40 @@ metadata: if got := run(t, capability+"# Комментарий на русском\n"); len(got) != 1 { t.Errorf("Cyrillic outside the annotations is still reported, got %v", got) } + + // A block scalar value is part of the annotation (review of #479, finding 13h). + block := capability + " ru.meta.deckhouse.io/description: >-\n Длинное описание\n на две строки.\n labels:\n x: y\n" + if got := run(t, block); len(got) != 0 { + t.Errorf("a block scalar annotation must not be reported, got %v", got) + } + + // Only a key counts: the name in a comment next to Russian text does not exempt the line. + if got := run(t, capability+" # Ошибка доступа -- ru.meta.deckhouse.io/title\n"); len(got) != 1 { + t.Errorf("a line merely mentioning the key is still judged, got %v", got) + } +} + +// Outside YAML templates the key name exempts nothing (review of #479, finding 13h). +func TestFilesRule_CheckFile_LocalizedKeyInGoIsJudged(t *testing.T) { + mockModule := mocks.NewModuleMock(minimock.NewController(t)) + tempDir := t.TempDir() + mockModule.GetPathMock.Return(tempDir) + + path := filepath.Join(tempDir, "hooks", "x.go") + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + + if err := os.WriteFile(path, []byte("package hooks\n\n// ru.meta.deckhouse.io/title: Ошибка доступа\n"), 0o600); err != nil { + t.Fatal(err) + } + + errorList := errors.NewLintRuleErrorsList() + NewFilesRule(nil, nil, mockModule, errorList).checkFile(path) + + if errs := errorList.GetErrors(); len(errs) != 1 { + t.Errorf("Cyrillic in a Go file is reported whatever key it follows, got %v", errs) + } } // rbac.yaml holds the ru titles and descriptions the rbac declaration requires for capabilities diff --git a/pkg/linters/rbac/README.md b/pkg/linters/rbac/README.md index d923afc6..1c947b7c 100644 --- a/pkg/linters/rbac/README.md +++ b/pkg/linters/rbac/README.md @@ -1016,7 +1016,9 @@ You can also place a `.dmtlint.yaml` configuration file directly in your module # modules/my-module/.dmtlint.yaml linters-settings: rbac: - impact: warning # More lenient for this specific module + impact: warn # More lenient for this specific module; ignored silences every rbac rule + rules: # levels of the declaration rules for this module only; they win over the root's + sync: {impact: ignored} exclude-rules: binding-subject: - legacy-sa @@ -1461,7 +1463,10 @@ access: Format rules the loader enforces: - `apiVersion` is required and must be `rbac.deckhouse.io/v1alpha1`; unknown keys anywhere are an error. -- Verbs are listed explicitly (`get`, `list`, `watch`, `create`, `update`, `patch`, `delete`, `deletecollection`); there are no aliases. +- Verbs are listed explicitly (`get`, `list`, `watch`, `create`, `update`, `patch`, `delete`, `deletecollection`); there are no aliases, and `*` is refused at every level (the `contract` rule reports `*` verbs and `apiGroups: ["*"]` in a rendered capability, too). `group: "*"` and a partial wildcard in a resource name are refused. +- No value holds `{{` or `}}`: the generator writes the values into Helm templates as they are, and a `when` is the condition only. +- `legacy.SuperAdmin` validates but is a warning: user-authz aggregates custom legacy roles for `User` through `ClusterAdmin` only. +- `prometheusAccess.when` gates the RoleBinding of the Prometheus scraper (typically `.Values.global.enabledModules | has "prometheus"`); the Role stays unconditional. - Levels: `namespace` -- `viewer`, `user`, `manager`, `admin`, `superadmin`; `system` -- `viewer`, `manager`, `superadmin`; `legacy` -- `User`, `PrivilegedUser`, `Editor`, `Admin`, `ClusterEditor`, `ClusterAdmin`, `SuperAdmin`. - `namespace` levels are allowed only for `Namespaced` resources; a `Namespaced` resource at a `system` level needs a `reason`. - `scope` is required for a resource the module ships no CRD for, and must agree with the CRD when the module ships one. `resource: "*"` is allowed only for a group without CRDs in the module and needs a `reason`. @@ -1612,10 +1617,12 @@ Without `rbac.yaml` the rule reports the declaration missing, and `--fix` writes objects the module renders today: the declaration a person would have transcribed from the templates, with a `TODO` wherever a decision is still theirs (a resource without a CRD whose scope the linter cannot know, a CRD nobody grants, a namespaced resource granted cluster-wide) and a note on top for -every object the generator will name differently or cannot describe. Every entry gets its `scope` -written out, CRD or not, so a lint of one edition directory that lacks the other editions' CRDs still -validates the declaration. Review it, resolve the TODOs, -then run `--fix` again to regenerate the templates from it. From then on `rbac.yaml` is the source. +every object the generator will name differently or cannot describe. An entry whose CRD is in `crds/` +carries no `scope`: the CRD states it. A grant limited to `resourceNames` is never widened to every +object: it is left out and named in a note. The fix that writes the file keeps the finding, and the +exit code non-zero, while a `TODO` is left in it. Nothing is written into an edition overlay. Review +it, resolve the TODOs, then run `--fix` again to regenerate the templates from it. From then on +`rbac.yaml` is the source. With `rbac.yaml` the rule first validates the declaration; a declaration with errors is reported and nothing else is compared or generated. Then builds the objects the @@ -1645,11 +1652,11 @@ no longer names leaves the template; the finding that led there listed it, and t removed, so a `--fix` run without a preceding `dmt lint` does not remove rights in silence. Three things are never written over -- -- a file that also holds objects the declaration does not produce -- a controller ClusterRole beside a declared ServiceAccount, a hand-written binding -- is never rewritten, because the generator writes the whole file and they would vanish (and so they would if the file were deleted); the refusal names them: declare them (`extraClusterRoles`, `access` with `path`) or move them first. An object the generator produces under another name -- a binding with the same roleRef and subjects, a role with the same rules -- is replaced, not foreign; +- a file that also holds objects of someone else -- a controller ClusterRole beside a declared ServiceAccount, a hand-written binding, a ConfigMap or a Secret -- is never rewritten, because the generator writes the whole file and they would vanish (and so they would if the file were deleted); the refusal names them: declare them (`extraClusterRoles`, `access` with `path`) or move them first. A generated file lists under its header, one `# dmt:owns ` line each, what the generator wrote into it (contract 2); an owned object the declaration no longer produces -- a legacy level dropped, a ServiceAccount removed -- is a removal, named in the finding and in the log, and everything else in the file is someone else's. In a file without that list (contract 1, or hand-written), a legacy role or a module capability the declaration does not produce is a removal too, and an object the generator produces under another name -- a binding with the same roleRef and subjects, a role with the same rules, while the new name is not rendered yet -- is replaced, not foreign; - a file without the generator header is maintained by hand: the generated text is written beside it as `_.generated` (the underscore keeps Helm from rendering the copy) and the finding stays (delete the file and run `--fix` again to hand it back to the generator); - a template that serves both role models behind the version gate (`rbacv2_new_scheme`) is never regenerated: the legacy branch would vanish; -A missing file is created. A generated file the declaration produces nothing for any more -- every namespace level dropped, the `legacy` section gone -- is deleted, as long as it holds nothing but objects of the owned classes; the deletion is logged. A second `--fix` without changes to `rbac.yaml` changes nothing. Under +A missing file is created. A generated file the declaration produces nothing for any more -- every namespace level dropped, the `legacy` section gone -- is deleted, as long as it holds nothing but what the generator owns; the deletion is logged. A template the tolerant render skipped is neither compared nor regenerated in that run: its objects were never seen. The removals the log names are the union over every render variant. A second `--fix` without changes to `rbac.yaml` changes nothing. Under `--matrix` every render variant reports the file, but the fix runs once: the variants record what their renders grant while they exist, the first closure checks the union and writes, the others report its outcome -- so a right rendered only under some values is never dropped. @@ -1678,20 +1685,23 @@ linters-settings: name: d8:user-authz:my-module:super-admin ``` -**Levels:** `contract`, `coverage` and `sync` start at `warn` wherever nothing sets them, whatever -`impact` the `rbac` linter has: they are new to every tree. A tree raises them to `error` in its root -`.dmtlint.yaml` (`global.linters-settings.rbac.rules..impact`) once its modules are clean. +**Levels:** `contract`, `coverage` and `sync` start at `warn` wherever nothing sets them: they are new +to every tree. A tree raises them to `error` in its root `.dmtlint.yaml` +(`global.linters-settings.rbac.rules..impact`) once its modules are clean; a module sets its own +in its `.dmtlint.yaml` (`linters-settings.rbac.rules..impact`), which wins over the root's. The +linter's `impact` caps them: `impact: ignored` on `rbac` silences every rbac rule. A level other than +`ignored`, `warn`, `error` or `critical` is a configuration error. **Limits worth knowing:** - A conditional rule is checked only where it renders: with the default values, `dmt lint --values-file` or `dmt lint --matrix`. - **The three states a module can be in when the new `dmt` first runs.** *Only the legacy scheme* (an external module not yet migrated): `contract` reports one "migrate" finding per object; with an `rbac.yaml`, `sync` reports the generated files as absent and names the cause -- the template renders the legacy scheme -- and `--fix` leaves the legacy file alone (no generator header) with the generated version beside it. *Only the 1.78 scheme*: the ordinary case described above. *Both schemes behind the version gate* (`rbacv2-migrate-module.sh` without `--replace`): the linter's values answer the gate with the 1.78 model, so `contract` and `sync` see exactly the new objects and the legacy branch is neither judged nor "extra"; `--fix` never rewrites a gated file -- regenerating it would drop the legacy branch -- and says so. The legacy branch itself is exercised with `dmt lint --values-file` setting `global.deckhouseVersion` below 1.78; `--matrix` varies module values only, not the platform version. -- The declaration is one per module and describes the union of editions. Linting a single edition directory shows the edition-only objects as absent; lint the merged tree as CI does. An `rbac.yaml` inside an edition overlay (`ee/modules`, `ee/be/modules`, ...) is an error: CI merges the overlays over `modules/` before linting, so a copy there would shadow the base one or go unseen. +- The declaration is one per module and describes the union of editions. Linting a single edition directory shows the edition-only objects as absent; lint the merged tree as CI does. An `rbac.yaml` inside an edition overlay (`ee/be/modules`, `ee/se-plus/modules`, ..., and `ee/modules` for a module that also exists in `modules/`) is an error: CI merges the overlays over `modules/` before linting, so a copy there would shadow the base one or go unseen. For an EE-only module, `ee/modules/` is the base directory. - The generator refuses what the declaration alone cannot know is wrong: system levels on a module whose `module.yaml` names no `subsystems` (set `subsystems` in `rbac.yaml`); an account in a component directory named unlike it (the placement rule wants `` or `-`) or in a nested directory; a capability marker past 63 characters (a module name of 32 characters and up with a `superadmin` level). - Built-in Kubernetes resources (`""`/configmaps, `apps`/deployments, `rbac.authorization.k8s.io`/clusterroles, ...) need no `scope`: the validator knows them. Anything else without a CRD in the module declares its scope. - An `rbac.yaml` of the earlier, never consumed shape (no `apiVersion`) is named for what it is: delete it and run `--fix` to write the declaration from the render. - Under `--matrix` the first declaration is written from the union of every variant's render; objects rendered only under values other than the defaults are still invisible to a default run, so lint with `--values-file` before the first regeneration if the module has such templates. -- `impact: ignore` on a rule switches its autofix off with it: `--fix` never rewrites files on behalf of findings nobody sees. +- `impact: ignored` on a rule switches its autofix off with it: `--fix` never rewrites files on behalf of findings nobody sees. - A `when` condition must parse as a Helm expression (sprig and Helm functions are known); whether it holds under the linter's value stubs is decided by the render -- a condition that breaks the render is reported by the `helm-render` rule, and the module is not linted further. - `dmt lint remote` does not run these rules: a published image carries no chart to render. - The keys of the `rbac` configuration blocks are checked down to a rule's `impact` and the `kind`/`name` of an exclusion entry: every unknown key is reported in one error, not dropped in silence. diff --git a/pkg/linters/rbac/rbac.go b/pkg/linters/rbac/rbac.go index 369d0003..0a0faba9 100644 --- a/pkg/linters/rbac/rbac.go +++ b/pkg/linters/rbac/rbac.go @@ -49,6 +49,19 @@ func New(cfg *pkg.RBACLinterConfig, ruleIDs set.Set, m pkg.Module, errorList *er } } +// lower returns the lower of two caps: a rule's own level narrows the linter's, it never lifts it, +// so impact: ignored on the linter silences the declaration rules as well. +func lower(linter, rule *pkg.Level) *pkg.Level { + switch { + case linter == nil: + return rule + case rule == nil || *linter < *rule: + return linter + default: + return rule + } +} + func (l *Rbac) Lint(ctx context.Context) { pkg.RunRules(ctx, l.ruleIDs, l.rules()) } @@ -67,7 +80,7 @@ func (l *Rbac) rules() []pkg.Rule { // (coverage, contract, sync) do read their own level, so that they can start as // warnings in a tree that has not adopted the declaration yet. level := func(rule pkg.RuleConfig) *errors.LintRuleErrorsList { - return errorList.WithMaxLevel(rule.GetLevel()) + return errorList.WithMaxLevel(lower(l.cfg.Impact, rule.GetLevel())) } return []pkg.Rule{ diff --git a/pkg/linters/rbac/rbac_test.go b/pkg/linters/rbac/rbac_test.go new file mode 100644 index 00000000..556bcfe2 --- /dev/null +++ b/pkg/linters/rbac/rbac_test.go @@ -0,0 +1,35 @@ +/* +Copyright 2025 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rbac + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "k8s.io/utils/ptr" + + "github.com/deckhouse/dmt/pkg" +) + +// A rule's level narrows the linter's and never lifts it (review of #479, finding 7): impact: +// ignored on the rbac linter silences the declaration rules too. +func TestLower(t *testing.T) { + assert.Equal(t, pkg.Ignored, *lower(ptr.To(pkg.Ignored), ptr.To(pkg.Warn))) + assert.Equal(t, pkg.Warn, *lower(ptr.To(pkg.Error), ptr.To(pkg.Warn))) + assert.Equal(t, pkg.Warn, *lower(nil, ptr.To(pkg.Warn))) + assert.Equal(t, pkg.Error, *lower(ptr.To(pkg.Error), nil)) +} diff --git a/pkg/linters/rbac/rules/bootstrap/bootstrap.go b/pkg/linters/rbac/rules/bootstrap/bootstrap.go index 75365e8e..1449fa7c 100644 --- a/pkg/linters/rbac/rules/bootstrap/bootstrap.go +++ b/pkg/linters/rbac/rules/bootstrap/bootstrap.go @@ -110,16 +110,17 @@ func sortedLevels(m map[string]map[string]struct{}) map[string][]string { type builder struct { in Input res map[[2]string]*resourceAcc - // restricted marks resources every grant of which carried resourceNames: the format cannot - // keep that limit on a capability, and widening a grant is not the importer's call. - restricted map[[2]string]bool - unrestricted map[[2]string]bool - texts map[string]rbacyaml.CapabilityText - lineages map[string]struct{} - used map[string]struct{} - notes []string - unmanaged []string - decl *rbacyaml.Declaration + // restricted collects, per resource, the grants limited to resourceNames: the format cannot + // keep that limit on a capability, and widening a grant is not the importer's call, so they are + // left out of the levels and named in a note. Keyed by resource only for the note; the verbs of + // one level never widen another's. + restricted map[[2]string][]string + texts map[string]rbacyaml.CapabilityText + lineages map[string]struct{} + used map[string]struct{} + notes []string + unmanaged []string + decl *rbacyaml.Declaration } func (b *builder) note(format string, args ...any) { @@ -165,7 +166,7 @@ func Build(in Input) Result { return in.Objects[i].identity() < in.Objects[j].identity() }) - b := &builder{in: in, res: map[[2]string]*resourceAcc{}, restricted: map[[2]string]bool{}, unrestricted: map[[2]string]bool{}, texts: map[string]rbacyaml.CapabilityText{}, lineages: map[string]struct{}{}, used: map[string]struct{}{}, + b := &builder{in: in, res: map[[2]string]*resourceAcc{}, restricted: map[[2]string][]string{}, texts: map[string]rbacyaml.CapabilityText{}, lineages: map[string]struct{}{}, used: map[string]struct{}{}, decl: &rbacyaml.Declaration{APIVersion: rbacyaml.APIVersionV1Alpha1}} b.capabilitiesAndLegacy() @@ -177,7 +178,14 @@ func Build(in Input) Result { return Result{Decl: b.decl, Notes: b.notes, Unmanaged: b.unmanaged} } -func (b *builder) addRules(sectionName, level string, rules []rbacv1.PolicyRule, skipModuleConfigs bool) { +// generatedModuleConfigVerbs are the verbs of the moduleconfigs rule the generator adds itself to +// the system view and edit capabilities, on the module's own ModuleConfig only. +var generatedModuleConfigVerbs = map[string][]string{ + "view": {"get", "list", "watch"}, + "edit": {"create", "update", "patch", "delete"}, +} + +func (b *builder) addRules(sectionName, level string, rules []rbacv1.PolicyRule, systemCapability bool) { for _, r := range rules { if len(r.NonResourceURLs) > 0 { b.note("%s/%s: a nonResourceURLs rule (%s) has no place in resources[]; keep it in a ServiceAccount's clusterRules", sectionName, level, strings.Join(r.NonResourceURLs, ", ")) @@ -191,22 +199,29 @@ func (b *builder) addRules(sectionName, level string, rules []rbacv1.PolicyRule, for _, g := range groups { for _, rs := range r.Resources { - if skipModuleConfigs && g == "deckhouse.io" && rs == "moduleconfigs" { - continue // the generator adds it + if systemCapability && g == "deckhouse.io" && rs == "moduleconfigs" { + // The generator adds the module's own ModuleConfig rule to view and edit; that one + // is not imported. Any other -- at another level, on another module's config, + // with other verbs -- the format cannot hold and is named instead of dropped. + own := slices.Equal(r.ResourceNames, []string{b.in.Module}) + if want, conventional := generatedModuleConfigVerbs[rbaccontract.CapabilityAction(level)]; !own || !conventional || !subset(r.Verbs, want) { + b.note("system/%s: a moduleconfigs rule the generator does not produce (%s on %v) is not carried over; the format has no place for it", level, strings.Join(r.Verbs, ","), r.ResourceNames) + } + + continue } key := [2]string{g, rs} - if len(r.ResourceNames) > 0 { - b.restricted[key] = true - } else { - b.unrestricted[key] = true - } - if b.res[key] == nil { b.res[key] = newAcc() } + if len(r.ResourceNames) > 0 { + b.restricted[key] = append(b.restricted[key], fmt.Sprintf("%s/%s: %s on %v", sectionName, level, strings.Join(r.Verbs, ","), r.ResourceNames)) + continue + } + switch sectionName { case rbaccontract.LineageNamespace: addVerbs(b.res[key].namespace, level, r.Verbs) @@ -415,6 +430,11 @@ func (b *builder) serviceAccounts() { b.rename("Role", role.Name, sa.Name) b.rename("RoleBinding", rb.Name, sa.Name) b.mark(role) + } else if rb.RoleRef.Kind != "Role" { + // bindRoles binds Roles; the format has no RoleBinding to a ClusterRole, and turning it + // into one to a Role of that name would bind nothing (Kubernetes accepts a binding to a + // Role that does not exist). + b.unmanage(rb, "a RoleBinding to the ClusterRole "+rb.RoleRef.Name+", which bindRoles cannot express") } else { e.BindRoles = append(e.BindRoles, rbacyaml.RoleRef{Namespace: b.ns(rb), Name: rb.RoleRef.Name}) b.rename("RoleBinding", b.ns(rb)+"/"+rb.Name, b.ns(rb)+"/"+clusterName+":"+rbaccontract.BindingSuffix(rb.RoleRef.Name)) @@ -627,17 +647,24 @@ func (b *builder) resources() { } e.Namespace, e.System, e.Legacy = sortedLevels(acc.namespace), sortedLevels(acc.system), sortedLevels(acc.legacy) - if !e.HasLevels() { - continue + + // A grant limited to resourceNames is never widened to every object, at any level: it + // stays out of the entry and is named for the author. When nothing else grants the + // resource, the entry is left undecided and coverage keeps the run red until it is. + if restricted := b.restricted[k]; len(restricted) > 0 { + sort.Strings(restricted) + + if !e.HasLevels() { + e = rbacyaml.Resource{Group: group, Resource: resource, Scope: e.Scope, + NoAccess: "TODO: the templates limited this grant to specific resourceNames, which the format cannot express; grant the levels to every object or keep denying"} + b.note("%s/%s: every grant carried resourceNames; left as noAccess TODO instead of widening it to every object (%s)", group, resource, strings.Join(restricted, "; ")) + } else { + b.note("%s/%s: grants limited to resourceNames are not carried over, the format would grant them on every object: %s", group, resource, strings.Join(restricted, "; ")) + } } - // Every grant of this resource named specific objects (resourceNames); a declaration entry - // would grant every object. That widening is a person's decision, so the entry is left - // undecided and coverage keeps the run red until it is made. - if b.restricted[k] && !b.unrestricted[k] { - e = rbacyaml.Resource{Group: group, Resource: resource, Scope: e.Scope, - NoAccess: "TODO: the templates limited this grant to specific resourceNames, which the format cannot express; grant the levels to every object or keep denying"} - b.note("%s/%s: every grant carried resourceNames; left as noAccess TODO instead of widening it to every object", group, resource) + if !e.HasLevels() && e.NoAccess == "" { + continue } b.decl.Resources = append(b.decl.Resources, e) @@ -717,3 +744,14 @@ func subjects(list []rbacv1.Subject) []rbacyaml.Subject { return out } + +// subset reports whether every item of a is in b. +func subset(a, b []string) bool { + for _, x := range a { + if !slices.Contains(b, x) { + return false + } + } + + return true +} diff --git a/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go b/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go index be9bdb11..ba5117fd 100644 --- a/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go +++ b/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go @@ -204,9 +204,37 @@ func TestBuild_ResourceNamesAreNotWidened(t *testing.T) { require.Contains(t, byKey, "/secrets") assert.Empty(t, byKey["/secrets"].NoAccess) - assert.ElementsMatch(t, []string{"get", "list"}, byKey["/secrets"].Namespace["viewer"]) + assert.Equal(t, []string{"list"}, byKey["/secrets"].Namespace["viewer"], "get was limited to m-token and is not widened") - assert.Contains(t, strings.Join(got.Notes, "\n"), "/configmaps: every grant carried resourceNames") + notes := strings.Join(got.Notes, "\n") + assert.Contains(t, notes, "/configmaps: every grant carried resourceNames") + assert.Contains(t, notes, "/secrets: grants limited to resourceNames are not carried over, the format would grant them on every object: namespace/viewer: get on [m-token]") +} + +// An unrestricted grant at one level does not carry a restricted grant at another level with it +// (review of #479, finding 8). +func TestBuild_ResourceNamesOfOneLevelDoNotRideOnAnother(t *testing.T) { + capability := func(name, level string, rules ...rbacv1.PolicyRule) Object { + return Object{Kind: "ClusterRole", Name: name, Path: "templates/rbacv2/use/x.yaml", Rules: rules, + Labels: map[string]string{"module": "m", "rbac.deckhouse.io/kind": "capability", "rbac.deckhouse.io/scope": "namespace", "rbac.deckhouse.io/aggregate-to-namespace-as": level}} + } + + got := Build(Input{Module: "m", Namespace: "d8-m", Objects: []Object{ + capability("d8:namespace-capability:m:view", "viewer", rbacv1.PolicyRule{APIGroups: []string{""}, Resources: []string{"secrets"}, Verbs: []string{"list"}}), + capability("d8:namespace-capability:m:edit", "manager", rbacv1.PolicyRule{APIGroups: []string{""}, Resources: []string{"secrets"}, ResourceNames: []string{"m-config"}, Verbs: []string{"get", "update", "delete"}}), + }}) + + var secrets rbacyaml.Resource + + for _, r := range got.Decl.Resources { + if r.Resource == "secrets" { + secrets = r + } + } + + assert.Equal(t, []string{"list"}, secrets.Namespace["viewer"]) + assert.NotContains(t, secrets.Namespace, "manager", "the manager grant named m-config only") + assert.Contains(t, strings.Join(got.Notes, "\n"), "namespace/manager: get,update,delete on [m-config]") } // The lint path fills the input from a map; the result must not depend on that order. @@ -283,3 +311,35 @@ func TestBuild_TODOsAndUnmanaged(t *testing.T) { assert.Contains(t, got.Unmanaged[0], "d8:namespace-capability:kubernetes:view_logs") assert.Contains(t, got.Unmanaged[1], "d8:use:capability:module:m:view") } + +// A system capability's moduleconfigs rule other than the one the generator adds is named, not +// dropped in silence; a ServiceAccount's RoleBinding to a ClusterRole stays hand-written instead of +// becoming a binding to a Role of that name (review of #479, findings 13c and 13d). +func TestBuild_WhatTheFormatCannotHoldIsNamed(t *testing.T) { + capability := func(action, level string, rules ...rbacv1.PolicyRule) Object { + return Object{Kind: "ClusterRole", Name: "d8:system-capability:m:" + action, Path: "templates/rbacv2/manage/" + action + ".yaml", Rules: rules, + Labels: map[string]string{"module": "m", "rbac.deckhouse.io/kind": "capability", "rbac.deckhouse.io/scope": "system", "rbac.deckhouse.io/aggregate-to-security-as": level}} + } + moduleConfigs := func(names []string, verbs ...string) rbacv1.PolicyRule { + return rbacv1.PolicyRule{APIGroups: []string{"deckhouse.io"}, Resources: []string{"moduleconfigs"}, ResourceNames: names, Verbs: verbs} + } + + got := Build(Input{Module: "m", Namespace: "d8-m", Subsystems: []string{"security"}, Objects: []Object{ + capability("view", "viewer", moduleConfigs([]string{"m"}, "get", "list", "watch")), + capability("superadmin", "superadmin", moduleConfigs([]string{"m"}, "delete")), + capability("edit", "manager", moduleConfigs([]string{"other"}, "update")), + {Kind: "ServiceAccount", Name: "worker", Path: "templates/worker/rbac-for-us.yaml", Labels: map[string]string{"module": "m"}}, + {Kind: "RoleBinding", Name: "worker-view", Namespace: "d8-m", Path: "templates/worker/rbac-for-us.yaml", Labels: map[string]string{"module": "m"}, + RoleRef: rbacv1.RoleRef{Kind: "ClusterRole", Name: "view"}, + Subjects: []rbacv1.Subject{{Kind: "ServiceAccount", Name: "worker", Namespace: "d8-m"}}}, + }}) + + notes := strings.Join(got.Notes, "\n") + assert.NotContains(t, notes, "system/viewer: a moduleconfigs rule", "the generator's own rule is not a note") + assert.Contains(t, notes, "system/superadmin: a moduleconfigs rule the generator does not produce (delete on [m])") + assert.Contains(t, notes, "system/manager: a moduleconfigs rule the generator does not produce (update on [other])") + + require.Len(t, got.Decl.ServiceAccounts, 1) + assert.Empty(t, got.Decl.ServiceAccounts[0].BindRoles) + assert.Contains(t, strings.Join(got.Unmanaged, "\n"), "a RoleBinding to the ClusterRole view, which bindRoles cannot express") +} diff --git a/pkg/linters/rbac/rules/contract.go b/pkg/linters/rbac/rules/contract.go index ff95b8f5..f5270203 100644 --- a/pkg/linters/rbac/rules/contract.go +++ b/pkg/linters/rbac/rules/contract.go @@ -123,6 +123,26 @@ func (r *ContractRule) Check(_ context.Context) { sort.Slice(objects, func(i, j int) bool { return objects[i].Unstructured.GetName() < objects[j].Unstructured.GetName() }) + // Two capabilities with one marker are indistinguishable to the console and to everything that + // selects a capability by it. + markers := map[string]string{} + + for _, object := range objects { + marker := object.Unstructured.GetLabels()[rbaccontract.LabelCapability] + if marker == "" { + continue + } + + if first, dup := markers[marker]; dup { + r.errorList.WithObjectID(object.Identity()).WithFilePath(object.ShortPath()). + Errorf("capability marker %q is also carried by %s; every capability of the module needs its own", marker, first) + + continue + } + + markers[marker] = object.Unstructured.GetName() + } + for _, object := range objects { errorList := r.errorList.WithObjectID(object.Identity()).WithFilePath(object.ShortPath()) @@ -154,10 +174,10 @@ func (r *ContractRule) Check(_ context.Context) { func (r *ContractRule) resourceScopes() rbacyaml.CRDScopes { scopes := make(rbacyaml.CRDScopes) - if crds, err := moduleCRDs(r.module.GetPath()); err == nil { - for _, crd := range crds { - scopes[crd.Key()] = crd.Scope - } + // A document that does not parse is reported by coverage; the others still give their scope. + crds, _ := moduleCRDs(r.module.GetPath()) + for _, crd := range crds { + scopes[crd.Key()] = crd.Scope } if decl, err := rbacyaml.Load(r.module.GetPath()); err == nil { @@ -378,7 +398,12 @@ func checkCapability(role *rbacv1.ClusterRole, scope string, scopes rbacyaml.CRD warned[group+"/"+resource] = struct{}{} - if scopes[group+"/"+resource] == rbacyaml.ScopeCluster { + scope := scopes[group+"/"+resource] + if scope == "" { + scope, _ = rbacyaml.WellKnownScope(group, resource) + } + + if scope == rbacyaml.ScopeCluster { errorList.Warnf("capability %q grants %s/%s, a cluster-scoped resource, in a namespace capability: bound through a RoleBinding the rule grants nothing; move it to a system capability", name, group, resource) } } diff --git a/pkg/linters/rbac/rules/contract_test.go b/pkg/linters/rbac/rules/contract_test.go index e2a3e373..8bf0c032 100644 --- a/pkg/linters/rbac/rules/contract_test.go +++ b/pkg/linters/rbac/rules/contract_test.go @@ -271,3 +271,23 @@ func TestContract_LegacyScheme(t *testing.T) { assert.Empty(t, runContract(t, modulePath, rendered{"templates/rbacv2/use/view.yaml", legacy})) }) } + +// Two capabilities of one module with one marker are refused; a namespace capability granting a +// built-in cluster-scoped resource is warned about (review of #479, findings 13f and 13g). +func TestContract_DuplicateMarkerAndBuiltInScope(t *testing.T) { + labels := func(marker string) map[string]string { + return map[string]string{"module": "cert-manager", "rbac.deckhouse.io/kind": "capability", "rbac.deckhouse.io/scope": "namespace", + "rbac.deckhouse.io/capability": marker, "rbac.deckhouse.io/aggregate-to-namespace-as": "viewer"} + } + + got := runContract(t, t.TempDir(), + rendered{"templates/rbacv2/use/view.yaml", clusterRole("d8:namespace-capability:cert-manager:view", labels("namespace-capability.cert-manager.view"), i18n, + "rules:\n- apiGroups: [\"\"]\n resources: [nodes]\n verbs: [get]\n")}, + rendered{"templates/rbacv2/use/view2.yaml", clusterRole("d8:namespace-capability:cert-manager:view_more", labels("namespace-capability.cert-manager.view"), i18n, + "rules:\n- apiGroups: [x.io]\n resources: [ys]\n verbs: [get]\n")}, + ) + + joined := strings.Join(got, "\n") + assert.Contains(t, joined, `capability marker "namespace-capability.cert-manager.view" is also carried by d8:namespace-capability:cert-manager:view`) + assert.Contains(t, joined, "grants /nodes, a cluster-scoped resource, in a namespace capability") +} diff --git a/pkg/linters/rbac/rules/coverage.go b/pkg/linters/rbac/rules/coverage.go index af5363a4..4c42954e 100644 --- a/pkg/linters/rbac/rules/coverage.go +++ b/pkg/linters/rbac/rules/coverage.go @@ -77,10 +77,9 @@ func (r *CoverageRule) Check(_ context.Context) { return } - crds, err := moduleCRDs(modulePath) - if err != nil { - r.errorList.WithFilePath("crds").Errorf("cannot read the module CRDs: %v", err) - return + crds, skipped := moduleCRDs(modulePath) + for _, err := range skipped { + r.errorList.WithFilePath("crds").Warnf("a CRD document is skipped: %v; its resource is judged as external until it parses", err) } entries := make(map[string]rbacyaml.Resource, len(decl.Resources)) diff --git a/pkg/linters/rbac/rules/coverage_test.go b/pkg/linters/rbac/rules/coverage_test.go index f24aa800..25a459ad 100644 --- a/pkg/linters/rbac/rules/coverage_test.go +++ b/pkg/linters/rbac/rules/coverage_test.go @@ -94,8 +94,8 @@ func TestModuleCRDs(t *testing.T) { "images/img/testdata/crds/look-alike.yaml": crdYAML("z.io", "zetas", "Cluster"), }) - crds, err := moduleCRDs(modulePath) - require.NoError(t, err) + crds, skipped := moduleCRDs(modulePath) + require.Empty(t, skipped) keys := make([]string, 0, len(crds)) for _, c := range crds { @@ -321,3 +321,17 @@ resources: require.Len(t, got, 1, "got: %v", got) assert.Contains(t, got[0], "warn: gone.io/relics is denied access but the module ships no CRD for it and the entry names no scope") } + +// One CRD document that does not parse is skipped with a warning; the other CRDs are still +// covered (review of #479, finding 13i). +func TestCoverage_BadCRDDocumentIsSkipped(t *testing.T) { + modulePath := writeModule(t, map[string]string{ + "crds/a.yaml": crdYAML("a.io", "alphas", "Namespaced") + "---\n{{ if .Values.x }}: [\n", + rbacyaml.Filename: "apiVersion: rbac.deckhouse.io/v1alpha1\n", + }) + + got := texts(runCoverage(t, modulePath)) + joined := strings.Join(got, "\n") + assert.Contains(t, joined, "warn: a CRD document is skipped: parse crds/a.yaml") + assert.Contains(t, joined, "a.io/alphas", "the CRD that parses is still judged") +} diff --git a/pkg/linters/rbac/rules/crds.go b/pkg/linters/rbac/rules/crds.go index 8aa5fe84..1a60637f 100644 --- a/pkg/linters/rbac/rules/crds.go +++ b/pkg/linters/rbac/rules/crds.go @@ -75,13 +75,17 @@ type crdDocument struct { // Documents that are not a CustomResourceDefinition are skipped: a crds/ directory may hold a // README or other manifests. A file that does not parse is an error: a CRD the rule cannot read // is a CRD whose access nobody decided on. -func moduleCRDs(modulePath string) ([]crdInfo, error) { - var out []crdInfo +func moduleCRDs(modulePath string) ([]crdInfo, []error) { + var ( + out []crdInfo + skipped []error + ) for _, file := range fsutils.GetFiles(modulePath, true, filterCRDFiles) { data, err := os.ReadFile(file) if err != nil { - return nil, fmt.Errorf("read %s: %w", fsutils.Rel(modulePath, file), err) + skipped = append(skipped, fmt.Errorf("read %s: %w", fsutils.Rel(modulePath, file), err)) + continue } for _, doc := range fsutils.SplitManifests(string(data)) { @@ -89,9 +93,12 @@ func moduleCRDs(modulePath string) ([]crdInfo, error) { continue } + // One document that does not parse -- a Helm-templated CRD, say -- costs its own CRD, + // not every other one of the module. var crd crdDocument if err := yaml.Unmarshal([]byte(doc), &crd); err != nil { - return nil, fmt.Errorf("parse %s: %w", fsutils.Rel(modulePath, file), err) + skipped = append(skipped, fmt.Errorf("parse %s: %w", fsutils.Rel(modulePath, file), err)) + continue } if crd.Kind != "CustomResourceDefinition" { @@ -119,5 +126,5 @@ func moduleCRDs(modulePath string) ([]crdInfo, error) { return out[i].File < out[j].File }) - return out, nil + return out, skipped } diff --git a/pkg/linters/rbac/rules/fixstate.go b/pkg/linters/rbac/rules/fixstate.go index dbd2f8b1..f8916e5c 100644 --- a/pkg/linters/rbac/rules/fixstate.go +++ b/pkg/linters/rbac/rules/fixstate.go @@ -175,6 +175,13 @@ func editionOverlay(modulePath string) string { switch { case parts[n-3] == "ee": + // ee/modules is merged over modules/ for a module that exists in both; an EE-only module + // has no other directory, and ee/modules/ is its base. + root := string(filepath.Separator) + filepath.Join(parts[:n-3]...) + if _, err := os.Stat(filepath.Join(root, "modules", parts[n-1])); err != nil { + return "" + } + return "ee/modules" case n >= 4 && parts[n-4] == "ee": return "ee/" + parts[n-3] + "/modules" diff --git a/pkg/linters/rbac/rules/generate/generate_test.go b/pkg/linters/rbac/rules/generate/generate_test.go index 7e3e0cba..2ef25ec6 100644 --- a/pkg/linters/rbac/rules/generate/generate_test.go +++ b/pkg/linters/rbac/rules/generate/generate_test.go @@ -17,6 +17,7 @@ limitations under the License. package generate import ( + "fmt" "os" "path/filepath" "regexp" @@ -415,6 +416,43 @@ func TestBuild_PrometheusAccessWhen(t *testing.T) { assert.Less(t, strings.Index(rendered, "kind: Role\n"), strings.Index(rendered, "{{- if"), "the Role comes before the gate") } +// A capability whose every rule is conditional is conditional as a whole: it is not rendered with +// an empty rules list when the conditions do not hold (review of #479, finding 11). +func TestBuild_AllRulesUnderWhenLiftTheCondition(t *testing.T) { + declWith := func(whens ...string) *rbacyaml.Declaration { + d := &rbacyaml.Declaration{APIVersion: rbacyaml.APIVersionV1Alpha1} + for i, w := range whens { + d.Resources = append(d.Resources, rbacyaml.Resource{Group: "x.io", Resource: fmt.Sprintf("things%d", i), Scope: "Namespaced", When: w, + Namespace: map[string][]string{"viewer": {"get"}}}) + } + + return d + } + + capability := func(t *testing.T, decl *rbacyaml.Declaration) Object { + t.Helper() + + model, err := Build(Input{Module: "m", Namespace: "d8-m", Decl: decl}) + require.NoError(t, err) + + f := model.File("templates/rbacv2/use/view.yaml") + require.NotNil(t, f) + + return f.Objects[0] + } + + one := capability(t, declWith(".Values.m.a", ".Values.m.a")) + assert.Equal(t, ".Values.m.a", one.When) + assert.Empty(t, one.Rules[0].When, "a shared condition leaves the rules") + + two := capability(t, declWith(".Values.m.a", ".Values.m.b")) + assert.Equal(t, "or (.Values.m.a) (.Values.m.b)", two.When) + assert.Equal(t, ".Values.m.a", two.Rules[0].When, "different conditions stay on their rules") + + mixed := capability(t, declWith(".Values.m.a", "")) + assert.Empty(t, mixed.When, "an unconditional rule keeps the role unconditional") +} + // A generated file that acquired CRLF line endings is still the generator's. func TestParseHeader_CRLF(t *testing.T) { generated, version := ParseHeader(Header() + "\r\n---\r\n") diff --git a/pkg/linters/rbac/rules/generate/model.go b/pkg/linters/rbac/rules/generate/model.go index 367bf5e3..30452244 100644 --- a/pkg/linters/rbac/rules/generate/model.go +++ b/pkg/linters/rbac/rules/generate/model.go @@ -28,6 +28,7 @@ package generate import ( "errors" "fmt" + "slices" "sort" "strings" @@ -188,6 +189,10 @@ func Build(in Input) (*Model, error) { model := &Model{Files: make([]File, 0, len(b.files))} for _, f := range b.files { + for i := range f.Objects { + liftRuleConditions(&f.Objects[i]) + } + model.Files = append(model.Files, *f) } @@ -573,3 +578,46 @@ func sortRules(rules []Rule) []Rule { return out } + +// liftRuleConditions moves the conditions of a role whose every rule is conditional onto the role +// itself, so that the role is not rendered with an empty rules list when none of them holds +// (ADR: a ClusterRole without rules is not generated). One shared condition is lifted as it is +// and leaves the rules; different ones become an `or` of them, and each rule keeps its own. +func liftRuleConditions(o *Object) { + if (o.Kind != "ClusterRole" && o.Kind != "Role") || len(o.Rules) == 0 { + return + } + + var conditions []string + + for _, r := range o.Rules { + if r.When == "" { + return + } + + if !slices.Contains(conditions, r.When) { + conditions = append(conditions, r.When) + } + } + + lifted := conditions[0] + + if len(conditions) == 1 { + for i := range o.Rules { + o.Rules[i].When = "" + } + } else { + parts := make([]string, 0, len(conditions)) + for _, c := range conditions { + parts = append(parts, "("+c+")") + } + + lifted = "or " + strings.Join(parts, " ") + } + + if o.When != "" { + lifted = "and (" + o.When + ") (" + lifted + ")" + } + + o.When = lifted +} diff --git a/pkg/linters/rbac/rules/rbacyaml/load_test.go b/pkg/linters/rbac/rules/rbacyaml/load_test.go index 5dfa9de7..750220bb 100644 --- a/pkg/linters/rbac/rules/rbacyaml/load_test.go +++ b/pkg/linters/rbac/rules/rbacyaml/load_test.go @@ -606,3 +606,17 @@ access: assert.Contains(t, msgs, "access[0] (dup).subjects[1]: duplicate subject Group g") assert.Len(t, msgs, 2, "configmaps and deployments/scale need no scope: %v", msgs) } + +// legacy.SuperAdmin validates but reaches nobody; it is a warning (review of #479, finding 13e). +func TestWarnings_LegacySuperAdmin(t *testing.T) { + decl, err := Parse([]byte(entry(`group: cert-manager.io +resource: issuers +legacy: + SuperAdmin: [get]`))) + require.NoError(t, err) + assert.Empty(t, Validate(decl, certManagerCRDs)) + + w := Warnings(decl) + require.Len(t, w, 1) + assert.Contains(t, w[0], "legacy.SuperAdmin produces a role user-authz does not aggregate") +} diff --git a/pkg/linters/rbac/rules/rbacyaml/validate.go b/pkg/linters/rbac/rules/rbacyaml/validate.go index dc89e199..92029548 100644 --- a/pkg/linters/rbac/rules/rbacyaml/validate.go +++ b/pkg/linters/rbac/rules/rbacyaml/validate.go @@ -500,3 +500,20 @@ func yamlName(f reflect.StructField) string { return name } + +// Warnings lists what the declaration may say but likely does not mean. They do not stop +// generation. +func Warnings(d *Declaration) []string { + var out []string + + for i, r := range d.Resources { + // SuperAdmin is in the ClusterAuthorizationRule enum, but user-authz aggregates custom + // legacy roles only for User through ClusterAdmin: d8:user-authz::super-admin is + // generated and then ignored by the platform. + if _, ok := r.Legacy["SuperAdmin"]; ok { + out = append(out, fmt.Sprintf("resources[%d] (%s): legacy.SuperAdmin produces a role user-authz does not aggregate (it handles User through ClusterAdmin); the grant reaches nobody", i, r.Key())) + } + } + + return out +} diff --git a/pkg/linters/rbac/rules/sync.go b/pkg/linters/rbac/rules/sync.go index dbb0f285..408d366a 100644 --- a/pkg/linters/rbac/rules/sync.go +++ b/pkg/linters/rbac/rules/sync.go @@ -78,17 +78,25 @@ type moduleMetadata struct { Subsystems []string `json:"subsystems"` } -func readModuleMetadata(modulePath string) moduleMetadata { +func readModuleMetadata(modulePath string) (moduleMetadata, error) { var meta moduleMetadata data, err := os.ReadFile(filepath.Join(modulePath, "module.yaml")) if err != nil { - return meta + if stderrors.Is(err, os.ErrNotExist) { + return meta, nil + } + + return meta, err } - _ = yaml.Unmarshal(data, &meta) + // The subsystems decide the aggregation edges of every system capability; a module.yaml that + // does not parse must stop the rule, not strip them. + if err := yaml.Unmarshal(data, &meta); err != nil { + return meta, fmt.Errorf("parse module.yaml: %w", err) + } - return meta + return meta, nil } func (r *SyncRule) Check(_ context.Context) { @@ -96,12 +104,19 @@ func (r *SyncRule) Check(_ context.Context) { declList := r.errorList.WithFilePath(rbacyaml.Filename) decl, err := rbacyaml.Load(modulePath) + overlay := editionOverlay(modulePath) + if stderrors.Is(err, rbacyaml.ErrNotFound) { - r.bootstrap(declList) + // An overlay carries no declaration of its own: the one in the base directory describes + // the union of editions, so there is nothing to write here. + if overlay == "" { + r.bootstrap(declList) + } + return } - if overlay := editionOverlay(modulePath); overlay != "" { + if overlay != "" { declList.Errorf("%s lies in the edition overlay %s; the declaration describes the union of editions and belongs to modules// only -- CI merges the overlays over modules/ before linting, so a copy here would shadow it or go unseen. Only a person can close this: move the file", rbacyaml.Filename, overlay) @@ -119,9 +134,13 @@ func (r *SyncRule) Check(_ context.Context) { return } - crds, err := moduleCRDs(modulePath) + // A CRD document that does not parse is reported by coverage; the declaration is judged + // against the CRDs that do. + crds, _ := moduleCRDs(modulePath) + + meta, err := readModuleMetadata(modulePath) if err != nil { - r.errorList.WithFilePath("crds").Errorf("cannot read the module CRDs: %v", err) + r.errorList.WithFilePath("module.yaml").Errorf("%v; nothing is compared or generated until it parses: its subsystems decide the aggregation of every system capability", err) return } @@ -133,10 +152,14 @@ func (r *SyncRule) Check(_ context.Context) { return } + for _, w := range rbacyaml.Warnings(decl) { + declList.Warnf("%s", w) + } + model, err := generate.Build(generate.Input{ Module: r.module.GetName(), Namespace: r.module.GetNamespace(), - Subsystems: readModuleMetadata(modulePath).Subsystems, + Subsystems: meta.Subsystems, Decl: decl, }) if err != nil { @@ -224,7 +247,10 @@ func (r *SyncRule) compareText(modulePath string, model *generate.Model, actual // render cannot tell a conditional object whose condition is false from one whose // template was never written, but the text can -- nothing produces it (D4 covers the // render, not the file). - if stderrors.Is(err, os.ErrNotExist) && hasAbsentObject(file, actual) { + switch { + case !stderrors.Is(err, os.ErrNotExist): + divergences[file.Path] = append(divergences[file.Path], fmt.Sprintf("the file cannot be read: %v", err)) + case hasAbsentObject(file, actual): divergences[file.Path] = append(divergences[file.Path], "the file does not exist, and objects the declaration puts in it are absent from the render (objects under `when` included: no template produces them)") } @@ -316,12 +342,25 @@ func (r *SyncRule) report(modulePath string, model *generate.Model, divergences sort.Strings(paths) + var dropped map[string]string + if store := r.module.GetObjectStore(); store != nil { + dropped = store.Dropped + } + for _, path := range paths { list := divergences[path] sort.Strings(list) fileList := r.errorList.WithFilePath(path).WithObjectID(path) + // A template the render skipped is missing from the storage without being missing from + // the chart: its objects -- the foreign ones included -- were never seen, so neither the + // comparison nor a rewrite can be trusted. + if cause, skipped := dropped[path]; skipped { + fileList.Errorf("%s failed to render in this run (%s); nothing in it is compared or regenerated until it renders", path, cause) + continue + } + if file := model.File(path); file != nil { fileList = fileList.WithFix(regenerateFix(modulePath, *file, r.foreignObjects(*file, model), removalsOf(list))) fileList.Errorf("%s does not match %s: %s. Run `%s` to regenerate the file from the declaration", @@ -1093,12 +1132,17 @@ func (r *SyncRule) bootstrap(declList *errors.LintRuleErrorsList) { return } - in := bootstrap.Input{Module: r.module.GetName(), Namespace: r.module.GetNamespace(), Subsystems: readModuleMetadata(modulePath).Subsystems, CRDs: map[string]string{}} + meta, err := readModuleMetadata(modulePath) + if err != nil { + r.errorList.WithFilePath("module.yaml").Errorf("%v; the declaration is not written until it parses", err) + return + } - if crds, err := moduleCRDs(modulePath); err == nil { - for _, crd := range crds { - in.CRDs[crd.Key()] = crd.Scope - } + in := bootstrap.Input{Module: r.module.GetName(), Namespace: r.module.GetNamespace(), Subsystems: meta.Subsystems, CRDs: map[string]string{}} + + crds, _ := moduleCRDs(modulePath) + for _, crd := range crds { + in.CRDs[crd.Key()] = crd.Scope } for _, object := range storage { @@ -1133,7 +1177,17 @@ func (r *SyncRule) bootstrap(declList *errors.LintRuleErrorsList) { return fmt.Errorf("render %s: %w", rbacyaml.Filename, err) } - return writeFileAtomic(path, content, 0o644) //nolint:gosec // a source file of the module + if err := writeFileAtomic(path, content, 0o644); err != nil { //nolint:gosec // a source file of the module + return err + } + + // The file is written, but a TODO in it is a decision nobody has made yet: the finding + // stays, and so does the non-zero exit, until a person makes it (ADR, bootstrap). + if open := openDecisions(string(content)); open > 0 { + return fmt.Errorf("%s is written; %d TODO in it are decisions only a person can make -- resolve them, then run `%s` to regenerate the templates", rbacyaml.Filename, open, FixCommand) + } + + return nil }) }).Errorf("%s is missing: `%s` writes it from the RBAC objects the module renders today (%d of %d objects described, the rest listed in the file as hand-written); every TODO and note in it is a decision for a person before the templates are regenerated from it", rbacyaml.Filename, FixCommand, described, len(in.Objects)) @@ -1187,3 +1241,17 @@ func bootstrapObject(object storage.StoreObject) (bootstrap.Object, bool) { return o, true } + +// openDecisions counts the TODO values in a written declaration; the header comment that explains +// them does not count. +func openDecisions(content string) int { + n := 0 + + for line := range strings.SplitSeq(content, "\n") { + if !strings.HasPrefix(strings.TrimSpace(line), "#") { + n += strings.Count(line, "TODO") + } + } + + return n +} diff --git a/pkg/linters/rbac/rules/sync_test.go b/pkg/linters/rbac/rules/sync_test.go index 44c6190d..66fefc55 100644 --- a/pkg/linters/rbac/rules/sync_test.go +++ b/pkg/linters/rbac/rules/sync_test.go @@ -162,6 +162,7 @@ func runSync(t *testing.T, modulePath string, store *storage.UnstructuredObjectS m.GetNameMock.Optional().Return(syncModule) m.GetNamespaceMock.Optional().Return("d8-cert-manager") m.GetStorageMock.Optional().Return(store.Storage) + m.GetObjectStoreMock.Optional().Return(store) errorList := errors.NewLintRuleErrorsList() NewSyncRule(excludes, m, errorList).Check(context.Background()) @@ -577,9 +578,16 @@ func TestSync_DeclarationInEditionOverlay(t *testing.T) { } func TestEditionOverlay(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(root, "modules", "110-istio"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(root, "ee", "modules", "110-istio"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(root, "ee", "modules", "030-cloud-provider-openstack"), 0o755)) + + assert.Equal(t, "ee/modules", editionOverlay(filepath.Join(root, "ee", "modules", "110-istio")), "merged over modules/110-istio") + assert.Empty(t, editionOverlay(filepath.Join(root, "ee", "modules", "030-cloud-provider-openstack")), "an EE-only module: ee/modules is its base") + for path, want := range map[string]string{ "/r/modules/101-cert-manager": "", - "/r/ee/modules/500-x": "ee/modules", "/r/ee/be/modules/500-x": "ee/be/modules", "/r/ee/se-plus/modules/500-x/": "ee/se-plus/modules", "/r/ee/fe/x": "", @@ -1371,3 +1379,102 @@ func TestSync_AccessLevelAndAggregationAreCompared(t *testing.T) { require.Len(t, got, 1, "got: %v", got) assert.Contains(t, got[0], `ClusterRole/d8:user-authz:cert-manager:user: the user-authz.deckhouse.io/access-level annotation is "SuperAdmin" in the render, the declaration produces "User"`) } + +// A written declaration with TODO in it keeps the bootstrap finding and the non-zero exit: the +// file exists, the decisions do not (review of #479, finding 9). +func TestSync_BootstrapWithOpenDecisionsKeepsTheFinding(t *testing.T) { + resetFixState() + t.Cleanup(resetFixState) + + modulePath := syncModuleDir(t) + model := syncModel(t, modulePath) + require.NoError(t, os.Remove(rbacyaml.Path(modulePath))) + require.NoError(t, os.WriteFile(filepath.Join(modulePath, "crds", "extra.yaml"), []byte(crdYAML("cert-manager.io", "nobodies", "Namespaced")), 0o600)) + + errorList := runSync(t, modulePath, renderedFrom(t, model, nil)) + for _, fix := range errorList.GetFixes() { + fix() + } + + remaining := errorList.GetErrors() + require.Len(t, remaining, 1) + require.Error(t, remaining[0].FixError) + assert.Contains(t, remaining[0].FixError.Error(), "rbac.yaml is written; 1 TODO in it are decisions only a person can make") + + _, err := os.Stat(rbacyaml.Path(modulePath)) + require.NoError(t, err, "the file is written all the same") +} + +// A module directory in an edition overlay gets no declaration of its own (review of #479, +// finding 9): nothing is reported and nothing is written. +func TestSync_NoBootstrapInAnOverlay(t *testing.T) { + resetFixState() + t.Cleanup(resetFixState) + + root := t.TempDir() + base := filepath.Join(root, "modules", "101-cert-manager") + overlay := filepath.Join(root, "ee", "se-plus", "modules", "101-cert-manager") + + require.NoError(t, os.MkdirAll(base, 0o755)) + require.NoError(t, os.MkdirAll(filepath.Dir(overlay), 0o755)) + + modulePath := syncModuleDir(t) + model := syncModel(t, modulePath) + require.NoError(t, os.Remove(rbacyaml.Path(modulePath))) + require.NoError(t, os.Rename(modulePath, overlay)) + + errorList := runSync(t, overlay, renderedFrom(t, model, nil)) + assert.Empty(t, texts(errorList)) + + _, err := os.Stat(rbacyaml.Path(overlay)) + assert.True(t, os.IsNotExist(err)) +} + +// A template the tolerant render skipped is neither compared nor regenerated: its objects were +// never seen (review of #479, finding 13k). +func TestSync_DroppedTemplateIsNotRegenerated(t *testing.T) { + resetFixState() + t.Cleanup(resetFixState) + + modulePath := syncModuleDir(t) + model := syncModel(t, modulePath) + writeGenerated(t, modulePath, model) + + const rel = "templates/rbac-to-us.yaml" + + // The render skipped the whole template, so none of its objects is in the storage. + dropped := map[string]struct{}{} + for _, o := range model.File(rel).Objects { + dropped[o.Identity()] = struct{}{} + } + + store := renderedFrom(t, model, func(o *generate.Object) bool { + _, gone := dropped[o.Identity()] + + return !gone + }) + store.MarkDropped(rel, "required value missing") + + errorList := runSync(t, modulePath, store) + got := texts(errorList) + require.Len(t, got, 1, "got: %v", got) + assert.Contains(t, got[0], rel+" failed to render in this run (required value missing); nothing in it is compared or regenerated") + assert.Empty(t, errorList.GetFixes()) +} + +// A module.yaml that does not parse stops the rule instead of generating without subsystems +// (review of #479, finding 13k). +func TestSync_BrokenModuleYAMLStops(t *testing.T) { + resetFixState() + t.Cleanup(resetFixState) + + modulePath := syncModuleDir(t) + model := syncModel(t, modulePath) + require.NoError(t, os.WriteFile(filepath.Join(modulePath, "module.yaml"), []byte("name: [broken\n"), 0o600)) + + errorList := runSync(t, modulePath, renderedFrom(t, model, nil)) + got := texts(errorList) + require.Len(t, got, 1, "got: %v", got) + assert.Contains(t, got[0], "parse module.yaml") + assert.Empty(t, errorList.GetFixes()) +} From ce86638e46d2bac9fa399ef89d09121ec1f1216d Mon Sep 17 00:00:00 2001 From: Ivan Zvyagintsev Date: Wed, 23 Sep 2026 16:49:27 +0300 Subject: [PATCH 34/58] rbac: second review of #479 -- moves, orphans on disk, levels per ADR - An object the declaration now puts in another file is a move: it leaves its file only once the target lists it, so a target that cannot be written (hand-maintained, gated, refused) keeps it where it is instead of losing it; the finding says "moves to", not "no longer produces" (finding 15, a regression of the contract 2 change). - In a contract 1 file an object named the way the generator names the module's accounts and access is the generator's, so dropping it from the declaration removes it in the same run as the upgrade (reply to 4). - Orphaned generated files are found on disk as well, so a file whose objects are all under a false condition is not missed (finding 16). - A template any render variant failed to render is not rewritten by another variant's fix (finding 17). - Per-rule levels are read from the root only, as the ADR says and as for every dmt linter; a rule the root leaves unset falls back to the linter's impact below warn, so impact: ignored on rbac in a module still silences it there. The module-level rules block is gone (finding 18). - A module that lives in one edition directory only has its base there (finding 19). - Groups and resources are validated as names; "*/" is a valid wildcard (finding 20, reply to 6). - Bootstrap writes an unknown scope as a TODO value, keeps a grant on every ModuleConfig as an ordinary entry, and lists a role granting "*" verbs or groups as hand-written with the reason (replies to 9 and 13d, finding 21). - A --fix run that leaves any fix open exits non-zero whatever the level of its finding (reply to 9). Signed-off-by: Ivan Zvyagintsev --- cmd/dmt/main.go | 4 + internal/manager/manager.go | 5 + internal/modules/module.go | 26 +-- internal/modules/rbac_rules_config_test.go | 16 +- pkg/config/linters_settings.go | 10 - pkg/config/loader.go | 6 +- pkg/config/rbac_keys_test.go | 9 +- pkg/errors/lint_errors.go | 17 ++ pkg/errors/lint_errors_fixes_test.go | 27 +++ pkg/linters/rbac/README.md | 30 +-- pkg/linters/rbac/rbac.go | 15 +- pkg/linters/rbac/rbac_test.go | 35 ---- pkg/linters/rbac/rules/bootstrap/bootstrap.go | 46 ++++- .../rbac/rules/bootstrap/bootstrap_test.go | 33 +++- pkg/linters/rbac/rules/fixstate.go | 89 ++++++++- pkg/linters/rbac/rules/rbacyaml/load_test.go | 26 ++- pkg/linters/rbac/rules/rbacyaml/scopes.go | 2 + pkg/linters/rbac/rules/rbacyaml/validate.go | 17 +- pkg/linters/rbac/rules/sync.go | 177 +++++++++++++++-- pkg/linters/rbac/rules/sync_test.go | 187 ++++++++++++++++-- 20 files changed, 621 insertions(+), 156 deletions(-) delete mode 100644 pkg/linters/rbac/rbac_test.go diff --git a/cmd/dmt/main.go b/cmd/dmt/main.go index a6f98dc5..4bcb7a6a 100644 --- a/cmd/dmt/main.go +++ b/cmd/dmt/main.go @@ -113,5 +113,9 @@ func runLint(ctx context.Context, src manager.Source) error { return errors.New("critical errors found") } + if flags.Fix && mng.HasFailedFixes() { + return errors.New("some fixes did not close their findings; see AutofixError") + } + return nil } diff --git a/internal/manager/manager.go b/internal/manager/manager.go index eadcf15b..d70ee147 100644 --- a/internal/manager/manager.go +++ b/internal/manager/manager.go @@ -440,6 +440,11 @@ func (m *Manager) HasCriticalErrors() bool { return m.errors.ContainsErrors() } +// HasFailedFixes reports whether --fix left a finding open with the reason in its FixError. +func (m *Manager) HasFailedFixes() bool { + return m.errors.ContainsFailedFixes() +} + // ApplyFixes is the single entry point for the --fix flag. It runs every fix // attached to a collected finding. Findings whose fix succeeds are marked Fixed // and subsequently dropped by GetErrors; findings whose fix fails are kept, and diff --git a/internal/modules/module.go b/internal/modules/module.go index 2b64cace..0879eb1c 100644 --- a/internal/modules/module.go +++ b/internal/modules/module.go @@ -238,9 +238,9 @@ func mapRuleSettings(linterSettings *pkg.LintersSettings, configSettings *config } // mapRBACRules configures the per-rule levels of the rbac rules added for the module RBAC -// declaration: coverage, sync and contract read their impact from the module's configuration, -// then from the root one, and start at warn. The linter's own impact still caps them (see -// rbac.New): impact: ignored on the linter silences every rbac rule. +// declaration. As for every dmt linter, a per-rule level is read from the root configuration only +// and wins over the linter's impact; a rule the root leaves unset falls back to the linter's impact +// -- the module's, if it sets one -- but never above warn, the level these rules start at. func mapRBACRules(linterSettings *pkg.LintersSettings, configSettings *config.LintersSettings, globalConfig *global.Linters) { // The declaration rules are new to every tree: a module without rbac.yaml sees only contract, // and the platform tree still carries six dead rbac.yaml files of an older shape and rules the @@ -248,22 +248,14 @@ func mapRBACRules(linterSettings *pkg.LintersSettings, configSettings *config.Li // rules of the documentation linter -- and are raised to error per tree in its root // .dmtlint.yaml once its modules are clean. The linter-level impact is intentionally not the // fallback: impact: error on rbac means the four original rules, as it always did. - module := configSettings.Rbac.Rules - - linterSettings.RBAC.Rules.CoverageRule.SetLevel(firstSet(module.CoverageRule.Impact, globalConfig.Rbac.Rules.CoverageRule.Impact), pkg.Warn.String()) - linterSettings.RBAC.Rules.SyncRule.SetLevel(firstSet(module.SyncRule.Impact, globalConfig.Rbac.Rules.SyncRule.Impact), pkg.Warn.String()) - linterSettings.RBAC.Rules.ContractRule.SetLevel(firstSet(module.ContractRule.Impact, globalConfig.Rbac.Rules.ContractRule.Impact), pkg.Warn.String()) -} - -// firstSet returns the first non-empty level. -func firstSet(levels ...string) string { - for _, l := range levels { - if l != "" { - return l - } + fallback := pkg.Warn.String() + if impact := configSettings.Rbac.Impact; impact != "" && pkg.ParseStringToLevel(impact) < pkg.Warn { + fallback = impact } - return "" + linterSettings.RBAC.Rules.CoverageRule.SetLevel(globalConfig.Rbac.Rules.CoverageRule.Impact, fallback) + linterSettings.RBAC.Rules.SyncRule.SetLevel(globalConfig.Rbac.Rules.SyncRule.Impact, fallback) + linterSettings.RBAC.Rules.ContractRule.SetLevel(globalConfig.Rbac.Rules.ContractRule.Impact, fallback) } // mapContainerRules configures Container linter rules diff --git a/internal/modules/rbac_rules_config_test.go b/internal/modules/rbac_rules_config_test.go index 82e9848c..383f4827 100644 --- a/internal/modules/rbac_rules_config_test.go +++ b/internal/modules/rbac_rules_config_test.go @@ -53,19 +53,19 @@ func TestRemapLinterSettings_RBACDeclarationRules(t *testing.T) { } }) - t.Run("a module's own levels win over the root's", func(t *testing.T) { + t.Run("the linter's impact is the fallback below warn, and the root's per-rule level wins", func(t *testing.T) { settings := remapLinterSettings( - &config.LintersSettings{Rbac: config.RbacSettings{Rules: config.RbacModuleRules{ - SyncRule: config.RuleConfig{Impact: pkg.Ignored.String()}, - }}}, + &config.LintersSettings{Rbac: config.RbacSettings{Impact: pkg.Ignored.String()}}, &global.Linters{Rbac: global.RBACLinterConfig{Rules: global.RBACRules{ - SyncRule: global.RuleConfig{Impact: pkg.Error.String()}, - CoverageRule: global.RuleConfig{Impact: pkg.Error.String()}, + SyncRule: global.RuleConfig{Impact: pkg.Error.String()}, }}}, ) - require.Equal(t, pkg.Ignored, *settings.RBAC.Rules.SyncRule.GetLevel()) - require.Equal(t, pkg.Error, *settings.RBAC.Rules.CoverageRule.GetLevel()) + require.Equal(t, pkg.Ignored, *settings.RBAC.Rules.CoverageRule.GetLevel(), "impact: ignored on the linter silences an unset rule") + require.Equal(t, pkg.Error, *settings.RBAC.Rules.SyncRule.GetLevel(), "a module cannot lower what the root sets") + + settings = remapLinterSettings(&config.LintersSettings{Rbac: config.RbacSettings{Impact: pkg.Error.String()}}, &global.Linters{}) + require.Equal(t, pkg.Warn, *settings.RBAC.Rules.ContractRule.GetLevel(), "error on the linter does not raise the unset rules above warn") }) t.Run("module-level exclusions for the three rules", func(t *testing.T) { diff --git a/pkg/config/linters_settings.go b/pkg/config/linters_settings.go index 08dd2204..00aad918 100644 --- a/pkg/config/linters_settings.go +++ b/pkg/config/linters_settings.go @@ -214,20 +214,10 @@ type OpenAPIExcludeRules struct { type RbacSettings struct { ExcludeRules RBACExcludeRules `mapstructure:"exclude-rules"` - // Rules sets the levels of the declaration rules for this module; a module that is not ready - // for the declaration silences them here without touching the rest of the tree. - Rules RbacModuleRules `mapstructure:"rules"` Impact string `mapstructure:"impact"` } -// RbacModuleRules are the per-module levels of the rules added for the module RBAC declaration. -type RbacModuleRules struct { - CoverageRule RuleConfig `mapstructure:"coverage"` - SyncRule RuleConfig `mapstructure:"sync"` - ContractRule RuleConfig `mapstructure:"contract"` -} - type RBACExcludeRules struct { BindingSubject StringRuleExcludeList `mapstructure:"binding-subject"` Placement KindRuleExcludeList `mapstructure:"placement"` diff --git a/pkg/config/loader.go b/pkg/config/loader.go index c3fc53e9..2dd59d83 100644 --- a/pkg/config/loader.go +++ b/pkg/config/loader.go @@ -166,11 +166,7 @@ var rbacKnownKeys = map[string]map[string]struct{}{ "global.linters-settings.rbac.rules.coverage": {"impact": {}}, "global.linters-settings.rbac.rules.sync": {"impact": {}}, "global.linters-settings.rbac.rules.contract": {"impact": {}}, - "linters-settings.rbac": {"impact": {}, "exclude-rules": {}, "rules": {}}, - "linters-settings.rbac.rules": {"coverage": {}, "sync": {}, "contract": {}}, - "linters-settings.rbac.rules.coverage": {"impact": {}}, - "linters-settings.rbac.rules.sync": {"impact": {}}, - "linters-settings.rbac.rules.contract": {"impact": {}}, + "linters-settings.rbac": {"impact": {}, "exclude-rules": {}}, "linters-settings.rbac.exclude-rules": { "binding-subject": {}, "placement": {}, "wildcards": {}, "coverage": {}, "contract": {}, "sync": {}, }, diff --git a/pkg/config/rbac_keys_test.go b/pkg/config/rbac_keys_test.go index a8583856..8ddc4fbf 100644 --- a/pkg/config/rbac_keys_test.go +++ b/pkg/config/rbac_keys_test.go @@ -73,12 +73,11 @@ linters-settings: assert.Contains(t, err.Error(), "the accepted keys are contract, coverage, sync") }) - t.Run("a module sets the levels of the declaration rules", func(t *testing.T) { - require.NoError(t, loadFrom(t, "linters-settings:\n rbac:\n rules:\n coverage: {impact: ignored}\n sync: {impact: warn}\n")) - - err := loadFrom(t, "linters-settings:\n rbac:\n rules:\n placement: {impact: warn}\n") + t.Run("per-rule levels do not belong to the module block", func(t *testing.T) { + // ADR: per-rule levels are read from the root configuration only, as for every dmt linter. + err := loadFrom(t, "linters-settings:\n rbac:\n rules:\n coverage: {impact: warn}\n") require.Error(t, err) - assert.Contains(t, err.Error(), `unknown key(s) placement under "linters-settings.rbac.rules"`) + assert.Contains(t, err.Error(), `unknown key(s) rules under "linters-settings.rbac"`) }) t.Run("an unknown level is an error, not a silent error level", func(t *testing.T) { diff --git a/pkg/errors/lint_errors.go b/pkg/errors/lint_errors.go index 3eeeb24a..de32769f 100644 --- a/pkg/errors/lint_errors.go +++ b/pkg/errors/lint_errors.go @@ -278,6 +278,23 @@ func (l *LintRuleErrorsList) GetFixes() []func() { return fixes } +// ContainsFailedFixes reports whether a fix ran and did not close its finding: a stub written +// that is not yet a decision, a regeneration refused. The run has to end non-zero whatever the +// finding's level (ADR, rbac declaration: --fix with an open decision is a failure). +func (l *LintRuleErrorsList) ContainsFailedFixes() bool { + if l.storage == nil { + return false + } + + for _, err := range l.storage.GetErrors() { + if err.FixError != nil && err.Level != pkg.Ignored { + return true + } + } + + return false +} + func (l *LintRuleErrorsList) ContainsErrors() bool { if l.storage == nil { l.storage = &errStorage{} diff --git a/pkg/errors/lint_errors_fixes_test.go b/pkg/errors/lint_errors_fixes_test.go index a74a6ed3..eac2969b 100644 --- a/pkg/errors/lint_errors_fixes_test.go +++ b/pkg/errors/lint_errors_fixes_test.go @@ -17,9 +17,12 @@ limitations under the License. package errors import ( + stderrors "errors" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "k8s.io/utils/ptr" "github.com/deckhouse/dmt/pkg" ) @@ -43,3 +46,27 @@ func TestGetFixes_SkipsIgnoredFindings(t *testing.T) { require.Equal(t, map[string]int{"active": 1}, ran) } + +// A fix that runs and does not close its finding fails the run at any level but ignored (review +// of #479, reply to finding 9). +func TestContainsFailedFixes(t *testing.T) { + list := NewLintRuleErrorsList().WithMaxLevel(ptr.To(pkg.Warn)) + list.WithFix(func() error { return nil }).Warn("closed by its fix") + assert.False(t, list.ContainsFailedFixes(), "nothing ran yet") + + for _, fix := range list.GetFixes() { + fix() + } + + assert.False(t, list.ContainsFailedFixes(), "the fix closed its finding") + + failing := NewLintRuleErrorsList().WithMaxLevel(ptr.To(pkg.Warn)) + failing.WithFix(func() error { return stderrors.New("a decision is open") }).Warn("left open") + + for _, fix := range failing.GetFixes() { + fix() + } + + assert.True(t, failing.ContainsFailedFixes(), "a warn-level finding with a failed fix fails the run") + assert.False(t, failing.ContainsErrors(), "its level stays warn") +} diff --git a/pkg/linters/rbac/README.md b/pkg/linters/rbac/README.md index 1c947b7c..cf89bc80 100644 --- a/pkg/linters/rbac/README.md +++ b/pkg/linters/rbac/README.md @@ -1016,9 +1016,7 @@ You can also place a `.dmtlint.yaml` configuration file directly in your module # modules/my-module/.dmtlint.yaml linters-settings: rbac: - impact: warn # More lenient for this specific module; ignored silences every rbac rule - rules: # levels of the declaration rules for this module only; they win over the root's - sync: {impact: ignored} + impact: warn # More lenient for this specific module; ignored silences every rbac rule the root does not set exclude-rules: binding-subject: - legacy-sa @@ -1463,7 +1461,7 @@ access: Format rules the loader enforces: - `apiVersion` is required and must be `rbac.deckhouse.io/v1alpha1`; unknown keys anywhere are an error. -- Verbs are listed explicitly (`get`, `list`, `watch`, `create`, `update`, `patch`, `delete`, `deletecollection`); there are no aliases, and `*` is refused at every level (the `contract` rule reports `*` verbs and `apiGroups: ["*"]` in a rendered capability, too). `group: "*"` and a partial wildcard in a resource name are refused. +- Verbs are listed explicitly (`get`, `list`, `watch`, `create`, `update`, `patch`, `delete`, `deletecollection`); there are no aliases, and `*` is refused at every level (the `contract` rule reports `*` verbs and `apiGroups: ["*"]` in a rendered capability, too). `group: "*"` is refused; a group is a lowercase DNS name and a resource a lowercase plural with an optional `/`, where `*` and `*/` are the only wildcards. - No value holds `{{` or `}}`: the generator writes the values into Helm templates as they are, and a `when` is the condition only. - `legacy.SuperAdmin` validates but is a warning: user-authz aggregates custom legacy roles for `User` through `ClusterAdmin` only. - `prometheusAccess.when` gates the RoleBinding of the Prometheus scraper (typically `.Values.global.enabledModules | has "prometheus"`); the Role stays unconditional. @@ -1618,9 +1616,12 @@ objects the module renders today: the declaration a person would have transcribe with a `TODO` wherever a decision is still theirs (a resource without a CRD whose scope the linter cannot know, a CRD nobody grants, a namespaced resource granted cluster-wide) and a note on top for every object the generator will name differently or cannot describe. An entry whose CRD is in `crds/` -carries no `scope`: the CRD states it. A grant limited to `resourceNames` is never widened to every -object: it is left out and named in a note. The fix that writes the file keeps the finding, and the -exit code non-zero, while a `TODO` is left in it. Nothing is written into an edition overlay. Review +carries no `scope`: the CRD states it; an external resource whose scope is not known gets +`scope: "TODO: Namespaced or Cluster"`. A grant limited to `resourceNames` is never widened to every +object: it is left out and named in a note. A role granting `*` verbs or API groups, which the format +refuses, is listed as hand-written with the reason. The fix that writes the file keeps the finding +while a `TODO` is left in it, and a `--fix` run with any fix left open exits non-zero whatever the +level of its finding. Nothing is written into an edition overlay. Review it, resolve the TODOs, then run `--fix` again to regenerate the templates from it. From then on `rbac.yaml` is the source. @@ -1652,11 +1653,11 @@ no longer names leaves the template; the finding that led there listed it, and t removed, so a `--fix` run without a preceding `dmt lint` does not remove rights in silence. Three things are never written over -- -- a file that also holds objects of someone else -- a controller ClusterRole beside a declared ServiceAccount, a hand-written binding, a ConfigMap or a Secret -- is never rewritten, because the generator writes the whole file and they would vanish (and so they would if the file were deleted); the refusal names them: declare them (`extraClusterRoles`, `access` with `path`) or move them first. A generated file lists under its header, one `# dmt:owns ` line each, what the generator wrote into it (contract 2); an owned object the declaration no longer produces -- a legacy level dropped, a ServiceAccount removed -- is a removal, named in the finding and in the log, and everything else in the file is someone else's. In a file without that list (contract 1, or hand-written), a legacy role or a module capability the declaration does not produce is a removal too, and an object the generator produces under another name -- a binding with the same roleRef and subjects, a role with the same rules, while the new name is not rendered yet -- is replaced, not foreign; +- a file that also holds objects of someone else -- a controller ClusterRole beside a declared ServiceAccount, a hand-written binding, a ConfigMap or a Secret -- is never rewritten, because the generator writes the whole file and they would vanish (and so they would if the file were deleted); the refusal names them: declare them (`extraClusterRoles`, `access` with `path`) or move them first. A generated file lists under its header, one `# dmt:owns ` line each, what the generator wrote into it (contract 2); an owned object the declaration no longer produces -- a legacy level dropped, a ServiceAccount removed -- is a removal, named in the finding and in the log, and everything else in the file is someone else's. An object the declaration now puts in another file is a move: it leaves this file only once the other file lists it, so a target that cannot be written (maintained by hand, gated, refused) keeps the object where it is, and a second `--fix` finishes the move. In a file without that list (contract 1, or hand-written), a legacy role, a module capability or an object named the way the generator names the module's accounts and access (`d8::...`, `access-to-...`) that the declaration does not produce is a removal too; a ServiceAccount there needs one `--fix` to reach contract 2 first. An object the generator produces under another name -- a binding with the same roleRef and subjects, a role with the same rules, while the new name is not rendered yet -- is replaced, not foreign; - a file without the generator header is maintained by hand: the generated text is written beside it as `_.generated` (the underscore keeps Helm from rendering the copy) and the finding stays (delete the file and run `--fix` again to hand it back to the generator); - a template that serves both role models behind the version gate (`rbacv2_new_scheme`) is never regenerated: the legacy branch would vanish; -A missing file is created. A generated file the declaration produces nothing for any more -- every namespace level dropped, the `legacy` section gone -- is deleted, as long as it holds nothing but what the generator owns; the deletion is logged. A template the tolerant render skipped is neither compared nor regenerated in that run: its objects were never seen. The removals the log names are the union over every render variant. A second `--fix` without changes to `rbac.yaml` changes nothing. Under +A missing file is created. A generated file the declaration produces nothing for any more -- every namespace level dropped, the `legacy` section gone -- is deleted, as long as it holds nothing but what the generator owns; the deletion is logged. Such files are found on disk too, so a file whose objects are all under a condition that is false for these values is not missed. A template the tolerant render skipped in any render variant is neither compared nor regenerated: its objects were never seen. The removals the log names are the union over every render variant. A second `--fix` without changes to `rbac.yaml` changes nothing. Under `--matrix` every render variant reports the file, but the fix runs once: the variants record what their renders grant while they exist, the first closure checks the union and writes, the others report its outcome -- so a right rendered only under some values is never dropped. @@ -1687,16 +1688,17 @@ linters-settings: **Levels:** `contract`, `coverage` and `sync` start at `warn` wherever nothing sets them: they are new to every tree. A tree raises them to `error` in its root `.dmtlint.yaml` -(`global.linters-settings.rbac.rules..impact`) once its modules are clean; a module sets its own -in its `.dmtlint.yaml` (`linters-settings.rbac.rules..impact`), which wins over the root's. The -linter's `impact` caps them: `impact: ignored` on `rbac` silences every rbac rule. A level other than -`ignored`, `warn`, `error` or `critical` is a configuration error. +(`global.linters-settings.rbac.rules..impact`) once its modules are clean. Per-rule levels are +read from the root only, as for every dmt linter, and a module cannot lower them. A rule the root +leaves unset falls back to the linter's `impact` below `warn`: `impact: ignored` on `rbac` in a module's +`.dmtlint.yaml` silences it there. A level other than `ignored`, `warn`, `error` or `critical` is a +configuration error. **Limits worth knowing:** - A conditional rule is checked only where it renders: with the default values, `dmt lint --values-file` or `dmt lint --matrix`. - **The three states a module can be in when the new `dmt` first runs.** *Only the legacy scheme* (an external module not yet migrated): `contract` reports one "migrate" finding per object; with an `rbac.yaml`, `sync` reports the generated files as absent and names the cause -- the template renders the legacy scheme -- and `--fix` leaves the legacy file alone (no generator header) with the generated version beside it. *Only the 1.78 scheme*: the ordinary case described above. *Both schemes behind the version gate* (`rbacv2-migrate-module.sh` without `--replace`): the linter's values answer the gate with the 1.78 model, so `contract` and `sync` see exactly the new objects and the legacy branch is neither judged nor "extra"; `--fix` never rewrites a gated file -- regenerating it would drop the legacy branch -- and says so. The legacy branch itself is exercised with `dmt lint --values-file` setting `global.deckhouseVersion` below 1.78; `--matrix` varies module values only, not the platform version. -- The declaration is one per module and describes the union of editions. Linting a single edition directory shows the edition-only objects as absent; lint the merged tree as CI does. An `rbac.yaml` inside an edition overlay (`ee/be/modules`, `ee/se-plus/modules`, ..., and `ee/modules` for a module that also exists in `modules/`) is an error: CI merges the overlays over `modules/` before linting, so a copy there would shadow the base one or go unseen. For an EE-only module, `ee/modules/` is the base directory. +- The declaration is one per module and describes the union of editions. Linting a single edition directory shows the edition-only objects as absent; lint the merged tree as CI does. An `rbac.yaml` inside an edition overlay (`ee/be/modules`, `ee/se-plus/modules`, ..., and `ee/modules` for a module that also exists in `modules/`) is an error: CI merges the overlays over `modules/` before linting, so a copy there would shadow the base one or go unseen. A module that has no base elsewhere -- EE-only in `ee/modules/`, or living in one edition directory only such as `ee/be/modules/350-node-local-dns` -- has its base there. - The generator refuses what the declaration alone cannot know is wrong: system levels on a module whose `module.yaml` names no `subsystems` (set `subsystems` in `rbac.yaml`); an account in a component directory named unlike it (the placement rule wants `` or `-`) or in a nested directory; a capability marker past 63 characters (a module name of 32 characters and up with a `superadmin` level). - Built-in Kubernetes resources (`""`/configmaps, `apps`/deployments, `rbac.authorization.k8s.io`/clusterroles, ...) need no `scope`: the validator knows them. Anything else without a CRD in the module declares its scope. - An `rbac.yaml` of the earlier, never consumed shape (no `apiVersion`) is named for what it is: delete it and run `--fix` to write the declaration from the render. diff --git a/pkg/linters/rbac/rbac.go b/pkg/linters/rbac/rbac.go index 0a0faba9..369d0003 100644 --- a/pkg/linters/rbac/rbac.go +++ b/pkg/linters/rbac/rbac.go @@ -49,19 +49,6 @@ func New(cfg *pkg.RBACLinterConfig, ruleIDs set.Set, m pkg.Module, errorList *er } } -// lower returns the lower of two caps: a rule's own level narrows the linter's, it never lifts it, -// so impact: ignored on the linter silences the declaration rules as well. -func lower(linter, rule *pkg.Level) *pkg.Level { - switch { - case linter == nil: - return rule - case rule == nil || *linter < *rule: - return linter - default: - return rule - } -} - func (l *Rbac) Lint(ctx context.Context) { pkg.RunRules(ctx, l.ruleIDs, l.rules()) } @@ -80,7 +67,7 @@ func (l *Rbac) rules() []pkg.Rule { // (coverage, contract, sync) do read their own level, so that they can start as // warnings in a tree that has not adopted the declaration yet. level := func(rule pkg.RuleConfig) *errors.LintRuleErrorsList { - return errorList.WithMaxLevel(lower(l.cfg.Impact, rule.GetLevel())) + return errorList.WithMaxLevel(rule.GetLevel()) } return []pkg.Rule{ diff --git a/pkg/linters/rbac/rbac_test.go b/pkg/linters/rbac/rbac_test.go deleted file mode 100644 index 556bcfe2..00000000 --- a/pkg/linters/rbac/rbac_test.go +++ /dev/null @@ -1,35 +0,0 @@ -/* -Copyright 2025 Flant JSC - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package rbac - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "k8s.io/utils/ptr" - - "github.com/deckhouse/dmt/pkg" -) - -// A rule's level narrows the linter's and never lifts it (review of #479, finding 7): impact: -// ignored on the rbac linter silences the declaration rules too. -func TestLower(t *testing.T) { - assert.Equal(t, pkg.Ignored, *lower(ptr.To(pkg.Ignored), ptr.To(pkg.Warn))) - assert.Equal(t, pkg.Warn, *lower(ptr.To(pkg.Error), ptr.To(pkg.Warn))) - assert.Equal(t, pkg.Warn, *lower(nil, ptr.To(pkg.Warn))) - assert.Equal(t, pkg.Error, *lower(ptr.To(pkg.Error), nil)) -} diff --git a/pkg/linters/rbac/rules/bootstrap/bootstrap.go b/pkg/linters/rbac/rules/bootstrap/bootstrap.go index 1449fa7c..eb196c88 100644 --- a/pkg/linters/rbac/rules/bootstrap/bootstrap.go +++ b/pkg/linters/rbac/rules/bootstrap/bootstrap.go @@ -185,6 +185,21 @@ var generatedModuleConfigVerbs = map[string][]string{ "edit": {"create", "update", "patch", "delete"}, } +// scopeTODO is the scope bootstrap writes for an external resource whose scope it cannot know. +const scopeTODO = "TODO: Namespaced or Cluster" + +// wildcardGrant reports whether any rule grants "*" verbs or API groups, which the declaration +// refuses at every level. +func wildcardGrant(rules []rbacv1.PolicyRule) bool { + for _, r := range rules { + if slices.Contains(r.Verbs, "*") || slices.Contains(r.APIGroups, "*") { + return true + } + } + + return false +} + func (b *builder) addRules(sectionName, level string, rules []rbacv1.PolicyRule, systemCapability bool) { for _, r := range rules { if len(r.NonResourceURLs) > 0 { @@ -199,10 +214,12 @@ func (b *builder) addRules(sectionName, level string, rules []rbacv1.PolicyRule, for _, g := range groups { for _, rs := range r.Resources { - if systemCapability && g == "deckhouse.io" && rs == "moduleconfigs" { + if systemCapability && g == "deckhouse.io" && rs == "moduleconfigs" && len(r.ResourceNames) > 0 { // The generator adds the module's own ModuleConfig rule to view and edit; that one - // is not imported. Any other -- at another level, on another module's config, - // with other verbs -- the format cannot hold and is named instead of dropped. + // is not imported. Another one limited to names -- at another level, on another + // module's config, with other verbs -- the format cannot hold and is named instead + // of dropped. A grant on every ModuleConfig (no resourceNames, as the deckhouse + // module has) is an ordinary resource entry. own := slices.Equal(r.ResourceNames, []string{b.in.Module}) if want, conventional := generatedModuleConfigVerbs[rbaccontract.CapabilityAction(level)]; !own || !conventional || !subset(r.Verbs, want) { b.note("system/%s: a moduleconfigs rule the generator does not produce (%s on %v) is not carried over; the format has no place for it", level, strings.Join(r.Verbs, ","), r.ResourceNames) @@ -242,6 +259,18 @@ func (b *builder) capabilitiesAndLegacy() { } if level := o.Annotations[rbaccontract.AccessLevelAnnotation]; level != "" { + if wildcardGrant(o.Rules) { + why := "grants \"*\" verbs or API groups, which the declaration refuses at every level; the regeneration of " + o.Path + " removes it" + if level == "SuperAdmin" { + why += " -- user-authz does not aggregate SuperAdmin, so the role grants nothing today" + } + + b.unmanage(o, why) + b.mark(o) + + continue + } + b.rename("ClusterRole", o.Name, "d8:user-authz:"+b.in.Module+":"+rbaccontract.LegacyKebab(level)) b.addRules("legacy", level, o.Rules, false) b.mark(o) @@ -262,6 +291,13 @@ func (b *builder) capabilitiesAndLegacy() { lineage, action := m[1], m[3] level := rbaccontract.LevelOfAction(action) + if wildcardGrant(o.Rules) { + b.unmanage(o, "grants \"*\" verbs or API groups, which the declaration refuses at every level; list them in the template before the declaration can describe it") + b.mark(o) + + continue + } + b.addRules(lineage, level, o.Rules, lineage == rbaccontract.LineageSystem) b.mark(o) @@ -628,7 +664,9 @@ func (b *builder) resources() { if s, ok := rbacyaml.WellKnownScope(group, base); ok { scope = s } else { - b.note("%s/%s: the module ships no CRD and the scope is not known; fill scope: Namespaced|Cluster", group, resource) + // A TODO value, not only a note: the run that writes the file keeps its finding, and + // the declaration does not validate until a person fills it. + scope = scopeTODO } if !strings.Contains(resource, "/") { diff --git a/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go b/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go index ba5117fd..f3f88bd1 100644 --- a/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go +++ b/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go @@ -286,7 +286,6 @@ func TestBuild_TODOsAndUnmanaged(t *testing.T) { joined += n + "\n" } - assert.Contains(t, joined, "trivy.deckhouse.io/vulnerabilityreports: the module ships no CRD and the scope is not known; fill scope") assert.Contains(t, joined, "ServiceAccount webhook mounted its token") byKey := map[string]rbacyaml.Resource{} @@ -295,7 +294,7 @@ func TestBuild_TODOsAndUnmanaged(t *testing.T) { } assert.Equal(t, "Namespaced", byKey["/pods"].Scope, "a well-known core resource gets its scope") - assert.Equal(t, "", byKey["trivy.deckhouse.io/vulnerabilityreports"].Scope, "an unknown one is left for the author") + assert.Equal(t, "TODO: Namespaced or Cluster", byKey["trivy.deckhouse.io/vulnerabilityreports"].Scope, "an unknown one is a TODO value for the author (review of #479, reply to finding 9)") assert.Contains(t, byKey["deckhouse.io/things"].NoAccess, "TODO", "a CRD nobody grants is an undecided entry") require.Len(t, got.Decl.ServiceAccounts, 1) @@ -343,3 +342,33 @@ func TestBuild_WhatTheFormatCannotHoldIsNamed(t *testing.T) { assert.Empty(t, got.Decl.ServiceAccounts[0].BindRoles) assert.Contains(t, strings.Join(got.Unmanaged, "\n"), "a RoleBinding to the ClusterRole view, which bindRoles cannot express") } + +// A role granting "*" verbs or API groups stays out of the declaration with a note, instead of +// being written in a shape the validation refuses (review of #479, finding 21); a grant on every +// ModuleConfig is an ordinary system entry (review of #479, reply to finding 13d). +func TestBuild_WildcardRolesAndEveryModuleConfig(t *testing.T) { + got := Build(Input{Module: "m", Namespace: "d8-m", Subsystems: []string{"security"}, Objects: []Object{ + {Kind: "ClusterRole", Name: "d8:user-authz:m:super-admin", Path: "templates/user-authz-cluster-roles.yaml", + Annotations: map[string]string{"user-authz.deckhouse.io/access-level": "SuperAdmin"}, + Rules: []rbacv1.PolicyRule{{APIGroups: []string{"*"}, Resources: []string{"*"}, Verbs: []string{"*"}}}}, + {Kind: "ClusterRole", Name: "d8:system-capability:m:view", Path: "templates/rbacv2/manage/view.yaml", + Labels: map[string]string{"module": "m", "rbac.deckhouse.io/kind": "capability", "rbac.deckhouse.io/scope": "system", "rbac.deckhouse.io/aggregate-to-security-as": "viewer"}, + Rules: []rbacv1.PolicyRule{{APIGroups: []string{"deckhouse.io"}, Resources: []string{"moduleconfigs"}, Verbs: []string{"get", "list", "watch"}}}}, + }}) + + unmanaged := strings.Join(got.Unmanaged, "\n") + assert.Contains(t, unmanaged, "d8:user-authz:m:super-admin") + assert.Contains(t, unmanaged, "user-authz does not aggregate SuperAdmin") + + var moduleConfigs rbacyaml.Resource + + for _, r := range got.Decl.Resources { + assert.NotEqual(t, "*", r.Group, "no wildcard entry is written") + + if r.Resource == "moduleconfigs" { + moduleConfigs = r + } + } + + assert.Equal(t, []string{"get", "list", "watch"}, moduleConfigs.System["viewer"], "the grant on every ModuleConfig is kept") +} diff --git a/pkg/linters/rbac/rules/fixstate.go b/pkg/linters/rbac/rules/fixstate.go index f8916e5c..3a131f39 100644 --- a/pkg/linters/rbac/rules/fixstate.go +++ b/pkg/linters/rbac/rules/fixstate.go @@ -25,6 +25,7 @@ import ( "sync" "github.com/deckhouse/dmt/pkg/linters/rbac/rules/bootstrap" + "github.com/deckhouse/dmt/pkg/linters/rbac/rules/generate" "github.com/deckhouse/dmt/pkg/linters/rbac/rules/rbaccontract" ) @@ -42,10 +43,14 @@ var fixState = struct { sync.Mutex foreign map[string]map[string]struct{} removals map[string]map[string]struct{} + moves map[string]map[string]string + dropped map[string]string bootstrap map[string]map[string]bootstrap.Object }{ foreign: map[string]map[string]struct{}{}, removals: map[string]map[string]struct{}{}, + moves: map[string]map[string]string{}, + dropped: map[string]string{}, bootstrap: map[string]map[string]bootstrap.Object{}, } @@ -114,6 +119,8 @@ func resetFixState() { fixState.foreign = map[string]map[string]struct{}{} fixState.removals = map[string]map[string]struct{}{} + fixState.moves = map[string]map[string]string{} + fixState.dropped = map[string]string{} fixState.bootstrap = map[string]map[string]bootstrap.Object{} fixOutcomes.Lock() @@ -178,12 +185,20 @@ func editionOverlay(modulePath string) string { // ee/modules is merged over modules/ for a module that exists in both; an EE-only module // has no other directory, and ee/modules/ is its base. root := string(filepath.Separator) + filepath.Join(parts[:n-3]...) - if _, err := os.Stat(filepath.Join(root, "modules", parts[n-1])); err != nil { + if !exists(filepath.Join(root, "modules", parts[n-1])) { return "" } return "ee/modules" case n >= 4 && parts[n-4] == "ee": + // An edition directory is an overlay only for a module that has a base to merge over; a + // module that lives in this edition alone (node-local-dns, cloud-provider-vsphere, ...) has + // its base here. + root := string(filepath.Separator) + filepath.Join(parts[:n-4]...) + if !exists(filepath.Join(root, "modules", parts[n-1])) && !exists(filepath.Join(root, "ee", "modules", parts[n-1])) { + return "" + } + return "ee/" + parts[n-3] + "/modules" default: return "" @@ -284,3 +299,75 @@ func recordedRemovals(file string) []string { return out } + +// recordMoves adds the objects one render variant saw in the file that the declaration now puts in +// another file, with that file's full path. +func recordMoves(file string, moves map[string]string) { + if len(moves) == 0 { + return + } + + fixState.Lock() + defer fixState.Unlock() + + known := fixState.moves[file] + if known == nil { + known = map[string]string{} + fixState.moves[file] = known + } + + for id, target := range moves { + known[id] = target + } +} + +// unfinishedMoves returns, sorted, the objects moving out of the file whose target does not hold +// them yet: its header does not list them. Such an object must not leave the file. +func unfinishedMoves(file, modulePath string) []string { + fixState.Lock() + moves := fixState.moves[file] + fixState.Unlock() + + var out []string + + for id, target := range moves { + content, err := os.ReadFile(filepath.Join(modulePath, target)) + if err == nil { + if owned, _ := generate.ParseOwned(string(content)); owned != nil { + if _, there := owned[id]; there { + continue + } + } + } + + out = append(out, id+" (to "+target+")") + } + + sort.Strings(out) + + return out +} + +// recordDropped marks a template the render skipped in some variant: no variant's fix may rewrite +// or delete it, since the objects that variant renders there were never seen. +func recordDropped(file, cause string) { + fixState.Lock() + defer fixState.Unlock() + + fixState.dropped[file] = cause +} + +// droppedCause returns why a variant skipped the template, if one did. +func droppedCause(file string) (string, bool) { + fixState.Lock() + defer fixState.Unlock() + + cause, ok := fixState.dropped[file] + + return cause, ok +} + +func exists(path string) bool { + _, err := os.Stat(path) + return err == nil +} diff --git a/pkg/linters/rbac/rules/rbacyaml/load_test.go b/pkg/linters/rbac/rules/rbacyaml/load_test.go index 750220bb..96dfbb02 100644 --- a/pkg/linters/rbac/rules/rbacyaml/load_test.go +++ b/pkg/linters/rbac/rules/rbacyaml/load_test.go @@ -331,7 +331,31 @@ resource: "secret*" scope: Namespaced noAccess: nobody`), crds: certManagerCRDs, - wantErr: `RBAC matches "*" only as a whole name`, + wantErr: `resource "secret*" is not a resource name`, + }, + "review 20: the wildcard resource of a subresource is valid": { + yaml: entry(`group: external.io +resource: "*/scale" +scope: Namespaced +noAccess: nobody`), + crds: certManagerCRDs, + wantErr: "", + }, + "review 6: a resource name with a space": { + yaml: entry(`group: external.io +resource: "Widgets " +scope: Namespaced +noAccess: nobody`), + crds: certManagerCRDs, + wantErr: `resource "Widgets " is not a resource name`, + }, + "review 6: a group that is not a DNS name": { + yaml: entry(`group: "External_IO" +resource: things +scope: Namespaced +noAccess: nobody`), + crds: certManagerCRDs, + wantErr: `group "External_IO" is not an API group name`, }, "review 13b: when with a template delimiter": { yaml: entry(`group: cert-manager.io diff --git a/pkg/linters/rbac/rules/rbacyaml/scopes.go b/pkg/linters/rbac/rules/rbacyaml/scopes.go index f12eeee6..275e8a69 100644 --- a/pkg/linters/rbac/rules/rbacyaml/scopes.go +++ b/pkg/linters/rbac/rules/rbacyaml/scopes.go @@ -46,6 +46,8 @@ var wellKnownScopes = map[string]string{ "apiextensions.k8s.io/customresourcedefinitions": "Cluster", "apiregistration.k8s.io/apiservices": "Cluster", "certificates.k8s.io/certificatesigningrequests": "Cluster", "flowcontrol.apiserver.k8s.io/flowschemas": "Cluster", "flowcontrol.apiserver.k8s.io/prioritylevelconfigurations": "Cluster", + // the platform's own configuration resource, which every module's system capabilities grant + "deckhouse.io/moduleconfigs": "Cluster", // authentication / authorization (virtual, cluster-scoped) "authentication.k8s.io/tokenreviews": "Cluster", "authorization.k8s.io/subjectaccessreviews": "Cluster", "authorization.k8s.io/selfsubjectaccessreviews": "Cluster", "authorization.k8s.io/selfsubjectrulesreviews": "Cluster", "authorization.k8s.io/localsubjectaccessreviews": "Namespaced", diff --git a/pkg/linters/rbac/rules/rbacyaml/validate.go b/pkg/linters/rbac/rules/rbacyaml/validate.go index 92029548..5ff6017a 100644 --- a/pkg/linters/rbac/rules/rbacyaml/validate.go +++ b/pkg/linters/rbac/rules/rbacyaml/validate.go @@ -19,6 +19,7 @@ package rbacyaml import ( "fmt" "reflect" + "regexp" "slices" "sort" "strings" @@ -139,12 +140,17 @@ func validateResource(r *Resource, where string, crds CRDScopes, usedCapabilitie validateWhen(r.When, where, report) - if r.Group == "*" { + switch { + case r.Group == "*": report("%s: group \"*\" grants the resource in every API group; name the group", where) + case !groupNameRe.MatchString(r.Group): + report("%s: group %q is not an API group name (lowercase DNS subdomain; \"\" for the core group)", where, r.Group) } - if strings.Contains(r.Resource, "*") && r.Resource != "*" { - report("%s: resource %q: RBAC matches \"*\" only as a whole name; list the resources or use \"*\" with reason", where, r.Resource) + // RBAC matches "*" as a whole resource name or as the resource of "*/" + // (k8s.io/component-helpers/auth/rbac/validation); anything else is no name Kubernetes knows. + if !resourceNameRe.MatchString(r.Resource) { + report("%s: resource %q is not a resource name: a lowercase plural, optionally /; \"*\" and \"*/\" are the only wildcards", where, r.Resource) } scope, scopeErr := resolveScope(r, crds) @@ -517,3 +523,8 @@ func Warnings(d *Declaration) []string { return out } + +var ( + groupNameRe = regexp.MustCompile(`^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$`) + resourceNameRe = regexp.MustCompile(`^(\*|[a-z0-9]([-a-z0-9.]*[a-z0-9])?)(/[a-z0-9]([-a-z0-9]*[a-z0-9])?)?$`) +) diff --git a/pkg/linters/rbac/rules/sync.go b/pkg/linters/rbac/rules/sync.go index 408d366a..00538ddf 100644 --- a/pkg/linters/rbac/rules/sync.go +++ b/pkg/linters/rbac/rules/sync.go @@ -20,9 +20,12 @@ import ( "context" stderrors "errors" "fmt" + "io/fs" "log/slog" + "maps" "os" "path/filepath" + "slices" "sort" "strings" @@ -236,6 +239,14 @@ func (r *SyncRule) compareRender(model *generate.Model, actual map[string]manage // must be what the declaration renders now, and a file that does not exist while an object it // holds is absent from the render was never written. func (r *SyncRule) compareText(modulePath string, model *generate.Model, actual map[string]managedObject, divergences map[string][]string) { + placed := map[string]string{} + + for _, f := range model.Files { + for _, o := range f.Objects { + placed[o.Identity()] = f.Path + } + } + // A rule under `when` whose condition is false today is absent from the render without being // a divergence (D4), yet it still has to reach the template -- so for these files the text is // compared too. A file of another contract version is the same case (R40). A file without the @@ -276,7 +287,8 @@ func (r *SyncRule) compareText(modulePath string, model *generate.Model, actual produced[o.Identity()] = struct{}{} } - divergences[file.Path] = append(divergences[file.Path], noLongerProduced(string(content), produced)...) + divergences[file.Path] = append(divergences[file.Path], noLongerProduced(string(content), produced, placed)...) + divergences[file.Path] = append(divergences[file.Path], r.contractOneRemovals(file.Path, string(content), produced, placed)...) } } @@ -287,42 +299,86 @@ func (r *SyncRule) compareText(modulePath string, model *generate.Model, actual inModel[f.Path] = struct{}{} } - seen := map[string]struct{}{} + // The render shows only the files whose objects render under these values; a generated file + // whose every object is under a false condition is found on disk. + candidates := map[string]struct{}{} for _, object := range r.module.GetStorage() { - path := object.ShortPath() + candidates[object.ShortPath()] = struct{}{} + } + + for _, path := range generatedTemplates(modulePath) { + candidates[path] = struct{}{} + } + + for _, path := range slices.Sorted(maps.Keys(candidates)) { if _, ok := inModel[path]; ok { continue } - if _, ok := seen[path]; ok { + content, err := os.ReadFile(filepath.Join(modulePath, path)) + if err != nil { continue } - seen[path] = struct{}{} + if generated, _ := generate.ParseHeader(string(content)); generated { + divergences[path] = append(divergences[path], noLongerProduced(string(content), nil, placed)...) + divergences[path] = append(divergences[path], r.contractOneRemovals(path, string(content), nil, placed)...) + } + } +} - content, err := os.ReadFile(filepath.Join(modulePath, path)) - if err != nil { +// contractOneRemovals does for a contract 1 file, which lists no owned objects, what +// noLongerProduced does with the list: a rendered object the generator named that the declaration +// no longer produces anywhere is a removal. +func (r *SyncRule) contractOneRemovals(path, content string, produced map[string]struct{}, placed map[string]string) []string { + if _, listed := generate.ParseOwned(content); listed { + return nil + } + + var out []string + + for index, object := range r.module.GetStorage() { + id := index.AsString() + + if object.ShortPath() != path || !isRBACKind(object.Unstructured.GetKind()) || !generatorNamed(object.Unstructured.GetName(), r.module.GetName()) { continue } - if generated, _ := generate.ParseHeader(string(content)); generated { - divergences[path] = append(divergences[path], noLongerProduced(string(content), nil)...) + if _, ok := produced[id]; ok { + continue + } + + if _, ok := placed[id]; ok { + continue } + + out = append(out, id+" was generated into this file and the declaration no longer produces it") } + + sort.Strings(out) + + return out } // noLongerProduced lists, as divergences, the objects the header of a generated file names as the // generator's that the declaration no longer produces there. -func noLongerProduced(content string, produced map[string]struct{}) []string { +func noLongerProduced(content string, produced map[string]struct{}, placed map[string]string) []string { owned, _ := generate.ParseOwned(content) out := make([]string, 0, len(owned)) for id := range owned { - if _, ok := produced[id]; !ok { - out = append(out, id+" was generated into this file and the declaration no longer produces it") + if _, ok := produced[id]; ok { + continue } + + if where, moved := placed[id]; moved { + out = append(out, id+" moves to "+where) + continue + } + + out = append(out, id+" was generated into this file and the declaration no longer produces it") } sort.Strings(out) @@ -347,6 +403,12 @@ func (r *SyncRule) report(modulePath string, model *generate.Model, divergences dropped = store.Dropped } + // Under --matrix another variant may regenerate a file this one could not render; record it + // for every variant's fix, whether or not this variant reports the file. + for path, cause := range dropped { + recordDropped(filepath.Join(modulePath, path), cause) + } + for _, path := range paths { list := divergences[path] sort.Strings(list) @@ -357,7 +419,9 @@ func (r *SyncRule) report(modulePath string, model *generate.Model, divergences // the chart: its objects -- the foreign ones included -- were never seen, so neither the // comparison nor a rewrite can be trusted. if cause, skipped := dropped[path]; skipped { + recordDropped(filepath.Join(modulePath, path), cause) fileList.Errorf("%s failed to render in this run (%s); nothing in it is compared or regenerated until it renders", path, cause) + continue } @@ -436,6 +500,10 @@ func removeFileFix(modulePath, path string, removed []string) errors.AutofixFunc return func() error { return fixOnce(fullPath, func() error { + if err := fixBlocked(modulePath, path); err != nil { + return err + } + removed := recordedRemovals(fullPath) if foreign := foreignObjectsOf(fullPath); len(foreign) > 0 { @@ -523,7 +591,12 @@ func (r *SyncRule) foreignObjects(file generate.File, model *generate.Model) []s // generator now produces under another name is a rename. Everything else is someone else's: a // ConfigMap, a Secret, a hand-written role -- and the fix refuses to drop it. func (r *SyncRule) foreignIn(path string, produced map[string]struct{}, producedObjects []generate.Object, model *generate.Model) []string { - owned, listed := ownedBy(filepath.Join(r.module.GetPath(), path)) + fullPath := filepath.Join(r.module.GetPath(), path) + owned, listed := ownedBy(fullPath) + header := hasHeader(fullPath) + moves := map[string]string{} + + defer func() { recordMoves(fullPath, moves) }() // Where the declaration puts every object it produces: an object rendered from another file // than that is misplaced rather than unknown, and the refusal says so. @@ -556,12 +629,15 @@ func (r *SyncRule) foreignIn(path string, produced map[string]struct{}, produced continue } - if _, ok := owned[id]; ok { + // Produced in another file of the model: a move. It may leave this file only once the + // other file holds it -- the fix checks that when it runs (recordMoves), so an object never + // leaves one file for a target that is not written. + if where, declared := placed[id]; declared && where != path { + moves[id] = where continue } - if where, declared := placed[id]; declared { - out = append(out, id+" (the declaration puts it in "+where+"; move it there or delete both files and run the fix)") + if _, ok := owned[id]; ok { continue } @@ -570,6 +646,13 @@ func (r *SyncRule) foreignIn(path string, produced map[string]struct{}, produced continue } + // A contract 1 file lists nothing, but it was generated whole: an object named the way + // the generator names the module's accounts and access (d8::..., access-to-) + // is the generator's, so dropping it from the declaration removes it in the same run. + if header && generatorNamed(object.Unstructured.GetName(), r.module.GetName()) { + continue + } + if replacedByProduced(object, producedObjects, renderedRolesOf(storage, path), rendered) { continue } @@ -598,6 +681,24 @@ func ownedBy(fullPath string) (map[string]struct{}, bool) { return generate.ParseOwned(string(content)) } +// hasHeader reports whether the file carries the generator header. +func hasHeader(fullPath string) bool { + content, err := os.ReadFile(fullPath) + if err != nil { + return false + } + + generated, _ := generate.ParseHeader(string(content)) + + return generated +} + +// generatorNamed reports whether a name is one the generator builds for the module's own +// accounts, extra roles and access grants. +func generatorNamed(name, module string) bool { + return strings.HasPrefix(name, "d8:"+module+":") || name == "access-to-"+module || strings.HasPrefix(name, "access-to-"+module+"-") +} + // isRBACKind reports whether the kind is one the generator produces. func isRBACKind(kind string) bool { switch kind { @@ -1039,6 +1140,10 @@ func regenerateFix(modulePath string, file generate.File, foreign, removals []st return func() error { return fixOnce(fullPath, func() error { + if err := fixBlocked(modulePath, file.Path); err != nil { + return err + } + removals := recordedRemovals(fullPath) existing, err := os.ReadFile(fullPath) @@ -1255,3 +1360,43 @@ func openDecisions(content string) int { return n } + +// fixBlocked says why a fix must leave the file alone although this variant would rewrite it: a +// render variant skipped the template, or an object moving to another file is not there yet. +func fixBlocked(modulePath, path string) error { + fullPath := filepath.Join(modulePath, path) + + if cause, dropped := droppedCause(fullPath); dropped { + return fmt.Errorf("%s failed to render under some values (%s); it is not rewritten until it renders in every variant", path, cause) + } + + if pending := unfinishedMoves(fullPath, modulePath); len(pending) > 0 { + return fmt.Errorf("%s holds objects the declaration moves to another file that does not hold them yet: %s; they leave this file only once the target is written -- run `%s` again after it is", path, strings.Join(pending, ", "), FixCommand) + } + + return nil +} + +// generatedTemplates lists, relative to the module, the files under templates/ that carry the +// generator header. +func generatedTemplates(modulePath string) []string { + var out []string + + root := filepath.Join(modulePath, "templates") + + _ = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil //nolint:nilerr // an unreadable entry is not a generated file + } + + if hasHeader(path) { + if rel, relErr := filepath.Rel(modulePath, path); relErr == nil { + out = append(out, filepath.ToSlash(rel)) + } + } + + return nil + }) + + return out +} diff --git a/pkg/linters/rbac/rules/sync_test.go b/pkg/linters/rbac/rules/sync_test.go index 66fefc55..a1336f59 100644 --- a/pkg/linters/rbac/rules/sync_test.go +++ b/pkg/linters/rbac/rules/sync_test.go @@ -565,7 +565,10 @@ func TestSync_FixSeesEveryRenderVariant(t *testing.T) { // R8a/D7: a declaration in an edition overlay is reported by sync and ignored by coverage. func TestSync_DeclarationInEditionOverlay(t *testing.T) { src := syncModuleDir(t) - modulePath := filepath.Join(t.TempDir(), "ee", "be", "modules", "101-cert-manager") + root := t.TempDir() + // The module has a base in modules/, so the edition directory is an overlay. + require.NoError(t, os.MkdirAll(filepath.Join(root, "modules", "101-cert-manager"), 0o755)) + modulePath := filepath.Join(root, "ee", "be", "modules", "101-cert-manager") require.NoError(t, os.MkdirAll(filepath.Dir(modulePath), 0o755)) require.NoError(t, os.Rename(src, modulePath)) @@ -586,14 +589,19 @@ func TestEditionOverlay(t *testing.T) { assert.Equal(t, "ee/modules", editionOverlay(filepath.Join(root, "ee", "modules", "110-istio")), "merged over modules/110-istio") assert.Empty(t, editionOverlay(filepath.Join(root, "ee", "modules", "030-cloud-provider-openstack")), "an EE-only module: ee/modules is its base") + // Review of #479, finding 19: a module of one edition directory only has its base there. + require.NoError(t, os.MkdirAll(filepath.Join(root, "ee", "be", "modules", "350-node-local-dns"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(root, "ee", "se-plus", "modules", "110-istio"), 0o755)) + assert.Empty(t, editionOverlay(filepath.Join(root, "ee", "be", "modules", "350-node-local-dns")), "only in ee/be") + assert.Equal(t, "ee/se-plus/modules", editionOverlay(filepath.Join(root, "ee", "se-plus", "modules", "110-istio")), "merged over a base") + for path, want := range map[string]string{ - "/r/modules/101-cert-manager": "", - "/r/ee/be/modules/500-x": "ee/be/modules", - "/r/ee/se-plus/modules/500-x/": "ee/se-plus/modules", - "/r/ee/fe/x": "", - "/r/external/x": "", - "/r/ee/x": "", - "modules/x": "", + "/r/modules/101-cert-manager": "", + "/r/ee/be/modules/500-x": "", // no base anywhere: this edition is its home + "/r/ee/fe/x": "", + "/r/external/x": "", + "/r/ee/x": "", + "modules/x": "", } { assert.Equal(t, want, editionOverlay(path), path) } @@ -875,30 +883,56 @@ func TestSync_MisplacedObjectIsNamed(t *testing.T) { model := syncModel(t, modulePath) writeGenerated(t, modulePath, model) - // The edit capability renders from view.yaml. + // The edit capability renders from view.yaml; the declaration puts it in edit.yaml. store := renderedFrom(t, model, func(o *generate.Object) bool { return o.Name != "d8:namespace-capability:cert-manager:edit" }) edit := *model.File("templates/rbacv2/use/edit.yaml") putObject(t, store, "templates/rbacv2/use/view.yaml", edit.Objects[0]) - // Make view.yaml's text stale so its fix is asked for. viewPath := filepath.Join(modulePath, "templates/rbacv2/use/view.yaml") + editPath := filepath.Join(modulePath, "templates/rbacv2/use/edit.yaml") + require.NoError(t, os.WriteFile(viewPath, []byte(generate.Header()+"\n# stale\n"), 0o600)) - errorList := runSync(t, modulePath, store) - for _, fix := range errorList.GetFixes() { - fix() - } + t.Run("the target holds the object: it leaves view.yaml", func(t *testing.T) { + resetFixState() - var messages []string + errorList := runSync(t, modulePath, store) + for _, fix := range errorList.GetFixes() { + fix() + } - for _, e := range errorList.GetErrors() { - if e.FixError != nil { - messages = append(messages, e.FixError.Error()) + assert.Empty(t, errorList.GetErrors()) + + written, err := os.ReadFile(viewPath) + require.NoError(t, err) + assert.NotContains(t, string(written), "d8:namespace-capability:cert-manager:edit") + }) + + // Review of #479, finding 15: an object moving to a file that is not written must not leave. + t.Run("the target is maintained by hand: the object stays", func(t *testing.T) { + resetFixState() + require.NoError(t, os.WriteFile(viewPath, []byte(generate.Header()+"\n# stale\n"), 0o600)) + require.NoError(t, os.WriteFile(editPath, []byte("# by hand\n"), 0o600)) + + errorList := runSync(t, modulePath, store) + for _, fix := range errorList.GetFixes() { + fix() } - } - require.Len(t, messages, 1, "got: %v", messages) - assert.Contains(t, messages[0], "ClusterRole/d8:namespace-capability:cert-manager:edit (the declaration puts it in templates/rbacv2/use/edit.yaml; move it there or delete both files and run the fix)") + var messages []string + + for _, e := range errorList.GetErrors() { + if e.FixError != nil { + messages = append(messages, e.FixError.Error()) + } + } + + assert.Contains(t, strings.Join(messages, "\n"), "templates/rbacv2/use/view.yaml holds objects the declaration moves to another file that does not hold them yet: ClusterRole/d8:namespace-capability:cert-manager:edit (to templates/rbacv2/use/edit.yaml)") + + kept, err := os.ReadFile(viewPath) + require.NoError(t, err) + assert.Equal(t, generate.Header()+"\n# stale\n", string(kept), "view.yaml is left alone") + }) } // Under --matrix the first declaration is written from the union of every variant's render. @@ -1478,3 +1512,114 @@ func TestSync_BrokenModuleYAMLStops(t *testing.T) { assert.Contains(t, got[0], "parse module.yaml") assert.Empty(t, errorList.GetFixes()) } + +// A generated file whose objects are all under a false condition is found on disk and deleted +// when the declaration drops them (review of #479, finding 16). +func TestSync_OrphanWithEveryObjectUnderWhenIsFound(t *testing.T) { + resetFixState() + t.Cleanup(resetFixState) + + modulePath := syncModuleDir(t) + model := syncModel(t, modulePath) + writeGenerated(t, modulePath, model) + + const rel = "templates/cainjector/rbac-for-us.yaml" + + // Default values: the cainjector account (under when) does not render. + store := renderedFrom(t, model, func(o *generate.Object) bool { return o.When == "" }) + + decl, err := rbacyaml.Load(modulePath) + require.NoError(t, err) + + decl.ServiceAccounts = nil + raw, err := yaml.Marshal(decl) + require.NoError(t, err) + require.NoError(t, os.WriteFile(rbacyaml.Path(modulePath), raw, 0o600)) + + errorList := runSync(t, modulePath, store) + assert.Contains(t, strings.Join(texts(errorList), "\n"), rel+" does not match rbac.yaml") + + for _, fix := range errorList.GetFixes() { + fix() + } + + _, err = os.Stat(filepath.Join(modulePath, rel)) + assert.True(t, os.IsNotExist(err), "the orphan is deleted") +} + +// A template one render variant could not render is not rewritten by another variant's fix +// (review of #479, finding 17). +func TestSync_TemplateDroppedInAnotherVariantIsNotRewritten(t *testing.T) { + resetFixState() + t.Cleanup(resetFixState) + + modulePath := syncModuleDir(t) + model := syncModel(t, modulePath) + writeGenerated(t, modulePath, model) + + const rel = "templates/rbac-to-us.yaml" + + fullPath := filepath.Join(modulePath, rel) + require.NoError(t, os.WriteFile(fullPath, []byte(generate.Header()+"\n# stale\n"), 0o600)) + + // The variant that could not render it. + failing := renderedFrom(t, model, nil) + failing.MarkDropped(rel, "required value missing") + runSync(t, modulePath, failing) + + // The variant that renders it asks for a regeneration. + errorList := runSync(t, modulePath, renderedFrom(t, model, nil)) + for _, fix := range errorList.GetFixes() { + fix() + } + + var messages []string + + for _, e := range errorList.GetErrors() { + if e.FixError != nil { + messages = append(messages, e.FixError.Error()) + } + } + + assert.Contains(t, strings.Join(messages, "\n"), rel+" failed to render under some values (required value missing)") + + kept, err := os.ReadFile(fullPath) + require.NoError(t, err) + assert.Equal(t, generate.Header()+"\n# stale\n", string(kept)) +} + +// In a contract 1 file an object named the way the generator names the module's access is the +// generator's: dropping it from the declaration removes it in the same run as the upgrade to +// contract 2 (review of #479, reply to finding 4). +func TestSync_ContractOneGeneratorNamedObjectIsRemoved(t *testing.T) { + resetFixState() + t.Cleanup(resetFixState) + + modulePath := syncModuleDir(t) + model := syncModel(t, modulePath) + writeGenerated(t, modulePath, model) + store := renderedFrom(t, model, nil) + + const rel = "templates/rbac-to-us.yaml" + + asContractOne(t, filepath.Join(modulePath, rel)) + + decl, err := rbacyaml.Load(modulePath) + require.NoError(t, err) + + decl.PrometheusAccess = nil + raw, err := yaml.Marshal(decl) + require.NoError(t, err) + require.NoError(t, os.WriteFile(rbacyaml.Path(modulePath), raw, 0o600)) + + errorList := runSync(t, modulePath, store) + for _, fix := range errorList.GetFixes() { + fix() + } + + assert.Empty(t, errorList.GetErrors(), "the fix applies in the same run as the upgrade to contract 2") + + if written, err := os.ReadFile(filepath.Join(modulePath, rel)); err == nil { + assert.NotContains(t, string(written), "name: access-to-cert-manager\n") + } +} From 942746846b654523c64cadf2a5ac9501320d69d9 Mon Sep 17 00:00:00 2001 From: Ivan Zvyagintsev Date: Wed, 23 Sep 2026 16:51:36 +0300 Subject: [PATCH 35/58] rbac: bootstrap keeps an account's aggregated ClusterRole hand-written An aggregated ClusterRole (aggregationRule, rules owned by the aggregation controller) was imported as an extraClusterRoles entry without rules, which the validation refuses; node-manager's capi-controller-manager has one. The account now keeps its binding by name (bindClusterRoles) and the role is listed as hand-written. Signed-off-by: Ivan Zvyagintsev --- pkg/linters/rbac/rules/bootstrap/bootstrap.go | 5 ++++- .../rbac/rules/bootstrap/bootstrap_test.go | 20 +++++++++++++++++++ pkg/linters/rbac/rules/sync.go | 2 +- 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/pkg/linters/rbac/rules/bootstrap/bootstrap.go b/pkg/linters/rbac/rules/bootstrap/bootstrap.go index eb196c88..89a06b47 100644 --- a/pkg/linters/rbac/rules/bootstrap/bootstrap.go +++ b/pkg/linters/rbac/rules/bootstrap/bootstrap.go @@ -44,6 +44,9 @@ type Object struct { RoleRef rbacv1.RoleRef Subjects []rbacv1.Subject Automount *bool + // Aggregated marks a ClusterRole with an aggregationRule: its rules belong to the aggregation + // controller, and the declaration has no place for the selectors. + Aggregated bool } // Input is what the render says about the module. @@ -362,7 +365,7 @@ func (b *builder) role(ns, name string) (Object, bool) { // ownClusterRole reports whether the ClusterRole is the module's own plain one: labelled with the // module, neither a capability nor a role of the model nor a legacy role. func (b *builder) ownClusterRole(o Object) bool { - return o.Kind == "ClusterRole" && o.Labels[rbaccontract.LabelModule] == b.in.Module && o.Labels[rbaccontract.LabelKind] == "" && o.Annotations[rbaccontract.AccessLevelAnnotation] == "" + return o.Kind == "ClusterRole" && !o.Aggregated && o.Labels[rbaccontract.LabelModule] == b.in.Module && o.Labels[rbaccontract.LabelKind] == "" && o.Annotations[rbaccontract.AccessLevelAnnotation] == "" } func (b *builder) bindingsOf(name string) []Object { diff --git a/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go b/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go index f3f88bd1..3448cbbc 100644 --- a/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go +++ b/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go @@ -372,3 +372,23 @@ func TestBuild_WildcardRolesAndEveryModuleConfig(t *testing.T) { assert.Equal(t, []string{"get", "list", "watch"}, moduleConfigs.System["viewer"], "the grant on every ModuleConfig is kept") } + +// An aggregated ClusterRole of a ServiceAccount is not an extra role without rules: the account +// keeps its binding by name and the role stays hand-written (found while checking finding 21 on +// node-manager). +func TestBuild_AggregatedRoleOfAnAccountStaysHandWritten(t *testing.T) { + labels := map[string]string{"module": "m"} + got := Build(Input{Module: "m", Namespace: "d8-m", Objects: []Object{ + {Kind: "ServiceAccount", Name: "capi", Path: "templates/capi/rbac-for-us.yaml", Labels: labels}, + {Kind: "ClusterRole", Name: "d8:m:capi:aggregated", Path: "templates/capi/rbac-for-us.yaml", Labels: labels, Aggregated: true}, + {Kind: "ClusterRoleBinding", Name: "d8:m:capi:aggregated", Path: "templates/capi/rbac-for-us.yaml", Labels: labels, + RoleRef: rbacv1.RoleRef{Kind: "ClusterRole", Name: "d8:m:capi:aggregated"}, + Subjects: []rbacv1.Subject{{Kind: "ServiceAccount", Name: "capi", Namespace: "d8-m"}}}, + }}) + + require.Len(t, got.Decl.ServiceAccounts, 1) + assert.Empty(t, got.Decl.ServiceAccounts[0].ExtraClusterRoles) + assert.Equal(t, []string{"d8:m:capi:aggregated"}, got.Decl.ServiceAccounts[0].BindClusterRoles) + assert.Contains(t, strings.Join(got.Unmanaged, "\n"), "ClusterRole/d8:m:capi:aggregated") + assert.Empty(t, rbacyaml.Validate(got.Decl, nil), "the written declaration validates") +} diff --git a/pkg/linters/rbac/rules/sync.go b/pkg/linters/rbac/rules/sync.go index 00538ddf..ae54d18e 100644 --- a/pkg/linters/rbac/rules/sync.go +++ b/pkg/linters/rbac/rules/sync.go @@ -1311,7 +1311,7 @@ func bootstrapObject(object storage.StoreObject) (bootstrap.Object, bool) { return o, false } - o.Rules = role.Rules + o.Rules, o.Aggregated = role.Rules, role.AggregationRule != nil case "Role": role := new(rbacv1.Role) if runtime.DefaultUnstructuredConverter.FromUnstructured(content, role) != nil { From c40469ab7a4128dacf5e71db2c8ac0b30257db1e Mon Sep 17 00:00:00 2001 From: Ivan Zvyagintsev Date: Thu, 24 Sep 2026 10:13:49 +0300 Subject: [PATCH 36/58] rbac: third review of #479 -- no moves, text-based ownership, TODO values The move and orphan machinery lost objects three rounds in a row; this takes the reviewer's suggestion and drops it. - The fix does not move objects between files. An object the declaration puts in another file is refused in the file it renders from, and the target is not written while the object still renders elsewhere, so nothing is lost and nothing renders twice (findings 23, 25, 29; reply to 15). - Besides the render, the fix reads the objects of a generated file from its text: an object under a false condition -- a hand-added ConfigMap, an account whose path changed -- blocks the regeneration and the deletion of an orphan found on disk (findings 23, 24). - A contract 1 file no longer treats generator-looking names as owned; only legacy roles and module capabilities are removals there. Dropping an account or access entry takes one --fix to reach contract 2 first (finding 26, reply to 4). - The disk scan skips the _.generated asides (finding 27). - Any noAccess, reason or scope value starting with TODO is an open decision: coverage reports it and a --fix run that meets one exits non-zero (reply to 9). - A rendered grant on every object covers a declared grant on one name, so the pinned moduleconfigs rule is not reported absent while the manage files are still hand-written (reply to 13d). - Bootstrap folds a second binding of an account to the same role into the one the generator names; node-manager's two rbac-proxy bindings no longer collide (finding 28). Signed-off-by: Ivan Zvyagintsev --- pkg/linters/rbac/README.md | 6 +- pkg/linters/rbac/rules/bootstrap/bootstrap.go | 6 + .../rbac/rules/bootstrap/bootstrap_test.go | 20 ++ pkg/linters/rbac/rules/coverage.go | 44 +++- pkg/linters/rbac/rules/coverage_test.go | 42 ++- pkg/linters/rbac/rules/fixstate.go | 54 ++-- pkg/linters/rbac/rules/sync.go | 235 ++++++++++++----- pkg/linters/rbac/rules/sync_test.go | 243 ++++++++++++++---- pkg/linters/rbac/rules/tuples.go | 26 ++ pkg/linters/rbac/rules/tuples_test.go | 36 +++ .../testdata/rbac/coverage-todo/expected.yaml | 2 +- 11 files changed, 545 insertions(+), 169 deletions(-) create mode 100644 pkg/linters/rbac/rules/tuples_test.go diff --git a/pkg/linters/rbac/README.md b/pkg/linters/rbac/README.md index cf89bc80..26547969 100644 --- a/pkg/linters/rbac/README.md +++ b/pkg/linters/rbac/README.md @@ -1468,7 +1468,7 @@ Format rules the loader enforces: - Levels: `namespace` -- `viewer`, `user`, `manager`, `admin`, `superadmin`; `system` -- `viewer`, `manager`, `superadmin`; `legacy` -- `User`, `PrivilegedUser`, `Editor`, `Admin`, `ClusterEditor`, `ClusterAdmin`, `SuperAdmin`. - `namespace` levels are allowed only for `Namespaced` resources; a `Namespaced` resource at a `system` level needs a `reason`. - `scope` is required for a resource the module ships no CRD for, and must agree with the CRD when the module ships one. `resource: "*"` is allowed only for a group without CRDs in the module and needs a `reason`. -- `noAccess` is a non-empty reason and excludes the levels. `noAccess: "TODO"` is the stub the coverage autofix writes; it is not a decision. +- `noAccess` is a non-empty reason and excludes the levels. `noAccess: "TODO"` is the stub the coverage autofix writes; it is not a decision. Any `noAccess`, `reason` or `scope` value that starts with `TODO` is an open decision: `coverage` reports it, and a `--fix` run that meets one exits non-zero. - A capability outside the view/edit convention (`admin`, `user`, `superadmin`) needs `capabilities..` texts in both languages. What the generator produces from it (level `viewer` -> capability `view`, `manager` -> `edit`, the rest as they are): @@ -1653,11 +1653,11 @@ no longer names leaves the template; the finding that led there listed it, and t removed, so a `--fix` run without a preceding `dmt lint` does not remove rights in silence. Three things are never written over -- -- a file that also holds objects of someone else -- a controller ClusterRole beside a declared ServiceAccount, a hand-written binding, a ConfigMap or a Secret -- is never rewritten, because the generator writes the whole file and they would vanish (and so they would if the file were deleted); the refusal names them: declare them (`extraClusterRoles`, `access` with `path`) or move them first. A generated file lists under its header, one `# dmt:owns ` line each, what the generator wrote into it (contract 2); an owned object the declaration no longer produces -- a legacy level dropped, a ServiceAccount removed -- is a removal, named in the finding and in the log, and everything else in the file is someone else's. An object the declaration now puts in another file is a move: it leaves this file only once the other file lists it, so a target that cannot be written (maintained by hand, gated, refused) keeps the object where it is, and a second `--fix` finishes the move. In a file without that list (contract 1, or hand-written), a legacy role, a module capability or an object named the way the generator names the module's accounts and access (`d8::...`, `access-to-...`) that the declaration does not produce is a removal too; a ServiceAccount there needs one `--fix` to reach contract 2 first. An object the generator produces under another name -- a binding with the same roleRef and subjects, a role with the same rules, while the new name is not rendered yet -- is replaced, not foreign; +- a file that also holds objects of someone else -- a controller ClusterRole beside a declared ServiceAccount, a hand-written binding, a ConfigMap or a Secret -- is never rewritten, because the generator writes the whole file and they would vanish (and so they would if the file were deleted); the refusal names them: declare them (`extraClusterRoles`, `access` with `path`) or move them first. A generated file lists under its header, one `# dmt:owns ` line each, what the generator wrote into it (contract 2); an owned object the declaration no longer produces -- a legacy level dropped, a ServiceAccount removed -- is a removal, named in the finding and in the log, and everything else in the file is someone else's. The fix does not move objects between files: an object the declaration now puts in another file is refused in the file it renders from ("move it by hand, or delete this file"), and the target is not written while the object still renders elsewhere, so nothing is lost or rendered twice. Besides the render, the fix reads the objects of a generated file from its text, so an object under a condition that is false for these values -- a hand-added ConfigMap, an account moved elsewhere -- is judged too. In a file without the list (contract 1, or hand-written), a legacy role or a module capability the declaration does not produce is a removal too; any other object is refused, whatever its name. To drop an account or an access entry from a contract 1 file, run `--fix` once with the declaration unchanged -- that writes contract 2 -- and drop it then. An object the generator produces under another name -- a binding with the same roleRef and subjects, a role with the same rules, while the new name is not rendered yet -- is replaced, not foreign; - a file without the generator header is maintained by hand: the generated text is written beside it as `_.generated` (the underscore keeps Helm from rendering the copy) and the finding stays (delete the file and run `--fix` again to hand it back to the generator); - a template that serves both role models behind the version gate (`rbacv2_new_scheme`) is never regenerated: the legacy branch would vanish; -A missing file is created. A generated file the declaration produces nothing for any more -- every namespace level dropped, the `legacy` section gone -- is deleted, as long as it holds nothing but what the generator owns; the deletion is logged. Such files are found on disk too, so a file whose objects are all under a condition that is false for these values is not missed. A template the tolerant render skipped in any render variant is neither compared nor regenerated: its objects were never seen. The removals the log names are the union over every render variant. A second `--fix` without changes to `rbac.yaml` changes nothing. Under +A missing file is created. A generated file the declaration produces nothing for any more -- every namespace level dropped, the `legacy` section gone -- is deleted, as long as it holds nothing but what the generator owns; the deletion is logged. Such files are found on disk too (the `_.generated` asides aside), so a file whose objects are all under a condition that is false for these values is not missed; one found only there is deleted only when its text holds nothing but what its header lists and the declaration places nowhere else. A template the tolerant render skipped in any render variant is neither compared nor regenerated: its objects were never seen. The removals the log names are the union over every render variant. A second `--fix` without changes to `rbac.yaml` changes nothing. Under `--matrix` every render variant reports the file, but the fix runs once: the variants record what their renders grant while they exist, the first closure checks the union and writes, the others report its outcome -- so a right rendered only under some values is never dropped. diff --git a/pkg/linters/rbac/rules/bootstrap/bootstrap.go b/pkg/linters/rbac/rules/bootstrap/bootstrap.go index 89a06b47..be5d5038 100644 --- a/pkg/linters/rbac/rules/bootstrap/bootstrap.go +++ b/pkg/linters/rbac/rules/bootstrap/bootstrap.go @@ -447,6 +447,10 @@ func (b *builder) serviceAccounts() { e.ExtraClusterRoles = append(e.ExtraClusterRoles, extra) b.rename("ClusterRoleBinding", crb.Name, extra.FullName(b.in.Module, sa.Name)) + case slices.Contains(e.BindClusterRoles, crb.RoleRef.Name): + // A second binding of the same account to the same role grants nothing more, and the + // generator names one binding per role: it folds into the first. + b.note("ClusterRoleBinding %s binds %s to %s again; it folds into %s", crb.Name, sa.Name, crb.RoleRef.Name, clusterName+":"+rbaccontract.BindingSuffix(crb.RoleRef.Name)) default: e.BindClusterRoles = append(e.BindClusterRoles, crb.RoleRef.Name) b.rename("ClusterRoleBinding", crb.Name, clusterName+":"+rbaccontract.BindingSuffix(crb.RoleRef.Name)) @@ -474,6 +478,8 @@ func (b *builder) serviceAccounts() { // into one to a Role of that name would bind nothing (Kubernetes accepts a binding to a // Role that does not exist). b.unmanage(rb, "a RoleBinding to the ClusterRole "+rb.RoleRef.Name+", which bindRoles cannot express") + } else if ref := (rbacyaml.RoleRef{Namespace: b.ns(rb), Name: rb.RoleRef.Name}); slices.Contains(e.BindRoles, ref) { + b.note("RoleBinding %s/%s binds %s to the Role %s again; it folds into one", b.ns(rb), rb.Name, sa.Name, rb.RoleRef.Name) } else { e.BindRoles = append(e.BindRoles, rbacyaml.RoleRef{Namespace: b.ns(rb), Name: rb.RoleRef.Name}) b.rename("RoleBinding", b.ns(rb)+"/"+rb.Name, b.ns(rb)+"/"+clusterName+":"+rbaccontract.BindingSuffix(rb.RoleRef.Name)) diff --git a/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go b/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go index 3448cbbc..f3581654 100644 --- a/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go +++ b/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go @@ -392,3 +392,23 @@ func TestBuild_AggregatedRoleOfAnAccountStaysHandWritten(t *testing.T) { assert.Contains(t, strings.Join(got.Unmanaged, "\n"), "ClusterRole/d8:m:capi:aggregated") assert.Empty(t, rbacyaml.Validate(got.Decl, nil), "the written declaration validates") } + +// Two bindings of one account to one role fold into the one binding the generator names, instead of +// producing a declaration that does not generate (review of #479, finding 28: node-manager). +func TestBuild_RepeatedBindingOfAnAccountFolds(t *testing.T) { + labels := map[string]string{"module": "m"} + subject := []rbacv1.Subject{{Kind: "ServiceAccount", Name: "autoscaler", Namespace: "d8-m"}} + + got := Build(Input{Module: "m", Namespace: "d8-m", Objects: []Object{ + {Kind: "ServiceAccount", Name: "autoscaler", Path: "templates/autoscaler/rbac-for-us.yaml", Labels: labels}, + {Kind: "ClusterRoleBinding", Name: "d8:m:autoscaler:rbac-proxy", Path: "templates/autoscaler/rbac-for-us.yaml", Labels: labels, + RoleRef: rbacv1.RoleRef{Kind: "ClusterRole", Name: "d8:rbac-proxy"}, Subjects: subject}, + {Kind: "ClusterRoleBinding", Name: "d8:m:autoscaler-mcm:rbac-proxy", Path: "templates/autoscaler/rbac-for-us.yaml", Labels: labels, + RoleRef: rbacv1.RoleRef{Kind: "ClusterRole", Name: "d8:rbac-proxy"}, Subjects: subject}, + }}) + + require.Len(t, got.Decl.ServiceAccounts, 1) + assert.Equal(t, []string{"d8:rbac-proxy"}, got.Decl.ServiceAccounts[0].BindClusterRoles) + assert.Regexp(t, `ClusterRoleBinding d8:m:autoscaler(-mcm)?:rbac-proxy binds autoscaler to d8:rbac-proxy again; it folds into d8:m:autoscaler:rbac-proxy`, strings.Join(got.Notes, "\n")) + assert.Empty(t, rbacyaml.Validate(got.Decl, nil)) +} diff --git a/pkg/linters/rbac/rules/coverage.go b/pkg/linters/rbac/rules/coverage.go index 4c42954e..1c5de9c5 100644 --- a/pkg/linters/rbac/rules/coverage.go +++ b/pkg/linters/rbac/rules/coverage.go @@ -22,6 +22,7 @@ import ( stderrors "errors" "fmt" "os" + "strings" "gopkg.in/yaml.v3" @@ -98,23 +99,44 @@ func (r *CoverageRule) Check(_ context.Context) { continue } - entry, ok := entries[crd.Key()] - if !ok { + if _, ok := entries[crd.Key()]; !ok { errorList. WithObjectID("CustomResourceDefinition/"+crd.Key()). WithFix(appendStubFix(modulePath, crd)). Errorf("CRD %s (%s) has no entry in %s: decide the user access to it -- namespace, system or legacy levels, or noAccess with the reason; `%s` adds an undecided stub", crd.Key(), crd.File, rbacyaml.Filename, FixCommand) + } + } + // Any value that starts with TODO is a decision nobody has made yet: the stub the coverage + // autofix writes, and the scope, reason and noAccess values bootstrap leaves. Each keeps its + // finding, and a --fix run that meets one fails (the attached fix only says so), whatever the + // rule's level. + for _, res := range decl.Resources { + if !r.Enabled(res.Key()) { continue } - if entry.NoAccess == rbacyaml.NoAccessTODO { - errorList. - WithObjectID("CustomResourceDefinition/"+crd.Key()). - Errorf("%s is still noAccess: %q in %s: a decision is needed -- grant levels, or replace %q with the reason users get no access; only a person can close this", - crd.Key(), rbacyaml.NoAccessTODO, rbacyaml.Filename, rbacyaml.NoAccessTODO) + var open []string + + for _, field := range []struct{ key, value string }{{"noAccess", res.NoAccess}, {"scope", res.Scope}, {"reason", res.Reason}} { + if strings.HasPrefix(field.value, rbacyaml.NoAccessTODO) { + open = append(open, fmt.Sprintf("%s: %q", field.key, field.value)) + } } + + if len(open) == 0 { + continue + } + + id := "rbac.yaml/" + res.Key() + if _, isCRD := known[res.Key()]; isCRD { + id = "CustomResourceDefinition/" + res.Key() + } + + errorList.WithObjectID(id). + WithFix(openDecisionFix(res.Key())). + Errorf("%s is still undecided in %s (%s): a decision is needed -- only a person can close this", res.Key(), rbacyaml.Filename, strings.Join(open, ", ")) } // A resource of a group the module ships CRDs for, but not one of them, is most likely a @@ -147,6 +169,14 @@ func (r *CoverageRule) Check(_ context.Context) { } } +// openDecisionFix is attached to a finding on a TODO value: there is nothing to write, and a --fix +// run that meets it must not end green. +func openDecisionFix(key string) errors.AutofixFunc { + return func() error { + return fmt.Errorf("%s: a TODO in %s is a decision only a person can make", key, rbacyaml.Filename) + } +} + // appendStubFix returns the autofix for a CRD without an entry: append an undecided stub to // rbac.yaml. The closure reads the file when it runs, so several stubs written in one run land // in the same file; it leaves an already present entry alone, so the fix is idempotent. It diff --git a/pkg/linters/rbac/rules/coverage_test.go b/pkg/linters/rbac/rules/coverage_test.go index 25a459ad..b9d9f1c6 100644 --- a/pkg/linters/rbac/rules/coverage_test.go +++ b/pkg/linters/rbac/rules/coverage_test.go @@ -154,12 +154,13 @@ resources: require.Len(t, got, 4, "got: %v", got) assert.Contains(t, got, "error: CRD a.io/gammas (crds/a.yaml) has no entry in rbac.yaml: decide the user access to it -- namespace, system or legacy levels, or noAccess with the reason; `dmt lint --linter rbac --fix` adds an undecided stub") assert.Contains(t, got, "error: CRD d.io/deltas (crds/d.yaml) has no entry in rbac.yaml: decide the user access to it -- namespace, system or legacy levels, or noAccess with the reason; `dmt lint --linter rbac --fix` adds an undecided stub") - assert.Contains(t, got, `error: a.io/betas is still noAccess: "TODO" in rbac.yaml: a decision is needed -- grant levels, or replace "TODO" with the reason users get no access; only a person can close this`) + assert.Contains(t, got, `error: a.io/betas is still undecided in rbac.yaml (noAccess: "TODO"): a decision is needed -- only a person can close this`) assert.Contains(t, got, "warn: a.io/gamas names a resource the module's CRDs of group a.io do not have; check the spelling, or drop the entry if the resource is gone") - // --fix: two stubs are written, and both findings stay, each with the reason (R33). + // --fix: two stubs are written, and both findings stay, each with the reason (R33); the open + // decision fails its fix too, so the run does not end green. fixes := errorList.GetFixes() - require.Len(t, fixes, 2, "only the two missing entries carry an autofix") + require.Len(t, fixes, 3, "the two missing entries write stubs, the open decision only fails") for _, fix := range fixes { fix() @@ -174,11 +175,11 @@ resources: if e.FixError != nil { fixErrors++ - assert.Contains(t, e.FixError.Error(), `was added to rbac.yaml; decide its access (noAccess: "TODO" is not a decision)`) + assert.Regexp(t, `was added to rbac.yaml; decide its access \(noAccess: "TODO" is not a decision\)|a TODO in rbac.yaml is a decision only a person can make`, e.FixError.Error()) } } - assert.Equal(t, 2, fixErrors) + assert.Equal(t, 3, fixErrors) after, err := os.ReadFile(rbacyaml.Path(modulePath)) require.NoError(t, err) @@ -194,12 +195,14 @@ resources: second := texts(runCoverage(t, modulePath)) assert.Len(t, second, 4, "got: %v", second) assert.NotContains(t, strings.Join(second, "\n"), "has no entry") - assert.Equal(t, 3, strings.Count(strings.Join(second, "\n"), `is still noAccess: "TODO"`)) + assert.Equal(t, 3, strings.Count(strings.Join(second, "\n"), `(noAccess: "TODO")`)) - // Idempotency (R17): a run that has nothing to add carries no fixes, and appendStub itself - // leaves a present entry alone byte for byte. + // Idempotency (R17): a run that has nothing to add writes nothing -- its fixes only report the + // open decisions -- and appendStub itself leaves a present entry alone byte for byte. third := runCoverage(t, modulePath) - assert.Empty(t, third.GetFixes()) + for _, fix := range third.GetFixes() { + fix() + } added, err := appendStub(rbacyaml.Path(modulePath), "a.io", "alphas") require.NoError(t, err) @@ -335,3 +338,24 @@ func TestCoverage_BadCRDDocumentIsSkipped(t *testing.T) { assert.Contains(t, joined, "warn: a CRD document is skipped: parse crds/a.yaml") assert.Contains(t, joined, "a.io/alphas", "the CRD that parses is still judged") } + +// A value that starts with TODO is an open decision whatever field holds it: the scope and reason +// bootstrap leaves count as well as the stub, and --fix fails on them (review of #479, reply to 9). +func TestCoverage_TODOPrefixIsAnOpenDecision(t *testing.T) { + modulePath := writeModule(t, map[string]string{ + rbacyaml.Filename: "apiVersion: rbac.deckhouse.io/v1alpha1\nresources:\n" + + " - group: x.io\n resource: things\n scope: \"TODO: Namespaced or Cluster\"\n namespace: {viewer: [get]}\n" + + " - group: y.io\n resource: others\n scope: Namespaced\n noAccess: \"TODO: say why users get none\"\n", + }) + + errorList := runCoverage(t, modulePath) + got := strings.Join(texts(errorList), "\n") + assert.Contains(t, got, `x.io/things is still undecided in rbac.yaml (scope: "TODO: Namespaced or Cluster")`) + assert.Contains(t, got, `y.io/others is still undecided in rbac.yaml (noAccess: "TODO: say why users get none")`) + + for _, fix := range errorList.GetFixes() { + fix() + } + + assert.True(t, errorList.ContainsFailedFixes(), "a --fix run with open decisions fails") +} diff --git a/pkg/linters/rbac/rules/fixstate.go b/pkg/linters/rbac/rules/fixstate.go index 3a131f39..fb7d0a21 100644 --- a/pkg/linters/rbac/rules/fixstate.go +++ b/pkg/linters/rbac/rules/fixstate.go @@ -17,15 +17,16 @@ limitations under the License. package rules import ( + "maps" "os" "path/filepath" "regexp" + "slices" "sort" "strings" "sync" "github.com/deckhouse/dmt/pkg/linters/rbac/rules/bootstrap" - "github.com/deckhouse/dmt/pkg/linters/rbac/rules/generate" "github.com/deckhouse/dmt/pkg/linters/rbac/rules/rbaccontract" ) @@ -43,13 +44,13 @@ var fixState = struct { sync.Mutex foreign map[string]map[string]struct{} removals map[string]map[string]struct{} - moves map[string]map[string]string + blocked map[string]map[string]struct{} dropped map[string]string bootstrap map[string]map[string]bootstrap.Object }{ foreign: map[string]map[string]struct{}{}, removals: map[string]map[string]struct{}{}, - moves: map[string]map[string]string{}, + blocked: map[string]map[string]struct{}{}, dropped: map[string]string{}, bootstrap: map[string]map[string]bootstrap.Object{}, } @@ -119,7 +120,7 @@ func resetFixState() { fixState.foreign = map[string]map[string]struct{}{} fixState.removals = map[string]map[string]struct{}{} - fixState.moves = map[string]map[string]string{} + fixState.blocked = map[string]map[string]struct{}{} fixState.dropped = map[string]string{} fixState.bootstrap = map[string]map[string]bootstrap.Object{} @@ -300,52 +301,33 @@ func recordedRemovals(file string) []string { return out } -// recordMoves adds the objects one render variant saw in the file that the declaration now puts in -// another file, with that file's full path. -func recordMoves(file string, moves map[string]string) { - if len(moves) == 0 { +// recordBlocked adds, for a file one render variant would regenerate, the objects it would write +// that the render shows in another file. +func recordBlocked(file string, objects []string) { + if len(objects) == 0 { return } fixState.Lock() defer fixState.Unlock() - known := fixState.moves[file] + known := fixState.blocked[file] if known == nil { - known = map[string]string{} - fixState.moves[file] = known + known = map[string]struct{}{} + fixState.blocked[file] = known } - for id, target := range moves { - known[id] = target + for _, o := range objects { + known[o] = struct{}{} } } -// unfinishedMoves returns, sorted, the objects moving out of the file whose target does not hold -// them yet: its header does not list them. Such an object must not leave the file. -func unfinishedMoves(file, modulePath string) []string { +// blockedBy returns, sorted, what keeps the file from being regenerated. +func blockedBy(file string) []string { fixState.Lock() - moves := fixState.moves[file] - fixState.Unlock() - - var out []string - - for id, target := range moves { - content, err := os.ReadFile(filepath.Join(modulePath, target)) - if err == nil { - if owned, _ := generate.ParseOwned(string(content)); owned != nil { - if _, there := owned[id]; there { - continue - } - } - } - - out = append(out, id+" (to "+target+")") - } - - sort.Strings(out) + defer fixState.Unlock() - return out + return slices.Sorted(maps.Keys(fixState.blocked[file])) } // recordDropped marks a template the render skipped in some variant: no variant's fix may rewrite diff --git a/pkg/linters/rbac/rules/sync.go b/pkg/linters/rbac/rules/sync.go index ae54d18e..41aa58eb 100644 --- a/pkg/linters/rbac/rules/sync.go +++ b/pkg/linters/rbac/rules/sync.go @@ -25,6 +25,7 @@ import ( "maps" "os" "path/filepath" + "regexp" "slices" "sort" "strings" @@ -288,7 +289,6 @@ func (r *SyncRule) compareText(modulePath string, model *generate.Model, actual } divergences[file.Path] = append(divergences[file.Path], noLongerProduced(string(content), produced, placed)...) - divergences[file.Path] = append(divergences[file.Path], r.contractOneRemovals(file.Path, string(content), produced, placed)...) } } @@ -323,44 +323,10 @@ func (r *SyncRule) compareText(modulePath string, model *generate.Model, actual if generated, _ := generate.ParseHeader(string(content)); generated { divergences[path] = append(divergences[path], noLongerProduced(string(content), nil, placed)...) - divergences[path] = append(divergences[path], r.contractOneRemovals(path, string(content), nil, placed)...) } } } -// contractOneRemovals does for a contract 1 file, which lists no owned objects, what -// noLongerProduced does with the list: a rendered object the generator named that the declaration -// no longer produces anywhere is a removal. -func (r *SyncRule) contractOneRemovals(path, content string, produced map[string]struct{}, placed map[string]string) []string { - if _, listed := generate.ParseOwned(content); listed { - return nil - } - - var out []string - - for index, object := range r.module.GetStorage() { - id := index.AsString() - - if object.ShortPath() != path || !isRBACKind(object.Unstructured.GetKind()) || !generatorNamed(object.Unstructured.GetName(), r.module.GetName()) { - continue - } - - if _, ok := produced[id]; ok { - continue - } - - if _, ok := placed[id]; ok { - continue - } - - out = append(out, id+" was generated into this file and the declaration no longer produces it") - } - - sort.Strings(out) - - return out -} - // noLongerProduced lists, as divergences, the objects the header of a generated file names as the // generator's that the declaration no longer produces there. func noLongerProduced(content string, produced map[string]struct{}, placed map[string]string) []string { @@ -374,7 +340,7 @@ func noLongerProduced(content string, produced map[string]struct{}, placed map[s } if where, moved := placed[id]; moved { - out = append(out, id+" moves to "+where) + out = append(out, id+" is now declared in "+where+"; the fix does not move objects between files -- move it by hand") continue } @@ -389,6 +355,14 @@ func noLongerProduced(content string, produced map[string]struct{}, placed map[s // report emits one finding per template, with the fix that closes it when one exists: the // regeneration of a produced file, the deletion of an orphaned generated file, or none. func (r *SyncRule) report(modulePath string, model *generate.Model, divergences map[string][]string) { + placed := map[string]string{} + + for _, f := range model.Files { + for _, o := range f.Objects { + placed[o.Identity()] = f.Path + } + } + paths := make([]string, 0, len(divergences)) for path, list := range divergences { if len(list) > 0 { @@ -426,7 +400,10 @@ func (r *SyncRule) report(modulePath string, model *generate.Model, divergences } if file := model.File(path); file != nil { - fileList = fileList.WithFix(regenerateFix(modulePath, *file, r.foreignObjects(*file, model), removalsOf(list))) + // An object this file produces that still renders from another file stays there until a + // person moves it; writing it here as well would render it twice. + recordBlocked(filepath.Join(modulePath, path), r.renderedElsewhere(*file)) + fileList = fileList.WithFix(regenerateFix(modulePath, *file, placed, r.foreignObjects(*file, model), removalsOf(list))) fileList.Errorf("%s does not match %s: %s. Run `%s` to regenerate the file from the declaration", path, rbacyaml.Filename, strings.Join(list, "; "), FixCommand) @@ -441,7 +418,7 @@ func (r *SyncRule) report(modulePath string, model *generate.Model, divergences // the fix judges the union, as the regeneration does. recordForeignObjects(filepath.Join(modulePath, path), nil) - fileList.WithFix(removeFileFix(modulePath, path, list)).Errorf("%s does not match %s: %s. The file carries the generator header and the declaration produces nothing for it; `%s` deletes it", + fileList.WithFix(removeFileFix(modulePath, path, placed, list)).Errorf("%s does not match %s: %s. The file carries the generator header and the declaration produces nothing for it; `%s` deletes it", path, rbacyaml.Filename, strings.Join(list, "; "), FixCommand) continue @@ -494,7 +471,7 @@ func (r *SyncRule) orphanGeneratedFile(path string, model *generate.Model) (bool // file when it runs and refuses when any render variant placed an object in it that the // declaration does not describe, or when the header or the gate say the file is not the // generator's to delete. -func removeFileFix(modulePath, path string, removed []string) errors.AutofixFunc { +func removeFileFix(modulePath, path string, placed map[string]string, removed []string) errors.AutofixFunc { fullPath := filepath.Join(modulePath, path) recordRemovals(fullPath, removed) @@ -524,6 +501,13 @@ func removeFileFix(modulePath, path string, removed []string) errors.AutofixFunc return fmt.Errorf("%s is not the generator's to delete any more (no header, or the version gate); remove it by hand if that is the intent", path) } + // Found on disk, the file may hold objects no variant rendered; only a file whose every + // object the generator lists as its own and the declaration no longer places anywhere + // is deleted. + if unknown := notTheGenerators(string(content), nil, placed, path); len(unknown) > 0 { + return fmt.Errorf("%s holds objects the fix cannot account for: %s; it is not deleted -- move or remove them by hand", path, strings.Join(unknown, ", ")) + } + if err := os.Remove(fullPath); err != nil { return fmt.Errorf("delete %s: %w", path, err) } @@ -593,10 +577,6 @@ func (r *SyncRule) foreignObjects(file generate.File, model *generate.Model) []s func (r *SyncRule) foreignIn(path string, produced map[string]struct{}, producedObjects []generate.Object, model *generate.Model) []string { fullPath := filepath.Join(r.module.GetPath(), path) owned, listed := ownedBy(fullPath) - header := hasHeader(fullPath) - moves := map[string]string{} - - defer func() { recordMoves(fullPath, moves) }() // Where the declaration puts every object it produces: an object rendered from another file // than that is misplaced rather than unknown, and the refusal says so. @@ -629,11 +609,11 @@ func (r *SyncRule) foreignIn(path string, produced map[string]struct{}, produced continue } - // Produced in another file of the model: a move. It may leave this file only once the - // other file holds it -- the fix checks that when it runs (recordMoves), so an object never - // leaves one file for a target that is not written. + // Produced in another file of the model. The fix does not move objects between files -- + // the target may be maintained by hand, gated or refused, and the object would be lost or + // rendered twice -- so this file is left alone until a person moves it. if where, declared := placed[id]; declared && where != path { - moves[id] = where + out = append(out, id+" (the declaration now puts it in "+where+"; the fix does not move objects between files -- move it there by hand, or delete this file, then run the fix)") continue } @@ -646,13 +626,6 @@ func (r *SyncRule) foreignIn(path string, produced map[string]struct{}, produced continue } - // A contract 1 file lists nothing, but it was generated whole: an object named the way - // the generator names the module's accounts and access (d8::..., access-to-) - // is the generator's, so dropping it from the declaration removes it in the same run. - if header && generatorNamed(object.Unstructured.GetName(), r.module.GetName()) { - continue - } - if replacedByProduced(object, producedObjects, renderedRolesOf(storage, path), rendered) { continue } @@ -693,12 +666,6 @@ func hasHeader(fullPath string) bool { return generated } -// generatorNamed reports whether a name is one the generator builds for the module's own -// accounts, extra roles and access grants. -func generatorNamed(name, module string) bool { - return strings.HasPrefix(name, "d8:"+module+":") || name == "access-to-"+module || strings.HasPrefix(name, "access-to-"+module+"-") -} - // isRBACKind reports whether the kind is one the generator produces. func isRBACKind(kind string) bool { switch kind { @@ -949,7 +916,7 @@ func compareObject(expected generate.Object, actual storage.StoreObject, module actualTuples := expandRenderedRules(rules) always, conditional := expandModelRules(expected.Rules) - for _, t := range always.minus(actualTuples) { + for _, t := range always.uncoveredBy(actualTuples) { out = append(out, fmt.Sprintf("%s: %s is declared but absent from the render", id, t)) } @@ -1127,7 +1094,7 @@ func removalsOf(divergences []string) []string { return out } -func regenerateFix(modulePath string, file generate.File, foreign, removals []string) errors.AutofixFunc { +func regenerateFix(modulePath string, file generate.File, placed map[string]string, foreign, removals []string) errors.AutofixFunc { content := generate.RenderFile(file) fullPath := filepath.Join(modulePath, file.Path) @@ -1163,6 +1130,13 @@ func regenerateFix(modulePath string, file generate.File, foreign, removals []st file.Path, strings.Join(foreign, ", "), rbacyaml.Filename, FixCommand) } + // The render shows only what renders under these values; the text shows everything the + // file holds, objects under a false condition included. + if unknown := notTheGenerators(string(existing), identitiesOf(file.Objects), placed, file.Path); len(unknown) > 0 { + return fmt.Errorf("%s holds objects the fix cannot account for: %s; they are not what the generator wrote there, so regenerating the file would drop them -- declare them in %s, move them, or remove them by hand, then run `%s` again", + file.Path, strings.Join(unknown, ", "), rbacyaml.Filename, FixCommand) + } + if templateGated(string(existing), content) { return fmt.Errorf("%s renders one of two role models depending on the platform version (the %s gate of rbacv2-migrate-module.sh, or a deckhouseVersion test the declaration did not produce); regenerating it would drop the legacy branch -- edit the new branch by hand, or drop the gate and the legacy object once clusters below DKP 1.78 are no longer served, then run `%s`", file.Path, rbaccontract.GateMarker, FixCommand) @@ -1362,7 +1336,7 @@ func openDecisions(content string) int { } // fixBlocked says why a fix must leave the file alone although this variant would rewrite it: a -// render variant skipped the template, or an object moving to another file is not there yet. +// render variant skipped the template, or an object it would write still renders from another file. func fixBlocked(modulePath, path string) error { fullPath := filepath.Join(modulePath, path) @@ -1370,8 +1344,8 @@ func fixBlocked(modulePath, path string) error { return fmt.Errorf("%s failed to render under some values (%s); it is not rewritten until it renders in every variant", path, cause) } - if pending := unfinishedMoves(fullPath, modulePath); len(pending) > 0 { - return fmt.Errorf("%s holds objects the declaration moves to another file that does not hold them yet: %s; they leave this file only once the target is written -- run `%s` again after it is", path, strings.Join(pending, ", "), FixCommand) + if elsewhere := blockedBy(fullPath); len(elsewhere) > 0 { + return fmt.Errorf("%s would produce objects that still render from another file: %s; writing them here would render them twice -- move them by hand, then run `%s` again", path, strings.Join(elsewhere, ", "), FixCommand) } return nil @@ -1389,6 +1363,12 @@ func generatedTemplates(modulePath string) []string { return nil //nolint:nilerr // an unreadable entry is not a generated file } + // A `_.generated` aside is the generator's proposal beside a hand-maintained file, not + // a template of the chart. + if base := filepath.Base(path); strings.HasPrefix(base, "_") || strings.HasSuffix(base, ".generated") { + return nil + } + if hasHeader(path) { if rel, relErr := filepath.Rel(modulePath, path); relErr == nil { out = append(out, filepath.ToSlash(rel)) @@ -1400,3 +1380,128 @@ func generatedTemplates(modulePath string) []string { return out } + +// renderedElsewhere lists the objects the file produces that the render shows in another file. +func (r *SyncRule) renderedElsewhere(file generate.File) []string { + produced := identitiesOf(file.Objects) + + var out []string + + for index, object := range r.module.GetStorage() { + id := index.AsString() + if _, ok := produced[id]; ok && object.ShortPath() != file.Path { + out = append(out, id+" (renders from "+object.ShortPath()+")") + } + } + + sort.Strings(out) + + return out +} + +// identitiesOf returns the identities of the objects. +func identitiesOf(objects []generate.Object) map[string]struct{} { + out := make(map[string]struct{}, len(objects)) + for _, o := range objects { + out[o.Identity()] = struct{}{} + } + + return out +} + +// notTheGenerators reads the objects a generated file holds from its text -- rendered or not -- and +// returns those the fix cannot account for: not produced into this file now, and either placed in +// another file of the model (the fix does not move objects) or not the generator's at all. A +// contract 2 file lists the generator's objects in its header; in a contract 1 file only a legacy +// role or a module capability, recognized by its markers, counts as the generator's. +func notTheGenerators(content string, produced map[string]struct{}, placed map[string]string, path string) []string { + owned, listed := generate.ParseOwned(content) + + var out []string + + for _, doc := range textDocuments(content) { + if _, ok := produced[doc.id]; ok { + continue + } + + if where, ok := placed[doc.id]; ok && where != path { + out = append(out, doc.id+" (now declared in "+where+")") + continue + } + + _, isOwned := owned[doc.id] + + switch { + case listed && isOwned: + case !listed && doc.managed: + default: + out = append(out, doc.id) + } + } + + sort.Strings(out) + + return out +} + +// textDocument is what the fix needs to know about one object of a generated file's text. +type textDocument struct { + id string + managed bool // a legacy role or a module capability +} + +var ( + kindLineRe = regexp.MustCompile(`^kind:\s*(\S+)\s*$`) + nameLineRe = regexp.MustCompile(`^ name:\s*"?([^"\s]+)"?\s*$`) + namespaceLineRe = regexp.MustCompile(`^ namespace:\s*"?([^"\s]+)"?\s*$`) +) + +// textDocuments parses the objects of a generated file from its text: the generator writes kind, +// metadata.name and metadata.namespace on lines of their own. A document it cannot read yields an +// identity that matches nothing, so the fix refuses rather than guesses. +func textDocuments(content string) []textDocument { + var out []textDocument + + for _, doc := range strings.Split(content, "\n---\n")[1:] { + var kind, name, namespace string + + inMetadata := false + + for _, line := range strings.Split(doc, "\n") { + switch { + case line == "metadata:": + inMetadata = true + case strings.HasPrefix(line, " ") && inMetadata: + if m := nameLineRe.FindStringSubmatch(line); m != nil && name == "" { + name = m[1] + } + + if m := namespaceLineRe.FindStringSubmatch(line); m != nil && namespace == "" { + namespace = m[1] + } + default: + inMetadata = false + + if m := kindLineRe.FindStringSubmatch(line); m != nil && kind == "" { + kind = m[1] + } + } + } + + if kind == "" { + continue // the end of a conditional block, not an object + } + + id := kind + "/" + name + if namespace != "" { + id = namespace + "/" + id + } + + managed := strings.Contains(doc, rbaccontract.AccessLevelAnnotation+":") || + strings.Contains(doc, rbaccontract.LabelKind+": "+rbaccontract.KindCapability) + + out = append(out, textDocument{id: id, managed: managed}) + } + + return out +} diff --git a/pkg/linters/rbac/rules/sync_test.go b/pkg/linters/rbac/rules/sync_test.go index a1336f59..0d707078 100644 --- a/pkg/linters/rbac/rules/sync_test.go +++ b/pkg/linters/rbac/rules/sync_test.go @@ -873,8 +873,8 @@ func TestSync_OldShapeFileIsNamed(t *testing.T) { assert.Contains(t, got[0], "rbac.yaml is not a declaration (no apiVersion): an rbac.yaml of an earlier shape that nothing reads; delete it and run `dmt lint --linter rbac --fix`") } -// A declared object rendered from another file than the declaration places it is refused with the -// place it belongs to. +// An object the declaration puts in another file is left where it renders: the fix does not move +// objects between files (review of #479, findings 15, 23, 25 and 29). func TestSync_MisplacedObjectIsNamed(t *testing.T) { resetFixState() t.Cleanup(resetFixState) @@ -892,47 +892,31 @@ func TestSync_MisplacedObjectIsNamed(t *testing.T) { editPath := filepath.Join(modulePath, "templates/rbacv2/use/edit.yaml") require.NoError(t, os.WriteFile(viewPath, []byte(generate.Header()+"\n# stale\n"), 0o600)) + require.NoError(t, os.WriteFile(editPath, []byte(generate.Header()+"\n# stale\n"), 0o600)) - t.Run("the target holds the object: it leaves view.yaml", func(t *testing.T) { - resetFixState() - - errorList := runSync(t, modulePath, store) - for _, fix := range errorList.GetFixes() { - fix() - } - - assert.Empty(t, errorList.GetErrors()) - - written, err := os.ReadFile(viewPath) - require.NoError(t, err) - assert.NotContains(t, string(written), "d8:namespace-capability:cert-manager:edit") - }) - - // Review of #479, finding 15: an object moving to a file that is not written must not leave. - t.Run("the target is maintained by hand: the object stays", func(t *testing.T) { - resetFixState() - require.NoError(t, os.WriteFile(viewPath, []byte(generate.Header()+"\n# stale\n"), 0o600)) - require.NoError(t, os.WriteFile(editPath, []byte("# by hand\n"), 0o600)) - - errorList := runSync(t, modulePath, store) - for _, fix := range errorList.GetFixes() { - fix() - } + errorList := runSync(t, modulePath, store) + for _, fix := range errorList.GetFixes() { + fix() + } - var messages []string + var messages []string - for _, e := range errorList.GetErrors() { - if e.FixError != nil { - messages = append(messages, e.FixError.Error()) - } + for _, e := range errorList.GetErrors() { + if e.FixError != nil { + messages = append(messages, e.FixError.Error()) } + } - assert.Contains(t, strings.Join(messages, "\n"), "templates/rbacv2/use/view.yaml holds objects the declaration moves to another file that does not hold them yet: ClusterRole/d8:namespace-capability:cert-manager:edit (to templates/rbacv2/use/edit.yaml)") + joined := strings.Join(messages, "\n") + assert.Contains(t, joined, "ClusterRole/d8:namespace-capability:cert-manager:edit (the declaration now puts it in templates/rbacv2/use/edit.yaml; the fix does not move objects between files") + assert.Contains(t, joined, "templates/rbacv2/use/edit.yaml would produce objects that still render from another file: ClusterRole/d8:namespace-capability:cert-manager:edit (renders from templates/rbacv2/use/view.yaml)", + "the target is not written either, so the object never renders twice") - kept, err := os.ReadFile(viewPath) + for _, path := range []string{viewPath, editPath} { + kept, err := os.ReadFile(path) require.NoError(t, err) - assert.Equal(t, generate.Header()+"\n# stale\n", string(kept), "view.yaml is left alone") - }) + assert.Equal(t, generate.Header()+"\n# stale\n", string(kept), path+" is left alone") + } } // Under --matrix the first declaration is written from the union of every variant's render. @@ -1588,10 +1572,10 @@ func TestSync_TemplateDroppedInAnotherVariantIsNotRewritten(t *testing.T) { assert.Equal(t, generate.Header()+"\n# stale\n", string(kept)) } -// In a contract 1 file an object named the way the generator names the module's access is the -// generator's: dropping it from the declaration removes it in the same run as the upgrade to -// contract 2 (review of #479, reply to finding 4). -func TestSync_ContractOneGeneratorNamedObjectIsRemoved(t *testing.T) { +// A contract 1 file lists no owned objects, and a name is no proof of ownership (review of #479, +// finding 26): an object the declaration dropped is refused there. One --fix with the declaration +// unchanged brings the file to contract 2; then the drop applies. +func TestSync_ContractOneNeedsAnUpgradeBeforeADrop(t *testing.T) { resetFixState() t.Cleanup(resetFixState) @@ -1602,24 +1586,187 @@ func TestSync_ContractOneGeneratorNamedObjectIsRemoved(t *testing.T) { const rel = "templates/rbac-to-us.yaml" - asContractOne(t, filepath.Join(modulePath, rel)) + path := filepath.Join(modulePath, rel) + asContractOne(t, path) - decl, err := rbacyaml.Load(modulePath) + dropPrometheus := func() { + decl, err := rbacyaml.Load(modulePath) + require.NoError(t, err) + + decl.PrometheusAccess = nil + raw, err := yaml.Marshal(decl) + require.NoError(t, err) + require.NoError(t, os.WriteFile(rbacyaml.Path(modulePath), raw, 0o600)) + } + + original, err := os.ReadFile(rbacyaml.Path(modulePath)) require.NoError(t, err) - decl.PrometheusAccess = nil - raw, err := yaml.Marshal(decl) + t.Run("dropped in the same run: refused", func(t *testing.T) { + resetFixState() + dropPrometheus() + + errorList := runSync(t, modulePath, store) + for _, fix := range errorList.GetFixes() { + fix() + } + + var messages []string + + for _, e := range errorList.GetErrors() { + if e.FixError != nil { + messages = append(messages, e.FixError.Error()) + } + } + + assert.Contains(t, strings.Join(messages, "\n"), "d8-cert-manager/Role/access-to-cert-manager") + }) + + t.Run("upgraded first, dropped next: removed", func(t *testing.T) { + resetFixState() + require.NoError(t, os.WriteFile(rbacyaml.Path(modulePath), original, 0o600)) + asContractOne(t, path) + + upgrade := runSync(t, modulePath, store) + for _, fix := range upgrade.GetFixes() { + fix() + } + + require.Empty(t, upgrade.GetErrors(), "the upgrade to contract 2 applies") + + resetFixState() + dropPrometheus() + + errorList := runSync(t, modulePath, store) + for _, fix := range errorList.GetFixes() { + fix() + } + + assert.Empty(t, errorList.GetErrors()) + + if written, err := os.ReadFile(path); err == nil { + assert.NotContains(t, string(written), "name: access-to-cert-manager\n") + } + }) +} + +// A generated file found only on disk -- nothing in it rendered -- is not deleted when an object it +// holds is now declared elsewhere, nor when it holds an object the generator did not write +// (review of #479, findings 23 and 24). +func TestSync_UnrenderedOrphanIsJudgedByItsText(t *testing.T) { + resetFixState() + t.Cleanup(resetFixState) + + modulePath := syncModuleDir(t) + model := syncModel(t, modulePath) + writeGenerated(t, modulePath, model) + + const rel = "templates/cainjector/rbac-for-us.yaml" + + fullPath := filepath.Join(modulePath, rel) + + // Default values: the cainjector account (under when) does not render. + store := renderedFrom(t, model, func(o *generate.Object) bool { return o.When == "" }) + + decl, err := rbacyaml.Load(modulePath) require.NoError(t, err) - require.NoError(t, os.WriteFile(rbacyaml.Path(modulePath), raw, 0o600)) + + t.Run("the account moves to the root rbac-for-us.yaml: refused", func(t *testing.T) { + resetFixState() + + moved := *decl + moved.ServiceAccounts = append([]rbacyaml.ServiceAccount(nil), decl.ServiceAccounts...) + moved.ServiceAccounts[0].Path = "" + raw, err := yaml.Marshal(&moved) + require.NoError(t, err) + require.NoError(t, os.WriteFile(rbacyaml.Path(modulePath), raw, 0o600)) + + errorList := runSync(t, modulePath, store) + for _, fix := range errorList.GetFixes() { + fix() + } + + _, err = os.Stat(fullPath) + require.NoError(t, err, "the file with the account stays") + }) + + t.Run("a hand-added ConfigMap under the same condition: refused", func(t *testing.T) { + resetFixState() + + dropped := *decl + dropped.ServiceAccounts = nil + raw, err := yaml.Marshal(&dropped) + require.NoError(t, err) + require.NoError(t, os.WriteFile(rbacyaml.Path(modulePath), raw, 0o600)) + + content, err := os.ReadFile(fullPath) + require.NoError(t, err) + + withHand := strings.Replace(string(content), "{{- end }}\n", "---\napiVersion: v1\nkind: ConfigMap\nmetadata:\n name: cainjector-extra\n namespace: d8-cert-manager\n{{- end }}\n", 1) + require.NoError(t, os.WriteFile(fullPath, []byte(withHand), 0o600)) + + errorList := runSync(t, modulePath, store) + for _, fix := range errorList.GetFixes() { + fix() + } + + var messages []string + + for _, e := range errorList.GetErrors() { + if e.FixError != nil { + messages = append(messages, e.FixError.Error()) + } + } + + assert.Contains(t, strings.Join(messages, "\n"), "d8-cert-manager/ConfigMap/cainjector-extra") + + kept, err := os.ReadFile(fullPath) + require.NoError(t, err) + assert.Equal(t, withHand, string(kept)) + }) +} + +// The disk scan skips the generator's _.generated asides (review of #479, finding 27). +func TestGeneratedTemplates_SkipsAsides(t *testing.T) { + modulePath := t.TempDir() + dir := filepath.Join(modulePath, "templates", "rbacv2", "use") + require.NoError(t, os.MkdirAll(dir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "view.yaml"), []byte(generate.Header()+"\n"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "_edit.yaml.generated"), []byte(generate.Header()+"\n"), 0o600)) + + assert.Equal(t, []string{"templates/rbacv2/use/view.yaml"}, generatedTemplates(modulePath)) +} + +// A hand-added object named the way the generator names things, in a contract 1 file, is refused +// rather than removed (review of #479, finding 26). +func TestSync_ContractOneHandAddedGeneratorNamedIsForeign(t *testing.T) { + resetFixState() + t.Cleanup(resetFixState) + + modulePath := syncModuleDir(t) + model := syncModel(t, modulePath) + writeGenerated(t, modulePath, model) + + const rel = "templates/cainjector/rbac-for-us.yaml" + + asContractOne(t, filepath.Join(modulePath, rel)) + + store := renderedFrom(t, model, nil) + putObject(t, store, rel, generate.Object{Kind: "ClusterRole", Name: "d8:cert-manager:hand-extra", Class: generate.ClassDeclared, + Rules: []generate.Rule{{PolicyRule: rbacyaml.PolicyRule{APIGroups: []string{""}, Resources: []string{"pods"}, Verbs: []string{"get"}}}}}) errorList := runSync(t, modulePath, store) for _, fix := range errorList.GetFixes() { fix() } - assert.Empty(t, errorList.GetErrors(), "the fix applies in the same run as the upgrade to contract 2") + var messages []string - if written, err := os.ReadFile(filepath.Join(modulePath, rel)); err == nil { - assert.NotContains(t, string(written), "name: access-to-cert-manager\n") + for _, e := range errorList.GetErrors() { + if e.FixError != nil { + messages = append(messages, e.FixError.Error()) + } } + + assert.Contains(t, strings.Join(messages, "\n"), "ClusterRole/d8:cert-manager:hand-extra") } diff --git a/pkg/linters/rbac/rules/tuples.go b/pkg/linters/rbac/rules/tuples.go index 74eda155..c6921029 100644 --- a/pkg/linters/rbac/rules/tuples.go +++ b/pkg/linters/rbac/rules/tuples.go @@ -17,6 +17,7 @@ limitations under the License. package rules import ( + "slices" "sort" "strings" @@ -66,6 +67,31 @@ type tupleSet map[tuple]struct{} func (s tupleSet) add(t tuple) { s[t] = struct{}{} } +// uncoveredBy returns the tuples of s that other does not grant, sorted. A tuple limited to one +// object name is granted by the same group, resource and verb without a name as well: a rule on +// every ModuleConfig covers the one on the module's own. +func (s tupleSet) uncoveredBy(other tupleSet) []tuple { + var out []tuple + + for t := range s { + if _, ok := other[t]; ok { + continue + } + + if parts := strings.Split(string(t), "|"); len(parts) == 4 && parts[2] != "" { + if _, ok := other[resourceTuple(parts[0], parts[1], "", parts[3])]; ok { + continue + } + } + + out = append(out, t) + } + + slices.Sort(out) + + return out +} + // minus returns the tuples of s absent from other, sorted. func (s tupleSet) minus(other tupleSet) []tuple { var out []tuple diff --git a/pkg/linters/rbac/rules/tuples_test.go b/pkg/linters/rbac/rules/tuples_test.go new file mode 100644 index 00000000..8f824576 --- /dev/null +++ b/pkg/linters/rbac/rules/tuples_test.go @@ -0,0 +1,36 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rules + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// A grant on every object covers the declared grant on one name, never the other way round (review +// of #479, reply to finding 13d). +func TestTupleSet_UncoveredBy(t *testing.T) { + pinned := tupleSet{} + pinned.add(resourceTuple("deckhouse.io", "moduleconfigs", "deckhouse", "get")) + + wide := tupleSet{} + wide.add(resourceTuple("deckhouse.io", "moduleconfigs", "", "get")) + + assert.Empty(t, pinned.uncoveredBy(wide)) + assert.Equal(t, []tuple{resourceTuple("deckhouse.io", "moduleconfigs", "", "get")}, wide.uncoveredBy(pinned)) +} diff --git a/test/e2e/testdata/rbac/coverage-todo/expected.yaml b/test/e2e/testdata/rbac/coverage-todo/expected.yaml index 979618fd..26edf8a2 100644 --- a/test/e2e/testdata/rbac/coverage-todo/expected.yaml +++ b/test/e2e/testdata/rbac/coverage-todo/expected.yaml @@ -6,7 +6,7 @@ expect: - linter: rbac rule: coverage level: warn - textContains: 'e2e.deckhouse.io/widgets is still noAccess: "TODO" in rbac.yaml' + textContains: 'e2e.deckhouse.io/widgets is still undecided in rbac.yaml (noAccess: "TODO")' count: 1 - linter: rbac rule: coverage From b33f123e1475c43ef0990d93c8f3f3aa69abdef3 Mon Sep 17 00:00:00 2001 From: Ivan Zvyagintsev Date: Thu, 24 Sep 2026 10:42:45 +0300 Subject: [PATCH 37/58] rbac: regression hunt on c40469a -- text parser, duplicates, logs Found by two regression reviews of c40469a; each case has a test in sync_regressions_test.go. - The text parser of generated files reads separators with comments, CRLF, the part before the first separator and kind lines with comments; a document it cannot read (an include, a templated name) is refused instead of skipped, so a hand-added object in such a form is no longer dropped. - The text check runs only on files with the generator header; a hand-maintained file gets its _.generated aside again instead of a refusal listing objects the bootstrap had announced as renamed. - A file is not regenerated while another generated file's text still holds an object it would write, so an object under a false condition that the declaration moved is not defined twice. - Module capabilities in contract 1 files are recognised by the dict form the generator writes their labels in. - A deckhouseVersion condition in a file with the generator header is a when, not the migration gate. - exclude-rules.sync no longer turns an object of a contract 1 file into a silent removal. - Every change a regeneration makes to rights -- tokens, access levels, roleRefs, aggregation -- is logged at warn, not only removed rules. - An object the declaration replaced under another name that keeps rendering beside its replacement (the Prometheus Roles bootstrap folds into access-to-) is reported in its file, with its binding. Signed-off-by: Ivan Zvyagintsev --- pkg/linters/rbac/rules/fixstate.go | 7 + pkg/linters/rbac/rules/sync.go | 210 ++++++++- .../rbac/rules/sync_regressions_test.go | 402 ++++++++++++++++++ 3 files changed, 603 insertions(+), 16 deletions(-) create mode 100644 pkg/linters/rbac/rules/sync_regressions_test.go diff --git a/pkg/linters/rbac/rules/fixstate.go b/pkg/linters/rbac/rules/fixstate.go index fb7d0a21..2c291eb2 100644 --- a/pkg/linters/rbac/rules/fixstate.go +++ b/pkg/linters/rbac/rules/fixstate.go @@ -27,6 +27,7 @@ import ( "sync" "github.com/deckhouse/dmt/pkg/linters/rbac/rules/bootstrap" + "github.com/deckhouse/dmt/pkg/linters/rbac/rules/generate" "github.com/deckhouse/dmt/pkg/linters/rbac/rules/rbaccontract" ) @@ -231,6 +232,12 @@ func templateGated(existing, produced string) bool { return true } + // A file with the generator header is the generator's: a deckhouseVersion test in it is a `when` + // the declaration once had, not the migration gate, even when the declaration dropped it since. + if generated, _ := generate.ParseHeader(existing); generated { + return false + } + return gateActionRe.MatchString(existing) && !gateActionRe.MatchString(produced) } diff --git a/pkg/linters/rbac/rules/sync.go b/pkg/linters/rbac/rules/sync.go index 41aa58eb..9ea805ec 100644 --- a/pkg/linters/rbac/rules/sync.go +++ b/pkg/linters/rbac/rules/sync.go @@ -28,6 +28,7 @@ import ( "regexp" "slices" "sort" + "strconv" "strings" corev1 "k8s.io/api/core/v1" @@ -218,6 +219,8 @@ func (r *SyncRule) compareRender(model *generate.Model, actual map[string]manage } } + divergences = r.replacedCopies(model, actual, modelIdentities, divergences) + for identity, obj := range actual { if _, produced := modelIdentities[identity]; produced || obj.class == generate.ClassDeclared { continue @@ -355,6 +358,22 @@ func noLongerProduced(content string, produced map[string]struct{}, placed map[s // report emits one finding per template, with the fix that closes it when one exists: the // regeneration of a produced file, the deletion of an orphaned generated file, or none. func (r *SyncRule) report(modulePath string, model *generate.Model, divergences map[string][]string) { + // What every generated file holds by its text, rendered or not: an object under a false + // condition that the declaration moved is in its old file's text only, and writing it into the + // new one would define it twice once the condition holds. + held := map[string][]string{} + + for _, path := range generatedTemplates(modulePath) { + content, err := os.ReadFile(filepath.Join(modulePath, path)) + if err != nil { + continue + } + + for _, doc := range textDocuments(string(content)) { + held[doc.id] = append(held[doc.id], path) + } + } + placed := map[string]string{} for _, f := range model.Files { @@ -403,6 +422,7 @@ func (r *SyncRule) report(modulePath string, model *generate.Model, divergences // An object this file produces that still renders from another file stays there until a // person moves it; writing it here as well would render it twice. recordBlocked(filepath.Join(modulePath, path), r.renderedElsewhere(*file)) + recordBlocked(filepath.Join(modulePath, path), heldElsewhere(*file, held)) fileList = fileList.WithFix(regenerateFix(modulePath, *file, placed, r.foreignObjects(*file, model), removalsOf(list))) fileList.Errorf("%s does not match %s: %s. Run `%s` to regenerate the file from the declaration", path, rbacyaml.Filename, strings.Join(list, "; "), FixCommand) @@ -622,7 +642,9 @@ func (r *SyncRule) foreignIn(path string, produced map[string]struct{}, produced } if !listed && isRBACKind(object.Unstructured.GetKind()) { - if m, ok := managed[id]; ok && m.class != generate.ClassDeclared { + // exclude-rules.sync silences an object's finding; it must not turn the object into a + // silent removal either. + if m, ok := managed[id]; ok && m.class != generate.ClassDeclared && r.Enabled(object.Unstructured.GetKind(), object.Unstructured.GetName()) { continue } @@ -1082,13 +1104,17 @@ func crdScopes(crds []crdInfo) rbacyaml.CRDScopes { // the render has and the declaration does not name. They are logged when the file is written, so a // --fix run without a preceding dmt lint does not remove rights in silence. func removalsOf(divergences []string) []string { + // Every divergence is something the regeneration changes in the cluster -- a right removed, a + // token taken away, a level or a roleRef changed -- except the two that are only about the text. var out []string for _, d := range divergences { - if strings.Contains(d, "is in the render but not declared") || strings.Contains(d, "is in the render but rbac.yaml does not produce it") || - strings.Contains(d, "the declaration no longer produces it") { - out = append(out, d) + if strings.HasPrefix(d, "the file carries the generator header but is not what the declaration renders now") || + strings.HasPrefix(d, "the file was generated under contract version") { + continue } + + out = append(out, d) } return out @@ -1132,9 +1158,11 @@ func regenerateFix(modulePath string, file generate.File, placed map[string]stri // The render shows only what renders under these values; the text shows everything the // file holds, objects under a false condition included. - if unknown := notTheGenerators(string(existing), identitiesOf(file.Objects), placed, file.Path); len(unknown) > 0 { - return fmt.Errorf("%s holds objects the fix cannot account for: %s; they are not what the generator wrote there, so regenerating the file would drop them -- declare them in %s, move them, or remove them by hand, then run `%s` again", - file.Path, strings.Join(unknown, ", "), rbacyaml.Filename, FixCommand) + if generated, _ := generate.ParseHeader(string(existing)); generated { + if unknown := notTheGenerators(string(existing), identitiesOf(file.Objects), placed, file.Path); len(unknown) > 0 { + return fmt.Errorf("%s holds objects the fix cannot account for: %s; they are not what the generator wrote there, so regenerating the file would drop them -- declare them in %s, move them, or remove them by hand, then run `%s` again", + file.Path, strings.Join(unknown, ", "), rbacyaml.Filename, FixCommand) + } } if templateGated(string(existing), content) { @@ -1451,21 +1479,29 @@ type textDocument struct { } var ( - kindLineRe = regexp.MustCompile(`^kind:\s*(\S+)\s*$`) - nameLineRe = regexp.MustCompile(`^ name:\s*"?([^"\s]+)"?\s*$`) - namespaceLineRe = regexp.MustCompile(`^ namespace:\s*"?([^"\s]+)"?\s*$`) + kindLineRe = regexp.MustCompile(`^kind:\s*(\S+)\s*(#.*)?$`) + nameLineRe = regexp.MustCompile(`^ name:\s*"?([^"\s#]+)"?\s*(#.*)?$`) + namespaceLineRe = regexp.MustCompile(`^ namespace:\s*"?([^"\s#]+)"?\s*(#.*)?$`) + separatorRe = regexp.MustCompile(`(?m)^---[ \t]*(#.*)?$`) + // wrapperLineRe matches the lines the generator puts between objects: its conditions and + // their ends. Anything else outside an object is content the fix does not understand. + wrapperLineRe = regexp.MustCompile(`^\s*(\{\{-?\s*(if|else|end)\b[^}]*-?\}\}\s*)*$`) ) // textDocuments parses the objects of a generated file from its text: the generator writes kind, -// metadata.name and metadata.namespace on lines of their own. A document it cannot read yields an -// identity that matches nothing, so the fix refuses rather than guesses. +// metadata.name and metadata.namespace on lines of their own. A document it cannot read -- an +// include, a templated name, anything that is neither an object nor the generator's own +// wrapper lines -- yields an identity that matches nothing, so the fix refuses rather than guesses. func textDocuments(content string) []textDocument { + content = strings.ReplaceAll(content, "\r\n", "\n") + var out []textDocument - for _, doc := range strings.Split(content, "\n---\n")[1:] { + for i, doc := range separatorRe.Split(content, -1) { var kind, name, namespace string inMetadata := false + other := false for _, line := range strings.Split(doc, "\n") { switch { @@ -1484,12 +1520,22 @@ func textDocuments(content string) []textDocument { if m := kindLineRe.FindStringSubmatch(line); m != nil && kind == "" { kind = m[1] + continue + } + + trimmed := strings.TrimSpace(line) + if trimmed != "" && !strings.HasPrefix(trimmed, "#") && !wrapperLineRe.MatchString(line) && kind == "" { + other = true } } } - if kind == "" { - continue // the end of a conditional block, not an object + switch { + case kind == "" && !other: + continue // the header, or the end of a conditional block + case kind == "" || name == "" || strings.Contains(name, "{{"): + out = append(out, textDocument{id: fmt.Sprintf("", i)}) + continue } id := kind + "/" + name @@ -1498,10 +1544,142 @@ func textDocuments(content string) []textDocument { } managed := strings.Contains(doc, rbaccontract.AccessLevelAnnotation+":") || - strings.Contains(doc, rbaccontract.LabelKind+": "+rbaccontract.KindCapability) + strings.Contains(doc, rbaccontract.LabelKind+": "+rbaccontract.KindCapability) || + // the generator writes the module labels through helm_lib_module_labels, as a dict + strings.Contains(doc, strconv.Quote(rbaccontract.LabelKind)+" "+strconv.Quote(rbaccontract.KindCapability)) out = append(out, textDocument{id: id, managed: managed}) } return out } + +// heldElsewhere lists the objects the file produces that another generated file's text holds. +func heldElsewhere(file generate.File, held map[string][]string) []string { + var out []string + + for _, o := range file.Objects { + for _, path := range held[o.Identity()] { + if path != file.Path { + out = append(out, o.Identity()+" (held by "+path+")") + } + } + } + + sort.Strings(out) + + return out +} + +// replacedCopies reports a rendered object the declaration does not own that grants exactly what a +// produced object grants while that produced object renders too: the object it replaced under +// another name, often in another file (the Prometheus access Roles bootstrap folds into +// access-to-). Both render, so the old copy keeps the grant alive after the declaration +// drops it. No fix: the copy sits in a file the generator does not own. +func (r *SyncRule) replacedCopies(model *generate.Model, actual map[string]managedObject, produced map[string]struct{}, divergences map[string][]string) map[string][]string { + storage := r.module.GetStorage() + + rendered := make(map[string]struct{}, len(storage)) + for index := range storage { + rendered[index.AsString()] = struct{}{} + } + + var all []generate.Object + + for _, f := range model.Files { + all = append(all, f.Objects...) + } + + copies := map[string]string{} + + for index, object := range storage { + id := index.AsString() + if _, ok := produced[id]; ok { + continue + } + + if _, ok := actual[id]; ok { + continue + } + + if !isRBACKind(object.Unstructured.GetKind()) || object.Unstructured.GetKind() == "ServiceAccount" { + continue + } + + if twin := renderedTwin(object, all, rendered); twin != "" { + copies[object.Unstructured.GetKind()+"/"+object.Unstructured.GetName()] = twin + divergences[object.ShortPath()] = append(divergences[object.ShortPath()], + id+" grants what "+twin+" grants, and both render: the declaration replaced it -- delete it from the template") + } + } + + // A binding of such a copy is part of it. + for index, object := range storage { + kind := object.Unstructured.GetKind() + if kind != "RoleBinding" && kind != "ClusterRoleBinding" { + continue + } + + if _, ok := produced[index.AsString()]; ok { + continue + } + + binding := new(rbacv1.RoleBinding) + if runtime.DefaultUnstructuredConverter.FromUnstructured(object.Unstructured.UnstructuredContent(), binding) != nil { + continue + } + + if twin, ok := copies[binding.RoleRef.Kind+"/"+binding.RoleRef.Name]; ok { + divergences[object.ShortPath()] = append(divergences[object.ShortPath()], + index.AsString()+" binds "+binding.RoleRef.Name+", the old copy of "+twin+" -- delete it with the copy") + } + } + + return divergences +} + +// renderedTwin returns the identity of a produced object that renders and grants exactly what the +// object grants (same rules, or same roleRef and subjects), or "". +func renderedTwin(object storage.StoreObject, produced []generate.Object, rendered map[string]struct{}) string { + content := object.Unstructured.UnstructuredContent() + + for _, o := range produced { + if o.Kind != object.Unstructured.GetKind() || o.Namespace != object.Unstructured.GetNamespace() { + continue + } + + if _, ok := rendered[o.Identity()]; !ok { + continue + } + + switch o.Kind { + case "ClusterRole", "Role": + role := new(rbacv1.ClusterRole) + if runtime.DefaultUnstructuredConverter.FromUnstructured(content, role) != nil || role.AggregationRule != nil || len(role.Rules) == 0 { + continue + } + + got := expandRenderedRules(role.Rules) + always, conditional := expandModelRules(o.Rules) + + for t := range conditional { + always.add(t) + } + + if len(always.minus(got)) == 0 && len(got.minus(always)) == 0 { + return o.Identity() + } + case "ClusterRoleBinding", "RoleBinding": + binding := new(rbacv1.RoleBinding) + if runtime.DefaultUnstructuredConverter.FromUnstructured(content, binding) != nil { + continue + } + + if binding.RoleRef.Kind == o.RoleRefKind && binding.RoleRef.Name == o.RoleRefName && subjectSet(binding.Subjects) == subjectSetOf(o.Subjects) { + return o.Identity() + } + } + } + + return "" +} diff --git a/pkg/linters/rbac/rules/sync_regressions_test.go b/pkg/linters/rbac/rules/sync_regressions_test.go new file mode 100644 index 00000000..74a129ed --- /dev/null +++ b/pkg/linters/rbac/rules/sync_regressions_test.go @@ -0,0 +1,402 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Regression probes from the fourth review of deckhouse/dmt#479: each reproduces a way a --fix could +// lose, duplicate or misreport an object. +package rules + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + yaml "gopkg.in/yaml.v3" + + "github.com/deckhouse/dmt/pkg" + "github.com/deckhouse/dmt/pkg/errors" + "github.com/deckhouse/dmt/pkg/linters/rbac/rules/generate" + "github.com/deckhouse/dmt/pkg/linters/rbac/rules/rbacyaml" +) + +func probeFixMessages(list *errors.LintRuleErrorsList) string { + var out []string + + for _, e := range list.GetErrors() { + if e.FixError != nil { + out = append(out, e.FixError.Error()) + } + } + + return strings.Join(out, "\n") +} + +// P1: a hand-added object under the file's false condition that textDocuments cannot see is +// dropped by the regeneration (no declaration change needed: the text edit is the divergence). +func TestSyncRegression_TextDocumentsBlindSpots(t *testing.T) { + const rel = "templates/cainjector/rbac-for-us.yaml" + + const cm = "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: cainjector-extra\n namespace: d8-cert-manager\n" + + for name, mutate := range map[string]func(string) string{ + "control: plain ConfigMap document (refused today)": func(s string) string { + return strings.Replace(s, "{{- end }}\n", "---\n"+cm+"{{- end }}\n", 1) + }, + "document is an include": func(s string) string { + return strings.Replace(s, "{{- end }}\n", "---\n{{ include \"cainjector-extra\" . }}\n{{- end }}\n", 1) + }, + "kind line carries a comment": func(s string) string { + return strings.Replace(s, "{{- end }}\n", "---\n"+strings.Replace(cm, "kind: ConfigMap\n", "kind: ConfigMap # hand-added\n", 1)+"{{- end }}\n", 1) + }, + "separator carries a comment": func(s string) string { + return strings.Replace(s, "{{- end }}\n", "--- # hand-added\n"+cm+"{{- end }}\n", 1) + }, + "object before the first separator": func(s string) string { + return strings.Replace(s, "{{- if .Values.certManager.internal.enableCAInjector }}\n", "{{- if .Values.certManager.internal.enableCAInjector }}\n"+cm, 1) + }, + "sound: range with templated name": func(s string) string { + return strings.Replace(s, "{{- end }}\n", "{{- range .Values.x }}\n---\napiVersion: v1\nkind: ConfigMap\nmetadata:\n name: cainjector-extra-{{ . }}\n{{- end }}\n{{- end }}\n", 1) + }, + "sound: no metadata.name, no namespace, quoted": func(s string) string { + return strings.Replace(s, "{{- end }}\n", "---\napiVersion: v1\nkind: ConfigMap\nmetadata:\n labels: {x: cainjector-extra}\n---\nkind: Role\nmetadata:\n name: 'cainjector'\n{{- end }}\n", 1) + }, + "CRLF line endings": func(s string) string { + s = strings.Replace(s, "{{- end }}\n", "---\n"+cm+"{{- end }}\n", 1) + return strings.ReplaceAll(s, "\n", "\r\n") + }, + } { + t.Run(name, func(t *testing.T) { + resetFixState() + t.Cleanup(resetFixState) + + modulePath := syncModuleDir(t) + model := syncModel(t, modulePath) + writeGenerated(t, modulePath, model) + + fullPath := filepath.Join(modulePath, rel) + content, err := os.ReadFile(fullPath) + require.NoError(t, err) + require.NoError(t, os.WriteFile(fullPath, []byte(mutate(string(content))), 0o600)) + + // Default values: the cainjector block does not render. + store := renderedFrom(t, model, func(o *generate.Object) bool { return o.When == "" }) + + list := runSync(t, modulePath, store) + for _, fix := range list.GetFixes() { + fix() + } + + after, err := os.ReadFile(fullPath) + require.NoError(t, err) + assert.Contains(t, string(after), "cainjector-extra", "the hand-added object must survive --fix; fix errors: %s", probeFixMessages(list)) + }) + } +} + +// P1b: the same blind spot on the orphan path deletes the whole file. +func TestSyncRegression_OrphanDeletesIncludeDocument(t *testing.T) { + resetFixState() + t.Cleanup(resetFixState) + + const rel = "templates/cainjector/rbac-for-us.yaml" + + modulePath := syncModuleDir(t) + model := syncModel(t, modulePath) + writeGenerated(t, modulePath, model) + + fullPath := filepath.Join(modulePath, rel) + content, err := os.ReadFile(fullPath) + require.NoError(t, err) + require.NoError(t, os.WriteFile(fullPath, []byte(strings.Replace(string(content), "{{- end }}\n", "---\n{{ include \"cainjector-extra\" . }}\n{{- end }}\n", 1)), 0o600)) + + decl, err := rbacyaml.Load(modulePath) + require.NoError(t, err) + + decl.ServiceAccounts = nil + raw, err := yaml.Marshal(decl) + require.NoError(t, err) + require.NoError(t, os.WriteFile(rbacyaml.Path(modulePath), raw, 0o600)) + + store := renderedFrom(t, model, func(o *generate.Object) bool { return o.When == "" }) + + list := runSync(t, modulePath, store) + for _, fix := range list.GetFixes() { + fix() + } + + _, err = os.Stat(fullPath) + assert.NoError(t, err, "a file holding a hand-added include must not be deleted") +} + +// P5: an object under a false `when` that the declaration moves to another file is refused in +// its source but written into its target: after --fix both templates define it. +func TestSyncRegression_UnrenderedMoveWritesTwice(t *testing.T) { + resetFixState() + t.Cleanup(resetFixState) + + modulePath := syncModuleDir(t) + model := syncModel(t, modulePath) + writeGenerated(t, modulePath, model) + + decl, err := rbacyaml.Load(modulePath) + require.NoError(t, err) + + decl.ServiceAccounts[0].Path = "" + raw, err := yaml.Marshal(decl) + require.NoError(t, err) + require.NoError(t, os.WriteFile(rbacyaml.Path(modulePath), raw, 0o600)) + + // Default values: the cainjector account (under when) renders from nowhere. + store := renderedFrom(t, model, func(o *generate.Object) bool { return o.When == "" }) + + list := runSync(t, modulePath, store) + t.Logf("findings:\n%s", strings.Join(texts(list), "\n")) + + for _, fix := range list.GetFixes() { + fix() + } + + t.Logf("fix errors:\n%s", probeFixMessages(list)) + + const sa = "kind: ServiceAccount\nmetadata:\n name: cainjector\n" + + source, err := os.ReadFile(filepath.Join(modulePath, "templates/cainjector/rbac-for-us.yaml")) + require.NoError(t, err) + + target, err := os.ReadFile(filepath.Join(modulePath, "templates/rbac-for-us.yaml")) + require.NoError(t, err) + + inSource, inTarget := strings.Contains(string(source), sa), strings.Contains(string(target), sa) + assert.False(t, inSource && inTarget, "the account is defined in both templates after --fix") +} + +// P6: a contract 1 capability file the declaration drops is announced as deleted by --fix, and +// the fix refuses: the text parse does not recognize the capability label the generator writes. +func TestSyncRegression_ContractOneCapabilityOrphanRefused(t *testing.T) { + resetFixState() + t.Cleanup(resetFixState) + + const rel = "templates/rbacv2/use/admin.yaml" + + modulePath := syncModuleDir(t) + model := syncModel(t, modulePath) + writeGenerated(t, modulePath, model) + asContractOne(t, filepath.Join(modulePath, rel)) + + store := renderedFrom(t, model, nil) + + decl, err := rbacyaml.Load(modulePath) + require.NoError(t, err) + + for i := range decl.Resources { + if decl.Resources[i].Resource == "issuers" { + delete(decl.Resources[i].Namespace, "admin") + } + } + + delete(decl.Capabilities, "namespace.admin") + + raw, err := yaml.Marshal(decl) + require.NoError(t, err) + require.NoError(t, os.WriteFile(rbacyaml.Path(modulePath), raw, 0o600)) + require.Nil(t, syncModel(t, modulePath).File(rel), "the declaration no longer produces the file") + + list := runSync(t, modulePath, store) + joined := strings.Join(texts(list), "\n") + require.Contains(t, joined, rel+" does not match rbac.yaml") + t.Logf("findings:\n%s", joined) + + for _, fix := range list.GetFixes() { + fix() + } + + t.Logf("fix errors:\n%s", probeFixMessages(list)) + + _, err = os.Stat(filepath.Join(modulePath, rel)) + assert.True(t, os.IsNotExist(err), "the finding says --fix deletes the file") +} + +// P7: a `when` on deckhouseVersion that the declaration drops turns the generated file into a +// "gated" one: the fix refuses with a false reason. +func TestSyncRegression_DroppedVersionWhenLooksLikeAGate(t *testing.T) { + resetFixState() + t.Cleanup(resetFixState) + + modulePath := syncModuleDir(t) + + decl, err := rbacyaml.Load(modulePath) + require.NoError(t, err) + + decl.Resources[0].When = `semverCompare ">= 1.80" .Values.global.deckhouseVersion` + raw, err := yaml.Marshal(decl) + require.NoError(t, err) + require.NoError(t, os.WriteFile(rbacyaml.Path(modulePath), raw, 0o600)) + + writeGenerated(t, modulePath, syncModel(t, modulePath)) + + decl.Resources[0].When = "" + raw, err = yaml.Marshal(decl) + require.NoError(t, err) + require.NoError(t, os.WriteFile(rbacyaml.Path(modulePath), raw, 0o600)) + + model := syncModel(t, modulePath) + + list := runSync(t, modulePath, renderedFrom(t, model, nil)) + for _, fix := range list.GetFixes() { + fix() + } + + msgs := probeFixMessages(list) + t.Logf("fix errors:\n%s", msgs) + assert.NotContains(t, msgs, "renders one of two role models", "the file never had a gate") +} + +// P8: in a contract 1 file, a legacy role excluded from sync (exclude-rules.sync) is dropped by +// a regeneration, and the removal is neither reported nor logged. +func TestSyncRegression_ExcludedLegacyRoleDroppedFromContractOne(t *testing.T) { + resetFixState() + t.Cleanup(resetFixState) + + const rel = "templates/user-authz-cluster-roles.yaml" + + modulePath := syncModuleDir(t) + model := syncModel(t, modulePath) + writeGenerated(t, modulePath, model) + + fullPath := filepath.Join(modulePath, rel) + asContractOne(t, fullPath) + + extra := generate.Object{Kind: "ClusterRole", Name: "d8:user-authz:cert-manager:kept-by-hand", Class: generate.ClassLegacy, + Annotations: map[string]string{"user-authz.deckhouse.io/access-level": "User"}, + Rules: []generate.Rule{{PolicyRule: rbacyaml.PolicyRule{APIGroups: []string{""}, Resources: []string{"pods"}, Verbs: []string{"get"}}}}} + + content, err := os.ReadFile(fullPath) + require.NoError(t, err) + + doc := "---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRole\nmetadata:\n name: d8:user-authz:cert-manager:kept-by-hand\n annotations:\n user-authz.deckhouse.io/access-level: \"User\"\nrules:\n- apiGroups: [\"\"]\n resources: [pods]\n verbs: [get]\n" + require.NoError(t, os.WriteFile(fullPath, append(content, []byte(doc)...), 0o600)) + + store := renderedFrom(t, model, nil) + putObject(t, store, rel, extra) + + list := runSync(t, modulePath, store, pkg.KindRuleExclude{Kind: "ClusterRole", Name: extra.Name}) + joined := strings.Join(texts(list), "\n") + t.Logf("findings:\n%s", joined) + + for _, fix := range list.GetFixes() { + fix() + } + + t.Logf("fix errors:\n%s", probeFixMessages(list)) + + after, err := os.ReadFile(fullPath) + require.NoError(t, err) + assert.Contains(t, string(after), "kept-by-hand", "an object excluded from sync is dropped by the fix without a word") +} + +// P9: a regeneration that changes rights in ways other than "is in the render but not declared" +// logs no removal: on a --fix run the fixed finding is not printed, so the only trace is an Info line. +func TestSyncRegression_RightsChangesMissingFromRemovalLog(t *testing.T) { + for name, tc := range map[string]struct { + rel string + tweak func(o *generate.Object) bool + after func(store map[string]any) + name string + }{ + "automount token taken away": { + rel: "templates/cainjector/rbac-for-us.yaml", + tweak: func(o *generate.Object) bool { + if o.Kind == "ServiceAccount" && o.Name == "cainjector" { + yes := true + o.AutomountToken = &yes + } + + return true + }, + }, + "legacy access level lowered": { + rel: "templates/user-authz-cluster-roles.yaml", + tweak: func(o *generate.Object) bool { + if o.Name == "d8:user-authz:cert-manager:user" { + o.Annotations = map[string]string{"user-authz.deckhouse.io/access-level": "Admin"} + } + + return true + }, + }, + "binding repointed": { + rel: "templates/rbac-for-us.yaml", + tweak: func(o *generate.Object) bool { + if o.Kind == "ClusterRoleBinding" && o.Name == "d8:cert-manager:admin-kubeconfig" { + o.RoleRefName = "cluster-admin" + } + + return true + }, + }, + } { + t.Run(name, func(t *testing.T) { + resetFixState() + t.Cleanup(resetFixState) + + modulePath := syncModuleDir(t) + model := syncModel(t, modulePath) + writeGenerated(t, modulePath, model) + + // The template on disk is stale (a hand edit that the render shows). + fullPath := filepath.Join(modulePath, tc.rel) + content, err := os.ReadFile(fullPath) + require.NoError(t, err) + require.NoError(t, os.WriteFile(fullPath, append(content, []byte("# stale\n")...), 0o600)) + + list := runSync(t, modulePath, renderedFrom(t, model, tc.tweak)) + t.Logf("findings:\n%s", strings.Join(texts(list), "\n")) + + assert.NotEmpty(t, recordedRemovals(fullPath), "the rights change is not among the removals the fix logs") + }) + } +} + +// An old copy that keeps rendering beside the object that replaced it is reported in its own file: +// the Prometheus Role bootstrap folds into access-to- stays in the nested template +// otherwise, and the grant outlives its removal from the declaration. +func TestSyncRegression_ReplacedCopyIsReported(t *testing.T) { + resetFixState() + t.Cleanup(resetFixState) + + modulePath := syncModuleDir(t) + model := syncModel(t, modulePath) + writeGenerated(t, modulePath, model) + + store := renderedFrom(t, model, nil) + + for _, o := range model.File("templates/rbac-to-us.yaml").Objects { + old := o + old.Name = "access-to-cert-manager-prometheus-metrics" + + if old.Kind == "RoleBinding" { + old.RoleRefName = "access-to-cert-manager-prometheus-metrics" + } + + putObject(t, store, "templates/cert-manager/rbac-to-us.yaml", old) + } + + got := strings.Join(texts(runSync(t, modulePath, store)), "\n") + assert.Contains(t, got, "templates/cert-manager/rbac-to-us.yaml does not match rbac.yaml: d8-cert-manager/Role/access-to-cert-manager-prometheus-metrics grants what d8-cert-manager/Role/access-to-cert-manager grants, and both render") + assert.Contains(t, got, "d8-cert-manager/RoleBinding/access-to-cert-manager-prometheus-metrics binds access-to-cert-manager-prometheus-metrics, the old copy of d8-cert-manager/Role/access-to-cert-manager") +} From c9ee764838eb960c495e8f5f7d11c44d074f13c6 Mon Sep 17 00:00:00 2001 From: Ivan Zvyagintsev Date: Thu, 24 Sep 2026 10:54:12 +0300 Subject: [PATCH 38/58] rbac: follow the placement rule for nested paths, carry account annotations - An account in templates/a/b/ is named a-b or -a-b, as the placement rule wants; nested paths are no longer refused. - A namespace access entry with a path is named access-to--; bootstrap strips either placement prefix and keeps the existing name. - Every module namespace but default gets the rbac.deckhouse.io/namespace label, kube-system included. - serviceAccounts[].annotations and rbacAnnotations carry helm.sh/resource-policy, werf.io/deploy-on and the like; bootstrap imports them, sync compares them, keys are validated. - A declaration that does not parse, validate or build, a broken module.yaml and a declaration in an overlay carry a failing fix, so --fix exits non-zero. Signed-off-by: Ivan Zvyagintsev --- pkg/linters/rbac/README.md | 21 +++- pkg/linters/rbac/rules/bootstrap/bootstrap.go | 68 ++++++++++- .../rbac/rules/bootstrap/bootstrap_test.go | 32 ++++++ .../rbac/rules/generate/generate_test.go | 7 +- pkg/linters/rbac/rules/generate/model.go | 53 ++++++--- .../rbac/rules/generate/placement_test.go | 108 ++++++++++++++++++ .../rbac/rules/rbaccontract/contract.go | 11 ++ pkg/linters/rbac/rules/rbacyaml/load_test.go | 25 ++++ pkg/linters/rbac/rules/rbacyaml/types.go | 8 +- pkg/linters/rbac/rules/rbacyaml/validate.go | 23 ++++ pkg/linters/rbac/rules/sync.go | 51 +++++++-- .../rbac/rules/sync_regressions_test.go | 21 ++++ pkg/linters/rbac/rules/sync_test.go | 56 ++++++++- 13 files changed, 448 insertions(+), 36 deletions(-) create mode 100644 pkg/linters/rbac/rules/generate/placement_test.go diff --git a/pkg/linters/rbac/README.md b/pkg/linters/rbac/README.md index 26547969..c301dd7d 100644 --- a/pkg/linters/rbac/README.md +++ b/pkg/linters/rbac/README.md @@ -1423,8 +1423,11 @@ capabilities: serviceAccounts: - name: cainjector path: cainjector # templates/cainjector/rbac-for-us.yaml; omitted -> templates/rbac-for-us.yaml + # a nested path a/b names the account a-b or -a-b (placement rule) when: .Values.certManager.internal.enableCAInjector labels: {app: cainjector} + annotations: {helm.sh/resource-policy: keep} # on the ServiceAccount + rbacAnnotations: {werf.io/deploy-on: pre-install} # on every role and binding of the account clusterRules: # ClusterRole d8:: + ClusterRoleBinding - apiGroups: [cert-manager.io] resources: [certificates] @@ -1480,7 +1483,11 @@ What the generator produces from it (level `viewer` -> capability `view`, `manag | `resources[].legacy.` | `templates/user-authz-cluster-roles.yaml` | ClusterRole `d8:user-authz::` with the `user-authz.deckhouse.io/access-level` annotation | | `serviceAccounts[]` | `templates/[/]rbac-for-us.yaml` | ServiceAccount, ClusterRole/ClusterRoleBinding `d8::`, Role/RoleBinding ``, the extra bindings | | `access[]` with `clusterRules` | `templates/rbac-for-us.yaml` | ClusterRole/ClusterRoleBinding `d8::` | -| `prometheusAccess`, `access[]` with `namespaceRules` | `templates/rbac-to-us.yaml` | Role/RoleBinding `access-to-[-]` | +| `prometheusAccess`, `access[]` with `namespaceRules` | `templates/[/]rbac-to-us.yaml` | Role/RoleBinding `access-to-[-]`; with `path` `access-to--`, as the placement rule wants | + +Every object gets the `rbac.deckhouse.io/namespace` label of the module namespace unless that namespace is +`default`: user-authz projects the module's use roles by it, and `kube-system` is a module namespace as +any `d8-*` one. Every generated file starts with a header line naming the generator and the contract version. A file without that header is maintained by hand and is never overwritten. @@ -1641,12 +1648,16 @@ controller ClusterRoles with arbitrary names, objects with Helm-computed names). **What it checks:** 1. Every declared object is in the render (unless it is under `when`), and every rule of it: rules are compared as `(apiGroup, resource, resourceName, verb)` tuples, in both directions. A rule under `when` that did not render is not a divergence; a rule without `when` hidden behind a hand-written `{{ if }}` is. -2. A capability's aggregation edges (`aggregate-to--as`) match in both directions: rules may agree while a lineage is lost. Its `rbac.deckhouse.io/capability` marker, `module` and `rbac.deckhouse.io/namespace` labels are what the generator writes. -3. A binding's `roleRef` and subjects match. -4. Every rendered legacy role and module capability is produced by the declaration. -5. A file the declaration produces that does not exist while an object it holds is absent from the render is a divergence, whether or not the object is under `when`: the render cannot tell a false condition from a template nobody wrote, the text can. A file that carries the generator header is the generator's, and its text must be what the declaration renders now: a rule under `when` whose condition is false today is absent from the render without being a divergence, yet it still has to reach the template, so for generator-owned files the text is compared too. A file of another contract version is the same case. Remove the header to maintain a file by hand; then only its render is judged. +2. The annotations of an object written from `serviceAccounts` or `access` match the declaration's (`annotations`, `rbacAnnotations`): a `helm.sh/resource-policy: keep` the declaration does not carry would be lost by the next regeneration. +3. A capability's aggregation edges (`aggregate-to--as`) match in both directions: rules may agree while a lineage is lost. Its `rbac.deckhouse.io/capability` marker, `module` and `rbac.deckhouse.io/namespace` labels are what the generator writes. +4. A binding's `roleRef` and subjects match. +5. Every rendered legacy role and module capability is produced by the declaration. +6. A file the declaration produces that does not exist while an object it holds is absent from the render is a divergence, whether or not the object is under `when`: the render cannot tell a false condition from a template nobody wrote, the text can. A file that carries the generator header is the generator's, and its text must be what the declaration renders now: a rule under `when` whose condition is false today is absent from the render without being a divergence, yet it still has to reach the template, so for generator-owned files the text is compared too. A file of another contract version is the same case. Remove the header to maintain a file by hand; then only its render is judged. Findings are one per template file and carry the fix command; the text does not depend on the render variant. +A declaration that does not parse or validate, one the generator cannot turn into objects (an account +named against the placement rule), a broken `module.yaml` and a declaration in an edition overlay stop +the rule; their fix fails, so `--fix` exits non-zero instead of reporting a run that generated nothing. **Autofix:** regenerates the file from `rbac.yaml`. The declaration is the source of truth: a right it no longer names leaves the template; the finding that led there listed it, and the autofix logs what it diff --git a/pkg/linters/rbac/rules/bootstrap/bootstrap.go b/pkg/linters/rbac/rules/bootstrap/bootstrap.go index be5d5038..9e986ff1 100644 --- a/pkg/linters/rbac/rules/bootstrap/bootstrap.go +++ b/pkg/linters/rbac/rules/bootstrap/bootstrap.go @@ -22,6 +22,7 @@ package bootstrap import ( "fmt" + "maps" "regexp" "slices" "sort" @@ -422,10 +423,26 @@ func (b *builder) serviceAccounts() { b.note("ServiceAccount %s mounted its token (automountServiceAccountToken unset or true); kept as true -- set false once its pods mount the token themselves", sa.Name) } + e.Annotations = copyAnnotations(sa.Annotations) + b.mark(sa) clusterName := "d8:" + b.in.Module + ":" + sa.Name + // The roles and bindings of the account carry one set of annotations in the format; the + // first object's set is kept and a differing one is noted. + var rbacFrom string + + keep := func(o Object) { + switch { + case rbacFrom == "": + rbacFrom = o.Kind + " " + o.Name + e.RBACAnnotations = copyAnnotations(o.Annotations) + case !maps.Equal(e.RBACAnnotations, copyAnnotations(o.Annotations)): + b.note("%s %s carries annotations other than %s; the account's roles and bindings share one set (rbacAnnotations), the set of %s is kept", o.Kind, o.Name, rbacFrom, rbacFrom) + } + } + for _, crb := range b.byKind("ClusterRoleBinding") { if b.isUsed(crb) || !subjectsAreOnly(crb, sa.Name, b.in.Namespace) { continue @@ -457,9 +474,11 @@ func (b *builder) serviceAccounts() { } if exclusive { + keep(cr) b.mark(cr) } + keep(crb) b.mark(crb) } @@ -472,6 +491,7 @@ func (b *builder) serviceAccounts() { e.NamespaceRules = policyRules(role.Rules) b.rename("Role", role.Name, sa.Name) b.rename("RoleBinding", rb.Name, sa.Name) + keep(role) b.mark(role) } else if rb.RoleRef.Kind != "Role" { // bindRoles binds Roles; the format has no RoleBinding to a ClusterRole, and turning it @@ -485,6 +505,10 @@ func (b *builder) serviceAccounts() { b.rename("RoleBinding", b.ns(rb)+"/"+rb.Name, b.ns(rb)+"/"+clusterName+":"+rbaccontract.BindingSuffix(rb.RoleRef.Name)) } + if rb.RoleRef.Kind == "Role" { + keep(rb) + } + b.mark(rb) } @@ -504,6 +528,7 @@ func (b *builder) serviceAccounts() { e.ExtraClusterRoles = append(e.ExtraClusterRoles, extra) + keep(cr) b.mark(cr) } @@ -552,10 +577,25 @@ func (b *builder) otherBindings() { continue } - name := strings.TrimPrefix(rb.Name, "access-to-"+b.in.Module+"-") - b.decl.Access = append(b.decl.Access, rbacyaml.Access{Name: name, Path: componentOf(role.Path, "rbac-to-us.yaml"), Subjects: subjects(rb.Subjects), NamespaceRules: policyRules(role.Rules)}) - b.rename("Role", role.Name, "access-to-"+b.in.Module+"-"+name) - b.rename("RoleBinding", rb.Name, "access-to-"+b.in.Module+"-"+name) + path := componentOf(role.Path, "rbac-to-us.yaml") + name := rb.Name + + // The entry name is what follows the placement prefix: access-to-- in a + // component file, access-to-- (with or without the directory) at the root. + for _, prefix := range []string{ + "access-to-" + b.in.Module + "-" + strings.ReplaceAll(path, "/", "-") + "-", + "access-to-" + strings.ReplaceAll(path, "/", "-") + "-", + "access-to-" + b.in.Module + "-", + } { + if trimmed, ok := strings.CutPrefix(name, prefix); ok && trimmed != "" && (path != "" || prefix == "access-to-"+b.in.Module+"-") { + name = trimmed + break + } + } + + b.decl.Access = append(b.decl.Access, rbacyaml.Access{Name: name, Path: path, Subjects: subjects(rb.Subjects), NamespaceRules: policyRules(role.Rules)}) + b.rename("Role", role.Name, rbaccontract.AccessRoleName(b.in.Module, path, name)) + b.rename("RoleBinding", rb.Name, rbaccontract.AccessRoleName(b.in.Module, path, name)) b.mark(role) b.mark(rb) } @@ -802,3 +842,23 @@ func subset(a, b []string) bool { return true } + +// copyAnnotations returns the annotations a declaration can carry: Helm's own meta.helm.sh keys +// are stamped at install time and the rbac.deckhouse.io keys are the generator's. +func copyAnnotations(in map[string]string) map[string]string { + var out map[string]string + + for k, v := range in { + if strings.HasPrefix(k, "meta.helm.sh/") || strings.HasPrefix(k, "rbac.deckhouse.io/") { + continue + } + + if out == nil { + out = make(map[string]string, len(in)) + } + + out[k] = v + } + + return out +} diff --git a/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go b/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go index f3581654..54ccc79f 100644 --- a/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go +++ b/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go @@ -412,3 +412,35 @@ func TestBuild_RepeatedBindingOfAnAccountFolds(t *testing.T) { assert.Regexp(t, `ClusterRoleBinding d8:m:autoscaler(-mcm)?:rbac-proxy binds autoscaler to d8:rbac-proxy again; it folds into d8:m:autoscaler:rbac-proxy`, strings.Join(got.Notes, "\n")) assert.Empty(t, rbacyaml.Validate(got.Decl, nil)) } + +// Annotations of an account and of its roles survive the import, apart from Helm's and the +// generator's own; a nested access Role keeps its entry name (regression hunt, B7 and B8). +func TestBuild_AnnotationsAndNestedAccessNames(t *testing.T) { + labels := map[string]string{"module": "m"} + subject := []rbacv1.Subject{{Kind: "ServiceAccount", Name: "m", Namespace: "d8-m"}} + rules := []rbacv1.PolicyRule{{APIGroups: []string{""}, Resources: []string{"nodes"}, Verbs: []string{"get"}}} + + got := Build(Input{Module: "m", Namespace: "d8-m", Objects: []Object{ + {Kind: "ServiceAccount", Name: "m", Path: "templates/rbac-for-us.yaml", Labels: labels, + Annotations: map[string]string{"helm.sh/resource-policy": "keep", "meta.helm.sh/release-name": "m"}}, + {Kind: "ClusterRole", Name: "d8:m:m", Path: "templates/rbac-for-us.yaml", Labels: labels, Rules: rules, + Annotations: map[string]string{"werf.io/deploy-on": "pre-install"}}, + {Kind: "ClusterRoleBinding", Name: "d8:m:m", Path: "templates/rbac-for-us.yaml", Labels: labels, + RoleRef: rbacv1.RoleRef{Kind: "ClusterRole", Name: "d8:m:m"}, Subjects: subject, + Annotations: map[string]string{"werf.io/deploy-on": "pre-install"}}, + {Kind: "Role", Name: "access-to-webhook-reader", Namespace: "d8-m", Path: "templates/webhook/rbac-to-us.yaml", Labels: labels, + Rules: []rbacv1.PolicyRule{{APIGroups: []string{""}, Resources: []string{"secrets"}, Verbs: []string{"get"}}}}, + {Kind: "RoleBinding", Name: "access-to-webhook-reader", Namespace: "d8-m", Path: "templates/webhook/rbac-to-us.yaml", Labels: labels, + RoleRef: rbacv1.RoleRef{Kind: "Role", Name: "access-to-webhook-reader"}, Subjects: []rbacv1.Subject{{Kind: "Group", Name: "g"}}}, + }}) + + require.Len(t, got.Decl.ServiceAccounts, 1) + assert.Equal(t, map[string]string{"helm.sh/resource-policy": "keep"}, got.Decl.ServiceAccounts[0].Annotations) + assert.Equal(t, map[string]string{"werf.io/deploy-on": "pre-install"}, got.Decl.ServiceAccounts[0].RBACAnnotations) + + require.Len(t, got.Decl.Access, 1) + assert.Equal(t, "reader", got.Decl.Access[0].Name) + assert.Equal(t, "webhook", got.Decl.Access[0].Path) + assert.NotContains(t, strings.Join(got.Notes, "\n"), "will be named", "the generator writes the names the module already has") + assert.Empty(t, rbacyaml.Validate(got.Decl, nil)) +} diff --git a/pkg/linters/rbac/rules/generate/generate_test.go b/pkg/linters/rbac/rules/generate/generate_test.go index 2ef25ec6..32ed2346 100644 --- a/pkg/linters/rbac/rules/generate/generate_test.go +++ b/pkg/linters/rbac/rules/generate/generate_test.go @@ -284,7 +284,12 @@ func TestBuild_RefusesWhatTheModuleCannotCarry(t *testing.T) { decl.ServiceAccounts = []rbacyaml.ServiceAccount{{Name: "dir", Path: "some/nested/dir"}} _, err = Build(Input{Module: "m", Namespace: "d8-m", Subsystems: []string{"security"}, Decl: decl}) require.Error(t, err) - assert.Contains(t, err.Error(), "one directory under templates/ only") + assert.Contains(t, err.Error(), `wants the account named "some-nested-dir" or "m-some-nested-dir"`) + + // templates///rbac-for-us.yaml: the placement rule joins the directories. + decl.ServiceAccounts = []rbacyaml.ServiceAccount{{Name: "some-nested-dir", Path: "some/nested/dir"}, {Name: "m-a-b", Path: "a/b"}} + _, err = Build(Input{Module: "m", Namespace: "d8-m", Subsystems: []string{"security"}, Decl: decl}) + require.NoError(t, err) }) t.Run("a capability marker longer than a label value", func(t *testing.T) { diff --git a/pkg/linters/rbac/rules/generate/model.go b/pkg/linters/rbac/rules/generate/model.go index 30452244..2a90d894 100644 --- a/pkg/linters/rbac/rules/generate/model.go +++ b/pkg/linters/rbac/rules/generate/model.go @@ -249,12 +249,11 @@ func checkAgainstModule(in Input) error { continue } - if strings.Contains(sa.Path, "/") { - return fmt.Errorf("serviceAccounts[%s].path %q: one directory under templates/ only; the placement rule names the objects of a nested directory in a way the generator cannot follow", sa.Name, sa.Path) - } - - if sa.Name != sa.Path && sa.Name != in.Module+"-"+sa.Path { - return fmt.Errorf("serviceAccounts[%s].path %q: the placement rule wants the account named %q or %q after its directory; rename the account or move it to the module root", sa.Name, sa.Path, sa.Path, in.Module+"-"+sa.Path) + // The placement rule names the account of templates///rbac-for-us.yaml after its + // directories joined with dashes, with or without the module name in front. + dir := strings.ReplaceAll(sa.Path, "/", "-") + if sa.Name != dir && sa.Name != in.Module+"-"+dir { + return fmt.Errorf("serviceAccounts[%s].path %q: the placement rule wants the account named %q or %q after its directory; rename the account or move it to the module root", sa.Name, sa.Path, dir, in.Module+"-"+dir) } } @@ -351,7 +350,10 @@ func (b *builder) capabilities() { labels[rbaccontract.AggregationLabelPrefix+subsystem+rbaccontract.AggregationLabelSuffix] = level } - if strings.HasPrefix(b.in.Namespace, "d8-") { + // The user-authz controller projects the module's use-role RoleBindings into this namespace; + // every module namespace gets it -- kube-system included -- but default, which the + // secret-copier module fills with copies and which is nobody's to administer. + if b.in.Namespace != "" && b.in.Namespace != "default" { labels[rbaccontract.LabelNamespace] = b.in.Namespace } @@ -433,41 +435,43 @@ func (b *builder) serviceAccounts() { automount := sa.AutomountToken != nil && *sa.AutomountToken b.add(path, Object{ Kind: "ServiceAccount", Name: sa.Name, Namespace: b.in.Namespace, Class: ClassDeclared, - When: sa.When, Labels: labels, AutomountToken: &automount, + When: sa.When, Labels: labels, Annotations: copyMap(sa.Annotations), AutomountToken: &automount, }) + ann := sa.RBACAnnotations + subject := []Subject{{Kind: "ServiceAccount", Name: sa.Name, Namespace: b.in.Namespace}} clusterName := "d8:" + b.in.Module + ":" + sa.Name if len(sa.ClusterRules) > 0 { - b.add(path, Object{Kind: "ClusterRole", Name: clusterName, Class: ClassDeclared, When: sa.When, Labels: labels, Rules: policyRules(sa.ClusterRules)}) - b.add(path, Object{Kind: "ClusterRoleBinding", Name: clusterName, Class: ClassDeclared, When: sa.When, Labels: labels, RoleRefKind: "ClusterRole", RoleRefName: clusterName, Subjects: subject}) + b.add(path, Object{Kind: "ClusterRole", Name: clusterName, Class: ClassDeclared, When: sa.When, Labels: labels, Annotations: copyMap(ann), Rules: policyRules(sa.ClusterRules)}) + b.add(path, Object{Kind: "ClusterRoleBinding", Name: clusterName, Class: ClassDeclared, When: sa.When, Labels: labels, Annotations: copyMap(ann), RoleRefKind: "ClusterRole", RoleRefName: clusterName, Subjects: subject}) } if len(sa.NamespaceRules) > 0 { - b.add(path, Object{Kind: "Role", Name: sa.Name, Namespace: b.in.Namespace, Class: ClassDeclared, When: sa.When, Labels: labels, Rules: policyRules(sa.NamespaceRules)}) - b.add(path, Object{Kind: "RoleBinding", Name: sa.Name, Namespace: b.in.Namespace, Class: ClassDeclared, When: sa.When, Labels: labels, RoleRefKind: "Role", RoleRefName: sa.Name, Subjects: subject}) + b.add(path, Object{Kind: "Role", Name: sa.Name, Namespace: b.in.Namespace, Class: ClassDeclared, When: sa.When, Labels: labels, Annotations: copyMap(ann), Rules: policyRules(sa.NamespaceRules)}) + b.add(path, Object{Kind: "RoleBinding", Name: sa.Name, Namespace: b.in.Namespace, Class: ClassDeclared, When: sa.When, Labels: labels, Annotations: copyMap(ann), RoleRefKind: "Role", RoleRefName: sa.Name, Subjects: subject}) } for _, extra := range sa.ExtraClusterRoles { extraName := extra.FullName(b.in.Module, sa.Name) - b.add(path, Object{Kind: "ClusterRole", Name: extraName, Class: ClassDeclared, When: sa.When, Labels: labels, Rules: policyRules(extra.Rules)}) + b.add(path, Object{Kind: "ClusterRole", Name: extraName, Class: ClassDeclared, When: sa.When, Labels: labels, Annotations: copyMap(ann), Rules: policyRules(extra.Rules)}) if extra.IsBound() { - b.add(path, Object{Kind: "ClusterRoleBinding", Name: extraName, Class: ClassDeclared, When: sa.When, Labels: labels, RoleRefKind: "ClusterRole", RoleRefName: extraName, Subjects: subject}) + b.add(path, Object{Kind: "ClusterRoleBinding", Name: extraName, Class: ClassDeclared, When: sa.When, Labels: labels, Annotations: copyMap(ann), RoleRefKind: "ClusterRole", RoleRefName: extraName, Subjects: subject}) } } for _, bound := range sa.BindClusterRoles { b.add(path, Object{ - Kind: "ClusterRoleBinding", Name: clusterName + ":" + rbaccontract.BindingSuffix(bound), Class: ClassDeclared, When: sa.When, Labels: labels, + Kind: "ClusterRoleBinding", Name: clusterName + ":" + rbaccontract.BindingSuffix(bound), Class: ClassDeclared, When: sa.When, Labels: labels, Annotations: copyMap(ann), RoleRefKind: "ClusterRole", RoleRefName: bound, Subjects: subject, }) } for _, ref := range sa.BindRoles { b.add(path, Object{ - Kind: "RoleBinding", Name: clusterName + ":" + rbaccontract.BindingSuffix(ref.Name), Namespace: ref.Namespace, Class: ClassDeclared, When: sa.When, Labels: labels, + Kind: "RoleBinding", Name: clusterName + ":" + rbaccontract.BindingSuffix(ref.Name), Namespace: ref.Namespace, Class: ClassDeclared, When: sa.When, Labels: labels, Annotations: copyMap(ann), RoleRefKind: "Role", RoleRefName: ref.Name, Subjects: subject, }) } @@ -527,7 +531,8 @@ func (b *builder) access() { } if len(a.NamespaceRules) > 0 { - name := "access-to-" + b.in.Module + "-" + a.Name + name := rbaccontract.AccessRoleName(b.in.Module, a.Path, a.Name) + b.add(dir+"rbac-to-us.yaml", Object{Kind: "Role", Name: name, Namespace: b.in.Namespace, Class: ClassDeclared, Rules: policyRules(a.NamespaceRules)}) b.add(dir+"rbac-to-us.yaml", Object{Kind: "RoleBinding", Name: name, Namespace: b.in.Namespace, Class: ClassDeclared, RoleRefKind: "Role", RoleRefName: name, Subjects: subjects}) } @@ -621,3 +626,17 @@ func liftRuleConditions(o *Object) { o.When = lifted } + +// copyMap returns a copy of m, nil for an empty one. +func copyMap(m map[string]string) map[string]string { + if len(m) == 0 { + return nil + } + + out := make(map[string]string, len(m)) + for k, v := range m { + out[k] = v + } + + return out +} diff --git a/pkg/linters/rbac/rules/generate/placement_test.go b/pkg/linters/rbac/rules/generate/placement_test.go new file mode 100644 index 00000000..c5975026 --- /dev/null +++ b/pkg/linters/rbac/rules/generate/placement_test.go @@ -0,0 +1,108 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package generate + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/deckhouse/dmt/pkg/linters/rbac/rules/rbaccontract" + "github.com/deckhouse/dmt/pkg/linters/rbac/rules/rbacyaml" +) + +func placementDecl() *rbacyaml.Declaration { + return &rbacyaml.Declaration{APIVersion: rbacyaml.APIVersionV1Alpha1, + Resources: []rbacyaml.Resource{{Group: "x.io", Resource: "things", Scope: "Namespaced", Namespace: map[string][]string{"viewer": {"get"}}}}, + } +} + +// Every module namespace carries the label user-authz projects the use roles by, kube-system +// included; default does not (regression hunt, B2). +func TestBuild_NamespaceLabelOutsideD8(t *testing.T) { + for ns, want := range map[string]bool{"d8-m": true, "kube-system": true, "default": false} { + model, err := Build(Input{Module: "m", Namespace: ns, Subsystems: []string{"security"}, Decl: placementDecl()}) + require.NoError(t, err) + + labelled := false + + for _, f := range model.Files { + for _, o := range f.Objects { + if o.Labels[rbaccontract.LabelNamespace] == ns { + labelled = true + } + } + } + + assert.Equal(t, want, labelled, ns) + } +} + +// A namespace access entry in a component directory gets the name the placement rule wants there +// (regression hunt, B8). +func TestBuild_AccessNamesFollowThePlacementRule(t *testing.T) { + decl := placementDecl() + rules := []rbacyaml.PolicyRule{{APIGroups: []string{""}, Resources: []string{"secrets"}, Verbs: []string{"get"}}} + subjects := []rbacyaml.Subject{{Kind: "Group", Name: "g"}} + decl.Access = []rbacyaml.Access{ + {Name: "reader", Subjects: subjects, NamespaceRules: rules}, + {Name: "reader", Path: "webhook/tls", Subjects: subjects, NamespaceRules: rules}, + } + // Two entries with one name are a duplicate for the validator, not for the generator. + model, err := Build(Input{Module: "m", Namespace: "d8-m", Subsystems: []string{"security"}, Decl: decl}) + require.NoError(t, err) + + root := model.File("templates/rbac-to-us.yaml") + require.NotNil(t, root) + assert.Equal(t, "access-to-m-reader", root.Objects[0].Name) + + nested := model.File("templates/webhook/tls/rbac-to-us.yaml") + require.NotNil(t, nested) + assert.Equal(t, "access-to-webhook-tls-reader", nested.Objects[0].Name) +} + +// Account annotations land on the ServiceAccount, rbacAnnotations on its roles and bindings +// (regression hunt, B7). +func TestBuild_AccountAnnotations(t *testing.T) { + decl := placementDecl() + decl.ServiceAccounts = []rbacyaml.ServiceAccount{{ + Name: "m", + Annotations: map[string]string{"helm.sh/resource-policy": "keep"}, + RBACAnnotations: map[string]string{"werf.io/deploy-on": "pre-install"}, + ClusterRules: []rbacyaml.PolicyRule{{APIGroups: []string{""}, Resources: []string{"nodes"}, Verbs: []string{"get"}}}, + }} + + model, err := Build(Input{Module: "m", Namespace: "d8-m", Subsystems: []string{"security"}, Decl: decl}) + require.NoError(t, err) + + file := model.File("templates/rbac-for-us.yaml") + require.NotNil(t, file) + + for _, o := range file.Objects { + if o.Kind == "ServiceAccount" { + assert.Equal(t, map[string]string{"helm.sh/resource-policy": "keep"}, o.Annotations) + } else { + assert.Equal(t, map[string]string{"werf.io/deploy-on": "pre-install"}, o.Annotations, o.Identity()) + } + } + + rendered := RenderFile(*file) + assert.Equal(t, 1, strings.Count(rendered, `helm.sh/resource-policy: "keep"`)) + assert.Equal(t, 2, strings.Count(rendered, `werf.io/deploy-on: "pre-install"`)) +} diff --git a/pkg/linters/rbac/rules/rbaccontract/contract.go b/pkg/linters/rbac/rules/rbaccontract/contract.go index efcb8363..73bf8e67 100644 --- a/pkg/linters/rbac/rules/rbaccontract/contract.go +++ b/pkg/linters/rbac/rules/rbaccontract/contract.go @@ -266,3 +266,14 @@ var I18nAnnotations = []string{AnnotationTitleEN, AnnotationTitleRU, AnnotationD func IsLegacyKind(kind string) bool { return kind == KindLegacyUse || kind == KindLegacyManage } + +// AccessRoleName is the Role and RoleBinding name of a namespace access entry. The placement rule +// wants access-to--... in templates/rbac-to-us.yaml and access-to--... in +// templates//rbac-to-us.yaml. +func AccessRoleName(module, path, name string) string { + if path == "" { + return "access-to-" + module + "-" + name + } + + return "access-to-" + strings.ReplaceAll(path, "/", "-") + "-" + name +} diff --git a/pkg/linters/rbac/rules/rbacyaml/load_test.go b/pkg/linters/rbac/rules/rbacyaml/load_test.go index 96dfbb02..b4225fa2 100644 --- a/pkg/linters/rbac/rules/rbacyaml/load_test.go +++ b/pkg/linters/rbac/rules/rbacyaml/load_test.go @@ -644,3 +644,28 @@ legacy: require.Len(t, w, 1) assert.Contains(t, w[0], "legacy.SuperAdmin produces a role user-authz does not aggregate") } + +// The generator writes label and annotation keys unquoted; the generator's and Helm's own +// annotations are not the declaration's (regression hunt, B7). +func TestValidate_AccountMetadataKeys(t *testing.T) { + decl := &Declaration{APIVersion: APIVersionV1Alpha1, ServiceAccounts: []ServiceAccount{{ + Name: "m", + Labels: map[string]string{"bad key": "x"}, + Annotations: map[string]string{"helm.sh/resource-policy": "keep", "meta.helm.sh/release-name": "m"}, + RBACAnnotations: map[string]string{"rbac.deckhouse.io/kind": "x", "werf.io/deploy-on": "pre-install"}, + }}} + + errs := Validate(decl, nil) + + msgs := make([]string, 0, len(errs)) + for _, e := range errs { + msgs = append(msgs, e.Error()) + } + + got := strings.Join(msgs, "\n") + assert.Contains(t, got, `serviceAccounts[0] (m).labels: "bad key" is not a valid key`) + assert.Contains(t, got, `serviceAccounts[0] (m).annotations: "meta.helm.sh/release-name" is set by the generator or by Helm`) + assert.Contains(t, got, `serviceAccounts[0] (m).rbacAnnotations: "rbac.deckhouse.io/kind" is set by the generator or by Helm`) + assert.NotContains(t, got, "helm.sh/resource-policy") + assert.NotContains(t, got, "werf.io") +} diff --git a/pkg/linters/rbac/rules/rbacyaml/types.go b/pkg/linters/rbac/rules/rbacyaml/types.go index 300f7f47..11042b3b 100644 --- a/pkg/linters/rbac/rules/rbacyaml/types.go +++ b/pkg/linters/rbac/rules/rbacyaml/types.go @@ -158,6 +158,11 @@ type ServiceAccount struct { // d8:::, or exactly the given name when it starts with d8:. ExtraClusterRoles []ExtraClusterRole `yaml:"extraClusterRoles,omitempty"` + // Annotations go on the ServiceAccount (helm.sh/resource-policy: keep, werf.io/deploy-on, ...); + // RBACAnnotations on every role and binding generated for the account. + Annotations map[string]string `yaml:"annotations,omitempty"` + RBACAnnotations map[string]string `yaml:"rbacAnnotations,omitempty"` + // AutomountToken is the ServiceAccount's automountServiceAccountToken; unset means false, the // platform convention. A pod that needs the token sets it true on the pod, or the account // declares true here. @@ -205,7 +210,8 @@ type PrometheusAccess struct { // Access grants arbitrary subjects rights on the module. ClusterRules produce a ClusterRole and // ClusterRoleBinding d8:: in templates/rbac-for-us.yaml; NamespaceRules produce a -// Role and RoleBinding access-to-- in templates/rbac-to-us.yaml. Exactly one of the +// Role and RoleBinding access-to-- in templates/rbac-to-us.yaml, or +// access-to-- in templates//rbac-to-us.yaml. Exactly one of the // two must be set: the placement rule keeps cluster-scoped objects out of rbac-to-us.yaml. type Access struct { Name string `yaml:"name"` diff --git a/pkg/linters/rbac/rules/rbacyaml/validate.go b/pkg/linters/rbac/rules/rbacyaml/validate.go index 5ff6017a..ee01bc96 100644 --- a/pkg/linters/rbac/rules/rbacyaml/validate.go +++ b/pkg/linters/rbac/rules/rbacyaml/validate.go @@ -18,6 +18,7 @@ package rbacyaml import ( "fmt" + "maps" "reflect" "regexp" "slices" @@ -316,6 +317,10 @@ func validateServiceAccounts(accounts []ServiceAccount, report reporter) { validateWhen(sa.When, where, report) + validateMetadataKeys(sa.Labels, where+".labels", false, report) + validateMetadataKeys(sa.Annotations, where+".annotations", true, report) + validateMetadataKeys(sa.RBACAnnotations, where+".rbacAnnotations", true, report) + if strings.HasPrefix(sa.Path, "/") || strings.HasSuffix(sa.Path, "/") || strings.Contains(sa.Path, "..") { report("%s: path must be a directory under templates/ without leading or trailing slashes, got %q", where, sa.Path) } @@ -528,3 +533,21 @@ var ( groupNameRe = regexp.MustCompile(`^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$`) resourceNameRe = regexp.MustCompile(`^(\*|[a-z0-9]([-a-z0-9.]*[a-z0-9])?)(/[a-z0-9]([-a-z0-9]*[a-z0-9])?)?$`) ) + +// qualifiedNameRe is a Kubernetes label or annotation key: an optional DNS prefix and a name. +var qualifiedNameRe = regexp.MustCompile(`^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?[A-Za-z0-9]([-A-Za-z0-9_.]*[A-Za-z0-9])?$`) + +// validateMetadataKeys checks label or annotation keys: the generator writes them unquoted, and +// the rbac.deckhouse.io and meta.helm.sh annotations belong to the generator and to Helm. +func validateMetadataKeys(m map[string]string, where string, annotations bool, report reporter) { + for _, k := range slices.Sorted(maps.Keys(m)) { + if len(k) > 316 || !qualifiedNameRe.MatchString(k) { + report("%s: %q is not a valid key ([prefix/]name, the name up to 63 characters of letters, digits, '-', '_' and '.')", where, k) + continue + } + + if annotations && (strings.HasPrefix(k, "rbac.deckhouse.io/") || strings.HasPrefix(k, "meta.helm.sh/")) { + report("%s: %q is set by the generator or by Helm, not by the declaration", where, k) + } + } +} diff --git a/pkg/linters/rbac/rules/sync.go b/pkg/linters/rbac/rules/sync.go index 9ea805ec..3c6b3e68 100644 --- a/pkg/linters/rbac/rules/sync.go +++ b/pkg/linters/rbac/rules/sync.go @@ -122,7 +122,7 @@ func (r *SyncRule) Check(_ context.Context) { } if overlay != "" { - declList.Errorf("%s lies in the edition overlay %s; the declaration describes the union of editions and belongs to modules// only -- CI merges the overlays over modules/ before linting, so a copy here would shadow it or go unseen. Only a person can close this: move the file", + declList.WithFix(manualFix("move the declaration out of the edition overlay")).Errorf("%s lies in the edition overlay %s; the declaration describes the union of editions and belongs to modules// only -- CI merges the overlays over modules/ before linting, so a copy here would shadow it or go unseen. Only a person can close this: move the file", rbacyaml.Filename, overlay) return @@ -130,11 +130,11 @@ func (r *SyncRule) Check(_ context.Context) { if err != nil { if content, readErr := os.ReadFile(rbacyaml.Path(modulePath)); readErr == nil && !strings.Contains(string(content), "apiVersion:") { - declList.Errorf("%s is not a declaration (no apiVersion): an rbac.yaml of an earlier shape that nothing reads; delete it and run `%s` to write the declaration from the render", rbacyaml.Filename, FixCommand) + declList.WithFix(manualFix("delete the rbac.yaml of an earlier shape")).Errorf("%s is not a declaration (no apiVersion): an rbac.yaml of an earlier shape that nothing reads; delete it and run `%s` to write the declaration from the render", rbacyaml.Filename, FixCommand) return } - declList.Errorf("%v; nothing is compared or generated until the declaration parses", err) + declList.WithFix(manualFix("make the declaration parse")).Errorf("%v; nothing is compared or generated until the declaration parses", err) return } @@ -145,13 +145,13 @@ func (r *SyncRule) Check(_ context.Context) { meta, err := readModuleMetadata(modulePath) if err != nil { - r.errorList.WithFilePath("module.yaml").Errorf("%v; nothing is compared or generated until it parses: its subsystems decide the aggregation of every system capability", err) + r.errorList.WithFilePath("module.yaml").WithFix(manualFix("make module.yaml parse")).Errorf("%v; nothing is compared or generated until it parses: its subsystems decide the aggregation of every system capability", err) return } if errs := rbacyaml.Validate(decl, crdScopes(crds)); len(errs) > 0 { for _, e := range errs { - declList.Errorf("%v; nothing is compared or generated until the declaration is valid", e) + declList.WithFix(manualFix("correct the declaration")).Errorf("%v; nothing is compared or generated until the declaration is valid", e) } return @@ -168,7 +168,7 @@ func (r *SyncRule) Check(_ context.Context) { Decl: decl, }) if err != nil { - declList.Errorf("cannot derive the RBAC objects from the declaration: %v", err) + declList.WithFix(manualFix("correct the declaration")).Errorf("cannot derive the RBAC objects from the declaration: %v", err) return } @@ -910,6 +910,10 @@ func compareFile(file generate.File, actual map[string]managedObject, module str func compareObject(expected generate.Object, actual storage.StoreObject, module string) []string { var out []string + if expected.Class == generate.ClassDeclared { + out = append(out, compareAnnotations(expected, actual)...) + } + id := expected.Identity() content := actual.Unstructured.UnstructuredContent() @@ -1241,7 +1245,7 @@ func (r *SyncRule) bootstrap(declList *errors.LintRuleErrorsList) { meta, err := readModuleMetadata(modulePath) if err != nil { - r.errorList.WithFilePath("module.yaml").Errorf("%v; the declaration is not written until it parses", err) + r.errorList.WithFilePath("module.yaml").WithFix(manualFix("make module.yaml parse")).Errorf("%v; the declaration is not written until it parses", err) return } @@ -1683,3 +1687,36 @@ func renderedTwin(object storage.StoreObject, produced []generate.Object, render return "" } + +// compareAnnotations compares the annotations of an object the declaration writes whole: a +// resource policy or a deploy hook dropped by a regeneration changes what Helm or werf do with it. +func compareAnnotations(expected generate.Object, actual storage.StoreObject) []string { + id := expected.Identity() + rendered := actual.Unstructured.GetAnnotations() + + var out []string + + for _, k := range slices.Sorted(maps.Keys(rendered)) { + if want, ok := expected.Annotations[k]; !ok { + out = append(out, fmt.Sprintf("%s: annotation %s is in the render but not declared", id, k)) + } else if want != rendered[k] { + out = append(out, fmt.Sprintf("%s: annotation %s is %q in the render, the declaration produces %q", id, k, rendered[k], want)) + } + } + + for _, k := range slices.Sorted(maps.Keys(expected.Annotations)) { + if _, ok := rendered[k]; !ok { + out = append(out, fmt.Sprintf("%s: annotation %s is declared but absent from the render", id, k)) + } + } + + return out +} + +// manualFix is the fix of a finding only a person can close: nothing is generated while it +// stands, so `--fix` must not report success (it fails with what to do). +func manualFix(what string) errors.AutofixFunc { + return func() error { + return fmt.Errorf("nothing was generated: %s first", what) + } +} diff --git a/pkg/linters/rbac/rules/sync_regressions_test.go b/pkg/linters/rbac/rules/sync_regressions_test.go index 74a129ed..01b4f0e0 100644 --- a/pkg/linters/rbac/rules/sync_regressions_test.go +++ b/pkg/linters/rbac/rules/sync_regressions_test.go @@ -400,3 +400,24 @@ func TestSyncRegression_ReplacedCopyIsReported(t *testing.T) { assert.Contains(t, got, "templates/cert-manager/rbac-to-us.yaml does not match rbac.yaml: d8-cert-manager/Role/access-to-cert-manager-prometheus-metrics grants what d8-cert-manager/Role/access-to-cert-manager grants, and both render") assert.Contains(t, got, "d8-cert-manager/RoleBinding/access-to-cert-manager-prometheus-metrics binds access-to-cert-manager-prometheus-metrics, the old copy of d8-cert-manager/Role/access-to-cert-manager") } + +// An annotation on an object the declaration writes whole is compared: a resource policy the +// declaration does not carry would be dropped by the next regeneration (regression hunt, B7). +func TestSyncRegression_AnnotationsAreCompared(t *testing.T) { + resetFixState() + t.Cleanup(resetFixState) + + modulePath := syncModuleDir(t) + model := syncModel(t, modulePath) + writeGenerated(t, modulePath, model) + + errorList := runSync(t, modulePath, renderedFrom(t, model, func(o *generate.Object) bool { + if o.Kind == "ServiceAccount" && o.Name == "cainjector" { + o.Annotations = map[string]string{"helm.sh/resource-policy": "keep"} + } + + return true + })) + + assert.Contains(t, strings.Join(texts(errorList), "\n"), "ServiceAccount/cainjector: annotation helm.sh/resource-policy is in the render but not declared") +} diff --git a/pkg/linters/rbac/rules/sync_test.go b/pkg/linters/rbac/rules/sync_test.go index 0d707078..653fdeff 100644 --- a/pkg/linters/rbac/rules/sync_test.go +++ b/pkg/linters/rbac/rules/sync_test.go @@ -18,6 +18,7 @@ package rules import ( "context" + "io/fs" "os" "path/filepath" "strings" @@ -1494,7 +1495,40 @@ func TestSync_BrokenModuleYAMLStops(t *testing.T) { got := texts(errorList) require.Len(t, got, 1, "got: %v", got) assert.Contains(t, got[0], "parse module.yaml") - assert.Empty(t, errorList.GetFixes()) + + // The fix writes nothing and fails, so `--fix` does not exit 0 over a module it left alone. + before := snapshotTree(t, modulePath) + + for _, fix := range errorList.GetFixes() { + fix() + } + + assert.True(t, errorList.ContainsFailedFixes()) + assert.Equal(t, before, snapshotTree(t, modulePath)) +} + +// A declaration the linter refuses carries a failing fix: nothing is generated and `--fix` +// reports it rather than exiting 0 (regression hunt, B5). +func TestSync_InvalidDeclarationFailsTheFix(t *testing.T) { + resetFixState() + t.Cleanup(resetFixState) + + modulePath := syncModuleDir(t) + model := syncModel(t, modulePath) + + decl, err := os.ReadFile(filepath.Join(modulePath, "rbac.yaml")) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(modulePath, "rbac.yaml"), []byte(strings.Replace(string(decl), "serviceAccounts:\n", "serviceAccounts:\n - name: wrong-name\n path: a/b\n", 1)), 0o600)) + + errorList := runSync(t, modulePath, renderedFrom(t, model, nil)) + require.NotEmpty(t, errorList.GetFixes()) + assert.Contains(t, strings.Join(texts(errorList), "\n"), "the placement rule wants the account named") + + for _, fix := range errorList.GetFixes() { + fix() + } + + assert.True(t, errorList.ContainsFailedFixes()) } // A generated file whose objects are all under a false condition is found on disk and deleted @@ -1770,3 +1804,23 @@ func TestSync_ContractOneHandAddedGeneratorNamedIsForeign(t *testing.T) { assert.Contains(t, strings.Join(messages, "\n"), "ClusterRole/d8:cert-manager:hand-extra") } + +// snapshotTree maps every file under dir to its content. +func snapshotTree(t *testing.T, dir string) map[string]string { + t.Helper() + + out := map[string]string{} + + require.NoError(t, filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + + data, err := os.ReadFile(path) + out[path] = string(data) + + return err + })) + + return out +} From 29e6d898036f3d0b50215bddb543bff78390484e Mon Sep 17 00:00:00 2001 From: Ivan Zvyagintsev Date: Thu, 24 Sep 2026 11:03:33 +0300 Subject: [PATCH 39/58] rbac: bootstrap reads the template conditions around each object The render only holds what rendered for the linter's values; the importer now also reads the template text around each object: - {{ if X }} becomes when: X, an {{ else }} not (X), nested blocks an and; a condition with a template variable is a TODO, and so is an account or access entry whose objects render under different conditions. access[] gets `when`. - Objects inside range, with or define, objects rendered by an include of a named template (helm_lib) and roles without rules stay hand-written; bindings to them are not folded into the declaration. - An RBAC object the text holds but no render showed is named in the header and fails the fix, so the regeneration does not drop it. - prometheusAccess keeps the scraper gate as `when`; the fold note is written once. - The bootstrap fix names what the linter would refuse in the written file; the scope TODO says that Cluster drops the namespace levels. Signed-off-by: Ivan Zvyagintsev --- pkg/linters/rbac/README.md | 21 +- pkg/linters/rbac/rules/bootstrap/bootstrap.go | 126 ++++++- .../rbac/rules/bootstrap/bootstrap_test.go | 2 +- .../rbac/rules/bootstrap/conditions.go | 340 ++++++++++++++++++ .../rbac/rules/bootstrap/conditions_test.go | 200 +++++++++++ pkg/linters/rbac/rules/generate/model.go | 8 +- pkg/linters/rbac/rules/rbacyaml/types.go | 4 +- pkg/linters/rbac/rules/rbacyaml/validate.go | 2 + pkg/linters/rbac/rules/sync.go | 124 ++++++- .../rbac/rules/sync_regressions_test.go | 69 ++++ 10 files changed, 870 insertions(+), 26 deletions(-) create mode 100644 pkg/linters/rbac/rules/bootstrap/conditions.go create mode 100644 pkg/linters/rbac/rules/bootstrap/conditions_test.go diff --git a/pkg/linters/rbac/README.md b/pkg/linters/rbac/README.md index c301dd7d..32994baf 100644 --- a/pkg/linters/rbac/README.md +++ b/pkg/linters/rbac/README.md @@ -1452,6 +1452,7 @@ prometheusAccess: # Arbitrary subjects: clusterRules -> templates/[/]rbac-for-us.yaml, namespaceRules -> templates/[/]rbac-to-us.yaml access: - name: admin-kubeconfig + when: .Values.certManager.adminKubeconfig # optional; wraps the role and the binding, as for an account subjects: - kind: Group name: kubeadm:cluster-admins @@ -1624,11 +1625,23 @@ with a `TODO` wherever a decision is still theirs (a resource without a CRD whos cannot know, a CRD nobody grants, a namespaced resource granted cluster-wide) and a note on top for every object the generator will name differently or cannot describe. An entry whose CRD is in `crds/` carries no `scope`: the CRD states it; an external resource whose scope is not known gets -`scope: "TODO: Namespaced or Cluster"`. A grant limited to `resourceNames` is never widened to every +`scope: "TODO: Namespaced or Cluster (Cluster drops the namespace levels)"`. A grant limited to `resourceNames` is never widened to every object: it is left out and named in a note. A role granting `*` verbs or API groups, which the format -refuses, is listed as hand-written with the reason. The fix that writes the file keeps the finding -while a `TODO` is left in it, and a `--fix` run with any fix left open exits non-zero whatever the -level of its finding. Nothing is written into an edition overlay. Review +refuses, is listed as hand-written with the reason. + +The render only holds what rendered for the linter's values, so the importer also reads the template +text around each object. An object wrapped in `{{ if X }}` gets `when: X` (an `{{ else }}` branch +`not (X)`, nested blocks an `and`); a condition that uses a template variable becomes a `TODO`, and so +does an account or access entry whose role and binding render under different conditions. An object +inside `{{ range }}`, `{{ with }}` or `{{ define }}`, one rendered by an include of a named template +(`helm_lib_*`), and a role without rules stay hand-written. An object the text holds under a +condition false for these values is in no render: the header names it, and the fix fails, so the +regeneration does not drop it in silence. A block inside an object -- a rule under its own `{{ if }}` +-- is noted. The Prometheus scrape binding keeps its gate as `prometheusAccess.when`. + +The fix that writes the file keeps the finding while a `TODO` is left in it or while the linter would +refuse the written file (both are named in the fix error), and a `--fix` run with any fix left open +exits non-zero whatever the level of its finding. Nothing is written into an edition overlay. Review it, resolve the TODOs, then run `--fix` again to regenerate the templates from it. From then on `rbac.yaml` is the source. diff --git a/pkg/linters/rbac/rules/bootstrap/bootstrap.go b/pkg/linters/rbac/rules/bootstrap/bootstrap.go index 9e986ff1..94d185ae 100644 --- a/pkg/linters/rbac/rules/bootstrap/bootstrap.go +++ b/pkg/linters/rbac/rules/bootstrap/bootstrap.go @@ -48,6 +48,14 @@ type Object struct { // Aggregated marks a ClusterRole with an aggregationRule: its rules belong to the aggregation // controller, and the declaration has no place for the selectors. Aggregated bool + + // The template blocks around the object, from its template's text (TemplateDocs, Locate): + // When is the condition it renders under, Unmanageable why the declaration cannot carry that, + // Partial that a block opens inside it. Located is false when the text did not tell. + When string + Unmanageable string + Partial bool + Located bool } // Input is what the render says about the module. @@ -59,6 +67,9 @@ type Input struct { Objects []Object // CRDs maps group/plural to scope for the CRDs under crds/. CRDs map[string]string + // Unrendered are the RBAC objects the template text holds that no render showed: under a + // condition false for the linter's values. The declaration does not hold them. + Unrendered []string } // Result is the declaration with the reader's homework. @@ -125,6 +136,8 @@ type builder struct { notes []string unmanaged []string decl *rbacyaml.Declaration + // prometheusFolded is set once the note on folding several scrape Roles is written. + prometheusFolded bool } func (b *builder) note(format string, args ...any) { @@ -173,6 +186,11 @@ func Build(in Input) Result { b := &builder{in: in, res: map[[2]string]*resourceAcc{}, restricted: map[[2]string][]string{}, texts: map[string]rbacyaml.CapabilityText{}, lineages: map[string]struct{}{}, used: map[string]struct{}{}, decl: &rbacyaml.Declaration{APIVersion: rbacyaml.APIVersionV1Alpha1}} + for _, u := range in.Unrendered { + b.note("%s is in the templates but did not render with the linter's values, so this declaration does not hold it -- add it (with its `when`), or lint with --values-file values that render it, before the templates are regenerated", u) + } + + b.templateBlocks() b.capabilitiesAndLegacy() b.serviceAccounts() b.otherBindings() @@ -190,7 +208,9 @@ var generatedModuleConfigVerbs = map[string][]string{ } // scopeTODO is the scope bootstrap writes for an external resource whose scope it cannot know. -const scopeTODO = "TODO: Namespaced or Cluster" +// A Cluster-scoped resource cannot keep namespace levels: the text says so, since the entry the +// person decides on may hold them. +const scopeTODO = "TODO: Namespaced or Cluster (Cluster drops the namespace levels)" // wildcardGrant reports whether any rule grants "*" verbs or API groups, which the declaration // refuses at every level. @@ -256,9 +276,35 @@ func (b *builder) addRules(sectionName, level string, rules []rbacv1.PolicyRule, } } +// templateBlocks keeps out what the declaration cannot describe because of the template around +// it: an object in a range, a with or a define, one rendered by a named template of a library, a +// role without rules. A block inside an object is noted: its rules depend on the values. +func (b *builder) templateBlocks() { + for _, o := range b.in.Objects { + switch { + case o.Unmanageable != "": + b.unmanage(o, o.Unmanageable) + b.mark(o) + case (b.ownClusterRole(o) || o.Kind == "Role") && len(o.Rules) == 0: + b.unmanage(o, "has no rules with these values; the declaration writes no role without them") + b.mark(o) + case o.Partial: + b.note("%s %s (%s) has a template block inside it: part of it depends on the values, and the declaration holds what rendered with the linter's values -- put `when` on the rules the block gates", o.Kind, o.Name, o.Path) + } + } +} + +// conditional notes a capability or a legacy role under a condition: resources[] have no `when`, +// the regenerated role renders for every value. +func (b *builder) conditional(o Object) { + if o.When != "" { + b.note("ClusterRole %s renders under `%s`; resources[] capabilities and legacy roles render unconditionally -- with the condition false, the regenerated role grants what the module does not grant today", o.Name, o.When) + } +} + func (b *builder) capabilitiesAndLegacy() { for _, o := range b.in.Objects { - if o.Kind != "ClusterRole" { + if o.Kind != "ClusterRole" || b.isUsed(o) { continue } @@ -277,6 +323,7 @@ func (b *builder) capabilitiesAndLegacy() { b.rename("ClusterRole", o.Name, "d8:user-authz:"+b.in.Module+":"+rbaccontract.LegacyKebab(level)) b.addRules("legacy", level, o.Rules, false) + b.conditional(o) b.mark(o) continue @@ -303,6 +350,7 @@ func (b *builder) capabilitiesAndLegacy() { } b.addRules(lineage, level, o.Rules, lineage == rbaccontract.LineageSystem) + b.conditional(o) b.mark(o) if lineage == rbaccontract.LineageSystem { @@ -399,6 +447,10 @@ var pathRe = regexp.MustCompile(`^templates/(?:(.*)/)?rbac-for-us\.yaml$`) func (b *builder) serviceAccounts() { for _, sa := range b.byKind("ServiceAccount") { + if b.isUsed(sa) { + continue + } + if b.ns(sa) != b.in.Namespace { b.unmanage(sa, "outside the module namespace") continue @@ -424,6 +476,7 @@ func (b *builder) serviceAccounts() { } e.Annotations = copyAnnotations(sa.Annotations) + e.When = sa.When b.mark(sa) @@ -431,9 +484,16 @@ func (b *builder) serviceAccounts() { // The roles and bindings of the account carry one set of annotations in the format; the // first object's set is kept and a differing one is noted. - var rbacFrom string + var ( + rbacFrom string + apart []string + ) keep := func(o Object) { + if o.When != sa.When { + apart = append(apart, fmt.Sprintf("%s %s renders under `%s`", o.Kind, o.Name, orAlways(o.When))) + } + switch { case rbacFrom == "": rbacFrom = o.Kind + " " + o.Name @@ -449,7 +509,7 @@ func (b *builder) serviceAccounts() { } cr, found := b.clusterRole(crb.RoleRef.Name) - exclusive := found && b.ownClusterRole(cr) && len(b.bindingsOf(cr.Name)) == 1 + exclusive := found && !b.isUsed(cr) && b.ownClusterRole(cr) && len(b.bindingsOf(cr.Name)) == 1 switch { case exclusive && cr.Name == clusterName && e.ClusterRules == nil: @@ -487,7 +547,7 @@ func (b *builder) serviceAccounts() { continue } - if role, ok := b.role(b.ns(rb), rb.RoleRef.Name); ok && b.ns(rb) == b.in.Namespace && e.NamespaceRules == nil && rb.RoleRef.Kind == "Role" { + if role, ok := b.role(b.ns(rb), rb.RoleRef.Name); ok && !b.isUsed(role) && b.ns(rb) == b.in.Namespace && e.NamespaceRules == nil && rb.RoleRef.Kind == "Role" { e.NamespaceRules = policyRules(role.Rules) b.rename("Role", role.Name, sa.Name) b.rename("RoleBinding", rb.Name, sa.Name) @@ -532,6 +592,12 @@ func (b *builder) serviceAccounts() { b.mark(cr) } + // The declaration puts every object of an account under the account's condition; a role or + // a binding under another one is a decision for a person. + if len(apart) > 0 { + e.When = fmt.Sprintf("TODO: the account renders under `%s`, but %s; the declaration puts all of them under one condition", orAlways(sa.When), strings.Join(apart, ", ")) + } + if n := len(e.ExtraClusterRoles); n > 1 { b.note("ServiceAccount %s has %d ClusterRoles of its own besides d8:%s:%s; they are kept separate as extraClusterRoles -- merge them into clusterRules if nothing binds them separately", sa.Name, n, b.in.Module, sa.Name) } @@ -549,13 +615,13 @@ func (b *builder) otherBindings() { } cr, found := b.clusterRole(crb.RoleRef.Name) - if !found || !b.ownClusterRole(cr) { - b.unmanage(crb, fmt.Sprintf("binds %s, which is not a plain ClusterRole of this module", crb.RoleRef.Name)) + if !found || !b.ownClusterRole(cr) || b.isUsed(cr) { + b.unmanage(crb, fmt.Sprintf("binds %s, which is not a plain ClusterRole of this module the declaration describes", crb.RoleRef.Name)) continue } name := strings.TrimPrefix(cr.Name, "d8:"+b.in.Module+":") - b.decl.Access = append(b.decl.Access, rbacyaml.Access{Name: name, Path: componentOf(cr.Path, "rbac-for-us.yaml"), Subjects: subjects(crb.Subjects), ClusterRules: policyRules(cr.Rules)}) + b.decl.Access = append(b.decl.Access, rbacyaml.Access{Name: name, Path: componentOf(cr.Path, "rbac-for-us.yaml"), When: accessWhen(cr, crb), Subjects: subjects(crb.Subjects), ClusterRules: policyRules(cr.Rules)}) b.rename("ClusterRoleBinding", crb.Name, "d8:"+b.in.Module+":"+name) b.rename("ClusterRole", cr.Name, "d8:"+b.in.Module+":"+name) b.mark(cr) @@ -568,7 +634,7 @@ func (b *builder) otherBindings() { } role, ok := b.role(b.ns(rb), rb.RoleRef.Name) - if !ok || b.ns(rb) != b.in.Namespace || rb.RoleRef.Kind != "Role" { + if !ok || b.isUsed(role) || b.ns(rb) != b.in.Namespace || rb.RoleRef.Kind != "Role" { b.unmanage(rb, fmt.Sprintf("binds %s/%s outside the module's own Roles", rb.RoleRef.Kind, rb.RoleRef.Name)) continue } @@ -593,7 +659,7 @@ func (b *builder) otherBindings() { } } - b.decl.Access = append(b.decl.Access, rbacyaml.Access{Name: name, Path: path, Subjects: subjects(rb.Subjects), NamespaceRules: policyRules(role.Rules)}) + b.decl.Access = append(b.decl.Access, rbacyaml.Access{Name: name, Path: path, When: accessWhen(role, rb), Subjects: subjects(rb.Subjects), NamespaceRules: policyRules(role.Rules)}) b.rename("Role", role.Name, rbaccontract.AccessRoleName(b.in.Module, path, name)) b.rename("RoleBinding", rb.Name, rbaccontract.AccessRoleName(b.in.Module, path, name)) b.mark(role) @@ -624,15 +690,28 @@ func (b *builder) prometheus(role, rb Object) bool { } } - if b.decl.PrometheusAccess == nil { - b.decl.PrometheusAccess = &rbacyaml.PrometheusAccess{} - } else { + first := b.decl.PrometheusAccess == nil + if first { + b.decl.PrometheusAccess = &rbacyaml.PrometheusAccess{When: rb.When} + + if !rb.Located { + b.note("prometheusAccess: the template of RoleBinding %s could not be read, so whether it gated the scraper binding is unknown; add `when: .Values.global.enabledModules | has \"prometheus\"` if it did", rb.Name) + } + } else if !b.prometheusFolded { + b.prometheusFolded = true b.note("several Prometheus access Roles fold into one prometheusAccess (Role access-to-%s)", b.in.Module) - b.note("prometheusAccess: the render does not show whether the template gated the scraper binding on the prometheus module; add `when: .Values.global.enabledModules | has \"prometheus\"` if it did") } pa := b.decl.PrometheusAccess + if !first && rb.When != pa.When && !strings.HasPrefix(pa.When, "TODO") { + pa.When = fmt.Sprintf("TODO: the scraper bindings render under different conditions (`%s`, `%s`); prometheusAccess has one", orAlways(pa.When), orAlways(rb.When)) + } + + if role.When != "" { + b.note("Role %s renders under `%s`; prometheusAccess writes the Role unconditionally and gates only the binding", role.Name, role.When) + } + for _, r := range role.Rules { for _, res := range r.Resources { switch strings.TrimSuffix(res, "/prometheus-metrics") { @@ -862,3 +941,22 @@ func copyAnnotations(in map[string]string) map[string]string { return out } + +// orAlways names an empty condition in a note. +func orAlways(when string) string { + if when == "" { + return "no condition" + } + + return when +} + +// accessWhen is the condition of an access entry: its role and its binding render together, or +// the entry holds a decision for a person. +func accessWhen(role, binding Object) string { + if role.When == binding.When { + return binding.When + } + + return fmt.Sprintf("TODO: %s %s renders under `%s`, %s %s under `%s`; the access entry has one condition", role.Kind, role.Name, orAlways(role.When), binding.Kind, binding.Name, orAlways(binding.When)) +} diff --git a/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go b/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go index 54ccc79f..c3f913ae 100644 --- a/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go +++ b/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go @@ -294,7 +294,7 @@ func TestBuild_TODOsAndUnmanaged(t *testing.T) { } assert.Equal(t, "Namespaced", byKey["/pods"].Scope, "a well-known core resource gets its scope") - assert.Equal(t, "TODO: Namespaced or Cluster", byKey["trivy.deckhouse.io/vulnerabilityreports"].Scope, "an unknown one is a TODO value for the author (review of #479, reply to finding 9)") + assert.Equal(t, "TODO: Namespaced or Cluster (Cluster drops the namespace levels)", byKey["trivy.deckhouse.io/vulnerabilityreports"].Scope, "an unknown one is a TODO value for the author (review of #479, reply to finding 9)") assert.Contains(t, byKey["deckhouse.io/things"].NoAccess, "TODO", "a CRD nobody grants is an undecided entry") require.Len(t, got.Decl.ServiceAccounts, 1) diff --git a/pkg/linters/rbac/rules/bootstrap/conditions.go b/pkg/linters/rbac/rules/bootstrap/conditions.go new file mode 100644 index 00000000..48f84aad --- /dev/null +++ b/pkg/linters/rbac/rules/bootstrap/conditions.go @@ -0,0 +1,340 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package bootstrap + +import ( + "regexp" + "strings" +) + +// Doc is one YAML document of a template and the template blocks around it: what the render +// cannot show, because it only holds what rendered for the linter's values. +type Doc struct { + // Kind, Name and Namespace are the literal values of the document; Name is empty when the + // template computes it. + Kind, Name, Namespace string + // Library marks a document without an object of its own that includes a named template: the + // objects it renders come from helm_lib or another chart. + Library bool + // When is the condition under which the document renders, as a `when` expression. + When string + // Unmanageable says why the declaration cannot carry the condition: a range, a with, a define. + Unmanageable string + // Partial marks a block that opens inside the object: part of it is conditional. + Partial bool +} + +var ( + docSeparatorRe = regexp.MustCompile(`(?m)^---[ \t]*(#.*)?$`) + actionRe = regexp.MustCompile(`(?s)\{\{-?(.*?)-?\}\}`) + docKindRe = regexp.MustCompile(`(?m)^kind:[ \t]*(\S+)[ \t]*$`) + docMetadataRe = regexp.MustCompile(`(?m)^metadata:[ \t]*$`) + docFieldRe = regexp.MustCompile(`^ (name|namespace):[ \t]*(.+?)[ \t]*$`) + includeRe = regexp.MustCompile(`\{\{-?\s*(include|template)\s+"`) + variableRe = regexp.MustCompile(`\$[A-Za-z_]`) +) + +// frame is an open template block. For an if, cur is the condition of the branch being read and +// prior the conditions of the branches before it. +type frame struct { + kind string + cur string + prior []string +} + +type action struct { + offset int + words []string + body string +} + +// TemplateDocs reads the documents of a template and the blocks around each. It is a reader of +// the common shapes -- an object wrapped in {{ if }}, {{ else }}, {{ range }}, {{ with }} -- not a +// template engine: what it cannot follow ends up as Unmanageable or as a TODO in the declaration. +func TemplateDocs(text string) []Doc { + text = strings.ReplaceAll(text, "\r\n", "\n") + + actions := templateActions(text) + + var ( + out []Doc + stack []frame + next int + start int + ) + + bounds := docSeparatorRe.FindAllStringIndex(text, -1) + bounds = append(bounds, []int{len(text), len(text)}) + + for _, b := range bounds { + doc := text[start:b[0]] + docStart, docEnd := start, b[0] + start = b[1] + + d := Doc{} + at := docEnd + + if m := docKindRe.FindStringSubmatchIndex(doc); m != nil { + d.Kind = doc[m[2]:m[3]] + at = docStart + m[0] + } + + // The blocks open at the object's kind line are the object's condition. + for next < len(actions) && actions[next].offset < at { + stack = apply(stack, actions[next]) + next++ + } + + if d.Kind == "" { + if includeRe.MatchString(doc) && strings.TrimSpace(actionRe.ReplaceAllString(doc, "")) == "" { + d.Library = true + d.When, d.Unmanageable = conditionOf(stack) + out = append(out, d) + } + + for next < len(actions) && actions[next].offset < docEnd { + stack = apply(stack, actions[next]) + next++ + } + + continue + } + + d.Name, d.Namespace = metadataOf(doc) + d.When, d.Unmanageable = conditionOf(stack) + + for next < len(actions) && actions[next].offset < docEnd { + if w := actions[next].words; len(w) > 0 && (w[0] == "if" || w[0] == "range" || w[0] == "with") { + d.Partial = true + } + + stack = apply(stack, actions[next]) + next++ + } + + out = append(out, d) + } + + return out +} + +func templateActions(text string) []action { + var out []action + + for _, m := range actionRe.FindAllStringSubmatchIndex(text, -1) { + body := strings.TrimSpace(text[m[2]:m[3]]) + if strings.HasPrefix(body, "/*") { + continue + } + + out = append(out, action{offset: m[0], words: strings.Fields(body), body: body}) + } + + return out +} + +func apply(stack []frame, a action) []frame { + if len(a.words) == 0 { + return stack + } + + switch a.words[0] { + case "if": + return append(stack, frame{kind: "if", cur: strings.TrimSpace(strings.TrimPrefix(a.body, "if"))}) + case "range", "with", "define", "block": + return append(stack, frame{kind: a.words[0]}) + case "else": + if len(stack) == 0 { + return stack + } + + top := &stack[len(stack)-1] + if top.kind != "if" { + // {{ else }} of a range or a with: still not something the declaration expresses. + return stack + } + + top.prior = append(top.prior, top.cur) + + rest := strings.TrimSpace(strings.TrimPrefix(a.body, "else")) + switch { + case strings.HasPrefix(rest, "if "): + top.cur = strings.TrimSpace(strings.TrimPrefix(rest, "if")) + case rest == "": + top.cur = "" + default: + // else with ...: the dot changes. + top.kind = "with" + } + case "end": + if len(stack) > 0 { + return stack[:len(stack)-1] + } + } + + return stack +} + +// conditionOf turns the open blocks into a `when`: an if branch is its condition, an else the +// negation of the branches before it, nested blocks an `and` of them. +func conditionOf(stack []frame) (string, string) { + var parts []string + + for _, f := range stack { + switch f.kind { + case "range": + return "", "rendered inside {{ range }}, which the declaration cannot express" + case "with": + return "", "rendered inside {{ with }}, which changes the dot the declaration's `when` is written against" + case "define", "block": + return "", "rendered from a named template ({{ define }}), which the declaration cannot express" + } + + for _, p := range f.prior { + parts = append(parts, "not ("+unwrap(p)+")") + } + + if f.cur != "" { + parts = append(parts, f.cur) + } + } + + var when string + + switch len(parts) { + case 0: + return "", "" + case 1: + when = parts[0] + default: + for i, p := range parts { + if !strings.HasPrefix(p, "not (") { + parts[i] = "(" + unwrap(p) + ")" + } + } + + when = "and " + strings.Join(parts, " ") + } + + // A variable of the template does not exist where the generator writes the condition. + if variableRe.MatchString(when) || strings.Contains(when, "{{") || strings.Contains(when, "}}") { + return "TODO: " + when + " -- the template condition uses a variable of the template; write it against the root values", "" + } + + return when, "" +} + +func metadataOf(doc string) (string, string) { + m := docMetadataRe.FindStringIndex(doc) + if m == nil { + return "", "" + } + + var name, namespace string + + for _, line := range strings.Split(doc[m[1]:], "\n")[1:] { + if !strings.HasPrefix(line, " ") { + break + } + + f := docFieldRe.FindStringSubmatch(line) + if f == nil { + continue + } + + value := strings.Trim(f[2], `"'`) + if strings.Contains(value, "{{") { + value = "" + } + + switch { + case f[1] == "name" && name == "": + name = value + case f[1] == "namespace" && namespace == "": + namespace = value + } + } + + return name, namespace +} + +// Locate finds the document of a rendered object among the documents of its template. An object +// found in no document of its own kind but in a file that includes a named template came from +// that template (helm_lib, typically). ok is false when the text does not tell. +func Locate(docs []Doc, o Object) (Doc, bool) { + var byName, templated []Doc + + for _, d := range docs { + if d.Kind != o.Kind { + continue + } + + switch d.Name { + case o.Name: + byName = append(byName, d) + case "": + templated = append(templated, d) + } + } + + for _, set := range [][]Doc{byName, templated} { + if len(set) == 0 { + continue + } + + // Several candidates are one answer only when their blocks agree. + for _, d := range set[1:] { + if d.When != set[0].When || d.Unmanageable != set[0].Unmanageable { + return Doc{}, false + } + } + + return set[0], true + } + + for _, d := range docs { + if d.Library { + return d, true + } + } + + return Doc{}, false +} + +// unwrap drops parentheses around a whole expression: (a | b) -> a | b. +func unwrap(expr string) string { + for len(expr) > 1 && expr[0] == '(' && expr[len(expr)-1] == ')' { + depth := 0 + + for i, c := range expr { + switch c { + case '(': + depth++ + case ')': + depth-- + } + + if depth == 0 && i < len(expr)-1 { + return expr // the first parenthesis closes before the end: (a) and (b) + } + } + + expr = strings.TrimSpace(expr[1 : len(expr)-1]) + } + + return expr +} diff --git a/pkg/linters/rbac/rules/bootstrap/conditions_test.go b/pkg/linters/rbac/rules/bootstrap/conditions_test.go new file mode 100644 index 00000000..8ff82300 --- /dev/null +++ b/pkg/linters/rbac/rules/bootstrap/conditions_test.go @@ -0,0 +1,200 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package bootstrap + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + rbacv1 "k8s.io/api/rbac/v1" + + "github.com/deckhouse/dmt/pkg/linters/rbac/rules/rbacyaml" +) + +const conditionalTemplate = `{{- if .Values.m.internal.enabled }} +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: plain + namespace: d8-m +{{- if .Values.m.extra }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: d8:m:nested +rules: [] +{{- else }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: d8:m:otherwise +rules: +{{- if .Values.m.more }} +- apiGroups: [""] + resources: [pods] + verbs: [get] +{{- end }} +{{- end }} +{{- end }} +{{- range $name := .Values.m.names }} +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ $name }} +{{- end }} +{{- $ns := .Values.m.ns }} +{{- if $ns }} +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: by-variable +{{- end }} +--- +{{ include "helm_lib_kube_rbac_proxy" . }} +` + +// The template blocks around an object become its `when`, or the reason the declaration cannot +// carry them (regression hunt, B1 and B6). +func TestTemplateDocs(t *testing.T) { + docs := TemplateDocs(conditionalTemplate) + + byName := map[string]Doc{} + for _, d := range docs { + byName[d.Kind+"/"+d.Name] = d + } + + assert.Equal(t, ".Values.m.internal.enabled", byName["ServiceAccount/plain"].When) + assert.Equal(t, "d8-m", byName["ServiceAccount/plain"].Namespace) + assert.Equal(t, "and (.Values.m.internal.enabled) (.Values.m.extra)", byName["ClusterRole/d8:m:nested"].When) + assert.Equal(t, "and (.Values.m.internal.enabled) not (.Values.m.extra)", byName["ClusterRole/d8:m:otherwise"].When) + assert.True(t, byName["ClusterRole/d8:m:otherwise"].Partial, "a rule under its own block") + assert.False(t, byName["ClusterRole/d8:m:nested"].Partial) + assert.Contains(t, byName["ServiceAccount/"].Unmanageable, "{{ range }}") + assert.True(t, strings.HasPrefix(byName["ServiceAccount/by-variable"].When, "TODO: $ns"), byName["ServiceAccount/by-variable"].When) + + var library []Doc + + for _, d := range docs { + if d.Library { + library = append(library, d) + } + } + + require.Len(t, library, 1) + assert.Empty(t, library[0].When) + + // The written condition is a valid `when`. + decl := &rbacyaml.Declaration{APIVersion: rbacyaml.APIVersionV1Alpha1, ServiceAccounts: []rbacyaml.ServiceAccount{{Name: "m", When: byName["ClusterRole/d8:m:otherwise"].When}}} + assert.Empty(t, rbacyaml.Validate(decl, nil)) +} + +func TestLocate(t *testing.T) { + docs := TemplateDocs(conditionalTemplate) + + d, ok := Locate(docs, Object{Kind: "ClusterRole", Name: "d8:m:nested"}) + require.True(t, ok) + assert.Equal(t, "and (.Values.m.internal.enabled) (.Values.m.extra)", d.When) + + // A name the template computes matches the templated document of the kind. + d, ok = Locate(docs, Object{Kind: "ServiceAccount", Name: "from-values"}) + require.True(t, ok) + assert.Contains(t, d.Unmanageable, "range") + + // An object of a kind the text does not hold came from the include. + d, ok = Locate(docs, Object{Kind: "ClusterRoleBinding", Name: "d8:m:rbac-proxy"}) + require.True(t, ok) + assert.True(t, d.Library) + + _, ok = Locate(nil, Object{Kind: "Role", Name: "x"}) + assert.False(t, ok) +} + +// The conditions reach the declaration: an account and its objects under one condition, an access +// entry, the scraper gate; a mismatch is a TODO (regression hunt, B1, B9 and B10). +func TestBuild_TemplateConditions(t *testing.T) { + labels := map[string]string{"module": "m"} + sa := []rbacv1.Subject{{Kind: "ServiceAccount", Name: "m", Namespace: "d8-m"}} + scraper := []rbacv1.Subject{{Kind: "User", Name: "d8-monitoring:scraper"}, {Kind: "ServiceAccount", Name: "prometheus", Namespace: "d8-monitoring"}} + nodes := []rbacv1.PolicyRule{{APIGroups: []string{""}, Resources: []string{"nodes"}, Verbs: []string{"get"}}} + metrics := []rbacv1.PolicyRule{{APIGroups: []string{"apps"}, Resources: []string{"deployments/prometheus-metrics"}, ResourceNames: []string{"m"}, Verbs: []string{"get"}}} + gate := `.Values.global.enabledModules | has "prometheus"` + + got := Build(Input{Module: "m", Namespace: "d8-m", Objects: []Object{ + {Kind: "ServiceAccount", Name: "m", Path: "templates/rbac-for-us.yaml", Labels: labels, When: ".Values.m.on", Located: true}, + {Kind: "ClusterRole", Name: "d8:m:m", Path: "templates/rbac-for-us.yaml", Labels: labels, Rules: nodes, When: ".Values.m.on", Located: true}, + {Kind: "ClusterRoleBinding", Name: "d8:m:m", Path: "templates/rbac-for-us.yaml", Labels: labels, When: ".Values.m.on", Located: true, + RoleRef: rbacv1.RoleRef{Kind: "ClusterRole", Name: "d8:m:m"}, Subjects: sa}, + {Kind: "ClusterRole", Name: "d8:m:reader", Path: "templates/rbac-for-us.yaml", Labels: labels, Rules: nodes, When: ".Values.m.reader", Located: true}, + {Kind: "ClusterRoleBinding", Name: "d8:m:reader", Path: "templates/rbac-for-us.yaml", Labels: labels, Located: true, + RoleRef: rbacv1.RoleRef{Kind: "ClusterRole", Name: "d8:m:reader"}, Subjects: []rbacv1.Subject{{Kind: "Group", Name: "g"}}}, + {Kind: "ClusterRole", Name: "d8:m:empty", Path: "templates/rbac-for-us.yaml", Labels: labels, Located: true}, + {Kind: "ClusterRoleBinding", Name: "d8:m:empty", Path: "templates/rbac-for-us.yaml", Labels: labels, Located: true, + RoleRef: rbacv1.RoleRef{Kind: "ClusterRole", Name: "d8:m:empty"}, Subjects: []rbacv1.Subject{{Kind: "Group", Name: "g"}}}, + {Kind: "Role", Name: "access-to-m-prometheus-metrics", Namespace: "d8-m", Path: "templates/rbac-to-us.yaml", Labels: labels, Rules: metrics, Located: true}, + {Kind: "RoleBinding", Name: "access-to-m-prometheus-metrics", Namespace: "d8-m", Path: "templates/rbac-to-us.yaml", Labels: labels, When: gate, Located: true, + RoleRef: rbacv1.RoleRef{Kind: "Role", Name: "access-to-m-prometheus-metrics"}, Subjects: scraper}, + {Kind: "ServiceAccount", Name: "looped", Path: "templates/rbac-for-us.yaml", Labels: labels, Unmanageable: "rendered inside {{ range }}, which the declaration cannot express", Located: true}, + }}) + + require.Len(t, got.Decl.ServiceAccounts, 1) + assert.Equal(t, ".Values.m.on", got.Decl.ServiceAccounts[0].When) + + require.Len(t, got.Decl.Access, 1) + assert.Equal(t, "reader", got.Decl.Access[0].Name) + assert.True(t, strings.HasPrefix(got.Decl.Access[0].When, "TODO: ClusterRole d8:m:reader renders under `.Values.m.reader`"), got.Decl.Access[0].When) + + require.NotNil(t, got.Decl.PrometheusAccess) + assert.Equal(t, gate, got.Decl.PrometheusAccess.When) + assert.NotContains(t, strings.Join(got.Notes, "\n"), "whether it gated the scraper binding") + + unmanaged := strings.Join(got.Unmanaged, "\n") + assert.Contains(t, unmanaged, "ServiceAccount/looped (templates/rbac-for-us.yaml): rendered inside {{ range }}") + assert.Contains(t, unmanaged, "ClusterRole/d8:m:empty (templates/rbac-for-us.yaml): has no rules") + assert.Contains(t, unmanaged, "ClusterRoleBinding/d8:m:empty (templates/rbac-for-us.yaml): binds d8:m:empty, which is not a plain ClusterRole of this module the declaration describes") +} + +// An account whose role renders under another condition than the account holds a TODO. +func TestBuild_AccountObjectsUnderAnotherCondition(t *testing.T) { + labels := map[string]string{"module": "m"} + sa := []rbacv1.Subject{{Kind: "ServiceAccount", Name: "m", Namespace: "d8-m"}} + + got := Build(Input{Module: "m", Namespace: "d8-m", Objects: []Object{ + {Kind: "ServiceAccount", Name: "m", Path: "templates/rbac-for-us.yaml", Labels: labels, Located: true}, + {Kind: "ClusterRoleBinding", Name: "d8:m:m:rbac-proxy", Path: "templates/rbac-for-us.yaml", Labels: labels, When: ".Values.m.proxy", Located: true, + RoleRef: rbacv1.RoleRef{Kind: "ClusterRole", Name: "d8:rbac-proxy"}, Subjects: sa}, + }}) + + require.Len(t, got.Decl.ServiceAccounts, 1) + assert.Equal(t, "TODO: the account renders under `no condition`, but ClusterRoleBinding d8:m:m:rbac-proxy renders under `.Values.m.proxy`; the declaration puts all of them under one condition", got.Decl.ServiceAccounts[0].When) +} + +func TestUnwrap(t *testing.T) { + assert.Equal(t, `.Values.global.enabledModules | has "prometheus"`, unwrap(`(.Values.global.enabledModules | has "prometheus")`)) + assert.Equal(t, `(a) (b)`, unwrap(`(a) (b)`)) + assert.Equal(t, `a`, unwrap(`((a))`)) + assert.Equal(t, `.Values.x`, unwrap(`.Values.x`)) + + _, ok := Locate(TemplateDocs("{{- if (.Values.a) }}\n---\nkind: Role\nmetadata:\n name: r\n{{- if .Values.b }}\n---\nkind: Role\nmetadata:\n name: r\n{{- end }}\n{{- end }}\n"), Object{Kind: "Role", Name: "r"}) + assert.False(t, ok, "two documents of one name under different conditions are not one answer") +} diff --git a/pkg/linters/rbac/rules/generate/model.go b/pkg/linters/rbac/rules/generate/model.go index 2a90d894..8964b708 100644 --- a/pkg/linters/rbac/rules/generate/model.go +++ b/pkg/linters/rbac/rules/generate/model.go @@ -526,15 +526,15 @@ func (b *builder) access() { if len(a.ClusterRules) > 0 { name := "d8:" + b.in.Module + ":" + a.Name - b.add(dir+"rbac-for-us.yaml", Object{Kind: "ClusterRole", Name: name, Class: ClassDeclared, Rules: policyRules(a.ClusterRules)}) - b.add(dir+"rbac-for-us.yaml", Object{Kind: "ClusterRoleBinding", Name: name, Class: ClassDeclared, RoleRefKind: "ClusterRole", RoleRefName: name, Subjects: subjects}) + b.add(dir+"rbac-for-us.yaml", Object{Kind: "ClusterRole", Name: name, Class: ClassDeclared, When: a.When, Rules: policyRules(a.ClusterRules)}) + b.add(dir+"rbac-for-us.yaml", Object{Kind: "ClusterRoleBinding", Name: name, Class: ClassDeclared, When: a.When, RoleRefKind: "ClusterRole", RoleRefName: name, Subjects: subjects}) } if len(a.NamespaceRules) > 0 { name := rbaccontract.AccessRoleName(b.in.Module, a.Path, a.Name) - b.add(dir+"rbac-to-us.yaml", Object{Kind: "Role", Name: name, Namespace: b.in.Namespace, Class: ClassDeclared, Rules: policyRules(a.NamespaceRules)}) - b.add(dir+"rbac-to-us.yaml", Object{Kind: "RoleBinding", Name: name, Namespace: b.in.Namespace, Class: ClassDeclared, RoleRefKind: "Role", RoleRefName: name, Subjects: subjects}) + b.add(dir+"rbac-to-us.yaml", Object{Kind: "Role", Name: name, Namespace: b.in.Namespace, Class: ClassDeclared, When: a.When, Rules: policyRules(a.NamespaceRules)}) + b.add(dir+"rbac-to-us.yaml", Object{Kind: "RoleBinding", Name: name, Namespace: b.in.Namespace, Class: ClassDeclared, When: a.When, RoleRefKind: "Role", RoleRefName: name, Subjects: subjects}) } } } diff --git a/pkg/linters/rbac/rules/rbacyaml/types.go b/pkg/linters/rbac/rules/rbacyaml/types.go index 11042b3b..58bcf3e2 100644 --- a/pkg/linters/rbac/rules/rbacyaml/types.go +++ b/pkg/linters/rbac/rules/rbacyaml/types.go @@ -218,7 +218,9 @@ type Access struct { Subjects []Subject `yaml:"subjects"` // Path is the component directory under templates/ whose rbac-for-us.yaml (clusterRules) or // rbac-to-us.yaml (namespaceRules) holds the objects; empty means the module root files. - Path string `yaml:"path,omitempty"` + Path string `yaml:"path,omitempty"` + // When wraps the role and the binding in {{- if }}, as for a ServiceAccount. + When string `yaml:"when,omitempty"` ClusterRules []PolicyRule `yaml:"clusterRules,omitempty"` NamespaceRules []PolicyRule `yaml:"namespaceRules,omitempty"` } diff --git a/pkg/linters/rbac/rules/rbacyaml/validate.go b/pkg/linters/rbac/rules/rbacyaml/validate.go index ee01bc96..066d3640 100644 --- a/pkg/linters/rbac/rules/rbacyaml/validate.go +++ b/pkg/linters/rbac/rules/rbacyaml/validate.go @@ -386,6 +386,8 @@ func validateAccess(access []Access, report reporter) { report("%s: subjects is required", where) } + validateWhen(a.When, where, report) + if strings.HasPrefix(a.Path, "/") || strings.HasSuffix(a.Path, "/") || strings.Contains(a.Path, "..") { report("%s: path must be a directory under templates/ without leading or trailing slashes, got %q", where, a.Path) } diff --git a/pkg/linters/rbac/rules/sync.go b/pkg/linters/rbac/rules/sync.go index 3c6b3e68..6ba6fd18 100644 --- a/pkg/linters/rbac/rules/sync.go +++ b/pkg/linters/rbac/rules/sync.go @@ -1256,8 +1256,11 @@ func (r *SyncRule) bootstrap(declList *errors.LintRuleErrorsList) { in.CRDs[crd.Key()] = crd.Scope } + docs := map[string][]bootstrap.Doc{} + for _, object := range storage { if o, ok := bootstrapObject(object); ok { + locateInTemplate(modulePath, &o, docs) in.Objects = append(in.Objects, o) } } @@ -1266,6 +1269,7 @@ func (r *SyncRule) bootstrap(declList *errors.LintRuleErrorsList) { return } + in.Unrendered = unrenderedObjects(modulePath, in.Objects) result := bootstrap.Build(in) described := len(in.Objects) - len(result.Unmanaged) path := rbacyaml.Path(modulePath) @@ -1281,6 +1285,7 @@ func (r *SyncRule) bootstrap(declList *errors.LintRuleErrorsList) { } in.Objects = bootstrapObjectsOf(path) + in.Unrendered = unrenderedObjects(modulePath, in.Objects) result := bootstrap.Build(in) content, err := bootstrap.Marshal(result) @@ -1293,9 +1298,20 @@ func (r *SyncRule) bootstrap(declList *errors.LintRuleErrorsList) { } // The file is written, but a TODO in it is a decision nobody has made yet: the finding - // stays, and so does the non-zero exit, until a person makes it (ADR, bootstrap). + // stays, and so does the non-zero exit, until a person makes it (ADR, bootstrap). What + // the linter would refuse in the written file is named here too, rather than on the + // next run. + problems := writtenProblems(content, crds, in) + if len(in.Unrendered) > 0 { + problems += "; the templates hold objects no render showed, and the declaration does not: " + strings.Join(in.Unrendered, ", ") + } + if open := openDecisions(string(content)); open > 0 { - return fmt.Errorf("%s is written; %d TODO in it are decisions only a person can make -- resolve them, then run `%s` to regenerate the templates", rbacyaml.Filename, open, FixCommand) + return fmt.Errorf("%s is written; %d TODO in it are decisions only a person can make%s -- resolve them, then run `%s` to regenerate the templates", rbacyaml.Filename, open, problems, FixCommand) + } + + if problems != "" { + return fmt.Errorf("%s is written%s -- correct it, then run `%s` to regenerate the templates", rbacyaml.Filename, problems, FixCommand) } return nil @@ -1720,3 +1736,107 @@ func manualFix(what string) errors.AutofixFunc { return fmt.Errorf("nothing was generated: %s first", what) } } + +// locateInTemplate reads the template blocks around a rendered object from its template's text: +// the render only holds what rendered for the linter's values, the text holds the conditions. +func locateInTemplate(modulePath string, o *bootstrap.Object, cache map[string][]bootstrap.Doc) { + docs, ok := cache[o.Path] + if !ok { + if content, err := os.ReadFile(filepath.Join(modulePath, o.Path)); err == nil { + docs = bootstrap.TemplateDocs(string(content)) + } + + cache[o.Path] = docs + } + + d, found := bootstrap.Locate(docs, *o) + if !found { + return + } + + o.Located = true + o.When, o.Unmanageable, o.Partial = d.When, d.Unmanageable, d.Partial + + if d.Library && o.Unmanageable == "" { + o.Unmanageable = "rendered by an include of a named template (helm_lib or another chart), which owns it" + } +} + +// writtenProblems lists what the linter refuses in a declaration bootstrap wrote, the TODO +// values aside: they are counted on their own. +func writtenProblems(content []byte, crds []crdInfo, in bootstrap.Input) string { + decl, err := rbacyaml.Parse(content) + if err != nil { + return "; it does not parse: " + err.Error() + } + + var problems []string + + for _, e := range rbacyaml.Validate(decl, crdScopes(crds)) { + if !strings.Contains(e.Error(), "TODO") { + problems = append(problems, e.Error()) + } + } + + if len(problems) == 0 { + if _, err := generate.Build(generate.Input{Module: in.Module, Namespace: in.Namespace, Subsystems: in.Subsystems, Decl: decl}); err != nil && !strings.Contains(err.Error(), "TODO") { + problems = append(problems, err.Error()) + } + } + + if len(problems) == 0 { + return "" + } + + return "; the linter refuses: " + strings.Join(problems, "; ") +} + +// rbacKinds are the kinds bootstrap describes. +var rbacKinds = map[string]bool{"ClusterRole": true, "ClusterRoleBinding": true, "Role": true, "RoleBinding": true, "ServiceAccount": true} + +// unrenderedObjects lists the RBAC objects with a literal name that the module's templates hold +// and no render showed: an object under a condition false for the linter's values would +// otherwise be left out of the first declaration without a word, and the regeneration would drop +// it. Only the text can tell; a computed name is not followed. +func unrenderedObjects(modulePath string, rendered []bootstrap.Object) []string { + seen := make(map[string]bool, len(rendered)) + for _, o := range rendered { + seen[o.Kind+"/"+o.Name] = true + } + + var out []string + + root := filepath.Join(modulePath, "templates") + + _ = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() || strings.HasPrefix(d.Name(), "_") || (filepath.Ext(path) != ".yaml" && filepath.Ext(path) != ".yml") { + return nil //nolint:nilerr // an unreadable entry is not the importer's to report + } + + content, err := os.ReadFile(path) + if err != nil { + return nil //nolint:nilerr // as above + } + + rel, _ := filepath.Rel(modulePath, path) + + for _, doc := range bootstrap.TemplateDocs(string(content)) { + if !rbacKinds[doc.Kind] || doc.Name == "" || seen[doc.Kind+"/"+doc.Name] || doc.Unmanageable != "" { + continue + } + + entry := fmt.Sprintf("%s/%s (%s", doc.Kind, doc.Name, rel) + if doc.When != "" { + entry += ", under `" + doc.When + "`" + } + + out = append(out, entry+")") + } + + return nil + }) + + sort.Strings(out) + + return out +} diff --git a/pkg/linters/rbac/rules/sync_regressions_test.go b/pkg/linters/rbac/rules/sync_regressions_test.go index 01b4f0e0..ddad58f6 100644 --- a/pkg/linters/rbac/rules/sync_regressions_test.go +++ b/pkg/linters/rbac/rules/sync_regressions_test.go @@ -30,6 +30,7 @@ import ( "github.com/deckhouse/dmt/pkg" "github.com/deckhouse/dmt/pkg/errors" + "github.com/deckhouse/dmt/pkg/linters/rbac/rules/bootstrap" "github.com/deckhouse/dmt/pkg/linters/rbac/rules/generate" "github.com/deckhouse/dmt/pkg/linters/rbac/rules/rbacyaml" ) @@ -421,3 +422,71 @@ func TestSyncRegression_AnnotationsAreCompared(t *testing.T) { assert.Contains(t, strings.Join(texts(errorList), "\n"), "ServiceAccount/cainjector: annotation helm.sh/resource-policy is in the render but not declared") } + +// Bootstrap reads the conditions from the template text: an account under {{ if }} keeps its +// `when` (regression hunt, B1). +func TestSyncRegression_BootstrapKeepsTheTemplateCondition(t *testing.T) { + resetFixState() + t.Cleanup(resetFixState) + + modulePath := syncModuleDir(t) + model := syncModel(t, modulePath) + writeGenerated(t, modulePath, model) + require.NoError(t, os.Remove(rbacyaml.Path(modulePath))) + + errorList := runSync(t, modulePath, renderedFrom(t, model, nil)) + for _, fix := range errorList.GetFixes() { + fix() + } + + written, err := rbacyaml.Load(modulePath) + require.NoError(t, err) + require.Len(t, written.ServiceAccounts, 1) + assert.Equal(t, ".Values.certManager.internal.enableCAInjector", written.ServiceAccounts[0].When) +} + +// An object under a condition false for the linter's values is in no render; the text shows it, +// the declaration header names it and the fix fails, rather than the regeneration dropping it +// (regression hunt, B1). +func TestSyncRegression_BootstrapNamesWhatDidNotRender(t *testing.T) { + resetFixState() + t.Cleanup(resetFixState) + + modulePath := syncModuleDir(t) + model := syncModel(t, modulePath) + writeGenerated(t, modulePath, model) + require.NoError(t, os.Remove(rbacyaml.Path(modulePath))) + + errorList := runSync(t, modulePath, renderedFrom(t, model, func(o *generate.Object) bool { return o.When == "" })) + for _, fix := range errorList.GetFixes() { + fix() + } + + require.True(t, errorList.ContainsFailedFixes()) + + var fixErrors []string + + for _, e := range errorList.GetErrors() { + if e.FixError != nil { + fixErrors = append(fixErrors, e.FixError.Error()) + } + } + + assert.Contains(t, strings.Join(fixErrors, "\n"), "ServiceAccount/cainjector (templates/cainjector/rbac-for-us.yaml, under `.Values.certManager.internal.enableCAInjector`)") + + content, err := os.ReadFile(rbacyaml.Path(modulePath)) + require.NoError(t, err) + assert.Contains(t, string(content), "ServiceAccount/cainjector (templates/cainjector/rbac-for-us.yaml, under `.Values.certManager.internal.enableCAInjector`) is in the templates but did not render") +} + +// What the linter would refuse in a written declaration is named by the fix that wrote it +// (regression hunt, B10). +func TestSyncRegression_WrittenProblems(t *testing.T) { + in := bootstrap.Input{Module: "m", Namespace: "d8-m", Subsystems: []string{"security"}} + + assert.Empty(t, writtenProblems([]byte("apiVersion: rbac.deckhouse.io/v1alpha1\n"), nil, in)) + assert.Contains(t, writtenProblems([]byte("apiVersion: rbac.deckhouse.io/v1alpha1\nserviceAccounts:\n - name: x\n path: a/b\n"), nil, in), + `the placement rule wants the account named "a-b" or "m-a-b"`) + assert.Contains(t, writtenProblems([]byte("apiVersion: rbac.deckhouse.io/v1alpha1\nserviceAccounts:\n - name: x\n when: .Values.x }}\n"), nil, in), "template delimiter") + assert.Empty(t, writtenProblems([]byte("apiVersion: rbac.deckhouse.io/v1alpha1\nserviceAccounts:\n - name: x\n when: \"TODO: decide\"\n"), nil, in), "a TODO is counted on its own") +} From 1890d6f83d81fb2bf0ed6d20bf3d1fc0e0bb3b65 Mon Sep 17 00:00:00 2001 From: Ivan Zvyagintsev Date: Thu, 24 Sep 2026 11:10:16 +0300 Subject: [PATCH 40/58] rbac: tighten the declaration checks found by the regression hunt - A misspelling warning only for a resource one or two letters away from a CRD of the module: another module's CRD in a shared group is external. - A declared scope must agree with Kubernetes for a built-in resource. - "*/" follows the rules of "*": no CRD group, a reason. - Texts for a capability level no entry grants are an error. - ServiceAccount names are DNS subdomains; rules hold no empty values; resource names hold no dots. - Messages name a resource entry by its index in the file. - A module .dmtlint.yaml that sets global.linters-settings.rbac is refused: the per-rule levels are read from the root only. Signed-off-by: Ivan Zvyagintsev --- internal/modules/module.go | 8 ++- pkg/config/config.go | 7 +- pkg/config/loader.go | 18 +++++ pkg/config/rbac_keys_test.go | 33 +++++++++ pkg/linters/rbac/README.md | 11 +-- pkg/linters/rbac/rules/coverage.go | 59 +++++++++++++++- pkg/linters/rbac/rules/coverage_test.go | 15 ++++- pkg/linters/rbac/rules/rbacyaml/load.go | 6 ++ pkg/linters/rbac/rules/rbacyaml/load_test.go | 71 +++++++++++++++++++- pkg/linters/rbac/rules/rbacyaml/types.go | 7 ++ pkg/linters/rbac/rules/rbacyaml/validate.go | 47 +++++++++++-- 11 files changed, 264 insertions(+), 18 deletions(-) diff --git a/internal/modules/module.go b/internal/modules/module.go index 0879eb1c..be84fcf3 100644 --- a/internal/modules/module.go +++ b/internal/modules/module.go @@ -675,7 +675,13 @@ func NewModule(path string, vals *chartutil.Values, globalSchema *spec.Schema, r // Load module config cfg := &config.ModuleConfig{} - if err := config.NewLoader(cfg, path).Load(); err != nil { + + loader := config.NewLoader(cfg, path) + if err := loader.Load(); err != nil { + return nil, fmt.Errorf("can not parse module config: %w", err) + } + + if err := loader.RefuseRootOnlyKeys(rootConfig.File); err != nil { return nil, fmt.Errorf("can not parse module config: %w", err) } diff --git a/pkg/config/config.go b/pkg/config/config.go index e4446f28..81a9dc34 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -25,6 +25,8 @@ import ( type RootConfig struct { GlobalSettings *global.Global `mapstructure:"global"` Remote RemoteSettings `mapstructure:"remote"` + // File is the .dmtlint.yaml the root configuration came from; empty without one. + File string `mapstructure:"-"` } // RemoteSettings holds the linter settings of the scopes that lint a published @@ -60,9 +62,12 @@ func NewDefaultRootConfig(dir string) (*RootConfig, error) { GlobalSettings: &global.Global{}, } - if err := NewLoader(cfg, dir).Load(); err != nil { + loader := NewLoader(cfg, dir) + if err := loader.Load(); err != nil { return nil, err } + cfg.File = loader.ConfigFileUsed() + return cfg, nil } diff --git a/pkg/config/loader.go b/pkg/config/loader.go index 2dd59d83..69e180c0 100644 --- a/pkg/config/loader.go +++ b/pkg/config/loader.go @@ -55,6 +55,24 @@ func NewLoader(cfg any, dir string) *Loader { } } +// ConfigFileUsed is the .dmtlint.yaml the loader read; empty when none was found. +func (l *Loader) ConfigFileUsed() string { + return l.viper.ConfigFileUsed() +} + +// RefuseRootOnlyKeys refuses, in a module's own .dmtlint.yaml, the keys only the root +// configuration sets. Per-rule levels of the rbac rules are read from the root only (ADR, module +// rbac.yaml); in a module's file they would be dropped without a word and the rule would keep its +// level. rootFile is the root configuration's file; the same file is the root, not a module's. +func (l *Loader) RefuseRootOnlyKeys(rootFile string) error { + used := l.viper.ConfigFileUsed() + if used == "" || used == rootFile || !l.viper.IsSet("global.linters-settings.rbac") { + return nil + } + + return fmt.Errorf("%s sets global.linters-settings.rbac, which only the root .dmtlint.yaml sets; move it there, or use linters-settings.rbac for this module", used) +} + func (l *Loader) Load() error { err := l.setConfigFile() if err != nil { diff --git a/pkg/config/rbac_keys_test.go b/pkg/config/rbac_keys_test.go index 8ddc4fbf..b14135f4 100644 --- a/pkg/config/rbac_keys_test.go +++ b/pkg/config/rbac_keys_test.go @@ -115,3 +115,36 @@ linters-settings: require.NoError(t, loadFrom(t, "linters-settings:\n container:\n impakt: warn\n")) }) } + +// A module's own .dmtlint.yaml may not set the per-rule rbac levels: they are read from the root +// only and would be dropped without a word (regression hunt, B13). +func TestRefuseRootOnlyKeys(t *testing.T) { + root := t.TempDir() + module := filepath.Join(root, "modules", "m") + require.NoError(t, os.MkdirAll(module, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(root, ".dmtlint.yaml"), []byte("global:\n linters-settings:\n rbac:\n rules:\n sync:\n impact: error\n"), 0o600)) + + rootCfg, err := NewDefaultRootConfig(root) + require.NoError(t, err) + require.Equal(t, filepath.Join(root, ".dmtlint.yaml"), rootCfg.File) + + // Without a file of its own the module reads the root one: nothing to refuse. + own := NewLoader(&ModuleConfig{}, module) + require.NoError(t, own.Load()) + require.NoError(t, own.RefuseRootOnlyKeys(rootCfg.File)) + + require.NoError(t, os.WriteFile(filepath.Join(module, ".dmtlint.yaml"), []byte("linters-settings:\n rbac:\n impact: warn\n"), 0o600)) + + own = NewLoader(&ModuleConfig{}, module) + require.NoError(t, own.Load()) + require.NoError(t, own.RefuseRootOnlyKeys(rootCfg.File)) + + require.NoError(t, os.WriteFile(filepath.Join(module, ".dmtlint.yaml"), []byte("global:\n linters-settings:\n rbac:\n rules:\n sync:\n impact: ignored\n"), 0o600)) + + own = NewLoader(&ModuleConfig{}, module) + require.NoError(t, own.Load()) + + err = own.RefuseRootOnlyKeys(rootCfg.File) + require.Error(t, err) + assert.Contains(t, err.Error(), "sets global.linters-settings.rbac, which only the root .dmtlint.yaml sets") +} diff --git a/pkg/linters/rbac/README.md b/pkg/linters/rbac/README.md index 32994baf..123d113b 100644 --- a/pkg/linters/rbac/README.md +++ b/pkg/linters/rbac/README.md @@ -1471,9 +1471,11 @@ Format rules the loader enforces: - `prometheusAccess.when` gates the RoleBinding of the Prometheus scraper (typically `.Values.global.enabledModules | has "prometheus"`); the Role stays unconditional. - Levels: `namespace` -- `viewer`, `user`, `manager`, `admin`, `superadmin`; `system` -- `viewer`, `manager`, `superadmin`; `legacy` -- `User`, `PrivilegedUser`, `Editor`, `Admin`, `ClusterEditor`, `ClusterAdmin`, `SuperAdmin`. - `namespace` levels are allowed only for `Namespaced` resources; a `Namespaced` resource at a `system` level needs a `reason`. -- `scope` is required for a resource the module ships no CRD for, and must agree with the CRD when the module ships one. `resource: "*"` is allowed only for a group without CRDs in the module and needs a `reason`. +- `scope` is required for a resource the module ships no CRD for, and must agree with the CRD when the module ships one, or with Kubernetes for a built-in resource (`nodes` is `Cluster`). `resource: "*"` and `resource: "*/"` are allowed only for a group without CRDs in the module and need a `reason`. A resource is a lowercase plural without dots. - `noAccess` is a non-empty reason and excludes the levels. `noAccess: "TODO"` is the stub the coverage autofix writes; it is not a decision. Any `noAccess`, `reason` or `scope` value that starts with `TODO` is an open decision: `coverage` reports it, and a `--fix` run that meets one exits non-zero. -- A capability outside the view/edit convention (`admin`, `user`, `superadmin`) needs `capabilities..` texts in both languages. +- A capability outside the view/edit convention (`admin`, `user`, `superadmin`) needs `capabilities..` texts in both languages; texts for a level no entry grants are an error (a typo in the key, or a removed entry). +- A ServiceAccount name is a DNS subdomain; label and annotation keys are qualified names, and `rbac.deckhouse.io/*` and `meta.helm.sh/*` annotations are not the declaration's. A rule holds no empty verb, resource, resource name or URL. +- Messages name a resource entry by its index in the file as written (`resources[3]`), although the entries are compared in sorted order. What the generator produces from it (level `viewer` -> capability `view`, `manager` -> `edit`, the rest as they are): @@ -1543,7 +1545,8 @@ global: rules: contract: {impact: error} # the level of this rule alone; unset it starts at warn, and the four original rules keep the linter level -# module .dmtlint.yaml +# module .dmtlint.yaml -- global: is read from the root only; a module file that sets +# global.linters-settings.rbac is refused rather than ignored linters-settings: rbac: exclude-rules: @@ -1570,7 +1573,7 @@ Runs only when the module has an `rbac.yaml`. Reads the CRDs under `crds/` at an 1. Every CRD (`spec.group` / `spec.names.plural`) has an entry in `resources` that grants levels or denies access with a reason -- **error**, with an autofix. 2. An entry left as `noAccess: "TODO"` -- **error**, no autofix: only a person can decide. -3. An entry naming a group the module ships CRDs for, but a resource none of them spells -- **warning** (a likely misspelling). Whole-group (`"*"`) and subresource (`/`) entries are exempt. +3. An entry naming a group the module ships CRDs for, but a resource none of them spells, one or two letters away from one that is -- **warning** (a likely misspelling). A resource further away is another module's CRD in a shared group (`deckhouse.io`). Whole-group (`"*"`) and subresource (`/`) entries are exempt. 4. A `noAccess` entry of a group the module ships no CRD for, without a `scope` -- **warning**: a removed CRD is indistinguishable from an external resource nobody grants. Add `scope` to say the resource is external, or drop the entry if its CRD is gone. **Autofix:** appends an undecided stub for each CRD without an entry -- diff --git a/pkg/linters/rbac/rules/coverage.go b/pkg/linters/rbac/rules/coverage.go index 1c5de9c5..be1a26e2 100644 --- a/pkg/linters/rbac/rules/coverage.go +++ b/pkg/linters/rbac/rules/coverage.go @@ -21,7 +21,9 @@ import ( "context" stderrors "errors" "fmt" + "maps" "os" + "slices" "strings" "gopkg.in/yaml.v3" @@ -160,11 +162,17 @@ func (r *CoverageRule) Check(_ context.Context) { continue } - if _, resourceKnown := known[res.Key()]; !resourceKnown { + if _, resourceKnown := known[res.Key()]; resourceKnown { + continue + } + + // Several modules share a group (deckhouse.io): a resource of another module's CRD is + // external, not a misspelling. Only a name close to one of this module's is flagged. + if near := nearestResource(res, known); near != "" { errorList. WithObjectID("rbac.yaml/"+res.Key()). - Warnf("%s names a resource the module's CRDs of group %s do not have; check the spelling, or drop the entry if the resource is gone", - res.Key(), res.Group) + Warnf("%s names a resource the module's CRDs of group %s do not have, and %s is one letter or two away; check the spelling, or drop the entry if the resource is gone", + res.Key(), res.Group, near) } } } @@ -295,3 +303,48 @@ func scalarValue(node *yaml.Node) string { return node.Value } + +// nearestResource returns the module CRD of the entry's group whose plural is at most two edits +// away from the entry's resource, or "" when none is. +func nearestResource(res rbacyaml.Resource, known map[string]struct{}) string { + best, bestDistance := "", 3 + + for _, key := range slices.Sorted(maps.Keys(known)) { + group, plural, _ := strings.Cut(key, "/") + if group != res.Group { + continue + } + + if d := editDistance(res.Resource, plural); d < bestDistance { + best, bestDistance = key, d + } + } + + return best +} + +// editDistance is the Levenshtein distance of two ASCII names. +func editDistance(a, b string) int { + prev := make([]int, len(b)+1) + for j := range prev { + prev[j] = j + } + + for i := 1; i <= len(a); i++ { + cur := make([]int, len(b)+1) + cur[0] = i + + for j := 1; j <= len(b); j++ { + cost := 1 + if a[i-1] == b[j-1] { + cost = 0 + } + + cur[j] = min(prev[j]+1, cur[j-1]+1, prev[j-1]+cost) + } + + prev = cur + } + + return prev[len(b)] +} diff --git a/pkg/linters/rbac/rules/coverage_test.go b/pkg/linters/rbac/rules/coverage_test.go index b9d9f1c6..5633d5db 100644 --- a/pkg/linters/rbac/rules/coverage_test.go +++ b/pkg/linters/rbac/rules/coverage_test.go @@ -155,7 +155,7 @@ resources: assert.Contains(t, got, "error: CRD a.io/gammas (crds/a.yaml) has no entry in rbac.yaml: decide the user access to it -- namespace, system or legacy levels, or noAccess with the reason; `dmt lint --linter rbac --fix` adds an undecided stub") assert.Contains(t, got, "error: CRD d.io/deltas (crds/d.yaml) has no entry in rbac.yaml: decide the user access to it -- namespace, system or legacy levels, or noAccess with the reason; `dmt lint --linter rbac --fix` adds an undecided stub") assert.Contains(t, got, `error: a.io/betas is still undecided in rbac.yaml (noAccess: "TODO"): a decision is needed -- only a person can close this`) - assert.Contains(t, got, "warn: a.io/gamas names a resource the module's CRDs of group a.io do not have; check the spelling, or drop the entry if the resource is gone") + assert.Contains(t, got, "warn: a.io/gamas names a resource the module's CRDs of group a.io do not have, and a.io/gammas is one letter or two away; check the spelling, or drop the entry if the resource is gone") // --fix: two stubs are written, and both findings stay, each with the reason (R33); the open // decision fails its fix too, so the run does not end green. @@ -359,3 +359,16 @@ func TestCoverage_TODOPrefixIsAnOpenDecision(t *testing.T) { assert.True(t, errorList.ContainsFailedFixes(), "a --fix run with open decisions fails") } + +// A resource of a shared group that is no near miss of the module's CRDs is external: another +// module's CRD in deckhouse.io, not a misspelling (regression hunt, B11). +func TestNearestResource(t *testing.T) { + known := map[string]struct{}{"deckhouse.io/nodegroups": {}, "deckhouse.io/instances": {}} + + assert.Equal(t, "deckhouse.io/nodegroups", nearestResource(rbacyaml.Resource{Group: "deckhouse.io", Resource: "nodegroup"}, known)) + assert.Equal(t, "deckhouse.io/instances", nearestResource(rbacyaml.Resource{Group: "deckhouse.io", Resource: "instanses"}, known)) + assert.Empty(t, nearestResource(rbacyaml.Resource{Group: "deckhouse.io", Resource: "modulesources"}, known)) + assert.Empty(t, nearestResource(rbacyaml.Resource{Group: "other.io", Resource: "nodegroup"}, known)) + assert.Equal(t, 0, editDistance("abc", "abc")) + assert.Equal(t, 3, editDistance("", "abc")) +} diff --git a/pkg/linters/rbac/rules/rbacyaml/load.go b/pkg/linters/rbac/rules/rbacyaml/load.go index 341e8e16..b05863c5 100644 --- a/pkg/linters/rbac/rules/rbacyaml/load.go +++ b/pkg/linters/rbac/rules/rbacyaml/load.go @@ -72,6 +72,12 @@ func Parse(data []byte) (*Declaration, error) { return nil, fmt.Errorf("parse %s: %w", Filename, err) } + for i := range decl.Resources { + decl.Resources[i].Position = i + } + + decl.parsed = true + decl.Normalize() return decl, nil diff --git a/pkg/linters/rbac/rules/rbacyaml/load_test.go b/pkg/linters/rbac/rules/rbacyaml/load_test.go index b4225fa2..12780693 100644 --- a/pkg/linters/rbac/rules/rbacyaml/load_test.go +++ b/pkg/linters/rbac/rules/rbacyaml/load_test.go @@ -337,10 +337,41 @@ noAccess: nobody`), yaml: entry(`group: external.io resource: "*/scale" scope: Namespaced +reason: the resources of the group are not known statically noAccess: nobody`), crds: certManagerCRDs, wantErr: "", }, + "regression hunt B12: the wildcard of a subresource needs a reason, as \"*\" does": { + yaml: entry(`group: external.io +resource: "*/scale" +scope: Namespaced +noAccess: nobody`), + crds: certManagerCRDs, + wantErr: `resource "*/scale" requires reason`, + }, + "regression hunt B12: the wildcard of a subresource in a group of the module": { + yaml: entry(`group: cert-manager.io +resource: "*/status" +reason: every status +noAccess: nobody`), + crds: certManagerCRDs, + wantErr: `resource "*/status" is allowed only for a group the module ships no CRD for`, + }, + "regression hunt B12: a built-in resource under the wrong scope": { + yaml: entry(`group: "" +resource: nodes +scope: Namespaced +noAccess: nobody`), + wantErr: `scope "Namespaced" disagrees with Kubernetes, which serves /nodes as "Cluster"`, + }, + "regression hunt B12: no dot in a resource name": { + yaml: entry(`group: external.io +resource: things.v1 +scope: Namespaced +noAccess: nobody`), + wantErr: `resource "things.v1" is not a resource name`, + }, "review 6: a resource name with a space": { yaml: entry(`group: external.io resource: "Widgets " @@ -477,7 +508,8 @@ func TestValidate_TopLevel(t *testing.T) { wantErr: `"namespace.view" needs no texts`, }, "capabilities: missing ru": { - yaml: "apiVersion: rbac.deckhouse.io/v1alpha1\ncapabilities:\n namespace.admin: {title: {en: a}, description: {en: c, ru: d}}\n", + yaml: "apiVersion: rbac.deckhouse.io/v1alpha1\ncapabilities:\n namespace.admin: {title: {en: a}, description: {en: c, ru: d}}\n" + + "resources:\n - {group: x.io, resource: things, scope: Namespaced, namespace: {admin: [get]}}\n", wantErr: "namespace.admin.title requires both en and ru", }, "capabilities: bad key": { @@ -511,7 +543,8 @@ func TestValidate_TopLevel(t *testing.T) { wantErr: "prometheusAccess: when", }, "review 13a: a template delimiter in a capability text": { - yaml: "apiVersion: rbac.deckhouse.io/v1alpha1\ncapabilities:\n namespace.admin: {title: {en: 'Use {{ .Values.x }}', ru: b}, description: {en: c, ru: d}}\n", + yaml: "apiVersion: rbac.deckhouse.io/v1alpha1\ncapabilities:\n namespace.admin: {title: {en: 'Use {{ .Values.x }}', ru: b}, description: {en: c, ru: d}}\n" + + "resources:\n - {group: x.io, resource: things, scope: Namespaced, namespace: {admin: [get]}}\n", wantErr: `capabilities.namespace.admin.title.en: "Use {{ .Values.x }}" holds a template delimiter`, }, "prometheusAccess: empty": { @@ -669,3 +702,37 @@ func TestValidate_AccountMetadataKeys(t *testing.T) { assert.NotContains(t, got, "helm.sh/resource-policy") assert.NotContains(t, got, "werf.io") } + +// A text for a level nobody grants is reported; so are an account name Kubernetes refuses and an +// empty value in a rule; messages carry the entry's index in the file (regression hunt, B12). +func TestValidate_RegressionHuntB12(t *testing.T) { + decl, err := Parse([]byte(`apiVersion: rbac.deckhouse.io/v1alpha1 +capabilities: + namespace.approve: {title: {en: a, ru: b}, description: {en: c, ru: d}} +resources: + - {group: z.io, resource: things, scope: Namespaced, namespace: {viewer: [get]}} + - {group: a.io, resource: things, scope: Namespaced, namespace: {viewer: [bogus]}} +serviceAccounts: + - name: Bad_Name + clusterRules: + - apiGroups: [""] + resources: [""] + verbs: [get, ""] +`)) + require.NoError(t, err) + + errs := Validate(decl, nil) + + msgs := make([]string, 0, len(errs)) + for _, e := range errs { + msgs = append(msgs, e.Error()) + } + + got := strings.Join(msgs, "\n") + assert.Contains(t, got, `capabilities: "namespace.approve" has texts, but no resource entry grants namespace level "approve"`) + assert.Contains(t, got, `resources[1] (a.io/things): namespace.viewer: "bogus" is not a verb`, "the index of the file, not of the sorted list") + assert.NotContains(t, got, "resources[0] (a.io/things)") + assert.Contains(t, got, `serviceAccounts[0] (Bad_Name): a ServiceAccount name is a lowercase DNS subdomain`) + assert.Contains(t, got, `serviceAccounts[0] (Bad_Name).clusterRules[0]: verbs holds an empty value`) + assert.Contains(t, got, `serviceAccounts[0] (Bad_Name).clusterRules[0]: resources holds an empty value`) +} diff --git a/pkg/linters/rbac/rules/rbacyaml/types.go b/pkg/linters/rbac/rules/rbacyaml/types.go index 58bcf3e2..83f040b3 100644 --- a/pkg/linters/rbac/rules/rbacyaml/types.go +++ b/pkg/linters/rbac/rules/rbacyaml/types.go @@ -45,6 +45,9 @@ const ( type Declaration struct { APIVersion string `yaml:"apiVersion"` + // parsed marks a declaration read by Parse: its resources carry their Position in the file. + parsed bool + // Subsystems are the lineages the module's system capabilities aggregate into. Empty means // "the subsystems of module.yaml"; a module whose templates aggregate into more subsystems // than module.yaml declares must set it (kube-dns, kube-proxy, istio). @@ -70,6 +73,10 @@ type Resource struct { Group string `yaml:"group"` Resource string `yaml:"resource"` + // Position is the entry's index in the file as written, for the messages: Normalize sorts + // the entries. Set by Parse only. + Position int `yaml:"-"` + // Scope is required when the module ships no CRD for the resource (the linter cannot see // it) and when Resource is "*"; when the CRD is in the module tree the scope is read from // it and a declared Scope must agree. diff --git a/pkg/linters/rbac/rules/rbacyaml/validate.go b/pkg/linters/rbac/rules/rbacyaml/validate.go index 066d3640..9fff46c0 100644 --- a/pkg/linters/rbac/rules/rbacyaml/validate.go +++ b/pkg/linters/rbac/rules/rbacyaml/validate.go @@ -94,7 +94,14 @@ func Validate(d *Declaration, crds CRDScopes) []error { for i := range d.Resources { r := &d.Resources[i] - where := fmt.Sprintf("resources[%d] (%s)", i, r.Key()) + + // The index the author sees in the file, not the one after sorting. + pos := i + if d.parsed { + pos = r.Position + } + + where := fmt.Sprintf("resources[%d] (%s)", pos, r.Key()) if _, dup := seen[r.Key()]; dup { report("%s: duplicate entry for %s", where, r.Key()) @@ -159,13 +166,14 @@ func validateResource(r *Resource, where string, crds CRDScopes, usedCapabilitie report("%s: %s", where, scopeErr) } - if r.IsWildcard() { + // "*/" is a wildcard over the resources as much as "*" is. + if r.IsWildcard() || strings.HasPrefix(r.Resource, "*/") { if crds.groupKnown(r.Group) { - report("%s: resource \"*\" is allowed only for a group the module ships no CRD for; list the resources of %q", where, r.Group) + report("%s: resource %q is allowed only for a group the module ships no CRD for; list the resources of %q", where, r.Resource, r.Group) } if r.Reason == "" { - report("%s: resource \"*\" requires reason: why the resource names are not known statically", where) + report("%s: resource %q requires reason: why the resource names are not known statically", where, r.Resource) } } @@ -203,6 +211,12 @@ func resolveScope(r *Resource, crds CRDScopes) (string, string) { case known: return fromCRD, "" case r.Scope != "": + // A built-in resource has the scope Kubernetes serves it with; a declared one that differs + // would generate a capability that grants nothing (or a namespaced grant cluster-wide). + if builtin, ok := WellKnownScope(r.Group, r.Resource); ok && builtin != r.Scope { + return builtin, fmt.Sprintf("scope %q disagrees with Kubernetes, which serves %s as %q", r.Scope, r.Key(), builtin) + } + return r.Scope, "" default: if scope, ok := WellKnownScope(r.Group, r.Resource); ok { @@ -265,7 +279,9 @@ func validateLevels(levels map[string][]string, lineage string, allowed []string // validateCapabilities requires localized texts for every capability outside the platform // convention (anything but view/edit) and rejects malformed entries. func validateCapabilities(texts map[string]CapabilityText, used map[string]struct{}, report reporter) { - for key, text := range texts { + for _, key := range slices.Sorted(maps.Keys(texts)) { + text := texts[key] + lineage, action, ok := strings.Cut(key, ".") if !ok || (lineage != rbaccontract.LineageNamespace && lineage != rbaccontract.LineageSystem) { report("capabilities: key %q must be \"namespace.\" or \"system.\"", key) @@ -274,6 +290,10 @@ func validateCapabilities(texts map[string]CapabilityText, used map[string]struc if rbaccontract.IsConventionalAction(action) { report("capabilities: %q needs no texts: view and edit capabilities take the platform's conventional texts", key) + } else if _, isUsed := used[key]; !isUsed { + // A text for a level nobody grants -- a typo in the key, or an entry that was removed -- + // produces nothing, and the level it was meant for is left without texts. + report("capabilities: %q has texts, but no resource entry grants %s level %q; check the key, or drop the texts", key, lineage, action) } for _, field := range []struct { @@ -309,6 +329,10 @@ func validateServiceAccounts(accounts []ServiceAccount, report reporter) { continue } + if len(sa.Name) > 253 || !dnsSubdomainRe.MatchString(sa.Name) { + report("%s: a ServiceAccount name is a lowercase DNS subdomain (letters, digits, '-' and '.')", where) + } + if _, dup := names[sa.Name]; dup { report("%s: duplicate name", where) } @@ -438,6 +462,16 @@ func validatePolicyRules(rules []PolicyRule, where string, report reporter) { report("%s[%d]: verbs is required", where, i) } + // An empty string is no verb, resource or name: Kubernetes keeps it and it matches nothing. + for _, field := range []struct { + name string + values []string + }{{"verbs", rule.Verbs}, {"resources", rule.Resources}, {"resourceNames", rule.ResourceNames}, {"nonResourceURLs", rule.NonResourceURLs}} { + if slices.Contains(field.values, "") { + report("%s[%d]: %s holds an empty value", where, i, field.name) + } + } + if len(rule.NonResourceURLs) > 0 && (len(rule.APIGroups) > 0 || len(rule.Resources) > 0 || len(rule.ResourceNames) > 0) { report("%s[%d]: nonResourceURLs cannot be combined with apiGroups, resources or resourceNames", where, i) } @@ -533,7 +567,8 @@ func Warnings(d *Declaration) []string { var ( groupNameRe = regexp.MustCompile(`^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$`) - resourceNameRe = regexp.MustCompile(`^(\*|[a-z0-9]([-a-z0-9.]*[a-z0-9])?)(/[a-z0-9]([-a-z0-9]*[a-z0-9])?)?$`) + resourceNameRe = regexp.MustCompile(`^(\*|[a-z0-9]([-a-z0-9]*[a-z0-9])?)(/[a-z0-9]([-a-z0-9]*[a-z0-9])?)?$`) + dnsSubdomainRe = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$`) ) // qualifiedNameRe is a Kubernetes label or annotation key: an optional DNS prefix and a name. From 8f16d2e5d9ee023ea6e46811ce259a1c1e4762cf Mon Sep 17 00:00:00 2001 From: Ivan Zvyagintsev Date: Thu, 24 Sep 2026 11:15:21 +0300 Subject: [PATCH 41/58] rbac: preallocate the object list of replacedCopies (prealloc on golangci-lint 2.8) Signed-off-by: Ivan Zvyagintsev --- pkg/linters/rbac/rules/sync.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pkg/linters/rbac/rules/sync.go b/pkg/linters/rbac/rules/sync.go index 6ba6fd18..64f295c0 100644 --- a/pkg/linters/rbac/rules/sync.go +++ b/pkg/linters/rbac/rules/sync.go @@ -1604,8 +1604,12 @@ func (r *SyncRule) replacedCopies(model *generate.Model, actual map[string]manag rendered[index.AsString()] = struct{}{} } - var all []generate.Object + n := 0 + for _, f := range model.Files { + n += len(f.Objects) + } + all := make([]generate.Object, 0, n) for _, f := range model.Files { all = append(all, f.Objects...) } From d0d4b43c3105d831bf0baa26eb598ba8276cee4e Mon Sep 17 00:00:00 2001 From: Ivan Zvyagintsev Date: Thu, 24 Sep 2026 11:34:38 +0300 Subject: [PATCH 42/58] rbac: second regression hunt -- placement names, metadata, copies, logs - An account at templates/a/b/ gets the Role and RoleBinding a:b and foreign RoleBindings d8::a:b:, as the placement rule wants; - is accepted only in a namespace of the platform. The platform namespaces live in rbaccontract, shared with placement. - access[] and prometheusAccess take labels and annotations; bootstrap imports them and every account label but heritage/module. - Sync compares the labels of declared objects, skips Helm's and the generator's annotation keys, and reads a ServiceAccount subject without a namespace in its RoleBinding's namespace. - A role counts as a replaced copy only when a binding grants it to the subjects the declaration grants its successor to. - The fix log names what a regeneration adds apart from what it removes. - A template that failed to render and a file only a person can close carry a failing fix; an empty role is reported as such, and bootstrap notes an empty legacy role. Signed-off-by: Ivan Zvyagintsev --- pkg/linters/rbac/README.md | 7 +- pkg/linters/rbac/rules/bootstrap/bootstrap.go | 67 ++++++- .../rbac/rules/bootstrap/bootstrap_test.go | 22 +++ .../rbac/rules/generate/generate_test.go | 17 +- pkg/linters/rbac/rules/generate/model.go | 32 ++-- .../rbac/rules/generate/placement_test.go | 24 +++ pkg/linters/rbac/rules/placement.go | 5 +- .../rbac/rules/rbaccontract/contract.go | 41 +++++ pkg/linters/rbac/rules/rbacyaml/types.go | 12 +- pkg/linters/rbac/rules/rbacyaml/validate.go | 4 + pkg/linters/rbac/rules/sync.go | 171 +++++++++++++++++- .../rbac/rules/sync_regressions2_test.go | 148 +++++++++++++++ .../rbac/rules/sync_regressions_test.go | 2 +- pkg/linters/rbac/rules/sync_test.go | 19 +- 14 files changed, 528 insertions(+), 43 deletions(-) create mode 100644 pkg/linters/rbac/rules/sync_regressions2_test.go diff --git a/pkg/linters/rbac/README.md b/pkg/linters/rbac/README.md index 123d113b..46d756d2 100644 --- a/pkg/linters/rbac/README.md +++ b/pkg/linters/rbac/README.md @@ -1453,6 +1453,7 @@ prometheusAccess: access: - name: admin-kubeconfig when: .Values.certManager.adminKubeconfig # optional; wraps the role and the binding, as for an account + labels: {app: cert-manager} # optional, on the role and the binding; annotations likewise subjects: - kind: Group name: kubeadm:cluster-admins @@ -1664,7 +1665,7 @@ controller ClusterRoles with arbitrary names, objects with Helm-computed names). **What it checks:** 1. Every declared object is in the render (unless it is under `when`), and every rule of it: rules are compared as `(apiGroup, resource, resourceName, verb)` tuples, in both directions. A rule under `when` that did not render is not a divergence; a rule without `when` hidden behind a hand-written `{{ if }}` is. -2. The annotations of an object written from `serviceAccounts` or `access` match the declaration's (`annotations`, `rbacAnnotations`): a `helm.sh/resource-policy: keep` the declaration does not carry would be lost by the next regeneration. +2. The labels and annotations of an object written from `serviceAccounts`, `access` or `prometheusAccess` match the declaration's (`labels`, `annotations`, `rbacAnnotations`): a `helm.sh/resource-policy: keep` or an aggregation label the declaration does not carry would be lost by the next regeneration. `heritage` and `module` (written by `helm_lib_module_labels`), Helm's `meta.helm.sh/*` and the generator's `rbac.deckhouse.io/*` annotations are not compared. A ServiceAccount subject without a namespace is read in the namespace of its RoleBinding, as Kubernetes does. 3. A capability's aggregation edges (`aggregate-to--as`) match in both directions: rules may agree while a lineage is lost. Its `rbac.deckhouse.io/capability` marker, `module` and `rbac.deckhouse.io/namespace` labels are what the generator writes. 4. A binding's `roleRef` and subjects match. 5. Every rendered legacy role and module capability is produced by the declaration. @@ -1680,7 +1681,7 @@ no longer names leaves the template; the finding that led there listed it, and t removed, so a `--fix` run without a preceding `dmt lint` does not remove rights in silence. Three things are never written over -- -- a file that also holds objects of someone else -- a controller ClusterRole beside a declared ServiceAccount, a hand-written binding, a ConfigMap or a Secret -- is never rewritten, because the generator writes the whole file and they would vanish (and so they would if the file were deleted); the refusal names them: declare them (`extraClusterRoles`, `access` with `path`) or move them first. A generated file lists under its header, one `# dmt:owns ` line each, what the generator wrote into it (contract 2); an owned object the declaration no longer produces -- a legacy level dropped, a ServiceAccount removed -- is a removal, named in the finding and in the log, and everything else in the file is someone else's. The fix does not move objects between files: an object the declaration now puts in another file is refused in the file it renders from ("move it by hand, or delete this file"), and the target is not written while the object still renders elsewhere, so nothing is lost or rendered twice. Besides the render, the fix reads the objects of a generated file from its text, so an object under a condition that is false for these values -- a hand-added ConfigMap, an account moved elsewhere -- is judged too. In a file without the list (contract 1, or hand-written), a legacy role or a module capability the declaration does not produce is a removal too; any other object is refused, whatever its name. To drop an account or an access entry from a contract 1 file, run `--fix` once with the declaration unchanged -- that writes contract 2 -- and drop it then. An object the generator produces under another name -- a binding with the same roleRef and subjects, a role with the same rules, while the new name is not rendered yet -- is replaced, not foreign; +- a file that also holds objects of someone else -- a controller ClusterRole beside a declared ServiceAccount, a hand-written binding, a ConfigMap or a Secret -- is never rewritten, because the generator writes the whole file and they would vanish (and so they would if the file were deleted); the refusal names them: declare them (`extraClusterRoles`, `access` with `path`) or move them first. A generated file lists under its header, one `# dmt:owns ` line each, what the generator wrote into it (contract 2); an owned object the declaration no longer produces -- a legacy level dropped, a ServiceAccount removed -- is a removal, named in the finding and in the log, and everything else in the file is someone else's. The fix does not move objects between files: an object the declaration now puts in another file is refused in the file it renders from ("move it by hand, or delete this file"), and the target is not written while the object still renders elsewhere, so nothing is lost or rendered twice. An object the generator writes under a new name is a different object: until the old copy is deleted from its template both render, and the finding on the old copy says so when it grants the same subjects the same rights. Besides the render, the fix reads the objects of a generated file from its text, so an object under a condition that is false for these values -- a hand-added ConfigMap, an account moved elsewhere -- is judged too. In a file without the list (contract 1, or hand-written), a legacy role or a module capability the declaration does not produce is a removal too; any other object is refused, whatever its name. To drop an account or an access entry from a contract 1 file, run `--fix` once with the declaration unchanged -- that writes contract 2 -- and drop it then. An object the generator produces under another name -- a binding with the same roleRef and subjects, a role with the same rules, while the new name is not rendered yet -- is replaced, not foreign; - a file without the generator header is maintained by hand: the generated text is written beside it as `_.generated` (the underscore keeps Helm from rendering the copy) and the finding stays (delete the file and run `--fix` again to hand it back to the generator); - a template that serves both role models behind the version gate (`rbacv2_new_scheme`) is never regenerated: the legacy branch would vanish; @@ -1726,7 +1727,7 @@ configuration error. - A conditional rule is checked only where it renders: with the default values, `dmt lint --values-file` or `dmt lint --matrix`. - **The three states a module can be in when the new `dmt` first runs.** *Only the legacy scheme* (an external module not yet migrated): `contract` reports one "migrate" finding per object; with an `rbac.yaml`, `sync` reports the generated files as absent and names the cause -- the template renders the legacy scheme -- and `--fix` leaves the legacy file alone (no generator header) with the generated version beside it. *Only the 1.78 scheme*: the ordinary case described above. *Both schemes behind the version gate* (`rbacv2-migrate-module.sh` without `--replace`): the linter's values answer the gate with the 1.78 model, so `contract` and `sync` see exactly the new objects and the legacy branch is neither judged nor "extra"; `--fix` never rewrites a gated file -- regenerating it would drop the legacy branch -- and says so. The legacy branch itself is exercised with `dmt lint --values-file` setting `global.deckhouseVersion` below 1.78; `--matrix` varies module values only, not the platform version. - The declaration is one per module and describes the union of editions. Linting a single edition directory shows the edition-only objects as absent; lint the merged tree as CI does. An `rbac.yaml` inside an edition overlay (`ee/be/modules`, `ee/se-plus/modules`, ..., and `ee/modules` for a module that also exists in `modules/`) is an error: CI merges the overlays over `modules/` before linting, so a copy there would shadow the base one or go unseen. A module that has no base elsewhere -- EE-only in `ee/modules/`, or living in one edition directory only such as `ee/be/modules/350-node-local-dns` -- has its base there. -- The generator refuses what the declaration alone cannot know is wrong: system levels on a module whose `module.yaml` names no `subsystems` (set `subsystems` in `rbac.yaml`); an account in a component directory named unlike it (the placement rule wants `` or `-`) or in a nested directory; a capability marker past 63 characters (a module name of 32 characters and up with a `superadmin` level). +- The generator refuses what the declaration alone cannot know is wrong: system levels on a module whose `module.yaml` names no `subsystems` (set `subsystems` in `rbac.yaml`); an account in a component directory named unlike it (the placement rule wants ``, with `/` as `-` for a nested directory, or `-` in a namespace of the platform such as `d8-system`). The objects of an account at `a/b` follow the placement rule too: its Role and RoleBinding are `a:b`, its bindings in other namespaces `d8::a:b:`; a capability marker past 63 characters (a module name of 32 characters and up with a `superadmin` level). - Built-in Kubernetes resources (`""`/configmaps, `apps`/deployments, `rbac.authorization.k8s.io`/clusterroles, ...) need no `scope`: the validator knows them. Anything else without a CRD in the module declares its scope. - An `rbac.yaml` of the earlier, never consumed shape (no `apiVersion`) is named for what it is: delete it and run `--fix` to write the declaration from the render. - Under `--matrix` the first declaration is written from the union of every variant's render; objects rendered only under values other than the defaults are still invisible to a default run, so lint with `--values-file` before the first regeneration if the module has such templates. diff --git a/pkg/linters/rbac/rules/bootstrap/bootstrap.go b/pkg/linters/rbac/rules/bootstrap/bootstrap.go index 94d185ae..0b179943 100644 --- a/pkg/linters/rbac/rules/bootstrap/bootstrap.go +++ b/pkg/linters/rbac/rules/bootstrap/bootstrap.go @@ -321,6 +321,10 @@ func (b *builder) capabilitiesAndLegacy() { continue } + if len(o.Rules) == 0 { + b.note("ClusterRole %s (legacy %s) has no rules and grants nothing; the declaration writes no empty legacy role, so the regeneration drops it", o.Name, level) + } + b.rename("ClusterRole", o.Name, "d8:user-authz:"+b.in.Module+":"+rbaccontract.LegacyKebab(level)) b.addRules("legacy", level, o.Rules, false) b.conditional(o) @@ -464,9 +468,7 @@ func (b *builder) serviceAccounts() { b.note("ServiceAccount %s lives in %s; the generator keeps accounts in templates/[/]rbac-for-us.yaml and will write it to templates/rbac-for-us.yaml", sa.Name, sa.Path) } - if app := sa.Labels["app"]; app != "" { - e.Labels = map[string]string{"app": app} - } + e.Labels = copyLabels(sa.Labels) if sa.Automount == nil || *sa.Automount { yes := true @@ -494,6 +496,10 @@ func (b *builder) serviceAccounts() { apart = append(apart, fmt.Sprintf("%s %s renders under `%s`", o.Kind, o.Name, orAlways(o.When))) } + if !maps.Equal(copyLabels(o.Labels), e.Labels) { + b.note("%s %s carries labels other than ServiceAccount %s; the account's objects share its labels, so the regeneration writes those", o.Kind, o.Name, sa.Name) + } + switch { case rbacFrom == "": rbacFrom = o.Kind + " " + o.Name @@ -549,8 +555,8 @@ func (b *builder) serviceAccounts() { if role, ok := b.role(b.ns(rb), rb.RoleRef.Name); ok && !b.isUsed(role) && b.ns(rb) == b.in.Namespace && e.NamespaceRules == nil && rb.RoleRef.Kind == "Role" { e.NamespaceRules = policyRules(role.Rules) - b.rename("Role", role.Name, sa.Name) - b.rename("RoleBinding", rb.Name, sa.Name) + b.rename("Role", role.Name, rbaccontract.AccountRoleName(b.in.Module, e.Path, sa.Name)) + b.rename("RoleBinding", rb.Name, rbaccontract.AccountRoleName(b.in.Module, e.Path, sa.Name)) keep(role) b.mark(role) } else if rb.RoleRef.Kind != "Role" { @@ -562,7 +568,7 @@ func (b *builder) serviceAccounts() { b.note("RoleBinding %s/%s binds %s to the Role %s again; it folds into one", b.ns(rb), rb.Name, sa.Name, rb.RoleRef.Name) } else { e.BindRoles = append(e.BindRoles, rbacyaml.RoleRef{Namespace: b.ns(rb), Name: rb.RoleRef.Name}) - b.rename("RoleBinding", b.ns(rb)+"/"+rb.Name, b.ns(rb)+"/"+clusterName+":"+rbaccontract.BindingSuffix(rb.RoleRef.Name)) + b.rename("RoleBinding", b.ns(rb)+"/"+rb.Name, b.ns(rb)+"/"+rbaccontract.AccountForeignBindingPrefix(b.in.Module, e.Path, sa.Name)+":"+rbaccontract.BindingSuffix(rb.RoleRef.Name)) } if rb.RoleRef.Kind == "Role" { @@ -621,7 +627,8 @@ func (b *builder) otherBindings() { } name := strings.TrimPrefix(cr.Name, "d8:"+b.in.Module+":") - b.decl.Access = append(b.decl.Access, rbacyaml.Access{Name: name, Path: componentOf(cr.Path, "rbac-for-us.yaml"), When: accessWhen(cr, crb), Subjects: subjects(crb.Subjects), ClusterRules: policyRules(cr.Rules)}) + b.decl.Access = append(b.decl.Access, rbacyaml.Access{Name: name, Path: componentOf(cr.Path, "rbac-for-us.yaml"), When: accessWhen(cr, crb), + Labels: b.sharedLabels(cr, crb), Annotations: b.sharedAnnotations(cr, crb), Subjects: subjects(crb.Subjects), ClusterRules: policyRules(cr.Rules)}) b.rename("ClusterRoleBinding", crb.Name, "d8:"+b.in.Module+":"+name) b.rename("ClusterRole", cr.Name, "d8:"+b.in.Module+":"+name) b.mark(cr) @@ -659,7 +666,8 @@ func (b *builder) otherBindings() { } } - b.decl.Access = append(b.decl.Access, rbacyaml.Access{Name: name, Path: path, When: accessWhen(role, rb), Subjects: subjects(rb.Subjects), NamespaceRules: policyRules(role.Rules)}) + b.decl.Access = append(b.decl.Access, rbacyaml.Access{Name: name, Path: path, When: accessWhen(role, rb), + Labels: b.sharedLabels(role, rb), Annotations: b.sharedAnnotations(role, rb), Subjects: subjects(rb.Subjects), NamespaceRules: policyRules(role.Rules)}) b.rename("Role", role.Name, rbaccontract.AccessRoleName(b.in.Module, path, name)) b.rename("RoleBinding", rb.Name, rbaccontract.AccessRoleName(b.in.Module, path, name)) b.mark(role) @@ -692,7 +700,7 @@ func (b *builder) prometheus(role, rb Object) bool { first := b.decl.PrometheusAccess == nil if first { - b.decl.PrometheusAccess = &rbacyaml.PrometheusAccess{When: rb.When} + b.decl.PrometheusAccess = &rbacyaml.PrometheusAccess{When: rb.When, Labels: b.sharedLabels(role, rb), Annotations: b.sharedAnnotations(role, rb)} if !rb.Located { b.note("prometheusAccess: the template of RoleBinding %s could not be read, so whether it gated the scraper binding is unknown; add `when: .Values.global.enabledModules | has \"prometheus\"` if it did", rb.Name) @@ -960,3 +968,44 @@ func accessWhen(role, binding Object) string { return fmt.Sprintf("TODO: %s %s renders under `%s`, %s %s under `%s`; the access entry has one condition", role.Kind, role.Name, orAlways(role.When), binding.Kind, binding.Name, orAlways(binding.When)) } + +// copyLabels returns the labels a declaration carries: helm_lib_module_labels writes heritage and +// module on every object itself. +func copyLabels(in map[string]string) map[string]string { + var out map[string]string + + for k, v := range in { + if k == "heritage" || k == "module" { + continue + } + + if out == nil { + out = make(map[string]string, len(in)) + } + + out[k] = v + } + + return out +} + +// sharedLabels are the labels of an entry whose role and binding the declaration writes with one +// set; the binding's set is kept, and a role with another is noted. +func (b *builder) sharedLabels(role, binding Object) map[string]string { + labels := copyLabels(binding.Labels) + if !maps.Equal(labels, copyLabels(role.Labels)) { + b.note("%s %s and %s %s carry different labels; the entry has one set, the binding's is kept", role.Kind, role.Name, binding.Kind, binding.Name) + } + + return labels +} + +// sharedAnnotations is sharedLabels for annotations. +func (b *builder) sharedAnnotations(role, binding Object) map[string]string { + annotations := copyAnnotations(binding.Annotations) + if !maps.Equal(annotations, copyAnnotations(role.Annotations)) { + b.note("%s %s and %s %s carry different annotations; the entry has one set, the binding's is kept", role.Kind, role.Name, binding.Kind, binding.Name) + } + + return annotations +} diff --git a/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go b/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go index c3f913ae..adb40845 100644 --- a/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go +++ b/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go @@ -444,3 +444,25 @@ func TestBuild_AnnotationsAndNestedAccessNames(t *testing.T) { assert.NotContains(t, strings.Join(got.Notes, "\n"), "will be named", "the generator writes the names the module already has") assert.Empty(t, rbacyaml.Validate(got.Decl, nil)) } + +// Labels and annotations of an access entry and of the scrape access survive the import +// (regression hunt 2, A2 and A4). +func TestBuild_AccessMetadataImported(t *testing.T) { + labels := map[string]string{"module": "m", "heritage": "deckhouse", "app": "capi"} + hook := map[string]string{"werf.io/deploy-on": "pre-install"} + nodes := []rbacv1.PolicyRule{{APIGroups: []string{""}, Resources: []string{"nodes"}, Verbs: []string{"get"}}} + + got := Build(Input{Module: "m", Namespace: "d8-m", Objects: []Object{ + {Kind: "ClusterRole", Name: "d8:m:manager", Path: "templates/rbac-for-us.yaml", Labels: labels, Annotations: hook, Rules: nodes}, + {Kind: "ClusterRoleBinding", Name: "d8:m:manager", Path: "templates/rbac-for-us.yaml", Labels: labels, Annotations: hook, + RoleRef: rbacv1.RoleRef{Kind: "ClusterRole", Name: "d8:m:manager"}, Subjects: []rbacv1.Subject{{Kind: "Group", Name: "g"}}}, + {Kind: "ServiceAccount", Name: "m", Path: "templates/rbac-for-us.yaml", Labels: map[string]string{"module": "m", "app.kubernetes.io/part-of": "gatekeeper"}}, + }}) + + require.Len(t, got.Decl.Access, 1) + assert.Equal(t, map[string]string{"app": "capi"}, got.Decl.Access[0].Labels) + assert.Equal(t, hook, got.Decl.Access[0].Annotations) + + require.Len(t, got.Decl.ServiceAccounts, 1) + assert.Equal(t, map[string]string{"app.kubernetes.io/part-of": "gatekeeper"}, got.Decl.ServiceAccounts[0].Labels) +} diff --git a/pkg/linters/rbac/rules/generate/generate_test.go b/pkg/linters/rbac/rules/generate/generate_test.go index 32ed2346..13643592 100644 --- a/pkg/linters/rbac/rules/generate/generate_test.go +++ b/pkg/linters/rbac/rules/generate/generate_test.go @@ -275,19 +275,28 @@ func TestBuild_RefusesWhatTheModuleCannotCarry(t *testing.T) { decl.ServiceAccounts = []rbacyaml.ServiceAccount{{Name: "helper", Path: "cainjector"}} _, err := Build(Input{Module: "m", Namespace: "d8-m", Subsystems: []string{"security"}, Decl: decl}) require.Error(t, err) - assert.Contains(t, err.Error(), `the placement rule wants the account named "cainjector" or "m-cainjector"`) + assert.Contains(t, err.Error(), `the placement rule wants the account named "cainjector" after its directory`) - decl.ServiceAccounts = []rbacyaml.ServiceAccount{{Name: "m-cainjector", Path: "cainjector"}, {Name: "webhook", Path: "webhook"}, {Name: "anything", Path: ""}} + decl.ServiceAccounts = []rbacyaml.ServiceAccount{{Name: "cainjector", Path: "cainjector"}, {Name: "webhook", Path: "webhook"}, {Name: "anything", Path: ""}} _, err = Build(Input{Module: "m", Namespace: "d8-m", Subsystems: []string{"security"}, Decl: decl}) require.NoError(t, err) + // The module name in front is the platform namespaces' form only (regression hunt 2, A1). + decl.ServiceAccounts = []rbacyaml.ServiceAccount{{Name: "m-cainjector", Path: "cainjector"}} + _, err = Build(Input{Module: "m", Namespace: "d8-m", Subsystems: []string{"security"}, Decl: decl}) + require.Error(t, err) + assert.Contains(t, err.Error(), `only in a namespace of the platform`) + + _, err = Build(Input{Module: "m", Namespace: "d8-system", Subsystems: []string{"security"}, Decl: decl}) + require.NoError(t, err) + decl.ServiceAccounts = []rbacyaml.ServiceAccount{{Name: "dir", Path: "some/nested/dir"}} _, err = Build(Input{Module: "m", Namespace: "d8-m", Subsystems: []string{"security"}, Decl: decl}) require.Error(t, err) - assert.Contains(t, err.Error(), `wants the account named "some-nested-dir" or "m-some-nested-dir"`) + assert.Contains(t, err.Error(), `wants the account named "some-nested-dir"`) // templates///rbac-for-us.yaml: the placement rule joins the directories. - decl.ServiceAccounts = []rbacyaml.ServiceAccount{{Name: "some-nested-dir", Path: "some/nested/dir"}, {Name: "m-a-b", Path: "a/b"}} + decl.ServiceAccounts = []rbacyaml.ServiceAccount{{Name: "some-nested-dir", Path: "some/nested/dir"}} _, err = Build(Input{Module: "m", Namespace: "d8-m", Subsystems: []string{"security"}, Decl: decl}) require.NoError(t, err) }) diff --git a/pkg/linters/rbac/rules/generate/model.go b/pkg/linters/rbac/rules/generate/model.go index 8964b708..a755127d 100644 --- a/pkg/linters/rbac/rules/generate/model.go +++ b/pkg/linters/rbac/rules/generate/model.go @@ -250,10 +250,17 @@ func checkAgainstModule(in Input) error { } // The placement rule names the account of templates///rbac-for-us.yaml after its - // directories joined with dashes, with or without the module name in front. + // directories joined with dashes; with the module name in front only in a namespace of the + // platform (d8-system, d8-monitoring, ...). dir := strings.ReplaceAll(sa.Path, "/", "-") - if sa.Name != dir && sa.Name != in.Module+"-"+dir { - return fmt.Errorf("serviceAccounts[%s].path %q: the placement rule wants the account named %q or %q after its directory; rename the account or move it to the module root", sa.Name, sa.Path, dir, in.Module+"-"+dir) + + switch { + case sa.Name == dir: + case sa.Name == in.Module+"-"+dir && rbaccontract.IsDeckhouseNamespace(in.Namespace): + case sa.Name == in.Module+"-"+dir: + return fmt.Errorf("serviceAccounts[%s].path %q: the placement rule allows the module name in front of the directory only in a namespace of the platform (d8-system, d8-monitoring, ...); in %s name the account %q", sa.Name, sa.Path, in.Namespace, dir) + default: + return fmt.Errorf("serviceAccounts[%s].path %q: the placement rule wants the account named %q after its directory; rename the account or move it to the module root", sa.Name, sa.Path, dir) } } @@ -449,8 +456,9 @@ func (b *builder) serviceAccounts() { } if len(sa.NamespaceRules) > 0 { - b.add(path, Object{Kind: "Role", Name: sa.Name, Namespace: b.in.Namespace, Class: ClassDeclared, When: sa.When, Labels: labels, Annotations: copyMap(ann), Rules: policyRules(sa.NamespaceRules)}) - b.add(path, Object{Kind: "RoleBinding", Name: sa.Name, Namespace: b.in.Namespace, Class: ClassDeclared, When: sa.When, Labels: labels, Annotations: copyMap(ann), RoleRefKind: "Role", RoleRefName: sa.Name, Subjects: subject}) + roleName := rbaccontract.AccountRoleName(b.in.Module, sa.Path, sa.Name) + b.add(path, Object{Kind: "Role", Name: roleName, Namespace: b.in.Namespace, Class: ClassDeclared, When: sa.When, Labels: labels, Annotations: copyMap(ann), Rules: policyRules(sa.NamespaceRules)}) + b.add(path, Object{Kind: "RoleBinding", Name: roleName, Namespace: b.in.Namespace, Class: ClassDeclared, When: sa.When, Labels: labels, Annotations: copyMap(ann), RoleRefKind: "Role", RoleRefName: roleName, Subjects: subject}) } for _, extra := range sa.ExtraClusterRoles { @@ -471,7 +479,7 @@ func (b *builder) serviceAccounts() { for _, ref := range sa.BindRoles { b.add(path, Object{ - Kind: "RoleBinding", Name: clusterName + ":" + rbaccontract.BindingSuffix(ref.Name), Namespace: ref.Namespace, Class: ClassDeclared, When: sa.When, Labels: labels, Annotations: copyMap(ann), + Kind: "RoleBinding", Name: rbaccontract.AccountForeignBindingPrefix(b.in.Module, sa.Path, sa.Name) + ":" + rbaccontract.BindingSuffix(ref.Name), Namespace: ref.Namespace, Class: ClassDeclared, When: sa.When, Labels: labels, Annotations: copyMap(ann), RoleRefKind: "Role", RoleRefName: ref.Name, Subjects: subject, }) } @@ -501,11 +509,11 @@ func (b *builder) access() { rules = sortRules(rules) name := "access-to-" + b.in.Module - b.add("templates/rbac-to-us.yaml", Object{Kind: "Role", Name: name, Namespace: b.in.Namespace, Class: ClassDeclared, Rules: rules}) + b.add("templates/rbac-to-us.yaml", Object{Kind: "Role", Name: name, Namespace: b.in.Namespace, Class: ClassDeclared, Labels: copyMap(pa.Labels), Annotations: copyMap(pa.Annotations), Rules: rules}) // The Role is unconditional and only the binding to the scraper is gated: that is how the // modules write it today, and a Role nobody is bound to grants nothing. b.add("templates/rbac-to-us.yaml", Object{ - Kind: "RoleBinding", Name: name, Namespace: b.in.Namespace, Class: ClassDeclared, RoleRefKind: "Role", RoleRefName: name, When: pa.When, + Kind: "RoleBinding", Name: name, Namespace: b.in.Namespace, Class: ClassDeclared, Labels: copyMap(pa.Labels), Annotations: copyMap(pa.Annotations), RoleRefKind: "Role", RoleRefName: name, When: pa.When, Subjects: []Subject{{Kind: "User", Name: "d8-monitoring:scraper"}, {Kind: "ServiceAccount", Name: "prometheus", Namespace: "d8-monitoring"}}, }) } @@ -526,15 +534,15 @@ func (b *builder) access() { if len(a.ClusterRules) > 0 { name := "d8:" + b.in.Module + ":" + a.Name - b.add(dir+"rbac-for-us.yaml", Object{Kind: "ClusterRole", Name: name, Class: ClassDeclared, When: a.When, Rules: policyRules(a.ClusterRules)}) - b.add(dir+"rbac-for-us.yaml", Object{Kind: "ClusterRoleBinding", Name: name, Class: ClassDeclared, When: a.When, RoleRefKind: "ClusterRole", RoleRefName: name, Subjects: subjects}) + b.add(dir+"rbac-for-us.yaml", Object{Kind: "ClusterRole", Name: name, Class: ClassDeclared, When: a.When, Labels: copyMap(a.Labels), Annotations: copyMap(a.Annotations), Rules: policyRules(a.ClusterRules)}) + b.add(dir+"rbac-for-us.yaml", Object{Kind: "ClusterRoleBinding", Name: name, Class: ClassDeclared, When: a.When, Labels: copyMap(a.Labels), Annotations: copyMap(a.Annotations), RoleRefKind: "ClusterRole", RoleRefName: name, Subjects: subjects}) } if len(a.NamespaceRules) > 0 { name := rbaccontract.AccessRoleName(b.in.Module, a.Path, a.Name) - b.add(dir+"rbac-to-us.yaml", Object{Kind: "Role", Name: name, Namespace: b.in.Namespace, Class: ClassDeclared, When: a.When, Rules: policyRules(a.NamespaceRules)}) - b.add(dir+"rbac-to-us.yaml", Object{Kind: "RoleBinding", Name: name, Namespace: b.in.Namespace, Class: ClassDeclared, When: a.When, RoleRefKind: "Role", RoleRefName: name, Subjects: subjects}) + b.add(dir+"rbac-to-us.yaml", Object{Kind: "Role", Name: name, Namespace: b.in.Namespace, Class: ClassDeclared, When: a.When, Labels: copyMap(a.Labels), Annotations: copyMap(a.Annotations), Rules: policyRules(a.NamespaceRules)}) + b.add(dir+"rbac-to-us.yaml", Object{Kind: "RoleBinding", Name: name, Namespace: b.in.Namespace, Class: ClassDeclared, When: a.When, Labels: copyMap(a.Labels), Annotations: copyMap(a.Annotations), RoleRefKind: "Role", RoleRefName: name, Subjects: subjects}) } } } diff --git a/pkg/linters/rbac/rules/generate/placement_test.go b/pkg/linters/rbac/rules/generate/placement_test.go index c5975026..2bcd3711 100644 --- a/pkg/linters/rbac/rules/generate/placement_test.go +++ b/pkg/linters/rbac/rules/generate/placement_test.go @@ -106,3 +106,27 @@ func TestBuild_AccountAnnotations(t *testing.T) { assert.Equal(t, 1, strings.Count(rendered, `helm.sh/resource-policy: "keep"`)) assert.Equal(t, 2, strings.Count(rendered, `werf.io/deploy-on: "pre-install"`)) } + +// Access and Prometheus entries carry labels and annotations onto their role and binding +// (regression hunt 2, A2). +func TestBuild_AccessMetadata(t *testing.T) { + decl := placementDecl() + decl.Access = []rbacyaml.Access{{ + Name: "manager", Subjects: []rbacyaml.Subject{{Kind: "Group", Name: "g"}}, + Labels: map[string]string{"app": "capi"}, Annotations: map[string]string{"werf.io/deploy-on": "pre-install"}, + ClusterRules: []rbacyaml.PolicyRule{{APIGroups: []string{""}, Resources: []string{"nodes"}, Verbs: []string{"get"}}}, + }} + decl.PrometheusAccess = &rbacyaml.PrometheusAccess{Deployments: []string{"m"}, Labels: map[string]string{"app": "m"}} + + model, err := Build(Input{Module: "m", Namespace: "d8-m", Subsystems: []string{"security"}, Decl: decl}) + require.NoError(t, err) + + for _, o := range model.File("templates/rbac-for-us.yaml").Objects { + assert.Equal(t, map[string]string{"app": "capi"}, o.Labels, o.Identity()) + assert.Equal(t, map[string]string{"werf.io/deploy-on": "pre-install"}, o.Annotations, o.Identity()) + } + + for _, o := range model.File("templates/rbac-to-us.yaml").Objects { + assert.Equal(t, map[string]string{"app": "m"}, o.Labels, o.Identity()) + } +} diff --git a/pkg/linters/rbac/rules/placement.go b/pkg/linters/rbac/rules/placement.go index fa2a928d..c1253e75 100644 --- a/pkg/linters/rbac/rules/placement.go +++ b/pkg/linters/rbac/rules/placement.go @@ -20,7 +20,6 @@ import ( "context" "fmt" "os" - "slices" "strings" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -28,6 +27,7 @@ import ( "github.com/deckhouse/dmt/internal/storage" "github.com/deckhouse/dmt/pkg" "github.com/deckhouse/dmt/pkg/errors" + "github.com/deckhouse/dmt/pkg/linters/rbac/rules/rbaccontract" ) const ( @@ -67,14 +67,13 @@ const ( ) // TODO: remove entries after 'd8-system' after fixing RBAC objects names -var deckhouseNamespaces = []string{"d8-monitoring", "d8-system", "d8-admission-policy-engine", "d8-operator-trivy", "d8-log-shipper", "d8-local-path-provisioner"} func isSystemNamespace(actual string) bool { return actual == metav1.NamespaceDefault || actual == metav1.NamespaceSystem } func isDeckhouseSystemNamespace(actual string) bool { - return slices.Contains(deckhouseNamespaces, actual) + return rbaccontract.IsDeckhouseNamespace(actual) } func (r *PlacementRule) Check(_ context.Context) { diff --git a/pkg/linters/rbac/rules/rbaccontract/contract.go b/pkg/linters/rbac/rules/rbaccontract/contract.go index 73bf8e67..a00ef4fa 100644 --- a/pkg/linters/rbac/rules/rbaccontract/contract.go +++ b/pkg/linters/rbac/rules/rbaccontract/contract.go @@ -277,3 +277,44 @@ func AccessRoleName(module, path, name string) string { return "access-to-" + strings.ReplaceAll(path, "/", "-") + "-" + name } + +// DeckhouseNamespaces are the namespaces the placement rule treats as the platform's own: an +// object there is named after the module as well as after its directory. +var DeckhouseNamespaces = []string{"d8-monitoring", "d8-system", "d8-admission-policy-engine", "d8-operator-trivy", "d8-log-shipper", "d8-local-path-provisioner"} + +// IsDeckhouseNamespace reports whether the namespace is one of DeckhouseNamespaces. +func IsDeckhouseNamespace(ns string) bool { + for _, n := range DeckhouseNamespaces { + if n == ns { + return true + } + } + + return false +} + +// AccountRoleName is the Role and RoleBinding name of an account's namespaceRules. The placement +// rule wants the objects of templates///rbac-for-us.yaml to start with a:b (or +// :a:b for an account named after the module); at the root the account's own name. +func AccountRoleName(module, path, account string) string { + if path == "" { + return account + } + + dirs := strings.ReplaceAll(path, "/", ":") + if account == module+"-"+strings.ReplaceAll(path, "/", "-") { + return module + ":" + dirs + } + + return dirs +} + +// AccountForeignBindingPrefix is the prefix of an account's RoleBindings in other namespaces +// (bindRoles): d8:: at the root, d8::: for templates///. +func AccountForeignBindingPrefix(module, path, account string) string { + if path == "" { + return "d8:" + module + ":" + account + } + + return "d8:" + module + ":" + strings.ReplaceAll(path, "/", ":") +} diff --git a/pkg/linters/rbac/rules/rbacyaml/types.go b/pkg/linters/rbac/rules/rbacyaml/types.go index 83f040b3..b2d3e75b 100644 --- a/pkg/linters/rbac/rules/rbacyaml/types.go +++ b/pkg/linters/rbac/rules/rbacyaml/types.go @@ -213,6 +213,9 @@ type PrometheusAccess struct { // `.Values.global.enabledModules | has "prometheus"`. The Role stays unconditional, so the // generated file keeps the shape of the hand-written ones. When string `yaml:"when,omitempty"` + // Labels and Annotations go on the Role and the RoleBinding. + Labels map[string]string `yaml:"labels,omitempty"` + Annotations map[string]string `yaml:"annotations,omitempty"` } // Access grants arbitrary subjects rights on the module. ClusterRules produce a ClusterRole and @@ -227,9 +230,12 @@ type Access struct { // rbac-to-us.yaml (namespaceRules) holds the objects; empty means the module root files. Path string `yaml:"path,omitempty"` // When wraps the role and the binding in {{- if }}, as for a ServiceAccount. - When string `yaml:"when,omitempty"` - ClusterRules []PolicyRule `yaml:"clusterRules,omitempty"` - NamespaceRules []PolicyRule `yaml:"namespaceRules,omitempty"` + When string `yaml:"when,omitempty"` + // Labels and Annotations go on the role and the binding. + Labels map[string]string `yaml:"labels,omitempty"` + Annotations map[string]string `yaml:"annotations,omitempty"` + ClusterRules []PolicyRule `yaml:"clusterRules,omitempty"` + NamespaceRules []PolicyRule `yaml:"namespaceRules,omitempty"` } // Subject is an RBAC subject; Namespace is required for a ServiceAccount. diff --git a/pkg/linters/rbac/rules/rbacyaml/validate.go b/pkg/linters/rbac/rules/rbacyaml/validate.go index 9fff46c0..c6edc4ee 100644 --- a/pkg/linters/rbac/rules/rbacyaml/validate.go +++ b/pkg/linters/rbac/rules/rbacyaml/validate.go @@ -122,6 +122,8 @@ func Validate(d *Declaration, crds CRDScopes) []error { } validateWhen(d.PrometheusAccess.When, "prometheusAccess", report) + validateMetadataKeys(d.PrometheusAccess.Labels, "prometheusAccess.labels", false, report) + validateMetadataKeys(d.PrometheusAccess.Annotations, "prometheusAccess.annotations", true, report) } // Several checks walk maps; the reader and the e2e expectations get one order. @@ -411,6 +413,8 @@ func validateAccess(access []Access, report reporter) { } validateWhen(a.When, where, report) + validateMetadataKeys(a.Labels, where+".labels", false, report) + validateMetadataKeys(a.Annotations, where+".annotations", true, report) if strings.HasPrefix(a.Path, "/") || strings.HasSuffix(a.Path, "/") || strings.Contains(a.Path, "..") { report("%s: path must be a directory under templates/ without leading or trailing slashes, got %q", where, a.Path) diff --git a/pkg/linters/rbac/rules/sync.go b/pkg/linters/rbac/rules/sync.go index 64f295c0..c8dd41d7 100644 --- a/pkg/linters/rbac/rules/sync.go +++ b/pkg/linters/rbac/rules/sync.go @@ -33,6 +33,7 @@ import ( corev1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" "sigs.k8s.io/yaml" @@ -232,8 +233,16 @@ func (r *SyncRule) compareRender(model *generate.Model, actual map[string]manage continue } + why := "declare its rights in rbac.yaml or remove it from the template" + + kind := obj.object.Unstructured.GetKind() + if rules, found, _ := unstructured.NestedSlice(obj.object.Unstructured.Object, "rules"); (kind == "Role" || kind == "ClusterRole") && (!found || len(rules) == 0) { + // A role without rules grants nothing and has nothing to declare. + why = "it has no rules and grants nothing, so the regeneration drops it -- remove it from the template" + } + divergences[obj.object.ShortPath()] = append(divergences[obj.object.ShortPath()], - fmt.Sprintf("%s is in the render but rbac.yaml does not produce it: declare its rights in rbac.yaml or remove it from the template", identity)) + fmt.Sprintf("%s is in the render but rbac.yaml does not produce it: %s", identity, why)) } return divergences @@ -413,7 +422,7 @@ func (r *SyncRule) report(modulePath string, model *generate.Model, divergences // comparison nor a rewrite can be trusted. if cause, skipped := dropped[path]; skipped { recordDropped(filepath.Join(modulePath, path), cause) - fileList.Errorf("%s failed to render in this run (%s); nothing in it is compared or regenerated until it renders", path, cause) + fileList.WithFix(manualFix("make "+path+" render")).Errorf("%s failed to render in this run (%s); nothing in it is compared or regenerated until it renders", path, cause) continue } @@ -446,7 +455,7 @@ func (r *SyncRule) report(modulePath string, model *generate.Model, divergences list = append(list, reason) } - fileList.Errorf("%s does not match %s: %s. Only a person can close this: the declaration does not produce this file", + fileList.WithFix(manualFix("edit "+path+" by hand")).Errorf("%s does not match %s: %s. Only a person can close this: the declaration does not produce this file", path, rbacyaml.Filename, strings.Join(list, "; ")) } } @@ -912,6 +921,7 @@ func compareObject(expected generate.Object, actual storage.StoreObject, module if expected.Class == generate.ClassDeclared { out = append(out, compareAnnotations(expected, actual)...) + out = append(out, compareLabels(expected, actual)...) } id := expected.Identity() @@ -1015,7 +1025,13 @@ func compareObject(expected generate.Object, actual storage.StoreObject, module } got := map[string]struct{}{} + for _, s := range subjects { + // Kubernetes puts a ServiceAccount subject without a namespace into the binding's. + if s.Kind == "ServiceAccount" && s.Namespace == "" && expected.Kind == "RoleBinding" { + s.Namespace = actual.Unstructured.GetNamespace() + } + got[s.Kind+"/"+s.Namespace+"/"+s.Name] = struct{}{} } @@ -1205,8 +1221,11 @@ func regenerateFix(modulePath string, file generate.File, placed map[string]stri // The declaration is the source: what it no longer names left the file. Say so where a // --fix run without a preceding lint would otherwise remove it in silence. if len(removals) > 0 { - log.Warn("rbac autofix regenerated a template and removed what the declaration does not name", - slog.String("file", file.Path), slog.Any("removed", removals)) + // The divergences go both ways: what the render has and the declaration does not + // leaves, what the declaration has and the render lacks arrives. + added, removed := splitChanges(removals) + log.Warn("rbac autofix regenerated a template: what the render had and the declaration does not name is removed, what the declaration names is added", + slog.String("file", file.Path), slog.Any("removed", removed), slog.Any("added", added)) } else { log.Info("rbac autofix regenerated a template from rbac.yaml", slog.String("file", file.Path)) } @@ -1615,6 +1634,8 @@ func (r *SyncRule) replacedCopies(model *generate.Model, actual map[string]manag } copies := map[string]string{} + renderedGrantees := renderedRoleSubjects(storage) + declaredGrantees := modelRoleSubjects(all) for index, object := range storage { id := index.AsString() @@ -1626,11 +1647,18 @@ func (r *SyncRule) replacedCopies(model *generate.Model, actual map[string]manag continue } - if !isRBACKind(object.Unstructured.GetKind()) || object.Unstructured.GetKind() == "ServiceAccount" { + kind := object.Unstructured.GetKind() + if !isRBACKind(kind) || kind == "ServiceAccount" { continue } if twin := renderedTwin(object, all, rendered); twin != "" { + // Equal rules alone do not make a copy: another account may need the same rights. A + // replaced role is granted to the subjects the declaration grants its successor to. + if (kind == "Role" || kind == "ClusterRole") && !shareGrantee(renderedGrantees[roleKey(kind, object.Unstructured.GetNamespace(), object.Unstructured.GetName())], declaredGrantees[twinRoleKey(all, twin)]) { + continue + } + copies[object.Unstructured.GetKind()+"/"+object.Unstructured.GetName()] = twin divergences[object.ShortPath()] = append(divergences[object.ShortPath()], id+" grants what "+twin+" grants, and both render: the declaration replaced it -- delete it from the template") @@ -1717,6 +1745,11 @@ func compareAnnotations(expected generate.Object, actual storage.StoreObject) [] var out []string for _, k := range slices.Sorted(maps.Keys(rendered)) { + // Helm's release annotations and the generator's own are nobody's to declare. + if strings.HasPrefix(k, "meta.helm.sh/") || strings.HasPrefix(k, "rbac.deckhouse.io/") { + continue + } + if want, ok := expected.Annotations[k]; !ok { out = append(out, fmt.Sprintf("%s: annotation %s is in the render but not declared", id, k)) } else if want != rendered[k] { @@ -1844,3 +1877,129 @@ func unrenderedObjects(modulePath string, rendered []bootstrap.Object) []string return out } + +// moduleLabels are the labels helm_lib_module_labels writes on every object; the declaration +// names the others. +var moduleLabels = map[string]bool{"heritage": true, "module": true} + +// compareLabels compares the labels of an object the declaration writes whole: an aggregation +// label or a part-of label the declaration does not carry would be dropped by the next +// regeneration, and an aggregation label is a right. +func compareLabels(expected generate.Object, actual storage.StoreObject) []string { + id := expected.Identity() + rendered := actual.Unstructured.GetLabels() + + var out []string + + for _, k := range slices.Sorted(maps.Keys(rendered)) { + if moduleLabels[k] { + continue + } + + if want, ok := expected.Labels[k]; !ok { + out = append(out, fmt.Sprintf("%s: label %s is in the render but not declared", id, k)) + } else if want != rendered[k] { + out = append(out, fmt.Sprintf("%s: label %s is %q in the render, the declaration produces %q", id, k, rendered[k], want)) + } + } + + for _, k := range slices.Sorted(maps.Keys(expected.Labels)) { + if _, ok := rendered[k]; !ok && !moduleLabels[k] { + out = append(out, fmt.Sprintf("%s: label %s is declared but absent from the render", id, k)) + } + } + + return out +} + +// roleKey names a role as bindings refer to it: a Role with its namespace, a ClusterRole without. +func roleKey(kind, namespace, name string) string { + if kind == "ClusterRole" { + namespace = "" + } + + return kind + "/" + namespace + "/" + name +} + +// renderedRoleSubjects maps every role the render binds to the subject sets of its bindings. +func renderedRoleSubjects(objects map[storage.ResourceIndex]storage.StoreObject) map[string]map[string]bool { + out := map[string]map[string]bool{} + + for _, object := range objects { + kind := object.Unstructured.GetKind() + if kind != "RoleBinding" && kind != "ClusterRoleBinding" { + continue + } + + binding := new(rbacv1.RoleBinding) + if runtime.DefaultUnstructuredConverter.FromUnstructured(object.Unstructured.UnstructuredContent(), binding) != nil { + continue + } + + key := roleKey(binding.RoleRef.Kind, object.Unstructured.GetNamespace(), binding.RoleRef.Name) + if out[key] == nil { + out[key] = map[string]bool{} + } + + out[key][subjectSet(binding.Subjects)] = true + } + + return out +} + +// modelRoleSubjects is renderedRoleSubjects for the objects the declaration produces. +func modelRoleSubjects(all []generate.Object) map[string]map[string]bool { + out := map[string]map[string]bool{} + + for _, o := range all { + if o.Kind != "RoleBinding" && o.Kind != "ClusterRoleBinding" { + continue + } + + key := roleKey(o.RoleRefKind, o.Namespace, o.RoleRefName) + if out[key] == nil { + out[key] = map[string]bool{} + } + + out[key][subjectSetOf(o.Subjects)] = true + } + + return out +} + +// twinRoleKey is the roleKey of the produced object with the identity. +func twinRoleKey(all []generate.Object, identity string) string { + for _, o := range all { + if o.Identity() == identity { + return roleKey(o.Kind, o.Namespace, o.Name) + } + } + + return "" +} + +func shareGrantee(a, b map[string]bool) bool { + for k := range a { + if b[k] { + return true + } + } + + return false +} + +// splitChanges sorts the divergences of a regenerated file into what the regeneration adds (the +// declaration names it, the render lacks it) and everything else, which it removes or changes. +func splitChanges(divergences []string) ([]string, []string) { + var added, removed []string + + for _, d := range divergences { + if strings.Contains(d, "is declared but absent from the render") { + added = append(added, d) + } else { + removed = append(removed, d) + } + } + + return added, removed +} diff --git a/pkg/linters/rbac/rules/sync_regressions2_test.go b/pkg/linters/rbac/rules/sync_regressions2_test.go new file mode 100644 index 00000000..ae961dff --- /dev/null +++ b/pkg/linters/rbac/rules/sync_regressions2_test.go @@ -0,0 +1,148 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rules + +import ( + "context" + "strings" + "testing" + + "github.com/gojuno/minimock/v3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/deckhouse/dmt/internal/mocks" + "github.com/deckhouse/dmt/pkg/errors" + "github.com/deckhouse/dmt/pkg/linters/rbac/rules/generate" + "github.com/deckhouse/dmt/pkg/linters/rbac/rules/rbacyaml" +) + +// What the generator writes for accounts and access entries in nested directories passes the +// placement rule (regression hunt 2, A1). +func TestSyncRegression_GeneratedNamesPassPlacement(t *testing.T) { + get := []rbacyaml.PolicyRule{{APIGroups: []string{""}, Resources: []string{"secrets"}, Verbs: []string{"get"}}} + nodes := []rbacyaml.PolicyRule{{APIGroups: []string{""}, Resources: []string{"nodes"}, Verbs: []string{"get"}}} + group := []rbacyaml.Subject{{Kind: "Group", Name: "g"}} + + decl := &rbacyaml.Declaration{APIVersion: rbacyaml.APIVersionV1Alpha1, + ServiceAccounts: []rbacyaml.ServiceAccount{ + {Name: "webhook-tls", Path: "webhook/tls", ClusterRules: nodes, NamespaceRules: get, BindClusterRoles: []string{"d8:rbac-proxy"}, + BindRoles: []rbacyaml.RoleRef{{Namespace: "kube-system", Name: "extension-apiserver-authentication-reader"}}}, + {Name: "cainjector", Path: "cainjector", NamespaceRules: get}, + {Name: syncModule, ClusterRules: nodes, NamespaceRules: get}, + }, + Access: []rbacyaml.Access{ + {Name: "reader", Path: "webhook/tls", Subjects: group, NamespaceRules: get}, + {Name: "nodes", Path: "webhook", Subjects: group, ClusterRules: nodes}, + }, + } + require.Empty(t, rbacyaml.Validate(decl, nil)) + + model, err := generate.Build(generate.Input{Module: syncModule, Namespace: "d8-cert-manager", Subsystems: []string{"security"}, Decl: decl}) + require.NoError(t, err) + + store := renderedFrom(t, model, nil) + + m := mocks.NewModuleMock(minimock.NewController(t)) + m.GetNameMock.Return(syncModule) + m.GetNamespaceMock.Optional().Return("d8-cert-manager") + m.GetStorageMock.Return(store.Storage) + + errorList := errors.NewLintRuleErrorsList() + NewPlacementRule(nil, m, errorList).Check(context.Background()) + + assert.Empty(t, texts(errorList)) +} + +// A hand-written role of another account with the same rules as a declared one is not a +// replaced copy: it is granted to other subjects (regression hunt 2, A3). +func TestSyncRegression_EqualRulesOfAnotherAccountAreNoCopy(t *testing.T) { + resetFixState() + t.Cleanup(resetFixState) + + modulePath := syncModuleDir(t) + model := syncModel(t, modulePath) + writeGenerated(t, modulePath, model) + + store := renderedFrom(t, model, nil) + + var role generate.Object + + for _, o := range model.File("templates/cainjector/rbac-for-us.yaml").Objects { + if o.Kind == "Role" { + role = o + } + } + + require.NotEmpty(t, role.Name) + + other := role + other.Name = "other" + putObject(t, store, "templates/other/rbac-for-us.yaml", other) + putObject(t, store, "templates/other/rbac-for-us.yaml", generate.Object{ + Kind: "RoleBinding", Name: "other", Namespace: role.Namespace, RoleRefKind: "Role", RoleRefName: "other", + Subjects: []generate.Subject{{Kind: "ServiceAccount", Name: "other", Namespace: role.Namespace}}, + }) + + got := strings.Join(texts(runSync(t, modulePath, store)), "\n") + assert.NotContains(t, got, "the declaration replaced it") + assert.NotContains(t, got, "the old copy of") +} + +// Labels of a declared object are compared, the module labels aside; a ServiceAccount subject +// without a namespace is in the RoleBinding's (regression hunt 2, A4 and A5). +func TestSyncRegression_LabelsAndSubjectNamespace(t *testing.T) { + resetFixState() + t.Cleanup(resetFixState) + + modulePath := syncModuleDir(t) + model := syncModel(t, modulePath) + writeGenerated(t, modulePath, model) + + errorList := runSync(t, modulePath, renderedFrom(t, model, func(o *generate.Object) bool { + switch { + case o.Kind == "ClusterRole" && o.Name == "d8:cert-manager:cainjector": + o.Labels = map[string]string{"app": "cainjector", "rbac.authorization.k8s.io/aggregate-to-admin": "true"} + case o.Kind == "RoleBinding" && o.Name == "cainjector": + subjects := make([]generate.Subject, 0, len(o.Subjects)) + for _, s := range o.Subjects { + s.Namespace = "" + subjects = append(subjects, s) + } + + o.Subjects = subjects + } + + return true + })) + + got := strings.Join(texts(errorList), "\n") + assert.Contains(t, got, "ClusterRole/d8:cert-manager:cainjector: label rbac.authorization.k8s.io/aggregate-to-admin is in the render but not declared") + assert.NotContains(t, got, "label heritage") + assert.NotContains(t, got, "label module") + assert.NotContains(t, got, "RoleBinding/cainjector: subject") +} + +func TestSplitChanges(t *testing.T) { + added, removed := splitChanges([]string{ + "X: (, pods, , get) is declared but absent from the render", + "X: (, pods, , list) is in the render but not declared", + "X binds Role a, the declaration binds Role b", + }) + assert.Equal(t, []string{"X: (, pods, , get) is declared but absent from the render"}, added) + assert.Len(t, removed, 2) +} diff --git a/pkg/linters/rbac/rules/sync_regressions_test.go b/pkg/linters/rbac/rules/sync_regressions_test.go index ddad58f6..e5cb1f45 100644 --- a/pkg/linters/rbac/rules/sync_regressions_test.go +++ b/pkg/linters/rbac/rules/sync_regressions_test.go @@ -486,7 +486,7 @@ func TestSyncRegression_WrittenProblems(t *testing.T) { assert.Empty(t, writtenProblems([]byte("apiVersion: rbac.deckhouse.io/v1alpha1\n"), nil, in)) assert.Contains(t, writtenProblems([]byte("apiVersion: rbac.deckhouse.io/v1alpha1\nserviceAccounts:\n - name: x\n path: a/b\n"), nil, in), - `the placement rule wants the account named "a-b" or "m-a-b"`) + `the placement rule wants the account named "a-b" after its directory`) assert.Contains(t, writtenProblems([]byte("apiVersion: rbac.deckhouse.io/v1alpha1\nserviceAccounts:\n - name: x\n when: .Values.x }}\n"), nil, in), "template delimiter") assert.Empty(t, writtenProblems([]byte("apiVersion: rbac.deckhouse.io/v1alpha1\nserviceAccounts:\n - name: x\n when: \"TODO: decide\"\n"), nil, in), "a TODO is counted on its own") } diff --git a/pkg/linters/rbac/rules/sync_test.go b/pkg/linters/rbac/rules/sync_test.go index 653fdeff..c07b477c 100644 --- a/pkg/linters/rbac/rules/sync_test.go +++ b/pkg/linters/rbac/rules/sync_test.go @@ -999,7 +999,7 @@ func TestSync_OrphanGeneratedFileIsDeleted(t *testing.T) { got = texts(errorList) require.Len(t, got, 1, "got: %v", got) assert.Contains(t, got[0], "the file also holds ClusterRole/d8:cert-manager:something-else, which the declaration does not describe. Only a person can close this") - assert.Empty(t, errorList.GetFixes()) + assertOnlyFailingFixes(t, errorList, modulePath) } // exclude-rules.sync silences an object's findings without forgetting the object: a declared @@ -1478,7 +1478,7 @@ func TestSync_DroppedTemplateIsNotRegenerated(t *testing.T) { got := texts(errorList) require.Len(t, got, 1, "got: %v", got) assert.Contains(t, got[0], rel+" failed to render in this run (required value missing); nothing in it is compared or regenerated") - assert.Empty(t, errorList.GetFixes()) + assertOnlyFailingFixes(t, errorList, modulePath) } // A module.yaml that does not parse stops the rule instead of generating without subsystems @@ -1824,3 +1824,18 @@ func snapshotTree(t *testing.T, dir string) map[string]string { return out } + +// assertOnlyFailingFixes runs the fixes of a finding only a person can close: they change nothing +// on disk and fail, so `--fix` does not exit 0 over them. +func assertOnlyFailingFixes(t *testing.T, errorList *errors.LintRuleErrorsList, modulePath string) { + t.Helper() + + before := snapshotTree(t, modulePath) + + for _, fix := range errorList.GetFixes() { + fix() + } + + assert.True(t, errorList.ContainsFailedFixes()) + assert.Equal(t, before, snapshotTree(t, modulePath)) +} From 389416ea1b39a8ccffff0ef7b2df8b5f0d4a9573 Mon Sep 17 00:00:00 2001 From: Ivan Zvyagintsev Date: Thu, 24 Sep 2026 11:39:14 +0300 Subject: [PATCH 43/58] rbac: second regression hunt -- template conditions that render - Every argument of a combined condition is parenthesized, a negation included: `and not (a) (b)` parsed but failed at render time. validateWhen refuses a function passed without its arguments. - A condition over several lines is joined; notes quoting one stay comments, and bootstrap never writes a declaration that does not parse. - A document with an include and no object of its own is the library's, whatever comments sit beside it; commented-out objects are ignored; an action ends at the first }} outside a quoted string. - Locate compares namespaces, reads kinds and names with quotes or a trailing comment, and matches computed names as patterns; an object it cannot find is noted rather than silently unconditional. - A block is partial only when it opens and closes inside the object. - Objects of a subchart stay hand-written; an account outside the module namespace is listed once. - namespaces may be declared Namespaced (a RoleBinding on it grants the Namespace itself). Signed-off-by: Ivan Zvyagintsev --- pkg/linters/rbac/README.md | 3 +- pkg/linters/rbac/rules/bootstrap/bootstrap.go | 4 + .../rbac/rules/bootstrap/conditions.go | 134 ++++++++++++++---- .../rbac/rules/bootstrap/conditions_test.go | 118 ++++++++++++++- pkg/linters/rbac/rules/bootstrap/marshal.go | 22 ++- pkg/linters/rbac/rules/rbacyaml/load_test.go | 28 ++++ pkg/linters/rbac/rules/rbacyaml/validate.go | 63 +++++++- pkg/linters/rbac/rules/sync.go | 15 ++ .../rbac/rules/sync_regressions2_test.go | 11 ++ 9 files changed, 361 insertions(+), 37 deletions(-) diff --git a/pkg/linters/rbac/README.md b/pkg/linters/rbac/README.md index 46d756d2..0ee9f10f 100644 --- a/pkg/linters/rbac/README.md +++ b/pkg/linters/rbac/README.md @@ -1547,7 +1547,8 @@ global: contract: {impact: error} # the level of this rule alone; unset it starts at warn, and the four original rules keep the linter level # module .dmtlint.yaml -- global: is read from the root only; a module file that sets -# global.linters-settings.rbac is refused rather than ignored +# global.linters-settings.rbac is refused rather than ignored. Linting one module directory +# (dmt lint modules/) makes its own .dmtlint.yaml the root, as for every dmt setting. linters-settings: rbac: exclude-rules: diff --git a/pkg/linters/rbac/rules/bootstrap/bootstrap.go b/pkg/linters/rbac/rules/bootstrap/bootstrap.go index 0b179943..c72a247c 100644 --- a/pkg/linters/rbac/rules/bootstrap/bootstrap.go +++ b/pkg/linters/rbac/rules/bootstrap/bootstrap.go @@ -290,6 +290,8 @@ func (b *builder) templateBlocks() { b.mark(o) case o.Partial: b.note("%s %s (%s) has a template block inside it: part of it depends on the values, and the declaration holds what rendered with the linter's values -- put `when` on the rules the block gates", o.Kind, o.Name, o.Path) + case !o.Located && o.Path != "": + b.note("%s %s (%s) was not found in the text of its template, so whether it renders under a condition is unknown; the declaration writes it unconditionally -- add `when` if the template has one", o.Kind, o.Name, o.Path) } } } @@ -457,6 +459,8 @@ func (b *builder) serviceAccounts() { if b.ns(sa) != b.in.Namespace { b.unmanage(sa, "outside the module namespace") + b.mark(sa) + continue } diff --git a/pkg/linters/rbac/rules/bootstrap/conditions.go b/pkg/linters/rbac/rules/bootstrap/conditions.go index 48f84aad..6da475d5 100644 --- a/pkg/linters/rbac/rules/bootstrap/conditions.go +++ b/pkg/linters/rbac/rules/bootstrap/conditions.go @@ -25,8 +25,9 @@ import ( // cannot show, because it only holds what rendered for the linter's values. type Doc struct { // Kind, Name and Namespace are the literal values of the document; Name is empty when the - // template computes it. + // template computes it, and NamePattern then matches the names it can produce. Kind, Name, Namespace string + NamePattern *regexp.Regexp // Library marks a document without an object of its own that includes a named template: the // objects it renders come from helm_lib or another chart. Library bool @@ -40,12 +41,14 @@ type Doc struct { var ( docSeparatorRe = regexp.MustCompile(`(?m)^---[ \t]*(#.*)?$`) - actionRe = regexp.MustCompile(`(?s)\{\{-?(.*?)-?\}\}`) - docKindRe = regexp.MustCompile(`(?m)^kind:[ \t]*(\S+)[ \t]*$`) - docMetadataRe = regexp.MustCompile(`(?m)^metadata:[ \t]*$`) - docFieldRe = regexp.MustCompile(`^ (name|namespace):[ \t]*(.+?)[ \t]*$`) - includeRe = regexp.MustCompile(`\{\{-?\s*(include|template)\s+"`) - variableRe = regexp.MustCompile(`\$[A-Za-z_]`) + // An action ends at the first }} outside a quoted or raw string. + actionRe = regexp.MustCompile("(?s)\\{\\{-?((?:[^\"`}]|\"(?:[^\"\\\\]|\\\\.)*\"|`[^`]*`|\\}[^}])*?)-?\\}\\}") + commentRe = regexp.MustCompile(`(?s)\{\{-?\s*/\*.*?\*/\s*-?\}\}`) + docKindRe = regexp.MustCompile(`(?m)^kind:[ \t]*["']?([A-Za-z]+)["']?[ \t]*(#.*)?$`) + docMetadataRe = regexp.MustCompile(`(?m)^metadata:[ \t]*$`) + docFieldRe = regexp.MustCompile(`^ (name|namespace):[ \t]*(.+?)[ \t]*$`) + includeRe = regexp.MustCompile(`\{\{-?\s*(include|template)\s+"`) + variableRe = regexp.MustCompile(`\$[A-Za-z_]`) ) // frame is an open template block. For an if, cur is the condition of the branch being read and @@ -68,6 +71,18 @@ type action struct { func TemplateDocs(text string) []Doc { text = strings.ReplaceAll(text, "\r\n", "\n") + // A comment is neither a block nor a document: a commented-out object renders nothing. Blank + // it, keeping the offsets. + text = commentRe.ReplaceAllStringFunc(text, func(c string) string { + return strings.Map(func(r rune) rune { + if r == '\n' { + return r + } + + return ' ' + }, c) + }) + actions := templateActions(text) var ( @@ -100,7 +115,9 @@ func TemplateDocs(text string) []Doc { } if d.Kind == "" { - if includeRe.MatchString(doc) && strings.TrimSpace(actionRe.ReplaceAllString(doc, "")) == "" { + // A document with no object of its own that includes a named template renders what the + // template holds: helm_lib's objects, typically, whatever comments sit beside it. + if includeRe.MatchString(doc) { d.Library = true d.When, d.Unmanageable = conditionOf(stack) out = append(out, d) @@ -114,12 +131,22 @@ func TemplateDocs(text string) []Doc { continue } - d.Name, d.Namespace = metadataOf(doc) + d.Name, d.Namespace, d.NamePattern = metadataOf(doc) d.When, d.Unmanageable = conditionOf(stack) + // A block that opens and closes inside the object gates part of it; one that opens here + // and stays open belongs to the documents after it. + opened := 0 + for next < len(actions) && actions[next].offset < docEnd { - if w := actions[next].words; len(w) > 0 && (w[0] == "if" || w[0] == "range" || w[0] == "with") { - d.Partial = true + if w := actions[next].words; len(w) > 0 { + switch { + case w[0] == "if" || w[0] == "range" || w[0] == "with": + opened++ + case w[0] == "end" && opened > 0: + opened-- + d.Partial = true + } } stack = apply(stack, actions[next]) @@ -205,11 +232,11 @@ func conditionOf(stack []frame) (string, string) { } for _, p := range f.prior { - parts = append(parts, "not ("+unwrap(p)+")") + parts = append(parts, "not ("+unwrap(oneLine(p))+")") } if f.cur != "" { - parts = append(parts, f.cur) + parts = append(parts, oneLine(f.cur)) } } @@ -221,30 +248,38 @@ func conditionOf(stack []frame) (string, string) { case 1: when = parts[0] default: + // Every argument of and is parenthesized, a negation too: `and not (a) (b)` parses, yet + // passes not as a value and fails when it renders. for i, p := range parts { - if !strings.HasPrefix(p, "not (") { - parts[i] = "(" + unwrap(p) + ")" - } + parts[i] = "(" + unwrap(p) + ")" } when = "and " + strings.Join(parts, " ") } // A variable of the template does not exist where the generator writes the condition. - if variableRe.MatchString(when) || strings.Contains(when, "{{") || strings.Contains(when, "}}") { + if variableRe.MatchString(when) { return "TODO: " + when + " -- the template condition uses a variable of the template; write it against the root values", "" } + // The declaration's `when` holds no delimiter: the generator writes it inside {{ if }}. + if strings.Contains(when, "{{") || strings.Contains(when, "}}") { + return "TODO: " + when + " -- the template condition holds a template delimiter, which `when` cannot; rewrite it without one", "" + } + return when, "" } -func metadataOf(doc string) (string, string) { +func metadataOf(doc string) (string, string, *regexp.Regexp) { m := docMetadataRe.FindStringIndex(doc) if m == nil { - return "", "" + return "", "", nil } - var name, namespace string + var ( + name, namespace string + pattern *regexp.Regexp + ) for _, line := range strings.Split(doc[m[1]:], "\n")[1:] { if !strings.HasPrefix(line, " ") { @@ -256,20 +291,56 @@ func metadataOf(doc string) (string, string) { continue } - value := strings.Trim(f[2], `"'`) - if strings.Contains(value, "{{") { - value = "" + value := f[2] + if !strings.Contains(value, "{{") { + if i := strings.Index(value, " #"); i >= 0 { + value = strings.TrimSpace(value[:i]) + } } + value = strings.Trim(value, `"'`) + switch { - case f[1] == "name" && name == "": - name = value + case f[1] == "name" && name == "" && pattern == nil: + if strings.Contains(value, "{{") { + pattern = namePattern(value) + } else { + name = value + } case f[1] == "namespace" && namespace == "": - namespace = value + if !strings.Contains(value, "{{") { + namespace = value + } } } - return name, namespace + return name, namespace, pattern +} + +// namePattern matches the names a templated name can produce: its literal parts in place, any +// text for each action. +func namePattern(value string) *regexp.Regexp { + var b strings.Builder + + b.WriteString("^") + + last := 0 + for _, m := range actionRe.FindAllStringIndex(value, -1) { + b.WriteString(regexp.QuoteMeta(value[last:m[0]])) + b.WriteString(".*") + + last = m[1] + } + + b.WriteString(regexp.QuoteMeta(value[last:])) + b.WriteString("$") + + return regexp.MustCompile(b.String()) +} + +// oneLine joins a condition written over several lines: the declaration holds it on one. +func oneLine(expr string) string { + return strings.Join(strings.Fields(expr), " ") } // Locate finds the document of a rendered object among the documents of its template. An object @@ -279,14 +350,15 @@ func Locate(docs []Doc, o Object) (Doc, bool) { var byName, templated []Doc for _, d := range docs { - if d.Kind != o.Kind { + // A namespace the text names must be the object's. + if d.Kind != o.Kind || (d.Namespace != "" && o.Namespace != "" && d.Namespace != o.Namespace) { continue } - switch d.Name { - case o.Name: + switch { + case d.Name != "" && d.Name == o.Name: byName = append(byName, d) - case "": + case d.Name == "" && (d.NamePattern == nil || d.NamePattern.MatchString(o.Name)): templated = append(templated, d) } } diff --git a/pkg/linters/rbac/rules/bootstrap/conditions_test.go b/pkg/linters/rbac/rules/bootstrap/conditions_test.go index 8ff82300..51f6fe45 100644 --- a/pkg/linters/rbac/rules/bootstrap/conditions_test.go +++ b/pkg/linters/rbac/rules/bootstrap/conditions_test.go @@ -17,8 +17,10 @@ limitations under the License. package bootstrap import ( + "io" "strings" "testing" + "text/template" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -87,7 +89,7 @@ func TestTemplateDocs(t *testing.T) { assert.Equal(t, ".Values.m.internal.enabled", byName["ServiceAccount/plain"].When) assert.Equal(t, "d8-m", byName["ServiceAccount/plain"].Namespace) assert.Equal(t, "and (.Values.m.internal.enabled) (.Values.m.extra)", byName["ClusterRole/d8:m:nested"].When) - assert.Equal(t, "and (.Values.m.internal.enabled) not (.Values.m.extra)", byName["ClusterRole/d8:m:otherwise"].When) + assert.Equal(t, "and (.Values.m.internal.enabled) (not (.Values.m.extra))", byName["ClusterRole/d8:m:otherwise"].When) assert.True(t, byName["ClusterRole/d8:m:otherwise"].Partial, "a rule under its own block") assert.False(t, byName["ClusterRole/d8:m:nested"].Partial) assert.Contains(t, byName["ServiceAccount/"].Unmanageable, "{{ range }}") @@ -198,3 +200,117 @@ func TestUnwrap(t *testing.T) { _, ok := Locate(TemplateDocs("{{- if (.Values.a) }}\n---\nkind: Role\nmetadata:\n name: r\n{{- if .Values.b }}\n---\nkind: Role\nmetadata:\n name: r\n{{- end }}\n{{- end }}\n"), Object{Kind: "Role", Name: "r"}) assert.False(t, ok, "two documents of one name under different conditions are not one answer") } + +// The conditions bootstrap writes render: every argument of and is parenthesized, a negation +// included, and a condition over several lines is joined (regression hunt 2, B1 and B2). +func TestTemplateDocs_ConditionsRender(t *testing.T) { + text := "{{- if .Values.a }}\n---\nkind: Role\nmetadata:\n name: first\n{{- else if .Values.b }}\n---\nkind: Role\nmetadata:\n name: second\n" + + "{{- else }}\n---\nkind: Role\nmetadata:\n name: third\n{{- end }}\n" + + "{{- if and\n .Values.c\n (not .Values.d) }}\n---\nkind: Role\nmetadata:\n name: fourth\n{{- end }}\n" + + want := map[string]string{ + "first": ".Values.a", + "second": "and (not (.Values.a)) (.Values.b)", + "third": "and (not (.Values.a)) (not (.Values.b))", + "fourth": "and .Values.c (not .Values.d)", + } + + values := map[string]any{"Values": map[string]any{"a": false, "b": true, "c": true, "d": false}} + + for _, d := range TemplateDocs(text) { + require.Contains(t, want, d.Name) + assert.Equal(t, want[d.Name], d.When, d.Name) + + tpl, err := template.New(d.Name).Parse("{{ if " + d.When + " }}yes{{ end }}") + require.NoError(t, err, d.When) + require.NoError(t, tpl.Execute(io.Discard, values), d.When) + } +} + +// What the scanner reads besides: a quoted }} in a condition, a commented-out object, an include +// with comments beside it, a kind or name with a comment or quotes, a computed name, a block +// that stays open into the next document (regression hunt 2, B3, B4, B5, B8, B9). +func TestTemplateDocs_Shapes(t *testing.T) { + text := `{{- if eq .Values.x "}}" }} +--- +kind: "Role" # the role +metadata: + name: quoted # a comment +{{- end }} +{{/* +--- +kind: ClusterRole +metadata: + name: commented +*/}} +--- +{{ include "helm_lib_csi_controller_rbac" . }} +# ========================================= +--- +kind: ServiceAccount +metadata: + name: {{ .Chart.Name }}-extra +--- +kind: ServiceAccount +metadata: + name: a +{{- if .Values.b }} +--- +kind: ServiceAccount +metadata: + name: b +{{- end }} +` + docs := TemplateDocs(text) + + byName := map[string]Doc{} + library := 0 + + for _, d := range docs { + byName[d.Kind+"/"+d.Name] = d + if d.Library { + library++ + } + } + + assert.True(t, strings.HasPrefix(byName["Role/quoted"].When, `TODO: eq .Values.x "}}" -- the template condition holds a template delimiter`), "read to the end, not cut at the quoted }}: %s", byName["Role/quoted"].When) + assert.NotContains(t, byName, "ClusterRole/commented") + assert.Equal(t, 1, library, "an include beside comments is still the library's document") + assert.False(t, byName["ServiceAccount/a"].Partial, "the if after it belongs to the next document") + assert.Equal(t, ".Values.b", byName["ServiceAccount/b"].When) + + d, ok := Locate(docs, Object{Kind: "ServiceAccount", Name: "cert-manager-extra"}) + require.True(t, ok) + assert.Empty(t, d.Name) + assert.NotNil(t, d.NamePattern) + + withoutLibrary := make([]Doc, 0, len(docs)) + for _, d := range docs { + if !d.Library { + withoutLibrary = append(withoutLibrary, d) + } + } + + _, ok = Locate(withoutLibrary, Object{Kind: "ServiceAccount", Name: "unrelated"}) + assert.False(t, ok, "a computed name matches only what it can produce") +} + +// A note quoting a condition over several lines stays a comment, and the file parses +// (regression hunt 2, B2); an account outside the module namespace is listed once (B10). +func TestMarshal_MultilineNoteAndSingleListing(t *testing.T) { + got := Build(Input{Module: "m", Namespace: "d8-m", Objects: []Object{ + {Kind: "ServiceAccount", Name: "elsewhere", Namespace: "kube-system", Path: "templates/rbac-for-us.yaml", Labels: map[string]string{"module": "m"}}, + }, Unrendered: []string{"Role/r (templates/x.yaml, under `and\n (include \"a\" .)\n .Values.b`)"}}) + + assert.Len(t, got.Unmanaged, 1, "got: %v", got.Unmanaged) + + content, err := Marshal(got) + require.NoError(t, err) + + _, err = rbacyaml.Parse(content) + require.NoError(t, err, string(content)) + + for _, line := range strings.Split(strings.TrimSpace(strings.SplitN(string(content), "apiVersion:", 2)[0]), "\n") { + assert.True(t, strings.HasPrefix(line, "#"), "a header line that is no comment: %q", line) + } +} diff --git a/pkg/linters/rbac/rules/bootstrap/marshal.go b/pkg/linters/rbac/rules/bootstrap/marshal.go index 2dfb8ea8..2b5c3a31 100644 --- a/pkg/linters/rbac/rules/bootstrap/marshal.go +++ b/pkg/linters/rbac/rules/bootstrap/marshal.go @@ -36,7 +36,7 @@ func Marshal(r Result) ([]byte, error) { head.WriteString("#\n# Notes:\n") for _, n := range r.Notes { - head.WriteString("# - " + n + "\n") + head.WriteString(commentLines("# - ", n)) } } @@ -44,7 +44,7 @@ func Marshal(r Result) ([]byte, error) { head.WriteString("#\n# Not described by the declaration (stays hand-written, as it is):\n") for _, u := range r.Unmanaged { - head.WriteString("# - " + u + "\n") + head.WriteString(commentLines("# - ", u)) } } @@ -63,3 +63,21 @@ func Marshal(r Result) ([]byte, error) { return []byte(head.String() + body.String()), nil } + +// commentLines writes a note as comment lines: a template condition quoted in it may span lines, +// and a line without # would be YAML. +func commentLines(prefix, text string) string { + lines := strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n") + + var b strings.Builder + + for i, line := range lines { + if i == 0 { + b.WriteString(prefix + line + "\n") + } else { + b.WriteString("# " + line + "\n") + } + } + + return b.String() +} diff --git a/pkg/linters/rbac/rules/rbacyaml/load_test.go b/pkg/linters/rbac/rules/rbacyaml/load_test.go index 12780693..fc4b08d0 100644 --- a/pkg/linters/rbac/rules/rbacyaml/load_test.go +++ b/pkg/linters/rbac/rules/rbacyaml/load_test.go @@ -18,6 +18,7 @@ package rbacyaml import ( "errors" + "fmt" "os" "path/filepath" "strings" @@ -365,6 +366,13 @@ scope: Namespaced noAccess: nobody`), wantErr: `scope "Namespaced" disagrees with Kubernetes, which serves /nodes as "Cluster"`, }, + "regression hunt 2 B7: namespaces declared Namespaced on purpose": { + yaml: entry(`group: "" +resource: namespaces +scope: Namespaced +namespace: {viewer: [get]}`), + wantErr: "", + }, "regression hunt B12: no dot in a resource name": { yaml: entry(`group: external.io resource: things.v1 @@ -736,3 +744,23 @@ serviceAccounts: assert.Contains(t, got, `serviceAccounts[0] (Bad_Name).clusterRules[0]: verbs holds an empty value`) assert.Contains(t, got, `serviceAccounts[0] (Bad_Name).clusterRules[0]: resources holds an empty value`) } + +// A condition that parses but passes a function without its arguments fails when it renders; the +// validator names it (regression hunt 2, B1). +func TestValidateWhen_BareFunction(t *testing.T) { + check := func(when string) string { + var got []string + + validateWhen(when, "x", func(format string, args ...any) { got = append(got, fmt.Sprintf(format, args...)) }) + + return strings.Join(got, "\n") + } + + assert.Contains(t, check("and not (.Values.a) (.Values.b)"), "passes not to another function without its arguments") + assert.Contains(t, check("and (.Values.a) not (.Values.b)"), "passes not") + assert.Empty(t, check("and (not (.Values.a)) (.Values.b)")) + assert.Empty(t, check(`.Values.global.enabledModules | has "prometheus"`)) + assert.Empty(t, check(`and .Values.a (not .Values.b)`)) + assert.Empty(t, check(`include "helper" . | eq "true"`)) + assert.Empty(t, check(`lt (now | unixEpoch) 0 | not`)) +} diff --git a/pkg/linters/rbac/rules/rbacyaml/validate.go b/pkg/linters/rbac/rules/rbacyaml/validate.go index c6edc4ee..261abfd8 100644 --- a/pkg/linters/rbac/rules/rbacyaml/validate.go +++ b/pkg/linters/rbac/rules/rbacyaml/validate.go @@ -25,6 +25,7 @@ import ( "sort" "strings" "text/template" + "text/template/parse" "github.com/Masterminds/sprig/v3" @@ -58,11 +59,66 @@ func validateWhen(when, where string, report reporter) { return } - if _, err := template.New("when").Funcs(helmFuncs).Parse("{{ if " + when + " }}{{ end }}"); err != nil { + tpl, err := template.New("when").Funcs(helmFuncs).Parse("{{ if " + when + " }}{{ end }}") + if err != nil { report("%s: when %q is not a Helm expression: %v", where, when, err) + return + } + + // `and not (a) (b)` parses: not is an argument of and, called with no arguments of its own, + // and the render fails. A function takes its arguments inside parentheses: (not (a)). + if tpl.Tree != nil && tpl.Tree.Root != nil { + if name := bareFunction(tpl.Tree.Root); name != "" { + report("%s: when %q passes %s to another function without its arguments; write (%s ...) in parentheses", where, when, name, name) + } } } +// bareFunction returns the first function that stands as an argument of another call without +// arguments of its own -- a call with none, which only a function of no parameters survives. +func bareFunction(node parse.Node) string { + switch n := node.(type) { + case *parse.ListNode: + for _, c := range n.Nodes { + if name := bareFunction(c); name != "" { + return name + } + } + case *parse.IfNode: + return bareFunction(n.Pipe) + case *parse.PipeNode: + if n == nil { + return "" + } + + for _, cmd := range n.Cmds { + for i, arg := range cmd.Args { + if id, ok := arg.(*parse.IdentifierNode); ok && i > 0 && !niladic(id.Ident) { + return id.Ident + } + + if name := bareFunction(arg); name != "" { + return name + } + } + } + } + + return "" +} + +// niladic reports whether the function takes no arguments (sprig's now, uuidv4, ...). +func niladic(name string) bool { + f, ok := helmFuncs[name] + if !ok { + return false // a builtin: and, not, eq, len, ... all take arguments + } + + t := reflect.TypeOf(f) + + return t.Kind() == reflect.Func && t.NumIn() == 0 +} + // Validate checks the declaration against the format rules. crds is what the linted tree says // about the module's own resources (the scope of every CRD under crds/); an entry whose // resource is not in it is external, and its scope has to be declared. Every problem is @@ -215,7 +271,10 @@ func resolveScope(r *Resource, crds CRDScopes) (string, string) { case r.Scope != "": // A built-in resource has the scope Kubernetes serves it with; a declared one that differs // would generate a capability that grants nothing (or a namespaced grant cluster-wide). - if builtin, ok := WellKnownScope(r.Group, r.Resource); ok && builtin != r.Scope { + // namespaces is the exception: a RoleBinding that grants get on namespaces lets its + // subject read the Namespace it is bound in, so a namespace capability declares it + // Namespaced on purpose (user-authz's view_resources does). + if builtin, ok := WellKnownScope(r.Group, r.Resource); ok && builtin != r.Scope && (r.Group != "" || base != "namespaces") { return builtin, fmt.Sprintf("scope %q disagrees with Kubernetes, which serves %s as %q", r.Scope, r.Key(), builtin) } diff --git a/pkg/linters/rbac/rules/sync.go b/pkg/linters/rbac/rules/sync.go index c8dd41d7..a8cfd770 100644 --- a/pkg/linters/rbac/rules/sync.go +++ b/pkg/linters/rbac/rules/sync.go @@ -1312,6 +1312,12 @@ func (r *SyncRule) bootstrap(declList *errors.LintRuleErrorsList) { return fmt.Errorf("render %s: %w", rbacyaml.Filename, err) } + // A file that does not parse would stay on disk and stop every later bootstrap: the + // rule only writes a declaration that is missing. + if _, err := rbacyaml.Parse(content); err != nil { + return fmt.Errorf("%s is not written: the declaration bootstrap produced does not parse (%w); this is a bug of dmt, report it with the module", rbacyaml.Filename, err) + } + if err := writeFileAtomic(path, content, 0o644); err != nil { //nolint:gosec // a source file of the module return err } @@ -1777,6 +1783,15 @@ func manualFix(what string) errors.AutofixFunc { // locateInTemplate reads the template blocks around a rendered object from its template's text: // the render only holds what rendered for the linter's values, the text holds the conditions. func locateInTemplate(modulePath string, o *bootstrap.Object, cache map[string][]bootstrap.Doc) { + // A subchart's template is written against the subchart's values, and the generator writes + // the module's own templates: the declaration has no place for its objects. + if strings.HasPrefix(o.Path, "charts/") { + o.Located = true + o.Unmanageable = "rendered by the subchart " + strings.SplitN(o.Path, "/", 3)[1] + ", whose templates and values are its own" + + return + } + docs, ok := cache[o.Path] if !ok { if content, err := os.ReadFile(filepath.Join(modulePath, o.Path)); err == nil { diff --git a/pkg/linters/rbac/rules/sync_regressions2_test.go b/pkg/linters/rbac/rules/sync_regressions2_test.go index ae961dff..30023c6f 100644 --- a/pkg/linters/rbac/rules/sync_regressions2_test.go +++ b/pkg/linters/rbac/rules/sync_regressions2_test.go @@ -27,6 +27,7 @@ import ( "github.com/deckhouse/dmt/internal/mocks" "github.com/deckhouse/dmt/pkg/errors" + "github.com/deckhouse/dmt/pkg/linters/rbac/rules/bootstrap" "github.com/deckhouse/dmt/pkg/linters/rbac/rules/generate" "github.com/deckhouse/dmt/pkg/linters/rbac/rules/rbacyaml" ) @@ -146,3 +147,13 @@ func TestSplitChanges(t *testing.T) { assert.Equal(t, []string{"X: (, pods, , get) is declared but absent from the render"}, added) assert.Len(t, removed, 2) } + +// A subchart's objects stay hand-written: its templates read its own values (regression hunt 2, +// B6). +func TestLocateInTemplate_Subchart(t *testing.T) { + o := bootstrap.Object{Kind: "ServiceAccount", Name: "subsa", Path: "charts/sub/templates/rbac-for-us.yaml"} + locateInTemplate(t.TempDir(), &o, map[string][]bootstrap.Doc{}) + + assert.True(t, o.Located) + assert.Equal(t, "rendered by the subchart sub, whose templates and values are its own", o.Unmanageable) +} From 564dff2cea3f2651cfac37d15cfc996ce0c799d8 Mon Sep 17 00:00:00 2001 From: Ivan Zvyagintsev Date: Thu, 24 Sep 2026 12:04:08 +0300 Subject: [PATCH 44/58] rbac: write a bootstrapped declaration that does not parse, naming the line A declaration bootstrap produces that does not parse is a bug of dmt. Refusing to write it left the module's developer with nothing to look at until a dmt release. The file is now written and the fix fails with the parse error, which names the line: the developer fixes or deletes that line and runs --fix again. The next lint reads the file and does not bootstrap again. Signed-off-by: Ivan Zvyagintsev --- pkg/linters/rbac/README.md | 4 +- pkg/linters/rbac/rules/sync.go | 63 ++++++++++--------- .../rbac/rules/sync_regressions2_test.go | 37 +++++++++++ 3 files changed, 75 insertions(+), 29 deletions(-) diff --git a/pkg/linters/rbac/README.md b/pkg/linters/rbac/README.md index 0ee9f10f..dad5009b 100644 --- a/pkg/linters/rbac/README.md +++ b/pkg/linters/rbac/README.md @@ -1645,7 +1645,9 @@ regeneration does not drop it in silence. A block inside an object -- a rule und -- is noted. The Prometheus scrape binding keeps its gate as `prometheusAccess.when`. The fix that writes the file keeps the finding while a `TODO` is left in it or while the linter would -refuse the written file (both are named in the fix error), and a `--fix` run with any fix left open +refuse the written file (both are named in the fix error). A written file that does not parse is a bug +of dmt; it is written all the same and the fix error names the line, so the module's developer fixes +that line and goes on, and a `--fix` run with any fix left open exits non-zero whatever the level of its finding. Nothing is written into an edition overlay. Review it, resolve the TODOs, then run `--fix` again to regenerate the templates from it. From then on `rbac.yaml` is the source. diff --git a/pkg/linters/rbac/rules/sync.go b/pkg/linters/rbac/rules/sync.go index a8cfd770..0224aa45 100644 --- a/pkg/linters/rbac/rules/sync.go +++ b/pkg/linters/rbac/rules/sync.go @@ -1312,34 +1312,7 @@ func (r *SyncRule) bootstrap(declList *errors.LintRuleErrorsList) { return fmt.Errorf("render %s: %w", rbacyaml.Filename, err) } - // A file that does not parse would stay on disk and stop every later bootstrap: the - // rule only writes a declaration that is missing. - if _, err := rbacyaml.Parse(content); err != nil { - return fmt.Errorf("%s is not written: the declaration bootstrap produced does not parse (%w); this is a bug of dmt, report it with the module", rbacyaml.Filename, err) - } - - if err := writeFileAtomic(path, content, 0o644); err != nil { //nolint:gosec // a source file of the module - return err - } - - // The file is written, but a TODO in it is a decision nobody has made yet: the finding - // stays, and so does the non-zero exit, until a person makes it (ADR, bootstrap). What - // the linter would refuse in the written file is named here too, rather than on the - // next run. - problems := writtenProblems(content, crds, in) - if len(in.Unrendered) > 0 { - problems += "; the templates hold objects no render showed, and the declaration does not: " + strings.Join(in.Unrendered, ", ") - } - - if open := openDecisions(string(content)); open > 0 { - return fmt.Errorf("%s is written; %d TODO in it are decisions only a person can make%s -- resolve them, then run `%s` to regenerate the templates", rbacyaml.Filename, open, problems, FixCommand) - } - - if problems != "" { - return fmt.Errorf("%s is written%s -- correct it, then run `%s` to regenerate the templates", rbacyaml.Filename, problems, FixCommand) - } - - return nil + return writeBootstrapped(path, content, crds, in) }) }).Errorf("%s is missing: `%s` writes it from the RBAC objects the module renders today (%d of %d objects described, the rest listed in the file as hand-written); every TODO and note in it is a decision for a person before the templates are regenerated from it", rbacyaml.Filename, FixCommand, described, len(in.Objects)) @@ -2018,3 +1991,37 @@ func splitChanges(divergences []string) ([]string, []string) { return added, removed } + +// writeBootstrapped writes the declaration bootstrap produced and says what is left for a person. +// A declaration that does not parse is a bug of dmt, yet it is written all the same: the module's +// developer fixes the line the error names and goes on, instead of waiting for a dmt release with +// nothing to look at. Bootstrap runs only while the file is missing, so the error says where to +// look. +func writeBootstrapped(path string, content []byte, crds []crdInfo, in bootstrap.Input) error { + if err := writeFileAtomic(path, content, 0o644); err != nil { //nolint:gosec // a source file of the module + return err + } + + if _, err := rbacyaml.Parse(content); err != nil { + return fmt.Errorf("%s is written, but it does not parse (%w); the declaration is complete apart from that line -- most likely a note in the header that lost its '#': fix or delete the line, then run `%s` again. This is a bug of dmt, report it with the module", + rbacyaml.Filename, err, FixCommand) + } + + // The file is written, but a TODO in it is a decision nobody has made yet: the finding stays, + // and so does the non-zero exit, until a person makes it (ADR, bootstrap). What the linter + // would refuse in the written file is named here too, rather than on the next run. + problems := writtenProblems(content, crds, in) + if len(in.Unrendered) > 0 { + problems += "; the templates hold objects no render showed, and the declaration does not: " + strings.Join(in.Unrendered, ", ") + } + + if open := openDecisions(string(content)); open > 0 { + return fmt.Errorf("%s is written; %d TODO in it are decisions only a person can make%s -- resolve them, then run `%s` to regenerate the templates", rbacyaml.Filename, open, problems, FixCommand) + } + + if problems != "" { + return fmt.Errorf("%s is written%s -- correct it, then run `%s` to regenerate the templates", rbacyaml.Filename, problems, FixCommand) + } + + return nil +} diff --git a/pkg/linters/rbac/rules/sync_regressions2_test.go b/pkg/linters/rbac/rules/sync_regressions2_test.go index 30023c6f..7850fef4 100644 --- a/pkg/linters/rbac/rules/sync_regressions2_test.go +++ b/pkg/linters/rbac/rules/sync_regressions2_test.go @@ -18,6 +18,7 @@ package rules import ( "context" + "os" "strings" "testing" @@ -157,3 +158,39 @@ func TestLocateInTemplate_Subchart(t *testing.T) { assert.True(t, o.Located) assert.Equal(t, "rendered by the subchart sub, whose templates and values are its own", o.Unmanageable) } + +// A declaration bootstrap produced that does not parse is a bug of dmt, yet it is written: the +// error names the line, the developer fixes it and goes on. The next lint reads the file, it does +// not bootstrap again. +func TestWriteBootstrapped_UnparsableIsWrittenWithTheLine(t *testing.T) { + resetFixState() + t.Cleanup(resetFixState) + + modulePath := syncModuleDir(t) + path := rbacyaml.Path(modulePath) + require.NoError(t, os.Remove(path)) + + broken := []byte("# Written by dmt\n# - a note over\n two lines that lost its #\napiVersion: rbac.deckhouse.io/v1alpha1\n") + + err := writeBootstrapped(path, broken, nil, bootstrap.Input{Module: syncModule, Namespace: "d8-cert-manager"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "rbac.yaml is written, but it does not parse") + assert.Contains(t, err.Error(), "line 3") + assert.Contains(t, err.Error(), "fix or delete the line, then run `dmt lint --linter rbac --fix` again") + + written, readErr := os.ReadFile(path) + require.NoError(t, readErr) + assert.Equal(t, broken, written) + + got := strings.Join(texts(runSync(t, modulePath, renderedFrom(t, syncModelFromFixture(t), nil))), "\n") + assert.Contains(t, got, "nothing is compared or generated until the declaration parses") + assert.NotContains(t, got, "rbac.yaml is missing") +} + +// syncModelFromFixture builds the model of the cert-manager fixture without reading the module's +// rbac.yaml, which a test may have broken. +func syncModelFromFixture(t *testing.T) *generate.Model { + t.Helper() + + return syncModel(t, syncModuleDir(t)) +} From ab822c0e913c870947b911771bc2e5fa19faa233 Mon Sep 17 00:00:00 2001 From: Ivan Zvyagintsev Date: Thu, 24 Sep 2026 12:37:16 +0300 Subject: [PATCH 45/58] rbac: keep the pilot PR to what the ADR describes Review of #479 (scope): the recovery of `when` from template text and the new format fields go beyond the ADR and bring their own class of bugs. They move to a follow-up PR after the pilots: - bootstrap/conditions.go and the scan for objects no render showed; - serviceAccounts[].annotations/rbacAnnotations, access[].when, labels and annotations on access and prometheusAccess, and the label and annotation comparison built on them; - nested account paths and their naming, the namespace label outside d8-*, the edit-distance misspelling warning; - the validation refusals of the regression hunt (built-in scopes, */, capability texts, names, empty values) and the refusal of global rbac settings in a module .dmtlint.yaml. Kept, as fixes of what the ADR describes: failing fixes for findings only a person can close, replaced copies only with shared subjects, subjects without a namespace, added/removed in the fix log, empty roles, notes written once, entry indices of the file, notes that stay comments. Signed-off-by: Ivan Zvyagintsev --- internal/modules/module.go | 8 +- pkg/config/config.go | 7 +- pkg/config/loader.go | 18 - pkg/config/rbac_keys_test.go | 33 -- pkg/linters/rbac/README.md | 72 ++- pkg/linters/rbac/rules/bootstrap/bootstrap.go | 245 +---------- .../rbac/rules/bootstrap/bootstrap_test.go | 68 +-- .../rbac/rules/bootstrap/conditions.go | 412 ------------------ .../rbac/rules/bootstrap/conditions_test.go | 316 -------------- pkg/linters/rbac/rules/bootstrap/marshal.go | 2 +- pkg/linters/rbac/rules/coverage.go | 59 +-- pkg/linters/rbac/rules/coverage_test.go | 15 +- .../rbac/rules/generate/generate_test.go | 20 +- pkg/linters/rbac/rules/generate/model.go | 73 +--- .../rbac/rules/generate/placement_test.go | 132 ------ pkg/linters/rbac/rules/placement.go | 5 +- .../rbac/rules/rbaccontract/contract.go | 52 --- pkg/linters/rbac/rules/rbacyaml/load_test.go | 110 +---- pkg/linters/rbac/rules/rbacyaml/types.go | 22 +- pkg/linters/rbac/rules/rbacyaml/validate.go | 128 +----- pkg/linters/rbac/rules/sync.go | 228 ++-------- .../rbac/rules/sync_regressions2_test.go | 70 +-- .../rbac/rules/sync_regressions_test.go | 79 +--- pkg/linters/rbac/rules/sync_test.go | 2 +- 24 files changed, 160 insertions(+), 2016 deletions(-) delete mode 100644 pkg/linters/rbac/rules/bootstrap/conditions.go delete mode 100644 pkg/linters/rbac/rules/bootstrap/conditions_test.go delete mode 100644 pkg/linters/rbac/rules/generate/placement_test.go diff --git a/internal/modules/module.go b/internal/modules/module.go index be84fcf3..0879eb1c 100644 --- a/internal/modules/module.go +++ b/internal/modules/module.go @@ -675,13 +675,7 @@ func NewModule(path string, vals *chartutil.Values, globalSchema *spec.Schema, r // Load module config cfg := &config.ModuleConfig{} - - loader := config.NewLoader(cfg, path) - if err := loader.Load(); err != nil { - return nil, fmt.Errorf("can not parse module config: %w", err) - } - - if err := loader.RefuseRootOnlyKeys(rootConfig.File); err != nil { + if err := config.NewLoader(cfg, path).Load(); err != nil { return nil, fmt.Errorf("can not parse module config: %w", err) } diff --git a/pkg/config/config.go b/pkg/config/config.go index 81a9dc34..e4446f28 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -25,8 +25,6 @@ import ( type RootConfig struct { GlobalSettings *global.Global `mapstructure:"global"` Remote RemoteSettings `mapstructure:"remote"` - // File is the .dmtlint.yaml the root configuration came from; empty without one. - File string `mapstructure:"-"` } // RemoteSettings holds the linter settings of the scopes that lint a published @@ -62,12 +60,9 @@ func NewDefaultRootConfig(dir string) (*RootConfig, error) { GlobalSettings: &global.Global{}, } - loader := NewLoader(cfg, dir) - if err := loader.Load(); err != nil { + if err := NewLoader(cfg, dir).Load(); err != nil { return nil, err } - cfg.File = loader.ConfigFileUsed() - return cfg, nil } diff --git a/pkg/config/loader.go b/pkg/config/loader.go index 69e180c0..2dd59d83 100644 --- a/pkg/config/loader.go +++ b/pkg/config/loader.go @@ -55,24 +55,6 @@ func NewLoader(cfg any, dir string) *Loader { } } -// ConfigFileUsed is the .dmtlint.yaml the loader read; empty when none was found. -func (l *Loader) ConfigFileUsed() string { - return l.viper.ConfigFileUsed() -} - -// RefuseRootOnlyKeys refuses, in a module's own .dmtlint.yaml, the keys only the root -// configuration sets. Per-rule levels of the rbac rules are read from the root only (ADR, module -// rbac.yaml); in a module's file they would be dropped without a word and the rule would keep its -// level. rootFile is the root configuration's file; the same file is the root, not a module's. -func (l *Loader) RefuseRootOnlyKeys(rootFile string) error { - used := l.viper.ConfigFileUsed() - if used == "" || used == rootFile || !l.viper.IsSet("global.linters-settings.rbac") { - return nil - } - - return fmt.Errorf("%s sets global.linters-settings.rbac, which only the root .dmtlint.yaml sets; move it there, or use linters-settings.rbac for this module", used) -} - func (l *Loader) Load() error { err := l.setConfigFile() if err != nil { diff --git a/pkg/config/rbac_keys_test.go b/pkg/config/rbac_keys_test.go index b14135f4..8ddc4fbf 100644 --- a/pkg/config/rbac_keys_test.go +++ b/pkg/config/rbac_keys_test.go @@ -115,36 +115,3 @@ linters-settings: require.NoError(t, loadFrom(t, "linters-settings:\n container:\n impakt: warn\n")) }) } - -// A module's own .dmtlint.yaml may not set the per-rule rbac levels: they are read from the root -// only and would be dropped without a word (regression hunt, B13). -func TestRefuseRootOnlyKeys(t *testing.T) { - root := t.TempDir() - module := filepath.Join(root, "modules", "m") - require.NoError(t, os.MkdirAll(module, 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(root, ".dmtlint.yaml"), []byte("global:\n linters-settings:\n rbac:\n rules:\n sync:\n impact: error\n"), 0o600)) - - rootCfg, err := NewDefaultRootConfig(root) - require.NoError(t, err) - require.Equal(t, filepath.Join(root, ".dmtlint.yaml"), rootCfg.File) - - // Without a file of its own the module reads the root one: nothing to refuse. - own := NewLoader(&ModuleConfig{}, module) - require.NoError(t, own.Load()) - require.NoError(t, own.RefuseRootOnlyKeys(rootCfg.File)) - - require.NoError(t, os.WriteFile(filepath.Join(module, ".dmtlint.yaml"), []byte("linters-settings:\n rbac:\n impact: warn\n"), 0o600)) - - own = NewLoader(&ModuleConfig{}, module) - require.NoError(t, own.Load()) - require.NoError(t, own.RefuseRootOnlyKeys(rootCfg.File)) - - require.NoError(t, os.WriteFile(filepath.Join(module, ".dmtlint.yaml"), []byte("global:\n linters-settings:\n rbac:\n rules:\n sync:\n impact: ignored\n"), 0o600)) - - own = NewLoader(&ModuleConfig{}, module) - require.NoError(t, own.Load()) - - err = own.RefuseRootOnlyKeys(rootCfg.File) - require.Error(t, err) - assert.Contains(t, err.Error(), "sets global.linters-settings.rbac, which only the root .dmtlint.yaml sets") -} diff --git a/pkg/linters/rbac/README.md b/pkg/linters/rbac/README.md index dad5009b..2a1c66d7 100644 --- a/pkg/linters/rbac/README.md +++ b/pkg/linters/rbac/README.md @@ -1423,11 +1423,8 @@ capabilities: serviceAccounts: - name: cainjector path: cainjector # templates/cainjector/rbac-for-us.yaml; omitted -> templates/rbac-for-us.yaml - # a nested path a/b names the account a-b or -a-b (placement rule) when: .Values.certManager.internal.enableCAInjector labels: {app: cainjector} - annotations: {helm.sh/resource-policy: keep} # on the ServiceAccount - rbacAnnotations: {werf.io/deploy-on: pre-install} # on every role and binding of the account clusterRules: # ClusterRole d8:: + ClusterRoleBinding - apiGroups: [cert-manager.io] resources: [certificates] @@ -1452,8 +1449,6 @@ prometheusAccess: # Arbitrary subjects: clusterRules -> templates/[/]rbac-for-us.yaml, namespaceRules -> templates/[/]rbac-to-us.yaml access: - name: admin-kubeconfig - when: .Values.certManager.adminKubeconfig # optional; wraps the role and the binding, as for an account - labels: {app: cert-manager} # optional, on the role and the binding; annotations likewise subjects: - kind: Group name: kubeadm:cluster-admins @@ -1470,13 +1465,12 @@ Format rules the loader enforces: - No value holds `{{` or `}}`: the generator writes the values into Helm templates as they are, and a `when` is the condition only. - `legacy.SuperAdmin` validates but is a warning: user-authz aggregates custom legacy roles for `User` through `ClusterAdmin` only. - `prometheusAccess.when` gates the RoleBinding of the Prometheus scraper (typically `.Values.global.enabledModules | has "prometheus"`); the Role stays unconditional. +- Messages name a resource entry by its index in the file as written (`resources[3]`), although the entries are compared in sorted order. - Levels: `namespace` -- `viewer`, `user`, `manager`, `admin`, `superadmin`; `system` -- `viewer`, `manager`, `superadmin`; `legacy` -- `User`, `PrivilegedUser`, `Editor`, `Admin`, `ClusterEditor`, `ClusterAdmin`, `SuperAdmin`. - `namespace` levels are allowed only for `Namespaced` resources; a `Namespaced` resource at a `system` level needs a `reason`. -- `scope` is required for a resource the module ships no CRD for, and must agree with the CRD when the module ships one, or with Kubernetes for a built-in resource (`nodes` is `Cluster`). `resource: "*"` and `resource: "*/"` are allowed only for a group without CRDs in the module and need a `reason`. A resource is a lowercase plural without dots. +- `scope` is required for a resource the module ships no CRD for, and must agree with the CRD when the module ships one. `resource: "*"` is allowed only for a group without CRDs in the module and needs a `reason`. - `noAccess` is a non-empty reason and excludes the levels. `noAccess: "TODO"` is the stub the coverage autofix writes; it is not a decision. Any `noAccess`, `reason` or `scope` value that starts with `TODO` is an open decision: `coverage` reports it, and a `--fix` run that meets one exits non-zero. -- A capability outside the view/edit convention (`admin`, `user`, `superadmin`) needs `capabilities..` texts in both languages; texts for a level no entry grants are an error (a typo in the key, or a removed entry). -- A ServiceAccount name is a DNS subdomain; label and annotation keys are qualified names, and `rbac.deckhouse.io/*` and `meta.helm.sh/*` annotations are not the declaration's. A rule holds no empty verb, resource, resource name or URL. -- Messages name a resource entry by its index in the file as written (`resources[3]`), although the entries are compared in sorted order. +- A capability outside the view/edit convention (`admin`, `user`, `superadmin`) needs `capabilities..` texts in both languages. What the generator produces from it (level `viewer` -> capability `view`, `manager` -> `edit`, the rest as they are): @@ -1487,11 +1481,7 @@ What the generator produces from it (level `viewer` -> capability `view`, `manag | `resources[].legacy.` | `templates/user-authz-cluster-roles.yaml` | ClusterRole `d8:user-authz::` with the `user-authz.deckhouse.io/access-level` annotation | | `serviceAccounts[]` | `templates/[/]rbac-for-us.yaml` | ServiceAccount, ClusterRole/ClusterRoleBinding `d8::`, Role/RoleBinding ``, the extra bindings | | `access[]` with `clusterRules` | `templates/rbac-for-us.yaml` | ClusterRole/ClusterRoleBinding `d8::` | -| `prometheusAccess`, `access[]` with `namespaceRules` | `templates/[/]rbac-to-us.yaml` | Role/RoleBinding `access-to-[-]`; with `path` `access-to--`, as the placement rule wants | - -Every object gets the `rbac.deckhouse.io/namespace` label of the module namespace unless that namespace is -`default`: user-authz projects the module's use roles by it, and `kube-system` is a module namespace as -any `d8-*` one. +| `prometheusAccess`, `access[]` with `namespaceRules` | `templates/rbac-to-us.yaml` | Role/RoleBinding `access-to-[-]` | Every generated file starts with a header line naming the generator and the contract version. A file without that header is maintained by hand and is never overwritten. @@ -1546,9 +1536,7 @@ global: rules: contract: {impact: error} # the level of this rule alone; unset it starts at warn, and the four original rules keep the linter level -# module .dmtlint.yaml -- global: is read from the root only; a module file that sets -# global.linters-settings.rbac is refused rather than ignored. Linting one module directory -# (dmt lint modules/) makes its own .dmtlint.yaml the root, as for every dmt setting. +# module .dmtlint.yaml linters-settings: rbac: exclude-rules: @@ -1575,7 +1563,7 @@ Runs only when the module has an `rbac.yaml`. Reads the CRDs under `crds/` at an 1. Every CRD (`spec.group` / `spec.names.plural`) has an entry in `resources` that grants levels or denies access with a reason -- **error**, with an autofix. 2. An entry left as `noAccess: "TODO"` -- **error**, no autofix: only a person can decide. -3. An entry naming a group the module ships CRDs for, but a resource none of them spells, one or two letters away from one that is -- **warning** (a likely misspelling). A resource further away is another module's CRD in a shared group (`deckhouse.io`). Whole-group (`"*"`) and subresource (`/`) entries are exempt. +3. An entry naming a group the module ships CRDs for, but a resource none of them spells -- **warning** (a likely misspelling). Whole-group (`"*"`) and subresource (`/`) entries are exempt. 4. A `noAccess` entry of a group the module ships no CRD for, without a `scope` -- **warning**: a removed CRD is indistinguishable from an external resource nobody grants. Add `scope` to say the resource is external, or drop the entry if its CRD is gone. **Autofix:** appends an undecided stub for each CRD without an entry -- @@ -1630,25 +1618,13 @@ with a `TODO` wherever a decision is still theirs (a resource without a CRD whos cannot know, a CRD nobody grants, a namespaced resource granted cluster-wide) and a note on top for every object the generator will name differently or cannot describe. An entry whose CRD is in `crds/` carries no `scope`: the CRD states it; an external resource whose scope is not known gets -`scope: "TODO: Namespaced or Cluster (Cluster drops the namespace levels)"`. A grant limited to `resourceNames` is never widened to every +`scope: "TODO: Namespaced or Cluster"`. A grant limited to `resourceNames` is never widened to every object: it is left out and named in a note. A role granting `*` verbs or API groups, which the format -refuses, is listed as hand-written with the reason. - -The render only holds what rendered for the linter's values, so the importer also reads the template -text around each object. An object wrapped in `{{ if X }}` gets `when: X` (an `{{ else }}` branch -`not (X)`, nested blocks an `and`); a condition that uses a template variable becomes a `TODO`, and so -does an account or access entry whose role and binding render under different conditions. An object -inside `{{ range }}`, `{{ with }}` or `{{ define }}`, one rendered by an include of a named template -(`helm_lib_*`), and a role without rules stay hand-written. An object the text holds under a -condition false for these values is in no render: the header names it, and the fix fails, so the -regeneration does not drop it in silence. A block inside an object -- a rule under its own `{{ if }}` --- is noted. The Prometheus scrape binding keeps its gate as `prometheusAccess.when`. - -The fix that writes the file keeps the finding while a `TODO` is left in it or while the linter would -refuse the written file (both are named in the fix error). A written file that does not parse is a bug -of dmt; it is written all the same and the fix error names the line, so the module's developer fixes -that line and goes on, and a `--fix` run with any fix left open -exits non-zero whatever the level of its finding. Nothing is written into an edition overlay. Review +refuses, is listed as hand-written with the reason. The fix that writes the file keeps the finding +while a `TODO` is left in it or while the linter would refuse the written file (both are named in the +fix error); a written file that does not parse would be a bug of dmt, it is written all the same and +the fix error carries the parse error. A `--fix` run with any fix left open exits non-zero whatever the +level of its finding. Nothing is written into an edition overlay. Review it, resolve the TODOs, then run `--fix` again to regenerate the templates from it. From then on `rbac.yaml` is the source. @@ -1668,23 +1644,25 @@ controller ClusterRoles with arbitrary names, objects with Helm-computed names). **What it checks:** 1. Every declared object is in the render (unless it is under `when`), and every rule of it: rules are compared as `(apiGroup, resource, resourceName, verb)` tuples, in both directions. A rule under `when` that did not render is not a divergence; a rule without `when` hidden behind a hand-written `{{ if }}` is. -2. The labels and annotations of an object written from `serviceAccounts`, `access` or `prometheusAccess` match the declaration's (`labels`, `annotations`, `rbacAnnotations`): a `helm.sh/resource-policy: keep` or an aggregation label the declaration does not carry would be lost by the next regeneration. `heritage` and `module` (written by `helm_lib_module_labels`), Helm's `meta.helm.sh/*` and the generator's `rbac.deckhouse.io/*` annotations are not compared. A ServiceAccount subject without a namespace is read in the namespace of its RoleBinding, as Kubernetes does. -3. A capability's aggregation edges (`aggregate-to--as`) match in both directions: rules may agree while a lineage is lost. Its `rbac.deckhouse.io/capability` marker, `module` and `rbac.deckhouse.io/namespace` labels are what the generator writes. -4. A binding's `roleRef` and subjects match. -5. Every rendered legacy role and module capability is produced by the declaration. -6. A file the declaration produces that does not exist while an object it holds is absent from the render is a divergence, whether or not the object is under `when`: the render cannot tell a false condition from a template nobody wrote, the text can. A file that carries the generator header is the generator's, and its text must be what the declaration renders now: a rule under `when` whose condition is false today is absent from the render without being a divergence, yet it still has to reach the template, so for generator-owned files the text is compared too. A file of another contract version is the same case. Remove the header to maintain a file by hand; then only its render is judged. +2. A capability's aggregation edges (`aggregate-to--as`) match in both directions: rules may agree while a lineage is lost. Its `rbac.deckhouse.io/capability` marker, `module` and `rbac.deckhouse.io/namespace` labels are what the generator writes. +3. A binding's `roleRef` and subjects match. +4. Every rendered legacy role and module capability is produced by the declaration. +5. A file the declaration produces that does not exist while an object it holds is absent from the render is a divergence, whether or not the object is under `when`: the render cannot tell a false condition from a template nobody wrote, the text can. A file that carries the generator header is the generator's, and its text must be what the declaration renders now: a rule under `when` whose condition is false today is absent from the render without being a divergence, yet it still has to reach the template, so for generator-owned files the text is compared too. A file of another contract version is the same case. Remove the header to maintain a file by hand; then only its render is judged. Findings are one per template file and carry the fix command; the text does not depend on the render variant. -A declaration that does not parse or validate, one the generator cannot turn into objects (an account -named against the placement rule), a broken `module.yaml` and a declaration in an edition overlay stop -the rule; their fix fails, so `--fix` exits non-zero instead of reporting a run that generated nothing. +A declaration that does not parse or validate, one the generator cannot turn into objects, a broken +`module.yaml`, a declaration in an edition overlay, a template that failed to render and a file only a +person can close stop the rule or the file; their fix fails, so `--fix` exits non-zero instead of +reporting a run that generated nothing. A ServiceAccount subject without a namespace is read in the +namespace of its RoleBinding, as Kubernetes does. A role without rules grants nothing: the finding says +the regeneration drops it. **Autofix:** regenerates the file from `rbac.yaml`. The declaration is the source of truth: a right it no longer names leaves the template; the finding that led there listed it, and the autofix logs what it -removed, so a `--fix` run without a preceding `dmt lint` does not remove rights in silence. Three things are never +removed (and, apart from that, what it added), so a `--fix` run without a preceding `dmt lint` does not remove rights in silence. Three things are never written over -- -- a file that also holds objects of someone else -- a controller ClusterRole beside a declared ServiceAccount, a hand-written binding, a ConfigMap or a Secret -- is never rewritten, because the generator writes the whole file and they would vanish (and so they would if the file were deleted); the refusal names them: declare them (`extraClusterRoles`, `access` with `path`) or move them first. A generated file lists under its header, one `# dmt:owns ` line each, what the generator wrote into it (contract 2); an owned object the declaration no longer produces -- a legacy level dropped, a ServiceAccount removed -- is a removal, named in the finding and in the log, and everything else in the file is someone else's. The fix does not move objects between files: an object the declaration now puts in another file is refused in the file it renders from ("move it by hand, or delete this file"), and the target is not written while the object still renders elsewhere, so nothing is lost or rendered twice. An object the generator writes under a new name is a different object: until the old copy is deleted from its template both render, and the finding on the old copy says so when it grants the same subjects the same rights. Besides the render, the fix reads the objects of a generated file from its text, so an object under a condition that is false for these values -- a hand-added ConfigMap, an account moved elsewhere -- is judged too. In a file without the list (contract 1, or hand-written), a legacy role or a module capability the declaration does not produce is a removal too; any other object is refused, whatever its name. To drop an account or an access entry from a contract 1 file, run `--fix` once with the declaration unchanged -- that writes contract 2 -- and drop it then. An object the generator produces under another name -- a binding with the same roleRef and subjects, a role with the same rules, while the new name is not rendered yet -- is replaced, not foreign; +- a file that also holds objects of someone else -- a controller ClusterRole beside a declared ServiceAccount, a hand-written binding, a ConfigMap or a Secret -- is never rewritten, because the generator writes the whole file and they would vanish (and so they would if the file were deleted); the refusal names them: declare them (`extraClusterRoles`, `access` with `path`) or move them first. A generated file lists under its header, one `# dmt:owns ` line each, what the generator wrote into it (contract 2); an owned object the declaration no longer produces -- a legacy level dropped, a ServiceAccount removed -- is a removal, named in the finding and in the log, and everything else in the file is someone else's. The fix does not move objects between files: an object the declaration now puts in another file is refused in the file it renders from ("move it by hand, or delete this file"), and the target is not written while the object still renders elsewhere, so nothing is lost or rendered twice. An object the generator writes under a new name is a different object: until the old copy is deleted from its template both render, and the finding on the old copy says so when a binding grants it to the subjects the declaration grants its successor to. Besides the render, the fix reads the objects of a generated file from its text, so an object under a condition that is false for these values -- a hand-added ConfigMap, an account moved elsewhere -- is judged too. In a file without the list (contract 1, or hand-written), a legacy role or a module capability the declaration does not produce is a removal too; any other object is refused, whatever its name. To drop an account or an access entry from a contract 1 file, run `--fix` once with the declaration unchanged -- that writes contract 2 -- and drop it then. An object the generator produces under another name -- a binding with the same roleRef and subjects, a role with the same rules, while the new name is not rendered yet -- is replaced, not foreign; - a file without the generator header is maintained by hand: the generated text is written beside it as `_.generated` (the underscore keeps Helm from rendering the copy) and the finding stays (delete the file and run `--fix` again to hand it back to the generator); - a template that serves both role models behind the version gate (`rbacv2_new_scheme`) is never regenerated: the legacy branch would vanish; @@ -1730,7 +1708,7 @@ configuration error. - A conditional rule is checked only where it renders: with the default values, `dmt lint --values-file` or `dmt lint --matrix`. - **The three states a module can be in when the new `dmt` first runs.** *Only the legacy scheme* (an external module not yet migrated): `contract` reports one "migrate" finding per object; with an `rbac.yaml`, `sync` reports the generated files as absent and names the cause -- the template renders the legacy scheme -- and `--fix` leaves the legacy file alone (no generator header) with the generated version beside it. *Only the 1.78 scheme*: the ordinary case described above. *Both schemes behind the version gate* (`rbacv2-migrate-module.sh` without `--replace`): the linter's values answer the gate with the 1.78 model, so `contract` and `sync` see exactly the new objects and the legacy branch is neither judged nor "extra"; `--fix` never rewrites a gated file -- regenerating it would drop the legacy branch -- and says so. The legacy branch itself is exercised with `dmt lint --values-file` setting `global.deckhouseVersion` below 1.78; `--matrix` varies module values only, not the platform version. - The declaration is one per module and describes the union of editions. Linting a single edition directory shows the edition-only objects as absent; lint the merged tree as CI does. An `rbac.yaml` inside an edition overlay (`ee/be/modules`, `ee/se-plus/modules`, ..., and `ee/modules` for a module that also exists in `modules/`) is an error: CI merges the overlays over `modules/` before linting, so a copy there would shadow the base one or go unseen. A module that has no base elsewhere -- EE-only in `ee/modules/`, or living in one edition directory only such as `ee/be/modules/350-node-local-dns` -- has its base there. -- The generator refuses what the declaration alone cannot know is wrong: system levels on a module whose `module.yaml` names no `subsystems` (set `subsystems` in `rbac.yaml`); an account in a component directory named unlike it (the placement rule wants ``, with `/` as `-` for a nested directory, or `-` in a namespace of the platform such as `d8-system`). The objects of an account at `a/b` follow the placement rule too: its Role and RoleBinding are `a:b`, its bindings in other namespaces `d8::a:b:`; a capability marker past 63 characters (a module name of 32 characters and up with a `superadmin` level). +- The generator refuses what the declaration alone cannot know is wrong: system levels on a module whose `module.yaml` names no `subsystems` (set `subsystems` in `rbac.yaml`); an account in a component directory named unlike it (the placement rule wants `` or `-`) or in a nested directory; a capability marker past 63 characters (a module name of 32 characters and up with a `superadmin` level). - Built-in Kubernetes resources (`""`/configmaps, `apps`/deployments, `rbac.authorization.k8s.io`/clusterroles, ...) need no `scope`: the validator knows them. Anything else without a CRD in the module declares its scope. - An `rbac.yaml` of the earlier, never consumed shape (no `apiVersion`) is named for what it is: delete it and run `--fix` to write the declaration from the render. - Under `--matrix` the first declaration is written from the union of every variant's render; objects rendered only under values other than the defaults are still invisible to a default run, so lint with `--values-file` before the first regeneration if the module has such templates. diff --git a/pkg/linters/rbac/rules/bootstrap/bootstrap.go b/pkg/linters/rbac/rules/bootstrap/bootstrap.go index c72a247c..7052f18e 100644 --- a/pkg/linters/rbac/rules/bootstrap/bootstrap.go +++ b/pkg/linters/rbac/rules/bootstrap/bootstrap.go @@ -22,7 +22,6 @@ package bootstrap import ( "fmt" - "maps" "regexp" "slices" "sort" @@ -48,14 +47,6 @@ type Object struct { // Aggregated marks a ClusterRole with an aggregationRule: its rules belong to the aggregation // controller, and the declaration has no place for the selectors. Aggregated bool - - // The template blocks around the object, from its template's text (TemplateDocs, Locate): - // When is the condition it renders under, Unmanageable why the declaration cannot carry that, - // Partial that a block opens inside it. Located is false when the text did not tell. - When string - Unmanageable string - Partial bool - Located bool } // Input is what the render says about the module. @@ -67,9 +58,6 @@ type Input struct { Objects []Object // CRDs maps group/plural to scope for the CRDs under crds/. CRDs map[string]string - // Unrendered are the RBAC objects the template text holds that no render showed: under a - // condition false for the linter's values. The declaration does not hold them. - Unrendered []string } // Result is the declaration with the reader's homework. @@ -186,11 +174,6 @@ func Build(in Input) Result { b := &builder{in: in, res: map[[2]string]*resourceAcc{}, restricted: map[[2]string][]string{}, texts: map[string]rbacyaml.CapabilityText{}, lineages: map[string]struct{}{}, used: map[string]struct{}{}, decl: &rbacyaml.Declaration{APIVersion: rbacyaml.APIVersionV1Alpha1}} - for _, u := range in.Unrendered { - b.note("%s is in the templates but did not render with the linter's values, so this declaration does not hold it -- add it (with its `when`), or lint with --values-file values that render it, before the templates are regenerated", u) - } - - b.templateBlocks() b.capabilitiesAndLegacy() b.serviceAccounts() b.otherBindings() @@ -208,9 +191,7 @@ var generatedModuleConfigVerbs = map[string][]string{ } // scopeTODO is the scope bootstrap writes for an external resource whose scope it cannot know. -// A Cluster-scoped resource cannot keep namespace levels: the text says so, since the entry the -// person decides on may hold them. -const scopeTODO = "TODO: Namespaced or Cluster (Cluster drops the namespace levels)" +const scopeTODO = "TODO: Namespaced or Cluster" // wildcardGrant reports whether any rule grants "*" verbs or API groups, which the declaration // refuses at every level. @@ -276,37 +257,9 @@ func (b *builder) addRules(sectionName, level string, rules []rbacv1.PolicyRule, } } -// templateBlocks keeps out what the declaration cannot describe because of the template around -// it: an object in a range, a with or a define, one rendered by a named template of a library, a -// role without rules. A block inside an object is noted: its rules depend on the values. -func (b *builder) templateBlocks() { - for _, o := range b.in.Objects { - switch { - case o.Unmanageable != "": - b.unmanage(o, o.Unmanageable) - b.mark(o) - case (b.ownClusterRole(o) || o.Kind == "Role") && len(o.Rules) == 0: - b.unmanage(o, "has no rules with these values; the declaration writes no role without them") - b.mark(o) - case o.Partial: - b.note("%s %s (%s) has a template block inside it: part of it depends on the values, and the declaration holds what rendered with the linter's values -- put `when` on the rules the block gates", o.Kind, o.Name, o.Path) - case !o.Located && o.Path != "": - b.note("%s %s (%s) was not found in the text of its template, so whether it renders under a condition is unknown; the declaration writes it unconditionally -- add `when` if the template has one", o.Kind, o.Name, o.Path) - } - } -} - -// conditional notes a capability or a legacy role under a condition: resources[] have no `when`, -// the regenerated role renders for every value. -func (b *builder) conditional(o Object) { - if o.When != "" { - b.note("ClusterRole %s renders under `%s`; resources[] capabilities and legacy roles render unconditionally -- with the condition false, the regenerated role grants what the module does not grant today", o.Name, o.When) - } -} - func (b *builder) capabilitiesAndLegacy() { for _, o := range b.in.Objects { - if o.Kind != "ClusterRole" || b.isUsed(o) { + if o.Kind != "ClusterRole" { continue } @@ -329,7 +282,6 @@ func (b *builder) capabilitiesAndLegacy() { b.rename("ClusterRole", o.Name, "d8:user-authz:"+b.in.Module+":"+rbaccontract.LegacyKebab(level)) b.addRules("legacy", level, o.Rules, false) - b.conditional(o) b.mark(o) continue @@ -356,7 +308,6 @@ func (b *builder) capabilitiesAndLegacy() { } b.addRules(lineage, level, o.Rules, lineage == rbaccontract.LineageSystem) - b.conditional(o) b.mark(o) if lineage == rbaccontract.LineageSystem { @@ -453,10 +404,6 @@ var pathRe = regexp.MustCompile(`^templates/(?:(.*)/)?rbac-for-us\.yaml$`) func (b *builder) serviceAccounts() { for _, sa := range b.byKind("ServiceAccount") { - if b.isUsed(sa) { - continue - } - if b.ns(sa) != b.in.Namespace { b.unmanage(sa, "outside the module namespace") b.mark(sa) @@ -472,7 +419,9 @@ func (b *builder) serviceAccounts() { b.note("ServiceAccount %s lives in %s; the generator keeps accounts in templates/[/]rbac-for-us.yaml and will write it to templates/rbac-for-us.yaml", sa.Name, sa.Path) } - e.Labels = copyLabels(sa.Labels) + if app := sa.Labels["app"]; app != "" { + e.Labels = map[string]string{"app": app} + } if sa.Automount == nil || *sa.Automount { yes := true @@ -481,45 +430,17 @@ func (b *builder) serviceAccounts() { b.note("ServiceAccount %s mounted its token (automountServiceAccountToken unset or true); kept as true -- set false once its pods mount the token themselves", sa.Name) } - e.Annotations = copyAnnotations(sa.Annotations) - e.When = sa.When - b.mark(sa) clusterName := "d8:" + b.in.Module + ":" + sa.Name - // The roles and bindings of the account carry one set of annotations in the format; the - // first object's set is kept and a differing one is noted. - var ( - rbacFrom string - apart []string - ) - - keep := func(o Object) { - if o.When != sa.When { - apart = append(apart, fmt.Sprintf("%s %s renders under `%s`", o.Kind, o.Name, orAlways(o.When))) - } - - if !maps.Equal(copyLabels(o.Labels), e.Labels) { - b.note("%s %s carries labels other than ServiceAccount %s; the account's objects share its labels, so the regeneration writes those", o.Kind, o.Name, sa.Name) - } - - switch { - case rbacFrom == "": - rbacFrom = o.Kind + " " + o.Name - e.RBACAnnotations = copyAnnotations(o.Annotations) - case !maps.Equal(e.RBACAnnotations, copyAnnotations(o.Annotations)): - b.note("%s %s carries annotations other than %s; the account's roles and bindings share one set (rbacAnnotations), the set of %s is kept", o.Kind, o.Name, rbacFrom, rbacFrom) - } - } - for _, crb := range b.byKind("ClusterRoleBinding") { if b.isUsed(crb) || !subjectsAreOnly(crb, sa.Name, b.in.Namespace) { continue } cr, found := b.clusterRole(crb.RoleRef.Name) - exclusive := found && !b.isUsed(cr) && b.ownClusterRole(cr) && len(b.bindingsOf(cr.Name)) == 1 + exclusive := found && b.ownClusterRole(cr) && len(b.bindingsOf(cr.Name)) == 1 switch { case exclusive && cr.Name == clusterName && e.ClusterRules == nil: @@ -544,11 +465,9 @@ func (b *builder) serviceAccounts() { } if exclusive { - keep(cr) b.mark(cr) } - keep(crb) b.mark(crb) } @@ -557,11 +476,10 @@ func (b *builder) serviceAccounts() { continue } - if role, ok := b.role(b.ns(rb), rb.RoleRef.Name); ok && !b.isUsed(role) && b.ns(rb) == b.in.Namespace && e.NamespaceRules == nil && rb.RoleRef.Kind == "Role" { + if role, ok := b.role(b.ns(rb), rb.RoleRef.Name); ok && b.ns(rb) == b.in.Namespace && e.NamespaceRules == nil && rb.RoleRef.Kind == "Role" { e.NamespaceRules = policyRules(role.Rules) - b.rename("Role", role.Name, rbaccontract.AccountRoleName(b.in.Module, e.Path, sa.Name)) - b.rename("RoleBinding", rb.Name, rbaccontract.AccountRoleName(b.in.Module, e.Path, sa.Name)) - keep(role) + b.rename("Role", role.Name, sa.Name) + b.rename("RoleBinding", rb.Name, sa.Name) b.mark(role) } else if rb.RoleRef.Kind != "Role" { // bindRoles binds Roles; the format has no RoleBinding to a ClusterRole, and turning it @@ -572,11 +490,7 @@ func (b *builder) serviceAccounts() { b.note("RoleBinding %s/%s binds %s to the Role %s again; it folds into one", b.ns(rb), rb.Name, sa.Name, rb.RoleRef.Name) } else { e.BindRoles = append(e.BindRoles, rbacyaml.RoleRef{Namespace: b.ns(rb), Name: rb.RoleRef.Name}) - b.rename("RoleBinding", b.ns(rb)+"/"+rb.Name, b.ns(rb)+"/"+rbaccontract.AccountForeignBindingPrefix(b.in.Module, e.Path, sa.Name)+":"+rbaccontract.BindingSuffix(rb.RoleRef.Name)) - } - - if rb.RoleRef.Kind == "Role" { - keep(rb) + b.rename("RoleBinding", b.ns(rb)+"/"+rb.Name, b.ns(rb)+"/"+clusterName+":"+rbaccontract.BindingSuffix(rb.RoleRef.Name)) } b.mark(rb) @@ -598,16 +512,9 @@ func (b *builder) serviceAccounts() { e.ExtraClusterRoles = append(e.ExtraClusterRoles, extra) - keep(cr) b.mark(cr) } - // The declaration puts every object of an account under the account's condition; a role or - // a binding under another one is a decision for a person. - if len(apart) > 0 { - e.When = fmt.Sprintf("TODO: the account renders under `%s`, but %s; the declaration puts all of them under one condition", orAlways(sa.When), strings.Join(apart, ", ")) - } - if n := len(e.ExtraClusterRoles); n > 1 { b.note("ServiceAccount %s has %d ClusterRoles of its own besides d8:%s:%s; they are kept separate as extraClusterRoles -- merge them into clusterRules if nothing binds them separately", sa.Name, n, b.in.Module, sa.Name) } @@ -625,14 +532,13 @@ func (b *builder) otherBindings() { } cr, found := b.clusterRole(crb.RoleRef.Name) - if !found || !b.ownClusterRole(cr) || b.isUsed(cr) { - b.unmanage(crb, fmt.Sprintf("binds %s, which is not a plain ClusterRole of this module the declaration describes", crb.RoleRef.Name)) + if !found || !b.ownClusterRole(cr) { + b.unmanage(crb, fmt.Sprintf("binds %s, which is not a plain ClusterRole of this module", crb.RoleRef.Name)) continue } name := strings.TrimPrefix(cr.Name, "d8:"+b.in.Module+":") - b.decl.Access = append(b.decl.Access, rbacyaml.Access{Name: name, Path: componentOf(cr.Path, "rbac-for-us.yaml"), When: accessWhen(cr, crb), - Labels: b.sharedLabels(cr, crb), Annotations: b.sharedAnnotations(cr, crb), Subjects: subjects(crb.Subjects), ClusterRules: policyRules(cr.Rules)}) + b.decl.Access = append(b.decl.Access, rbacyaml.Access{Name: name, Path: componentOf(cr.Path, "rbac-for-us.yaml"), Subjects: subjects(crb.Subjects), ClusterRules: policyRules(cr.Rules)}) b.rename("ClusterRoleBinding", crb.Name, "d8:"+b.in.Module+":"+name) b.rename("ClusterRole", cr.Name, "d8:"+b.in.Module+":"+name) b.mark(cr) @@ -645,7 +551,7 @@ func (b *builder) otherBindings() { } role, ok := b.role(b.ns(rb), rb.RoleRef.Name) - if !ok || b.isUsed(role) || b.ns(rb) != b.in.Namespace || rb.RoleRef.Kind != "Role" { + if !ok || b.ns(rb) != b.in.Namespace || rb.RoleRef.Kind != "Role" { b.unmanage(rb, fmt.Sprintf("binds %s/%s outside the module's own Roles", rb.RoleRef.Kind, rb.RoleRef.Name)) continue } @@ -654,26 +560,10 @@ func (b *builder) otherBindings() { continue } - path := componentOf(role.Path, "rbac-to-us.yaml") - name := rb.Name - - // The entry name is what follows the placement prefix: access-to-- in a - // component file, access-to-- (with or without the directory) at the root. - for _, prefix := range []string{ - "access-to-" + b.in.Module + "-" + strings.ReplaceAll(path, "/", "-") + "-", - "access-to-" + strings.ReplaceAll(path, "/", "-") + "-", - "access-to-" + b.in.Module + "-", - } { - if trimmed, ok := strings.CutPrefix(name, prefix); ok && trimmed != "" && (path != "" || prefix == "access-to-"+b.in.Module+"-") { - name = trimmed - break - } - } - - b.decl.Access = append(b.decl.Access, rbacyaml.Access{Name: name, Path: path, When: accessWhen(role, rb), - Labels: b.sharedLabels(role, rb), Annotations: b.sharedAnnotations(role, rb), Subjects: subjects(rb.Subjects), NamespaceRules: policyRules(role.Rules)}) - b.rename("Role", role.Name, rbaccontract.AccessRoleName(b.in.Module, path, name)) - b.rename("RoleBinding", rb.Name, rbaccontract.AccessRoleName(b.in.Module, path, name)) + name := strings.TrimPrefix(rb.Name, "access-to-"+b.in.Module+"-") + b.decl.Access = append(b.decl.Access, rbacyaml.Access{Name: name, Path: componentOf(role.Path, "rbac-to-us.yaml"), Subjects: subjects(rb.Subjects), NamespaceRules: policyRules(role.Rules)}) + b.rename("Role", role.Name, "access-to-"+b.in.Module+"-"+name) + b.rename("RoleBinding", rb.Name, "access-to-"+b.in.Module+"-"+name) b.mark(role) b.mark(rb) } @@ -702,13 +592,10 @@ func (b *builder) prometheus(role, rb Object) bool { } } - first := b.decl.PrometheusAccess == nil - if first { - b.decl.PrometheusAccess = &rbacyaml.PrometheusAccess{When: rb.When, Labels: b.sharedLabels(role, rb), Annotations: b.sharedAnnotations(role, rb)} - - if !rb.Located { - b.note("prometheusAccess: the template of RoleBinding %s could not be read, so whether it gated the scraper binding is unknown; add `when: .Values.global.enabledModules | has \"prometheus\"` if it did", rb.Name) - } + // Each note once: the gate is a question for the first scrape Role already. + if b.decl.PrometheusAccess == nil { + b.decl.PrometheusAccess = &rbacyaml.PrometheusAccess{} + b.note("prometheusAccess: the render does not show whether the template gated the scraper binding on the prometheus module; add `when: .Values.global.enabledModules | has \"prometheus\"` if it did") } else if !b.prometheusFolded { b.prometheusFolded = true b.note("several Prometheus access Roles fold into one prometheusAccess (Role access-to-%s)", b.in.Module) @@ -716,14 +603,6 @@ func (b *builder) prometheus(role, rb Object) bool { pa := b.decl.PrometheusAccess - if !first && rb.When != pa.When && !strings.HasPrefix(pa.When, "TODO") { - pa.When = fmt.Sprintf("TODO: the scraper bindings render under different conditions (`%s`, `%s`); prometheusAccess has one", orAlways(pa.When), orAlways(rb.When)) - } - - if role.When != "" { - b.note("Role %s renders under `%s`; prometheusAccess writes the Role unconditionally and gates only the binding", role.Name, role.When) - } - for _, r := range role.Rules { for _, res := range r.Resources { switch strings.TrimSuffix(res, "/prometheus-metrics") { @@ -933,83 +812,3 @@ func subset(a, b []string) bool { return true } - -// copyAnnotations returns the annotations a declaration can carry: Helm's own meta.helm.sh keys -// are stamped at install time and the rbac.deckhouse.io keys are the generator's. -func copyAnnotations(in map[string]string) map[string]string { - var out map[string]string - - for k, v := range in { - if strings.HasPrefix(k, "meta.helm.sh/") || strings.HasPrefix(k, "rbac.deckhouse.io/") { - continue - } - - if out == nil { - out = make(map[string]string, len(in)) - } - - out[k] = v - } - - return out -} - -// orAlways names an empty condition in a note. -func orAlways(when string) string { - if when == "" { - return "no condition" - } - - return when -} - -// accessWhen is the condition of an access entry: its role and its binding render together, or -// the entry holds a decision for a person. -func accessWhen(role, binding Object) string { - if role.When == binding.When { - return binding.When - } - - return fmt.Sprintf("TODO: %s %s renders under `%s`, %s %s under `%s`; the access entry has one condition", role.Kind, role.Name, orAlways(role.When), binding.Kind, binding.Name, orAlways(binding.When)) -} - -// copyLabels returns the labels a declaration carries: helm_lib_module_labels writes heritage and -// module on every object itself. -func copyLabels(in map[string]string) map[string]string { - var out map[string]string - - for k, v := range in { - if k == "heritage" || k == "module" { - continue - } - - if out == nil { - out = make(map[string]string, len(in)) - } - - out[k] = v - } - - return out -} - -// sharedLabels are the labels of an entry whose role and binding the declaration writes with one -// set; the binding's set is kept, and a role with another is noted. -func (b *builder) sharedLabels(role, binding Object) map[string]string { - labels := copyLabels(binding.Labels) - if !maps.Equal(labels, copyLabels(role.Labels)) { - b.note("%s %s and %s %s carry different labels; the entry has one set, the binding's is kept", role.Kind, role.Name, binding.Kind, binding.Name) - } - - return labels -} - -// sharedAnnotations is sharedLabels for annotations. -func (b *builder) sharedAnnotations(role, binding Object) map[string]string { - annotations := copyAnnotations(binding.Annotations) - if !maps.Equal(annotations, copyAnnotations(role.Annotations)) { - b.note("%s %s and %s %s carry different annotations; the entry has one set, the binding's is kept", role.Kind, role.Name, binding.Kind, binding.Name) - } - - return annotations -} diff --git a/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go b/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go index adb40845..b7c36184 100644 --- a/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go +++ b/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go @@ -28,6 +28,7 @@ import ( rbacv1 "k8s.io/api/rbac/v1" "github.com/deckhouse/dmt/pkg/linters/rbac/rules/generate" + "github.com/deckhouse/dmt/pkg/linters/rbac/rules/rbaccontract" "github.com/deckhouse/dmt/pkg/linters/rbac/rules/rbacyaml" ) @@ -294,7 +295,7 @@ func TestBuild_TODOsAndUnmanaged(t *testing.T) { } assert.Equal(t, "Namespaced", byKey["/pods"].Scope, "a well-known core resource gets its scope") - assert.Equal(t, "TODO: Namespaced or Cluster (Cluster drops the namespace levels)", byKey["trivy.deckhouse.io/vulnerabilityreports"].Scope, "an unknown one is a TODO value for the author (review of #479, reply to finding 9)") + assert.Equal(t, "TODO: Namespaced or Cluster", byKey["trivy.deckhouse.io/vulnerabilityreports"].Scope, "an unknown one is a TODO value for the author (review of #479, reply to finding 9)") assert.Contains(t, byKey["deckhouse.io/things"].NoAccess, "TODO", "a CRD nobody grants is an undecided entry") require.Len(t, got.Decl.ServiceAccounts, 1) @@ -413,56 +414,29 @@ func TestBuild_RepeatedBindingOfAnAccountFolds(t *testing.T) { assert.Empty(t, rbacyaml.Validate(got.Decl, nil)) } -// Annotations of an account and of its roles survive the import, apart from Helm's and the -// generator's own; a nested access Role keeps its entry name (regression hunt, B7 and B8). -func TestBuild_AnnotationsAndNestedAccessNames(t *testing.T) { +// An account outside the module namespace is listed once; an empty legacy role is noted; the scrape +// gate is asked about once, for the first Role (regression hunts 1 and 2). +func TestBuild_NotesAreWrittenOnce(t *testing.T) { labels := map[string]string{"module": "m"} - subject := []rbacv1.Subject{{Kind: "ServiceAccount", Name: "m", Namespace: "d8-m"}} - rules := []rbacv1.PolicyRule{{APIGroups: []string{""}, Resources: []string{"nodes"}, Verbs: []string{"get"}}} - - got := Build(Input{Module: "m", Namespace: "d8-m", Objects: []Object{ - {Kind: "ServiceAccount", Name: "m", Path: "templates/rbac-for-us.yaml", Labels: labels, - Annotations: map[string]string{"helm.sh/resource-policy": "keep", "meta.helm.sh/release-name": "m"}}, - {Kind: "ClusterRole", Name: "d8:m:m", Path: "templates/rbac-for-us.yaml", Labels: labels, Rules: rules, - Annotations: map[string]string{"werf.io/deploy-on": "pre-install"}}, - {Kind: "ClusterRoleBinding", Name: "d8:m:m", Path: "templates/rbac-for-us.yaml", Labels: labels, - RoleRef: rbacv1.RoleRef{Kind: "ClusterRole", Name: "d8:m:m"}, Subjects: subject, - Annotations: map[string]string{"werf.io/deploy-on": "pre-install"}}, - {Kind: "Role", Name: "access-to-webhook-reader", Namespace: "d8-m", Path: "templates/webhook/rbac-to-us.yaml", Labels: labels, - Rules: []rbacv1.PolicyRule{{APIGroups: []string{""}, Resources: []string{"secrets"}, Verbs: []string{"get"}}}}, - {Kind: "RoleBinding", Name: "access-to-webhook-reader", Namespace: "d8-m", Path: "templates/webhook/rbac-to-us.yaml", Labels: labels, - RoleRef: rbacv1.RoleRef{Kind: "Role", Name: "access-to-webhook-reader"}, Subjects: []rbacv1.Subject{{Kind: "Group", Name: "g"}}}, - }}) - - require.Len(t, got.Decl.ServiceAccounts, 1) - assert.Equal(t, map[string]string{"helm.sh/resource-policy": "keep"}, got.Decl.ServiceAccounts[0].Annotations) - assert.Equal(t, map[string]string{"werf.io/deploy-on": "pre-install"}, got.Decl.ServiceAccounts[0].RBACAnnotations) - - require.Len(t, got.Decl.Access, 1) - assert.Equal(t, "reader", got.Decl.Access[0].Name) - assert.Equal(t, "webhook", got.Decl.Access[0].Path) - assert.NotContains(t, strings.Join(got.Notes, "\n"), "will be named", "the generator writes the names the module already has") - assert.Empty(t, rbacyaml.Validate(got.Decl, nil)) -} - -// Labels and annotations of an access entry and of the scrape access survive the import -// (regression hunt 2, A2 and A4). -func TestBuild_AccessMetadataImported(t *testing.T) { - labels := map[string]string{"module": "m", "heritage": "deckhouse", "app": "capi"} - hook := map[string]string{"werf.io/deploy-on": "pre-install"} - nodes := []rbacv1.PolicyRule{{APIGroups: []string{""}, Resources: []string{"nodes"}, Verbs: []string{"get"}}} + scraper := []rbacv1.Subject{{Kind: "User", Name: "d8-monitoring:scraper"}} + metrics := func(name string) []rbacv1.PolicyRule { + return []rbacv1.PolicyRule{{APIGroups: []string{"apps"}, Resources: []string{"deployments/prometheus-metrics"}, ResourceNames: []string{name}, Verbs: []string{"get"}}} + } got := Build(Input{Module: "m", Namespace: "d8-m", Objects: []Object{ - {Kind: "ClusterRole", Name: "d8:m:manager", Path: "templates/rbac-for-us.yaml", Labels: labels, Annotations: hook, Rules: nodes}, - {Kind: "ClusterRoleBinding", Name: "d8:m:manager", Path: "templates/rbac-for-us.yaml", Labels: labels, Annotations: hook, - RoleRef: rbacv1.RoleRef{Kind: "ClusterRole", Name: "d8:m:manager"}, Subjects: []rbacv1.Subject{{Kind: "Group", Name: "g"}}}, - {Kind: "ServiceAccount", Name: "m", Path: "templates/rbac-for-us.yaml", Labels: map[string]string{"module": "m", "app.kubernetes.io/part-of": "gatekeeper"}}, + {Kind: "ServiceAccount", Name: "elsewhere", Namespace: "kube-system", Path: "templates/rbac-for-us.yaml", Labels: labels}, + {Kind: "ClusterRole", Name: "m:user", Path: "templates/user-authz-cluster-roles.yaml", Labels: labels, + Annotations: map[string]string{rbaccontract.AccessLevelAnnotation: "User"}}, + {Kind: "Role", Name: "access-to-m-a", Namespace: "d8-m", Path: "templates/rbac-to-us.yaml", Labels: labels, Rules: metrics("a")}, + {Kind: "RoleBinding", Name: "access-to-m-a", Namespace: "d8-m", Path: "templates/rbac-to-us.yaml", Labels: labels, RoleRef: rbacv1.RoleRef{Kind: "Role", Name: "access-to-m-a"}, Subjects: scraper}, + {Kind: "Role", Name: "access-to-m-b", Namespace: "d8-m", Path: "templates/rbac-to-us.yaml", Labels: labels, Rules: metrics("b")}, + {Kind: "RoleBinding", Name: "access-to-m-b", Namespace: "d8-m", Path: "templates/rbac-to-us.yaml", Labels: labels, RoleRef: rbacv1.RoleRef{Kind: "Role", Name: "access-to-m-b"}, Subjects: scraper}, }}) - require.Len(t, got.Decl.Access, 1) - assert.Equal(t, map[string]string{"app": "capi"}, got.Decl.Access[0].Labels) - assert.Equal(t, hook, got.Decl.Access[0].Annotations) + assert.Len(t, got.Unmanaged, 1, "got: %v", got.Unmanaged) - require.Len(t, got.Decl.ServiceAccounts, 1) - assert.Equal(t, map[string]string{"app.kubernetes.io/part-of": "gatekeeper"}, got.Decl.ServiceAccounts[0].Labels) + notes := strings.Join(got.Notes, "\n") + assert.Contains(t, notes, "ClusterRole m:user (legacy User) has no rules and grants nothing") + assert.Equal(t, 1, strings.Count(notes, "whether the template gated the scraper binding")) + assert.Equal(t, 1, strings.Count(notes, "several Prometheus access Roles fold")) } diff --git a/pkg/linters/rbac/rules/bootstrap/conditions.go b/pkg/linters/rbac/rules/bootstrap/conditions.go deleted file mode 100644 index 6da475d5..00000000 --- a/pkg/linters/rbac/rules/bootstrap/conditions.go +++ /dev/null @@ -1,412 +0,0 @@ -/* -Copyright 2026 Flant JSC - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package bootstrap - -import ( - "regexp" - "strings" -) - -// Doc is one YAML document of a template and the template blocks around it: what the render -// cannot show, because it only holds what rendered for the linter's values. -type Doc struct { - // Kind, Name and Namespace are the literal values of the document; Name is empty when the - // template computes it, and NamePattern then matches the names it can produce. - Kind, Name, Namespace string - NamePattern *regexp.Regexp - // Library marks a document without an object of its own that includes a named template: the - // objects it renders come from helm_lib or another chart. - Library bool - // When is the condition under which the document renders, as a `when` expression. - When string - // Unmanageable says why the declaration cannot carry the condition: a range, a with, a define. - Unmanageable string - // Partial marks a block that opens inside the object: part of it is conditional. - Partial bool -} - -var ( - docSeparatorRe = regexp.MustCompile(`(?m)^---[ \t]*(#.*)?$`) - // An action ends at the first }} outside a quoted or raw string. - actionRe = regexp.MustCompile("(?s)\\{\\{-?((?:[^\"`}]|\"(?:[^\"\\\\]|\\\\.)*\"|`[^`]*`|\\}[^}])*?)-?\\}\\}") - commentRe = regexp.MustCompile(`(?s)\{\{-?\s*/\*.*?\*/\s*-?\}\}`) - docKindRe = regexp.MustCompile(`(?m)^kind:[ \t]*["']?([A-Za-z]+)["']?[ \t]*(#.*)?$`) - docMetadataRe = regexp.MustCompile(`(?m)^metadata:[ \t]*$`) - docFieldRe = regexp.MustCompile(`^ (name|namespace):[ \t]*(.+?)[ \t]*$`) - includeRe = regexp.MustCompile(`\{\{-?\s*(include|template)\s+"`) - variableRe = regexp.MustCompile(`\$[A-Za-z_]`) -) - -// frame is an open template block. For an if, cur is the condition of the branch being read and -// prior the conditions of the branches before it. -type frame struct { - kind string - cur string - prior []string -} - -type action struct { - offset int - words []string - body string -} - -// TemplateDocs reads the documents of a template and the blocks around each. It is a reader of -// the common shapes -- an object wrapped in {{ if }}, {{ else }}, {{ range }}, {{ with }} -- not a -// template engine: what it cannot follow ends up as Unmanageable or as a TODO in the declaration. -func TemplateDocs(text string) []Doc { - text = strings.ReplaceAll(text, "\r\n", "\n") - - // A comment is neither a block nor a document: a commented-out object renders nothing. Blank - // it, keeping the offsets. - text = commentRe.ReplaceAllStringFunc(text, func(c string) string { - return strings.Map(func(r rune) rune { - if r == '\n' { - return r - } - - return ' ' - }, c) - }) - - actions := templateActions(text) - - var ( - out []Doc - stack []frame - next int - start int - ) - - bounds := docSeparatorRe.FindAllStringIndex(text, -1) - bounds = append(bounds, []int{len(text), len(text)}) - - for _, b := range bounds { - doc := text[start:b[0]] - docStart, docEnd := start, b[0] - start = b[1] - - d := Doc{} - at := docEnd - - if m := docKindRe.FindStringSubmatchIndex(doc); m != nil { - d.Kind = doc[m[2]:m[3]] - at = docStart + m[0] - } - - // The blocks open at the object's kind line are the object's condition. - for next < len(actions) && actions[next].offset < at { - stack = apply(stack, actions[next]) - next++ - } - - if d.Kind == "" { - // A document with no object of its own that includes a named template renders what the - // template holds: helm_lib's objects, typically, whatever comments sit beside it. - if includeRe.MatchString(doc) { - d.Library = true - d.When, d.Unmanageable = conditionOf(stack) - out = append(out, d) - } - - for next < len(actions) && actions[next].offset < docEnd { - stack = apply(stack, actions[next]) - next++ - } - - continue - } - - d.Name, d.Namespace, d.NamePattern = metadataOf(doc) - d.When, d.Unmanageable = conditionOf(stack) - - // A block that opens and closes inside the object gates part of it; one that opens here - // and stays open belongs to the documents after it. - opened := 0 - - for next < len(actions) && actions[next].offset < docEnd { - if w := actions[next].words; len(w) > 0 { - switch { - case w[0] == "if" || w[0] == "range" || w[0] == "with": - opened++ - case w[0] == "end" && opened > 0: - opened-- - d.Partial = true - } - } - - stack = apply(stack, actions[next]) - next++ - } - - out = append(out, d) - } - - return out -} - -func templateActions(text string) []action { - var out []action - - for _, m := range actionRe.FindAllStringSubmatchIndex(text, -1) { - body := strings.TrimSpace(text[m[2]:m[3]]) - if strings.HasPrefix(body, "/*") { - continue - } - - out = append(out, action{offset: m[0], words: strings.Fields(body), body: body}) - } - - return out -} - -func apply(stack []frame, a action) []frame { - if len(a.words) == 0 { - return stack - } - - switch a.words[0] { - case "if": - return append(stack, frame{kind: "if", cur: strings.TrimSpace(strings.TrimPrefix(a.body, "if"))}) - case "range", "with", "define", "block": - return append(stack, frame{kind: a.words[0]}) - case "else": - if len(stack) == 0 { - return stack - } - - top := &stack[len(stack)-1] - if top.kind != "if" { - // {{ else }} of a range or a with: still not something the declaration expresses. - return stack - } - - top.prior = append(top.prior, top.cur) - - rest := strings.TrimSpace(strings.TrimPrefix(a.body, "else")) - switch { - case strings.HasPrefix(rest, "if "): - top.cur = strings.TrimSpace(strings.TrimPrefix(rest, "if")) - case rest == "": - top.cur = "" - default: - // else with ...: the dot changes. - top.kind = "with" - } - case "end": - if len(stack) > 0 { - return stack[:len(stack)-1] - } - } - - return stack -} - -// conditionOf turns the open blocks into a `when`: an if branch is its condition, an else the -// negation of the branches before it, nested blocks an `and` of them. -func conditionOf(stack []frame) (string, string) { - var parts []string - - for _, f := range stack { - switch f.kind { - case "range": - return "", "rendered inside {{ range }}, which the declaration cannot express" - case "with": - return "", "rendered inside {{ with }}, which changes the dot the declaration's `when` is written against" - case "define", "block": - return "", "rendered from a named template ({{ define }}), which the declaration cannot express" - } - - for _, p := range f.prior { - parts = append(parts, "not ("+unwrap(oneLine(p))+")") - } - - if f.cur != "" { - parts = append(parts, oneLine(f.cur)) - } - } - - var when string - - switch len(parts) { - case 0: - return "", "" - case 1: - when = parts[0] - default: - // Every argument of and is parenthesized, a negation too: `and not (a) (b)` parses, yet - // passes not as a value and fails when it renders. - for i, p := range parts { - parts[i] = "(" + unwrap(p) + ")" - } - - when = "and " + strings.Join(parts, " ") - } - - // A variable of the template does not exist where the generator writes the condition. - if variableRe.MatchString(when) { - return "TODO: " + when + " -- the template condition uses a variable of the template; write it against the root values", "" - } - - // The declaration's `when` holds no delimiter: the generator writes it inside {{ if }}. - if strings.Contains(when, "{{") || strings.Contains(when, "}}") { - return "TODO: " + when + " -- the template condition holds a template delimiter, which `when` cannot; rewrite it without one", "" - } - - return when, "" -} - -func metadataOf(doc string) (string, string, *regexp.Regexp) { - m := docMetadataRe.FindStringIndex(doc) - if m == nil { - return "", "", nil - } - - var ( - name, namespace string - pattern *regexp.Regexp - ) - - for _, line := range strings.Split(doc[m[1]:], "\n")[1:] { - if !strings.HasPrefix(line, " ") { - break - } - - f := docFieldRe.FindStringSubmatch(line) - if f == nil { - continue - } - - value := f[2] - if !strings.Contains(value, "{{") { - if i := strings.Index(value, " #"); i >= 0 { - value = strings.TrimSpace(value[:i]) - } - } - - value = strings.Trim(value, `"'`) - - switch { - case f[1] == "name" && name == "" && pattern == nil: - if strings.Contains(value, "{{") { - pattern = namePattern(value) - } else { - name = value - } - case f[1] == "namespace" && namespace == "": - if !strings.Contains(value, "{{") { - namespace = value - } - } - } - - return name, namespace, pattern -} - -// namePattern matches the names a templated name can produce: its literal parts in place, any -// text for each action. -func namePattern(value string) *regexp.Regexp { - var b strings.Builder - - b.WriteString("^") - - last := 0 - for _, m := range actionRe.FindAllStringIndex(value, -1) { - b.WriteString(regexp.QuoteMeta(value[last:m[0]])) - b.WriteString(".*") - - last = m[1] - } - - b.WriteString(regexp.QuoteMeta(value[last:])) - b.WriteString("$") - - return regexp.MustCompile(b.String()) -} - -// oneLine joins a condition written over several lines: the declaration holds it on one. -func oneLine(expr string) string { - return strings.Join(strings.Fields(expr), " ") -} - -// Locate finds the document of a rendered object among the documents of its template. An object -// found in no document of its own kind but in a file that includes a named template came from -// that template (helm_lib, typically). ok is false when the text does not tell. -func Locate(docs []Doc, o Object) (Doc, bool) { - var byName, templated []Doc - - for _, d := range docs { - // A namespace the text names must be the object's. - if d.Kind != o.Kind || (d.Namespace != "" && o.Namespace != "" && d.Namespace != o.Namespace) { - continue - } - - switch { - case d.Name != "" && d.Name == o.Name: - byName = append(byName, d) - case d.Name == "" && (d.NamePattern == nil || d.NamePattern.MatchString(o.Name)): - templated = append(templated, d) - } - } - - for _, set := range [][]Doc{byName, templated} { - if len(set) == 0 { - continue - } - - // Several candidates are one answer only when their blocks agree. - for _, d := range set[1:] { - if d.When != set[0].When || d.Unmanageable != set[0].Unmanageable { - return Doc{}, false - } - } - - return set[0], true - } - - for _, d := range docs { - if d.Library { - return d, true - } - } - - return Doc{}, false -} - -// unwrap drops parentheses around a whole expression: (a | b) -> a | b. -func unwrap(expr string) string { - for len(expr) > 1 && expr[0] == '(' && expr[len(expr)-1] == ')' { - depth := 0 - - for i, c := range expr { - switch c { - case '(': - depth++ - case ')': - depth-- - } - - if depth == 0 && i < len(expr)-1 { - return expr // the first parenthesis closes before the end: (a) and (b) - } - } - - expr = strings.TrimSpace(expr[1 : len(expr)-1]) - } - - return expr -} diff --git a/pkg/linters/rbac/rules/bootstrap/conditions_test.go b/pkg/linters/rbac/rules/bootstrap/conditions_test.go deleted file mode 100644 index 51f6fe45..00000000 --- a/pkg/linters/rbac/rules/bootstrap/conditions_test.go +++ /dev/null @@ -1,316 +0,0 @@ -/* -Copyright 2026 Flant JSC - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package bootstrap - -import ( - "io" - "strings" - "testing" - "text/template" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - rbacv1 "k8s.io/api/rbac/v1" - - "github.com/deckhouse/dmt/pkg/linters/rbac/rules/rbacyaml" -) - -const conditionalTemplate = `{{- if .Values.m.internal.enabled }} ---- -apiVersion: v1 -kind: ServiceAccount -metadata: - name: plain - namespace: d8-m -{{- if .Values.m.extra }} ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - name: d8:m:nested -rules: [] -{{- else }} ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - name: d8:m:otherwise -rules: -{{- if .Values.m.more }} -- apiGroups: [""] - resources: [pods] - verbs: [get] -{{- end }} -{{- end }} -{{- end }} -{{- range $name := .Values.m.names }} ---- -apiVersion: v1 -kind: ServiceAccount -metadata: - name: {{ $name }} -{{- end }} -{{- $ns := .Values.m.ns }} -{{- if $ns }} ---- -apiVersion: v1 -kind: ServiceAccount -metadata: - name: by-variable -{{- end }} ---- -{{ include "helm_lib_kube_rbac_proxy" . }} -` - -// The template blocks around an object become its `when`, or the reason the declaration cannot -// carry them (regression hunt, B1 and B6). -func TestTemplateDocs(t *testing.T) { - docs := TemplateDocs(conditionalTemplate) - - byName := map[string]Doc{} - for _, d := range docs { - byName[d.Kind+"/"+d.Name] = d - } - - assert.Equal(t, ".Values.m.internal.enabled", byName["ServiceAccount/plain"].When) - assert.Equal(t, "d8-m", byName["ServiceAccount/plain"].Namespace) - assert.Equal(t, "and (.Values.m.internal.enabled) (.Values.m.extra)", byName["ClusterRole/d8:m:nested"].When) - assert.Equal(t, "and (.Values.m.internal.enabled) (not (.Values.m.extra))", byName["ClusterRole/d8:m:otherwise"].When) - assert.True(t, byName["ClusterRole/d8:m:otherwise"].Partial, "a rule under its own block") - assert.False(t, byName["ClusterRole/d8:m:nested"].Partial) - assert.Contains(t, byName["ServiceAccount/"].Unmanageable, "{{ range }}") - assert.True(t, strings.HasPrefix(byName["ServiceAccount/by-variable"].When, "TODO: $ns"), byName["ServiceAccount/by-variable"].When) - - var library []Doc - - for _, d := range docs { - if d.Library { - library = append(library, d) - } - } - - require.Len(t, library, 1) - assert.Empty(t, library[0].When) - - // The written condition is a valid `when`. - decl := &rbacyaml.Declaration{APIVersion: rbacyaml.APIVersionV1Alpha1, ServiceAccounts: []rbacyaml.ServiceAccount{{Name: "m", When: byName["ClusterRole/d8:m:otherwise"].When}}} - assert.Empty(t, rbacyaml.Validate(decl, nil)) -} - -func TestLocate(t *testing.T) { - docs := TemplateDocs(conditionalTemplate) - - d, ok := Locate(docs, Object{Kind: "ClusterRole", Name: "d8:m:nested"}) - require.True(t, ok) - assert.Equal(t, "and (.Values.m.internal.enabled) (.Values.m.extra)", d.When) - - // A name the template computes matches the templated document of the kind. - d, ok = Locate(docs, Object{Kind: "ServiceAccount", Name: "from-values"}) - require.True(t, ok) - assert.Contains(t, d.Unmanageable, "range") - - // An object of a kind the text does not hold came from the include. - d, ok = Locate(docs, Object{Kind: "ClusterRoleBinding", Name: "d8:m:rbac-proxy"}) - require.True(t, ok) - assert.True(t, d.Library) - - _, ok = Locate(nil, Object{Kind: "Role", Name: "x"}) - assert.False(t, ok) -} - -// The conditions reach the declaration: an account and its objects under one condition, an access -// entry, the scraper gate; a mismatch is a TODO (regression hunt, B1, B9 and B10). -func TestBuild_TemplateConditions(t *testing.T) { - labels := map[string]string{"module": "m"} - sa := []rbacv1.Subject{{Kind: "ServiceAccount", Name: "m", Namespace: "d8-m"}} - scraper := []rbacv1.Subject{{Kind: "User", Name: "d8-monitoring:scraper"}, {Kind: "ServiceAccount", Name: "prometheus", Namespace: "d8-monitoring"}} - nodes := []rbacv1.PolicyRule{{APIGroups: []string{""}, Resources: []string{"nodes"}, Verbs: []string{"get"}}} - metrics := []rbacv1.PolicyRule{{APIGroups: []string{"apps"}, Resources: []string{"deployments/prometheus-metrics"}, ResourceNames: []string{"m"}, Verbs: []string{"get"}}} - gate := `.Values.global.enabledModules | has "prometheus"` - - got := Build(Input{Module: "m", Namespace: "d8-m", Objects: []Object{ - {Kind: "ServiceAccount", Name: "m", Path: "templates/rbac-for-us.yaml", Labels: labels, When: ".Values.m.on", Located: true}, - {Kind: "ClusterRole", Name: "d8:m:m", Path: "templates/rbac-for-us.yaml", Labels: labels, Rules: nodes, When: ".Values.m.on", Located: true}, - {Kind: "ClusterRoleBinding", Name: "d8:m:m", Path: "templates/rbac-for-us.yaml", Labels: labels, When: ".Values.m.on", Located: true, - RoleRef: rbacv1.RoleRef{Kind: "ClusterRole", Name: "d8:m:m"}, Subjects: sa}, - {Kind: "ClusterRole", Name: "d8:m:reader", Path: "templates/rbac-for-us.yaml", Labels: labels, Rules: nodes, When: ".Values.m.reader", Located: true}, - {Kind: "ClusterRoleBinding", Name: "d8:m:reader", Path: "templates/rbac-for-us.yaml", Labels: labels, Located: true, - RoleRef: rbacv1.RoleRef{Kind: "ClusterRole", Name: "d8:m:reader"}, Subjects: []rbacv1.Subject{{Kind: "Group", Name: "g"}}}, - {Kind: "ClusterRole", Name: "d8:m:empty", Path: "templates/rbac-for-us.yaml", Labels: labels, Located: true}, - {Kind: "ClusterRoleBinding", Name: "d8:m:empty", Path: "templates/rbac-for-us.yaml", Labels: labels, Located: true, - RoleRef: rbacv1.RoleRef{Kind: "ClusterRole", Name: "d8:m:empty"}, Subjects: []rbacv1.Subject{{Kind: "Group", Name: "g"}}}, - {Kind: "Role", Name: "access-to-m-prometheus-metrics", Namespace: "d8-m", Path: "templates/rbac-to-us.yaml", Labels: labels, Rules: metrics, Located: true}, - {Kind: "RoleBinding", Name: "access-to-m-prometheus-metrics", Namespace: "d8-m", Path: "templates/rbac-to-us.yaml", Labels: labels, When: gate, Located: true, - RoleRef: rbacv1.RoleRef{Kind: "Role", Name: "access-to-m-prometheus-metrics"}, Subjects: scraper}, - {Kind: "ServiceAccount", Name: "looped", Path: "templates/rbac-for-us.yaml", Labels: labels, Unmanageable: "rendered inside {{ range }}, which the declaration cannot express", Located: true}, - }}) - - require.Len(t, got.Decl.ServiceAccounts, 1) - assert.Equal(t, ".Values.m.on", got.Decl.ServiceAccounts[0].When) - - require.Len(t, got.Decl.Access, 1) - assert.Equal(t, "reader", got.Decl.Access[0].Name) - assert.True(t, strings.HasPrefix(got.Decl.Access[0].When, "TODO: ClusterRole d8:m:reader renders under `.Values.m.reader`"), got.Decl.Access[0].When) - - require.NotNil(t, got.Decl.PrometheusAccess) - assert.Equal(t, gate, got.Decl.PrometheusAccess.When) - assert.NotContains(t, strings.Join(got.Notes, "\n"), "whether it gated the scraper binding") - - unmanaged := strings.Join(got.Unmanaged, "\n") - assert.Contains(t, unmanaged, "ServiceAccount/looped (templates/rbac-for-us.yaml): rendered inside {{ range }}") - assert.Contains(t, unmanaged, "ClusterRole/d8:m:empty (templates/rbac-for-us.yaml): has no rules") - assert.Contains(t, unmanaged, "ClusterRoleBinding/d8:m:empty (templates/rbac-for-us.yaml): binds d8:m:empty, which is not a plain ClusterRole of this module the declaration describes") -} - -// An account whose role renders under another condition than the account holds a TODO. -func TestBuild_AccountObjectsUnderAnotherCondition(t *testing.T) { - labels := map[string]string{"module": "m"} - sa := []rbacv1.Subject{{Kind: "ServiceAccount", Name: "m", Namespace: "d8-m"}} - - got := Build(Input{Module: "m", Namespace: "d8-m", Objects: []Object{ - {Kind: "ServiceAccount", Name: "m", Path: "templates/rbac-for-us.yaml", Labels: labels, Located: true}, - {Kind: "ClusterRoleBinding", Name: "d8:m:m:rbac-proxy", Path: "templates/rbac-for-us.yaml", Labels: labels, When: ".Values.m.proxy", Located: true, - RoleRef: rbacv1.RoleRef{Kind: "ClusterRole", Name: "d8:rbac-proxy"}, Subjects: sa}, - }}) - - require.Len(t, got.Decl.ServiceAccounts, 1) - assert.Equal(t, "TODO: the account renders under `no condition`, but ClusterRoleBinding d8:m:m:rbac-proxy renders under `.Values.m.proxy`; the declaration puts all of them under one condition", got.Decl.ServiceAccounts[0].When) -} - -func TestUnwrap(t *testing.T) { - assert.Equal(t, `.Values.global.enabledModules | has "prometheus"`, unwrap(`(.Values.global.enabledModules | has "prometheus")`)) - assert.Equal(t, `(a) (b)`, unwrap(`(a) (b)`)) - assert.Equal(t, `a`, unwrap(`((a))`)) - assert.Equal(t, `.Values.x`, unwrap(`.Values.x`)) - - _, ok := Locate(TemplateDocs("{{- if (.Values.a) }}\n---\nkind: Role\nmetadata:\n name: r\n{{- if .Values.b }}\n---\nkind: Role\nmetadata:\n name: r\n{{- end }}\n{{- end }}\n"), Object{Kind: "Role", Name: "r"}) - assert.False(t, ok, "two documents of one name under different conditions are not one answer") -} - -// The conditions bootstrap writes render: every argument of and is parenthesized, a negation -// included, and a condition over several lines is joined (regression hunt 2, B1 and B2). -func TestTemplateDocs_ConditionsRender(t *testing.T) { - text := "{{- if .Values.a }}\n---\nkind: Role\nmetadata:\n name: first\n{{- else if .Values.b }}\n---\nkind: Role\nmetadata:\n name: second\n" + - "{{- else }}\n---\nkind: Role\nmetadata:\n name: third\n{{- end }}\n" + - "{{- if and\n .Values.c\n (not .Values.d) }}\n---\nkind: Role\nmetadata:\n name: fourth\n{{- end }}\n" - - want := map[string]string{ - "first": ".Values.a", - "second": "and (not (.Values.a)) (.Values.b)", - "third": "and (not (.Values.a)) (not (.Values.b))", - "fourth": "and .Values.c (not .Values.d)", - } - - values := map[string]any{"Values": map[string]any{"a": false, "b": true, "c": true, "d": false}} - - for _, d := range TemplateDocs(text) { - require.Contains(t, want, d.Name) - assert.Equal(t, want[d.Name], d.When, d.Name) - - tpl, err := template.New(d.Name).Parse("{{ if " + d.When + " }}yes{{ end }}") - require.NoError(t, err, d.When) - require.NoError(t, tpl.Execute(io.Discard, values), d.When) - } -} - -// What the scanner reads besides: a quoted }} in a condition, a commented-out object, an include -// with comments beside it, a kind or name with a comment or quotes, a computed name, a block -// that stays open into the next document (regression hunt 2, B3, B4, B5, B8, B9). -func TestTemplateDocs_Shapes(t *testing.T) { - text := `{{- if eq .Values.x "}}" }} ---- -kind: "Role" # the role -metadata: - name: quoted # a comment -{{- end }} -{{/* ---- -kind: ClusterRole -metadata: - name: commented -*/}} ---- -{{ include "helm_lib_csi_controller_rbac" . }} -# ========================================= ---- -kind: ServiceAccount -metadata: - name: {{ .Chart.Name }}-extra ---- -kind: ServiceAccount -metadata: - name: a -{{- if .Values.b }} ---- -kind: ServiceAccount -metadata: - name: b -{{- end }} -` - docs := TemplateDocs(text) - - byName := map[string]Doc{} - library := 0 - - for _, d := range docs { - byName[d.Kind+"/"+d.Name] = d - if d.Library { - library++ - } - } - - assert.True(t, strings.HasPrefix(byName["Role/quoted"].When, `TODO: eq .Values.x "}}" -- the template condition holds a template delimiter`), "read to the end, not cut at the quoted }}: %s", byName["Role/quoted"].When) - assert.NotContains(t, byName, "ClusterRole/commented") - assert.Equal(t, 1, library, "an include beside comments is still the library's document") - assert.False(t, byName["ServiceAccount/a"].Partial, "the if after it belongs to the next document") - assert.Equal(t, ".Values.b", byName["ServiceAccount/b"].When) - - d, ok := Locate(docs, Object{Kind: "ServiceAccount", Name: "cert-manager-extra"}) - require.True(t, ok) - assert.Empty(t, d.Name) - assert.NotNil(t, d.NamePattern) - - withoutLibrary := make([]Doc, 0, len(docs)) - for _, d := range docs { - if !d.Library { - withoutLibrary = append(withoutLibrary, d) - } - } - - _, ok = Locate(withoutLibrary, Object{Kind: "ServiceAccount", Name: "unrelated"}) - assert.False(t, ok, "a computed name matches only what it can produce") -} - -// A note quoting a condition over several lines stays a comment, and the file parses -// (regression hunt 2, B2); an account outside the module namespace is listed once (B10). -func TestMarshal_MultilineNoteAndSingleListing(t *testing.T) { - got := Build(Input{Module: "m", Namespace: "d8-m", Objects: []Object{ - {Kind: "ServiceAccount", Name: "elsewhere", Namespace: "kube-system", Path: "templates/rbac-for-us.yaml", Labels: map[string]string{"module": "m"}}, - }, Unrendered: []string{"Role/r (templates/x.yaml, under `and\n (include \"a\" .)\n .Values.b`)"}}) - - assert.Len(t, got.Unmanaged, 1, "got: %v", got.Unmanaged) - - content, err := Marshal(got) - require.NoError(t, err) - - _, err = rbacyaml.Parse(content) - require.NoError(t, err, string(content)) - - for _, line := range strings.Split(strings.TrimSpace(strings.SplitN(string(content), "apiVersion:", 2)[0]), "\n") { - assert.True(t, strings.HasPrefix(line, "#"), "a header line that is no comment: %q", line) - } -} diff --git a/pkg/linters/rbac/rules/bootstrap/marshal.go b/pkg/linters/rbac/rules/bootstrap/marshal.go index 2b5c3a31..7b8b4aab 100644 --- a/pkg/linters/rbac/rules/bootstrap/marshal.go +++ b/pkg/linters/rbac/rules/bootstrap/marshal.go @@ -64,7 +64,7 @@ func Marshal(r Result) ([]byte, error) { return []byte(head.String() + body.String()), nil } -// commentLines writes a note as comment lines: a template condition quoted in it may span lines, +// commentLines writes a note as comment lines: a note may span lines, // and a line without # would be YAML. func commentLines(prefix, text string) string { lines := strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n") diff --git a/pkg/linters/rbac/rules/coverage.go b/pkg/linters/rbac/rules/coverage.go index be1a26e2..1c5de9c5 100644 --- a/pkg/linters/rbac/rules/coverage.go +++ b/pkg/linters/rbac/rules/coverage.go @@ -21,9 +21,7 @@ import ( "context" stderrors "errors" "fmt" - "maps" "os" - "slices" "strings" "gopkg.in/yaml.v3" @@ -162,17 +160,11 @@ func (r *CoverageRule) Check(_ context.Context) { continue } - if _, resourceKnown := known[res.Key()]; resourceKnown { - continue - } - - // Several modules share a group (deckhouse.io): a resource of another module's CRD is - // external, not a misspelling. Only a name close to one of this module's is flagged. - if near := nearestResource(res, known); near != "" { + if _, resourceKnown := known[res.Key()]; !resourceKnown { errorList. WithObjectID("rbac.yaml/"+res.Key()). - Warnf("%s names a resource the module's CRDs of group %s do not have, and %s is one letter or two away; check the spelling, or drop the entry if the resource is gone", - res.Key(), res.Group, near) + Warnf("%s names a resource the module's CRDs of group %s do not have; check the spelling, or drop the entry if the resource is gone", + res.Key(), res.Group) } } } @@ -303,48 +295,3 @@ func scalarValue(node *yaml.Node) string { return node.Value } - -// nearestResource returns the module CRD of the entry's group whose plural is at most two edits -// away from the entry's resource, or "" when none is. -func nearestResource(res rbacyaml.Resource, known map[string]struct{}) string { - best, bestDistance := "", 3 - - for _, key := range slices.Sorted(maps.Keys(known)) { - group, plural, _ := strings.Cut(key, "/") - if group != res.Group { - continue - } - - if d := editDistance(res.Resource, plural); d < bestDistance { - best, bestDistance = key, d - } - } - - return best -} - -// editDistance is the Levenshtein distance of two ASCII names. -func editDistance(a, b string) int { - prev := make([]int, len(b)+1) - for j := range prev { - prev[j] = j - } - - for i := 1; i <= len(a); i++ { - cur := make([]int, len(b)+1) - cur[0] = i - - for j := 1; j <= len(b); j++ { - cost := 1 - if a[i-1] == b[j-1] { - cost = 0 - } - - cur[j] = min(prev[j]+1, cur[j-1]+1, prev[j-1]+cost) - } - - prev = cur - } - - return prev[len(b)] -} diff --git a/pkg/linters/rbac/rules/coverage_test.go b/pkg/linters/rbac/rules/coverage_test.go index 5633d5db..b9d9f1c6 100644 --- a/pkg/linters/rbac/rules/coverage_test.go +++ b/pkg/linters/rbac/rules/coverage_test.go @@ -155,7 +155,7 @@ resources: assert.Contains(t, got, "error: CRD a.io/gammas (crds/a.yaml) has no entry in rbac.yaml: decide the user access to it -- namespace, system or legacy levels, or noAccess with the reason; `dmt lint --linter rbac --fix` adds an undecided stub") assert.Contains(t, got, "error: CRD d.io/deltas (crds/d.yaml) has no entry in rbac.yaml: decide the user access to it -- namespace, system or legacy levels, or noAccess with the reason; `dmt lint --linter rbac --fix` adds an undecided stub") assert.Contains(t, got, `error: a.io/betas is still undecided in rbac.yaml (noAccess: "TODO"): a decision is needed -- only a person can close this`) - assert.Contains(t, got, "warn: a.io/gamas names a resource the module's CRDs of group a.io do not have, and a.io/gammas is one letter or two away; check the spelling, or drop the entry if the resource is gone") + assert.Contains(t, got, "warn: a.io/gamas names a resource the module's CRDs of group a.io do not have; check the spelling, or drop the entry if the resource is gone") // --fix: two stubs are written, and both findings stay, each with the reason (R33); the open // decision fails its fix too, so the run does not end green. @@ -359,16 +359,3 @@ func TestCoverage_TODOPrefixIsAnOpenDecision(t *testing.T) { assert.True(t, errorList.ContainsFailedFixes(), "a --fix run with open decisions fails") } - -// A resource of a shared group that is no near miss of the module's CRDs is external: another -// module's CRD in deckhouse.io, not a misspelling (regression hunt, B11). -func TestNearestResource(t *testing.T) { - known := map[string]struct{}{"deckhouse.io/nodegroups": {}, "deckhouse.io/instances": {}} - - assert.Equal(t, "deckhouse.io/nodegroups", nearestResource(rbacyaml.Resource{Group: "deckhouse.io", Resource: "nodegroup"}, known)) - assert.Equal(t, "deckhouse.io/instances", nearestResource(rbacyaml.Resource{Group: "deckhouse.io", Resource: "instanses"}, known)) - assert.Empty(t, nearestResource(rbacyaml.Resource{Group: "deckhouse.io", Resource: "modulesources"}, known)) - assert.Empty(t, nearestResource(rbacyaml.Resource{Group: "other.io", Resource: "nodegroup"}, known)) - assert.Equal(t, 0, editDistance("abc", "abc")) - assert.Equal(t, 3, editDistance("", "abc")) -} diff --git a/pkg/linters/rbac/rules/generate/generate_test.go b/pkg/linters/rbac/rules/generate/generate_test.go index 13643592..2ef25ec6 100644 --- a/pkg/linters/rbac/rules/generate/generate_test.go +++ b/pkg/linters/rbac/rules/generate/generate_test.go @@ -275,30 +275,16 @@ func TestBuild_RefusesWhatTheModuleCannotCarry(t *testing.T) { decl.ServiceAccounts = []rbacyaml.ServiceAccount{{Name: "helper", Path: "cainjector"}} _, err := Build(Input{Module: "m", Namespace: "d8-m", Subsystems: []string{"security"}, Decl: decl}) require.Error(t, err) - assert.Contains(t, err.Error(), `the placement rule wants the account named "cainjector" after its directory`) + assert.Contains(t, err.Error(), `the placement rule wants the account named "cainjector" or "m-cainjector"`) - decl.ServiceAccounts = []rbacyaml.ServiceAccount{{Name: "cainjector", Path: "cainjector"}, {Name: "webhook", Path: "webhook"}, {Name: "anything", Path: ""}} + decl.ServiceAccounts = []rbacyaml.ServiceAccount{{Name: "m-cainjector", Path: "cainjector"}, {Name: "webhook", Path: "webhook"}, {Name: "anything", Path: ""}} _, err = Build(Input{Module: "m", Namespace: "d8-m", Subsystems: []string{"security"}, Decl: decl}) require.NoError(t, err) - // The module name in front is the platform namespaces' form only (regression hunt 2, A1). - decl.ServiceAccounts = []rbacyaml.ServiceAccount{{Name: "m-cainjector", Path: "cainjector"}} - _, err = Build(Input{Module: "m", Namespace: "d8-m", Subsystems: []string{"security"}, Decl: decl}) - require.Error(t, err) - assert.Contains(t, err.Error(), `only in a namespace of the platform`) - - _, err = Build(Input{Module: "m", Namespace: "d8-system", Subsystems: []string{"security"}, Decl: decl}) - require.NoError(t, err) - decl.ServiceAccounts = []rbacyaml.ServiceAccount{{Name: "dir", Path: "some/nested/dir"}} _, err = Build(Input{Module: "m", Namespace: "d8-m", Subsystems: []string{"security"}, Decl: decl}) require.Error(t, err) - assert.Contains(t, err.Error(), `wants the account named "some-nested-dir"`) - - // templates///rbac-for-us.yaml: the placement rule joins the directories. - decl.ServiceAccounts = []rbacyaml.ServiceAccount{{Name: "some-nested-dir", Path: "some/nested/dir"}} - _, err = Build(Input{Module: "m", Namespace: "d8-m", Subsystems: []string{"security"}, Decl: decl}) - require.NoError(t, err) + assert.Contains(t, err.Error(), "one directory under templates/ only") }) t.Run("a capability marker longer than a label value", func(t *testing.T) { diff --git a/pkg/linters/rbac/rules/generate/model.go b/pkg/linters/rbac/rules/generate/model.go index a755127d..30452244 100644 --- a/pkg/linters/rbac/rules/generate/model.go +++ b/pkg/linters/rbac/rules/generate/model.go @@ -249,18 +249,12 @@ func checkAgainstModule(in Input) error { continue } - // The placement rule names the account of templates///rbac-for-us.yaml after its - // directories joined with dashes; with the module name in front only in a namespace of the - // platform (d8-system, d8-monitoring, ...). - dir := strings.ReplaceAll(sa.Path, "/", "-") - - switch { - case sa.Name == dir: - case sa.Name == in.Module+"-"+dir && rbaccontract.IsDeckhouseNamespace(in.Namespace): - case sa.Name == in.Module+"-"+dir: - return fmt.Errorf("serviceAccounts[%s].path %q: the placement rule allows the module name in front of the directory only in a namespace of the platform (d8-system, d8-monitoring, ...); in %s name the account %q", sa.Name, sa.Path, in.Namespace, dir) - default: - return fmt.Errorf("serviceAccounts[%s].path %q: the placement rule wants the account named %q after its directory; rename the account or move it to the module root", sa.Name, sa.Path, dir) + if strings.Contains(sa.Path, "/") { + return fmt.Errorf("serviceAccounts[%s].path %q: one directory under templates/ only; the placement rule names the objects of a nested directory in a way the generator cannot follow", sa.Name, sa.Path) + } + + if sa.Name != sa.Path && sa.Name != in.Module+"-"+sa.Path { + return fmt.Errorf("serviceAccounts[%s].path %q: the placement rule wants the account named %q or %q after its directory; rename the account or move it to the module root", sa.Name, sa.Path, sa.Path, in.Module+"-"+sa.Path) } } @@ -357,10 +351,7 @@ func (b *builder) capabilities() { labels[rbaccontract.AggregationLabelPrefix+subsystem+rbaccontract.AggregationLabelSuffix] = level } - // The user-authz controller projects the module's use-role RoleBindings into this namespace; - // every module namespace gets it -- kube-system included -- but default, which the - // secret-copier module fills with copies and which is nobody's to administer. - if b.in.Namespace != "" && b.in.Namespace != "default" { + if strings.HasPrefix(b.in.Namespace, "d8-") { labels[rbaccontract.LabelNamespace] = b.in.Namespace } @@ -442,44 +433,41 @@ func (b *builder) serviceAccounts() { automount := sa.AutomountToken != nil && *sa.AutomountToken b.add(path, Object{ Kind: "ServiceAccount", Name: sa.Name, Namespace: b.in.Namespace, Class: ClassDeclared, - When: sa.When, Labels: labels, Annotations: copyMap(sa.Annotations), AutomountToken: &automount, + When: sa.When, Labels: labels, AutomountToken: &automount, }) - ann := sa.RBACAnnotations - subject := []Subject{{Kind: "ServiceAccount", Name: sa.Name, Namespace: b.in.Namespace}} clusterName := "d8:" + b.in.Module + ":" + sa.Name if len(sa.ClusterRules) > 0 { - b.add(path, Object{Kind: "ClusterRole", Name: clusterName, Class: ClassDeclared, When: sa.When, Labels: labels, Annotations: copyMap(ann), Rules: policyRules(sa.ClusterRules)}) - b.add(path, Object{Kind: "ClusterRoleBinding", Name: clusterName, Class: ClassDeclared, When: sa.When, Labels: labels, Annotations: copyMap(ann), RoleRefKind: "ClusterRole", RoleRefName: clusterName, Subjects: subject}) + b.add(path, Object{Kind: "ClusterRole", Name: clusterName, Class: ClassDeclared, When: sa.When, Labels: labels, Rules: policyRules(sa.ClusterRules)}) + b.add(path, Object{Kind: "ClusterRoleBinding", Name: clusterName, Class: ClassDeclared, When: sa.When, Labels: labels, RoleRefKind: "ClusterRole", RoleRefName: clusterName, Subjects: subject}) } if len(sa.NamespaceRules) > 0 { - roleName := rbaccontract.AccountRoleName(b.in.Module, sa.Path, sa.Name) - b.add(path, Object{Kind: "Role", Name: roleName, Namespace: b.in.Namespace, Class: ClassDeclared, When: sa.When, Labels: labels, Annotations: copyMap(ann), Rules: policyRules(sa.NamespaceRules)}) - b.add(path, Object{Kind: "RoleBinding", Name: roleName, Namespace: b.in.Namespace, Class: ClassDeclared, When: sa.When, Labels: labels, Annotations: copyMap(ann), RoleRefKind: "Role", RoleRefName: roleName, Subjects: subject}) + b.add(path, Object{Kind: "Role", Name: sa.Name, Namespace: b.in.Namespace, Class: ClassDeclared, When: sa.When, Labels: labels, Rules: policyRules(sa.NamespaceRules)}) + b.add(path, Object{Kind: "RoleBinding", Name: sa.Name, Namespace: b.in.Namespace, Class: ClassDeclared, When: sa.When, Labels: labels, RoleRefKind: "Role", RoleRefName: sa.Name, Subjects: subject}) } for _, extra := range sa.ExtraClusterRoles { extraName := extra.FullName(b.in.Module, sa.Name) - b.add(path, Object{Kind: "ClusterRole", Name: extraName, Class: ClassDeclared, When: sa.When, Labels: labels, Annotations: copyMap(ann), Rules: policyRules(extra.Rules)}) + b.add(path, Object{Kind: "ClusterRole", Name: extraName, Class: ClassDeclared, When: sa.When, Labels: labels, Rules: policyRules(extra.Rules)}) if extra.IsBound() { - b.add(path, Object{Kind: "ClusterRoleBinding", Name: extraName, Class: ClassDeclared, When: sa.When, Labels: labels, Annotations: copyMap(ann), RoleRefKind: "ClusterRole", RoleRefName: extraName, Subjects: subject}) + b.add(path, Object{Kind: "ClusterRoleBinding", Name: extraName, Class: ClassDeclared, When: sa.When, Labels: labels, RoleRefKind: "ClusterRole", RoleRefName: extraName, Subjects: subject}) } } for _, bound := range sa.BindClusterRoles { b.add(path, Object{ - Kind: "ClusterRoleBinding", Name: clusterName + ":" + rbaccontract.BindingSuffix(bound), Class: ClassDeclared, When: sa.When, Labels: labels, Annotations: copyMap(ann), + Kind: "ClusterRoleBinding", Name: clusterName + ":" + rbaccontract.BindingSuffix(bound), Class: ClassDeclared, When: sa.When, Labels: labels, RoleRefKind: "ClusterRole", RoleRefName: bound, Subjects: subject, }) } for _, ref := range sa.BindRoles { b.add(path, Object{ - Kind: "RoleBinding", Name: rbaccontract.AccountForeignBindingPrefix(b.in.Module, sa.Path, sa.Name) + ":" + rbaccontract.BindingSuffix(ref.Name), Namespace: ref.Namespace, Class: ClassDeclared, When: sa.When, Labels: labels, Annotations: copyMap(ann), + Kind: "RoleBinding", Name: clusterName + ":" + rbaccontract.BindingSuffix(ref.Name), Namespace: ref.Namespace, Class: ClassDeclared, When: sa.When, Labels: labels, RoleRefKind: "Role", RoleRefName: ref.Name, Subjects: subject, }) } @@ -509,11 +497,11 @@ func (b *builder) access() { rules = sortRules(rules) name := "access-to-" + b.in.Module - b.add("templates/rbac-to-us.yaml", Object{Kind: "Role", Name: name, Namespace: b.in.Namespace, Class: ClassDeclared, Labels: copyMap(pa.Labels), Annotations: copyMap(pa.Annotations), Rules: rules}) + b.add("templates/rbac-to-us.yaml", Object{Kind: "Role", Name: name, Namespace: b.in.Namespace, Class: ClassDeclared, Rules: rules}) // The Role is unconditional and only the binding to the scraper is gated: that is how the // modules write it today, and a Role nobody is bound to grants nothing. b.add("templates/rbac-to-us.yaml", Object{ - Kind: "RoleBinding", Name: name, Namespace: b.in.Namespace, Class: ClassDeclared, Labels: copyMap(pa.Labels), Annotations: copyMap(pa.Annotations), RoleRefKind: "Role", RoleRefName: name, When: pa.When, + Kind: "RoleBinding", Name: name, Namespace: b.in.Namespace, Class: ClassDeclared, RoleRefKind: "Role", RoleRefName: name, When: pa.When, Subjects: []Subject{{Kind: "User", Name: "d8-monitoring:scraper"}, {Kind: "ServiceAccount", Name: "prometheus", Namespace: "d8-monitoring"}}, }) } @@ -534,15 +522,14 @@ func (b *builder) access() { if len(a.ClusterRules) > 0 { name := "d8:" + b.in.Module + ":" + a.Name - b.add(dir+"rbac-for-us.yaml", Object{Kind: "ClusterRole", Name: name, Class: ClassDeclared, When: a.When, Labels: copyMap(a.Labels), Annotations: copyMap(a.Annotations), Rules: policyRules(a.ClusterRules)}) - b.add(dir+"rbac-for-us.yaml", Object{Kind: "ClusterRoleBinding", Name: name, Class: ClassDeclared, When: a.When, Labels: copyMap(a.Labels), Annotations: copyMap(a.Annotations), RoleRefKind: "ClusterRole", RoleRefName: name, Subjects: subjects}) + b.add(dir+"rbac-for-us.yaml", Object{Kind: "ClusterRole", Name: name, Class: ClassDeclared, Rules: policyRules(a.ClusterRules)}) + b.add(dir+"rbac-for-us.yaml", Object{Kind: "ClusterRoleBinding", Name: name, Class: ClassDeclared, RoleRefKind: "ClusterRole", RoleRefName: name, Subjects: subjects}) } if len(a.NamespaceRules) > 0 { - name := rbaccontract.AccessRoleName(b.in.Module, a.Path, a.Name) - - b.add(dir+"rbac-to-us.yaml", Object{Kind: "Role", Name: name, Namespace: b.in.Namespace, Class: ClassDeclared, When: a.When, Labels: copyMap(a.Labels), Annotations: copyMap(a.Annotations), Rules: policyRules(a.NamespaceRules)}) - b.add(dir+"rbac-to-us.yaml", Object{Kind: "RoleBinding", Name: name, Namespace: b.in.Namespace, Class: ClassDeclared, When: a.When, Labels: copyMap(a.Labels), Annotations: copyMap(a.Annotations), RoleRefKind: "Role", RoleRefName: name, Subjects: subjects}) + name := "access-to-" + b.in.Module + "-" + a.Name + b.add(dir+"rbac-to-us.yaml", Object{Kind: "Role", Name: name, Namespace: b.in.Namespace, Class: ClassDeclared, Rules: policyRules(a.NamespaceRules)}) + b.add(dir+"rbac-to-us.yaml", Object{Kind: "RoleBinding", Name: name, Namespace: b.in.Namespace, Class: ClassDeclared, RoleRefKind: "Role", RoleRefName: name, Subjects: subjects}) } } } @@ -634,17 +621,3 @@ func liftRuleConditions(o *Object) { o.When = lifted } - -// copyMap returns a copy of m, nil for an empty one. -func copyMap(m map[string]string) map[string]string { - if len(m) == 0 { - return nil - } - - out := make(map[string]string, len(m)) - for k, v := range m { - out[k] = v - } - - return out -} diff --git a/pkg/linters/rbac/rules/generate/placement_test.go b/pkg/linters/rbac/rules/generate/placement_test.go deleted file mode 100644 index 2bcd3711..00000000 --- a/pkg/linters/rbac/rules/generate/placement_test.go +++ /dev/null @@ -1,132 +0,0 @@ -/* -Copyright 2026 Flant JSC - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package generate - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/deckhouse/dmt/pkg/linters/rbac/rules/rbaccontract" - "github.com/deckhouse/dmt/pkg/linters/rbac/rules/rbacyaml" -) - -func placementDecl() *rbacyaml.Declaration { - return &rbacyaml.Declaration{APIVersion: rbacyaml.APIVersionV1Alpha1, - Resources: []rbacyaml.Resource{{Group: "x.io", Resource: "things", Scope: "Namespaced", Namespace: map[string][]string{"viewer": {"get"}}}}, - } -} - -// Every module namespace carries the label user-authz projects the use roles by, kube-system -// included; default does not (regression hunt, B2). -func TestBuild_NamespaceLabelOutsideD8(t *testing.T) { - for ns, want := range map[string]bool{"d8-m": true, "kube-system": true, "default": false} { - model, err := Build(Input{Module: "m", Namespace: ns, Subsystems: []string{"security"}, Decl: placementDecl()}) - require.NoError(t, err) - - labelled := false - - for _, f := range model.Files { - for _, o := range f.Objects { - if o.Labels[rbaccontract.LabelNamespace] == ns { - labelled = true - } - } - } - - assert.Equal(t, want, labelled, ns) - } -} - -// A namespace access entry in a component directory gets the name the placement rule wants there -// (regression hunt, B8). -func TestBuild_AccessNamesFollowThePlacementRule(t *testing.T) { - decl := placementDecl() - rules := []rbacyaml.PolicyRule{{APIGroups: []string{""}, Resources: []string{"secrets"}, Verbs: []string{"get"}}} - subjects := []rbacyaml.Subject{{Kind: "Group", Name: "g"}} - decl.Access = []rbacyaml.Access{ - {Name: "reader", Subjects: subjects, NamespaceRules: rules}, - {Name: "reader", Path: "webhook/tls", Subjects: subjects, NamespaceRules: rules}, - } - // Two entries with one name are a duplicate for the validator, not for the generator. - model, err := Build(Input{Module: "m", Namespace: "d8-m", Subsystems: []string{"security"}, Decl: decl}) - require.NoError(t, err) - - root := model.File("templates/rbac-to-us.yaml") - require.NotNil(t, root) - assert.Equal(t, "access-to-m-reader", root.Objects[0].Name) - - nested := model.File("templates/webhook/tls/rbac-to-us.yaml") - require.NotNil(t, nested) - assert.Equal(t, "access-to-webhook-tls-reader", nested.Objects[0].Name) -} - -// Account annotations land on the ServiceAccount, rbacAnnotations on its roles and bindings -// (regression hunt, B7). -func TestBuild_AccountAnnotations(t *testing.T) { - decl := placementDecl() - decl.ServiceAccounts = []rbacyaml.ServiceAccount{{ - Name: "m", - Annotations: map[string]string{"helm.sh/resource-policy": "keep"}, - RBACAnnotations: map[string]string{"werf.io/deploy-on": "pre-install"}, - ClusterRules: []rbacyaml.PolicyRule{{APIGroups: []string{""}, Resources: []string{"nodes"}, Verbs: []string{"get"}}}, - }} - - model, err := Build(Input{Module: "m", Namespace: "d8-m", Subsystems: []string{"security"}, Decl: decl}) - require.NoError(t, err) - - file := model.File("templates/rbac-for-us.yaml") - require.NotNil(t, file) - - for _, o := range file.Objects { - if o.Kind == "ServiceAccount" { - assert.Equal(t, map[string]string{"helm.sh/resource-policy": "keep"}, o.Annotations) - } else { - assert.Equal(t, map[string]string{"werf.io/deploy-on": "pre-install"}, o.Annotations, o.Identity()) - } - } - - rendered := RenderFile(*file) - assert.Equal(t, 1, strings.Count(rendered, `helm.sh/resource-policy: "keep"`)) - assert.Equal(t, 2, strings.Count(rendered, `werf.io/deploy-on: "pre-install"`)) -} - -// Access and Prometheus entries carry labels and annotations onto their role and binding -// (regression hunt 2, A2). -func TestBuild_AccessMetadata(t *testing.T) { - decl := placementDecl() - decl.Access = []rbacyaml.Access{{ - Name: "manager", Subjects: []rbacyaml.Subject{{Kind: "Group", Name: "g"}}, - Labels: map[string]string{"app": "capi"}, Annotations: map[string]string{"werf.io/deploy-on": "pre-install"}, - ClusterRules: []rbacyaml.PolicyRule{{APIGroups: []string{""}, Resources: []string{"nodes"}, Verbs: []string{"get"}}}, - }} - decl.PrometheusAccess = &rbacyaml.PrometheusAccess{Deployments: []string{"m"}, Labels: map[string]string{"app": "m"}} - - model, err := Build(Input{Module: "m", Namespace: "d8-m", Subsystems: []string{"security"}, Decl: decl}) - require.NoError(t, err) - - for _, o := range model.File("templates/rbac-for-us.yaml").Objects { - assert.Equal(t, map[string]string{"app": "capi"}, o.Labels, o.Identity()) - assert.Equal(t, map[string]string{"werf.io/deploy-on": "pre-install"}, o.Annotations, o.Identity()) - } - - for _, o := range model.File("templates/rbac-to-us.yaml").Objects { - assert.Equal(t, map[string]string{"app": "m"}, o.Labels, o.Identity()) - } -} diff --git a/pkg/linters/rbac/rules/placement.go b/pkg/linters/rbac/rules/placement.go index c1253e75..fa2a928d 100644 --- a/pkg/linters/rbac/rules/placement.go +++ b/pkg/linters/rbac/rules/placement.go @@ -20,6 +20,7 @@ import ( "context" "fmt" "os" + "slices" "strings" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -27,7 +28,6 @@ import ( "github.com/deckhouse/dmt/internal/storage" "github.com/deckhouse/dmt/pkg" "github.com/deckhouse/dmt/pkg/errors" - "github.com/deckhouse/dmt/pkg/linters/rbac/rules/rbaccontract" ) const ( @@ -67,13 +67,14 @@ const ( ) // TODO: remove entries after 'd8-system' after fixing RBAC objects names +var deckhouseNamespaces = []string{"d8-monitoring", "d8-system", "d8-admission-policy-engine", "d8-operator-trivy", "d8-log-shipper", "d8-local-path-provisioner"} func isSystemNamespace(actual string) bool { return actual == metav1.NamespaceDefault || actual == metav1.NamespaceSystem } func isDeckhouseSystemNamespace(actual string) bool { - return rbaccontract.IsDeckhouseNamespace(actual) + return slices.Contains(deckhouseNamespaces, actual) } func (r *PlacementRule) Check(_ context.Context) { diff --git a/pkg/linters/rbac/rules/rbaccontract/contract.go b/pkg/linters/rbac/rules/rbaccontract/contract.go index a00ef4fa..efcb8363 100644 --- a/pkg/linters/rbac/rules/rbaccontract/contract.go +++ b/pkg/linters/rbac/rules/rbaccontract/contract.go @@ -266,55 +266,3 @@ var I18nAnnotations = []string{AnnotationTitleEN, AnnotationTitleRU, AnnotationD func IsLegacyKind(kind string) bool { return kind == KindLegacyUse || kind == KindLegacyManage } - -// AccessRoleName is the Role and RoleBinding name of a namespace access entry. The placement rule -// wants access-to--... in templates/rbac-to-us.yaml and access-to--... in -// templates//rbac-to-us.yaml. -func AccessRoleName(module, path, name string) string { - if path == "" { - return "access-to-" + module + "-" + name - } - - return "access-to-" + strings.ReplaceAll(path, "/", "-") + "-" + name -} - -// DeckhouseNamespaces are the namespaces the placement rule treats as the platform's own: an -// object there is named after the module as well as after its directory. -var DeckhouseNamespaces = []string{"d8-monitoring", "d8-system", "d8-admission-policy-engine", "d8-operator-trivy", "d8-log-shipper", "d8-local-path-provisioner"} - -// IsDeckhouseNamespace reports whether the namespace is one of DeckhouseNamespaces. -func IsDeckhouseNamespace(ns string) bool { - for _, n := range DeckhouseNamespaces { - if n == ns { - return true - } - } - - return false -} - -// AccountRoleName is the Role and RoleBinding name of an account's namespaceRules. The placement -// rule wants the objects of templates///rbac-for-us.yaml to start with a:b (or -// :a:b for an account named after the module); at the root the account's own name. -func AccountRoleName(module, path, account string) string { - if path == "" { - return account - } - - dirs := strings.ReplaceAll(path, "/", ":") - if account == module+"-"+strings.ReplaceAll(path, "/", "-") { - return module + ":" + dirs - } - - return dirs -} - -// AccountForeignBindingPrefix is the prefix of an account's RoleBindings in other namespaces -// (bindRoles): d8:: at the root, d8::: for templates///. -func AccountForeignBindingPrefix(module, path, account string) string { - if path == "" { - return "d8:" + module + ":" + account - } - - return "d8:" + module + ":" + strings.ReplaceAll(path, "/", ":") -} diff --git a/pkg/linters/rbac/rules/rbacyaml/load_test.go b/pkg/linters/rbac/rules/rbacyaml/load_test.go index fc4b08d0..431baab4 100644 --- a/pkg/linters/rbac/rules/rbacyaml/load_test.go +++ b/pkg/linters/rbac/rules/rbacyaml/load_test.go @@ -18,7 +18,6 @@ package rbacyaml import ( "errors" - "fmt" "os" "path/filepath" "strings" @@ -338,48 +337,10 @@ noAccess: nobody`), yaml: entry(`group: external.io resource: "*/scale" scope: Namespaced -reason: the resources of the group are not known statically noAccess: nobody`), crds: certManagerCRDs, wantErr: "", }, - "regression hunt B12: the wildcard of a subresource needs a reason, as \"*\" does": { - yaml: entry(`group: external.io -resource: "*/scale" -scope: Namespaced -noAccess: nobody`), - crds: certManagerCRDs, - wantErr: `resource "*/scale" requires reason`, - }, - "regression hunt B12: the wildcard of a subresource in a group of the module": { - yaml: entry(`group: cert-manager.io -resource: "*/status" -reason: every status -noAccess: nobody`), - crds: certManagerCRDs, - wantErr: `resource "*/status" is allowed only for a group the module ships no CRD for`, - }, - "regression hunt B12: a built-in resource under the wrong scope": { - yaml: entry(`group: "" -resource: nodes -scope: Namespaced -noAccess: nobody`), - wantErr: `scope "Namespaced" disagrees with Kubernetes, which serves /nodes as "Cluster"`, - }, - "regression hunt 2 B7: namespaces declared Namespaced on purpose": { - yaml: entry(`group: "" -resource: namespaces -scope: Namespaced -namespace: {viewer: [get]}`), - wantErr: "", - }, - "regression hunt B12: no dot in a resource name": { - yaml: entry(`group: external.io -resource: things.v1 -scope: Namespaced -noAccess: nobody`), - wantErr: `resource "things.v1" is not a resource name`, - }, "review 6: a resource name with a space": { yaml: entry(`group: external.io resource: "Widgets " @@ -516,8 +477,7 @@ func TestValidate_TopLevel(t *testing.T) { wantErr: `"namespace.view" needs no texts`, }, "capabilities: missing ru": { - yaml: "apiVersion: rbac.deckhouse.io/v1alpha1\ncapabilities:\n namespace.admin: {title: {en: a}, description: {en: c, ru: d}}\n" + - "resources:\n - {group: x.io, resource: things, scope: Namespaced, namespace: {admin: [get]}}\n", + yaml: "apiVersion: rbac.deckhouse.io/v1alpha1\ncapabilities:\n namespace.admin: {title: {en: a}, description: {en: c, ru: d}}\n", wantErr: "namespace.admin.title requires both en and ru", }, "capabilities: bad key": { @@ -551,8 +511,7 @@ func TestValidate_TopLevel(t *testing.T) { wantErr: "prometheusAccess: when", }, "review 13a: a template delimiter in a capability text": { - yaml: "apiVersion: rbac.deckhouse.io/v1alpha1\ncapabilities:\n namespace.admin: {title: {en: 'Use {{ .Values.x }}', ru: b}, description: {en: c, ru: d}}\n" + - "resources:\n - {group: x.io, resource: things, scope: Namespaced, namespace: {admin: [get]}}\n", + yaml: "apiVersion: rbac.deckhouse.io/v1alpha1\ncapabilities:\n namespace.admin: {title: {en: 'Use {{ .Values.x }}', ru: b}, description: {en: c, ru: d}}\n", wantErr: `capabilities.namespace.admin.title.en: "Use {{ .Values.x }}" holds a template delimiter`, }, "prometheusAccess: empty": { @@ -686,46 +645,13 @@ legacy: assert.Contains(t, w[0], "legacy.SuperAdmin produces a role user-authz does not aggregate") } -// The generator writes label and annotation keys unquoted; the generator's and Helm's own -// annotations are not the declaration's (regression hunt, B7). -func TestValidate_AccountMetadataKeys(t *testing.T) { - decl := &Declaration{APIVersion: APIVersionV1Alpha1, ServiceAccounts: []ServiceAccount{{ - Name: "m", - Labels: map[string]string{"bad key": "x"}, - Annotations: map[string]string{"helm.sh/resource-policy": "keep", "meta.helm.sh/release-name": "m"}, - RBACAnnotations: map[string]string{"rbac.deckhouse.io/kind": "x", "werf.io/deploy-on": "pre-install"}, - }}} - - errs := Validate(decl, nil) - - msgs := make([]string, 0, len(errs)) - for _, e := range errs { - msgs = append(msgs, e.Error()) - } - - got := strings.Join(msgs, "\n") - assert.Contains(t, got, `serviceAccounts[0] (m).labels: "bad key" is not a valid key`) - assert.Contains(t, got, `serviceAccounts[0] (m).annotations: "meta.helm.sh/release-name" is set by the generator or by Helm`) - assert.Contains(t, got, `serviceAccounts[0] (m).rbacAnnotations: "rbac.deckhouse.io/kind" is set by the generator or by Helm`) - assert.NotContains(t, got, "helm.sh/resource-policy") - assert.NotContains(t, got, "werf.io") -} - -// A text for a level nobody grants is reported; so are an account name Kubernetes refuses and an -// empty value in a rule; messages carry the entry's index in the file (regression hunt, B12). -func TestValidate_RegressionHuntB12(t *testing.T) { +// Messages name a resource entry by its index in the file, although the entries are sorted +// (regression hunt, B12). +func TestValidate_IndexOfTheFile(t *testing.T) { decl, err := Parse([]byte(`apiVersion: rbac.deckhouse.io/v1alpha1 -capabilities: - namespace.approve: {title: {en: a, ru: b}, description: {en: c, ru: d}} resources: - {group: z.io, resource: things, scope: Namespaced, namespace: {viewer: [get]}} - {group: a.io, resource: things, scope: Namespaced, namespace: {viewer: [bogus]}} -serviceAccounts: - - name: Bad_Name - clusterRules: - - apiGroups: [""] - resources: [""] - verbs: [get, ""] `)) require.NoError(t, err) @@ -737,30 +663,6 @@ serviceAccounts: } got := strings.Join(msgs, "\n") - assert.Contains(t, got, `capabilities: "namespace.approve" has texts, but no resource entry grants namespace level "approve"`) - assert.Contains(t, got, `resources[1] (a.io/things): namespace.viewer: "bogus" is not a verb`, "the index of the file, not of the sorted list") + assert.Contains(t, got, `resources[1] (a.io/things): namespace.viewer: "bogus" is not a verb`) assert.NotContains(t, got, "resources[0] (a.io/things)") - assert.Contains(t, got, `serviceAccounts[0] (Bad_Name): a ServiceAccount name is a lowercase DNS subdomain`) - assert.Contains(t, got, `serviceAccounts[0] (Bad_Name).clusterRules[0]: verbs holds an empty value`) - assert.Contains(t, got, `serviceAccounts[0] (Bad_Name).clusterRules[0]: resources holds an empty value`) -} - -// A condition that parses but passes a function without its arguments fails when it renders; the -// validator names it (regression hunt 2, B1). -func TestValidateWhen_BareFunction(t *testing.T) { - check := func(when string) string { - var got []string - - validateWhen(when, "x", func(format string, args ...any) { got = append(got, fmt.Sprintf(format, args...)) }) - - return strings.Join(got, "\n") - } - - assert.Contains(t, check("and not (.Values.a) (.Values.b)"), "passes not to another function without its arguments") - assert.Contains(t, check("and (.Values.a) not (.Values.b)"), "passes not") - assert.Empty(t, check("and (not (.Values.a)) (.Values.b)")) - assert.Empty(t, check(`.Values.global.enabledModules | has "prometheus"`)) - assert.Empty(t, check(`and .Values.a (not .Values.b)`)) - assert.Empty(t, check(`include "helper" . | eq "true"`)) - assert.Empty(t, check(`lt (now | unixEpoch) 0 | not`)) } diff --git a/pkg/linters/rbac/rules/rbacyaml/types.go b/pkg/linters/rbac/rules/rbacyaml/types.go index b2d3e75b..6f5a4498 100644 --- a/pkg/linters/rbac/rules/rbacyaml/types.go +++ b/pkg/linters/rbac/rules/rbacyaml/types.go @@ -165,11 +165,6 @@ type ServiceAccount struct { // d8:::, or exactly the given name when it starts with d8:. ExtraClusterRoles []ExtraClusterRole `yaml:"extraClusterRoles,omitempty"` - // Annotations go on the ServiceAccount (helm.sh/resource-policy: keep, werf.io/deploy-on, ...); - // RBACAnnotations on every role and binding generated for the account. - Annotations map[string]string `yaml:"annotations,omitempty"` - RBACAnnotations map[string]string `yaml:"rbacAnnotations,omitempty"` - // AutomountToken is the ServiceAccount's automountServiceAccountToken; unset means false, the // platform convention. A pod that needs the token sets it true on the pod, or the account // declares true here. @@ -213,29 +208,20 @@ type PrometheusAccess struct { // `.Values.global.enabledModules | has "prometheus"`. The Role stays unconditional, so the // generated file keeps the shape of the hand-written ones. When string `yaml:"when,omitempty"` - // Labels and Annotations go on the Role and the RoleBinding. - Labels map[string]string `yaml:"labels,omitempty"` - Annotations map[string]string `yaml:"annotations,omitempty"` } // Access grants arbitrary subjects rights on the module. ClusterRules produce a ClusterRole and // ClusterRoleBinding d8:: in templates/rbac-for-us.yaml; NamespaceRules produce a -// Role and RoleBinding access-to-- in templates/rbac-to-us.yaml, or -// access-to-- in templates//rbac-to-us.yaml. Exactly one of the +// Role and RoleBinding access-to-- in templates/rbac-to-us.yaml. Exactly one of the // two must be set: the placement rule keeps cluster-scoped objects out of rbac-to-us.yaml. type Access struct { Name string `yaml:"name"` Subjects []Subject `yaml:"subjects"` // Path is the component directory under templates/ whose rbac-for-us.yaml (clusterRules) or // rbac-to-us.yaml (namespaceRules) holds the objects; empty means the module root files. - Path string `yaml:"path,omitempty"` - // When wraps the role and the binding in {{- if }}, as for a ServiceAccount. - When string `yaml:"when,omitempty"` - // Labels and Annotations go on the role and the binding. - Labels map[string]string `yaml:"labels,omitempty"` - Annotations map[string]string `yaml:"annotations,omitempty"` - ClusterRules []PolicyRule `yaml:"clusterRules,omitempty"` - NamespaceRules []PolicyRule `yaml:"namespaceRules,omitempty"` + Path string `yaml:"path,omitempty"` + ClusterRules []PolicyRule `yaml:"clusterRules,omitempty"` + NamespaceRules []PolicyRule `yaml:"namespaceRules,omitempty"` } // Subject is an RBAC subject; Namespace is required for a ServiceAccount. diff --git a/pkg/linters/rbac/rules/rbacyaml/validate.go b/pkg/linters/rbac/rules/rbacyaml/validate.go index 261abfd8..2e935b08 100644 --- a/pkg/linters/rbac/rules/rbacyaml/validate.go +++ b/pkg/linters/rbac/rules/rbacyaml/validate.go @@ -18,14 +18,12 @@ package rbacyaml import ( "fmt" - "maps" "reflect" "regexp" "slices" "sort" "strings" "text/template" - "text/template/parse" "github.com/Masterminds/sprig/v3" @@ -59,64 +57,9 @@ func validateWhen(when, where string, report reporter) { return } - tpl, err := template.New("when").Funcs(helmFuncs).Parse("{{ if " + when + " }}{{ end }}") - if err != nil { + if _, err := template.New("when").Funcs(helmFuncs).Parse("{{ if " + when + " }}{{ end }}"); err != nil { report("%s: when %q is not a Helm expression: %v", where, when, err) - return - } - - // `and not (a) (b)` parses: not is an argument of and, called with no arguments of its own, - // and the render fails. A function takes its arguments inside parentheses: (not (a)). - if tpl.Tree != nil && tpl.Tree.Root != nil { - if name := bareFunction(tpl.Tree.Root); name != "" { - report("%s: when %q passes %s to another function without its arguments; write (%s ...) in parentheses", where, when, name, name) - } - } -} - -// bareFunction returns the first function that stands as an argument of another call without -// arguments of its own -- a call with none, which only a function of no parameters survives. -func bareFunction(node parse.Node) string { - switch n := node.(type) { - case *parse.ListNode: - for _, c := range n.Nodes { - if name := bareFunction(c); name != "" { - return name - } - } - case *parse.IfNode: - return bareFunction(n.Pipe) - case *parse.PipeNode: - if n == nil { - return "" - } - - for _, cmd := range n.Cmds { - for i, arg := range cmd.Args { - if id, ok := arg.(*parse.IdentifierNode); ok && i > 0 && !niladic(id.Ident) { - return id.Ident - } - - if name := bareFunction(arg); name != "" { - return name - } - } - } - } - - return "" -} - -// niladic reports whether the function takes no arguments (sprig's now, uuidv4, ...). -func niladic(name string) bool { - f, ok := helmFuncs[name] - if !ok { - return false // a builtin: and, not, eq, len, ... all take arguments } - - t := reflect.TypeOf(f) - - return t.Kind() == reflect.Func && t.NumIn() == 0 } // Validate checks the declaration against the format rules. crds is what the linted tree says @@ -178,8 +121,6 @@ func Validate(d *Declaration, crds CRDScopes) []error { } validateWhen(d.PrometheusAccess.When, "prometheusAccess", report) - validateMetadataKeys(d.PrometheusAccess.Labels, "prometheusAccess.labels", false, report) - validateMetadataKeys(d.PrometheusAccess.Annotations, "prometheusAccess.annotations", true, report) } // Several checks walk maps; the reader and the e2e expectations get one order. @@ -224,14 +165,13 @@ func validateResource(r *Resource, where string, crds CRDScopes, usedCapabilitie report("%s: %s", where, scopeErr) } - // "*/" is a wildcard over the resources as much as "*" is. - if r.IsWildcard() || strings.HasPrefix(r.Resource, "*/") { + if r.IsWildcard() { if crds.groupKnown(r.Group) { - report("%s: resource %q is allowed only for a group the module ships no CRD for; list the resources of %q", where, r.Resource, r.Group) + report("%s: resource \"*\" is allowed only for a group the module ships no CRD for; list the resources of %q", where, r.Group) } if r.Reason == "" { - report("%s: resource %q requires reason: why the resource names are not known statically", where, r.Resource) + report("%s: resource \"*\" requires reason: why the resource names are not known statically", where) } } @@ -269,15 +209,6 @@ func resolveScope(r *Resource, crds CRDScopes) (string, string) { case known: return fromCRD, "" case r.Scope != "": - // A built-in resource has the scope Kubernetes serves it with; a declared one that differs - // would generate a capability that grants nothing (or a namespaced grant cluster-wide). - // namespaces is the exception: a RoleBinding that grants get on namespaces lets its - // subject read the Namespace it is bound in, so a namespace capability declares it - // Namespaced on purpose (user-authz's view_resources does). - if builtin, ok := WellKnownScope(r.Group, r.Resource); ok && builtin != r.Scope && (r.Group != "" || base != "namespaces") { - return builtin, fmt.Sprintf("scope %q disagrees with Kubernetes, which serves %s as %q", r.Scope, r.Key(), builtin) - } - return r.Scope, "" default: if scope, ok := WellKnownScope(r.Group, r.Resource); ok { @@ -340,9 +271,7 @@ func validateLevels(levels map[string][]string, lineage string, allowed []string // validateCapabilities requires localized texts for every capability outside the platform // convention (anything but view/edit) and rejects malformed entries. func validateCapabilities(texts map[string]CapabilityText, used map[string]struct{}, report reporter) { - for _, key := range slices.Sorted(maps.Keys(texts)) { - text := texts[key] - + for key, text := range texts { lineage, action, ok := strings.Cut(key, ".") if !ok || (lineage != rbaccontract.LineageNamespace && lineage != rbaccontract.LineageSystem) { report("capabilities: key %q must be \"namespace.\" or \"system.\"", key) @@ -351,10 +280,6 @@ func validateCapabilities(texts map[string]CapabilityText, used map[string]struc if rbaccontract.IsConventionalAction(action) { report("capabilities: %q needs no texts: view and edit capabilities take the platform's conventional texts", key) - } else if _, isUsed := used[key]; !isUsed { - // A text for a level nobody grants -- a typo in the key, or an entry that was removed -- - // produces nothing, and the level it was meant for is left without texts. - report("capabilities: %q has texts, but no resource entry grants %s level %q; check the key, or drop the texts", key, lineage, action) } for _, field := range []struct { @@ -390,10 +315,6 @@ func validateServiceAccounts(accounts []ServiceAccount, report reporter) { continue } - if len(sa.Name) > 253 || !dnsSubdomainRe.MatchString(sa.Name) { - report("%s: a ServiceAccount name is a lowercase DNS subdomain (letters, digits, '-' and '.')", where) - } - if _, dup := names[sa.Name]; dup { report("%s: duplicate name", where) } @@ -402,10 +323,6 @@ func validateServiceAccounts(accounts []ServiceAccount, report reporter) { validateWhen(sa.When, where, report) - validateMetadataKeys(sa.Labels, where+".labels", false, report) - validateMetadataKeys(sa.Annotations, where+".annotations", true, report) - validateMetadataKeys(sa.RBACAnnotations, where+".rbacAnnotations", true, report) - if strings.HasPrefix(sa.Path, "/") || strings.HasSuffix(sa.Path, "/") || strings.Contains(sa.Path, "..") { report("%s: path must be a directory under templates/ without leading or trailing slashes, got %q", where, sa.Path) } @@ -471,10 +388,6 @@ func validateAccess(access []Access, report reporter) { report("%s: subjects is required", where) } - validateWhen(a.When, where, report) - validateMetadataKeys(a.Labels, where+".labels", false, report) - validateMetadataKeys(a.Annotations, where+".annotations", true, report) - if strings.HasPrefix(a.Path, "/") || strings.HasSuffix(a.Path, "/") || strings.Contains(a.Path, "..") { report("%s: path must be a directory under templates/ without leading or trailing slashes, got %q", where, a.Path) } @@ -525,16 +438,6 @@ func validatePolicyRules(rules []PolicyRule, where string, report reporter) { report("%s[%d]: verbs is required", where, i) } - // An empty string is no verb, resource or name: Kubernetes keeps it and it matches nothing. - for _, field := range []struct { - name string - values []string - }{{"verbs", rule.Verbs}, {"resources", rule.Resources}, {"resourceNames", rule.ResourceNames}, {"nonResourceURLs", rule.NonResourceURLs}} { - if slices.Contains(field.values, "") { - report("%s[%d]: %s holds an empty value", where, i, field.name) - } - } - if len(rule.NonResourceURLs) > 0 && (len(rule.APIGroups) > 0 || len(rule.Resources) > 0 || len(rule.ResourceNames) > 0) { report("%s[%d]: nonResourceURLs cannot be combined with apiGroups, resources or resourceNames", where, i) } @@ -630,24 +533,5 @@ func Warnings(d *Declaration) []string { var ( groupNameRe = regexp.MustCompile(`^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$`) - resourceNameRe = regexp.MustCompile(`^(\*|[a-z0-9]([-a-z0-9]*[a-z0-9])?)(/[a-z0-9]([-a-z0-9]*[a-z0-9])?)?$`) - dnsSubdomainRe = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$`) + resourceNameRe = regexp.MustCompile(`^(\*|[a-z0-9]([-a-z0-9.]*[a-z0-9])?)(/[a-z0-9]([-a-z0-9]*[a-z0-9])?)?$`) ) - -// qualifiedNameRe is a Kubernetes label or annotation key: an optional DNS prefix and a name. -var qualifiedNameRe = regexp.MustCompile(`^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?[A-Za-z0-9]([-A-Za-z0-9_.]*[A-Za-z0-9])?$`) - -// validateMetadataKeys checks label or annotation keys: the generator writes them unquoted, and -// the rbac.deckhouse.io and meta.helm.sh annotations belong to the generator and to Helm. -func validateMetadataKeys(m map[string]string, where string, annotations bool, report reporter) { - for _, k := range slices.Sorted(maps.Keys(m)) { - if len(k) > 316 || !qualifiedNameRe.MatchString(k) { - report("%s: %q is not a valid key ([prefix/]name, the name up to 63 characters of letters, digits, '-', '_' and '.')", where, k) - continue - } - - if annotations && (strings.HasPrefix(k, "rbac.deckhouse.io/") || strings.HasPrefix(k, "meta.helm.sh/")) { - report("%s: %q is set by the generator or by Helm, not by the declaration", where, k) - } - } -} diff --git a/pkg/linters/rbac/rules/sync.go b/pkg/linters/rbac/rules/sync.go index 0224aa45..7d1748d8 100644 --- a/pkg/linters/rbac/rules/sync.go +++ b/pkg/linters/rbac/rules/sync.go @@ -919,11 +919,6 @@ func compareFile(file generate.File, actual map[string]managedObject, module str func compareObject(expected generate.Object, actual storage.StoreObject, module string) []string { var out []string - if expected.Class == generate.ClassDeclared { - out = append(out, compareAnnotations(expected, actual)...) - out = append(out, compareLabels(expected, actual)...) - } - id := expected.Identity() content := actual.Unstructured.UnstructuredContent() @@ -1275,11 +1270,8 @@ func (r *SyncRule) bootstrap(declList *errors.LintRuleErrorsList) { in.CRDs[crd.Key()] = crd.Scope } - docs := map[string][]bootstrap.Doc{} - for _, object := range storage { if o, ok := bootstrapObject(object); ok { - locateInTemplate(modulePath, &o, docs) in.Objects = append(in.Objects, o) } } @@ -1288,7 +1280,6 @@ func (r *SyncRule) bootstrap(declList *errors.LintRuleErrorsList) { return } - in.Unrendered = unrenderedObjects(modulePath, in.Objects) result := bootstrap.Build(in) described := len(in.Objects) - len(result.Unmanaged) path := rbacyaml.Path(modulePath) @@ -1304,7 +1295,6 @@ func (r *SyncRule) bootstrap(declList *errors.LintRuleErrorsList) { } in.Objects = bootstrapObjectsOf(path) - in.Unrendered = unrenderedObjects(modulePath, in.Objects) result := bootstrap.Build(in) content, err := bootstrap.Marshal(result) @@ -1715,36 +1705,6 @@ func renderedTwin(object storage.StoreObject, produced []generate.Object, render return "" } -// compareAnnotations compares the annotations of an object the declaration writes whole: a -// resource policy or a deploy hook dropped by a regeneration changes what Helm or werf do with it. -func compareAnnotations(expected generate.Object, actual storage.StoreObject) []string { - id := expected.Identity() - rendered := actual.Unstructured.GetAnnotations() - - var out []string - - for _, k := range slices.Sorted(maps.Keys(rendered)) { - // Helm's release annotations and the generator's own are nobody's to declare. - if strings.HasPrefix(k, "meta.helm.sh/") || strings.HasPrefix(k, "rbac.deckhouse.io/") { - continue - } - - if want, ok := expected.Annotations[k]; !ok { - out = append(out, fmt.Sprintf("%s: annotation %s is in the render but not declared", id, k)) - } else if want != rendered[k] { - out = append(out, fmt.Sprintf("%s: annotation %s is %q in the render, the declaration produces %q", id, k, rendered[k], want)) - } - } - - for _, k := range slices.Sorted(maps.Keys(expected.Annotations)) { - if _, ok := rendered[k]; !ok { - out = append(out, fmt.Sprintf("%s: annotation %s is declared but absent from the render", id, k)) - } - } - - return out -} - // manualFix is the fix of a finding only a person can close: nothing is generated while it // stands, so `--fix` must not report success (it fails with what to do). func manualFix(what string) errors.AutofixFunc { @@ -1753,153 +1713,6 @@ func manualFix(what string) errors.AutofixFunc { } } -// locateInTemplate reads the template blocks around a rendered object from its template's text: -// the render only holds what rendered for the linter's values, the text holds the conditions. -func locateInTemplate(modulePath string, o *bootstrap.Object, cache map[string][]bootstrap.Doc) { - // A subchart's template is written against the subchart's values, and the generator writes - // the module's own templates: the declaration has no place for its objects. - if strings.HasPrefix(o.Path, "charts/") { - o.Located = true - o.Unmanageable = "rendered by the subchart " + strings.SplitN(o.Path, "/", 3)[1] + ", whose templates and values are its own" - - return - } - - docs, ok := cache[o.Path] - if !ok { - if content, err := os.ReadFile(filepath.Join(modulePath, o.Path)); err == nil { - docs = bootstrap.TemplateDocs(string(content)) - } - - cache[o.Path] = docs - } - - d, found := bootstrap.Locate(docs, *o) - if !found { - return - } - - o.Located = true - o.When, o.Unmanageable, o.Partial = d.When, d.Unmanageable, d.Partial - - if d.Library && o.Unmanageable == "" { - o.Unmanageable = "rendered by an include of a named template (helm_lib or another chart), which owns it" - } -} - -// writtenProblems lists what the linter refuses in a declaration bootstrap wrote, the TODO -// values aside: they are counted on their own. -func writtenProblems(content []byte, crds []crdInfo, in bootstrap.Input) string { - decl, err := rbacyaml.Parse(content) - if err != nil { - return "; it does not parse: " + err.Error() - } - - var problems []string - - for _, e := range rbacyaml.Validate(decl, crdScopes(crds)) { - if !strings.Contains(e.Error(), "TODO") { - problems = append(problems, e.Error()) - } - } - - if len(problems) == 0 { - if _, err := generate.Build(generate.Input{Module: in.Module, Namespace: in.Namespace, Subsystems: in.Subsystems, Decl: decl}); err != nil && !strings.Contains(err.Error(), "TODO") { - problems = append(problems, err.Error()) - } - } - - if len(problems) == 0 { - return "" - } - - return "; the linter refuses: " + strings.Join(problems, "; ") -} - -// rbacKinds are the kinds bootstrap describes. -var rbacKinds = map[string]bool{"ClusterRole": true, "ClusterRoleBinding": true, "Role": true, "RoleBinding": true, "ServiceAccount": true} - -// unrenderedObjects lists the RBAC objects with a literal name that the module's templates hold -// and no render showed: an object under a condition false for the linter's values would -// otherwise be left out of the first declaration without a word, and the regeneration would drop -// it. Only the text can tell; a computed name is not followed. -func unrenderedObjects(modulePath string, rendered []bootstrap.Object) []string { - seen := make(map[string]bool, len(rendered)) - for _, o := range rendered { - seen[o.Kind+"/"+o.Name] = true - } - - var out []string - - root := filepath.Join(modulePath, "templates") - - _ = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { - if err != nil || d.IsDir() || strings.HasPrefix(d.Name(), "_") || (filepath.Ext(path) != ".yaml" && filepath.Ext(path) != ".yml") { - return nil //nolint:nilerr // an unreadable entry is not the importer's to report - } - - content, err := os.ReadFile(path) - if err != nil { - return nil //nolint:nilerr // as above - } - - rel, _ := filepath.Rel(modulePath, path) - - for _, doc := range bootstrap.TemplateDocs(string(content)) { - if !rbacKinds[doc.Kind] || doc.Name == "" || seen[doc.Kind+"/"+doc.Name] || doc.Unmanageable != "" { - continue - } - - entry := fmt.Sprintf("%s/%s (%s", doc.Kind, doc.Name, rel) - if doc.When != "" { - entry += ", under `" + doc.When + "`" - } - - out = append(out, entry+")") - } - - return nil - }) - - sort.Strings(out) - - return out -} - -// moduleLabels are the labels helm_lib_module_labels writes on every object; the declaration -// names the others. -var moduleLabels = map[string]bool{"heritage": true, "module": true} - -// compareLabels compares the labels of an object the declaration writes whole: an aggregation -// label or a part-of label the declaration does not carry would be dropped by the next -// regeneration, and an aggregation label is a right. -func compareLabels(expected generate.Object, actual storage.StoreObject) []string { - id := expected.Identity() - rendered := actual.Unstructured.GetLabels() - - var out []string - - for _, k := range slices.Sorted(maps.Keys(rendered)) { - if moduleLabels[k] { - continue - } - - if want, ok := expected.Labels[k]; !ok { - out = append(out, fmt.Sprintf("%s: label %s is in the render but not declared", id, k)) - } else if want != rendered[k] { - out = append(out, fmt.Sprintf("%s: label %s is %q in the render, the declaration produces %q", id, k, rendered[k], want)) - } - } - - for _, k := range slices.Sorted(maps.Keys(expected.Labels)) { - if _, ok := rendered[k]; !ok && !moduleLabels[k] { - out = append(out, fmt.Sprintf("%s: label %s is declared but absent from the render", id, k)) - } - } - - return out -} - // roleKey names a role as bindings refer to it: a Role with its namespace, a ClusterRole without. func roleKey(kind, namespace, name string) string { if kind == "ClusterRole" { @@ -1993,27 +1806,21 @@ func splitChanges(divergences []string) ([]string, []string) { } // writeBootstrapped writes the declaration bootstrap produced and says what is left for a person. -// A declaration that does not parse is a bug of dmt, yet it is written all the same: the module's -// developer fixes the line the error names and goes on, instead of waiting for a dmt release with -// nothing to look at. Bootstrap runs only while the file is missing, so the error says where to -// look. +// A declaration that does not parse would be a bug of dmt; it is written all the same, so the +// module's developer sees the file and the parse error rather than nothing. func writeBootstrapped(path string, content []byte, crds []crdInfo, in bootstrap.Input) error { if err := writeFileAtomic(path, content, 0o644); err != nil { //nolint:gosec // a source file of the module return err } if _, err := rbacyaml.Parse(content); err != nil { - return fmt.Errorf("%s is written, but it does not parse (%w); the declaration is complete apart from that line -- most likely a note in the header that lost its '#': fix or delete the line, then run `%s` again. This is a bug of dmt, report it with the module", - rbacyaml.Filename, err, FixCommand) + return fmt.Errorf("%s is written, but it does not parse: %w; this is a bug of dmt, report it with the module", rbacyaml.Filename, err) } // The file is written, but a TODO in it is a decision nobody has made yet: the finding stays, // and so does the non-zero exit, until a person makes it (ADR, bootstrap). What the linter // would refuse in the written file is named here too, rather than on the next run. problems := writtenProblems(content, crds, in) - if len(in.Unrendered) > 0 { - problems += "; the templates hold objects no render showed, and the declaration does not: " + strings.Join(in.Unrendered, ", ") - } if open := openDecisions(string(content)); open > 0 { return fmt.Errorf("%s is written; %d TODO in it are decisions only a person can make%s -- resolve them, then run `%s` to regenerate the templates", rbacyaml.Filename, open, problems, FixCommand) @@ -2025,3 +1832,32 @@ func writeBootstrapped(path string, content []byte, crds []crdInfo, in bootstrap return nil } + +// writtenProblems lists what the linter refuses in a declaration bootstrap wrote, the TODO +// values aside: they are counted on their own. +func writtenProblems(content []byte, crds []crdInfo, in bootstrap.Input) string { + decl, err := rbacyaml.Parse(content) + if err != nil { + return "; it does not parse: " + err.Error() + } + + var problems []string + + for _, e := range rbacyaml.Validate(decl, crdScopes(crds)) { + if !strings.Contains(e.Error(), "TODO") { + problems = append(problems, e.Error()) + } + } + + if len(problems) == 0 { + if _, err := generate.Build(generate.Input{Module: in.Module, Namespace: in.Namespace, Subsystems: in.Subsystems, Decl: decl}); err != nil && !strings.Contains(err.Error(), "TODO") { + problems = append(problems, err.Error()) + } + } + + if len(problems) == 0 { + return "" + } + + return "; the linter refuses: " + strings.Join(problems, "; ") +} diff --git a/pkg/linters/rbac/rules/sync_regressions2_test.go b/pkg/linters/rbac/rules/sync_regressions2_test.go index 7850fef4..b0f4d5c2 100644 --- a/pkg/linters/rbac/rules/sync_regressions2_test.go +++ b/pkg/linters/rbac/rules/sync_regressions2_test.go @@ -17,59 +17,18 @@ limitations under the License. package rules import ( - "context" "os" "strings" "testing" - "github.com/gojuno/minimock/v3" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/deckhouse/dmt/internal/mocks" - "github.com/deckhouse/dmt/pkg/errors" "github.com/deckhouse/dmt/pkg/linters/rbac/rules/bootstrap" "github.com/deckhouse/dmt/pkg/linters/rbac/rules/generate" "github.com/deckhouse/dmt/pkg/linters/rbac/rules/rbacyaml" ) -// What the generator writes for accounts and access entries in nested directories passes the -// placement rule (regression hunt 2, A1). -func TestSyncRegression_GeneratedNamesPassPlacement(t *testing.T) { - get := []rbacyaml.PolicyRule{{APIGroups: []string{""}, Resources: []string{"secrets"}, Verbs: []string{"get"}}} - nodes := []rbacyaml.PolicyRule{{APIGroups: []string{""}, Resources: []string{"nodes"}, Verbs: []string{"get"}}} - group := []rbacyaml.Subject{{Kind: "Group", Name: "g"}} - - decl := &rbacyaml.Declaration{APIVersion: rbacyaml.APIVersionV1Alpha1, - ServiceAccounts: []rbacyaml.ServiceAccount{ - {Name: "webhook-tls", Path: "webhook/tls", ClusterRules: nodes, NamespaceRules: get, BindClusterRoles: []string{"d8:rbac-proxy"}, - BindRoles: []rbacyaml.RoleRef{{Namespace: "kube-system", Name: "extension-apiserver-authentication-reader"}}}, - {Name: "cainjector", Path: "cainjector", NamespaceRules: get}, - {Name: syncModule, ClusterRules: nodes, NamespaceRules: get}, - }, - Access: []rbacyaml.Access{ - {Name: "reader", Path: "webhook/tls", Subjects: group, NamespaceRules: get}, - {Name: "nodes", Path: "webhook", Subjects: group, ClusterRules: nodes}, - }, - } - require.Empty(t, rbacyaml.Validate(decl, nil)) - - model, err := generate.Build(generate.Input{Module: syncModule, Namespace: "d8-cert-manager", Subsystems: []string{"security"}, Decl: decl}) - require.NoError(t, err) - - store := renderedFrom(t, model, nil) - - m := mocks.NewModuleMock(minimock.NewController(t)) - m.GetNameMock.Return(syncModule) - m.GetNamespaceMock.Optional().Return("d8-cert-manager") - m.GetStorageMock.Return(store.Storage) - - errorList := errors.NewLintRuleErrorsList() - NewPlacementRule(nil, m, errorList).Check(context.Background()) - - assert.Empty(t, texts(errorList)) -} - // A hand-written role of another account with the same rules as a declared one is not a // replaced copy: it is granted to other subjects (regression hunt 2, A3). func TestSyncRegression_EqualRulesOfAnotherAccountAreNoCopy(t *testing.T) { @@ -105,9 +64,9 @@ func TestSyncRegression_EqualRulesOfAnotherAccountAreNoCopy(t *testing.T) { assert.NotContains(t, got, "the old copy of") } -// Labels of a declared object are compared, the module labels aside; a ServiceAccount subject -// without a namespace is in the RoleBinding's (regression hunt 2, A4 and A5). -func TestSyncRegression_LabelsAndSubjectNamespace(t *testing.T) { +// A ServiceAccount subject without a namespace is in the RoleBinding's, as Kubernetes has it +// (regression hunt 2, A5). +func TestSyncRegression_SubjectWithoutNamespace(t *testing.T) { resetFixState() t.Cleanup(resetFixState) @@ -116,10 +75,7 @@ func TestSyncRegression_LabelsAndSubjectNamespace(t *testing.T) { writeGenerated(t, modulePath, model) errorList := runSync(t, modulePath, renderedFrom(t, model, func(o *generate.Object) bool { - switch { - case o.Kind == "ClusterRole" && o.Name == "d8:cert-manager:cainjector": - o.Labels = map[string]string{"app": "cainjector", "rbac.authorization.k8s.io/aggregate-to-admin": "true"} - case o.Kind == "RoleBinding" && o.Name == "cainjector": + if o.Kind == "RoleBinding" && o.Name == "cainjector" { subjects := make([]generate.Subject, 0, len(o.Subjects)) for _, s := range o.Subjects { s.Namespace = "" @@ -132,11 +88,7 @@ func TestSyncRegression_LabelsAndSubjectNamespace(t *testing.T) { return true })) - got := strings.Join(texts(errorList), "\n") - assert.Contains(t, got, "ClusterRole/d8:cert-manager:cainjector: label rbac.authorization.k8s.io/aggregate-to-admin is in the render but not declared") - assert.NotContains(t, got, "label heritage") - assert.NotContains(t, got, "label module") - assert.NotContains(t, got, "RoleBinding/cainjector: subject") + assert.NotContains(t, strings.Join(texts(errorList), "\n"), "RoleBinding/cainjector: subject") } func TestSplitChanges(t *testing.T) { @@ -149,16 +101,6 @@ func TestSplitChanges(t *testing.T) { assert.Len(t, removed, 2) } -// A subchart's objects stay hand-written: its templates read its own values (regression hunt 2, -// B6). -func TestLocateInTemplate_Subchart(t *testing.T) { - o := bootstrap.Object{Kind: "ServiceAccount", Name: "subsa", Path: "charts/sub/templates/rbac-for-us.yaml"} - locateInTemplate(t.TempDir(), &o, map[string][]bootstrap.Doc{}) - - assert.True(t, o.Located) - assert.Equal(t, "rendered by the subchart sub, whose templates and values are its own", o.Unmanageable) -} - // A declaration bootstrap produced that does not parse is a bug of dmt, yet it is written: the // error names the line, the developer fixes it and goes on. The next lint reads the file, it does // not bootstrap again. @@ -176,7 +118,7 @@ func TestWriteBootstrapped_UnparsableIsWrittenWithTheLine(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "rbac.yaml is written, but it does not parse") assert.Contains(t, err.Error(), "line 3") - assert.Contains(t, err.Error(), "fix or delete the line, then run `dmt lint --linter rbac --fix` again") + assert.Contains(t, err.Error(), "this is a bug of dmt") written, readErr := os.ReadFile(path) require.NoError(t, readErr) diff --git a/pkg/linters/rbac/rules/sync_regressions_test.go b/pkg/linters/rbac/rules/sync_regressions_test.go index e5cb1f45..1d0f176d 100644 --- a/pkg/linters/rbac/rules/sync_regressions_test.go +++ b/pkg/linters/rbac/rules/sync_regressions_test.go @@ -402,83 +402,6 @@ func TestSyncRegression_ReplacedCopyIsReported(t *testing.T) { assert.Contains(t, got, "d8-cert-manager/RoleBinding/access-to-cert-manager-prometheus-metrics binds access-to-cert-manager-prometheus-metrics, the old copy of d8-cert-manager/Role/access-to-cert-manager") } -// An annotation on an object the declaration writes whole is compared: a resource policy the -// declaration does not carry would be dropped by the next regeneration (regression hunt, B7). -func TestSyncRegression_AnnotationsAreCompared(t *testing.T) { - resetFixState() - t.Cleanup(resetFixState) - - modulePath := syncModuleDir(t) - model := syncModel(t, modulePath) - writeGenerated(t, modulePath, model) - - errorList := runSync(t, modulePath, renderedFrom(t, model, func(o *generate.Object) bool { - if o.Kind == "ServiceAccount" && o.Name == "cainjector" { - o.Annotations = map[string]string{"helm.sh/resource-policy": "keep"} - } - - return true - })) - - assert.Contains(t, strings.Join(texts(errorList), "\n"), "ServiceAccount/cainjector: annotation helm.sh/resource-policy is in the render but not declared") -} - -// Bootstrap reads the conditions from the template text: an account under {{ if }} keeps its -// `when` (regression hunt, B1). -func TestSyncRegression_BootstrapKeepsTheTemplateCondition(t *testing.T) { - resetFixState() - t.Cleanup(resetFixState) - - modulePath := syncModuleDir(t) - model := syncModel(t, modulePath) - writeGenerated(t, modulePath, model) - require.NoError(t, os.Remove(rbacyaml.Path(modulePath))) - - errorList := runSync(t, modulePath, renderedFrom(t, model, nil)) - for _, fix := range errorList.GetFixes() { - fix() - } - - written, err := rbacyaml.Load(modulePath) - require.NoError(t, err) - require.Len(t, written.ServiceAccounts, 1) - assert.Equal(t, ".Values.certManager.internal.enableCAInjector", written.ServiceAccounts[0].When) -} - -// An object under a condition false for the linter's values is in no render; the text shows it, -// the declaration header names it and the fix fails, rather than the regeneration dropping it -// (regression hunt, B1). -func TestSyncRegression_BootstrapNamesWhatDidNotRender(t *testing.T) { - resetFixState() - t.Cleanup(resetFixState) - - modulePath := syncModuleDir(t) - model := syncModel(t, modulePath) - writeGenerated(t, modulePath, model) - require.NoError(t, os.Remove(rbacyaml.Path(modulePath))) - - errorList := runSync(t, modulePath, renderedFrom(t, model, func(o *generate.Object) bool { return o.When == "" })) - for _, fix := range errorList.GetFixes() { - fix() - } - - require.True(t, errorList.ContainsFailedFixes()) - - var fixErrors []string - - for _, e := range errorList.GetErrors() { - if e.FixError != nil { - fixErrors = append(fixErrors, e.FixError.Error()) - } - } - - assert.Contains(t, strings.Join(fixErrors, "\n"), "ServiceAccount/cainjector (templates/cainjector/rbac-for-us.yaml, under `.Values.certManager.internal.enableCAInjector`)") - - content, err := os.ReadFile(rbacyaml.Path(modulePath)) - require.NoError(t, err) - assert.Contains(t, string(content), "ServiceAccount/cainjector (templates/cainjector/rbac-for-us.yaml, under `.Values.certManager.internal.enableCAInjector`) is in the templates but did not render") -} - // What the linter would refuse in a written declaration is named by the fix that wrote it // (regression hunt, B10). func TestSyncRegression_WrittenProblems(t *testing.T) { @@ -486,7 +409,7 @@ func TestSyncRegression_WrittenProblems(t *testing.T) { assert.Empty(t, writtenProblems([]byte("apiVersion: rbac.deckhouse.io/v1alpha1\n"), nil, in)) assert.Contains(t, writtenProblems([]byte("apiVersion: rbac.deckhouse.io/v1alpha1\nserviceAccounts:\n - name: x\n path: a/b\n"), nil, in), - `the placement rule wants the account named "a-b" after its directory`) + "one directory under templates/ only") assert.Contains(t, writtenProblems([]byte("apiVersion: rbac.deckhouse.io/v1alpha1\nserviceAccounts:\n - name: x\n when: .Values.x }}\n"), nil, in), "template delimiter") assert.Empty(t, writtenProblems([]byte("apiVersion: rbac.deckhouse.io/v1alpha1\nserviceAccounts:\n - name: x\n when: \"TODO: decide\"\n"), nil, in), "a TODO is counted on its own") } diff --git a/pkg/linters/rbac/rules/sync_test.go b/pkg/linters/rbac/rules/sync_test.go index c07b477c..af4015ac 100644 --- a/pkg/linters/rbac/rules/sync_test.go +++ b/pkg/linters/rbac/rules/sync_test.go @@ -1522,7 +1522,7 @@ func TestSync_InvalidDeclarationFailsTheFix(t *testing.T) { errorList := runSync(t, modulePath, renderedFrom(t, model, nil)) require.NotEmpty(t, errorList.GetFixes()) - assert.Contains(t, strings.Join(texts(errorList), "\n"), "the placement rule wants the account named") + assert.Contains(t, strings.Join(texts(errorList), "\n"), "one directory under templates/ only") for _, fix := range errorList.GetFixes() { fix() From 15f9c934278712a03d87eaef5a48473a20aa763e Mon Sep 17 00:00:00 2001 From: Ivan Zvyagintsev Date: Thu, 24 Sep 2026 12:40:01 +0300 Subject: [PATCH 46/58] rbac: an unknown template action anywhere in a document makes it foreign Review of #479, finding 31: a line was judged only until the kind line was seen, so an include after it -- `{{ include "x" . }}` before the last `{{- end }}` of a generated file -- was invisible. Dropping the account deleted the file with the include; changing nothing regenerated it without the include. Any line with an action the generator does not write (it writes only the if/else/end wrappers and the module labels include) now makes the document unreadable, so the fix refuses. Signed-off-by: Ivan Zvyagintsev --- pkg/linters/rbac/rules/sync.go | 14 +++- .../rbac/rules/sync_regressions_test.go | 68 +++++++++++++++++++ 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/pkg/linters/rbac/rules/sync.go b/pkg/linters/rbac/rules/sync.go index 7d1748d8..575fa34b 100644 --- a/pkg/linters/rbac/rules/sync.go +++ b/pkg/linters/rbac/rules/sync.go @@ -1494,6 +1494,8 @@ var ( // wrapperLineRe matches the lines the generator puts between objects: its conditions and // their ends. Anything else outside an object is content the fix does not understand. wrapperLineRe = regexp.MustCompile(`^\s*(\{\{-?\s*(if|else|end)\b[^}]*-?\}\}\s*)*$`) + // labelsLineRe is the only other template action the generator writes: the module labels. + labelsLineRe = regexp.MustCompile(`^\s*\{\{- include "helm_lib_module_labels" \(list \..*\| nindent 2 \}\}\s*$`) ) // textDocuments parses the objects of a generated file from its text: the generator writes kind, @@ -1510,8 +1512,16 @@ func textDocuments(content string) []textDocument { inMetadata := false other := false + // An action the generator does not write -- an include, a range, a value from the values -- + // makes the document someone else's wherever it stands, after the kind line too: what it + // renders under other values is not in this render (review of #479, finding 31). + foreign := false for _, line := range strings.Split(doc, "\n") { + if strings.Contains(line, "{{") && !wrapperLineRe.MatchString(line) && !labelsLineRe.MatchString(line) { + foreign = true + } + switch { case line == "metadata:": inMetadata = true @@ -1539,9 +1549,9 @@ func textDocuments(content string) []textDocument { } switch { - case kind == "" && !other: + case kind == "" && !other && !foreign: continue // the header, or the end of a conditional block - case kind == "" || name == "" || strings.Contains(name, "{{"): + case foreign || kind == "" || name == "" || strings.Contains(name, "{{"): out = append(out, textDocument{id: fmt.Sprintf("", i)}) continue } diff --git a/pkg/linters/rbac/rules/sync_regressions_test.go b/pkg/linters/rbac/rules/sync_regressions_test.go index 1d0f176d..b6b6333a 100644 --- a/pkg/linters/rbac/rules/sync_regressions_test.go +++ b/pkg/linters/rbac/rules/sync_regressions_test.go @@ -413,3 +413,71 @@ func TestSyncRegression_WrittenProblems(t *testing.T) { assert.Contains(t, writtenProblems([]byte("apiVersion: rbac.deckhouse.io/v1alpha1\nserviceAccounts:\n - name: x\n when: .Values.x }}\n"), nil, in), "template delimiter") assert.Empty(t, writtenProblems([]byte("apiVersion: rbac.deckhouse.io/v1alpha1\nserviceAccounts:\n - name: x\n when: \"TODO: decide\"\n"), nil, in), "a TODO is counted on its own") } + +// An include inside an object's document, after its kind line, is someone else's too: the fix +// neither deletes the file (the account dropped) nor regenerates it without the include (nothing +// changed) (review of #479, finding 31). +func TestSyncRegression_IncludeInsideTheDocument(t *testing.T) { + const rel = "templates/cainjector/rbac-for-us.yaml" + + inject := func(t *testing.T, modulePath string) string { + t.Helper() + + fullPath := filepath.Join(modulePath, rel) + content, err := os.ReadFile(fullPath) + require.NoError(t, err) + + i := strings.LastIndex(string(content), "{{- end }}\n") + require.GreaterOrEqual(t, i, 0) + + patched := string(content[:i]) + "{{- if .Values.handExtra }}\n{{ include \"cainjector-extra\" . }}\n{{- end }}\n" + string(content[i:]) + require.NoError(t, os.WriteFile(fullPath, []byte(patched), 0o600)) + + return patched + } + + t.Run("the account dropped: the file is not deleted", func(t *testing.T) { + resetFixState() + t.Cleanup(resetFixState) + + modulePath := syncModuleDir(t) + model := syncModel(t, modulePath) + writeGenerated(t, modulePath, model) + inject(t, modulePath) + + decl, err := rbacyaml.Load(modulePath) + require.NoError(t, err) + + decl.ServiceAccounts = nil + raw, err := yaml.Marshal(decl) + require.NoError(t, err) + require.NoError(t, os.WriteFile(rbacyaml.Path(modulePath), raw, 0o600)) + + list := runSync(t, modulePath, renderedFrom(t, model, nil)) + for _, fix := range list.GetFixes() { + fix() + } + + _, err = os.Stat(filepath.Join(modulePath, rel)) + assert.NoError(t, err, "a file holding a hand-added include must not be deleted") + }) + + t.Run("nothing changed: the include is not dropped", func(t *testing.T) { + resetFixState() + t.Cleanup(resetFixState) + + modulePath := syncModuleDir(t) + model := syncModel(t, modulePath) + writeGenerated(t, modulePath, model) + patched := inject(t, modulePath) + + list := runSync(t, modulePath, renderedFrom(t, model, nil)) + for _, fix := range list.GetFixes() { + fix() + } + + got, err := os.ReadFile(filepath.Join(modulePath, rel)) + require.NoError(t, err) + assert.Equal(t, patched, string(got)) + }) +} From a5b8fffaf097ab7ff10acfab7cc8880bb61fc45f Mon Sep 17 00:00:00 2001 From: Ivan Zvyagintsev Date: Thu, 24 Sep 2026 14:04:35 +0300 Subject: [PATCH 47/58] rbac: recognize the generator's labels line only in the shapes it writes Review of #479, finding 31 (follow-up): the pattern ended in '.*| nindent 2 }}', so an include appended to the labels line or labels taken from the values counted as the generator's, and --fix dropped them. The line now matches only the two shapes generate.labelsInclude writes: no labels, or a dict of quoted literals. Signed-off-by: Ivan Zvyagintsev --- pkg/linters/rbac/rules/sync.go | 7 ++- .../rbac/rules/sync_regressions_test.go | 45 +++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/pkg/linters/rbac/rules/sync.go b/pkg/linters/rbac/rules/sync.go index 575fa34b..1d714c87 100644 --- a/pkg/linters/rbac/rules/sync.go +++ b/pkg/linters/rbac/rules/sync.go @@ -1494,8 +1494,11 @@ var ( // wrapperLineRe matches the lines the generator puts between objects: its conditions and // their ends. Anything else outside an object is content the fix does not understand. wrapperLineRe = regexp.MustCompile(`^\s*(\{\{-?\s*(if|else|end)\b[^}]*-?\}\}\s*)*$`) - // labelsLineRe is the only other template action the generator writes: the module labels. - labelsLineRe = regexp.MustCompile(`^\s*\{\{- include "helm_lib_module_labels" \(list \..*\| nindent 2 \}\}\s*$`) + // labelsLineRe is the only other template action the generator writes: the module labels, with + // no labels of its own or a dict of quoted literals (generate.labelsInclude). Anything else on + // that line -- another include, labels from the values -- is not the generator's (review of + // #479, finding 31). + labelsLineRe = regexp.MustCompile(`^ \{\{- include "helm_lib_module_labels" \(list \.(?: \(dict(?: "(?:[^"\\]|\\.)*" "(?:[^"\\]|\\.)*")+\))?\) \| nindent 2 \}\}$`) ) // textDocuments parses the objects of a generated file from its text: the generator writes kind, diff --git a/pkg/linters/rbac/rules/sync_regressions_test.go b/pkg/linters/rbac/rules/sync_regressions_test.go index b6b6333a..d7d76735 100644 --- a/pkg/linters/rbac/rules/sync_regressions_test.go +++ b/pkg/linters/rbac/rules/sync_regressions_test.go @@ -481,3 +481,48 @@ func TestSyncRegression_IncludeInsideTheDocument(t *testing.T) { assert.Equal(t, patched, string(got)) }) } + +// The generator's labels line is recognized only in the shapes the generator writes: an include +// or labels from the values appended to it are someone else's (review of #479, finding 31). +func TestLabelsLineRe(t *testing.T) { + for line, want := range map[string]bool{ + ` {{- include "helm_lib_module_labels" (list .) | nindent 2 }}`: true, + ` {{- include "helm_lib_module_labels" (list . (dict "app" "cainjector")) | nindent 2 }}`: true, + ` {{- include "helm_lib_module_labels" (list . (dict "a" "x \" y" "b" "z")) | nindent 2 }}`: true, + ` {{- include "helm_lib_module_labels" (list .) | nindent 2 }}{{ include "x" . | nindent 2 }}`: false, + ` {{- include "helm_lib_module_labels" (list . .Values.m.labels) | nindent 2 }}`: false, + ` {{- include "helm_lib_module_labels" (list . (dict "app" .Values.m.app)) | nindent 2 }}`: false, + ` {{- include "helm_lib_module_labels" (list . (dict "app" "a")) | nindent 2 }} {{ include "x" . }}`: false, + } { + assert.Equal(t, want, labelsLineRe.MatchString(line), line) + } +} + +// An include appended to the generator's labels line is not dropped by a regeneration. +func TestSyncRegression_IncludeOnTheLabelsLine(t *testing.T) { + resetFixState() + t.Cleanup(resetFixState) + + const rel = "templates/cainjector/rbac-for-us.yaml" + + modulePath := syncModuleDir(t) + model := syncModel(t, modulePath) + writeGenerated(t, modulePath, model) + + fullPath := filepath.Join(modulePath, rel) + content, err := os.ReadFile(fullPath) + require.NoError(t, err) + + patched := strings.Replace(string(content), "| nindent 2 }}\n", "| nindent 2 }}{{ include \"extra-labels\" . | nindent 2 }}\n", 1) + require.NotEqual(t, string(content), patched) + require.NoError(t, os.WriteFile(fullPath, []byte(patched), 0o600)) + + list := runSync(t, modulePath, renderedFrom(t, model, nil)) + for _, fix := range list.GetFixes() { + fix() + } + + got, err := os.ReadFile(fullPath) + require.NoError(t, err) + assert.Equal(t, patched, string(got)) +} From 83f55feff332cddb2e69215b9de8b15436b65104 Mon Sep 17 00:00:00 2001 From: Ivan Zvyagintsev Date: Thu, 24 Sep 2026 14:08:05 +0300 Subject: [PATCH 48/58] rbac: bootstrap leaves library, repeated and empty objects hand-written Review of #479, findings 32 (follow-up) and 39: the scope cut imported everything a template renders. - An object its template renders through an include of a named template (helm_lib_csi_controller_rbac) stays hand-written, as the ADR lists it; a legacy role or a capability is imported whatever renders it, since sync owns those by class. Computed names match their own documents. - An object inside a {{ range }} stays hand-written: the declaration would freeze one of several objects under its literal name (istio's istiod-). The template is read with text/template/parse. - A role without rules stays hand-written, and a binding to it binds a hand-written role instead of producing an entry validation refuses. Signed-off-by: Ivan Zvyagintsev --- pkg/linters/rbac/rules/bootstrap/bootstrap.go | 48 ++++- .../rbac/rules/bootstrap/bootstrap_test.go | 34 ++++ pkg/linters/rbac/rules/sync.go | 169 ++++++++++++++++++ .../rbac/rules/sync_regressions_test.go | 50 ++++++ 4 files changed, 295 insertions(+), 6 deletions(-) diff --git a/pkg/linters/rbac/rules/bootstrap/bootstrap.go b/pkg/linters/rbac/rules/bootstrap/bootstrap.go index 7052f18e..153cdd28 100644 --- a/pkg/linters/rbac/rules/bootstrap/bootstrap.go +++ b/pkg/linters/rbac/rules/bootstrap/bootstrap.go @@ -47,6 +47,12 @@ type Object struct { // Aggregated marks a ClusterRole with an aggregationRule: its rules belong to the aggregation // controller, and the declaration has no place for the selectors. Aggregated bool + // Library marks an object its template renders through an include of a named template + // (helm_lib): the library owns it, unless sync owns it by class. + Library bool + // Repeated marks an object its template renders inside a {{ range }}: one document, several + // objects, the name of this one frozen by a declaration. + Repeated bool } // Input is what the render says about the module. @@ -174,6 +180,7 @@ func Build(in Input) Result { b := &builder{in: in, res: map[[2]string]*resourceAcc{}, restricted: map[[2]string][]string{}, texts: map[string]rbacyaml.CapabilityText{}, lineages: map[string]struct{}{}, used: map[string]struct{}{}, decl: &rbacyaml.Declaration{APIVersion: rbacyaml.APIVersionV1Alpha1}} + b.setAside() b.capabilitiesAndLegacy() b.serviceAccounts() b.otherBindings() @@ -257,9 +264,34 @@ func (b *builder) addRules(sectionName, level string, rules []rbacv1.PolicyRule, } } +// setAside keeps out what the declaration cannot describe before anything is imported: objects a +// library renders -- a legacy role or a capability is sync's whatever renders it (ADR, class 1 and +// 2), so those are imported -- and roles without rules. +func (b *builder) setAside() { + for _, o := range b.in.Objects { + switch { + case o.Library && !b.ownedByClass(o): + b.unmanage(o, "rendered by an include of a named template (helm_lib), which owns it") + b.mark(o) + case o.Repeated && !b.ownedByClass(o): + b.unmanage(o, "rendered inside {{ range }}: one document renders several objects, and the declaration would freeze this one under its name") + b.mark(o) + case (b.ownClusterRole(o) || o.Kind == "Role") && len(o.Rules) == 0: + b.unmanage(o, "has no rules; the declaration writes no role without them") + b.mark(o) + } + } +} + +// ownedByClass reports whether sync owns the object by its class rather than by its name: a +// legacy role (the access-level annotation) or a capability (the kind label). +func (b *builder) ownedByClass(o Object) bool { + return o.Kind == "ClusterRole" && (o.Annotations[rbaccontract.AccessLevelAnnotation] != "" || o.Labels[rbaccontract.LabelKind] == rbaccontract.KindCapability) +} + func (b *builder) capabilitiesAndLegacy() { for _, o := range b.in.Objects { - if o.Kind != "ClusterRole" { + if o.Kind != "ClusterRole" || b.isUsed(o) { continue } @@ -404,6 +436,10 @@ var pathRe = regexp.MustCompile(`^templates/(?:(.*)/)?rbac-for-us\.yaml$`) func (b *builder) serviceAccounts() { for _, sa := range b.byKind("ServiceAccount") { + if b.isUsed(sa) { + continue + } + if b.ns(sa) != b.in.Namespace { b.unmanage(sa, "outside the module namespace") b.mark(sa) @@ -440,7 +476,7 @@ func (b *builder) serviceAccounts() { } cr, found := b.clusterRole(crb.RoleRef.Name) - exclusive := found && b.ownClusterRole(cr) && len(b.bindingsOf(cr.Name)) == 1 + exclusive := found && !b.isUsed(cr) && b.ownClusterRole(cr) && len(b.bindingsOf(cr.Name)) == 1 switch { case exclusive && cr.Name == clusterName && e.ClusterRules == nil: @@ -476,7 +512,7 @@ func (b *builder) serviceAccounts() { continue } - if role, ok := b.role(b.ns(rb), rb.RoleRef.Name); ok && b.ns(rb) == b.in.Namespace && e.NamespaceRules == nil && rb.RoleRef.Kind == "Role" { + if role, ok := b.role(b.ns(rb), rb.RoleRef.Name); ok && !b.isUsed(role) && b.ns(rb) == b.in.Namespace && e.NamespaceRules == nil && rb.RoleRef.Kind == "Role" { e.NamespaceRules = policyRules(role.Rules) b.rename("Role", role.Name, sa.Name) b.rename("RoleBinding", rb.Name, sa.Name) @@ -532,8 +568,8 @@ func (b *builder) otherBindings() { } cr, found := b.clusterRole(crb.RoleRef.Name) - if !found || !b.ownClusterRole(cr) { - b.unmanage(crb, fmt.Sprintf("binds %s, which is not a plain ClusterRole of this module", crb.RoleRef.Name)) + if !found || !b.ownClusterRole(cr) || b.isUsed(cr) { + b.unmanage(crb, fmt.Sprintf("binds %s, which is not a plain ClusterRole of this module the declaration describes", crb.RoleRef.Name)) continue } @@ -551,7 +587,7 @@ func (b *builder) otherBindings() { } role, ok := b.role(b.ns(rb), rb.RoleRef.Name) - if !ok || b.ns(rb) != b.in.Namespace || rb.RoleRef.Kind != "Role" { + if !ok || b.isUsed(role) || b.ns(rb) != b.in.Namespace || rb.RoleRef.Kind != "Role" { b.unmanage(rb, fmt.Sprintf("binds %s/%s outside the module's own Roles", rb.RoleRef.Kind, rb.RoleRef.Name)) continue } diff --git a/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go b/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go index b7c36184..6a32446b 100644 --- a/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go +++ b/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go @@ -440,3 +440,37 @@ func TestBuild_NotesAreWrittenOnce(t *testing.T) { assert.Equal(t, 1, strings.Count(notes, "whether the template gated the scraper binding")) assert.Equal(t, 1, strings.Count(notes, "several Prometheus access Roles fold")) } + +// What a library renders stays hand-written, apart from the legacy roles and capabilities sync +// owns by class; a role without rules stays hand-written and its binding binds a hand-written +// role (review of #479, findings 32 and 39). +func TestBuild_LibraryAndEmptyRolesAreSetAside(t *testing.T) { + labels := map[string]string{"module": "m"} + sa := []rbacv1.Subject{{Kind: "ServiceAccount", Name: "m", Namespace: "d8-m"}} + + got := Build(Input{Module: "m", Namespace: "d8-m", Objects: []Object{ + {Kind: "ServiceAccount", Name: "csi", Path: "templates/csi/rbac-for-us.yaml", Labels: labels, Library: true}, + {Kind: "ClusterRole", Name: "d8:m:csi:controller", Path: "templates/csi/rbac-for-us.yaml", Labels: labels, Library: true, + Rules: []rbacv1.PolicyRule{{APIGroups: []string{""}, Resources: []string{"nodes"}, Verbs: []string{"get"}}}}, + {Kind: "ClusterRole", Name: "d8:m:user", Path: "templates/user-authz-cluster-roles.yaml", Labels: labels, Library: true, + Annotations: map[string]string{rbaccontract.AccessLevelAnnotation: "User"}, + Rules: []rbacv1.PolicyRule{{APIGroups: []string{"x.io"}, Resources: []string{"things"}, Verbs: []string{"get"}}}}, + {Kind: "ServiceAccount", Name: "m", Path: "templates/rbac-for-us.yaml", Labels: labels}, + {Kind: "ClusterRole", Name: "d8:m:m:iop:istiod-1x25", Path: "templates/rbac-for-us.yaml", Labels: labels}, + {Kind: "ClusterRoleBinding", Name: "d8:m:m:iop:istiod-1x25", Path: "templates/rbac-for-us.yaml", Labels: labels, + RoleRef: rbacv1.RoleRef{Kind: "ClusterRole", Name: "d8:m:m:iop:istiod-1x25"}, Subjects: sa}, + }, CRDs: map[string]string{"x.io/things": "Namespaced"}}) + + unmanaged := strings.Join(got.Unmanaged, "\n") + assert.Contains(t, unmanaged, "ServiceAccount/csi (templates/csi/rbac-for-us.yaml): rendered by an include of a named template") + assert.Contains(t, unmanaged, "ClusterRole/d8:m:csi:controller (templates/csi/rbac-for-us.yaml): rendered by an include") + assert.Contains(t, unmanaged, "ClusterRole/d8:m:m:iop:istiod-1x25 (templates/rbac-for-us.yaml): has no rules") + + require.Len(t, got.Decl.Resources, 1, "the legacy role a library renders is imported") + assert.Contains(t, got.Decl.Resources[0].Legacy, "User") + + require.Len(t, got.Decl.ServiceAccounts, 1) + assert.Empty(t, got.Decl.ServiceAccounts[0].ExtraClusterRoles, "no entry without rules") + assert.Equal(t, []string{"d8:m:m:iop:istiod-1x25"}, got.Decl.ServiceAccounts[0].BindClusterRoles) + assert.Empty(t, rbacyaml.Validate(got.Decl, rbacyaml.CRDScopes{"x.io/things": "Namespaced"})) +} diff --git a/pkg/linters/rbac/rules/sync.go b/pkg/linters/rbac/rules/sync.go index 1d714c87..574c75f6 100644 --- a/pkg/linters/rbac/rules/sync.go +++ b/pkg/linters/rbac/rules/sync.go @@ -30,6 +30,7 @@ import ( "sort" "strconv" "strings" + "text/template/parse" corev1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" @@ -1270,8 +1271,21 @@ func (r *SyncRule) bootstrap(declList *errors.LintRuleErrorsList) { in.CRDs[crd.Key()] = crd.Scope } + texts := map[string]string{} + for _, object := range storage { if o, ok := bootstrapObject(object); ok { + text, read := texts[o.Path] + if !read { + if content, err := os.ReadFile(filepath.Join(modulePath, o.Path)); err == nil { + text = string(content) + } + + texts[o.Path] = text + } + + o.Library = renderedByInclude(text, o.Kind, o.Name) + o.Repeated = renderedInRange(text, o.Kind, o.Name) in.Objects = append(in.Objects, o) } } @@ -1874,3 +1888,158 @@ func writtenProblems(content []byte, crds []crdInfo, in bootstrap.Input) string return "; the linter refuses: " + strings.Join(problems, "; ") } + +var ( + // includeRe finds an include of a named template (or the template action) in a document. + includeRe = regexp.MustCompile(`\{\{-?\s*(include|template)\s+"`) + // rawNameLineRe is a metadata name as written, computed or not. + rawNameLineRe = regexp.MustCompile(`^ name:\s*(.+?)\s*$`) + actionRe = regexp.MustCompile(`\{\{.*?\}\}`) +) + +// renderedByInclude reports whether the template renders the object through an include of a +// named template -- helm_lib_csi_controller_rbac, typically -- rather than through a document of +// its own: no document of the object's kind names it (literally or with a computed name), and a +// document without a kind of its own includes a template. +func renderedByInclude(content, kind, name string) bool { + include := false + + for _, doc := range separatorRe.Split(strings.ReplaceAll(content, "\r\n", "\n"), -1) { + var docKind, docName string + + for _, line := range strings.Split(doc, "\n") { + if m := kindLineRe.FindStringSubmatch(line); m != nil && docKind == "" { + docKind = m[1] + } + + if m := rawNameLineRe.FindStringSubmatch(line); m != nil && docName == "" { + docName = m[1] + if i := strings.Index(docName, " #"); i >= 0 && !strings.Contains(docName, "{{") { + docName = strings.TrimSpace(docName[:i]) + } + + docName = strings.Trim(docName, `"'`) + } + } + + switch { + case docKind == "": + include = include || includeRe.MatchString(doc) + case docKind == kind && namesMatch(docName, name): + return false + } + } + + return include +} + +// namesMatch reports whether a metadata name as the template writes it can be the rendered name: +// equal, or with every action of a computed name standing for any text. +func namesMatch(written, rendered string) bool { + if !strings.Contains(written, "{{") { + return written == rendered + } + + var b strings.Builder + + b.WriteString("^") + + last := 0 + for _, m := range actionRe.FindAllStringIndex(written, -1) { + b.WriteString(regexp.QuoteMeta(written[last:m[0]])) + b.WriteString(".*") + + last = m[1] + } + + b.WriteString(regexp.QuoteMeta(written[last:]) + "$") + + re, err := regexp.Compile(b.String()) + + return err == nil && re.MatchString(rendered) +} + +// renderedInRange reports whether the object's document sits inside a {{ range }} of its +// template: one document renders several objects, and the declaration would freeze the one this +// render produced under its literal name (istio's istiod-). The template is read with +// text/template/parse, not with patterns; a template that does not parse tells nothing. +func renderedInRange(content, kind, name string) bool { + content = strings.ReplaceAll(content, "\r\n", "\n") + + offset := documentOffset(content, kind, name) + if offset < 0 { + return false + } + + tree := parse.New("template") + tree.Mode = parse.SkipFuncCheck + + if _, err := tree.Parse(content, "", "", map[string]*parse.Tree{}); err != nil || tree.Root == nil { + return false + } + + return inRange(tree.Root, offset, false) +} + +// documentOffset is the offset of the metadata name line of the object's document, or -1. +func documentOffset(content, kind, name string) int { + start := 0 + + for _, bounds := range append(separatorRe.FindAllStringIndex(content, -1), []int{len(content), len(content)}) { + doc := content[start:bounds[0]] + docStart := start + start = bounds[1] + + var docKind string + + offset, lineStart := -1, docStart + + for _, line := range strings.SplitAfter(doc, "\n") { + trimmed := strings.TrimRight(line, "\n") + + if m := kindLineRe.FindStringSubmatch(trimmed); m != nil && docKind == "" { + docKind = m[1] + } + + if m := rawNameLineRe.FindStringSubmatch(trimmed); m != nil && offset < 0 && namesMatch(strings.Trim(m[1], `"'`), name) { + offset = lineStart + } + + lineStart += len(line) + } + + if docKind == kind && offset >= 0 { + return offset + } + } + + return -1 +} + +// inRange reports whether the text at offset lies in the body of a range. +func inRange(node parse.Node, offset int, ranged bool) bool { + switch n := node.(type) { + case *parse.ListNode: + if n == nil { + return false + } + + for _, c := range n.Nodes { + if inRange(c, offset, ranged) { + return true + } + } + case *parse.TextNode: + start := int(n.Position()) + + return ranged && offset >= start && offset < start+len(n.Text) + case *parse.IfNode: + return inRange(n.List, offset, ranged) || inRange(n.ElseList, offset, ranged) + case *parse.WithNode: + return inRange(n.List, offset, ranged) || inRange(n.ElseList, offset, ranged) + case *parse.RangeNode: + return inRange(n.List, offset, true) || inRange(n.ElseList, offset, ranged) + } + + return false +} diff --git a/pkg/linters/rbac/rules/sync_regressions_test.go b/pkg/linters/rbac/rules/sync_regressions_test.go index d7d76735..65f1503b 100644 --- a/pkg/linters/rbac/rules/sync_regressions_test.go +++ b/pkg/linters/rbac/rules/sync_regressions_test.go @@ -526,3 +526,53 @@ func TestSyncRegression_IncludeOnTheLabelsLine(t *testing.T) { require.NoError(t, err) assert.Equal(t, patched, string(got)) } + +// An object the template renders through an include of a named template is the library's; one +// with a document of its own, literal or with a computed name, is the module's (review of #479, +// finding 32). +func TestRenderedByInclude(t *testing.T) { + text := `{{- include "helm_lib_csi_controller_rbac" . }} +# ========== +--- +kind: ClusterRole +metadata: + name: d8:csi-vsphere:csi +--- +kind: ServiceAccount +metadata: + name: {{ .Chart.Name }}-extra +` + assert.True(t, renderedByInclude(text, "ServiceAccount", "csi"), "no document of its own: the include renders it") + assert.True(t, renderedByInclude(text, "Role", "csi:controller:external-provisioner")) + assert.False(t, renderedByInclude(text, "ClusterRole", "d8:csi-vsphere:csi"), "a literal document of its own") + assert.False(t, renderedByInclude(text, "ServiceAccount", "csi-vsphere-extra"), "a document of its kind with a computed name") + assert.False(t, renderedByInclude("---\nkind: Role\nmetadata:\n name: r\n", "Role", "other"), "no include: not the library's") +} + +// An object a {{ range }} renders is found by the stdlib template parser; one beside the range is +// not (review of #479, finding 39). +func TestRenderedInRange(t *testing.T) { + text := `{{- range $version := .Values.istio.internal.versions }} +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: istiod-{{ $version | replace "." "x" }} +{{- end }} +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: operator +{{- if .Values.x }} +--- +kind: Role +metadata: + name: conditional +{{- end }} +` + assert.True(t, renderedInRange(text, "ServiceAccount", "istiod-1x25")) + assert.False(t, renderedInRange(text, "ServiceAccount", "operator")) + assert.False(t, renderedInRange(text, "Role", "conditional"), "an if is no range") + assert.False(t, renderedInRange("{{ if }", "Role", "x"), "a template that does not parse tells nothing") +} From 27c41affa14943b6bb1418a73aa183beeaaf102f Mon Sep 17 00:00:00 2001 From: Ivan Zvyagintsev Date: Thu, 24 Sep 2026 14:10:25 +0300 Subject: [PATCH 49/58] rbac: refuse the names the placement rule would reject Review of #479, finding 37 (after nested naming moved to #480): - access[] with a path and namespaceRules is refused: the generator would name the Role access-to-- in templates//rbac-to-us.yaml, where the placement rule wants access-to--; bootstrap keeps such a Role hand-written instead of producing access-to--access-to-...; - an account named - is accepted only in a namespace of the platform, and there without namespaceRules or bindRoles, whose objects the placement rule would name :; - a test runs the placement rule over what the generator writes for the shapes this version supports. The platform namespaces now live in rbaccontract, shared with the placement rule. Signed-off-by: Ivan Zvyagintsev --- pkg/linters/rbac/README.md | 5 ++- pkg/linters/rbac/rules/bootstrap/bootstrap.go | 14 ++++++- .../rbac/rules/bootstrap/bootstrap_test.go | 18 ++++++++ .../rbac/rules/generate/generate_test.go | 32 ++++++++++++++- pkg/linters/rbac/rules/generate/model.go | 22 +++++++++- pkg/linters/rbac/rules/placement.go | 5 +-- .../rbac/rules/rbaccontract/contract.go | 15 +++++++ .../rbac/rules/sync_regressions2_test.go | 41 +++++++++++++++++++ 8 files changed, 142 insertions(+), 10 deletions(-) diff --git a/pkg/linters/rbac/README.md b/pkg/linters/rbac/README.md index 2a1c66d7..a9820c4d 100644 --- a/pkg/linters/rbac/README.md +++ b/pkg/linters/rbac/README.md @@ -1446,7 +1446,8 @@ prometheusAccess: deployments: [cert-manager] when: .Values.global.enabledModules | has "prometheus" -# Arbitrary subjects: clusterRules -> templates/[/]rbac-for-us.yaml, namespaceRules -> templates/[/]rbac-to-us.yaml +# Arbitrary subjects: clusterRules -> templates/[/]rbac-for-us.yaml, namespaceRules -> templates/rbac-to-us.yaml +# (namespaceRules with a path are refused: the placement rule wants access-to-- names there) access: - name: admin-kubeconfig subjects: @@ -1662,7 +1663,7 @@ no longer names leaves the template; the finding that led there listed it, and t removed (and, apart from that, what it added), so a `--fix` run without a preceding `dmt lint` does not remove rights in silence. Three things are never written over -- -- a file that also holds objects of someone else -- a controller ClusterRole beside a declared ServiceAccount, a hand-written binding, a ConfigMap or a Secret -- is never rewritten, because the generator writes the whole file and they would vanish (and so they would if the file were deleted); the refusal names them: declare them (`extraClusterRoles`, `access` with `path`) or move them first. A generated file lists under its header, one `# dmt:owns ` line each, what the generator wrote into it (contract 2); an owned object the declaration no longer produces -- a legacy level dropped, a ServiceAccount removed -- is a removal, named in the finding and in the log, and everything else in the file is someone else's. The fix does not move objects between files: an object the declaration now puts in another file is refused in the file it renders from ("move it by hand, or delete this file"), and the target is not written while the object still renders elsewhere, so nothing is lost or rendered twice. An object the generator writes under a new name is a different object: until the old copy is deleted from its template both render, and the finding on the old copy says so when a binding grants it to the subjects the declaration grants its successor to. Besides the render, the fix reads the objects of a generated file from its text, so an object under a condition that is false for these values -- a hand-added ConfigMap, an account moved elsewhere -- is judged too. In a file without the list (contract 1, or hand-written), a legacy role or a module capability the declaration does not produce is a removal too; any other object is refused, whatever its name. To drop an account or an access entry from a contract 1 file, run `--fix` once with the declaration unchanged -- that writes contract 2 -- and drop it then. An object the generator produces under another name -- a binding with the same roleRef and subjects, a role with the same rules, while the new name is not rendered yet -- is replaced, not foreign; +- a file that also holds objects of someone else -- a controller ClusterRole beside a declared ServiceAccount, a hand-written binding, a ConfigMap or a Secret -- is never rewritten, because the generator writes the whole file and they would vanish (and so they would if the file were deleted); the refusal names them: declare them (`extraClusterRoles`, `access` with `path` and `clusterRules`) or move them first. A generated file lists under its header, one `# dmt:owns ` line each, what the generator wrote into it (contract 2); an owned object the declaration no longer produces -- a legacy level dropped, a ServiceAccount removed -- is a removal, named in the finding and in the log, and everything else in the file is someone else's. The fix does not move objects between files: an object the declaration now puts in another file is refused in the file it renders from ("move it by hand, or delete this file"), and the target is not written while the object still renders elsewhere, so nothing is lost or rendered twice. An object the generator writes under a new name is a different object: until the old copy is deleted from its template both render, and the finding on the old copy says so when a binding grants it to the subjects the declaration grants its successor to. Besides the render, the fix reads the objects of a generated file from its text, so an object under a condition that is false for these values -- a hand-added ConfigMap, an account moved elsewhere -- is judged too. In a file without the list (contract 1, or hand-written), a legacy role or a module capability the declaration does not produce is a removal too; any other object is refused, whatever its name. To drop an account or an access entry from a contract 1 file, run `--fix` once with the declaration unchanged -- that writes contract 2 -- and drop it then. An object the generator produces under another name -- a binding with the same roleRef and subjects, a role with the same rules, while the new name is not rendered yet -- is replaced, not foreign; - a file without the generator header is maintained by hand: the generated text is written beside it as `_.generated` (the underscore keeps Helm from rendering the copy) and the finding stays (delete the file and run `--fix` again to hand it back to the generator); - a template that serves both role models behind the version gate (`rbacv2_new_scheme`) is never regenerated: the legacy branch would vanish; diff --git a/pkg/linters/rbac/rules/bootstrap/bootstrap.go b/pkg/linters/rbac/rules/bootstrap/bootstrap.go index 153cdd28..3c08f6e7 100644 --- a/pkg/linters/rbac/rules/bootstrap/bootstrap.go +++ b/pkg/linters/rbac/rules/bootstrap/bootstrap.go @@ -596,8 +596,20 @@ func (b *builder) otherBindings() { continue } + // The generator writes namespace access at the module root only: an entry for a Role of + // templates//rbac-to-us.yaml would be refused (review of #479, finding 37). + path := componentOf(role.Path, "rbac-to-us.yaml") + if path != "" { + b.unmanage(role, "a namespace access Role in templates/"+path+"/rbac-to-us.yaml; access entries with namespaceRules are generated at the module root only") + b.unmanage(rb, "binds "+role.Name+", which stays hand-written") + b.mark(role) + b.mark(rb) + + continue + } + name := strings.TrimPrefix(rb.Name, "access-to-"+b.in.Module+"-") - b.decl.Access = append(b.decl.Access, rbacyaml.Access{Name: name, Path: componentOf(role.Path, "rbac-to-us.yaml"), Subjects: subjects(rb.Subjects), NamespaceRules: policyRules(role.Rules)}) + b.decl.Access = append(b.decl.Access, rbacyaml.Access{Name: name, Subjects: subjects(rb.Subjects), NamespaceRules: policyRules(role.Rules)}) b.rename("Role", role.Name, "access-to-"+b.in.Module+"-"+name) b.rename("RoleBinding", rb.Name, "access-to-"+b.in.Module+"-"+name) b.mark(role) diff --git a/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go b/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go index 6a32446b..914770e9 100644 --- a/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go +++ b/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go @@ -474,3 +474,21 @@ func TestBuild_LibraryAndEmptyRolesAreSetAside(t *testing.T) { assert.Equal(t, []string{"d8:m:m:iop:istiod-1x25"}, got.Decl.ServiceAccounts[0].BindClusterRoles) assert.Empty(t, rbacyaml.Validate(got.Decl, rbacyaml.CRDScopes{"x.io/things": "Namespaced"})) } + +// A namespace access Role of a component directory stays hand-written rather than becoming an +// entry the generator refuses or names access-to--access-to--x (review of #479, +// finding 37). +func TestBuild_NestedNamespaceAccessStaysHandWritten(t *testing.T) { + labels := map[string]string{"module": "istio"} + + got := Build(Input{Module: "istio", Namespace: "d8-istio", Objects: []Object{ + {Kind: "Role", Name: "access-to-kiali-http", Namespace: "d8-istio", Path: "templates/kiali/rbac-to-us.yaml", Labels: labels, + Rules: []rbacv1.PolicyRule{{APIGroups: []string{""}, Resources: []string{"services/proxy"}, Verbs: []string{"get"}}}}, + {Kind: "RoleBinding", Name: "access-to-kiali-http", Namespace: "d8-istio", Path: "templates/kiali/rbac-to-us.yaml", Labels: labels, + RoleRef: rbacv1.RoleRef{Kind: "Role", Name: "access-to-kiali-http"}, Subjects: []rbacv1.Subject{{Kind: "Group", Name: "g"}}}, + }}) + + assert.Empty(t, got.Decl.Access) + assert.Len(t, got.Unmanaged, 2) + assert.NotContains(t, strings.Join(got.Notes, "\n"), "access-to-istio-access-to") +} diff --git a/pkg/linters/rbac/rules/generate/generate_test.go b/pkg/linters/rbac/rules/generate/generate_test.go index 2ef25ec6..1aacae71 100644 --- a/pkg/linters/rbac/rules/generate/generate_test.go +++ b/pkg/linters/rbac/rules/generate/generate_test.go @@ -275,18 +275,46 @@ func TestBuild_RefusesWhatTheModuleCannotCarry(t *testing.T) { decl.ServiceAccounts = []rbacyaml.ServiceAccount{{Name: "helper", Path: "cainjector"}} _, err := Build(Input{Module: "m", Namespace: "d8-m", Subsystems: []string{"security"}, Decl: decl}) require.Error(t, err) - assert.Contains(t, err.Error(), `the placement rule wants the account named "cainjector" or "m-cainjector"`) + assert.Contains(t, err.Error(), `the placement rule wants the account named "cainjector" after its directory`) - decl.ServiceAccounts = []rbacyaml.ServiceAccount{{Name: "m-cainjector", Path: "cainjector"}, {Name: "webhook", Path: "webhook"}, {Name: "anything", Path: ""}} + decl.ServiceAccounts = []rbacyaml.ServiceAccount{{Name: "cainjector", Path: "cainjector"}, {Name: "webhook", Path: "webhook"}, {Name: "anything", Path: ""}} _, err = Build(Input{Module: "m", Namespace: "d8-m", Subsystems: []string{"security"}, Decl: decl}) require.NoError(t, err) + // The module name in front: a namespace of the platform only, and without the Role and + // RoleBindings the placement rule would name : (review of #479, finding 37). + decl.ServiceAccounts = []rbacyaml.ServiceAccount{{Name: "m-cainjector", Path: "cainjector"}} + _, err = Build(Input{Module: "m", Namespace: "d8-m", Subsystems: []string{"security"}, Decl: decl}) + require.Error(t, err) + assert.Contains(t, err.Error(), "only in a namespace of the platform") + + _, err = Build(Input{Module: "m", Namespace: "d8-system", Subsystems: []string{"security"}, Decl: decl}) + require.NoError(t, err) + + decl.ServiceAccounts[0].NamespaceRules = []rbacyaml.PolicyRule{{APIGroups: []string{""}, Resources: []string{"secrets"}, Verbs: []string{"get"}}} + _, err = Build(Input{Module: "m", Namespace: "d8-system", Subsystems: []string{"security"}, Decl: decl}) + require.Error(t, err) + assert.Contains(t, err.Error(), "named m:cainjector, which this version does not generate") + decl.ServiceAccounts = []rbacyaml.ServiceAccount{{Name: "dir", Path: "some/nested/dir"}} _, err = Build(Input{Module: "m", Namespace: "d8-m", Subsystems: []string{"security"}, Decl: decl}) require.Error(t, err) assert.Contains(t, err.Error(), "one directory under templates/ only") }) + t.Run("namespace access in a directory", func(t *testing.T) { + decl := base() + decl.Access = []rbacyaml.Access{{Name: "reader", Path: "cainjector", Subjects: []rbacyaml.Subject{{Kind: "Group", Name: "g"}}, + NamespaceRules: []rbacyaml.PolicyRule{{APIGroups: []string{""}, Resources: []string{"secrets"}, Verbs: []string{"get"}}}}} + _, err := Build(Input{Module: "m", Namespace: "d8-m", Subsystems: []string{"security"}, Decl: decl}) + require.Error(t, err) + assert.Contains(t, err.Error(), "the placement rule wants access-to-cainjector- in templates/cainjector/rbac-to-us.yaml") + + decl.Access[0].ClusterRules, decl.Access[0].NamespaceRules = decl.Access[0].NamespaceRules, nil + _, err = Build(Input{Module: "m", Namespace: "d8-m", Subsystems: []string{"security"}, Decl: decl}) + require.NoError(t, err, "clusterRules in a directory follow the placement rule") + }) + t.Run("a capability marker longer than a label value", func(t *testing.T) { // The levels are a fixed set, so only the module name can push the marker // namespace-capability..superadmin past 63 characters: at 32 characters it does. diff --git a/pkg/linters/rbac/rules/generate/model.go b/pkg/linters/rbac/rules/generate/model.go index 30452244..a4e185e0 100644 --- a/pkg/linters/rbac/rules/generate/model.go +++ b/pkg/linters/rbac/rules/generate/model.go @@ -253,8 +253,26 @@ func checkAgainstModule(in Input) error { return fmt.Errorf("serviceAccounts[%s].path %q: one directory under templates/ only; the placement rule names the objects of a nested directory in a way the generator cannot follow", sa.Name, sa.Path) } - if sa.Name != sa.Path && sa.Name != in.Module+"-"+sa.Path { - return fmt.Errorf("serviceAccounts[%s].path %q: the placement rule wants the account named %q or %q after its directory; rename the account or move it to the module root", sa.Name, sa.Path, sa.Path, in.Module+"-"+sa.Path) + // The placement rule allows the module name in front of the directory only in a namespace of + // the platform, and then wants its Role and foreign RoleBindings named :, which + // the generator does not write (review of #479, finding 37). + switch { + case sa.Name == sa.Path: + case sa.Name == in.Module+"-"+sa.Path && !rbaccontract.IsDeckhouseNamespace(in.Namespace): + return fmt.Errorf("serviceAccounts[%s].path %q: the placement rule allows the module name in front of the directory only in a namespace of the platform (d8-system, d8-monitoring, ...); in %s name the account %q", sa.Name, sa.Path, in.Namespace, sa.Path) + case sa.Name == in.Module+"-"+sa.Path && (len(sa.NamespaceRules) > 0 || len(sa.BindRoles) > 0): + return fmt.Errorf("serviceAccounts[%s].path %q: the placement rule wants the Role and RoleBindings of this account named %s:%s, which this version does not generate; name the account %q, or keep its namespaceRules and bindRoles by hand", sa.Name, sa.Path, in.Module, sa.Path, sa.Path) + case sa.Name == in.Module+"-"+sa.Path: + default: + return fmt.Errorf("serviceAccounts[%s].path %q: the placement rule wants the account named %q after its directory; rename the account or move it to the module root", sa.Name, sa.Path, sa.Path) + } + } + + // templates//rbac-to-us.yaml wants access-to-- names; the generator writes + // access-to--, which the placement rule refuses there. + for _, a := range in.Decl.Access { + if a.Path != "" && len(a.NamespaceRules) > 0 { + return fmt.Errorf("access[%s].path %q: namespaceRules in a directory would be named access-to-%s-%s, and the placement rule wants access-to-%s- in templates/%s/rbac-to-us.yaml; this version generates namespaceRules at the module root only -- drop path, or keep the entry by hand", a.Name, a.Path, in.Module, a.Name, strings.ReplaceAll(a.Path, "/", "-"), a.Path) } } diff --git a/pkg/linters/rbac/rules/placement.go b/pkg/linters/rbac/rules/placement.go index fa2a928d..c1253e75 100644 --- a/pkg/linters/rbac/rules/placement.go +++ b/pkg/linters/rbac/rules/placement.go @@ -20,7 +20,6 @@ import ( "context" "fmt" "os" - "slices" "strings" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -28,6 +27,7 @@ import ( "github.com/deckhouse/dmt/internal/storage" "github.com/deckhouse/dmt/pkg" "github.com/deckhouse/dmt/pkg/errors" + "github.com/deckhouse/dmt/pkg/linters/rbac/rules/rbaccontract" ) const ( @@ -67,14 +67,13 @@ const ( ) // TODO: remove entries after 'd8-system' after fixing RBAC objects names -var deckhouseNamespaces = []string{"d8-monitoring", "d8-system", "d8-admission-policy-engine", "d8-operator-trivy", "d8-log-shipper", "d8-local-path-provisioner"} func isSystemNamespace(actual string) bool { return actual == metav1.NamespaceDefault || actual == metav1.NamespaceSystem } func isDeckhouseSystemNamespace(actual string) bool { - return slices.Contains(deckhouseNamespaces, actual) + return rbaccontract.IsDeckhouseNamespace(actual) } func (r *PlacementRule) Check(_ context.Context) { diff --git a/pkg/linters/rbac/rules/rbaccontract/contract.go b/pkg/linters/rbac/rules/rbaccontract/contract.go index efcb8363..8d65c0c4 100644 --- a/pkg/linters/rbac/rules/rbaccontract/contract.go +++ b/pkg/linters/rbac/rules/rbaccontract/contract.go @@ -266,3 +266,18 @@ var I18nAnnotations = []string{AnnotationTitleEN, AnnotationTitleRU, AnnotationD func IsLegacyKind(kind string) bool { return kind == KindLegacyUse || kind == KindLegacyManage } + +// DeckhouseNamespaces are the namespaces the placement rule treats as the platform's own: there an +// account of templates// may carry the module name in front of the directory. +var DeckhouseNamespaces = []string{"d8-monitoring", "d8-system", "d8-admission-policy-engine", "d8-operator-trivy", "d8-log-shipper", "d8-local-path-provisioner"} + +// IsDeckhouseNamespace reports whether the namespace is one of DeckhouseNamespaces. +func IsDeckhouseNamespace(ns string) bool { + for _, n := range DeckhouseNamespaces { + if n == ns { + return true + } + } + + return false +} diff --git a/pkg/linters/rbac/rules/sync_regressions2_test.go b/pkg/linters/rbac/rules/sync_regressions2_test.go index b0f4d5c2..77fe1973 100644 --- a/pkg/linters/rbac/rules/sync_regressions2_test.go +++ b/pkg/linters/rbac/rules/sync_regressions2_test.go @@ -17,13 +17,17 @@ limitations under the License. package rules import ( + "context" "os" "strings" "testing" + "github.com/gojuno/minimock/v3" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/deckhouse/dmt/internal/mocks" + "github.com/deckhouse/dmt/pkg/errors" "github.com/deckhouse/dmt/pkg/linters/rbac/rules/bootstrap" "github.com/deckhouse/dmt/pkg/linters/rbac/rules/generate" "github.com/deckhouse/dmt/pkg/linters/rbac/rules/rbacyaml" @@ -136,3 +140,40 @@ func syncModelFromFixture(t *testing.T) *generate.Model { return syncModel(t, syncModuleDir(t)) } + +// What the generator writes for the shapes this version supports passes the placement rule: an +// account in a component directory, access with clusterRules in one, namespace access at the root +// (review of #479, finding 37). +func TestSyncRegression_GeneratedNamesPassPlacement(t *testing.T) { + get := []rbacyaml.PolicyRule{{APIGroups: []string{""}, Resources: []string{"secrets"}, Verbs: []string{"get"}}} + nodes := []rbacyaml.PolicyRule{{APIGroups: []string{""}, Resources: []string{"nodes"}, Verbs: []string{"get"}}} + group := []rbacyaml.Subject{{Kind: "Group", Name: "g"}} + + decl := &rbacyaml.Declaration{APIVersion: rbacyaml.APIVersionV1Alpha1, + ServiceAccounts: []rbacyaml.ServiceAccount{ + {Name: "webhook", Path: "webhook", ClusterRules: nodes, NamespaceRules: get, BindClusterRoles: []string{"d8:rbac-proxy"}, + BindRoles: []rbacyaml.RoleRef{{Namespace: "kube-system", Name: "extension-apiserver-authentication-reader"}}}, + {Name: syncModule, ClusterRules: nodes, NamespaceRules: get}, + }, + Access: []rbacyaml.Access{ + {Name: "reader", Subjects: group, NamespaceRules: get}, + {Name: "nodes", Path: "webhook", Subjects: group, ClusterRules: nodes}, + }, + } + require.Empty(t, rbacyaml.Validate(decl, nil)) + + model, err := generate.Build(generate.Input{Module: syncModule, Namespace: "d8-cert-manager", Subsystems: []string{"security"}, Decl: decl}) + require.NoError(t, err) + + store := renderedFrom(t, model, nil) + + m := mocks.NewModuleMock(minimock.NewController(t)) + m.GetNameMock.Return(syncModule) + m.GetNamespaceMock.Optional().Return("d8-cert-manager") + m.GetStorageMock.Return(store.Storage) + + errorList := errors.NewLintRuleErrorsList() + NewPlacementRule(nil, m, errorList).Check(context.Background()) + + assert.Empty(t, texts(errorList)) +} From 1520624b4117755670bc6bd9bd277840c4f19554 Mon Sep 17 00:00:00 2001 From: Ivan Zvyagintsev Date: Thu, 24 Sep 2026 14:10:37 +0300 Subject: [PATCH 50/58] rbac: name the kube-system account naming gap as a known limitation Review of #479, finding 38: for an account of templates// in default or kube-system the placement rule wants d8--, which the generator refuses. It is older than this round; the README says so and names the modules it keeps out. Signed-off-by: Ivan Zvyagintsev --- pkg/linters/rbac/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/linters/rbac/README.md b/pkg/linters/rbac/README.md index a9820c4d..9ede24ae 100644 --- a/pkg/linters/rbac/README.md +++ b/pkg/linters/rbac/README.md @@ -1707,6 +1707,7 @@ configuration error. **Limits worth knowing:** - A conditional rule is checked only where it renders: with the default values, `dmt lint --values-file` or `dmt lint --matrix`. +- An account of a component directory in `default` or `kube-system` cannot be declared yet: the placement rule wants it named `d8--` there, and the generator accepts `` and, in a namespace of the platform, `-` only. Bootstrap names the problem in the written file; the account stays hand-written until the two rules agree (control-plane-manager, vertical-pod-autoscaler). - **The three states a module can be in when the new `dmt` first runs.** *Only the legacy scheme* (an external module not yet migrated): `contract` reports one "migrate" finding per object; with an `rbac.yaml`, `sync` reports the generated files as absent and names the cause -- the template renders the legacy scheme -- and `--fix` leaves the legacy file alone (no generator header) with the generated version beside it. *Only the 1.78 scheme*: the ordinary case described above. *Both schemes behind the version gate* (`rbacv2-migrate-module.sh` without `--replace`): the linter's values answer the gate with the 1.78 model, so `contract` and `sync` see exactly the new objects and the legacy branch is neither judged nor "extra"; `--fix` never rewrites a gated file -- regenerating it would drop the legacy branch -- and says so. The legacy branch itself is exercised with `dmt lint --values-file` setting `global.deckhouseVersion` below 1.78; `--matrix` varies module values only, not the platform version. - The declaration is one per module and describes the union of editions. Linting a single edition directory shows the edition-only objects as absent; lint the merged tree as CI does. An `rbac.yaml` inside an edition overlay (`ee/be/modules`, `ee/se-plus/modules`, ..., and `ee/modules` for a module that also exists in `modules/`) is an error: CI merges the overlays over `modules/` before linting, so a copy there would shadow the base one or go unseen. A module that has no base elsewhere -- EE-only in `ee/modules/`, or living in one edition directory only such as `ee/be/modules/350-node-local-dns` -- has its base there. - The generator refuses what the declaration alone cannot know is wrong: system levels on a module whose `module.yaml` names no `subsystems` (set `subsystems` in `rbac.yaml`); an account in a component directory named unlike it (the placement rule wants `` or `-`) or in a nested directory; a capability marker past 63 characters (a module name of 32 characters and up with a `superadmin` level). From 3ab9bc1a3a950b71df53d927be2896f25c84b3cc Mon Sep 17 00:00:00 2001 From: Ivan Zvyagintsev Date: Thu, 24 Sep 2026 14:12:32 +0300 Subject: [PATCH 51/58] rbac: note per object what a regeneration drops that the format cannot hold Review of #479, finding 40: after the scope cut bootstrap dropped conditions, annotations and labels with only the generic header as a warning. Without bringing the features back, the written file now names per object: - the labels and annotations the format has no field for (werf.io hooks, helm.sh/resource-policy, gatekeeper.sh/system, k8s-app ...); heritage, module, an account's app label, Helm's meta.helm.sh keys and the generator's own are not listed; - under --matrix, every object some render variants did not render: its condition is not in the declaration. Signed-off-by: Ivan Zvyagintsev --- pkg/linters/rbac/README.md | 7 +- pkg/linters/rbac/rules/bootstrap/bootstrap.go | 75 +++++++++++++++++-- .../rbac/rules/bootstrap/bootstrap_test.go | 30 ++++++++ pkg/linters/rbac/rules/fixstate.go | 36 ++++++++- pkg/linters/rbac/rules/sync.go | 1 + .../rbac/rules/sync_regressions2_test.go | 26 +++++++ 6 files changed, 165 insertions(+), 10 deletions(-) diff --git a/pkg/linters/rbac/README.md b/pkg/linters/rbac/README.md index 9ede24ae..1c760bb1 100644 --- a/pkg/linters/rbac/README.md +++ b/pkg/linters/rbac/README.md @@ -1621,7 +1621,12 @@ every object the generator will name differently or cannot describe. An entry wh carries no `scope`: the CRD states it; an external resource whose scope is not known gets `scope: "TODO: Namespaced or Cluster"`. A grant limited to `resourceNames` is never widened to every object: it is left out and named in a note. A role granting `*` verbs or API groups, which the format -refuses, is listed as hand-written with the reason. The fix that writes the file keeps the finding +refuses, is listed as hand-written with the reason, and so are objects a helm_lib include renders +(its legacy roles and capabilities aside, which sync owns whatever renders them), objects inside a +`{{ range }}` and roles without rules. The render shows neither the conditions around an object nor +labels and annotations the format has no field for: a note names, per object, the labels and +annotations a regeneration would drop, and under `--matrix` every object only some variants rendered, +whose `when` a person has to write. The fix that writes the file keeps the finding while a `TODO` is left in it or while the linter would refuse the written file (both are named in the fix error); a written file that does not parse would be a bug of dmt, it is written all the same and the fix error carries the parse error. A `--fix` run with any fix left open exits non-zero whatever the diff --git a/pkg/linters/rbac/rules/bootstrap/bootstrap.go b/pkg/linters/rbac/rules/bootstrap/bootstrap.go index 3c08f6e7..98c48485 100644 --- a/pkg/linters/rbac/rules/bootstrap/bootstrap.go +++ b/pkg/linters/rbac/rules/bootstrap/bootstrap.go @@ -22,6 +22,7 @@ package bootstrap import ( "fmt" + "maps" "regexp" "slices" "sort" @@ -64,6 +65,9 @@ type Input struct { Objects []Object // CRDs maps group/plural to scope for the CRDs under crds/. CRDs map[string]string + // Partial are the objects (Kind/namespace/name) some render variants did not render: under + // --matrix, the objects under a condition. The declaration has no `when` for them yet. + Partial []string } // Result is the declaration with the reader's homework. @@ -130,6 +134,10 @@ type builder struct { notes []string unmanaged []string decl *rbacyaml.Declaration + // unmanagedIDs are the objects left hand-written; account the objects imported with an + // account, which carry its app label. + unmanagedIDs map[string]bool + account map[string]bool // prometheusFolded is set once the note on folding several scrape Roles is written. prometheusFolded bool } @@ -139,6 +147,7 @@ func (b *builder) note(format string, args ...any) { } func (b *builder) unmanage(o Object, why string) { + b.unmanagedIDs[o.identity()] = true b.unmanaged = append(b.unmanaged, fmt.Sprintf("%s (%s): %s", o.identity(), o.Path, why)) } @@ -177,7 +186,7 @@ func Build(in Input) Result { return in.Objects[i].identity() < in.Objects[j].identity() }) - b := &builder{in: in, res: map[[2]string]*resourceAcc{}, restricted: map[[2]string][]string{}, texts: map[string]rbacyaml.CapabilityText{}, lineages: map[string]struct{}{}, used: map[string]struct{}{}, + b := &builder{in: in, unmanagedIDs: map[string]bool{}, account: map[string]bool{}, res: map[[2]string]*resourceAcc{}, restricted: map[[2]string][]string{}, texts: map[string]rbacyaml.CapabilityText{}, lineages: map[string]struct{}{}, used: map[string]struct{}{}, decl: &rbacyaml.Declaration{APIVersion: rbacyaml.APIVersionV1Alpha1}} b.setAside() @@ -186,6 +195,7 @@ func Build(in Input) Result { b.otherBindings() b.leftovers() b.resources() + b.dropped() return Result{Decl: b.decl, Notes: b.notes, Unmanaged: b.unmanaged} } @@ -442,7 +452,7 @@ func (b *builder) serviceAccounts() { if b.ns(sa) != b.in.Namespace { b.unmanage(sa, "outside the module namespace") - b.mark(sa) + b.markAccount(sa) continue } @@ -466,7 +476,7 @@ func (b *builder) serviceAccounts() { b.note("ServiceAccount %s mounted its token (automountServiceAccountToken unset or true); kept as true -- set false once its pods mount the token themselves", sa.Name) } - b.mark(sa) + b.markAccount(sa) clusterName := "d8:" + b.in.Module + ":" + sa.Name @@ -501,10 +511,10 @@ func (b *builder) serviceAccounts() { } if exclusive { - b.mark(cr) + b.markAccount(cr) } - b.mark(crb) + b.markAccount(crb) } for _, rb := range b.byKind("RoleBinding") { @@ -516,7 +526,7 @@ func (b *builder) serviceAccounts() { e.NamespaceRules = policyRules(role.Rules) b.rename("Role", role.Name, sa.Name) b.rename("RoleBinding", rb.Name, sa.Name) - b.mark(role) + b.markAccount(role) } else if rb.RoleRef.Kind != "Role" { // bindRoles binds Roles; the format has no RoleBinding to a ClusterRole, and turning it // into one to a Role of that name would bind nothing (Kubernetes accepts a binding to a @@ -529,7 +539,7 @@ func (b *builder) serviceAccounts() { b.rename("RoleBinding", b.ns(rb)+"/"+rb.Name, b.ns(rb)+"/"+clusterName+":"+rbaccontract.BindingSuffix(rb.RoleRef.Name)) } - b.mark(rb) + b.markAccount(rb) } // Unbound ClusterRoles of the module in the account's file: roles shipped for others to @@ -548,7 +558,7 @@ func (b *builder) serviceAccounts() { e.ExtraClusterRoles = append(e.ExtraClusterRoles, extra) - b.mark(cr) + b.markAccount(cr) } if n := len(e.ExtraClusterRoles); n > 1 { @@ -860,3 +870,52 @@ func subset(a, b []string) bool { return true } + +// markAccount marks an object imported with an account: it carries the account's app label. +func (b *builder) markAccount(o Object) { + b.mark(o) + b.account[o.identity()] = true +} + +// dropped notes, per object the declaration describes, what a regeneration drops without the +// format saying so: labels and annotations it has no field for, and under --matrix the condition +// of an object some variants did not render (review of #479, finding 40). +func (b *builder) dropped() { + partial := make(map[string]bool, len(b.in.Partial)) + for _, p := range b.in.Partial { + partial[p] = true + } + + for _, o := range b.in.Objects { + id := o.identity() + if !b.isUsed(o) || b.unmanagedIDs[id] { + continue + } + + if partial[o.Kind+"/"+o.Namespace+"/"+o.Name] { + b.note("%s %s (%s) renders only under some of the linted values; the declaration writes it unconditionally -- add `when` if its template has a condition", o.Kind, o.Name, o.Path) + } + + var lost []string + + for _, k := range slices.Sorted(maps.Keys(o.Labels)) { + if k == "heritage" || k == "module" || strings.HasPrefix(k, "rbac.deckhouse.io/") || (k == "app" && b.account[id]) { + continue + } + + lost = append(lost, "label "+k) + } + + for _, k := range slices.Sorted(maps.Keys(o.Annotations)) { + if strings.HasPrefix(k, "meta.helm.sh/") || strings.HasPrefix(k, "rbac.deckhouse.io/") || k == rbaccontract.AccessLevelAnnotation || slices.Contains(rbaccontract.I18nAnnotations, k) { + continue + } + + lost = append(lost, "annotation "+k) + } + + if len(lost) > 0 { + b.note("%s %s (%s) carries what the format does not describe (%s); the regeneration drops it", o.Kind, o.Name, o.Path, strings.Join(lost, ", ")) + } + } +} diff --git a/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go b/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go index 914770e9..c43b326b 100644 --- a/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go +++ b/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go @@ -492,3 +492,33 @@ func TestBuild_NestedNamespaceAccessStaysHandWritten(t *testing.T) { assert.Len(t, got.Unmanaged, 2) assert.NotContains(t, strings.Join(got.Notes, "\n"), "access-to-istio-access-to") } + +// What the format does not describe is noted per object: labels and annotations a regeneration +// drops, and the condition of an object only some render variants showed (review of #479, +// finding 40). An account's app label is the format's. +func TestBuild_DroppedMetadataAndConditionsAreNoted(t *testing.T) { + labels := map[string]string{"module": "m", "heritage": "deckhouse", "app": "m"} + nodes := []rbacv1.PolicyRule{{APIGroups: []string{""}, Resources: []string{"nodes"}, Verbs: []string{"get"}}} + sa := []rbacv1.Subject{{Kind: "ServiceAccount", Name: "m", Namespace: "d8-m"}} + + got := Build(Input{Module: "m", Namespace: "d8-m", Objects: []Object{ + {Kind: "ServiceAccount", Name: "m", Namespace: "d8-m", Path: "templates/rbac-for-us.yaml", Labels: labels, + Annotations: map[string]string{"helm.sh/resource-policy": "keep", "meta.helm.sh/release-name": "m"}}, + {Kind: "ClusterRole", Name: "d8:m:m", Path: "templates/rbac-for-us.yaml", Labels: labels, Rules: nodes}, + {Kind: "ClusterRoleBinding", Name: "d8:m:m", Path: "templates/rbac-for-us.yaml", Labels: labels, + Annotations: map[string]string{"werf.io/deploy-on": "pre-install"}, + RoleRef: rbacv1.RoleRef{Kind: "ClusterRole", Name: "d8:m:m"}, Subjects: sa}, + {Kind: "ClusterRole", Name: "d8:m:reader", Path: "templates/rbac-for-us.yaml", Labels: map[string]string{"module": "m", "gatekeeper.sh/system": "yes"}, Rules: nodes}, + {Kind: "ClusterRoleBinding", Name: "d8:m:reader", Path: "templates/rbac-for-us.yaml", Labels: map[string]string{"module": "m"}, + RoleRef: rbacv1.RoleRef{Kind: "ClusterRole", Name: "d8:m:reader"}, Subjects: []rbacv1.Subject{{Kind: "Group", Name: "g"}}}, + }, Partial: []string{"ClusterRoleBinding//d8:m:reader"}}) + + notes := strings.Join(got.Notes, "\n") + assert.Contains(t, notes, "ServiceAccount m (templates/rbac-for-us.yaml) carries what the format does not describe (annotation helm.sh/resource-policy)") + assert.Contains(t, notes, "ClusterRoleBinding d8:m:m (templates/rbac-for-us.yaml) carries what the format does not describe (annotation werf.io/deploy-on)") + assert.Contains(t, notes, "ClusterRole d8:m:reader (templates/rbac-for-us.yaml) carries what the format does not describe (label gatekeeper.sh/system)") + assert.Contains(t, notes, "ClusterRoleBinding d8:m:reader (templates/rbac-for-us.yaml) renders only under some of the linted values") + assert.NotContains(t, notes, "label app") + assert.NotContains(t, notes, "meta.helm.sh") + assert.NotContains(t, notes, "label heritage") +} diff --git a/pkg/linters/rbac/rules/fixstate.go b/pkg/linters/rbac/rules/fixstate.go index 2c291eb2..30fba458 100644 --- a/pkg/linters/rbac/rules/fixstate.go +++ b/pkg/linters/rbac/rules/fixstate.go @@ -48,12 +48,18 @@ var fixState = struct { blocked map[string]map[string]struct{} dropped map[string]string bootstrap map[string]map[string]bootstrap.Object + // variants counts the render variants that recorded bootstrap objects, seen how many of them + // rendered each object: under --matrix an object seen in fewer renders only under some values. + variants map[string]int + seen map[string]map[string]int }{ foreign: map[string]map[string]struct{}{}, removals: map[string]map[string]struct{}{}, blocked: map[string]map[string]struct{}{}, dropped: map[string]string{}, bootstrap: map[string]map[string]bootstrap.Object{}, + variants: map[string]int{}, + seen: map[string]map[string]int{}, } // fixOutcomes remembers the result of every fix that ran, by file. It has a lock of its own, held @@ -124,6 +130,8 @@ func resetFixState() { fixState.blocked = map[string]map[string]struct{}{} fixState.dropped = map[string]string{} fixState.bootstrap = map[string]map[string]bootstrap.Object{} + fixState.variants = map[string]int{} + fixState.seen = map[string]map[string]int{} fixOutcomes.Lock() defer fixOutcomes.Unlock() @@ -143,8 +151,15 @@ func recordBootstrapObjects(path string, objects []bootstrap.Object) { fixState.bootstrap[path] = known } + fixState.variants[path]++ + if fixState.seen[path] == nil { + fixState.seen[path] = map[string]int{} + } + for _, o := range objects { - known[o.Kind+"/"+o.Namespace+"/"+o.Name] = o + key := o.Kind + "/" + o.Namespace + "/" + o.Name + known[key] = o + fixState.seen[path][key]++ } } @@ -360,3 +375,22 @@ func exists(path string) bool { _, err := os.Stat(path) return err == nil } + +// bootstrapPartialOf lists the objects that some render variants did not render, as +// Kind/namespace/name; empty without --matrix. +func bootstrapPartialOf(path string) []string { + fixState.Lock() + defer fixState.Unlock() + + var out []string + + for key, n := range fixState.seen[path] { + if n < fixState.variants[path] { + out = append(out, key) + } + } + + sort.Strings(out) + + return out +} diff --git a/pkg/linters/rbac/rules/sync.go b/pkg/linters/rbac/rules/sync.go index 574c75f6..e6304d0f 100644 --- a/pkg/linters/rbac/rules/sync.go +++ b/pkg/linters/rbac/rules/sync.go @@ -1309,6 +1309,7 @@ func (r *SyncRule) bootstrap(declList *errors.LintRuleErrorsList) { } in.Objects = bootstrapObjectsOf(path) + in.Partial = bootstrapPartialOf(path) result := bootstrap.Build(in) content, err := bootstrap.Marshal(result) diff --git a/pkg/linters/rbac/rules/sync_regressions2_test.go b/pkg/linters/rbac/rules/sync_regressions2_test.go index 77fe1973..7e83024f 100644 --- a/pkg/linters/rbac/rules/sync_regressions2_test.go +++ b/pkg/linters/rbac/rules/sync_regressions2_test.go @@ -177,3 +177,29 @@ func TestSyncRegression_GeneratedNamesPassPlacement(t *testing.T) { assert.Empty(t, texts(errorList)) } + +// Under --matrix an object only some variants rendered is named in the written declaration: its +// condition is not in it (review of #479, finding 40). +func TestSyncRegression_BootstrapNotesObjectsOfSomeVariants(t *testing.T) { + resetFixState() + t.Cleanup(resetFixState) + + modulePath := syncModuleDir(t) + model := syncModel(t, modulePath) + require.NoError(t, os.Remove(rbacyaml.Path(modulePath))) + + withoutInjector := renderedFrom(t, model, func(o *generate.Object) bool { return o.When == "" }) + withInjector := renderedFrom(t, model, nil) + + lists := []*errors.LintRuleErrorsList{runSync(t, modulePath, withoutInjector), runSync(t, modulePath, withInjector)} + for _, list := range lists { + for _, fix := range list.GetFixes() { + fix() + } + } + + content, err := os.ReadFile(rbacyaml.Path(modulePath)) + require.NoError(t, err) + assert.Contains(t, string(content), "ServiceAccount cainjector (templates/cainjector/rbac-for-us.yaml) renders only under some of the linted values") + assert.NotContains(t, string(content), "ServiceAccount cert-manager (templates/cert-manager/rbac-for-us.yaml) renders only under some") +} From e394b0e021894c5ee48eaf56bd523b438b9b3321 Mon Sep 17 00:00:00 2001 From: Ivan Zvyagintsev Date: Thu, 24 Sep 2026 14:58:04 +0300 Subject: [PATCH 52/58] rbac: review of #479, findings 41-43 and 46 - 41: an object only some render variants rendered stays hand-written where the declaration has no `when` for it (access entries, the scrape access); an account with such objects gets a TODO `when`. The note that asked for a `when` the format could not hold is gone. - 42: an object of the module in a file that also holds a document a helm_lib include renders stays hand-written: the generator writes the whole file and could never regenerate it. - 43: the generator refuses an account of a component directory in default and kube-system, naming the known limitation, instead of steering into a name the placement rule rejects. - 46: the cloud-data-discoverer Role in kube-system is listed among the limits in the README. The placement test says it cannot prove the root shapes while the placement rule skips the root files (45). Signed-off-by: Ivan Zvyagintsev --- pkg/linters/rbac/README.md | 6 +- pkg/linters/rbac/rules/bootstrap/bootstrap.go | 75 +++++++++++++++---- .../rbac/rules/bootstrap/bootstrap_test.go | 34 ++++++++- .../rbac/rules/generate/generate_test.go | 11 +++ pkg/linters/rbac/rules/generate/model.go | 6 ++ pkg/linters/rbac/rules/sync.go | 23 ++++++ .../rbac/rules/sync_regressions2_test.go | 19 ++++- .../rbac/rules/sync_regressions_test.go | 7 ++ 8 files changed, 161 insertions(+), 20 deletions(-) diff --git a/pkg/linters/rbac/README.md b/pkg/linters/rbac/README.md index 1c760bb1..3041b087 100644 --- a/pkg/linters/rbac/README.md +++ b/pkg/linters/rbac/README.md @@ -1625,8 +1625,9 @@ refuses, is listed as hand-written with the reason, and so are objects a helm_li (its legacy roles and capabilities aside, which sync owns whatever renders them), objects inside a `{{ range }}` and roles without rules. The render shows neither the conditions around an object nor labels and annotations the format has no field for: a note names, per object, the labels and -annotations a regeneration would drop, and under `--matrix` every object only some variants rendered, -whose `when` a person has to write. The fix that writes the file keeps the finding +annotations a regeneration would drop. Under `--matrix` an object only some variants rendered stays +hand-written where the declaration has no `when` for it (access entries, the scrape access), and an +account with such objects gets a `TODO` `when`, so the run stays red until someone writes it. The fix that writes the file keeps the finding while a `TODO` is left in it or while the linter would refuse the written file (both are named in the fix error); a written file that does not parse would be a bug of dmt, it is written all the same and the fix error carries the parse error. A `--fix` run with any fix left open exits non-zero whatever the @@ -1712,6 +1713,7 @@ configuration error. **Limits worth knowing:** - A conditional rule is checked only where it renders: with the default values, `dmt lint --values-file` or `dmt lint --matrix`. +- The cloud-data-discoverer account of the cloud providers (and csi-vsphere) keeps its own Role `d8::cloud-data-discoverer:secret-reader` in `kube-system`, in the account's `rbac-for-us.yaml`. The format cannot declare a Role in another namespace, so the file holds an object the declaration does not produce and `--fix` refuses to rewrite it; the account stays hand-written until the format can say it. - An account of a component directory in `default` or `kube-system` cannot be declared yet: the placement rule wants it named `d8--` there, and the generator accepts `` and, in a namespace of the platform, `-` only. Bootstrap names the problem in the written file; the account stays hand-written until the two rules agree (control-plane-manager, vertical-pod-autoscaler). - **The three states a module can be in when the new `dmt` first runs.** *Only the legacy scheme* (an external module not yet migrated): `contract` reports one "migrate" finding per object; with an `rbac.yaml`, `sync` reports the generated files as absent and names the cause -- the template renders the legacy scheme -- and `--fix` leaves the legacy file alone (no generator header) with the generated version beside it. *Only the 1.78 scheme*: the ordinary case described above. *Both schemes behind the version gate* (`rbacv2-migrate-module.sh` without `--replace`): the linter's values answer the gate with the 1.78 model, so `contract` and `sync` see exactly the new objects and the legacy branch is neither judged nor "extra"; `--fix` never rewrites a gated file -- regenerating it would drop the legacy branch -- and says so. The legacy branch itself is exercised with `dmt lint --values-file` setting `global.deckhouseVersion` below 1.78; `--matrix` varies module values only, not the platform version. - The declaration is one per module and describes the union of editions. Linting a single edition directory shows the edition-only objects as absent; lint the merged tree as CI does. An `rbac.yaml` inside an edition overlay (`ee/be/modules`, `ee/se-plus/modules`, ..., and `ee/modules` for a module that also exists in `modules/`) is an error: CI merges the overlays over `modules/` before linting, so a copy there would shadow the base one or go unseen. A module that has no base elsewhere -- EE-only in `ee/modules/`, or living in one edition directory only such as `ee/be/modules/350-node-local-dns` -- has its base there. diff --git a/pkg/linters/rbac/rules/bootstrap/bootstrap.go b/pkg/linters/rbac/rules/bootstrap/bootstrap.go index 98c48485..ca7e0d15 100644 --- a/pkg/linters/rbac/rules/bootstrap/bootstrap.go +++ b/pkg/linters/rbac/rules/bootstrap/bootstrap.go @@ -54,6 +54,9 @@ type Object struct { // Repeated marks an object its template renders inside a {{ range }}: one document, several // objects, the name of this one frozen by a declaration. Repeated bool + // LibraryFile marks an object whose template also holds a document a library renders: the + // generator writes the whole file, so it can never regenerate it. + LibraryFile bool } // Input is what the render says about the module. @@ -138,6 +141,10 @@ type builder struct { // account, which carry its app label. unmanagedIDs map[string]bool account map[string]bool + // partialIDs are the objects some render variants did not render (Input.Partial); current the + // objects imported with the account being read. + partialIDs map[string]bool + current []Object // prometheusFolded is set once the note on folding several scrape Roles is written. prometheusFolded bool } @@ -186,9 +193,13 @@ func Build(in Input) Result { return in.Objects[i].identity() < in.Objects[j].identity() }) - b := &builder{in: in, unmanagedIDs: map[string]bool{}, account: map[string]bool{}, res: map[[2]string]*resourceAcc{}, restricted: map[[2]string][]string{}, texts: map[string]rbacyaml.CapabilityText{}, lineages: map[string]struct{}{}, used: map[string]struct{}{}, + b := &builder{in: in, unmanagedIDs: map[string]bool{}, account: map[string]bool{}, partialIDs: map[string]bool{}, res: map[[2]string]*resourceAcc{}, restricted: map[[2]string][]string{}, texts: map[string]rbacyaml.CapabilityText{}, lineages: map[string]struct{}{}, used: map[string]struct{}{}, decl: &rbacyaml.Declaration{APIVersion: rbacyaml.APIVersionV1Alpha1}} + for _, p := range in.Partial { + b.partialIDs[p] = true + } + b.setAside() b.capabilitiesAndLegacy() b.serviceAccounts() @@ -283,6 +294,9 @@ func (b *builder) setAside() { case o.Library && !b.ownedByClass(o): b.unmanage(o, "rendered by an include of a named template (helm_lib), which owns it") b.mark(o) + case o.LibraryFile && !b.ownedByClass(o): + b.unmanage(o, "shares "+o.Path+" with objects a helm_lib include renders; the generator writes the whole file, so it stays hand-written") + b.mark(o) case o.Repeated && !b.ownedByClass(o): b.unmanage(o, "rendered inside {{ range }}: one document renders several objects, and the declaration would freeze this one under its name") b.mark(o) @@ -458,6 +472,7 @@ func (b *builder) serviceAccounts() { } e := rbacyaml.ServiceAccount{Name: sa.Name} + b.current = nil if m := pathRe.FindStringSubmatch(sa.Path); m != nil { e.Path = m[1] @@ -565,6 +580,20 @@ func (b *builder) serviceAccounts() { b.note("ServiceAccount %s has %d ClusterRoles of its own besides d8:%s:%s; they are kept separate as extraClusterRoles -- merge them into clusterRules if nothing binds them separately", sa.Name, n, b.in.Module, sa.Name) } + // An account and its objects share its `when`: what renders only under some values leaves a + // TODO for its condition, so the run stays red until someone writes it (finding 41). + var partial []string + + for _, o := range b.current { + if b.partial(o) { + partial = append(partial, o.Kind+" "+o.Name) + } + } + + if len(partial) > 0 { + e.When = "TODO: " + strings.Join(partial, ", ") + " render only under some of the linted values; write the condition they render under" + } + b.decl.ServiceAccounts = append(b.decl.ServiceAccounts, e) } } @@ -583,6 +612,10 @@ func (b *builder) otherBindings() { continue } + if b.unmanagePartial(cr, crb) { + continue + } + name := strings.TrimPrefix(cr.Name, "d8:"+b.in.Module+":") b.decl.Access = append(b.decl.Access, rbacyaml.Access{Name: name, Path: componentOf(cr.Path, "rbac-for-us.yaml"), Subjects: subjects(crb.Subjects), ClusterRules: policyRules(cr.Rules)}) b.rename("ClusterRoleBinding", crb.Name, "d8:"+b.in.Module+":"+name) @@ -602,7 +635,7 @@ func (b *builder) otherBindings() { continue } - if b.prometheus(role, rb) { + if b.unmanagePartial(role, rb) || b.prometheus(role, rb) { continue } @@ -875,27 +908,43 @@ func subset(a, b []string) bool { func (b *builder) markAccount(o Object) { b.mark(o) b.account[o.identity()] = true + b.current = append(b.current, o) } -// dropped notes, per object the declaration describes, what a regeneration drops without the -// format saying so: labels and annotations it has no field for, and under --matrix the condition -// of an object some variants did not render (review of #479, finding 40). -func (b *builder) dropped() { - partial := make(map[string]bool, len(b.in.Partial)) - for _, p := range b.in.Partial { - partial[p] = true +// partial reports whether some render variants did not render the object. +func (b *builder) partial(o Object) bool { + return b.partialIDs[o.Kind+"/"+o.Namespace+"/"+o.Name] +} + +// unmanagePartial keeps hand-written what renders only under some of the linted values where the +// declaration has no `when` for it: written unconditionally, it would grant for every value +// (review of #479, finding 41). +func (b *builder) unmanagePartial(objects ...Object) bool { + for _, o := range objects { + if !b.partial(o) { + continue + } + + for _, p := range objects { + b.unmanage(p, "renders only under some of the linted values, and the declaration has no `when` for it here") + b.mark(p) + } + + return true } + return false +} + +// dropped notes, per object the declaration describes, what a regeneration drops without the +// format saying so: labels and annotations it has no field for (review of #479, finding 40). +func (b *builder) dropped() { for _, o := range b.in.Objects { id := o.identity() if !b.isUsed(o) || b.unmanagedIDs[id] { continue } - if partial[o.Kind+"/"+o.Namespace+"/"+o.Name] { - b.note("%s %s (%s) renders only under some of the linted values; the declaration writes it unconditionally -- add `when` if its template has a condition", o.Kind, o.Name, o.Path) - } - var lost []string for _, k := range slices.Sorted(maps.Keys(o.Labels)) { diff --git a/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go b/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go index c43b326b..69816dc2 100644 --- a/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go +++ b/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go @@ -516,9 +516,39 @@ func TestBuild_DroppedMetadataAndConditionsAreNoted(t *testing.T) { notes := strings.Join(got.Notes, "\n") assert.Contains(t, notes, "ServiceAccount m (templates/rbac-for-us.yaml) carries what the format does not describe (annotation helm.sh/resource-policy)") assert.Contains(t, notes, "ClusterRoleBinding d8:m:m (templates/rbac-for-us.yaml) carries what the format does not describe (annotation werf.io/deploy-on)") - assert.Contains(t, notes, "ClusterRole d8:m:reader (templates/rbac-for-us.yaml) carries what the format does not describe (label gatekeeper.sh/system)") - assert.Contains(t, notes, "ClusterRoleBinding d8:m:reader (templates/rbac-for-us.yaml) renders only under some of the linted values") + // An access entry has no `when`: what renders only under some values stays hand-written + // (review of #479, finding 41). + unmanaged := strings.Join(got.Unmanaged, "\n") + assert.Contains(t, unmanaged, "ClusterRoleBinding/d8:m:reader (templates/rbac-for-us.yaml): renders only under some of the linted values") + assert.Contains(t, unmanaged, "ClusterRole/d8:m:reader (templates/rbac-for-us.yaml): renders only under some of the linted values") + assert.Empty(t, got.Decl.Access) assert.NotContains(t, notes, "label app") assert.NotContains(t, notes, "meta.helm.sh") assert.NotContains(t, notes, "label heritage") } + +// An object of the module in a file that also holds what a helm_lib include renders stays +// hand-written: the generator writes the whole file (review of #479, finding 42). A scrape Role +// only some variants render stays hand-written too: prometheusAccess gates only the binding +// (finding 41). +func TestBuild_LibraryFileAndPartialScrapeAccess(t *testing.T) { + labels := map[string]string{"module": "m"} + metrics := []rbacv1.PolicyRule{{APIGroups: []string{"apps"}, Resources: []string{"deployments/prometheus-metrics"}, ResourceNames: []string{"m"}, Verbs: []string{"get"}}} + + got := Build(Input{Module: "m", Namespace: "d8-m", Objects: []Object{ + {Kind: "ClusterRole", Name: "d8:m:csi", Path: "templates/csi/rbac-for-us.yaml", Labels: labels, LibraryFile: true, + Rules: []rbacv1.PolicyRule{{APIGroups: []string{""}, Resources: []string{"nodes"}, Verbs: []string{"get"}}}}, + {Kind: "ClusterRoleBinding", Name: "d8:m:csi", Path: "templates/csi/rbac-for-us.yaml", Labels: labels, LibraryFile: true, + RoleRef: rbacv1.RoleRef{Kind: "ClusterRole", Name: "d8:m:csi"}, Subjects: []rbacv1.Subject{{Kind: "ServiceAccount", Name: "csi", Namespace: "d8-m"}}}, + {Kind: "Role", Name: "access-to-m-prometheus-metrics", Namespace: "d8-m", Path: "templates/rbac-to-us.yaml", Labels: labels, Rules: metrics}, + {Kind: "RoleBinding", Name: "access-to-m-prometheus-metrics", Namespace: "d8-m", Path: "templates/rbac-to-us.yaml", Labels: labels, + RoleRef: rbacv1.RoleRef{Kind: "Role", Name: "access-to-m-prometheus-metrics"}, Subjects: []rbacv1.Subject{{Kind: "User", Name: "d8-monitoring:scraper"}}}, + }, Partial: []string{"Role/d8-m/access-to-m-prometheus-metrics"}}) + + unmanaged := strings.Join(got.Unmanaged, "\n") + assert.Contains(t, unmanaged, "ClusterRole/d8:m:csi (templates/csi/rbac-for-us.yaml): shares templates/csi/rbac-for-us.yaml with objects a helm_lib include renders") + assert.Contains(t, unmanaged, "ClusterRoleBinding/d8:m:csi (templates/csi/rbac-for-us.yaml): shares") + assert.Contains(t, unmanaged, "d8-m/Role/access-to-m-prometheus-metrics (templates/rbac-to-us.yaml): renders only under some of the linted values") + assert.Empty(t, got.Decl.Access) + assert.Nil(t, got.Decl.PrometheusAccess) +} diff --git a/pkg/linters/rbac/rules/generate/generate_test.go b/pkg/linters/rbac/rules/generate/generate_test.go index 1aacae71..d9f0d285 100644 --- a/pkg/linters/rbac/rules/generate/generate_test.go +++ b/pkg/linters/rbac/rules/generate/generate_test.go @@ -156,6 +156,8 @@ func TestBuild_SubsystemsOverrideAndNamespaceLabel(t *testing.T) { in := certManagerInput(t) in.Decl.Subsystems = []string{"networking", "kubernetes"} in.Namespace = "default" + // Accounts of component directories cannot live in default (README, limits). + in.Decl.ServiceAccounts = nil in.Decl.Normalize() model, err := Build(in) @@ -300,6 +302,15 @@ func TestBuild_RefusesWhatTheModuleCannotCarry(t *testing.T) { _, err = Build(Input{Module: "m", Namespace: "d8-m", Subsystems: []string{"security"}, Decl: decl}) require.Error(t, err) assert.Contains(t, err.Error(), "one directory under templates/ only") + + // In default and kube-system the placement rule wants d8--, which the generator + // does not accept: refused with the limitation named (review of #479, finding 43). + decl.ServiceAccounts = []rbacyaml.ServiceAccount{{Name: "control-plane-proxy", Path: "control-plane-proxy"}} + for _, ns := range []string{"kube-system", "default"} { + _, err = Build(Input{Module: "m", Namespace: ns, Subsystems: []string{"security"}, Decl: decl}) + require.Error(t, err, ns) + assert.Contains(t, err.Error(), `the placement rule wants the account named "d8-m-control-plane-proxy", which the generator does not accept yet (a known limitation)`) + } }) t.Run("namespace access in a directory", func(t *testing.T) { diff --git a/pkg/linters/rbac/rules/generate/model.go b/pkg/linters/rbac/rules/generate/model.go index a4e185e0..1997a0bc 100644 --- a/pkg/linters/rbac/rules/generate/model.go +++ b/pkg/linters/rbac/rules/generate/model.go @@ -253,6 +253,12 @@ func checkAgainstModule(in Input) error { return fmt.Errorf("serviceAccounts[%s].path %q: one directory under templates/ only; the placement rule names the objects of a nested directory in a way the generator cannot follow", sa.Name, sa.Path) } + // In default and kube-system the placement rule wants the account named d8--, + // which the generator does not write (README, limits; review of #479, finding 43). + if in.Namespace == "default" || in.Namespace == "kube-system" { + return fmt.Errorf("serviceAccounts[%s].path %q: in %s the placement rule wants the account named %q, which the generator does not accept yet (a known limitation); keep the account hand-written", sa.Name, sa.Path, in.Namespace, "d8-"+in.Module+"-"+sa.Path) + } + // The placement rule allows the module name in front of the directory only in a namespace of // the platform, and then wants its Role and foreign RoleBindings named :, which // the generator does not write (review of #479, finding 37). diff --git a/pkg/linters/rbac/rules/sync.go b/pkg/linters/rbac/rules/sync.go index e6304d0f..f52f919b 100644 --- a/pkg/linters/rbac/rules/sync.go +++ b/pkg/linters/rbac/rules/sync.go @@ -1285,6 +1285,7 @@ func (r *SyncRule) bootstrap(declList *errors.LintRuleErrorsList) { } o.Library = renderedByInclude(text, o.Kind, o.Name) + o.LibraryFile = holdsLibraryDocument(text) o.Repeated = renderedInRange(text, o.Kind, o.Name) in.Objects = append(in.Objects, o) } @@ -1934,6 +1935,28 @@ func renderedByInclude(content, kind, name string) bool { return include } +// holdsLibraryDocument reports whether the template has a document without a kind of its own +// that includes a named template: what such a file renders is partly the library's (review of +// #479, finding 42). +func holdsLibraryDocument(content string) bool { + for _, doc := range separatorRe.Split(strings.ReplaceAll(content, "\r\n", "\n"), -1) { + kind := false + + for _, line := range strings.Split(doc, "\n") { + if kindLineRe.MatchString(line) { + kind = true + break + } + } + + if !kind && includeRe.MatchString(doc) { + return true + } + } + + return false +} + // namesMatch reports whether a metadata name as the template writes it can be the rendered name: // equal, or with every action of a computed name standing for any text. func namesMatch(written, rendered string) bool { diff --git a/pkg/linters/rbac/rules/sync_regressions2_test.go b/pkg/linters/rbac/rules/sync_regressions2_test.go index 7e83024f..04f07756 100644 --- a/pkg/linters/rbac/rules/sync_regressions2_test.go +++ b/pkg/linters/rbac/rules/sync_regressions2_test.go @@ -143,7 +143,9 @@ func syncModelFromFixture(t *testing.T) *generate.Model { // What the generator writes for the shapes this version supports passes the placement rule: an // account in a component directory, access with clusterRules in one, namespace access at the root -// (review of #479, finding 37). +// (review of #479, finding 37). The placement rule skips templates/rbac-for-us.yaml and +// templates/rbac-to-us.yaml today (RBACv2Path is a prefix of both, finding 45), so the root shapes +// are not proven here until that is fixed upstream. func TestSyncRegression_GeneratedNamesPassPlacement(t *testing.T) { get := []rbacyaml.PolicyRule{{APIGroups: []string{""}, Resources: []string{"secrets"}, Verbs: []string{"get"}}} nodes := []rbacyaml.PolicyRule{{APIGroups: []string{""}, Resources: []string{"nodes"}, Verbs: []string{"get"}}} @@ -200,6 +202,17 @@ func TestSyncRegression_BootstrapNotesObjectsOfSomeVariants(t *testing.T) { content, err := os.ReadFile(rbacyaml.Path(modulePath)) require.NoError(t, err) - assert.Contains(t, string(content), "ServiceAccount cainjector (templates/cainjector/rbac-for-us.yaml) renders only under some of the linted values") - assert.NotContains(t, string(content), "ServiceAccount cert-manager (templates/cert-manager/rbac-for-us.yaml) renders only under some") + decl, err := rbacyaml.Parse(content) + require.NoError(t, err) + + whens := map[string]string{} + for _, sa := range decl.ServiceAccounts { + whens[sa.Name] = sa.When + } + + // The account and its objects render only with the injector on: a TODO for their condition + // keeps the run red (review of #479, finding 41). + assert.True(t, strings.HasPrefix(whens["cainjector"], "TODO: "), whens["cainjector"]) + assert.Contains(t, whens["cainjector"], "ServiceAccount cainjector") + assert.Empty(t, whens["cert-manager"]) } diff --git a/pkg/linters/rbac/rules/sync_regressions_test.go b/pkg/linters/rbac/rules/sync_regressions_test.go index 65f1503b..563349b2 100644 --- a/pkg/linters/rbac/rules/sync_regressions_test.go +++ b/pkg/linters/rbac/rules/sync_regressions_test.go @@ -576,3 +576,10 @@ metadata: assert.False(t, renderedInRange(text, "Role", "conditional"), "an if is no range") assert.False(t, renderedInRange("{{ if }", "Role", "x"), "a template that does not parse tells nothing") } + +// A template with a document that only includes a named template holds library objects (review of +// #479, finding 42). +func TestHoldsLibraryDocument(t *testing.T) { + assert.True(t, holdsLibraryDocument("{{- include \"helm_lib_csi_controller_rbac\" . }}\n---\nkind: ClusterRole\nmetadata:\n name: d8:m:csi\n")) + assert.False(t, holdsLibraryDocument("---\nkind: ClusterRole\nmetadata:\n name: d8:m:csi\n {{- include \"helm_lib_module_labels\" (list .) | nindent 2 }}\n")) +} From c074c33f105315756dc1467fef6963a23bf62f77 Mon Sep 17 00:00:00 2001 From: Ivan Zvyagintsev Date: Thu, 24 Sep 2026 15:01:16 +0300 Subject: [PATCH 53/58] rbac: a partially rendered capability leaves a TODO reason Review of #479, finding 41 (completion): the note removed in e394b0e also covered capabilities and legacy roles only some render variants rendered. They cannot stay hand-written (sync owns them by class) and resources[] have no `when`, so the entries they grant get a TODO reason and the run stays red until someone decides. Signed-off-by: Ivan Zvyagintsev --- pkg/linters/rbac/README.md | 5 +- pkg/linters/rbac/rules/bootstrap/bootstrap.go | 48 ++++++++++++++++++- .../rbac/rules/bootstrap/bootstrap_test.go | 14 ++++++ 3 files changed, 64 insertions(+), 3 deletions(-) diff --git a/pkg/linters/rbac/README.md b/pkg/linters/rbac/README.md index 3041b087..eab051fb 100644 --- a/pkg/linters/rbac/README.md +++ b/pkg/linters/rbac/README.md @@ -1626,8 +1626,9 @@ refuses, is listed as hand-written with the reason, and so are objects a helm_li `{{ range }}` and roles without rules. The render shows neither the conditions around an object nor labels and annotations the format has no field for: a note names, per object, the labels and annotations a regeneration would drop. Under `--matrix` an object only some variants rendered stays -hand-written where the declaration has no `when` for it (access entries, the scrape access), and an -account with such objects gets a `TODO` `when`, so the run stays red until someone writes it. The fix that writes the file keeps the finding +hand-written where the declaration has no `when` for it (access entries, the scrape access), an +account with such objects gets a `TODO` `when`, and a legacy role or a capability a `TODO` reason on the +resources it grants, so the run stays red until someone decides. The fix that writes the file keeps the finding while a `TODO` is left in it or while the linter would refuse the written file (both are named in the fix error); a written file that does not parse would be a bug of dmt, it is written all the same and the fix error carries the parse error. A `--fix` run with any fix left open exits non-zero whatever the diff --git a/pkg/linters/rbac/rules/bootstrap/bootstrap.go b/pkg/linters/rbac/rules/bootstrap/bootstrap.go index ca7e0d15..4767db74 100644 --- a/pkg/linters/rbac/rules/bootstrap/bootstrap.go +++ b/pkg/linters/rbac/rules/bootstrap/bootstrap.go @@ -145,6 +145,9 @@ type builder struct { // objects imported with the account being read. partialIDs map[string]bool current []Object + // conditionalKeys are the resources a capability or a legacy role only some variants rendered + // grants, with why. + conditionalKeys map[[2]string][]string // prometheusFolded is set once the note on folding several scrape Roles is written. prometheusFolded bool } @@ -193,7 +196,7 @@ func Build(in Input) Result { return in.Objects[i].identity() < in.Objects[j].identity() }) - b := &builder{in: in, unmanagedIDs: map[string]bool{}, account: map[string]bool{}, partialIDs: map[string]bool{}, res: map[[2]string]*resourceAcc{}, restricted: map[[2]string][]string{}, texts: map[string]rbacyaml.CapabilityText{}, lineages: map[string]struct{}{}, used: map[string]struct{}{}, + b := &builder{in: in, unmanagedIDs: map[string]bool{}, account: map[string]bool{}, partialIDs: map[string]bool{}, conditionalKeys: map[[2]string][]string{}, res: map[[2]string]*resourceAcc{}, restricted: map[[2]string][]string{}, texts: map[string]rbacyaml.CapabilityText{}, lineages: map[string]struct{}{}, used: map[string]struct{}{}, decl: &rbacyaml.Declaration{APIVersion: rbacyaml.APIVersionV1Alpha1}} for _, p := range in.Partial { @@ -338,6 +341,7 @@ func (b *builder) capabilitiesAndLegacy() { b.rename("ClusterRole", o.Name, "d8:user-authz:"+b.in.Module+":"+rbaccontract.LegacyKebab(level)) b.addRules("legacy", level, o.Rules, false) + b.partialGrant(o) b.mark(o) continue @@ -364,6 +368,7 @@ func (b *builder) capabilitiesAndLegacy() { } b.addRules(lineage, level, o.Rules, lineage == rbaccontract.LineageSystem) + b.partialGrant(o) b.mark(o) if lineage == rbaccontract.LineageSystem { @@ -794,6 +799,10 @@ func (b *builder) resources() { e.Reason = "TODO: a namespaced resource granted cluster-wide, as the templates did; confirm or move it to namespace" } + if conditions := b.conditionalKeys[k]; len(conditions) > 0 { + e.Reason = conditionalReason(conditions, e.Reason) + } + e.Namespace, e.System, e.Legacy = sortedLevels(acc.namespace), sortedLevels(acc.system), sortedLevels(acc.legacy) // A grant limited to resourceNames is never widened to every object, at any level: it @@ -916,6 +925,43 @@ func (b *builder) partial(o Object) bool { return b.partialIDs[o.Kind+"/"+o.Namespace+"/"+o.Name] } +// partialGrant records a capability or a legacy role only some render variants rendered against +// the resources it grants: resources[] have no `when`, and the regenerated role would render for +// every value, so the entries get a TODO reason the run stays red for (review of #479, finding 41). +func (b *builder) partialGrant(o Object) { + if !b.partial(o) { + return + } + + msg := fmt.Sprintf("ClusterRole %s renders only under some of the linted values", o.Name) + + for _, r := range o.Rules { + groups := r.APIGroups + if len(groups) == 0 { + groups = []string{""} + } + + for _, g := range groups { + for _, rs := range r.Resources { + key := [2]string{g, rs} + if !slices.Contains(b.conditionalKeys[key], msg) { + b.conditionalKeys[key] = append(b.conditionalKeys[key], msg) + } + } + } + } +} + +// conditionalReason puts the conditions of the roles granting a resource in front of its reason. +func conditionalReason(conditions []string, reason string) string { + todo := "TODO: " + strings.Join(conditions, "; ") + "; resources[] have no `when`, so the regenerated role would render for every value -- decide, then write the reason" + if reason == "" { + return todo + } + + return todo + "; " + strings.TrimPrefix(reason, "TODO: ") +} + // unmanagePartial keeps hand-written what renders only under some of the linted values where the // declaration has no `when` for it: written unconditionally, it would grant for every value // (review of #479, finding 41). diff --git a/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go b/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go index 69816dc2..2d855e57 100644 --- a/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go +++ b/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go @@ -552,3 +552,17 @@ func TestBuild_LibraryFileAndPartialScrapeAccess(t *testing.T) { assert.Empty(t, got.Decl.Access) assert.Nil(t, got.Decl.PrometheusAccess) } + +// A capability only some variants rendered leaves a TODO reason on what it grants: resources[] have +// no `when` (review of #479, finding 41). +func TestBuild_PartialCapabilityIsATODO(t *testing.T) { + labels := map[string]string{"module": "m", rbaccontract.LabelKind: rbaccontract.KindCapability} + + got := Build(Input{Module: "m", Namespace: "d8-m", Subsystems: []string{"security"}, CRDs: map[string]string{"x.io/things": "Namespaced"}, Objects: []Object{ + {Kind: "ClusterRole", Name: "d8:namespace-capability:m:view", Path: "templates/rbacv2/use/view.yaml", Labels: labels, + Rules: []rbacv1.PolicyRule{{APIGroups: []string{"x.io"}, Resources: []string{"things"}, Verbs: []string{"get"}}}}, + }, Partial: []string{"ClusterRole//d8:namespace-capability:m:view"}}) + + require.Len(t, got.Decl.Resources, 1) + assert.True(t, strings.HasPrefix(got.Decl.Resources[0].Reason, "TODO: ClusterRole d8:namespace-capability:m:view renders only under some of the linted values"), got.Decl.Resources[0].Reason) +} From d62d257b7884062ffcd29c7f5faa07402a8e46c3 Mon Sep 17 00:00:00 2001 From: Ivan Zvyagintsev Date: Thu, 24 Sep 2026 15:05:07 +0300 Subject: [PATCH 54/58] rbac: document that objects sharing a file with a helm_lib include stay hand-written Review of #479, finding 42: the README section on bootstrap now names the rule e394b0e added. Signed-off-by: Ivan Zvyagintsev --- pkg/linters/rbac/README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/linters/rbac/README.md b/pkg/linters/rbac/README.md index eab051fb..d442254e 100644 --- a/pkg/linters/rbac/README.md +++ b/pkg/linters/rbac/README.md @@ -1623,7 +1623,8 @@ carries no `scope`: the CRD states it; an external resource whose scope is not k object: it is left out and named in a note. A role granting `*` verbs or API groups, which the format refuses, is listed as hand-written with the reason, and so are objects a helm_lib include renders (its legacy roles and capabilities aside, which sync owns whatever renders them), objects inside a -`{{ range }}` and roles without rules. The render shows neither the conditions around an object nor +`{{ range }}`, objects of the module in a file that also holds what a helm_lib include renders (the +generator writes the whole file, so it could never regenerate it) and roles without rules. The render shows neither the conditions around an object nor labels and annotations the format has no field for: a note names, per object, the labels and annotations a regeneration would drop. Under `--matrix` an object only some variants rendered stays hand-written where the declaration has no `when` for it (access entries, the scrape access), an From 547f5c9a135f271043f23d82a02a423a588aafe1 Mon Sep 17 00:00:00 2001 From: Ivan Zvyagintsev Date: Thu, 24 Sep 2026 16:30:53 +0300 Subject: [PATCH 55/58] rbac: review of #479, findings 47-51 - 47: a `when` excuses an absent declared object only while it is false: when another object of the file under the same `when` rendered, the absent one is reported. - 48: a partially rendered object kept hand-written in a file the declaration also writes is named, with the way out (a file of its own before --fix). - 49: bootstrap keeps an account of a component directory in default or kube-system hand-written, with what binds it, instead of writing an entry the generator refuses. - 50: the library-file mark comes from the render (another object of the template is the library's), not from the text; holdsLibraryDocument is gone. - 51: a TODO `when` is reported as an open decision, and the TODO for an account that always renders does not invite narrowing it. Signed-off-by: Ivan Zvyagintsev --- pkg/linters/rbac/README.md | 2 +- pkg/linters/rbac/rules/bootstrap/bootstrap.go | 62 ++++++++++++++++++- .../rbac/rules/bootstrap/bootstrap_test.go | 53 ++++++++++++++++ pkg/linters/rbac/rules/rbacyaml/load_test.go | 12 ++++ pkg/linters/rbac/rules/rbacyaml/validate.go | 7 +++ pkg/linters/rbac/rules/sync.go | 43 +++++++------ .../rbac/rules/sync_regressions_test.go | 43 +++++++++++-- 7 files changed, 197 insertions(+), 25 deletions(-) diff --git a/pkg/linters/rbac/README.md b/pkg/linters/rbac/README.md index d442254e..921f047b 100644 --- a/pkg/linters/rbac/README.md +++ b/pkg/linters/rbac/README.md @@ -1652,7 +1652,7 @@ controller ClusterRoles with arbitrary names, objects with Helm-computed names). **What it checks:** -1. Every declared object is in the render (unless it is under `when`), and every rule of it: rules are compared as `(apiGroup, resource, resourceName, verb)` tuples, in both directions. A rule under `when` that did not render is not a divergence; a rule without `when` hidden behind a hand-written `{{ if }}` is. +1. Every declared object is in the render (unless it is under a `when` that is false in this render: when another object of the file under the same `when` rendered, the condition holds, and an absent one is a divergence), and every rule of it: rules are compared as `(apiGroup, resource, resourceName, verb)` tuples, in both directions. A rule under `when` that did not render is not a divergence; a rule without `when` hidden behind a hand-written `{{ if }}` is. 2. A capability's aggregation edges (`aggregate-to--as`) match in both directions: rules may agree while a lineage is lost. Its `rbac.deckhouse.io/capability` marker, `module` and `rbac.deckhouse.io/namespace` labels are what the generator writes. 3. A binding's `roleRef` and subjects match. 4. Every rendered legacy role and module capability is produced by the declaration. diff --git a/pkg/linters/rbac/rules/bootstrap/bootstrap.go b/pkg/linters/rbac/rules/bootstrap/bootstrap.go index 4767db74..c0e93b19 100644 --- a/pkg/linters/rbac/rules/bootstrap/bootstrap.go +++ b/pkg/linters/rbac/rules/bootstrap/bootstrap.go @@ -145,6 +145,8 @@ type builder struct { // objects imported with the account being read. partialIDs map[string]bool current []Object + // partialKept are the objects kept hand-written because only some variants rendered them. + partialKept []Object // conditionalKeys are the resources a capability or a legacy role only some variants rendered // grants, with why. conditionalKeys map[[2]string][]string @@ -210,6 +212,7 @@ func Build(in Input) Result { b.leftovers() b.resources() b.dropped() + b.sharedWithDeclared() return Result{Decl: b.decl, Notes: b.notes, Unmanaged: b.unmanaged} } @@ -481,6 +484,14 @@ func (b *builder) serviceAccounts() { if m := pathRe.FindStringSubmatch(sa.Path); m != nil { e.Path = m[1] + + // The generator refuses such an account (README, limits): it stays hand-written with + // what binds it, rather than making the whole declaration refused (finding 49). + if e.Path != "" && (b.in.Namespace == "default" || b.in.Namespace == "kube-system") { + b.setAsideAccount(sa, fmt.Sprintf("in %s the placement rule wants the account named %q, which the generator does not accept yet (a known limitation)", b.in.Namespace, "d8-"+b.in.Module+"-"+strings.ReplaceAll(e.Path, "/", "-"))) + + continue + } } else { b.note("ServiceAccount %s lives in %s; the generator keeps accounts in templates/[/]rbac-for-us.yaml and will write it to templates/rbac-for-us.yaml", sa.Name, sa.Path) } @@ -595,8 +606,12 @@ func (b *builder) serviceAccounts() { } } - if len(partial) > 0 { + switch { + case len(partial) > 0 && b.partial(sa): e.When = "TODO: " + strings.Join(partial, ", ") + " render only under some of the linted values; write the condition they render under" + case len(partial) > 0: + // The account itself renders always: narrowing its `when` would take it away (finding 51). + e.When = "TODO: " + strings.Join(partial, ", ") + " render only under some of the linted values, the account always; the declaration puts them under one `when` -- keep them hand-written, or decide that they share one condition" } b.decl.ServiceAccounts = append(b.decl.ServiceAccounts, e) @@ -974,6 +989,7 @@ func (b *builder) unmanagePartial(objects ...Object) bool { for _, p := range objects { b.unmanage(p, "renders only under some of the linted values, and the declaration has no `when` for it here") b.mark(p) + b.partialKept = append(b.partialKept, p) } return true @@ -1014,3 +1030,47 @@ func (b *builder) dropped() { } } } + +// sharedWithDeclared notes a partially rendered object kept hand-written in a file the declaration +// writes objects into: a --fix without --matrix does not see it, puts the generated file beside +// the template and advises to delete the template, which would drop it (review of #479, finding +// 48). +func (b *builder) sharedWithDeclared() { + for _, kept := range b.partialKept { + for _, o := range b.in.Objects { + if o.Path == kept.Path && b.isUsed(o) && !b.unmanagedIDs[o.identity()] { + b.note("%s %s stays hand-written in %s, which the declaration also writes: move it to a file of its own (templates//rbac-for-us.yaml) before `--fix`, or regenerating %s drops it", kept.Kind, kept.Name, kept.Path, kept.Path) + + break + } + } + } +} + +// setAsideAccount keeps an account hand-written with the bindings that bind only it and the +// module's own roles only they bind. +func (b *builder) setAsideAccount(sa Object, why string) { + b.unmanage(sa, why) + b.mark(sa) + + for _, kind := range []string{"ClusterRoleBinding", "RoleBinding"} { + for _, binding := range b.byKind(kind) { + if b.isUsed(binding) || !subjectsAreOnly(binding, sa.Name, b.ns(sa)) { + continue + } + + b.unmanage(binding, "binds "+sa.Name+", which stays hand-written") + b.mark(binding) + + if binding.RoleRef.Kind == "ClusterRole" { + if cr, ok := b.clusterRole(binding.RoleRef.Name); ok && !b.isUsed(cr) && b.ownClusterRole(cr) && len(b.bindingsOf(cr.Name)) == 1 { + b.unmanage(cr, "bound only to "+sa.Name+", which stays hand-written") + b.mark(cr) + } + } else if role, ok := b.role(b.ns(binding), binding.RoleRef.Name); ok && !b.isUsed(role) { + b.unmanage(role, "bound to "+sa.Name+", which stays hand-written") + b.mark(role) + } + } + } +} diff --git a/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go b/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go index 2d855e57..1d933248 100644 --- a/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go +++ b/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go @@ -566,3 +566,56 @@ func TestBuild_PartialCapabilityIsATODO(t *testing.T) { require.Len(t, got.Decl.Resources, 1) assert.True(t, strings.HasPrefix(got.Decl.Resources[0].Reason, "TODO: ClusterRole d8:namespace-capability:m:view renders only under some of the linted values"), got.Decl.Resources[0].Reason) } + +// A partially rendered object kept hand-written in a file the declaration also writes is named +// with the way out (review of #479, finding 48). +func TestBuild_PartialObjectBesideDeclaredOnes(t *testing.T) { + labels := map[string]string{"module": "m"} + nodes := []rbacv1.PolicyRule{{APIGroups: []string{""}, Resources: []string{"nodes"}, Verbs: []string{"get"}}} + + got := Build(Input{Module: "m", Namespace: "d8-m", Objects: []Object{ + {Kind: "ServiceAccount", Name: "m", Namespace: "d8-m", Path: "templates/rbac-for-us.yaml", Labels: labels}, + {Kind: "ClusterRole", Name: "d8:m:supplement", Path: "templates/rbac-for-us.yaml", Labels: labels, Rules: nodes}, + {Kind: "ClusterRoleBinding", Name: "d8:m:supplement", Path: "templates/rbac-for-us.yaml", Labels: labels, + RoleRef: rbacv1.RoleRef{Kind: "ClusterRole", Name: "d8:m:supplement"}, Subjects: []rbacv1.Subject{{Kind: "Group", Name: "g"}}}, + }, Partial: []string{"ClusterRoleBinding//d8:m:supplement"}}) + + notes := strings.Join(got.Notes, "\n") + assert.Contains(t, notes, "ClusterRoleBinding d8:m:supplement stays hand-written in templates/rbac-for-us.yaml, which the declaration also writes: move it to a file of its own") + assert.Contains(t, notes, "ClusterRole d8:m:supplement stays hand-written in templates/rbac-for-us.yaml") +} + +// An account of a component directory in kube-system stays hand-written with what binds it: the +// generator refuses it, and the declaration would be refused whole (review of #479, finding 49). +func TestBuild_KubeSystemComponentAccountIsSetAside(t *testing.T) { + labels := map[string]string{"module": "m"} + sa := []rbacv1.Subject{{Kind: "ServiceAccount", Name: "proxy", Namespace: "kube-system"}} + + got := Build(Input{Module: "m", Namespace: "kube-system", Objects: []Object{ + {Kind: "ServiceAccount", Name: "proxy", Namespace: "kube-system", Path: "templates/proxy/rbac-for-us.yaml", Labels: labels}, + {Kind: "ClusterRole", Name: "d8:m:proxy", Path: "templates/proxy/rbac-for-us.yaml", Labels: labels, + Rules: []rbacv1.PolicyRule{{APIGroups: []string{""}, Resources: []string{"nodes"}, Verbs: []string{"get"}}}}, + {Kind: "ClusterRoleBinding", Name: "d8:m:proxy", Path: "templates/proxy/rbac-for-us.yaml", Labels: labels, + RoleRef: rbacv1.RoleRef{Kind: "ClusterRole", Name: "d8:m:proxy"}, Subjects: sa}, + }}) + + assert.Empty(t, got.Decl.ServiceAccounts) + assert.Empty(t, got.Decl.Access) + assert.Len(t, got.Unmanaged, 3, "got: %v", got.Unmanaged) + assert.Contains(t, strings.Join(got.Unmanaged, "\n"), `in kube-system the placement rule wants the account named "d8-m-proxy"`) +} + +// When only an object of an account renders in some variants, the TODO does not invite narrowing +// the account itself (review of #479, finding 51). +func TestBuild_PartialObjectOfAnAlwaysRenderedAccount(t *testing.T) { + labels := map[string]string{"module": "m"} + + got := Build(Input{Module: "m", Namespace: "d8-m", Objects: []Object{ + {Kind: "ServiceAccount", Name: "m", Namespace: "d8-m", Path: "templates/rbac-for-us.yaml", Labels: labels}, + {Kind: "ClusterRoleBinding", Name: "d8:m:m:rbac-proxy", Path: "templates/rbac-for-us.yaml", Labels: labels, + RoleRef: rbacv1.RoleRef{Kind: "ClusterRole", Name: "d8:rbac-proxy"}, Subjects: []rbacv1.Subject{{Kind: "ServiceAccount", Name: "m", Namespace: "d8-m"}}}, + }, Partial: []string{"ClusterRoleBinding//d8:m:m:rbac-proxy"}}) + + require.Len(t, got.Decl.ServiceAccounts, 1) + assert.Contains(t, got.Decl.ServiceAccounts[0].When, "the account always") +} diff --git a/pkg/linters/rbac/rules/rbacyaml/load_test.go b/pkg/linters/rbac/rules/rbacyaml/load_test.go index 431baab4..3579f004 100644 --- a/pkg/linters/rbac/rules/rbacyaml/load_test.go +++ b/pkg/linters/rbac/rules/rbacyaml/load_test.go @@ -18,6 +18,7 @@ package rbacyaml import ( "errors" + "fmt" "os" "path/filepath" "strings" @@ -666,3 +667,14 @@ resources: assert.Contains(t, got, `resources[1] (a.io/things): namespace.viewer: "bogus" is not a verb`) assert.NotContains(t, got, "resources[0] (a.io/things)") } + +// A TODO `when` is an open decision, not a malformed expression (review of #479, finding 51). +func TestValidateWhen_TODO(t *testing.T) { + var got []string + + validateWhen("TODO: write the condition", "serviceAccounts[0] (m)", func(format string, args ...any) { got = append(got, fmt.Sprintf(format, args...)) }) + + require.Len(t, got, 1) + assert.Contains(t, got[0], "is still undecided: a decision is needed") + assert.NotContains(t, got[0], "not a Helm expression") +} diff --git a/pkg/linters/rbac/rules/rbacyaml/validate.go b/pkg/linters/rbac/rules/rbacyaml/validate.go index 2e935b08..475b246f 100644 --- a/pkg/linters/rbac/rules/rbacyaml/validate.go +++ b/pkg/linters/rbac/rules/rbacyaml/validate.go @@ -57,6 +57,13 @@ func validateWhen(when, where string, report reporter) { return } + // A TODO is a decision nobody has made yet, not a malformed expression (review of #479, + // finding 51). + if strings.HasPrefix(when, NoAccessTODO) { + report("%s: when %q is still undecided: a decision is needed -- only a person can close this", where, when) + return + } + if _, err := template.New("when").Funcs(helmFuncs).Parse("{{ if " + when + " }}{{ end }}"); err != nil { report("%s: when %q is not a Helm expression: %v", where, when, err) } diff --git a/pkg/linters/rbac/rules/sync.go b/pkg/linters/rbac/rules/sync.go index f52f919b..9d4ce9b3 100644 --- a/pkg/linters/rbac/rules/sync.go +++ b/pkg/linters/rbac/rules/sync.go @@ -901,10 +901,21 @@ func hasAbsentObject(file generate.File, actual map[string]managedObject) bool { func compareFile(file generate.File, actual map[string]managedObject, module string) []string { var out []string + // A `when` excuses an absent object only while its condition is false: when another object of + // the file under the same `when` rendered, the condition holds in this render, and the absence + // is drift (review of #479, finding 47). + holds := map[string]bool{} + + for _, o := range file.Objects { + if _, ok := actual[o.Identity()]; ok && o.When != "" { + holds[o.When] = true + } + } + for _, expected := range file.Objects { act, ok := actual[expected.Identity()] if !ok { - if expected.When == "" { + if expected.When == "" || holds[expected.When] { out = append(out, fmt.Sprintf("%s is declared but absent from the render", expected.Identity())) } @@ -1285,7 +1296,6 @@ func (r *SyncRule) bootstrap(declList *errors.LintRuleErrorsList) { } o.Library = renderedByInclude(text, o.Kind, o.Name) - o.LibraryFile = holdsLibraryDocument(text) o.Repeated = renderedInRange(text, o.Kind, o.Name) in.Objects = append(in.Objects, o) } @@ -1295,6 +1305,8 @@ func (r *SyncRule) bootstrap(declList *errors.LintRuleErrorsList) { return } + markLibraryFiles(in.Objects) + result := bootstrap.Build(in) described := len(in.Objects) - len(result.Unmanaged) path := rbacyaml.Path(modulePath) @@ -1310,6 +1322,7 @@ func (r *SyncRule) bootstrap(declList *errors.LintRuleErrorsList) { } in.Objects = bootstrapObjectsOf(path) + markLibraryFiles(in.Objects) in.Partial = bootstrapPartialOf(path) result := bootstrap.Build(in) @@ -1935,26 +1948,20 @@ func renderedByInclude(content, kind, name string) bool { return include } -// holdsLibraryDocument reports whether the template has a document without a kind of its own -// that includes a named template: what such a file renders is partly the library's (review of -// #479, finding 42). -func holdsLibraryDocument(content string) bool { - for _, doc := range separatorRe.Split(strings.ReplaceAll(content, "\r\n", "\n"), -1) { - kind := false +// markLibraryFiles marks the objects of a template that also renders a library's objects: the +// render tells, not the text (review of #479, finding 50). +func markLibraryFiles(objects []bootstrap.Object) { + library := map[string]bool{} - for _, line := range strings.Split(doc, "\n") { - if kindLineRe.MatchString(line) { - kind = true - break - } - } - - if !kind && includeRe.MatchString(doc) { - return true + for _, o := range objects { + if o.Library { + library[o.Path] = true } } - return false + for i := range objects { + objects[i].LibraryFile = library[objects[i].Path] + } } // namesMatch reports whether a metadata name as the template writes it can be the rendered name: diff --git a/pkg/linters/rbac/rules/sync_regressions_test.go b/pkg/linters/rbac/rules/sync_regressions_test.go index 563349b2..b373c95b 100644 --- a/pkg/linters/rbac/rules/sync_regressions_test.go +++ b/pkg/linters/rbac/rules/sync_regressions_test.go @@ -577,9 +577,42 @@ metadata: assert.False(t, renderedInRange("{{ if }", "Role", "x"), "a template that does not parse tells nothing") } -// A template with a document that only includes a named template holds library objects (review of -// #479, finding 42). -func TestHoldsLibraryDocument(t *testing.T) { - assert.True(t, holdsLibraryDocument("{{- include \"helm_lib_csi_controller_rbac\" . }}\n---\nkind: ClusterRole\nmetadata:\n name: d8:m:csi\n")) - assert.False(t, holdsLibraryDocument("---\nkind: ClusterRole\nmetadata:\n name: d8:m:csi\n {{- include \"helm_lib_module_labels\" (list .) | nindent 2 }}\n")) +// A template that renders a library's objects marks its other objects: the render tells, so a +// define holding an include marks nothing (review of #479, finding 50). +func TestMarkLibraryFiles(t *testing.T) { + objects := []bootstrap.Object{ + {Kind: "ServiceAccount", Name: "csi", Path: "templates/csi/rbac-for-us.yaml", Library: true}, + {Kind: "ClusterRole", Name: "d8:m:csi", Path: "templates/csi/rbac-for-us.yaml"}, + {Kind: "ClusterRole", Name: "user-authz:m:user", Path: "templates/user-authz-cluster-roles.yaml"}, + } + + markLibraryFiles(objects) + + assert.True(t, objects[0].LibraryFile) + assert.True(t, objects[1].LibraryFile) + assert.False(t, objects[2].LibraryFile) +} + +// A `when` excuses an absent object only while its condition is false: when the account under the +// same `when` rendered, its missing ClusterRole and binding are drift (review of #479, finding 47). +func TestSyncRegression_WhenDoesNotExcuseAbsentSiblings(t *testing.T) { + resetFixState() + t.Cleanup(resetFixState) + + modulePath := syncModuleDir(t) + model := syncModel(t, modulePath) + writeGenerated(t, modulePath, model) + + store := renderedFrom(t, model, func(o *generate.Object) bool { + return o.When == "" || o.Kind == "ServiceAccount" + }) + + got := strings.Join(texts(runSync(t, modulePath, store)), "\n") + assert.Contains(t, got, "ClusterRole/d8:cert-manager:cainjector is declared but absent from the render") + + // With the condition false for the whole file, nothing is reported. + resetFixState() + + got = strings.Join(texts(runSync(t, modulePath, renderedFrom(t, model, func(o *generate.Object) bool { return o.When == "" }))), "\n") + assert.NotContains(t, got, "cainjector is declared but absent") } From cfd42f4975cf85627571aaa5e60d0b78639b167b Mon Sep 17 00:00:00 2001 From: Ivan Zvyagintsev Date: Thu, 24 Sep 2026 18:01:01 +0300 Subject: [PATCH 56/58] rbac: review of #479, findings 52, 53 and the 48 wording - 52: under --matrix the variants that rendered each object are kept; objects of an account that render in other variants than the account get a TODO saying no single `when` holds them, instead of asking for one (node-manager's cluster-autoscaler). - 53: a Role counts as an account's own, or an access entry's, only when that binding is its only one; a ClusterRole's bindings include the RoleBindings to it. A shared role stays hand-written with its bindings, whatever order the accounts come in. - 48: the note says to move the object to another component directory. Signed-off-by: Ivan Zvyagintsev --- pkg/linters/rbac/rules/bootstrap/bootstrap.go | 54 ++++++++++++++++-- .../rbac/rules/bootstrap/bootstrap_test.go | 57 ++++++++++++++++++- pkg/linters/rbac/rules/fixstate.go | 25 ++++++++ pkg/linters/rbac/rules/sync.go | 1 + 4 files changed, 132 insertions(+), 5 deletions(-) diff --git a/pkg/linters/rbac/rules/bootstrap/bootstrap.go b/pkg/linters/rbac/rules/bootstrap/bootstrap.go index c0e93b19..bcebcd87 100644 --- a/pkg/linters/rbac/rules/bootstrap/bootstrap.go +++ b/pkg/linters/rbac/rules/bootstrap/bootstrap.go @@ -71,6 +71,8 @@ type Input struct { // Partial are the objects (Kind/namespace/name) some render variants did not render: under // --matrix, the objects under a condition. The declaration has no `when` for them yet. Partial []string + // Variants names, per object (Kind/namespace/name), the render variants that rendered it. + Variants map[string]string } // Result is the declaration with the reader's homework. @@ -438,11 +440,26 @@ func (b *builder) ownClusterRole(o Object) bool { return o.Kind == "ClusterRole" && !o.Aggregated && o.Labels[rbaccontract.LabelModule] == b.in.Module && o.Labels[rbaccontract.LabelKind] == "" && o.Annotations[rbaccontract.AccessLevelAnnotation] == "" } +// bindingsOf lists every binding of the ClusterRole: ClusterRoleBindings and RoleBindings that +// refer to it (review of #479, finding 53). func (b *builder) bindingsOf(name string) []Object { out := make([]Object, 0, len(b.in.Objects)) for _, o := range b.in.Objects { - if o.Kind == "ClusterRoleBinding" && o.RoleRef.Name == name { + if (o.Kind == "ClusterRoleBinding" || (o.Kind == "RoleBinding" && o.RoleRef.Kind == "ClusterRole")) && o.RoleRef.Name == name { + out = append(out, o) + } + } + + return out +} + +// roleBindingsOf lists the RoleBindings of a Role. +func (b *builder) roleBindingsOf(namespace, name string) []Object { + out := make([]Object, 0, len(b.in.Objects)) + + for _, o := range b.in.Objects { + if o.Kind == "RoleBinding" && o.RoleRef.Kind == "Role" && o.RoleRef.Name == name && b.ns(o) == namespace { out = append(out, o) } } @@ -553,7 +570,8 @@ func (b *builder) serviceAccounts() { continue } - if role, ok := b.role(b.ns(rb), rb.RoleRef.Name); ok && !b.isUsed(role) && b.ns(rb) == b.in.Namespace && e.NamespaceRules == nil && rb.RoleRef.Kind == "Role" { + // A Role another binding also uses is not the account's own (finding 53). + if role, ok := b.role(b.ns(rb), rb.RoleRef.Name); ok && !b.isUsed(role) && b.ns(rb) == b.in.Namespace && e.NamespaceRules == nil && rb.RoleRef.Kind == "Role" && len(b.roleBindingsOf(b.ns(rb), role.Name)) == 1 { e.NamespaceRules = policyRules(role.Rules) b.rename("Role", role.Name, sa.Name) b.rename("RoleBinding", rb.Name, sa.Name) @@ -606,7 +624,19 @@ func (b *builder) serviceAccounts() { } } + // Objects that render in other variants than the account share no condition with it: the + // declaration's one `when` for the account cannot hold them (review of #479, finding 52). + var apartFromAccount []string + + for _, o := range b.current { + if o.identity() != sa.identity() && b.variantsOf(o) != b.variantsOf(sa) { + apartFromAccount = append(apartFromAccount, o.Kind+" "+o.Name) + } + } + switch { + case len(apartFromAccount) > 0: + e.When = "TODO: " + strings.Join(apartFromAccount, ", ") + " render in other variants than ServiceAccount " + sa.Name + ", so no single `when` holds for the account's objects -- keep them hand-written, or split the account" case len(partial) > 0 && b.partial(sa): e.When = "TODO: " + strings.Join(partial, ", ") + " render only under some of the linted values; write the condition they render under" case len(partial) > 0: @@ -632,6 +662,12 @@ func (b *builder) otherBindings() { continue } + // A ClusterRole several bindings use is no single entry's own (finding 53). + if len(b.bindingsOf(cr.Name)) != 1 { + b.unmanage(crb, fmt.Sprintf("binds %s, which other bindings use too; it stays hand-written with them", crb.RoleRef.Name)) + continue + } + if b.unmanagePartial(cr, crb) { continue } @@ -650,6 +686,11 @@ func (b *builder) otherBindings() { } role, ok := b.role(b.ns(rb), rb.RoleRef.Name) + if ok && !b.isUsed(role) && rb.RoleRef.Kind == "Role" && len(b.roleBindingsOf(b.ns(rb), role.Name)) != 1 { + b.unmanage(rb, fmt.Sprintf("binds the Role %s, which other bindings use too; it stays hand-written with them", role.Name)) + continue + } + if !ok || b.isUsed(role) || b.ns(rb) != b.in.Namespace || rb.RoleRef.Kind != "Role" { b.unmanage(rb, fmt.Sprintf("binds %s/%s outside the module's own Roles", rb.RoleRef.Kind, rb.RoleRef.Name)) continue @@ -935,6 +976,11 @@ func (b *builder) markAccount(o Object) { b.current = append(b.current, o) } +// variantsOf names the render variants that rendered the object; empty without --matrix. +func (b *builder) variantsOf(o Object) string { + return b.in.Variants[o.Kind+"/"+o.Namespace+"/"+o.Name] +} + // partial reports whether some render variants did not render the object. func (b *builder) partial(o Object) bool { return b.partialIDs[o.Kind+"/"+o.Namespace+"/"+o.Name] @@ -1039,7 +1085,7 @@ func (b *builder) sharedWithDeclared() { for _, kept := range b.partialKept { for _, o := range b.in.Objects { if o.Path == kept.Path && b.isUsed(o) && !b.unmanagedIDs[o.identity()] { - b.note("%s %s stays hand-written in %s, which the declaration also writes: move it to a file of its own (templates//rbac-for-us.yaml) before `--fix`, or regenerating %s drops it", kept.Kind, kept.Name, kept.Path, kept.Path) + b.note("%s %s stays hand-written in %s, which the declaration also writes: move it to the rbac-for-us.yaml of another component directory before `--fix` (the placement rule accepts it in any), or regenerating %s drops it", kept.Kind, kept.Name, kept.Path, kept.Path) break } @@ -1067,7 +1113,7 @@ func (b *builder) setAsideAccount(sa Object, why string) { b.unmanage(cr, "bound only to "+sa.Name+", which stays hand-written") b.mark(cr) } - } else if role, ok := b.role(b.ns(binding), binding.RoleRef.Name); ok && !b.isUsed(role) { + } else if role, ok := b.role(b.ns(binding), binding.RoleRef.Name); ok && !b.isUsed(role) && len(b.roleBindingsOf(b.ns(binding), role.Name)) == 1 { b.unmanage(role, "bound to "+sa.Name+", which stays hand-written") b.mark(role) } diff --git a/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go b/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go index 1d933248..a396f5b8 100644 --- a/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go +++ b/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go @@ -581,7 +581,7 @@ func TestBuild_PartialObjectBesideDeclaredOnes(t *testing.T) { }, Partial: []string{"ClusterRoleBinding//d8:m:supplement"}}) notes := strings.Join(got.Notes, "\n") - assert.Contains(t, notes, "ClusterRoleBinding d8:m:supplement stays hand-written in templates/rbac-for-us.yaml, which the declaration also writes: move it to a file of its own") + assert.Contains(t, notes, "ClusterRoleBinding d8:m:supplement stays hand-written in templates/rbac-for-us.yaml, which the declaration also writes: move it to the rbac-for-us.yaml of another component directory") assert.Contains(t, notes, "ClusterRole d8:m:supplement stays hand-written in templates/rbac-for-us.yaml") } @@ -619,3 +619,58 @@ func TestBuild_PartialObjectOfAnAlwaysRenderedAccount(t *testing.T) { require.Len(t, got.Decl.ServiceAccounts, 1) assert.Contains(t, got.Decl.ServiceAccounts[0].When, "the account always") } + +// A Role two accounts bind is neither account's own: it is not absorbed into one account's +// namespaceRules, whatever order the accounts come in, and the hand-written binding of the other +// keeps pointing at a Role that stays (review of #479, finding 53). +func TestBuild_SharedRoleIsNoAccountsOwn(t *testing.T) { + labels := map[string]string{"module": "m"} + secrets := []rbacv1.PolicyRule{{APIGroups: []string{""}, Resources: []string{"secrets"}, Verbs: []string{"get"}}} + + for _, names := range [][2]string{{"a", "z"}, {"z", "a"}} { + declared, other := names[0], names[1] + + got := Build(Input{Module: "m", Namespace: "d8-m", Objects: []Object{ + {Kind: "ServiceAccount", Name: declared, Namespace: "d8-m", Path: "templates/rbac-for-us.yaml", Labels: labels}, + {Kind: "ServiceAccount", Name: other, Namespace: "d8-other", Path: "templates/rbac-for-us.yaml", Labels: labels}, + {Kind: "Role", Name: "shared", Namespace: "d8-m", Path: "templates/rbac-for-us.yaml", Labels: labels, Rules: secrets}, + {Kind: "RoleBinding", Name: declared, Namespace: "d8-m", Path: "templates/rbac-for-us.yaml", Labels: labels, + RoleRef: rbacv1.RoleRef{Kind: "Role", Name: "shared"}, Subjects: []rbacv1.Subject{{Kind: "ServiceAccount", Name: declared, Namespace: "d8-m"}}}, + {Kind: "RoleBinding", Name: other, Namespace: "d8-m", Path: "templates/rbac-for-us.yaml", Labels: labels, + RoleRef: rbacv1.RoleRef{Kind: "Role", Name: "shared"}, Subjects: []rbacv1.Subject{{Kind: "ServiceAccount", Name: other, Namespace: "d8-other"}}}, + }}) + + require.Len(t, got.Decl.ServiceAccounts, 1, names) + assert.Empty(t, got.Decl.ServiceAccounts[0].NamespaceRules, "%v: the shared Role is not absorbed", names) + assert.Contains(t, strings.Join(got.Unmanaged, "\n"), "d8-m/Role/shared", names) + } +} + +// Objects of one account that render in other variants than the account -- the cluster-autoscaler +// shape, two mutually exclusive sets -- get a TODO that says no single `when` holds them, rather +// than asking for one (review of #479, finding 52). +func TestBuild_AccountObjectsInOtherVariants(t *testing.T) { + labels := map[string]string{"module": "m"} + sa := []rbacv1.Subject{{Kind: "ServiceAccount", Name: "autoscaler", Namespace: "d8-m"}} + + got := Build(Input{Module: "m", Namespace: "d8-m", Objects: []Object{ + {Kind: "ServiceAccount", Name: "autoscaler", Namespace: "d8-m", Path: "templates/autoscaler/rbac-for-us.yaml", Labels: labels}, + {Kind: "ClusterRoleBinding", Name: "d8:m:autoscaler:plain", Path: "templates/autoscaler/rbac-for-us.yaml", Labels: labels, + RoleRef: rbacv1.RoleRef{Kind: "ClusterRole", Name: "d8:rbac-proxy"}, Subjects: sa}, + {Kind: "ClusterRoleBinding", Name: "d8:m:autoscaler:mcm", Path: "templates/autoscaler/rbac-for-us.yaml", Labels: labels, + RoleRef: rbacv1.RoleRef{Kind: "ClusterRole", Name: "d8:mcm"}, Subjects: sa}, + }, + Partial: []string{"ClusterRoleBinding//d8:m:autoscaler:plain", "ClusterRoleBinding//d8:m:autoscaler:mcm"}, + Variants: map[string]string{ + "ServiceAccount/d8-m/autoscaler": "1,2,", + "ClusterRoleBinding//d8:m:autoscaler:plain": "1,", + "ClusterRoleBinding//d8:m:autoscaler:mcm": "2,", + }}) + + require.Len(t, got.Decl.ServiceAccounts, 1) + when := got.Decl.ServiceAccounts[0].When + assert.True(t, strings.HasPrefix(when, "TODO: "), when) + assert.Contains(t, when, "render in other variants than ServiceAccount autoscaler, so no single `when` holds") + assert.Contains(t, when, "ClusterRoleBinding d8:m:autoscaler:plain") + assert.Contains(t, when, "ClusterRoleBinding d8:m:autoscaler:mcm") +} diff --git a/pkg/linters/rbac/rules/fixstate.go b/pkg/linters/rbac/rules/fixstate.go index 30fba458..c4299edf 100644 --- a/pkg/linters/rbac/rules/fixstate.go +++ b/pkg/linters/rbac/rules/fixstate.go @@ -17,6 +17,7 @@ limitations under the License. package rules import ( + "fmt" "maps" "os" "path/filepath" @@ -52,6 +53,8 @@ var fixState = struct { // rendered each object: under --matrix an object seen in fewer renders only under some values. variants map[string]int seen map[string]map[string]int + // in names, per object, the variants that rendered it (review of #479, finding 52). + in map[string]map[string]string }{ foreign: map[string]map[string]struct{}{}, removals: map[string]map[string]struct{}{}, @@ -60,6 +63,7 @@ var fixState = struct { bootstrap: map[string]map[string]bootstrap.Object{}, variants: map[string]int{}, seen: map[string]map[string]int{}, + in: map[string]map[string]string{}, } // fixOutcomes remembers the result of every fix that ran, by file. It has a lock of its own, held @@ -132,6 +136,7 @@ func resetFixState() { fixState.bootstrap = map[string]map[string]bootstrap.Object{} fixState.variants = map[string]int{} fixState.seen = map[string]map[string]int{} + fixState.in = map[string]map[string]string{} fixOutcomes.Lock() defer fixOutcomes.Unlock() @@ -154,12 +159,14 @@ func recordBootstrapObjects(path string, objects []bootstrap.Object) { fixState.variants[path]++ if fixState.seen[path] == nil { fixState.seen[path] = map[string]int{} + fixState.in[path] = map[string]string{} } for _, o := range objects { key := o.Kind + "/" + o.Namespace + "/" + o.Name known[key] = o fixState.seen[path][key]++ + fixState.in[path][key] += fmt.Sprintf("%d,", fixState.variants[path]) } } @@ -394,3 +401,21 @@ func bootstrapPartialOf(path string) []string { return out } + +// bootstrapVariantsOf names, per object (Kind/namespace/name), the render variants that rendered +// it; empty without --matrix. +func bootstrapVariantsOf(path string) map[string]string { + fixState.Lock() + defer fixState.Unlock() + + if fixState.variants[path] < 2 { + return nil + } + + out := make(map[string]string, len(fixState.in[path])) + for key, in := range fixState.in[path] { + out[key] = in + } + + return out +} diff --git a/pkg/linters/rbac/rules/sync.go b/pkg/linters/rbac/rules/sync.go index 9d4ce9b3..2ae9dd3e 100644 --- a/pkg/linters/rbac/rules/sync.go +++ b/pkg/linters/rbac/rules/sync.go @@ -1324,6 +1324,7 @@ func (r *SyncRule) bootstrap(declList *errors.LintRuleErrorsList) { in.Objects = bootstrapObjectsOf(path) markLibraryFiles(in.Objects) in.Partial = bootstrapPartialOf(path) + in.Variants = bootstrapVariantsOf(path) result := bootstrap.Build(in) content, err := bootstrap.Marshal(result) From c1d2a0a34dcc12f6acf849df8256389fa0f9ff07 Mon Sep 17 00:00:00 2001 From: Ivan Zvyagintsev Date: Fri, 25 Sep 2026 13:33:59 +0300 Subject: [PATCH 57/58] test(e2e): link the rbac cases to the shared helm_lib archive The four rbac cases that render the generated templates carried their own copy of deckhouse_lib_helm-1.72.1.tgz; they now symlink test/e2e/lib like the container case does. Signed-off-by: Ivan Zvyagintsev --- .../charts/deckhouse_lib_helm-1.72.1.tgz | Bin 45549 -> 48 bytes .../charts/deckhouse_lib_helm-1.72.1.tgz | Bin 45549 -> 48 bytes .../charts/deckhouse_lib_helm-1.72.1.tgz | Bin 45549 -> 48 bytes .../charts/deckhouse_lib_helm-1.72.1.tgz | Bin 45549 -> 48 bytes 4 files changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 120000 test/e2e/testdata/rbac/bootstrap-writes-declaration/module/charts/deckhouse_lib_helm-1.72.1.tgz mode change 100644 => 120000 test/e2e/testdata/rbac/sync-clean/module/charts/deckhouse_lib_helm-1.72.1.tgz mode change 100644 => 120000 test/e2e/testdata/rbac/sync-fix-regenerates/module/charts/deckhouse_lib_helm-1.72.1.tgz mode change 100644 => 120000 test/e2e/testdata/rbac/sync-hand-edited/module/charts/deckhouse_lib_helm-1.72.1.tgz diff --git a/test/e2e/testdata/rbac/bootstrap-writes-declaration/module/charts/deckhouse_lib_helm-1.72.1.tgz b/test/e2e/testdata/rbac/bootstrap-writes-declaration/module/charts/deckhouse_lib_helm-1.72.1.tgz deleted file mode 100644 index 3f56d38ec75c51bc2f29d3a7a75a4e3ff760550e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 45549 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 Date: Tue, 29 Sep 2026 15:18:07 +0300 Subject: [PATCH 58/58] rbac: review of #479, the orphaned TODO and the dead .tpl case The TODO about the entries after d8-system moves onto rbaccontract.DeckhouseNamespaces, where the list now lives. The .tpl case of the no-cyrillic YAML check is dropped: .tpl files never reach checkFile, and checking them would be a behaviour change of its own. Signed-off-by: Ivan Zvyagintsev --- pkg/linters/no-cyrillic/rules/files.go | 8 ++++---- pkg/linters/rbac/rules/placement.go | 2 -- pkg/linters/rbac/rules/rbaccontract/contract.go | 10 +++------- 3 files changed, 7 insertions(+), 13 deletions(-) diff --git a/pkg/linters/no-cyrillic/rules/files.go b/pkg/linters/no-cyrillic/rules/files.go index b52e58ce..8d8759e6 100644 --- a/pkg/linters/no-cyrillic/rules/files.go +++ b/pkg/linters/no-cyrillic/rules/files.go @@ -131,7 +131,7 @@ func (r *FilesRule) checkFile(fileName string) { return } - if isYAMLTemplate(fileName) { + if isYAMLFile(fileName) { lines = r.withoutLocalizedAnnotations(lines) } @@ -185,10 +185,10 @@ func (r *FilesRule) withoutLocalizedAnnotations(lines []string) []string { return out } -// isYAMLTemplate reports whether the file is one the localized annotations can live in. -func isYAMLTemplate(fileName string) bool { +// isYAMLFile reports whether the file is one the localized annotations can live in. +func isYAMLFile(fileName string) bool { switch filepath.Ext(fileName) { - case ".yaml", ".yml", ".tpl": + case ".yaml", ".yml": return true } diff --git a/pkg/linters/rbac/rules/placement.go b/pkg/linters/rbac/rules/placement.go index c1253e75..a27be36c 100644 --- a/pkg/linters/rbac/rules/placement.go +++ b/pkg/linters/rbac/rules/placement.go @@ -66,8 +66,6 @@ const ( RBACv2Path = "templates/rbac" ) -// TODO: remove entries after 'd8-system' after fixing RBAC objects names - func isSystemNamespace(actual string) bool { return actual == metav1.NamespaceDefault || actual == metav1.NamespaceSystem } diff --git a/pkg/linters/rbac/rules/rbaccontract/contract.go b/pkg/linters/rbac/rules/rbaccontract/contract.go index 8d65c0c4..31fd79d2 100644 --- a/pkg/linters/rbac/rules/rbaccontract/contract.go +++ b/pkg/linters/rbac/rules/rbaccontract/contract.go @@ -269,15 +269,11 @@ func IsLegacyKind(kind string) bool { // DeckhouseNamespaces are the namespaces the placement rule treats as the platform's own: there an // account of templates// may carry the module name in front of the directory. +// +// TODO: remove the entries after d8-system once the RBAC object names are fixed. var DeckhouseNamespaces = []string{"d8-monitoring", "d8-system", "d8-admission-policy-engine", "d8-operator-trivy", "d8-log-shipper", "d8-local-path-provisioner"} // IsDeckhouseNamespace reports whether the namespace is one of DeckhouseNamespaces. func IsDeckhouseNamespace(ns string) bool { - for _, n := range DeckhouseNamespaces { - if n == ns { - return true - } - } - - return false + return slices.Contains(DeckhouseNamespaces, ns) }