Conversation
…t constants rbacyaml loads and validates modules/<module>/rbac.yaml (rbac.deckhouse.io/v1alpha1): strict keys, one entry per resource with the levels of both role models or a documented denial, scope from the module's CRDs or declared for external resources, verbs listed explicitly, localized texts required for capabilities outside the view/edit convention, and a reason wherever the declaration widens access beyond what the resource alone implies (a whole group, a namespaced resource at a system level). Normalization gives the generator and the sync rule a canonical order. rbaccontract holds the lineages, per-lineage levels, legacy access levels, label keys and the conventional localized texts, each with its source in the deckhouse repository. Signed-off-by: Ivan Zvyagintsev <ivan.zvyagintsev@flant.com>
…on in rbac.yaml coverage runs only when the module has an rbac.yaml. It reads the CRDs under crds/ at any depth, selecting them by kind, and requires an entry for each: levels of either role model, or noAccess with the reason. A CRD without an entry gets an autofix that appends an undecided stub (noAccess: "TODO") and then still reports the finding -- the stub is not a decision, and a --fix run that wrote stubs must not end green. A stub left as TODO is an error without a fix; a resource of a known group that no CRD spells is a warning. Signed-off-by: Ivan Zvyagintsev <ivan.zvyagintsev@flant.com>
… contract on rendered objects contract ports the first part of deckhouse/testing/rbacv2/rbacv2_templates_validation_test.go to the rendered ClusterRoles under templates/rbacv2/, so that a module outside the platform repository is held to the same contract: the d8: prefix, the four localized annotations, kind and scope labels, the shape of roles and capabilities, aggregation labels with a lineage and a level of that lineage, delegatable only on namespace/project roles. New here, as a warning for now: a cluster-scoped resource inside a namespace capability grants nothing through the RoleBinding it is bound with. The scope comes from the module's CRDs or its rbac.yaml; a resource the run knows nothing about is not judged. The rule needs no rbac.yaml. Signed-off-by: Ivan Zvyagintsev <ivan.zvyagintsev@flant.com>
…per-rule levels and exclusions
The root configuration now accepts linters-settings.rbac.rules.{coverage,sync,contract}.impact, and
the module configuration exclude-rules.{coverage,contract,sync}; the levels fall back to the
linter's. The four original rbac rules keep the linter level as before: wiring them up would change
the severity of existing findings. Both new rules are registered in the static scope.
Signed-off-by: Ivan Zvyagintsev <ivan.zvyagintsev@flant.com>
Signed-off-by: Ivan Zvyagintsev <ivan.zvyagintsev@flant.com>
…plates rendered from it generate.Build derives the RBAC objects a declaration produces (namespace and system capabilities, legacy roles, ServiceAccount rights, external access) with their names, labels, localized texts and rules, following the ADR table; generate.Render writes them as Helm templates under a header the sync autofix recognizes. The model is what the sync rule compares the rendered chart against, so the comparison and the generated text can never disagree. Golden files for the cert-manager example pin the output; rendering is a pure function of the model. Signed-off-by: Ivan Zvyagintsev <ivan.zvyagintsev@flant.com>
…in both directions, and --fix regenerates the templates sync compares the rendered objects of the three classes it owns (legacy roles, the module's capabilities, the objects the generator names) with the model built from the declaration: rules as (group, resource, name, verb) tuples, aggregation edges of capabilities, binding subjects and role references, the marker and module/namespace labels the generator writes. A rule under when that did not render is not a divergence; a rule without when that did not render is. Findings are one per template file and carry the fix command; an object the declaration does not produce in a file it does not generate is a person's decision. The autofix regenerates a file from the declaration with two safeguards: a file without the generator header is maintained by hand and gets the generated text beside it as <file>.generated; a regeneration that would drop a right or an aggregation edge the render grants today is refused with the list of what would be lost. Everything the fix needs is captured while the render exists. Signed-off-by: Ivan Zvyagintsev <ivan.zvyagintsev@flant.com>
…through helm_lib The cases vendor deckhouse_lib_helm as the container cases do, and every one asserts that the module rendered (expectPass on the manager linter): without it a render failure would satisfy any expectPass. Signed-off-by: Ivan Zvyagintsev <ivan.zvyagintsev@flant.com>
viper drops an unknown key without a word; for the per-rule levels and exclusions of the rbac linter that silence leaves a rule at full strength -- or off -- with nobody noticing. The two rbac blocks (global.linters-settings.rbac with its rules, linters-settings.rbac with its exclude-rules) are held to their known keys; the other linters keep the lenient behaviour. Adds the mapping test for the per-rule levels: coverage, sync and contract read their own level from the root configuration and fall back to the linter's, the four original rules keep the linter level. Signed-off-by: Ivan Zvyagintsev <ivan.zvyagintsev@flant.com>
…nd sync rules Signed-off-by: Ivan Zvyagintsev <ivan.zvyagintsev@flant.com>
A rule switched off with impact: ignore still had its fix closures collected and run by --fix, so files could change on behalf of findings nobody sees. GetFixes now skips findings at the ignored level. Signed-off-by: Ivan Zvyagintsev <ivan.zvyagintsev@flant.com>
…spec - contract: the module label of a framework role or capability must be the module's name (R21); helpers without a kind label, such as d8:dict, are not judged. - sync: a generated file whose header names another contract version is a divergence and is regenerated (R40); an rbac.yaml inside an edition overlay (ee/modules, ee/be/modules, ...) is an error, the declaration lives in modules/<module>/ only (R8a, D7). - coverage and sync autofixes run once per target however many render variants reported it (R36); the render variants record what they grant at lint time and the sync fix judges the union, so a right rendered only under some values is not dropped (D3). - rbac.yaml: a when condition must parse as a Helm expression (R13c). - e2e: the hand-labelled contract cases carry the module label. Signed-off-by: Ivan Zvyagintsev <ivan.zvyagintsev@flant.com>
contract, coverage and sync are new to every tree: a module without rbac.yaml sees only contract, and the platform tree still carries six dead rbac.yaml files of an older shape and rules the contract flags. Like the style rules of the documentation linter they now default to warn, whatever impact the rbac linter has, and a tree raises them to error in its root .dmtlint.yaml once its modules are clean. The four original rules keep the linter level. Signed-off-by: Ivan Zvyagintsev <ivan.zvyagintsev@flant.com>
…e that serves both A module meets the new rules in one of three states: only the manage/use scheme that preceded the 1.78 role model (an external module not yet migrated), only the 1.78 scheme, or both behind the version gate that rbacv2-migrate-module.sh writes (include "<module>.rbacv2_new_scheme"). - contract: a legacy object (kind: use or manage) gets one finding -- migrate -- instead of failing every check of the contract; a legacy object rendered from a gated template is not reported, the module serves both models on purpose. - sync: when a generated file's template renders the legacy scheme, the finding names that as the cause once instead of listing the absent objects as a mystery; the legacy objects are not reported as extra. - sync --fix: a gated template is never regenerated -- regenerating it would drop the legacy branch -- and the refusal says so; the gate is detected by its helper name, not only by a literal deckhouseVersion. The check runs before the header guard, so a gated file is not called hand-maintained. - e2e: one case per state (scheme-legacy-only, scheme-legacy-with-declaration, scheme-dual, the last written by the platform's migration script). Signed-off-by: Ivan Zvyagintsev <ivan.zvyagintsev@flant.com>
The RBACv2 role model requires a Russian title and description on every role and capability (ru.meta.deckhouse.io/*), and the rbac contract rule enforces them; the files rule then reported the same lines as Cyrillic in source. 41 in-tree modules carry an identical exclude-rules entry for templates/rbacv2 to reconcile the two, and every external module would need one too. The files rule now skips those annotation lines; Cyrillic anywhere else in the same file is still reported. Signed-off-by: Ivan Zvyagintsev <ivan.zvyagintsev@flant.com>
Thirteen changes a module goes through were run against the rules: a CRD added, removed, renamed or rescoped; a verb dropped from the declaration; a rule added to a template by hand; a generated file or the declaration deleted; subsystems changed; a subresource and a whole-group entry; a rule under when. Three did not hold: - coverage: a noAccess entry whose group has no CRD left in the module was silent -- a removed CRD is indistinguishable from an external resource nobody grants. It is a warning now unless the entry names a scope. - sync: a rule under when whose condition is false today is absent from the render without being a divergence (D4), so it never reached the template through --fix. For a file that carries the generator header the text is now compared with what the declaration renders; a file without the header is still judged by its render only. A file of another contract version is the same check. - sync --fix: the copy written beside a hand-maintained file lived inside templates/, and Helm renders every file there whatever its extension, so the module rendered a second set of objects. The copy is _<file>.generated now: an underscore-prefixed file is a partial to Helm. The drop-guard refusal names the way out: delete the file and run --fix again to regenerate it without the right. Signed-off-by: Ivan Zvyagintsev <ivan.zvyagintsev@flant.com>
…rgence The full loop on an external module -- every RBAC template deleted, --fix asked to write them back -- lost three ServiceAccounts and 88 rules without a finding: the files held only objects under when, the conditions were true and the objects rendered before, and once the files were gone their absence from the render read as "conditional, not rendered" (D4). The render cannot tell a false condition from a template nobody wrote; the text can. A file the declaration produces that does not exist while an object it holds is absent from the render is now reported and created by --fix. A file that exists is judged as before. The sync tests write the generated files to disk before simulating the render, as a module that rendered them would have them. Signed-off-by: Ivan Zvyagintsev <ivan.zvyagintsev@flant.com>
…ger showed Running the whole cycle -- declaration from the rendered objects, every RBAC template regenerated -- on three platform modules found three things: - sync owned every capability that carried the module label, including the ones the declaration cannot produce: the project lineage of multitenancy-manager and the platform-wide capabilities of user-authz, which are named after a lineage rather than the module. They were about to be reported as objects the declaration does not produce. The capability class is now the module's own namespace and system capabilities by name; the rest stays hand-written and unreported (D2). - a generated file is written whole, so an object in it that the declaration does not produce -- a controller ClusterRole beside a declared ServiceAccount (cert-manager's webhook, user-authz's permission browser) -- would vanish with the rewrite, and the advice to delete the file and run --fix again would lose it too. The fix now refuses first of all when the file holds such objects and names them; every render variant's list is joined. - no-cyrillic judged rbac.yaml for the Russian titles and descriptions the declaration requires of capabilities outside the view/edit convention; it is the module's documentation, like module.yaml, and is skipped. Signed-off-by: Ivan Zvyagintsev <ivan.zvyagintsev@flant.com>
Signed-off-by: Ivan Zvyagintsev <ivan.zvyagintsev@flant.com>
… from its render
Decided with the platform owner on 2026-09-22, after the loop on four modules:
- bootstrap: a module without rbac.yaml gets a sync finding, and --fix writes
the declaration from the RBAC objects the module renders today -- the file
a person would have transcribed from the templates, with a TODO wherever a
decision is still theirs (a resource with no CRD and an unknown scope, a
CRD nobody grants, a namespaced resource granted cluster-wide) and a note
for every object the generator will name differently or cannot describe.
From then on rbac.yaml is the source and the templates follow it. This
replaces R22 ("contract only without a declaration") and the ADR's "coverage
creates the file".
- the declaration wins: a right it no longer names leaves the template on
--fix, the finding that led there having listed it. This replaces D3
("autofix never removes a right"), whose danger -- the first run deleting
what nobody had declared yet -- is gone once the declaration starts from
the render.
- serviceAccounts[].extraClusterRoles: further ClusterRoles in the account's
file, bound to it or not (cert-manager's per-controller roles, an
aggregated apiserver's requester role), named d8:<module>:<account>:<name>
or exactly as given when they start with d8:.
- serviceAccounts[].automountServiceAccountToken: the generator no longer
forces false; the import keeps true where the account mounted its token,
so no pod loses it.
- access[].path: cluster and namespace grants may live in a component's
rbac-for-us.yaml / rbac-to-us.yaml, where the module kept them.
- the foreign-objects safeguard recognizes an object the generator produces
under another name -- a binding with the same roleRef and subjects, a role
with the same rules -- as replaced, not lost.
On operator-trivy, cert-manager, user-authz and multitenancy-manager the flow
now runs end to end: every RBAC template regenerated from the written
declaration, the rendered rights identical before and after up to the dead
clusterissuers rule of cert-manager and the renames the generator's naming
forces.
Signed-off-by: Ivan Zvyagintsev <ivan.zvyagintsev@flant.com>
…nd the declaration as the source Signed-off-by: Ivan Zvyagintsev <ivan.zvyagintsev@flant.com>
…n CI) Signed-off-by: Ivan Zvyagintsev <ivan.zvyagintsev@flant.com>
Signed-off-by: Ivan Zvyagintsev <ivan.zvyagintsev@flant.com>
4 tasks
Fifteen corner cases were run against the rules on a synthetic module (a
module without subsystems or with a non-d8 namespace, a marker past 63
characters, an account named unlike its directory and nested directories,
a declared object rendered from another file, when conditions with braces,
core-group resources and wildcard verbs, BOM and CRLF in rbac.yaml and in a
generated file, an rbac.yaml of the earlier shape, duplicate objects, a
bootstrap with a conditional object off by default, a read-only template,
duplicate subjects). Nine did not hold:
- system levels on a module that aggregates into no subsystem produced
capabilities nobody aggregates; the generator refuses and asks for
subsystems in rbac.yaml.
- a capability marker longer than a label value (a module name of 32
characters and up with a namespace superadmin level) was written and
would fail the contract; the generator refuses.
- an account named unlike its directory, or in a nested directory, produced
objects the placement rule rejects; the generator refuses with the names
placement wants.
- an object the declaration produces but rendered from another file was
called foreign without saying where it belongs; the refusal now names the
file the declaration puts it in.
- built-in Kubernetes resources (""/configmaps, apps/deployments, ...)
needed an explicit scope; the validator and the importer share one table
of well-known scopes, and an unknown resource still asks for one.
- an rbac.yaml of the earlier, never consumed shape gave a bare parse error;
it is named for what it is, with the way out.
- under --matrix the first declaration was written from whichever variant
ran its fix first; it is written from the union of every variant, and the
file's header says that objects off by default need a run with
--values-file before the first regeneration.
- a generated file with CRLF line endings lost its header and became
hand-maintained in silence; the header is recognized and the file
regenerated.
- duplicate subjects of an access entry passed validation.
Signed-off-by: Ivan Zvyagintsev <ivan.zvyagintsev@flant.com>
…he corner-case behaviour Signed-off-by: Ivan Zvyagintsev <ivan.zvyagintsev@flant.com>
Signed-off-by: Ivan Zvyagintsev <ivan.zvyagintsev@flant.com>
The ADR (ccd35e0) names it among the drawbacks: a --fix run without a preceding dmt lint would remove rights the declaration no longer names with nothing said. The autofix now logs, per regenerated file, the rights and objects the render had and the declaration did not name, and logs the plain regeneration otherwise. Signed-off-by: Ivan Zvyagintsev <ivan.zvyagintsev@flant.com>
… bootstrap A third round of corner cases on a synthetic module and on the edition overlay of admission-policy-engine: - a generated file the declaration produces nothing for any more -- every namespace level dropped, the legacy section gone -- stayed with a finding only a person could close. It is the generator's file and the declaration wins: --fix deletes it when it holds nothing but objects of the owned classes, and logs the deletion; a foreign object or the version gate keeps it, and the finding says which. - the bootstrap left the scope implicit for resources whose CRD is in the module. A lint of one edition directory does not see the CRDs of the other editions (ratify of admission-policy-engine lives in ee/se-plus), and an entry without a scope then stopped the whole validation instead of raising one coverage warning. Every entry now carries its scope, denied ones too. - a generator header with trailing whitespace handed the file over to a person in silence; the header is recognized. Signed-off-by: Ivan Zvyagintsev <ivan.zvyagintsev@flant.com>
…ety, quoting sync - exclude-rules.sync silences an object's findings without forgetting the object: the exclusion is applied when findings are emitted, so an excluded declared object is no longer reported as absent. - ServiceAccount objects are compared: a token the render mounts and the declaration does not is a divergence, since the regeneration would drop it. - A rendered binding under another name counts as a rename only when it points at the same role, or at a role rendered in the same file whose rules the produced role carries. A binding to cluster-admin is a foreign object. - The orphan-file fix judges the union of render variants and re-reads the header and the gate before deleting; a file another variant placed a foreign object in stays. - The gate is detected by the migration helper or a deckhouseVersion test in a template action that the declaration itself did not produce, so a declared `when` on the platform version no longer blocks regeneration forever. - Files are written through a temporary file and a rename; the aside copy gets the permissions of a source file. validation and generation - resources require apiGroups (the core group is ""). - Generated names are quoted unless they are plain scalars; YAML 1.1 words (no, yes, on, off, null) and numbers are quoted. - Two declared roles that map to one generated binding name are refused. - Validation errors, CRD ordering and the bootstrap input have one order. contract - The level in a role name is checked against the lineage of its scope. - One warning per cluster-scoped group/resource in a namespace capability. - ProjectLevels no longer shares its backing array with NamespaceLevels. bootstrap - A resource every grant of which carried resourceNames is written as an undecided noAccess entry instead of being widened to every object. coverage - exclude-rules.coverage also silences the misspelling warning of the entry. Tests cover each change; the READMEs describe the current behaviour. Signed-off-by: Ivan Zvyagintsev <ivan.zvyagintsev@flant.com>
…olintlint - The rbac config blocks are checked down to a rule's impact and the kind/name of an exclusion entry, and every unknown key is reported in one error. - global.RbacLinterConfig/RbacRules follow the RBAC initialism of pkg/config. - BindingSuffix and LevelOfAction live in rbaccontract; bootstrap and generate share them. ResourceVerbs is the verb list without the wildcard. - sortedKeys, indexByte, IsSubresource and FullName use the standard library. - SyncRule.Check is three steps: compareRender, compareText, report. - fixOnce holds its own lock while the fix runs; the records keep theirs. - legacyFiles names one kind per file whatever order the storage yields. - The bootstrap builder has one blank line between methods; a redundant condition is gone; fmt.Errorf without a format is errors.New. - no-cyrillic skips the module's own rbac.yaml only, not every file of that name. - nolintlint requires a named linter and a reason on every directive; the unused check stays off because it depends on the golangci-lint version. - rbaccontract has unit tests; the loader tests cover the nested keys. Signed-off-by: Ivan Zvyagintsev <ivan.zvyagintsev@flant.com>
4 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
One declaration per module,
modules/<module>/rbac.yaml, describes the user-facing access to every resource the module ships in both role models (RBACv2 capabilities and legacyuser-authzroles), the rights of its ServiceAccounts and the access it grants to others. Three new rules of therbaclinter work with it; the templates are generated from it by--fix.contract— runs on every module,rbac.yamlor not. It holds the rendered ClusterRoles undertemplates/rbacv2/to the platform's label and naming contract (d8:prefix,rbac.deckhouse.io/{kind,scope,capability,aggregate-to-<lineage>-as,use-role,delegatable}, the fouren|ru.meta.deckhouse.io/*annotations, per-lineage levels, themodulelabel). It is a port of the platform'stesting/rbacv2validation, so a module outside the platform repository is checked the same way. One check beyond the platform test: a cluster-scoped resource inside a namespace capability is a warning — such a rule grants nothing through a RoleBinding.coverage— withrbac.yaml: every CRD undercrds/(recursively, by kind) has an entry that grants levels or denies access with a reason.noAccess: "TODO"is an error of its own: a decision is owed.--fixappends an undecided stub for a missing entry and deliberately leaves the run red — the stub is not a decision.sync— withrbac.yaml: the rendered objects and the declaration say the same thing, in both directions, compared as(apiGroup, resource, resourceName, verb)tuples plus aggregation edges, roleRefs and subjects. It owns three classes of rendered objects (legacy roles by annotation, module capabilities by kind andmodulelabel, objects whose names the generator builds); everything else in the render is neither generated nor reported.--fixregenerates a file from the declaration with two safeguards: a file without the generator header is maintained by hand (the generated text lands beside it as<file>.generated), and a regeneration that would drop a right the render grants today is refused and names the right — removing access is always a person's decision. Under--matrixthe fix runs once per file and judges the union of every variant's render.rbac.deckhouse.io/v1alpha1) is validated before anything is compared or generated: explicit verb lists,namespacelevels only for namespaced resources, ascopefor resources the module ships no CRD for, areasonfor whole-group entries and for namespaced resources granted cluster-wide, localized texts for capabilities outside the view/edit convention,whenconditions that parse as Helm expressions. Unknown keys are errors.global.linters-settings.rbac.rules.{contract,coverage,sync}.impactsets each rule's level on its own;linters-settings.rbac.exclude-rules.{contract,coverage,sync}excludes objects orgroup/resourcekeys. Therbacconfiguration blocks refuse unknown keys. The three rules start atwarnwherever nothing sets them, whatever the linter'simpactis; the four original rules keep their level and their output untouched. One change outside the linter:--fixno longer runs the autofix of a finding at the ignored level.dmtfirst runs are handled and covered by e2e cases: only the RBACv2 scheme before DKP 1.78 (kind: use|manage—contractgives one "migrate" finding per object instead of failing every check; with anrbac.yaml,syncnames the legacy template as the cause), only the 1.78 scheme, and both behind the version gaterbacv2-migrate-module.shwrites (the linter's values answer the gate with the new model, the legacy branch is neither judged nor "extra", and--fixnever rewrites a gated file).no-cyrillic/filesno longer judges theru.meta.deckhouse.io/title|descriptionlines: the role model requires that Russian text and thecontractrule enforces it, so the two rules contradicted each other -- 41 in-tree modules carry an identicalexclude-rulesentry fortemplates/rbacv2to reconcile them, and every external module would have needed one. Cyrillic anywhere else in the same file is still reported.when), on a regenerated copy of an external module. Three findings from that pass are in: anoAccessentry whose group lost every CRD is a coverage warning unless it names ascope; a generator-owned file (header present) is compared as text with what the declaration renders, so a rule underwhenthat is false today still reaches the template through--fix; the copy written beside a hand-maintained file is_<file>.generated, because Helm renders every file undertemplates/. A fourth came from the full loop on an external module (every RBAC template deleted and regenerated from the declaration): a file whose objects are all underwhenwas never written back, because their absence from the render is not a divergence (D4) -- a declared file that does not exist while an object it holds is absent from the render is now reported and created. The loop ends with 646 rules, 26 bindings and ServiceAccounts and 18 role-model labels identical before and after, modulo the renames the generator's naming forces.syncowns only the module's own namespace/system capabilities by name -- the project lineage of multitenancy-manager and the platform-wide capabilities of user-authz are not the declaration's and stay hand-written; and a generated file that also holds objects the declaration does not produce (cert-manager'swebhook:requester, user-authz's permission-browser ClusterRoles) is never rewritten, since the generator writes the whole file.no-cyrillicskipsrbac.yamlas it skipsmodule.yaml: the declaration carries the Russian capability texts the role model requires.when, core-group resources, BOM and CRLF, an rbac.yaml of the earlier shape, duplicate objects and subjects, a conditional object off by default at bootstrap) closed nine more gaps; the generator now refuses what the declaration alone cannot know is wrong -- system levels without a subsystem, a marker too long for a label, an account the placement rule would reject -- and built-in Kubernetes resources need noscope.pkg/linters/rbac/README.mdhas the format, the three rules and the developer loop (rbac.yaml→dmt lint --linter rbac --fix→dmt lint). e2e cases undertest/e2e/testdata/rbac/.What the findings look like on the platform tree today (
--linter rbac, 47 modules; the basedmtreports nothing there):Under
--matrixthe same 10 findings appear; every other rule's output on the tree, default render and matrix alike, is identical to the base. On two external modules still on the pre-1.78 scheme (operator-trivy,security-profiles) the run gives four "legacy scheme" warnings each and no errors.The
syncwarnings come from sixrbac.yamlfiles of an older, never consumed shape that still sit in the platform tree; their removal is a separate platform PR.How an existing module enters the declaration
Without
rbac.yaml,syncreports the declaration missing and--fixwrites it from the RBAC objects the module renders today: the file a person would have transcribed from the templates, with aTODOwherever 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. The person reviews it, resolves the TODOs, and runs--fixagain: the templates are regenerated from the declaration, which is the source from then on -- a right removed from it leaves the template, a CRD added to the module gets an undecided stub, a new ServiceAccount is declared and generated.The format grew what the platform modules needed for this:
serviceAccounts[].extraClusterRoles(further ClusterRoles in the account's file, bound to it or not),serviceAccounts[].automountServiceAccountToken(the import keeps the token mounted where it was, so no pod loses it),access[].path(grants kept in a component's files).Verified end to end on four modules -- operator-trivy after the platform's migration script (both RBACv2 schemes behind the version gate), cert-manager, user-authz and multitenancy-manager: every RBAC template regenerated from the written declaration, the rendered rights identical before and after (646, 339→336 for the dead
clusterissuersrule, 2367 and 206 rule tuples), only the object names the generator's convention forces changed; the project-lineage capabilities of multitenancy-manager and the platform-wide roles and capabilities of user-authz stay hand-written and are listed as such.Why do we need it, and what problem does it solve?
A module's RBAC lives in four places that nobody keeps in step: RBACv2 capability templates, legacy
user-authzroles,rbac-for-us.yamlandrbac-to-us.yaml. On the platform tree 66 of 181 CRDs have no user-facing access at all, and nothing says whether that is a decision or an omission. When the platform's role contract changes, every module is edited by hand, and the platform test that checks the contract sees only in-tree modules — external modules drift silently until aggregation breaks in a cluster.The declaration makes the decision explicit per resource and the templates a derivative of it. The linter is where this belongs:
dmtalready renders the chart, sosynccompares rendered objects rather than template text, the developer keeps the freedom to add conditions and comments, and an external module gets the same contract check as an in-tree one from the tool it already runs.Design:
platform-security/2026-04-27-module-rbac-yaml.md(architecture-decision-records, branchrbac-v2, revisionccd35e0of 2026-09-22 -- the ADR text now describes exactly this implementation: the bootstrap from the render, the declaration as the source, the three owned classes,extraClusterRoles/automountServiceAccountToken/path/reason, the version gate, theno-cyrillicexemptions and thewarndefault). A regeneration logs the rights and objects it removes, so a--fixrun without a precedingdmt lintdoes not remove rights in silence.