-
Notifications
You must be signed in to change notification settings - Fork 28
feat(chart): deny EtcdMember deletion at admission #348
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| {{- /* | ||
| Enforces at the API boundary the contract the docs have carried since day one: | ||
| EtcdMember objects are created and deleted by the operator, not by users. | ||
|
|
||
| Deleting one is not a recoverable mistake. Its data PVC is controller-owned by | ||
| the member, so the volume — and on a Delete-reclaim StorageClass the data | ||
| itself — goes with it, while the member's finalizer removes the member from | ||
| etcd on the way out. A cluster whose members are deleted one by one therefore | ||
| dismembers itself and leaves nothing to restore from. | ||
|
|
||
| Denied here rather than survived in the controllers: the accident (a stray | ||
| kubectl delete, a GitOps prune, a cleanup script sweeping CRs) is what needs to | ||
| stop, and the requester learns why at the moment they try it. | ||
|
|
||
| Allowed through: | ||
| - the operator's own ServiceAccount — scale-down and crash-loop replacement | ||
| delete members deliberately; | ||
| - the garbage collector — cascade from a deleted EtcdCluster must still work; | ||
| - the namespace controller — deleting a namespace must not hang. | ||
|
|
||
| Break-glass without uninstalling the policy: annotate the member with | ||
| etcd-operator.cozystack.io/allow-deletion=true, then delete it. | ||
|
|
||
| Requires Kubernetes 1.30+ (ValidatingAdmissionPolicy GA). On an older | ||
| apiserver the install fails with "no matches for kind | ||
| ValidatingAdmissionPolicy"; set memberDeletionProtection.enabled=false to | ||
| install without the guard. Deliberately not gated on .Capabilities: that is | ||
| empty for a plain `helm template`, so gating there would silently drop the | ||
| policy from the rendered release manifests. | ||
| */ -}} | ||
| {{- if .Values.memberDeletionProtection.enabled }} | ||
| {{- $allowed := concat | ||
| (list | ||
| (printf "system:serviceaccount:%s:%s" .Release.Namespace (include "etcd-operator.serviceAccountName" .)) | ||
| "system:serviceaccount:kube-system:generic-garbage-collector" | ||
| "system:serviceaccount:kube-system:namespace-controller") | ||
| .Values.memberDeletionProtection.additionalAllowedUsers }} | ||
| apiVersion: admissionregistration.k8s.io/v1 | ||
| kind: ValidatingAdmissionPolicy | ||
| metadata: | ||
| name: {{ include "etcd-operator.fullname" . }}-protect-members | ||
| labels: | ||
| {{- include "etcd-operator.labels" . | nindent 4 }} | ||
| spec: | ||
| failurePolicy: Fail | ||
| matchConstraints: | ||
| resourceRules: | ||
| - apiGroups: ["etcd-operator.cozystack.io"] | ||
| apiVersions: ["*"] | ||
| operations: ["DELETE"] | ||
| resources: ["etcdmembers"] | ||
| validations: | ||
| - expression: >- | ||
| request.userInfo.username in {{ $allowed | toJson }} | ||
| || (has(oldObject.metadata.annotations) | ||
| && "etcd-operator.cozystack.io/allow-deletion" in oldObject.metadata.annotations | ||
| && oldObject.metadata.annotations["etcd-operator.cozystack.io/allow-deletion"] == "true") | ||
| reason: Forbidden | ||
| messageExpression: >- | ||
| "EtcdMember " + oldObject.metadata.name + " is managed exclusively by etcd-operator, and deleting it | ||
| removes the member from etcd and releases its data volume — the data is not recoverable from here. | ||
| Scale the EtcdCluster instead. If you really mean it, annotate the member with | ||
| etcd-operator.cozystack.io/allow-deletion=true first." | ||
| --- | ||
| apiVersion: admissionregistration.k8s.io/v1 | ||
| kind: ValidatingAdmissionPolicyBinding | ||
| metadata: | ||
| name: {{ include "etcd-operator.fullname" . }}-protect-members | ||
| labels: | ||
| {{- include "etcd-operator.labels" . | nindent 4 }} | ||
| spec: | ||
| policyName: {{ include "etcd-operator.fullname" . }}-protect-members | ||
| validationActions: ["Deny"] | ||
|
Comment on lines
+71
to
+73
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "Files:"
git ls-files | rg '(^|/)member-deletion-policy.yaml$|charts/|templates/'
echo
echo "Target file outline/stat:"
wc -l charts/etcd-operator/templates/member-deletion-policy.yaml
ast-grep outline charts/etcd-operator/templates/member-deletion-policy.yaml || true
echo
echo "Target file relevant contents:"
cat -n charts/etcd-operator/templates/member-deletion-policy.yaml | sed -n '1,140p'
echo
echo "Search for related templates/values/CRDs:"
find charts/etcd-operator -maxdepth 4 -type f -print | sort | xargs -r rg -n "allowlist|validationActions|policyName|etcd-operator\.fullname|validation|binding|Deny|EtcdMember|ServiceAccount|namespace|admission|restrict|member-deletion|protection" || trueRepository: cozystack/etcd-operator Length of output: 47357 🌐 Web query:
💡 Result: In Kubernetes, the Citations:
Scope the EtcdMember deletion policy binding per release namespace or enforce singleton installation. The binding has no 🤖 Prompt for AI Agents |
||
| {{- end }} | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,128 @@ | ||
| suite: EtcdMember deletion is denied at admission | ||
|
|
||
| # The policy is the enforcement point for a contract the docs have always | ||
| # stated: EtcdMember objects belong to the operator. What matters in these | ||
| # assertions is not the YAML shape but who keeps the ability to delete — | ||
| # getting that list wrong either wedges cluster/namespace deletion (too | ||
| # strict) or leaves the hole open (too loose). | ||
|
|
||
| templates: | ||
| - member-deletion-policy.yaml | ||
|
|
||
| tests: | ||
| - it: is installed by default | ||
| asserts: | ||
| - hasDocuments: | ||
| count: 2 | ||
| - containsDocument: | ||
| apiVersion: admissionregistration.k8s.io/v1 | ||
| kind: ValidatingAdmissionPolicy | ||
| name: RELEASE-NAME-etcd-operator-protect-members | ||
| documentIndex: 0 | ||
| - containsDocument: | ||
| apiVersion: admissionregistration.k8s.io/v1 | ||
| kind: ValidatingAdmissionPolicyBinding | ||
| name: RELEASE-NAME-etcd-operator-protect-members | ||
| documentIndex: 1 | ||
| # The binding must name the policy it enforces — a typo here installs a | ||
| # policy that matches nothing and silently protects nothing. | ||
| - equal: | ||
| path: spec.policyName | ||
| value: RELEASE-NAME-etcd-operator-protect-members | ||
| documentIndex: 1 | ||
|
|
||
| - it: matches only DELETE on etcdmembers | ||
| documentSelector: | ||
| path: kind | ||
| value: ValidatingAdmissionPolicy | ||
| asserts: | ||
| - equal: | ||
| path: spec.matchConstraints.resourceRules[0].operations | ||
| value: ["DELETE"] | ||
| - equal: | ||
| path: spec.matchConstraints.resourceRules[0].resources | ||
| value: ["etcdmembers"] | ||
| - equal: | ||
| path: spec.matchConstraints.resourceRules[0].apiGroups | ||
| value: ["etcd-operator.cozystack.io"] | ||
|
|
||
| - it: denies rather than warns, and fails closed | ||
| asserts: | ||
| - equal: | ||
| path: spec.validationActions | ||
| value: ["Deny"] | ||
| documentSelector: | ||
| path: kind | ||
| value: ValidatingAdmissionPolicyBinding | ||
| - equal: | ||
| path: spec.failurePolicy | ||
| value: Fail | ||
| documentSelector: | ||
| path: kind | ||
| value: ValidatingAdmissionPolicy | ||
|
|
||
| # The operator deletes members itself on scale-down and crash-loop | ||
| # replacement; the GC has to cascade from a deleted EtcdCluster; the | ||
| # namespace controller has to finish a namespace deletion. Denying any of | ||
| # the three turns a routine operation into a wedge. | ||
| - it: still allows the operator, the garbage collector and the namespace controller | ||
| documentSelector: | ||
| path: kind | ||
| value: ValidatingAdmissionPolicy | ||
| asserts: | ||
| - matchRegex: | ||
| path: spec.validations[0].expression | ||
| pattern: system:serviceaccount:NAMESPACE:RELEASE-NAME-etcd-operator | ||
| - matchRegex: | ||
| path: spec.validations[0].expression | ||
| pattern: system:serviceaccount:kube-system:generic-garbage-collector | ||
| - matchRegex: | ||
| path: spec.validations[0].expression | ||
| pattern: system:serviceaccount:kube-system:namespace-controller | ||
|
|
||
| - it: honours the break-glass annotation | ||
| documentSelector: | ||
| path: kind | ||
| value: ValidatingAdmissionPolicy | ||
| asserts: | ||
| - matchRegex: | ||
| path: spec.validations[0].expression | ||
| pattern: etcd-operator\.cozystack\.io/allow-deletion | ||
|
|
||
| # A platform whose own controller legitimately reaps members needs a way in | ||
| # that does not mean disabling the guard outright. | ||
| - it: accepts additional allowed users | ||
| set: | ||
| memberDeletionProtection: | ||
| additionalAllowedUsers: | ||
| - system:serviceaccount:platform:reaper | ||
| documentSelector: | ||
| path: kind | ||
| value: ValidatingAdmissionPolicy | ||
| asserts: | ||
| - matchRegex: | ||
| path: spec.validations[0].expression | ||
| pattern: system:serviceaccount:platform:reaper | ||
|
|
||
| # Kubernetes below 1.30 has no ValidatingAdmissionPolicy; the chart must | ||
| # still install there. | ||
| - it: can be switched off for older apiservers | ||
| set: | ||
| memberDeletionProtection: | ||
| enabled: false | ||
| asserts: | ||
| - hasDocuments: | ||
| count: 0 | ||
|
|
||
| - it: names the ServiceAccount the release actually uses | ||
| set: | ||
| serviceAccount: | ||
| create: false | ||
| name: byo-sa | ||
| documentSelector: | ||
| path: kind | ||
| value: ValidatingAdmissionPolicy | ||
| asserts: | ||
| - matchRegex: | ||
| path: spec.validations[0].expression | ||
| pattern: system:serviceaccount:NAMESPACE:byo-sa |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: cozystack/etcd-operator
Length of output: 4803
🏁 Script executed:
Repository: cozystack/etcd-operator
Length of output: 17388
🌐 Web query:
Kubernetes kube-controller-manager use-service-account-credentials service account username system:kube-controller-manager authorizationrbac💡 Result:
The --use-service-account-credentials flag for kube-controller-manager controls how individual control loops (controllers) authenticate with the Kubernetes API server [1][2]. When --use-service-account-credentials is set to true (default is false), the kube-controller-manager creates a dedicated service account for each built-in controller [3][2]. These controllers then use these individual service account credentials to perform their tasks [3][2]. The roles required for each controller are predefined in Kubernetes and are prefixed with system:controller: (e.g., system:controller:deployment-controller) [2]. In this mode, the kube-controller-manager's primary credential only requires the system:kube-controller-manager ClusterRole [2][4]. If --use-service-account-credentials is set to false (monolithic mode), the kube-controller-manager runs all its internal control loops using its own single credential [2][4]. In this configuration, that credential must be granted the system:kube-controller-manager ClusterRole as well as all the permissions required by every individual controller, which is not handled automatically by the cluster [2][4]. The system:kube-controller-manager user identity is a built-in system identity (User: system:kube-controller-manager) [5]. By default, this user is bound to the system:kube-controller-manager ClusterRole via a ClusterRoleBinding [5]. This role provides the base access required for the component to function, such as accessing essential resources like namespaces, endpoints, and secrets [2][6]. If the controller manager requires additional permissions (e.g., accessing ConfigMaps), administrators may need to update this ClusterRole or bind additional roles to the system:kube-controller-manager user [6].
Citations:
Allow the kube-controller-manager fallback identity.
Clusters running kube-controller-manager without
--use-service-account-credentialsauthenticate deletion requests assystem:kube-controller-manager, so cascade deletes from a deletedEtcdClusterand namespace cleanup can be rejected and leave objects stuck. Addsystem:kube-controller-managerto the allowed delete users and cover it in the member-deletion policy tests.Proposed fix
(printf "system:serviceaccount:%s:%s" .Release.Namespace (include "etcd-operator.serviceAccountName" .)) "system:serviceaccount:kube-system:generic-garbage-collector" "system:serviceaccount:kube-system:namespace-controller" + "system:kube-controller-manager")📝 Committable suggestion
🤖 Prompt for AI Agents