Conversation
Signed-off-by: Ilya Drey <ilya.drey@flant.com>
Signed-off-by: Ilya Drey <ilya.drey@flant.com>
Signed-off-by: Ilya Drey <ilya.drey@flant.com>
Move LabelRepositoryName, LabelChartName and the UnavailableReason* constants out of helm_cluster_addon_chart.go into constants.go, since they are now shared by three chart-catalog families instead of one. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
Signed-off-by: Ilya Drey <ilya.drey@flant.com>
Signed-off-by: Ilya Drey <ilya.drey@flant.com>
…elpers Bound spec.metadata.name to 63 characters via a CEL rule: the controller writes the object's name into a source label value on every internal resource it creates (chart_service.go, release_service.go, oci_repo_service.go, chart_claim_service.go), and Kubernetes caps label values at 63 characters. The kind is unreleased, so tightening it now is free; closing it after release would be a breaking change. No lower bound is added, since nothing references a HelmApplication by name. Added RepositoryName() and RepositoryKind() on *HelmApplicationLastAppliedChartRef, mirroring the existing helpers on HelmApplication's spec, so callers that need "which repository did we last deploy from" (uninstall, and the namespaced-to-cluster migration IsChartStatusInfoOutdated detects) don't have to re-implement the repository/clusterRepository XOR by hand. Also covers ConfigurationApplyInProgress/UpdateInstallInProgress with tests that reach their Unknown+ReasonReconciling branch, and clarifies a subtest name that overstated its assertion. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
…tion The description asserted that the resource "requires no cluster-wide permissions". Whether that holds is decided by the RBAC that ships with the controllers, not by the schema, so the CRD is the wrong place to promise it. The namespace statement it followed already carries the part that is true of the API itself. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
The three repository kinds differ only in scope; their spec and status were already identical field for field, description for description, so the generated CRDs are unchanged by this commit. One declaration lets the controller reconcile all of them through a single code path. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
The three catalog kinds are projections of a repository index and were
already identical in shape. Sharing the status type changes one word in
the released HelmClusterAddonChart description ("addon chart state" ->
"chart state") and its translation; nothing else in the schema moves.
Signed-off-by: Ilya Drey <ilya.drey@flant.com>
Internal objects of every family share d8-operator-helm, so a name derived from the source name alone lets two same-named namespaced sources overwrite each other's HelmRepository and auth secret. DerivedName always includes a hash over kind, namespace and name. The addon naming functions are left untouched and their exact output is now pinned by a test: those names are live objects. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
source.Repository is how the services will see any repository kind; the three adapters hold everything that differs between kinds — labels, derived names, owner GVK. The addon adapter reproduces the released names and labels exactly. Nothing consumes the contract yet. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
…catalog The catalog writer is the one place generic over API types: catalog objects must be created, listed and status-patched with their concrete kind. A namespaced catalog lists by the repository namespace, which is what keeps two same-named repositories in different namespaces apart. The merge and sort rules move here unchanged with their tests; the application catalogs have no consumers until HelmApplication is reconciled. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
…h the contract EnsureSecrets, EnsureInternalHelmRepository and the cleanup paths take a source.Repository instead of the addon type; names and labels come from the adapter. The addon reconciler wraps its object at the boundary, so nothing observable changes for the addon family. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
RepoSyncService fetches through source.Repository and writes through source.Catalog; its own catalog code is gone, the generic catalog owns it. OCIRepoService cleans up by InternalNames and implements ConsumerForcer for the addon family. Behaviour is unchanged: the synchronization tests pass with their expectations untouched. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
…iler The repository reconciler reads the API object through a source.Repository adapter handed to it as a factory, so the same code serves any repository kind. The package moves from reconcile/helmclusteraddonrepository to reconcile/repository; Evaluate and its tests move with it unchanged. The addon controller is the first, and so far the only, user. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
A namespaced source cannot be identified by its name alone once its internal objects share d8-operator-helm with everything else; the new mapper reads the namespace from the second source label. The existing mapper is untouched. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
…ApplicationRepository Both kinds are served by the shared repository reconciler through their adapters. Their internal objects carry namespace-aware derived names and, for the namespaced kind, a source-namespace label the watches map back through. Neither has consumers yet: HelmApplication is the next plan, and until then force requests stop at the repository and no chart version is protected from pruning. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
…neralization Mapper log lines for other families' internal objects drop to debug verbosity; a reconciler test now covers the namespaced kind end to end; catalog errors name the repository namespace; RBAC gains /finalizers on the new resources; stale comments corrected and the stage-5 stand-ins marked. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
source.Release is how the services will see any release kind; the companion contracts (RepositoryResolver, ChartClaim, TargetNamespaceEnsurer, AccessManager, ReleaseLister) name the places where the addon and the application families differ, each with a no-op for the family that does not need it. HelmReleaseName bounds a release name to Helm's 53-character limit without touching any name that already fits; Catalog.Lookup lets a release find the version it asks for. Nothing consumes the contract yet. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
The addon release adapter reproduces the released names and labels exactly and bounds the release name to Helm's limit. The repository resolver and the release lister give the repository branch a way to reach the addon's consumers without knowing their kind. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
…es through the release contract ChartService, ReleaseService and MaintenanceService take a source.Release (and, for the chart, the source.Repository whose internal HelmRepository it points at). ResolveChartSource takes the repository url so every kind can use it. The HelmRelease gains serviceAccountName and storageNamespace when the family impersonates — no family does yet. The addon reconciler wraps its objects at the boundary; behaviour is unchanged. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
…e lister The release side of OCIRepoService takes the contract. Forcing the consumers' internal OCIRepositories moves into ForceService, which is handed the family's ReleaseLister instead of listing addons itself; the catalog's in-use lookup is built from the same lister. The addon family is the only one wired so far. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
…release contract ClaimService takes a source.Release and implements source.ChartClaim; it stays an addon service in substance — the holder is read as a HelmClusterAddon. Creating the target namespace moves out of the reconciler into NamespaceService, the addon's TargetNamespaceEnsurer. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
The release reconciler reads the API object through a source.Release adapter and takes its kind-specific collaborators as Deps: repository resolver, chart claim, target-namespace ensurer and access manager. Each step is the addon's step of today; the package moves from reconcile/helmclusteraddon to reconcile/release. AccessSetupFailed is the reason a release reports when its identity cannot be set up — no family sets one up yet. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
The application adapter is the one place the two mutually exclusive repository fields are read; downstream sees a kind, a namespace and a name. Its internal objects carry namespace-aware derived names, the Helm release name is prefixed so a hand-installed release cannot be taken over, and lastAppliedChart is replaced wholesale. The HelmApplication indexes carry the repository kind and namespace, which is what lets the application catalogs see real consumers scoped to their namespace. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
…ied with AccessService creates a ServiceAccount in the operator namespace without a token, seeds a Role with full rights inside the application namespace and binds the two. The Role is created once and never reconciled: it is where a namespace owner cuts the rights down, and a RoleBinding cannot grant anything beyond the namespace regardless of its content. Cleanup removes the account and the binding and keeps the Role. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
…sing them Two mappers enqueue the HelmApplication objects referencing a repository or one of its charts, through indexes that carry the repository kind and namespace. The application repository controllers now force their real consumers instead of nobody; the last stage-5 stand-ins of the repository plan are gone. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
HelmApplication is served by the shared release reconciler with no chart claim, no target-namespace creation and an identity of its own: the chart is applied as a ServiceAccount bound to a namespace Role, so the release cannot reach beyond its namespace. The webhook rejects system namespaces and deletion under maintenance. The controller gains rights on roles and role bindings (with escalate and bind) and on service accounts in its own namespace; helm-controller already holds every verb. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
The chart of an application is applied as a ServiceAccount bound to a Role with every permission inside the namespace, so the right to create the resource is the right to administer the namespace. The CRD description — the source of the module's resource documentation — now says so, and that the Role may be narrowed by the namespace owner. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
The verdict of a failed rename was written only when the pass also attempted a synchronization, but the rename runs on every pass by design. A repository already past its schedule therefore kept the Synced=True its last successful sync had left, reporting health while its consumers could not resolve their chart, and the failure reached neither the work queue nor the logs. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
HelmApplicationRepository and HelmClusterApplicationRepository carry their registry credentials in plaintext (spec.auth.username/password), so any right to list them is a right to read that password. The viewer capability and the classic User role granted get/list/watch on both kinds; move that read to the level where Deckhouse first permits reading Secrets instead (the new user capability, and the PrivilegedUser role), leaving HelmApplication and the chart catalogs at viewer/User. Write-level roles keep read access transitively through the existing aggregation chain. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
A version the repository no longer offers survives only in the status of the object under the old name. Writing the fetched versions into the new, still-empty object made the next pass consider the carry-over done and delete that last copy, so a consumer pinned to that version lost the media type it needs and could not recover: the version is gone from the remote too. The gate before the synchronization now checks the rename alongside the two failures it already checked. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
EnsureAccess failing returned only the status-patch error, which is nil on a successful write, so a failed identity setup was reported once and never retried: nothing watches the ServiceAccount/RoleBinding it manages, and a status-only update is filtered out by the controller's predicates. Return the service error alongside the (best-effort) status write so the work queue's rate limiter picks the release back up. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
CleanupAccess failing during deletion already returned an error, so the finalizer correctly stays and the queue retries, but the status was left untouched. Every earlier step of the delete path marks the status through the shared status manager; this one did not, so the object gave no reason for why deletion was stuck. Call MarkDeletionFailed, mirroring the sibling repository reconciler's use of the same method. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
chartObjectName truncated and trimmed the repository and chart name but never lower-cased or replaced characters a Kubernetes object name cannot hold, so an upper-case or space-containing chart from a repository index (both legal there) produced a name the API server rejects, failing CreateOrPatch for that one entry and taking Synced=False for the whole repository with it. Sanitize both readable parts (lower-case, replace anything outside [a-z0-9.-] with a dash) before the existing truncate/trim logic runs, and widen the final trim to both ends so an empty or all-separator readable part no longer leaves a leading dash. The hash still hashes the raw inputs, so uniqueness is unaffected. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
…uest The handler decoded an unbounded body and validated only presence of the request fields before authorizing, so an unauthenticated caller could send an arbitrarily large body, hold the handler open past the header-only timeout, or send a whitespace-only version that would never match a catalog entry and poll as pending forever; an invalid repositoryName reached the resolver and read back as repository_not_found instead of a bad request. Bound the body with http.MaxBytesReader (answering 413 when exceeded), add ReadTimeout and WriteTimeout alongside the existing ReadHeaderTimeout, and move the cheap bearer-token presence check ahead of body parsing; the actual token/access review still needs the kind and namespace the body carries, so it stays after. Validate repositoryName as an object name and against the length bounds its own repository CRD enforces, and reject a whitespace-only chart or version, bounding both lengths without imposing a naming grammar neither field actually has. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
…take The release name hashed only what exceeded Helm's limit, so a short name spelled exactly like the cut and hashed form of a long one produced the same release. Both names are valid, both applications live in one namespace, and two releases under one name share one storage: each overwrites the other's history, and deleting one uninstalls the other. The application family now always carries the hash, which removes the second branch the two names met in. The addon family keeps its scheme — moving it would move every release installed today — and the limit is recorded where the scheme lives. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
…espace The rule named three kube- namespaces and the d8- prefix, so an application could be installed into default or into any other kube- one. Installing it there seeds a Role granting everything inside that namespace to whatever the chart contains. The rule mattered less when only a cluster administrator could create a namespaced resource here. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
Mirror the CEL rule already on HelmApplicationRepository and HelmClusterApplicationRepository: this repository's name is likewise copied into the "repository" label on its chart catalog objects, and a label value cannot exceed 63 characters. Because this rule also applies to updates of existing objects, any live HelmClusterAddonRepository whose name is shorter than 3 characters becomes un-updatable by this change, including status writes from the controller. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
The condition type and the reason were still spelled as literals here, while the application twin next to them uses the constants. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
The policy closed the status of every kind an application's account can reach but stopped short of the two cluster-scoped repositories. No role this module seeds reaches them, so nothing depends on it — but an entry missing from a list like this reads as an oversight rather than a decision. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
The delete helper promised to wait for the internal helm release and polled only the application, a weaker signal than the addon family's own helper gives. And the namespace each suite creates was never registered for deletion, so a run against a live cluster left it behind along with whatever the module had seeded in it. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
The resource list repeated the same sentence for all three catalog kinds and the limitations named one of them among the resources a namespace owner manages, which the policy and the roles both contradict. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
escalate and bind are checked against the role a RoleBinding or Role update would name, so both can be restricted to resourceNames: [operator-helm-application] — the only Role this module ever seeds. create is left unrestricted because the object does not exist yet when the request is authorized. rolebindings also dropped list and watch: the RoleBinding cache is disabled and the code only ever does Get, Create, Patch and Delete on it. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
The HelmApplication webhook and NamespaceService read a Namespace through the manager's cached client, whose reflector needs watch — a verb the ClusterRole does not grant. Without it the reflector relists in a loop and the cache is stale between relists, which matters for the webhook's delete path deciding whether the namespace is terminating. Both now read through mgr.GetAPIReader(), the same pattern ClaimService already uses for a decision that must not be made against stale data. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
Transport built a bare &http.Transport{} per probe, carrying none of the
default's dial/idle timeouts and never releasing its connection pool, and
ChartLayerMediaType had no deadline of its own, so a caller with none could
be held open indefinitely by a hung registry. Transport now clones
http.DefaultTransport before setting TLS, and the probe both bounds itself
with a probeTimeout floor (kept below the caller's own deadline when it has
one) and closes the single-use transport's idle connections once it returns.
Signed-off-by: Ilya Drey <ilya.drey@flant.com>
…pace
main.go disabled caching for ServiceAccount and RoleBinding but left Secret
alone, and the repository controllers watch Secrets, so the operator held
every Secret in the cluster in memory. Every Secret the operator reads or
writes lives in the module namespace (the repository services all take
helmv1alpha1.TargetNamespace), so cache.Options.ByObject restricts the Secret
informer to it. Adding Secret to Client.Cache.DisableFor instead would not
help: an explicit Watches(&corev1.Secret{}, ...) starts an informer regardless.
Signed-off-by: Ilya Drey <ilya.drey@flant.com>
The message is produced on update as well as on create, where "cannot be created" describes neither the request nor the rule. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
Scoping escalate by resourceNames denied every seeded role: the check authorizes against the name in the request path, and a create carries none, so the rule matched nothing and each application failed to set up its identity. bind keeps the scope — it is authorized against the name in the binding's roleRef, which is always present. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
It belongs to whoever works there, and that is a user without a namespace of their own — exactly the person this family exists for. Refusing an application in default kept them out for no reason the cluster shares. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
Every repository kind now records the size of the catalog it published at its last successful read, refreshed on each synchronization. The field is a pointer: a repository nobody has managed to read yet answers nothing, which is not the same answer as a repository offering no charts. A failed read leaves the previous count standing rather than replacing it with zero, so the field always describes a real reading. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
The experimental role model is dropped until it is added deliberately, and the classic one is reduced to the two levels that matter: Editor may do anything with an application and list its repositories and charts, Admin adds the repositories themselves and a read of the chart catalog. Levels accumulate upwards, so Admin carries only the difference. The cluster-scoped repository and its catalog lose their user-facing level with the experimental model and are reachable only cluster-wide. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
Reading a repository is reading its password, since the credentials live in the object; PrivilegedUser is the level from which Deckhouse permits reading Secrets, so that is where both kinds become readable. Admin keeps only what it adds: creating and modifying a repository. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
The addon family and the cluster-wide application repository had no role of their own, so they were reachable only by a binding written by hand. ClusterAdmin now carries them, and reads both cluster-scoped catalogs — writing a catalog belongs to the controller and to no one else. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
Installing an application carries namespace-admin rights, so the level below it had nothing to offer that was safe to offer: reading a repository is reading its password, and everything else it granted led straight to that install. Admin now holds the whole namespaced family. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
An application may take its chart from a HelmClusterApplicationRepository that only ClusterAdmin manages, and until now the person writing that application could not see what the repository offers. The catalog moves down to Admin; ClusterAdmin keeps the addon one, which is all that is left to add there. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
A client that lists a catalog usually wants to follow it, and refusing the watch only pushes it into polling. Signed-off-by: Ilya Drey <ilya.drey@flant.com>
Signed-off-by: Ilya Drey <ilya.drey@flant.com>
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.
Summary
Adds a namespace-scoped resource family so a namespace owner can install a Helm chart without cluster-wide rights: five new CRDs (
HelmApplication,HelmApplicationRepository,HelmApplicationChart,HelmClusterApplicationRepository,HelmClusterApplicationChart) and their controllers. The existing addon code is generalized rather than copied — both families now run through the same reconcilers.Why
Installing a chart required
cluster-admin, because every resource the module owns is cluster-scoped. A namespace owner needs a way to install charts in their own namespace without gaining access to the whole cluster.Two requirements shaped the design. A release must be applied under a bounded identity, or a chart carrying a cluster-scoped object would escape the namespace boundary. And the new family must not become a second copy of the addon code, which lives on just as long and would have to be edited in both places.
Key changes
API (
api/v1alpha1,api/naming,crds/). Five new CRDs with their Russian doc counterparts.RepositorySpec/RepositoryStatusand the chart catalog status are now shared types: all eight CRDs generate byte-identical to before, except onedescriptionline in the addon catalog. The catalog naming scheme moved toapi/namingand became injective: the hash now covers the repository and chart joined by a byte no name can hold, so two pairs can no longer produce one object name.Controller (
images/operator-helm-controller/internal). Domain contracts ininternal/source(repository, catalog, release, access) with a per-kind adapter ininternal/adapter. The catalogs collapsed into one genericinternal/catalogpackage, and the repository and release reconcilers are now shared. Adds controllers for the five new kinds and aHelmApplicationwebhook. A transitional step renames existing catalog objects to the current scheme on every repository reconcile, independent of the remote, carrying their status over before the old object is deleted.Release identity (
internal/services/access_service.go). Each application gets a token-less ServiceAccount ind8-operator-helm, aRoleseeded in the application's namespace with full rights inside it, and aRoleBinding. The Role is created once and never read back, so a namespace owner may narrow it afterwards. The internalHelmReleasecarriesspec.serviceAccountNameandspec.storageNamespace, and the release storage lives in the application's namespace.Chart values service (
images/chart-values-controller/internal). A repository kind is described by one family value instead of branching. The HTTP contract gained anamespacefield, and authorization is decided per family: permission to create aHelmClusterAddonfor addons, permission to create aHelmApplicationin the requested namespace for applications. The namespace is part of a chart's identity only for a namespaced family; for a cluster-scoped one it is only the authorization context. Auxiliary object names now key on the repository namespace, and the cluster-scoped name shape is unchanged.Templates (
templates/). RBAC for both controllers, the webhook configuration and theValidatingAdmissionPolicycover the new kinds. The controller's rights overrolesare narrowed tocreate,escalateandbind, since the Role is only ever seeded and never read back. Seven user-facing ClusterRoles are added for both Deckhouse role models, so a namespace owner can reach the new kinds at all: write access sits at admin level, the controller-written chart catalogs are read-only, and the cluster-scoped repository stays with cluster editors. The admission policy now also closes the application and application-repository statuses, which the seeded Role can otherwise reach.Tests and docs. New e2e suites
helmapplicationandhelmapplicationrepository. The derived-name scheme is mirrored intests/e2e/internal/naming(the operator'sinternalpackage cannot be imported) and tied to the operator's own implementation by twin tests. README and EXAMPLE in both languages describe the new family and correct the claim thatcluster-adminis required. CI gained a unit-test workflow and a generated-files check, and the Go lint job is enabled again now that the linter is built from source rather than downloaded.Review focus / risks
RemovedFromRepositorymarker and media type — and deletes the old object. The rename runs before anything touches the remote, so a repository not yet due for a sync, or one whose registry is gone for good, still migrates. Everything about it is markedTRANSITIONALand comes out once every cluster has reconciled once:MigrateNamesininternal/catalog/catalog.go, its interface entry, andInputs.MigrateErr.serviceAccountNameandstorageNamespaceare set, but thewerf/3p-helm-controllerfork holds full rights itself: an implementation honouring the storage namespace while ignoring the service account would pass every check in this branch. Proving it needs a chart containing a cluster-scoped object and an assertion ofReady=Falsewithforbidden. This is the one load-bearing assumption no test closes.HelmApplicationis effectively namespace-admin. The seeded Role grants*/*/*within the namespace. It is a deliberate decision, documented, but worth confirming the boundary is acceptable.templates/rbacv2/**entirely, its wildcard rule only reads files namedrbac-for-us.yaml, and its classic-model rule verifies the name and the access-level annotation but not the rules. No e2e exercises the aggregation chain either. Worth reading the labels against the upstream selectors by hand, in particular that write access lands at admin level and never at an editor one.helmclusterapplicationrepository. The request must carry a namespace and is authorized against it, but the namespace is dropped when the chart is resolved. Worth confirming the divergence is genuinely not exploitable.