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/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/metrics/metrics_test.go b/internal/metrics/metrics_test.go index 803c1d71..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.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..0879eb1c 100644 --- a/internal/modules/module.go +++ b/internal/modules/module.go @@ -229,10 +229,35 @@ 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. 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 + // 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. + fallback := pkg.Warn.String() + if impact := configSettings.Rbac.Impact; impact != "" && pkg.ParseStringToLevel(impact) < pkg.Warn { + fallback = impact + } + + 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 func mapContainerRules(linterSettings *pkg.LintersSettings, configSettings *config.LintersSettings, globalConfig *global.Linters) { linterSettings.Container.Rules.RecommendedLabelsRule.SetLevel( @@ -582,6 +607,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/internal/modules/rbac_rules_config_test.go b/internal/modules/rbac_rules_config_test.go new file mode 100644 index 00000000..383f4827 --- /dev/null +++ b/internal/modules/rbac_rules_config_test.go @@ -0,0 +1,85 @@ +/* +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, warn as the 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.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{ + &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("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{Impact: pkg.Ignored.String()}}, + &global.Linters{Rbac: global.RBACLinterConfig{Rules: global.RBACRules{ + SyncRule: global.RuleConfig{Impact: pkg.Error.String()}, + }}}, + ) + + 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) { + 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/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.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..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 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/config/loader.go b/pkg/config/loader.go index a3fc8c37..2dd59d83 100644 --- a/pkg/config/loader.go +++ b/pkg/config/loader.go @@ -20,9 +20,12 @@ import ( "errors" "fmt" "log/slog" + "maps" "os" "path/filepath" "slices" + "sort" + "strings" "github.com/mitchellh/go-homedir" "github.com/mitchellh/mapstructure" @@ -150,7 +153,101 @@ func (l *Loader) parseConfig() error { return fmt.Errorf("can't unmarshal config by viper (flags, file): %w", err) } - return nil + return validateRbacKeys(l.viper) +} + +// 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": {}}, + "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 { + var problems []string + + for _, path := range slices.Sorted(maps.Keys(rbacKnownKeys)) { + 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)) + } + } + } + + for _, path := range slices.Sorted(maps.Keys(rbacKnownListKeys)) { + list, ok := v.Get(path).([]any) + if !ok { + continue + } + + 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 + } + + problems = append(problems, unknownKeys(entry, rbacKnownListKeys[path], fmt.Sprintf("%s[%d]", path, i))...) + } + } + + if len(problems) == 0 { + return nil + } + + 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 + + for key := range block { + if _, ok := known[key]; !ok { + unknown = append(unknown, key) + } + } + + if len(unknown) == 0 { + return nil + } + + sort.Strings(unknown) + + 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 new file mode 100644 index 00000000..8ddc4fbf --- /dev/null +++ b/pkg/config/rbac_keys_test.go @@ -0,0 +1,117 @@ +/* +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) { + // 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) rules under "linters-settings.rbac"`) + }) + + 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(), `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) { + 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("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/errors/lint_errors.go b/pkg/errors/lint_errors.go index fdf6c40e..de32769f 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: ignored keeps its autofix off with it. + if l.storage.errList[idx].fix == nil || l.storage.errList[idx].Level == pkg.Ignored { continue } @@ -276,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 new file mode 100644 index 00000000..eac2969b --- /dev/null +++ b/pkg/errors/lint_errors_fixes_test.go @@ -0,0 +1,72 @@ +/* +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 ( + stderrors "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/utils/ptr" + + "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) +} + +// 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/no-cyrillic/rules/files.go b/pkg/linters/no-cyrillic/rules/files.go index a4bfb1d5..8d8759e6 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" @@ -36,9 +37,20 @@ var ( // what fsutils.FilterFileByExtensions compares against. fileExtensions = []string{".yaml", ".yml", ".json", ".go"} + // 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|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. + // 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, @@ -52,11 +64,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 +77,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 @@ -98,7 +112,7 @@ func (r *FilesRule) checkFile(fileName string) { return } - if r.skipDocRe.MatchString(fileName) { + if r.skipDocRe.MatchString(fileName) || fName == "rbac.yaml" { return } @@ -117,6 +131,10 @@ func (r *FilesRule) checkFile(fileName string) { return } + if isYAMLFile(fileName) { + lines = r.withoutLocalizedAnnotations(lines) + } + cyrMsg, hasCyr := checkCyrillicLettersInArray(lines) if hasCyr { errorList.WithFilePath(fName).WithValue(cyrMsg). @@ -134,3 +152,45 @@ 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)) + + // 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 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 + } + + out = append(out, line) + } + + return out +} + +// 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": + 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 2f4669c5..0e38468d 100644 --- a/pkg/linters/no-cyrillic/rules/files_test.go +++ b/pkg/linters/no-cyrillic/rules/files_test.go @@ -430,3 +430,109 @@ 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) + } + + // 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 +// 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/README.md b/pkg/linters/rbac/README.md index e2fe1a7c..921f047b 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, 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. @@ -1013,7 +1016,7 @@ 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 the root does not set exclude-rules: binding-subject: - legacy-sa @@ -1340,3 +1343,388 @@ 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. +# 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 + 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-); `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 +# (namespaceRules with a path are refused: the placement rule wants access-to-- names there) +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, 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. +- 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. `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. + +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. + +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 +`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; 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). +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. 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. + +**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: 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: + 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. +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 -- + +```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 without the file is a `sync` finding, and `--fix` of +that rule writes the first declaration from the render (see [sync](#sync)). + +**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:** + +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. 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 +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 }}`, 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 +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 +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. + +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: + +1. legacy roles -- ClusterRoles with the `user-authz.deckhouse.io/access-level` annotation; +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"`, +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 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. +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, 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 (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` 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; + +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. + +**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 +``` + +**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. 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 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. +- 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: 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 df8fa92d..369d0003 100644 --- a/pkg/linters/rbac/rbac.go +++ b/pkg/linters/rbac/rbac.go @@ -60,15 +60,24 @@ 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)), + rules.NewSyncRule(l.cfg.ExcludeRules.Sync.Get(), m, level(l.cfg.Rules.SyncRule)), } } diff --git a/pkg/linters/rbac/rules/bootstrap/bootstrap.go b/pkg/linters/rbac/rules/bootstrap/bootstrap.go new file mode 100644 index 00000000..bcebcd87 --- /dev/null +++ b/pkg/linters/rbac/rules/bootstrap/bootstrap.go @@ -0,0 +1,1122 @@ +/* +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" + "maps" + "regexp" + "slices" + "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 + // 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 + // 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. +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 + // 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. +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 + // 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 + // 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 + // 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 + // 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 + // prometheusFolded is set once the note on folding several scrape Roles is written. + prometheusFolded bool +} + +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.unmanagedIDs[o.identity()] = true + 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 { + // 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, 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 { + b.partialIDs[p] = true + } + + b.setAside() + b.capabilitiesAndLegacy() + b.serviceAccounts() + b.otherBindings() + b.leftovers() + b.resources() + b.dropped() + b.sharedWithDeclared() + + return Result{Decl: b.decl, Notes: b.notes, Unmanaged: b.unmanaged} +} + +// 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"}, +} + +// 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 { + 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 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. 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) + } + + continue + } + + key := [2]string{g, rs} + + 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) + case rbaccontract.LineageSystem: + addVerbs(b.res[key].system, level, r.Verbs) + default: + addVerbs(b.res[key].legacy, level, r.Verbs) + } + } + } + } +} + +// 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.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) + 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" || b.isUsed(o) { + continue + } + + 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 + } + + 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.partialGrant(o) + 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 := 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.partialGrant(o) + 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 { + out := make([]Object, 0, len(b.in.Objects)) + + 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.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.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) + } + } + + 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.isUsed(sa) { + continue + } + + if b.ns(sa) != b.in.Namespace { + b.unmanage(sa, "outside the module namespace") + b.markAccount(sa) + + continue + } + + e := rbacyaml.ServiceAccount{Name: sa.Name} + b.current = nil + + 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) + } + + 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.markAccount(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.isUsed(cr) && 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)) + 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)) + } + + if exclusive { + b.markAccount(cr) + } + + b.markAccount(crb) + } + + for _, rb := range b.byKind("RoleBinding") { + if b.isUsed(rb) || !subjectsAreOnly(rb, sa.Name, b.in.Namespace) { + continue + } + + // 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) + 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 + // 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)) + } + + b.markAccount(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.markAccount(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) + } + + // 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) + } + } + + // 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: + // 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) + } +} + +// 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.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 + } + + // 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 + } + + 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.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 + } + + if b.unmanagePartial(role, rb) || b.prometheus(role, rb) { + 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, 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 + } + } + } + + // 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) + } + + 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: + // 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" + default: + if s, ok := rbacyaml.WellKnownScope(group, base); ok { + scope = s + } else { + // 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, "/") { + 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" + } + + 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 + // 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, "; ")) + } + } + + if !e.HasLevels() && e.NoAccess == "" { + 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 { + // 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"}) + } + } + + 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 +} + +// 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 +} + +// 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 + 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] +} + +// 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). +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) + b.partialKept = append(b.partialKept, 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 + } + + 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, ", ")) + } + } +} + +// 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 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 + } + } + } +} + +// 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) && 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 new file mode 100644 index 00000000..a396f5b8 --- /dev/null +++ b/pkg/linters/rbac/rules/bootstrap/bootstrap_test.go @@ -0,0 +1,676 @@ +/* +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" + "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/generate" + "github.com/deckhouse/dmt/pkg/linters/rbac/rules/rbaccontract" + "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 { + out := make([]Object, 0, len(model.Files)) + + 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) + + 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) + 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. +// 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.Equal(t, []string{"list"}, byKey["/secrets"].Namespace["viewer"], "get was limited to m-token and is not widened") + + 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. +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{ + // 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, "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, "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) + 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], "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") +} + +// 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") +} + +// 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") +} + +// 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)) +} + +// 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"} + 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: "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}, + }}) + + assert.Len(t, got.Unmanaged, 1, "got: %v", got.Unmanaged) + + 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")) +} + +// 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"})) +} + +// 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") +} + +// 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)") + // 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) +} + +// 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) +} + +// 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 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") +} + +// 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") +} + +// 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/bootstrap/marshal.go b/pkg/linters/rbac/rules/bootstrap/marshal.go new file mode 100644 index 00000000..7b8b4aab --- /dev/null +++ b/pkg/linters/rbac/rules/bootstrap/marshal.go @@ -0,0 +1,83 @@ +/* +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") + 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") + + for _, n := range r.Notes { + head.WriteString(commentLines("# - ", 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(commentLines("# - ", u)) + } + } + + 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 +} + +// 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") + + 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/contract.go b/pkg/linters/rbac/rules/contract.go new file mode 100644 index 00000000..f5270203 --- /dev/null +++ b/pkg/linters/rbac/rules/contract.go @@ -0,0 +1,413 @@ +/* +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" + "maps" + "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 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 +// 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() }) + + // 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()) + + 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 + } + + // 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) + } +} + +// 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) + + // 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 { + 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, module string, 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) + } + + // 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) + } + } + + 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 slices.Sorted(maps.Keys(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 + } + + // 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 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" { + 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 slices.Sorted(maps.Keys(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) + } + + // 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) + } + + 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" { + 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{}{} + + 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 new file mode 100644 index 00000000..8bf0c032 --- /dev/null +++ b/pkg/linters/rbac/rules/contract_test.go @@ -0,0 +1,293 @@ +/* +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" + "maps" + "slices" + "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) + // 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() + 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 slices.Sorted(maps.Keys(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{"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", + }, 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{"module": "cert-manager", + "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{"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 + 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{"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"), + 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{"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"), + 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{"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"), + 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{"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{ + `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{"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{ + `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{"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"), + wantErrs: []string{ + `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"`, + }, + }, + "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", + "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{"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{ + `error: label rbac.deckhouse.io/scope must be one of namespace/project/subsystem/system, 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{"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", + }, 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) +} + +// 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})) + }) +} + +// 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 new file mode 100644 index 00000000..1c5de9c5 --- /dev/null +++ b/pkg/linters/rbac/rules/coverage.go @@ -0,0 +1,297 @@ +/* +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" + "strings" + + "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 || 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 + } + + 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)) + 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 + } + + 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 + } + + 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 + // misspelling (R11). Whole-group and subresource entries are exempt: CRDs describe neither. + for _, res := range decl.Resources { + if res.IsWildcard() || res.IsSubresource() || !r.Enabled(res.Key()) { + continue + } + + 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 + } + + 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) + } + } +} + +// 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 +// 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 { + path := rbacyaml.Path(modulePath) + + 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) + }) + } +} + +// 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, writeFileAtomic(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..b9d9f1c6 --- /dev/null +++ b/pkg/linters/rbac/rules/coverage_test.go @@ -0,0 +1,361 @@ +/* +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, skipped := moduleCRDs(modulePath) + require.Empty(t, skipped) + + 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) { + resetFixState() + t.Cleanup(resetFixState) + + 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 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); the open + // decision fails its fix too, so the run does not end green. + fixes := errorList.GetFixes() + require.Len(t, fixes, 3, "the two missing entries write stubs, the open decision only fails") + + 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.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, 3, 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"), `(noAccess: "TODO")`)) + + // 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) + for _, fix := range third.GetFixes() { + fix() + } + + 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"), + 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) +} + +// 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") +} + +// 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") +} + +// 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") +} + +// 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/crds.go b/pkg/linters/rbac/rules/crds.go new file mode 100644 index 00000000..1a60637f --- /dev/null +++ b/pkg/linters/rbac/rules/crds.go @@ -0,0 +1,130 @@ +/* +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 + skipped []error + ) + + for _, file := range fsutils.GetFiles(modulePath, true, filterCRDFiles) { + data, err := os.ReadFile(file) + if err != nil { + skipped = append(skipped, fmt.Errorf("read %s: %w", fsutils.Rel(modulePath, file), err)) + continue + } + + for _, doc := range fsutils.SplitManifests(string(data)) { + if strings.TrimSpace(doc) == "" { + 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 { + skipped = append(skipped, fmt.Errorf("parse %s: %w", fsutils.Rel(modulePath, file), err)) + continue + } + + 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.SliceStable(out, func(i, j int) bool { + if out[i].Group != out[j].Group { + return out[i].Group < out[j].Group + } + + if out[i].Plural != out[j].Plural { + return out[i].Plural < out[j].Plural + } + + return out[i].File < out[j].File + }) + + return out, skipped +} diff --git a/pkg/linters/rbac/rules/fixstate.go b/pkg/linters/rbac/rules/fixstate.go new file mode 100644 index 00000000..c4299edf --- /dev/null +++ b/pkg/linters/rbac/rules/fixstate.go @@ -0,0 +1,421 @@ +/* +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" + "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" +) + +// 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; +// - 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 + 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 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 + // 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{}{}, + 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{}, + 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 +// 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. +func fixOnce(key string, fix func() error) error { + fixOutcomes.Lock() + defer fixOutcomes.Unlock() + + if err, done := fixOutcomes.done[key]; done { + return err + } + + err := fix() + fixOutcomes.done[key] = err + + return err +} + +// 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() + defer fixState.Unlock() + + fixState.foreign = map[string]map[string]struct{}{} + fixState.removals = map[string]map[string]struct{}{} + 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{} + fixState.in = map[string]map[string]string{} + + fixOutcomes.Lock() + defer fixOutcomes.Unlock() + + fixOutcomes.done = map[string]error{} +} + +// 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 + } + + 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]) + } +} + +// 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", +// "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": + // 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 !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 "" + } +} + +// 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 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 + } + + // 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) +} + +// 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 +} + +// 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 +} + +// 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.blocked[file] + if known == nil { + known = map[string]struct{}{} + fixState.blocked[file] = known + } + + for _, o := range objects { + known[o] = struct{}{} + } +} + +// blockedBy returns, sorted, what keeps the file from being regenerated. +func blockedBy(file string) []string { + fixState.Lock() + defer fixState.Unlock() + + return slices.Sorted(maps.Keys(fixState.blocked[file])) +} + +// 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 +} + +// 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 +} + +// 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/generate/generate_test.go b/pkg/linters/rbac/rules/generate/generate_test.go new file mode 100644 index 00000000..d9f0d285 --- /dev/null +++ b/pkg/linters/rbac/rules/generate/generate_test.go @@ -0,0 +1,512 @@ +/* +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 ( + "fmt" + "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" +) + +// 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" + // Accounts of component directories cannot live in default (README, limits). + in.Decl.ServiceAccounts = nil + 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, rbaccontract.ContractVersion, version) + } +} + +func TestParseHeader(t *testing.T) { + 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) + assert.Equal(t, "0", version) + + 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") + } + } +} + +// 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" after its directory`) + + 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") + + // 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) { + 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. + 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) + }) +} + +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", + "": `""`, + "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) + } +} + +// 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) { + 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") +} + +// 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 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") + assert.True(t, generated) + 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 new file mode 100644 index 00000000..1997a0bc --- /dev/null +++ b/pkg/linters/rbac/rules/generate/model.go @@ -0,0 +1,647 @@ +/* +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 ( + "errors" + "fmt" + "slices" + "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, errors.New("no declaration") + } + + if in.Module == "" { + return nil, errors.New("the module name is required") + } + + if err := checkAgainstModule(in); err != nil { + return nil, err + } + + 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 { + for i := range f.Objects { + liftRuleConditions(&f.Objects[i]) + } + + model.Files = append(model.Files, *f) + } + + 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. + // 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 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()] = 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) + } + } + } + + 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) + } + + // 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). + 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) + } + } + + return 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 := 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, + }) + + 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 _, 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 + ":" + 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 + ":" + rbaccontract.BindingSuffix(ref.Name), Namespace: ref.Namespace, Class: ClassDeclared, When: sa.When, Labels: labels, + RoleRefKind: "Role", RoleRefName: ref.Name, Subjects: subject, + }) + } + } +} + +// 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}) + // 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, + 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}) + } + + dir := "templates/" + if a.Path != "" { + dir = "templates/" + a.Path + "/" + } + + 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}) + } + + if len(a.NamespaceRules) > 0 { + 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}) + } + } +} + +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 +} + +// 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/generate/render.go b/pkg/linters/rbac/rules/generate/render.go new file mode 100644 index 00000000..e0589609 --- /dev/null +++ b/pkg/linters/rbac/rules/generate/render.go @@ -0,0 +1,277 @@ +/* +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 ( + "maps" + "regexp" + "slices" + "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.` + // 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 +// starts with a header: a file without one is maintained by hand and is left alone. +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. +func ParseHeader(content string) (bool, string) { + line, _, _ := strings.Cut(content, "\n") + line = strings.TrimRight(line, " \t\r") + + 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') + + 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 := "" + + 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: " + yamlScalar(o.Name) + "\n") + + if o.Namespace != "" { + b.WriteString(" namespace: " + yamlScalar(o.Namespace) + "\n") + } + + b.WriteString(" " + labelsInclude(o.Labels) + "\n") + + if len(o.Annotations) > 0 { + b.WriteString(" annotations:\n") + + for _, key := range slices.Sorted(maps.Keys(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: " + yamlScalar(o.RoleRefName) + "\n") + b.WriteString("subjects:\n") + + for _, s := range o.Subjects { + switch s.Kind { + case "ServiceAccount": + 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: " + yamlScalar(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 slices.Sorted(maps.Keys(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 plainScalarRe.MatchString(v) && !yaml11Reserved[strings.ToLower(v)] { + return v + } + + return strconv.Quote(v) +} + +// 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. +var yaml11Reserved = map[string]bool{ + "y": true, "yes": true, "n": true, "no": true, "true": true, "false": true, + "on": true, "off": true, "null": true, "~": true, +} 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..c326d803 --- /dev/null +++ b/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/cainjector/rbac-for-us.yaml @@ -0,0 +1,122 @@ +# 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 +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..174967ef --- /dev/null +++ b/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/rbac-for-us.yaml @@ -0,0 +1,36 @@ +# 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 +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..2685760a --- /dev/null +++ b/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/rbac-to-us.yaml @@ -0,0 +1,71 @@ +# 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 +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..f48477b7 --- /dev/null +++ b/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/rbacv2/manage/edit.yaml @@ -0,0 +1,35 @@ +# 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 +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..8df6c345 --- /dev/null +++ b/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/rbacv2/manage/view.yaml @@ -0,0 +1,32 @@ +# 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 +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..ed18c3e6 --- /dev/null +++ b/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/rbacv2/use/admin.yaml @@ -0,0 +1,23 @@ +# 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 +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..d8eb317d --- /dev/null +++ b/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/rbacv2/use/edit.yaml @@ -0,0 +1,31 @@ +# 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 +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..04c4b92f --- /dev/null +++ b/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/rbacv2/use/view.yaml @@ -0,0 +1,56 @@ +# 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 +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..59b8e276 --- /dev/null +++ b/pkg/linters/rbac/rules/generate/testdata/cert-manager/expected/templates/user-authz-cluster-roles.yaml @@ -0,0 +1,117 @@ +# 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 +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/placement.go b/pkg/linters/rbac/rules/placement.go index fa2a928d..a27be36c 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 ( @@ -66,15 +66,12 @@ const ( RBACv2Path = "templates/rbac" ) -// 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 new file mode 100644 index 00000000..31fd79d2 --- /dev/null +++ b/pkg/linters/rbac/rules/rbaccontract/contract.go @@ -0,0 +1,279 @@ +/* +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" + "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"). +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" + + // 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. + 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 = slices.Clone(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 = "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). +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. +var LegacyLevels = []string{"User", "PrivilegedUser", "Editor", "Admin", "ClusterEditor", "ClusterAdmin", "SuperAdmin"} + +// 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. +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. +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 +} + +// 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"} + +// 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} + +// 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 +} + +// 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 { + return slices.Contains(DeckhouseNamespaces, ns) +} 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/load.go b/pkg/linters/rbac/rules/rbacyaml/load.go new file mode 100644 index 00000000..b05863c5 --- /dev/null +++ b/pkg/linters/rbac/rules/rbacyaml/load.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 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) + } + + for i := range decl.Resources { + decl.Resources[i].Position = i + } + + decl.parsed = true + + 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..3579f004 --- /dev/null +++ b/pkg/linters/rbac/rules/rbacyaml/load_test.go @@ -0,0 +1,680 @@ +/* +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" + "fmt" + "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`, + }, + "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: "", + }, + "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: `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 +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 +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: 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", + }, + "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", + }, + "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", + }, + } { + 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) +} + +// 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) +} + +// 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) + + errs := Validate(decl, nil) + + msgs := make([]string, 0, len(errs)) + for _, e := range errs { + 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) +} + +// 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") +} + +// 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 +resources: + - {group: z.io, resource: things, scope: Namespaced, namespace: {viewer: [get]}} + - {group: a.io, resource: things, scope: Namespaced, namespace: {viewer: [bogus]}} +`)) + 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, `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/scopes.go b/pkg/linters/rbac/rules/rbacyaml/scopes.go new file mode 100644 index 00000000..275e8a69 --- /dev/null +++ b/pkg/linters/rbac/rules/rbacyaml/scopes.go @@ -0,0 +1,69 @@ +/* +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 "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. +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", + // 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", + // 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 := strings.IndexByte(base, '/'); i >= 0 { + base = base[:i] + } + + scope, ok := wellKnownScopes[group+"/"+base] + + return scope, ok +} diff --git a/pkg/linters/rbac/rules/rbacyaml/types.go b/pkg/linters/rbac/rules/rbacyaml/types.go new file mode 100644 index 00000000..6f5a4498 --- /dev/null +++ b/pkg/linters/rbac/rules/rbacyaml/types.go @@ -0,0 +1,237 @@ +/* +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 + +import "strings" + +// 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"` + + // 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). + 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"` + + // 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. + 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 { + return strings.Contains(r.Resource, "/") +} + +// 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"` + + // 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 strings.HasPrefix(r.Name, "d8:") { + return r.Name + } + + return "d8:" + module + ":" + account + ":" + r.Name +} + +// 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"` + // 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 +// 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"` + // 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"` +} + +// 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..475b246f --- /dev/null +++ b/pkg/linters/rbac/rules/rbacyaml/validate.go @@ -0,0 +1,544 @@ +/* +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" + "reflect" + "regexp" + "slices" + "sort" + "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 + } + + // 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 + } + + // 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) + } +} + +// 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) + } + + 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, ", ")) + } + } + + seen := make(map[string]struct{}, len(d.Resources)) + usedCapabilities := make(map[string]struct{}) + + for i := range d.Resources { + r := &d.Resources[i] + + // 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()) + } + + 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 { + 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. + sort.SliceStable(errs, func(i, j int) bool { return errs[i].Error() < errs[j].Error() }) + + 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) + } + + validateWhen(r.When, where, report) + + 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) + } + + // 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) + if scopeErr != "" { + report("%s: %s", where, scopeErr) + } + + if r.IsWildcard() { + 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) + } + + 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, "" + 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 "", "" + 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) bool { + for key := range c { + if strings.HasPrefix(key, group+"/") { + return 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 { + 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, ", ")) + } + } + + 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 := 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) + } + } + } + + 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{}{} + + 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) + } + + 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) + } + } + + 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) + } + + 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) + } + + 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 != "" { + 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) + } + + if len(rule.Resources) > 0 && len(rule.APIGroups) == 0 { + report("%s[%d]: resources require apiGroups; the core group is \"\"", 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 "" +} + +// 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 +} + +// 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 +} + +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 new file mode 100644 index 00000000..2ae9dd3e --- /dev/null +++ b/pkg/linters/rbac/rules/sync.go @@ -0,0 +1,2077 @@ +/* +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" + "io/fs" + "log/slog" + "maps" + "os" + "path/filepath" + "regexp" + "slices" + "sort" + "strconv" + "strings" + "text/template/parse" + + 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" + + "github.com/deckhouse/deckhouse/pkg/log" + + "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" +) + +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, error) { + var meta moduleMetadata + + data, err := os.ReadFile(filepath.Join(modulePath, "module.yaml")) + if err != nil { + if stderrors.Is(err, os.ErrNotExist) { + return meta, nil + } + + return meta, err + } + + // 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, nil +} + +func (r *SyncRule) Check(_ context.Context) { + modulePath := r.module.GetPath() + declList := r.errorList.WithFilePath(rbacyaml.Filename) + + decl, err := rbacyaml.Load(modulePath) + overlay := editionOverlay(modulePath) + + if stderrors.Is(err, rbacyaml.ErrNotFound) { + // 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 != "" { + 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 + } + + if err != nil { + if content, readErr := os.ReadFile(rbacyaml.Path(modulePath)); readErr == nil && !strings.Contains(string(content), "apiVersion:") { + 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.WithFix(manualFix("make the declaration parse")).Errorf("%v; nothing is compared or generated until the declaration parses", err) + + return + } + + // 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("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.WithFix(manualFix("correct the declaration")).Errorf("%v; nothing is compared or generated until the declaration is valid", e) + } + + 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: meta.Subsystems, + Decl: decl, + }) + if err != nil { + declList.WithFix(manualFix("correct the declaration")).Errorf("cannot derive the RBAC objects from the declaration: %v", err) + return + } + + 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{} + + 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(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. + 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)) + } + + divergences[file.Path] = append(divergences[file.Path], found...) + } + + // 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{}{} + } + } + + divergences = r.replacedCopies(model, actual, modelIdentities, divergences) + + for identity, obj := range actual { + if _, produced := modelIdentities[identity]; produced || obj.class == generate.ClassDeclared { + 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 + } + + 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: %s", identity, why)) + } + + 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) { + 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 + // 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 { + // 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). + 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)") + } + + continue + } + + 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") + } + + 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, placed)...) + } + } + + // 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{}{} + } + + // 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() { + 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 + } + + 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, placed)...) + } + } +} + +// 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 { + owned, _ := generate.ParseOwned(content) + + out := make([]string, 0, len(owned)) + + for id := range owned { + if _, ok := produced[id]; ok { + continue + } + + if where, moved := placed[id]; moved { + out = append(out, id+" is now declared in "+where+"; the fix does not move objects between files -- move it by hand") + continue + } + + 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) { + // 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 { + 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 { + paths = append(paths, path) + } + } + + sort.Strings(paths) + + var dropped map[string]string + if store := r.module.GetObjectStore(); store != nil { + 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) + + 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 { + recordDropped(filepath.Join(modulePath, 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 + } + + if file := model.File(path); file != nil { + // 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) + + 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 { + // 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, 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 + } else if reason != "" { + list = append(list, reason) + } + + 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, "; ")) + } +} + +// 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. 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, "" + } + + fullPath := filepath.Join(r.module.GetPath(), path) + + content, err := os.ReadFile(fullPath) + if err != nil { + return false, "" + } + + if generated, _ := generate.ParseHeader(string(content)); !generated { + return false, "" + } + + if templateGated(string(content), "") { + return false, "the file serves both role models behind the version gate" + } + + foreign := r.foreignIn(path, nil, nil, model) + + 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. 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, placed map[string]string, removed []string) errors.AutofixFunc { + fullPath := filepath.Join(modulePath, path) + recordRemovals(fullPath, removed) + + 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 { + 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) + } + + // 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) + } + + log.Warn("rbac autofix deleted a generated template the declaration produces nothing for", + slog.String("file", path), slog.Any("removed", removed)) + + return nil + }) + } +} + +// 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 { + out := map[string]string{} + + for _, object := range r.module.GetStorage() { + 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 + } + } + + return out +} + +// 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 := 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 { + fullPath := filepath.Join(r.module.GetPath(), path) + owned, listed := ownedBy(fullPath) + + // 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 + } + } + + 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 storage { + if object.ShortPath() != path { + continue + } + + id := index.AsString() + + if _, ok := produced[id]; ok { + continue + } + + // 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 { + 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 + } + + if _, ok := owned[id]; ok { + continue + } + + if !listed && isRBACKind(object.Unstructured.GetKind()) { + // 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 + } + + if replacedByProduced(object, producedObjects, renderedRolesOf(storage, path), rendered) { + continue + } + } + + out = append(out, id) + } + + sort.Strings(out) + + 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)) +} + +// 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 +} + +// 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, rendered map[string]struct{}) 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 + } + + // 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 + } + } + 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 + } + + if _, present := rendered[o.Identity()]; present { + 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 +} + +// 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 + } + + rendered, ok := renderedRoles[renderedRef.Kind+"/"+renderedRef.Name] + if !ok { + return false + } + + for _, o := range produced { + 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 +} + +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 { + subjects := make([]rbacv1.Subject, 0, len(list)) + for _, s := range list { + subjects = append(subjects, rbacv1.Subject{Kind: s.Kind, Namespace: s.Namespace, Name: s.Name}) + } + + return subjectSet(subjects) +} + +// 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() { + 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() && + 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 { + out[identity] = managedObject{object, generate.ClassDeclared} + } + } + } + + 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 + + // 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 == "" || holds[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.uncoveredBy(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)...) + } 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) + 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 + + 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 { + // 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{}{} + } + + 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). 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 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). +// +// 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 { + // 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.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 +} + +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) + + // 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). + recordForeignObjects(fullPath, foreign) + recordRemovals(fullPath, removals) + + 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) + exists := err == nil + + if err != nil && !stderrors.Is(err, os.ErrNotExist) { + return fmt.Errorf("read %s: %w", file.Path, err) + } + + 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) + } + + // The render shows only what renders under these values; the text shows everything the + // file holds, objects under a false condition included. + 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) { + 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 := 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) + } + + 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 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() + } + + if err := writeFileAtomic(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 { + // 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)) + } + + return nil + }) + } +} + +// 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+":") +} + +// 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 + } + + meta, err := readModuleMetadata(modulePath) + if err != nil { + r.errorList.WithFilePath("module.yaml").WithFix(manualFix("make module.yaml parse")).Errorf("%v; the declaration is not written until it parses", err) + return + } + + 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 + } + + 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) + } + } + + if len(in.Objects) == 0 { + return + } + + markLibraryFiles(in.Objects) + + result := bootstrap.Build(in) + 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) + markLibraryFiles(in.Objects) + in.Partial = bootstrapPartialOf(path) + in.Variants = bootstrapVariantsOf(path) + result := bootstrap.Build(in) + + content, err := bootstrap.Marshal(result) + if err != nil { + return fmt.Errorf("render %s: %w", rbacyaml.Filename, err) + } + + 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)) +} + +// 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() + + switch o.Kind { + case "ClusterRole": + role := new(rbacv1.ClusterRole) + if runtime.DefaultUnstructuredConverter.FromUnstructured(content, role) != nil { + return o, false + } + + o.Rules, o.Aggregated = role.Rules, role.AggregationRule != nil + case "Role": + role := new(rbacv1.Role) + if runtime.DefaultUnstructuredConverter.FromUnstructured(content, role) != nil { + return o, false + } + + o.Rules = role.Rules + case "ClusterRoleBinding": + b := new(rbacv1.ClusterRoleBinding) + if runtime.DefaultUnstructuredConverter.FromUnstructured(content, b) != nil { + return o, false + } + + o.RoleRef, o.Subjects = b.RoleRef, b.Subjects + case "RoleBinding": + b := new(rbacv1.RoleBinding) + if runtime.DefaultUnstructuredConverter.FromUnstructured(content, b) != nil { + return o, false + } + + o.RoleRef, o.Subjects = b.RoleRef, b.Subjects + case "ServiceAccount": + sa := new(corev1.ServiceAccount) + if runtime.DefaultUnstructuredConverter.FromUnstructured(content, sa) != nil { + return o, false + } + + o.Automount = sa.AutomountServiceAccountToken + default: + return o, false + } + + 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 +} + +// 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 it would write still renders from another file. +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 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 +} + +// 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 + } + + // 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)) + } + } + + return nil + }) + + 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*(#.*)?$`) + 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*)*$`) + // 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, +// 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 i, doc := range separatorRe.Split(content, -1) { + var kind, name, namespace string + + 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 + 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] + continue + } + + trimmed := strings.TrimSpace(line) + if trimmed != "" && !strings.HasPrefix(trimmed, "#") && !wrapperLineRe.MatchString(line) && kind == "" { + other = true + } + } + } + + switch { + case kind == "" && !other && !foreign: + continue // the header, or the end of a conditional block + case foreign || kind == "" || name == "" || strings.Contains(name, "{{"): + out = append(out, textDocument{id: fmt.Sprintf("", i)}) + continue + } + + id := kind + "/" + name + if namespace != "" { + id = namespace + "/" + id + } + + managed := strings.Contains(doc, rbaccontract.AccessLevelAnnotation+":") || + 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{}{} + } + + 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...) + } + + copies := map[string]string{} + renderedGrantees := renderedRoleSubjects(storage) + declaredGrantees := modelRoleSubjects(all) + + for index, object := range storage { + id := index.AsString() + if _, ok := produced[id]; ok { + continue + } + + if _, ok := actual[id]; ok { + continue + } + + 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") + } + } + + // 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 "" +} + +// 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) + } +} + +// 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 +} + +// writeBootstrapped writes the declaration bootstrap produced and says what is left for a person. +// 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; 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 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 +} + +// 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, "; ") +} + +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 +} + +// 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 _, o := range objects { + if o.Library { + library[o.Path] = true + } + } + + 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: +// 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_regressions2_test.go b/pkg/linters/rbac/rules/sync_regressions2_test.go new file mode 100644 index 00000000..04f07756 --- /dev/null +++ b/pkg/linters/rbac/rules/sync_regressions2_test.go @@ -0,0 +1,218 @@ +/* +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" + "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" +) + +// 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") +} + +// 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) + + 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 == "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 + })) + + assert.NotContains(t, strings.Join(texts(errorList), "\n"), "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) +} + +// 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(), "this is a bug of dmt") + + 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)) +} + +// 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). 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"}}} + 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)) +} + +// 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) + 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 new file mode 100644 index 00000000..b373c95b --- /dev/null +++ b/pkg/linters/rbac/rules/sync_regressions_test.go @@ -0,0 +1,618 @@ +/* +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/bootstrap" + "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") +} + +// 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), + "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") +} + +// 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)) + }) +} + +// 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)) +} + +// 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") +} + +// 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") +} diff --git a/pkg/linters/rbac/rules/sync_test.go b/pkg/linters/rbac/rules/sync_test.go new file mode 100644 index 00000000..af4015ac --- /dev/null +++ b/pkg/linters/rbac/rules/sync_test.go @@ -0,0 +1,1841 @@ +/* +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" + "io/fs" + "os" + "path/filepath" + "strings" + "testing" + + "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" + "k8s.io/apimachinery/pkg/runtime" + + "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/rbaccontract" + "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()))) +} + +// 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, excludes ...pkg.KindRuleExclude) *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) + m.GetObjectStoreMock.Optional().Return(store) + + errorList := errors.NewLintRuleErrorsList() + NewSyncRule(excludes, 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: 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{ + 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) + writeGenerated(t, modulePath, model) + 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") +} + +// 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)))) +} + +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) + + // 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, rbaccontract.ContractVersion, version) + + // Running the same fix again, in a new run, changes nothing. + resetFixState() + + list := runSync(t, modulePath, store) + for _, fix := range list.GetFixes() { + fix() + } + + 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) { + resetFixState() + + 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 + }) + + 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, 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() + } + + assert.Empty(t, errorList.GetErrors()) + + written, err := os.ReadFile(path) + require.NoError(t, err) + 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) { + 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 + }) + + 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(asidePath(path)) + require.NoError(t, err) + 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 "+rbaccontract.ContractVersion+".", "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 "`+rbaccontract.ContractVersion+`". 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: 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 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:namespace-capability:cert-manager:view" { + o.Rules = o.Rules[:len(o.Rules)-1] + } + + return true + } + variantB := renderedFrom(t, model, stale) + 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"}}}}, + }) + + 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) + 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(), "also holds objects the declaration does not produce: ClusterRole/d8:cert-manager:only-sometimes", "variant %s", name) + } + + unchanged, err := os.ReadFile(filepath.Join(modulePath, rel)) + require.NoError(t, err) + 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. +func TestSync_DeclarationInEditionOverlay(t *testing.T) { + src := syncModuleDir(t) + 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)) + + 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) { + 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") + + // 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": "", // 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) + } +} + +// 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, 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) + assert.Equal(t, gated, string(unchanged)) + + _, 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))) + }) +} + +// 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))) +} + +// 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") +} + +// 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" + + // 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 { + 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)) +} + +// 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`") +} + +// 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) + + modulePath := syncModuleDir(t) + model := syncModel(t, modulePath) + writeGenerated(t, modulePath, model) + + // 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]) + + 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)) + require.NoError(t, os.WriteFile(editPath, []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()) + } + } + + 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") + + 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), path+" is left alone") + } +} + +// 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) +} + +// 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") + assertOnlyFailingFixes(t, errorList, modulePath) +} + +// 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)) +} + +// 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"`) +} + +// 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") + assertOnlyFailingFixes(t, errorList, modulePath) +} + +// 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") + + // 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"), "one directory under templates/ only") + + 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 +// 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)) +} + +// 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) + + modulePath := syncModuleDir(t) + model := syncModel(t, modulePath) + writeGenerated(t, modulePath, model) + store := renderedFrom(t, model, nil) + + const rel = "templates/rbac-to-us.yaml" + + path := filepath.Join(modulePath, rel) + asContractOne(t, path) + + 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) + + 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) + + 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() + } + + 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"), "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 +} + +// 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)) +} diff --git a/pkg/linters/rbac/rules/tuples.go b/pkg/linters/rbac/rules/tuples.go new file mode 100644 index 00000000..c6921029 --- /dev/null +++ b/pkg/linters/rbac/rules/tuples.go @@ -0,0 +1,197 @@ +/* +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 ( + "slices" + "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{}{} } + +// 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 + + 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/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/pkg/scopes/static.go b/pkg/scopes/static.go index b9fc3fde..27122931 100644 --- a/pkg/scopes/static.go +++ b/pkg/scopes/static.go @@ -123,7 +123,10 @@ var staticRules = map[string]set.Set{ ), rbac.ID: set.New( rbacrules.BindingSubjectRuleName, + rbacrules.ContractRuleName, + rbacrules.CoverageRuleName, rbacrules.PlacementRuleName, + rbacrules.SyncRuleName, rbacrules.UserAuthZRuleName, rbacrules.WildcardsRuleName, ), diff --git a/test/e2e/README.md b/test/e2e/README.md index 737301c0..d209e130 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -107,6 +107,20 @@ 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 | +| `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/) | 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 120000 index 00000000..1acac586 --- /dev/null +++ b/test/e2e/testdata/rbac/bootstrap-writes-declaration/module/charts/deckhouse_lib_helm-1.72.1.tgz @@ -0,0 +1 @@ +../../../../../lib/deckhouse_lib_helm-1.72.1.tgz \ No newline at end of file diff --git a/test/e2e/testdata/rbac/bootstrap-writes-declaration/module/crds/certificaterequests.yaml b/test/e2e/testdata/rbac/bootstrap-writes-declaration/module/crds/certificaterequests.yaml new file mode 100644 index 00000000..14c6d1b1 --- /dev/null +++ b/test/e2e/testdata/rbac/bootstrap-writes-declaration/module/crds/certificaterequests.yaml @@ -0,0 +1,9 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: certificaterequests.cert-manager.io +spec: + group: cert-manager.io + names: {plural: certificaterequests, kind: X} + scope: Namespaced + versions: [] diff --git a/test/e2e/testdata/rbac/bootstrap-writes-declaration/module/crds/certificates.yaml b/test/e2e/testdata/rbac/bootstrap-writes-declaration/module/crds/certificates.yaml new file mode 100644 index 00000000..bbf8b520 --- /dev/null +++ b/test/e2e/testdata/rbac/bootstrap-writes-declaration/module/crds/certificates.yaml @@ -0,0 +1,9 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: certificates.cert-manager.io +spec: + group: cert-manager.io + names: {plural: certificates, kind: X} + scope: Namespaced + versions: [] diff --git a/test/e2e/testdata/rbac/bootstrap-writes-declaration/module/crds/challenges.yaml b/test/e2e/testdata/rbac/bootstrap-writes-declaration/module/crds/challenges.yaml new file mode 100644 index 00000000..1cc18cd1 --- /dev/null +++ b/test/e2e/testdata/rbac/bootstrap-writes-declaration/module/crds/challenges.yaml @@ -0,0 +1,9 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: challenges.acme.cert-manager.io +spec: + group: acme.cert-manager.io + names: {plural: challenges, kind: X} + scope: Namespaced + versions: [] diff --git a/test/e2e/testdata/rbac/bootstrap-writes-declaration/module/crds/clusterissuers.yaml b/test/e2e/testdata/rbac/bootstrap-writes-declaration/module/crds/clusterissuers.yaml new file mode 100644 index 00000000..fda031c7 --- /dev/null +++ b/test/e2e/testdata/rbac/bootstrap-writes-declaration/module/crds/clusterissuers.yaml @@ -0,0 +1,9 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: clusterissuers.cert-manager.io +spec: + group: cert-manager.io + names: {plural: clusterissuers, kind: X} + scope: Cluster + versions: [] diff --git a/test/e2e/testdata/rbac/bootstrap-writes-declaration/module/crds/issuers.yaml b/test/e2e/testdata/rbac/bootstrap-writes-declaration/module/crds/issuers.yaml new file mode 100644 index 00000000..6fadee1b --- /dev/null +++ b/test/e2e/testdata/rbac/bootstrap-writes-declaration/module/crds/issuers.yaml @@ -0,0 +1,9 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: issuers.cert-manager.io +spec: + group: cert-manager.io + names: {plural: issuers, kind: X} + scope: Namespaced + versions: [] diff --git a/test/e2e/testdata/rbac/bootstrap-writes-declaration/module/crds/orders.yaml b/test/e2e/testdata/rbac/bootstrap-writes-declaration/module/crds/orders.yaml new file mode 100644 index 00000000..18ed21d5 --- /dev/null +++ b/test/e2e/testdata/rbac/bootstrap-writes-declaration/module/crds/orders.yaml @@ -0,0 +1,9 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: orders.acme.cert-manager.io +spec: + group: acme.cert-manager.io + names: {plural: orders, kind: X} + scope: Namespaced + versions: [] diff --git a/test/e2e/testdata/rbac/bootstrap-writes-declaration/module/module.yaml b/test/e2e/testdata/rbac/bootstrap-writes-declaration/module/module.yaml new file mode 100644 index 00000000..8bd62e35 --- /dev/null +++ b/test/e2e/testdata/rbac/bootstrap-writes-declaration/module/module.yaml @@ -0,0 +1,3 @@ +name: cert-manager +namespace: d8-cert-manager +subsystems: [security] diff --git a/test/e2e/testdata/rbac/bootstrap-writes-declaration/module/openapi/config-values.yaml b/test/e2e/testdata/rbac/bootstrap-writes-declaration/module/openapi/config-values.yaml new file mode 100644 index 00000000..03b0d8bf --- /dev/null +++ b/test/e2e/testdata/rbac/bootstrap-writes-declaration/module/openapi/config-values.yaml @@ -0,0 +1,2 @@ +type: object +properties: {} diff --git a/test/e2e/testdata/rbac/bootstrap-writes-declaration/module/openapi/values.yaml b/test/e2e/testdata/rbac/bootstrap-writes-declaration/module/openapi/values.yaml new file mode 100644 index 00000000..2434db79 --- /dev/null +++ b/test/e2e/testdata/rbac/bootstrap-writes-declaration/module/openapi/values.yaml @@ -0,0 +1,10 @@ +x-extend: + schema: config-values.yaml +type: object +properties: + internal: + type: object + default: {} + properties: + enableCAInjector: {type: boolean, default: true} + acmeEnabled: {type: boolean, default: true} diff --git a/test/e2e/testdata/rbac/bootstrap-writes-declaration/module/templates/cainjector/rbac-for-us.yaml b/test/e2e/testdata/rbac/bootstrap-writes-declaration/module/templates/cainjector/rbac-for-us.yaml new file mode 100644 index 00000000..6cf3648d --- /dev/null +++ b/test/e2e/testdata/rbac/bootstrap-writes-declaration/module/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/test/e2e/testdata/rbac/bootstrap-writes-declaration/module/templates/rbac-for-us.yaml b/test/e2e/testdata/rbac/bootstrap-writes-declaration/module/templates/rbac-for-us.yaml new file mode 100644 index 00000000..9b7a734a --- /dev/null +++ b/test/e2e/testdata/rbac/bootstrap-writes-declaration/module/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/test/e2e/testdata/rbac/bootstrap-writes-declaration/module/templates/rbac-to-us.yaml b/test/e2e/testdata/rbac/bootstrap-writes-declaration/module/templates/rbac-to-us.yaml new file mode 100644 index 00000000..5f4dce11 --- /dev/null +++ b/test/e2e/testdata/rbac/bootstrap-writes-declaration/module/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/test/e2e/testdata/rbac/bootstrap-writes-declaration/module/templates/rbacv2/manage/edit.yaml b/test/e2e/testdata/rbac/bootstrap-writes-declaration/module/templates/rbacv2/manage/edit.yaml new file mode 100644 index 00000000..a490c211 --- /dev/null +++ b/test/e2e/testdata/rbac/bootstrap-writes-declaration/module/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/test/e2e/testdata/rbac/bootstrap-writes-declaration/module/templates/rbacv2/manage/view.yaml b/test/e2e/testdata/rbac/bootstrap-writes-declaration/module/templates/rbacv2/manage/view.yaml new file mode 100644 index 00000000..7013011b --- /dev/null +++ b/test/e2e/testdata/rbac/bootstrap-writes-declaration/module/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/test/e2e/testdata/rbac/bootstrap-writes-declaration/module/templates/rbacv2/use/admin.yaml b/test/e2e/testdata/rbac/bootstrap-writes-declaration/module/templates/rbacv2/use/admin.yaml new file mode 100644 index 00000000..31792698 --- /dev/null +++ b/test/e2e/testdata/rbac/bootstrap-writes-declaration/module/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/test/e2e/testdata/rbac/bootstrap-writes-declaration/module/templates/rbacv2/use/edit.yaml b/test/e2e/testdata/rbac/bootstrap-writes-declaration/module/templates/rbacv2/use/edit.yaml new file mode 100644 index 00000000..22d5ecb6 --- /dev/null +++ b/test/e2e/testdata/rbac/bootstrap-writes-declaration/module/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/test/e2e/testdata/rbac/bootstrap-writes-declaration/module/templates/rbacv2/use/view.yaml b/test/e2e/testdata/rbac/bootstrap-writes-declaration/module/templates/rbacv2/use/view.yaml new file mode 100644 index 00000000..92c9568c --- /dev/null +++ b/test/e2e/testdata/rbac/bootstrap-writes-declaration/module/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/test/e2e/testdata/rbac/bootstrap-writes-declaration/module/templates/user-authz-cluster-roles.yaml b/test/e2e/testdata/rbac/bootstrap-writes-declaration/module/templates/user-authz-cluster-roles.yaml new file mode 100644 index 00000000..371a61bb --- /dev/null +++ b/test/e2e/testdata/rbac/bootstrap-writes-declaration/module/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/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..12b299d7 --- /dev/null +++ b/test/e2e/testdata/rbac/contract-cluster-scoped-in-namespace-capability/module/templates/rbacv2/use/view.yaml @@ -0,0 +1,19 @@ +apiVersion: rbac.authorization.k8s.io/v1 +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 + 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..be747677 --- /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: warn + textContains: "missing the ru.meta.deckhouse.io/title annotation" + count: 1 + - linter: rbac + rule: contract + level: warn + textContains: "missing the ru.meta.deckhouse.io/description annotation" + count: 1 + - linter: rbac + rule: contract + 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: 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/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..d4e29cac --- /dev/null +++ b/test/e2e/testdata/rbac/contract-violations/module/templates/rbacv2/roles/viewer.yaml @@ -0,0 +1,22 @@ +apiVersion: rbac.authorization.k8s.io/v1 +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" + 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..c24bb657 --- /dev/null +++ b/test/e2e/testdata/rbac/contract-violations/module/templates/rbacv2/use/view.yaml @@ -0,0 +1,16 @@ +apiVersion: rbac.authorization.k8s.io/v1 +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 + 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..ac19b609 --- /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: 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-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..261b4c33 --- /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: 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/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..26edf8a2 --- /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: warn + textContains: 'e2e.deckhouse.io/widgets is still undecided in rbac.yaml (noAccess: "TODO")' + 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: {} 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..62a96509 --- /dev/null +++ b/test/e2e/testdata/rbac/scheme-legacy-only/expected.yaml @@ -0,0 +1,28 @@ +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 + - linter: rbac + rule: sync + level: warn + textContains: "rbac.yaml is missing" + count: 1 +expectPass: + - linter: manager + - linter: rbac + rule: contract + textContains: "must" + - linter: rbac + rule: coverage 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 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 120000 index 00000000..1acac586 --- /dev/null +++ b/test/e2e/testdata/rbac/sync-clean/module/charts/deckhouse_lib_helm-1.72.1.tgz @@ -0,0 +1 @@ +../../../../../lib/deckhouse_lib_helm-1.72.1.tgz \ No newline at end of file diff --git a/test/e2e/testdata/rbac/sync-clean/module/crds/certificaterequests.yaml b/test/e2e/testdata/rbac/sync-clean/module/crds/certificaterequests.yaml new file mode 100644 index 00000000..14c6d1b1 --- /dev/null +++ b/test/e2e/testdata/rbac/sync-clean/module/crds/certificaterequests.yaml @@ -0,0 +1,9 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: certificaterequests.cert-manager.io +spec: + group: cert-manager.io + names: {plural: certificaterequests, kind: X} + scope: Namespaced + versions: [] diff --git a/test/e2e/testdata/rbac/sync-clean/module/crds/certificates.yaml b/test/e2e/testdata/rbac/sync-clean/module/crds/certificates.yaml new file mode 100644 index 00000000..bbf8b520 --- /dev/null +++ b/test/e2e/testdata/rbac/sync-clean/module/crds/certificates.yaml @@ -0,0 +1,9 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: certificates.cert-manager.io +spec: + group: cert-manager.io + names: {plural: certificates, kind: X} + scope: Namespaced + versions: [] diff --git a/test/e2e/testdata/rbac/sync-clean/module/crds/challenges.yaml b/test/e2e/testdata/rbac/sync-clean/module/crds/challenges.yaml new file mode 100644 index 00000000..1cc18cd1 --- /dev/null +++ b/test/e2e/testdata/rbac/sync-clean/module/crds/challenges.yaml @@ -0,0 +1,9 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: challenges.acme.cert-manager.io +spec: + group: acme.cert-manager.io + names: {plural: challenges, kind: X} + scope: Namespaced + versions: [] diff --git a/test/e2e/testdata/rbac/sync-clean/module/crds/clusterissuers.yaml b/test/e2e/testdata/rbac/sync-clean/module/crds/clusterissuers.yaml new file mode 100644 index 00000000..fda031c7 --- /dev/null +++ b/test/e2e/testdata/rbac/sync-clean/module/crds/clusterissuers.yaml @@ -0,0 +1,9 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: clusterissuers.cert-manager.io +spec: + group: cert-manager.io + names: {plural: clusterissuers, kind: X} + scope: Cluster + versions: [] diff --git a/test/e2e/testdata/rbac/sync-clean/module/crds/issuers.yaml b/test/e2e/testdata/rbac/sync-clean/module/crds/issuers.yaml new file mode 100644 index 00000000..6fadee1b --- /dev/null +++ b/test/e2e/testdata/rbac/sync-clean/module/crds/issuers.yaml @@ -0,0 +1,9 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: issuers.cert-manager.io +spec: + group: cert-manager.io + names: {plural: issuers, kind: X} + scope: Namespaced + versions: [] diff --git a/test/e2e/testdata/rbac/sync-clean/module/crds/orders.yaml b/test/e2e/testdata/rbac/sync-clean/module/crds/orders.yaml new file mode 100644 index 00000000..18ed21d5 --- /dev/null +++ b/test/e2e/testdata/rbac/sync-clean/module/crds/orders.yaml @@ -0,0 +1,9 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: orders.acme.cert-manager.io +spec: + group: acme.cert-manager.io + names: {plural: orders, kind: X} + scope: Namespaced + versions: [] diff --git a/test/e2e/testdata/rbac/sync-clean/module/module.yaml b/test/e2e/testdata/rbac/sync-clean/module/module.yaml new file mode 100644 index 00000000..8bd62e35 --- /dev/null +++ b/test/e2e/testdata/rbac/sync-clean/module/module.yaml @@ -0,0 +1,3 @@ +name: cert-manager +namespace: d8-cert-manager +subsystems: [security] diff --git a/test/e2e/testdata/rbac/sync-clean/module/openapi/config-values.yaml b/test/e2e/testdata/rbac/sync-clean/module/openapi/config-values.yaml new file mode 100644 index 00000000..03b0d8bf --- /dev/null +++ b/test/e2e/testdata/rbac/sync-clean/module/openapi/config-values.yaml @@ -0,0 +1,2 @@ +type: object +properties: {} diff --git a/test/e2e/testdata/rbac/sync-clean/module/openapi/values.yaml b/test/e2e/testdata/rbac/sync-clean/module/openapi/values.yaml new file mode 100644 index 00000000..2434db79 --- /dev/null +++ b/test/e2e/testdata/rbac/sync-clean/module/openapi/values.yaml @@ -0,0 +1,10 @@ +x-extend: + schema: config-values.yaml +type: object +properties: + internal: + type: object + default: {} + properties: + enableCAInjector: {type: boolean, default: true} + acmeEnabled: {type: boolean, default: true} diff --git a/test/e2e/testdata/rbac/sync-clean/module/rbac.yaml b/test/e2e/testdata/rbac/sync-clean/module/rbac.yaml new file mode 100644 index 00000000..589e18b0 --- /dev/null +++ b/test/e2e/testdata/rbac/sync-clean/module/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/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 new file mode 100644 index 00000000..c326d803 --- /dev/null +++ b/test/e2e/testdata/rbac/sync-clean/module/templates/cainjector/rbac-for-us.yaml @@ -0,0 +1,122 @@ +# 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 +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/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 new file mode 100644 index 00000000..174967ef --- /dev/null +++ b/test/e2e/testdata/rbac/sync-clean/module/templates/rbac-for-us.yaml @@ -0,0 +1,36 @@ +# 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 +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/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 new file mode 100644 index 00000000..2685760a --- /dev/null +++ b/test/e2e/testdata/rbac/sync-clean/module/templates/rbac-to-us.yaml @@ -0,0 +1,71 @@ +# 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 +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/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 new file mode 100644 index 00000000..f48477b7 --- /dev/null +++ b/test/e2e/testdata/rbac/sync-clean/module/templates/rbacv2/manage/edit.yaml @@ -0,0 +1,35 @@ +# 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 +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/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 new file mode 100644 index 00000000..8df6c345 --- /dev/null +++ b/test/e2e/testdata/rbac/sync-clean/module/templates/rbacv2/manage/view.yaml @@ -0,0 +1,32 @@ +# 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 +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/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 new file mode 100644 index 00000000..ed18c3e6 --- /dev/null +++ b/test/e2e/testdata/rbac/sync-clean/module/templates/rbacv2/use/admin.yaml @@ -0,0 +1,23 @@ +# 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 +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/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 new file mode 100644 index 00000000..d8eb317d --- /dev/null +++ b/test/e2e/testdata/rbac/sync-clean/module/templates/rbacv2/use/edit.yaml @@ -0,0 +1,31 @@ +# 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 +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/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 new file mode 100644 index 00000000..04c4b92f --- /dev/null +++ b/test/e2e/testdata/rbac/sync-clean/module/templates/rbacv2/use/view.yaml @@ -0,0 +1,56 @@ +# 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 +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/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 new file mode 100644 index 00000000..59b8e276 --- /dev/null +++ b/test/e2e/testdata/rbac/sync-clean/module/templates/user-authz-cluster-roles.yaml @@ -0,0 +1,117 @@ +# 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 +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/test/e2e/testdata/rbac/sync-fix-regenerates/expected.yaml b/test/e2e/testdata/rbac/sync-fix-regenerates/expected.yaml new file mode 100644 index 00000000..d45ceb22 --- /dev/null +++ b/test/e2e/testdata/rbac/sync-fix-regenerates/expected.yaml @@ -0,0 +1,10 @@ +description: > + 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 120000 index 00000000..1acac586 --- /dev/null +++ b/test/e2e/testdata/rbac/sync-fix-regenerates/module/charts/deckhouse_lib_helm-1.72.1.tgz @@ -0,0 +1 @@ +../../../../../lib/deckhouse_lib_helm-1.72.1.tgz \ No newline at end of file diff --git a/test/e2e/testdata/rbac/sync-fix-regenerates/module/crds/certificaterequests.yaml b/test/e2e/testdata/rbac/sync-fix-regenerates/module/crds/certificaterequests.yaml new file mode 100644 index 00000000..14c6d1b1 --- /dev/null +++ b/test/e2e/testdata/rbac/sync-fix-regenerates/module/crds/certificaterequests.yaml @@ -0,0 +1,9 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: certificaterequests.cert-manager.io +spec: + group: cert-manager.io + names: {plural: certificaterequests, kind: X} + scope: Namespaced + versions: [] diff --git a/test/e2e/testdata/rbac/sync-fix-regenerates/module/crds/certificates.yaml b/test/e2e/testdata/rbac/sync-fix-regenerates/module/crds/certificates.yaml new file mode 100644 index 00000000..bbf8b520 --- /dev/null +++ b/test/e2e/testdata/rbac/sync-fix-regenerates/module/crds/certificates.yaml @@ -0,0 +1,9 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: certificates.cert-manager.io +spec: + group: cert-manager.io + names: {plural: certificates, kind: X} + scope: Namespaced + versions: [] diff --git a/test/e2e/testdata/rbac/sync-fix-regenerates/module/crds/challenges.yaml b/test/e2e/testdata/rbac/sync-fix-regenerates/module/crds/challenges.yaml new file mode 100644 index 00000000..1cc18cd1 --- /dev/null +++ b/test/e2e/testdata/rbac/sync-fix-regenerates/module/crds/challenges.yaml @@ -0,0 +1,9 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: challenges.acme.cert-manager.io +spec: + group: acme.cert-manager.io + names: {plural: challenges, kind: X} + scope: Namespaced + versions: [] diff --git a/test/e2e/testdata/rbac/sync-fix-regenerates/module/crds/clusterissuers.yaml b/test/e2e/testdata/rbac/sync-fix-regenerates/module/crds/clusterissuers.yaml new file mode 100644 index 00000000..fda031c7 --- /dev/null +++ b/test/e2e/testdata/rbac/sync-fix-regenerates/module/crds/clusterissuers.yaml @@ -0,0 +1,9 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: clusterissuers.cert-manager.io +spec: + group: cert-manager.io + names: {plural: clusterissuers, kind: X} + scope: Cluster + versions: [] diff --git a/test/e2e/testdata/rbac/sync-fix-regenerates/module/crds/issuers.yaml b/test/e2e/testdata/rbac/sync-fix-regenerates/module/crds/issuers.yaml new file mode 100644 index 00000000..6fadee1b --- /dev/null +++ b/test/e2e/testdata/rbac/sync-fix-regenerates/module/crds/issuers.yaml @@ -0,0 +1,9 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: issuers.cert-manager.io +spec: + group: cert-manager.io + names: {plural: issuers, kind: X} + scope: Namespaced + versions: [] diff --git a/test/e2e/testdata/rbac/sync-fix-regenerates/module/crds/orders.yaml b/test/e2e/testdata/rbac/sync-fix-regenerates/module/crds/orders.yaml new file mode 100644 index 00000000..18ed21d5 --- /dev/null +++ b/test/e2e/testdata/rbac/sync-fix-regenerates/module/crds/orders.yaml @@ -0,0 +1,9 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: orders.acme.cert-manager.io +spec: + group: acme.cert-manager.io + names: {plural: orders, kind: X} + scope: Namespaced + versions: [] diff --git a/test/e2e/testdata/rbac/sync-fix-regenerates/module/module.yaml b/test/e2e/testdata/rbac/sync-fix-regenerates/module/module.yaml new file mode 100644 index 00000000..8bd62e35 --- /dev/null +++ b/test/e2e/testdata/rbac/sync-fix-regenerates/module/module.yaml @@ -0,0 +1,3 @@ +name: cert-manager +namespace: d8-cert-manager +subsystems: [security] diff --git a/test/e2e/testdata/rbac/sync-fix-regenerates/module/openapi/config-values.yaml b/test/e2e/testdata/rbac/sync-fix-regenerates/module/openapi/config-values.yaml new file mode 100644 index 00000000..03b0d8bf --- /dev/null +++ b/test/e2e/testdata/rbac/sync-fix-regenerates/module/openapi/config-values.yaml @@ -0,0 +1,2 @@ +type: object +properties: {} diff --git a/test/e2e/testdata/rbac/sync-fix-regenerates/module/openapi/values.yaml b/test/e2e/testdata/rbac/sync-fix-regenerates/module/openapi/values.yaml new file mode 100644 index 00000000..2434db79 --- /dev/null +++ b/test/e2e/testdata/rbac/sync-fix-regenerates/module/openapi/values.yaml @@ -0,0 +1,10 @@ +x-extend: + schema: config-values.yaml +type: object +properties: + internal: + type: object + default: {} + properties: + enableCAInjector: {type: boolean, default: true} + acmeEnabled: {type: boolean, default: true} diff --git a/test/e2e/testdata/rbac/sync-fix-regenerates/module/rbac.yaml b/test/e2e/testdata/rbac/sync-fix-regenerates/module/rbac.yaml new file mode 100644 index 00000000..589e18b0 --- /dev/null +++ b/test/e2e/testdata/rbac/sync-fix-regenerates/module/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/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 new file mode 100644 index 00000000..c326d803 --- /dev/null +++ b/test/e2e/testdata/rbac/sync-fix-regenerates/module/templates/cainjector/rbac-for-us.yaml @@ -0,0 +1,122 @@ +# 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 +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/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 new file mode 100644 index 00000000..174967ef --- /dev/null +++ b/test/e2e/testdata/rbac/sync-fix-regenerates/module/templates/rbac-for-us.yaml @@ -0,0 +1,36 @@ +# 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 +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/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 new file mode 100644 index 00000000..2685760a --- /dev/null +++ b/test/e2e/testdata/rbac/sync-fix-regenerates/module/templates/rbac-to-us.yaml @@ -0,0 +1,71 @@ +# 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 +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/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 new file mode 100644 index 00000000..f48477b7 --- /dev/null +++ b/test/e2e/testdata/rbac/sync-fix-regenerates/module/templates/rbacv2/manage/edit.yaml @@ -0,0 +1,35 @@ +# 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 +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/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 new file mode 100644 index 00000000..8df6c345 --- /dev/null +++ b/test/e2e/testdata/rbac/sync-fix-regenerates/module/templates/rbacv2/manage/view.yaml @@ -0,0 +1,32 @@ +# 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 +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/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 new file mode 100644 index 00000000..d8eb317d --- /dev/null +++ b/test/e2e/testdata/rbac/sync-fix-regenerates/module/templates/rbacv2/use/edit.yaml @@ -0,0 +1,31 @@ +# 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 +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/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 new file mode 100644 index 00000000..04c4b92f --- /dev/null +++ b/test/e2e/testdata/rbac/sync-fix-regenerates/module/templates/rbacv2/use/view.yaml @@ -0,0 +1,56 @@ +# 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 +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/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 new file mode 100644 index 00000000..59b8e276 --- /dev/null +++ b/test/e2e/testdata/rbac/sync-fix-regenerates/module/templates/user-authz-cluster-roles.yaml @@ -0,0 +1,117 @@ +# 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 +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/test/e2e/testdata/rbac/sync-hand-edited/expected.yaml b/test/e2e/testdata/rbac/sync-hand-edited/expected.yaml new file mode 100644 index 00000000..c35fcb00 --- /dev/null +++ b/test/e2e/testdata/rbac/sync-hand-edited/expected.yaml @@ -0,0 +1,18 @@ +description: > + 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: 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: 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: + # 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 120000 index 00000000..1acac586 --- /dev/null +++ b/test/e2e/testdata/rbac/sync-hand-edited/module/charts/deckhouse_lib_helm-1.72.1.tgz @@ -0,0 +1 @@ +../../../../../lib/deckhouse_lib_helm-1.72.1.tgz \ No newline at end of file diff --git a/test/e2e/testdata/rbac/sync-hand-edited/module/crds/certificaterequests.yaml b/test/e2e/testdata/rbac/sync-hand-edited/module/crds/certificaterequests.yaml new file mode 100644 index 00000000..14c6d1b1 --- /dev/null +++ b/test/e2e/testdata/rbac/sync-hand-edited/module/crds/certificaterequests.yaml @@ -0,0 +1,9 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: certificaterequests.cert-manager.io +spec: + group: cert-manager.io + names: {plural: certificaterequests, kind: X} + scope: Namespaced + versions: [] diff --git a/test/e2e/testdata/rbac/sync-hand-edited/module/crds/certificates.yaml b/test/e2e/testdata/rbac/sync-hand-edited/module/crds/certificates.yaml new file mode 100644 index 00000000..bbf8b520 --- /dev/null +++ b/test/e2e/testdata/rbac/sync-hand-edited/module/crds/certificates.yaml @@ -0,0 +1,9 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: certificates.cert-manager.io +spec: + group: cert-manager.io + names: {plural: certificates, kind: X} + scope: Namespaced + versions: [] diff --git a/test/e2e/testdata/rbac/sync-hand-edited/module/crds/challenges.yaml b/test/e2e/testdata/rbac/sync-hand-edited/module/crds/challenges.yaml new file mode 100644 index 00000000..1cc18cd1 --- /dev/null +++ b/test/e2e/testdata/rbac/sync-hand-edited/module/crds/challenges.yaml @@ -0,0 +1,9 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: challenges.acme.cert-manager.io +spec: + group: acme.cert-manager.io + names: {plural: challenges, kind: X} + scope: Namespaced + versions: [] diff --git a/test/e2e/testdata/rbac/sync-hand-edited/module/crds/clusterissuers.yaml b/test/e2e/testdata/rbac/sync-hand-edited/module/crds/clusterissuers.yaml new file mode 100644 index 00000000..fda031c7 --- /dev/null +++ b/test/e2e/testdata/rbac/sync-hand-edited/module/crds/clusterissuers.yaml @@ -0,0 +1,9 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: clusterissuers.cert-manager.io +spec: + group: cert-manager.io + names: {plural: clusterissuers, kind: X} + scope: Cluster + versions: [] diff --git a/test/e2e/testdata/rbac/sync-hand-edited/module/crds/issuers.yaml b/test/e2e/testdata/rbac/sync-hand-edited/module/crds/issuers.yaml new file mode 100644 index 00000000..6fadee1b --- /dev/null +++ b/test/e2e/testdata/rbac/sync-hand-edited/module/crds/issuers.yaml @@ -0,0 +1,9 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: issuers.cert-manager.io +spec: + group: cert-manager.io + names: {plural: issuers, kind: X} + scope: Namespaced + versions: [] diff --git a/test/e2e/testdata/rbac/sync-hand-edited/module/crds/orders.yaml b/test/e2e/testdata/rbac/sync-hand-edited/module/crds/orders.yaml new file mode 100644 index 00000000..18ed21d5 --- /dev/null +++ b/test/e2e/testdata/rbac/sync-hand-edited/module/crds/orders.yaml @@ -0,0 +1,9 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: orders.acme.cert-manager.io +spec: + group: acme.cert-manager.io + names: {plural: orders, kind: X} + scope: Namespaced + versions: [] diff --git a/test/e2e/testdata/rbac/sync-hand-edited/module/module.yaml b/test/e2e/testdata/rbac/sync-hand-edited/module/module.yaml new file mode 100644 index 00000000..8bd62e35 --- /dev/null +++ b/test/e2e/testdata/rbac/sync-hand-edited/module/module.yaml @@ -0,0 +1,3 @@ +name: cert-manager +namespace: d8-cert-manager +subsystems: [security] diff --git a/test/e2e/testdata/rbac/sync-hand-edited/module/openapi/config-values.yaml b/test/e2e/testdata/rbac/sync-hand-edited/module/openapi/config-values.yaml new file mode 100644 index 00000000..03b0d8bf --- /dev/null +++ b/test/e2e/testdata/rbac/sync-hand-edited/module/openapi/config-values.yaml @@ -0,0 +1,2 @@ +type: object +properties: {} diff --git a/test/e2e/testdata/rbac/sync-hand-edited/module/openapi/values.yaml b/test/e2e/testdata/rbac/sync-hand-edited/module/openapi/values.yaml new file mode 100644 index 00000000..2434db79 --- /dev/null +++ b/test/e2e/testdata/rbac/sync-hand-edited/module/openapi/values.yaml @@ -0,0 +1,10 @@ +x-extend: + schema: config-values.yaml +type: object +properties: + internal: + type: object + default: {} + properties: + enableCAInjector: {type: boolean, default: true} + acmeEnabled: {type: boolean, default: true} diff --git a/test/e2e/testdata/rbac/sync-hand-edited/module/rbac.yaml b/test/e2e/testdata/rbac/sync-hand-edited/module/rbac.yaml new file mode 100644 index 00000000..589e18b0 --- /dev/null +++ b/test/e2e/testdata/rbac/sync-hand-edited/module/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/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 new file mode 100644 index 00000000..c326d803 --- /dev/null +++ b/test/e2e/testdata/rbac/sync-hand-edited/module/templates/cainjector/rbac-for-us.yaml @@ -0,0 +1,122 @@ +# 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 +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/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 new file mode 100644 index 00000000..174967ef --- /dev/null +++ b/test/e2e/testdata/rbac/sync-hand-edited/module/templates/rbac-for-us.yaml @@ -0,0 +1,36 @@ +# 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 +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/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 new file mode 100644 index 00000000..2685760a --- /dev/null +++ b/test/e2e/testdata/rbac/sync-hand-edited/module/templates/rbac-to-us.yaml @@ -0,0 +1,71 @@ +# 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 +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/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 new file mode 100644 index 00000000..f48477b7 --- /dev/null +++ b/test/e2e/testdata/rbac/sync-hand-edited/module/templates/rbacv2/manage/edit.yaml @@ -0,0 +1,35 @@ +# 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 +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/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 new file mode 100644 index 00000000..8df6c345 --- /dev/null +++ b/test/e2e/testdata/rbac/sync-hand-edited/module/templates/rbacv2/manage/view.yaml @@ -0,0 +1,32 @@ +# 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 +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/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 new file mode 100644 index 00000000..ed18c3e6 --- /dev/null +++ b/test/e2e/testdata/rbac/sync-hand-edited/module/templates/rbacv2/use/admin.yaml @@ -0,0 +1,23 @@ +# 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 +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/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 new file mode 100644 index 00000000..c530a64b --- /dev/null +++ b/test/e2e/testdata/rbac/sync-hand-edited/module/templates/rbacv2/use/edit.yaml @@ -0,0 +1,38 @@ +# 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 +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 + - deletecollection +- apiGroups: + - "" + resources: + - secrets + verbs: + - get 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 new file mode 100644 index 00000000..04c4b92f --- /dev/null +++ b/test/e2e/testdata/rbac/sync-hand-edited/module/templates/rbacv2/use/view.yaml @@ -0,0 +1,56 @@ +# 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 +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/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 new file mode 100644 index 00000000..b8be3aa5 --- /dev/null +++ b/test/e2e/testdata/rbac/sync-hand-edited/module/templates/user-authz-cluster-roles.yaml @@ -0,0 +1,132 @@ +# 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 +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 +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: d8:user-authz:cert-manager:super-admin + {{- include "helm_lib_module_labels" (list .) | nindent 2 }} + annotations: + user-authz.deckhouse.io/access-level: "SuperAdmin" +rules: +- apiGroups: + - cert-manager.io + resources: + - issuers + verbs: + - deletecollection