-
Notifications
You must be signed in to change notification settings - Fork 31
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
Merged
Andrey Kolkov (androndo)
merged 3 commits into
main
from
feat/protect-members-at-admission
Aug 26, 2026
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
99 changes: 99 additions & 0 deletions
99
charts/etcd-operator/templates/member-deletion-policy.yaml
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,99 @@ | ||
| {{- /* | ||
| 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. | ||
|
|
||
| Nothing manages EtcdMember objects declaratively (the only sanctioned | ||
| non-operator writer is cmd/etcd-migrate, which creates and never deletes), so a | ||
| DELETE from anywhere else is always an accident or a tracking misconfiguration | ||
| — stopped at the boundary, with the requester told why. | ||
|
|
||
| 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; | ||
| - kube-controller-manager itself — on clusters run without | ||
| --use-service-account-credentials the GC and namespace-cleanup deletes | ||
| authenticate as the user system:kube-controller-manager rather than the | ||
| per-controller ServiceAccounts above. | ||
|
|
||
| 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. | ||
|
|
||
| Rendered unconditionally (NOT gated on .Capabilities.APIVersions): capability | ||
| detection would make a security guard's absence silent — a pre-1.30 `helm | ||
| install`, or a GitOps render carrying the destination's API versions, would | ||
| quietly omit it. Absence of a guard against an unrecoverable accident must be | ||
| loud and explicit: always render, and opt out via | ||
| memberDeletionProtection.enabled=false. | ||
| */ -}} | ||
| {{- 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" | ||
| "system:kube-controller-manager") | ||
| .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"] | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| {{- with .Values.manager.watchNamespaces }} | ||
| # Scope to the watched namespaces so multiple namespace-scoped releases don't | ||
| # deny each other's members (admission is deny-wins; each allowlist holds only | ||
| # its own operator SA). Cluster-wide install must be a singleton — see values.yaml. | ||
| matchResources: | ||
| namespaceSelector: | ||
| matchExpressions: | ||
| - key: kubernetes.io/metadata.name | ||
| operator: In | ||
| values: | ||
| {{- range . }} | ||
| - {{ . | quote }} | ||
| {{- end }} | ||
| {{- end }} | ||
| {{- end }} | ||
165 changes: 165 additions & 0 deletions
165
charts/etcd-operator/tests/member_deletion_policy_test.yaml
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,165 @@ | ||
| 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, the namespace controller and kube-controller-manager | ||
| 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 | ||
| # Clusters without --use-service-account-credentials run GC and namespace | ||
| # cleanup as the user system:kube-controller-manager, not per-controller SAs. | ||
| - matchRegex: | ||
| path: spec.validations[0].expression | ||
| pattern: system:kube-controller-manager | ||
|
|
||
| - 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 | ||
|
|
||
| # Cluster-wide by default (no watchNamespaces): the binding must match every | ||
| # namespace, so it carries no matchResources scoping. | ||
| - it: binds cluster-wide when the operator watches all namespaces | ||
| documentSelector: | ||
| path: kind | ||
| value: ValidatingAdmissionPolicyBinding | ||
| asserts: | ||
| - notExists: | ||
| path: spec.matchResources | ||
|
|
||
| # Namespace-scoped operator: the binding must scope to the watched namespaces | ||
| # so two scoped releases don't deny each other's members. | ||
| - it: scopes the binding to the watched namespaces when set | ||
| set: | ||
| manager: | ||
| watchNamespaces: | ||
| - team-a | ||
| - team-b | ||
| documentSelector: | ||
| path: kind | ||
| value: ValidatingAdmissionPolicyBinding | ||
| asserts: | ||
| - equal: | ||
| path: spec.matchResources.namespaceSelector.matchExpressions[0].key | ||
| value: kubernetes.io/metadata.name | ||
| - equal: | ||
| path: spec.matchResources.namespaceSelector.matchExpressions[0].operator | ||
| value: In | ||
| - equal: | ||
| path: spec.matchResources.namespaceSelector.matchExpressions[0].values | ||
| value: [team-a, team-b] |
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
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
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
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
Oops, something went wrong.
Oops, something went wrong.
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.
Uh oh!
There was an error while loading. Please reload this page.