Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions charts/weka-operator/templates/manager.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,8 @@ spec:
value: "{{ .Values.debugSleep }}"
- name: WEKA_OPERATOR_MAINTENANCE_SA_NAME
value: "{{ .Values.prefix }}-maintenance"
- name: WEKA_OPERATOR_SERVICE_ACCOUNT_NAME
value: "{{ .Values.prefix }}-controller-manager"
- name: ENVOY_IMAGE
value: "{{ .Values.envoyImage | default "docker.io/envoyproxy/envoy:v1.31-latest" }}"
- name: WEKA_MAINTENANCE_IMAGE
Expand Down
4 changes: 3 additions & 1 deletion cmd/manager/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -485,5 +485,7 @@ func setupWebhook(ctx context.Context, mgr ctrl.Manager, admissionEnabled bool,
os.Exit(1)
}

logger.Info("Admission validating webhooks enabled", "webhooks", []string{"WekaCluster", "WekaClient", "WekaContainer"})
admission.RegisterFinalizerProtectionWebhook(mgr)

logger.Info("Admission validating webhooks enabled", "webhooks", []string{"WekaCluster", "WekaClient", "WekaContainer", "FinalizerProtection"})
}
100 changes: 100 additions & 0 deletions internal/admission/finalizer_protection.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package admission

import (
"context"
"fmt"
"net/http"

admissionv1 "k8s.io/api/admission/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/webhook"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"

"github.com/weka/weka-operator/internal/config"
"github.com/weka/weka-operator/internal/consts"
)

// FinalizerProtectionHandler is a raw admission handler that blocks
// removal of protected finalizers by non-operator service accounts.
// Unlike other validators, it has NO objectSelector escape hatch.
type FinalizerProtectionHandler struct {
decoder admission.Decoder
}

// protectedFinalizers is the set of finalizers that may only be removed
// by the operator's own service account.
var protectedFinalizers = []string{
consts.WekaFinalizer,
}

func (h *FinalizerProtectionHandler) Handle(_ context.Context, req admission.Request) admission.Response {

Check failure on line 31 in internal/admission/finalizer_protection.go

View workflow job for this annotation

GitHub Actions / Lint

hugeParam: req is heavy (400 bytes); consider passing it by pointer (gocritic)
// Only guard UPDATE; allow everything else unconditionally.
if req.Operation != admissionv1.Update {
return admission.Allowed("")
}

oldMeta := &metav1.PartialObjectMetadata{}
if err := h.decoder.DecodeRaw(req.OldObject, oldMeta); err != nil {
return admission.Errored(http.StatusBadRequest,
fmt.Errorf("failed to decode old object metadata: %w", err))
}

newMeta := &metav1.PartialObjectMetadata{}
if err := h.decoder.DecodeRaw(req.Object, newMeta); err != nil {
return admission.Errored(http.StatusBadRequest,
fmt.Errorf("failed to decode new object metadata: %w", err))
}

removed := removedFinalizers(oldMeta.Finalizers, newMeta.Finalizers)
if len(removed) == 0 {
return admission.Allowed("")
}

// Check whether the caller is the operator's own SA.
if config.Config.OperatorPodNamespace == "" || config.Config.OperatorServiceAccountName == "" {
return admission.Denied("operator namespace or service account name is not configured; cannot verify caller identity")
}
expected := fmt.Sprintf("system:serviceaccount:%s:%s",
config.Config.OperatorPodNamespace, config.Config.OperatorServiceAccountName)
if req.UserInfo.Username == expected {
return admission.Allowed("")
}
Comment on lines +54 to +62

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug — bypass is too permissive. This prefix check matches any service account in the operator's namespace, not just the operator's own SA. The operator namespace also hosts (at minimum) {{prefix}}-maintenance (see charts/weka-operator/templates/maintenance_service_account.yaml), the CSI controller SA, and the CSI node SA. Anything running under those identities could remove the protected finalizers and defeat the guard.

Match the exact operator SA instead, e.g.:

expected := fmt.Sprintf("system:serviceaccount:%s:%s",
    config.Config.OperatorPodNamespace, config.Config.OperatorServiceAccountName)
if req.UserInfo.Username == expected {
    return admission.Allowed("")
}

(OperatorServiceAccountName would need to be plumbed through env the same way MaintenanceSaName already is — see internal/config/env.go:392.)


// Non-operator caller removed a protected finalizer — deny.
return admission.Denied(fmt.Sprintf(
"removal of finalizer(s) %v is not allowed; they are managed by the weka-operator",
removed))
}

// removedFinalizers returns protected finalizers present in old but absent in new.
func removedFinalizers(old, current []string) []string {
newSet := make(map[string]struct{}, len(current))
for _, f := range current {
newSet[f] = struct{}{}
}

var removed []string
for _, f := range old {
if _, ok := newSet[f]; ok {
continue
}
for _, pf := range protectedFinalizers {
if f == pf {
removed = append(removed, f)
break
}
}
}
return removed
}

// RegisterFinalizerProtectionWebhook registers the finalizer-protection
// admission handler with the manager's webhook server.
func RegisterFinalizerProtectionWebhook(mgr ctrl.Manager) {
mgr.GetWebhookServer().Register(FinalizerProtectionWebhookPath, &webhook.Admission{
Handler: &FinalizerProtectionHandler{
decoder: admission.NewDecoder(mgr.GetScheme()),
},
})
}
40 changes: 39 additions & 1 deletion internal/admission/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ const (
WekaClientValidateWebhookPath = "/validate-weka-weka-io-v1alpha1-wekaclient"
WekaContainerValidateWebhookPath = "/validate-weka-weka-io-v1alpha1-wekacontainer"

FinalizerProtectionWebhookPath = "/protect-finalizers"

// SkipAdmissionLabel on a CR excludes it from admission via the VWC's
// objectSelector — per-object escape hatch for emergencies.
SkipAdmissionLabel = "weka.io/skip-admission"
Expand Down Expand Up @@ -359,6 +361,7 @@ func (m *WebhookManager) buildVWC(caBundle []byte) *admissionregistrationv1.Vali
clusterPath := WekaClusterValidateWebhookPath
clientPath := WekaClientValidateWebhookPath
containerPath := WekaContainerValidateWebhookPath
finalizerPath := FinalizerProtectionWebhookPath
failurePolicy := admissionregistrationv1.Fail

skipSelector := &metav1.LabelSelector{
Expand All @@ -368,7 +371,7 @@ func (m *WebhookManager) buildVWC(caBundle []byte) *admissionregistrationv1.Vali
}},
}

return &admissionregistrationv1.ValidatingWebhookConfiguration{
vwc := &admissionregistrationv1.ValidatingWebhookConfiguration{
ObjectMeta: metav1.ObjectMeta{Name: m.config.WebhookName},
Webhooks: []admissionregistrationv1.ValidatingWebhook{
{
Expand Down Expand Up @@ -460,4 +463,39 @@ func (m *WebhookManager) buildVWC(caBundle []byte) *admissionregistrationv1.Vali
},
},
}

// Finalizer-protection webhooks — NO objectSelector escape hatch.
for _, res := range []string{"wekaclusters", "wekaclients", "wekacontainers"} {
// Trim trailing "s" to derive the singular name for the webhook.
singular := res[:len(res)-1]
vwc.Webhooks = append(vwc.Webhooks, admissionregistrationv1.ValidatingWebhook{
Name: fmt.Sprintf("protect-finalizers.%s.weka.io", singular),
AdmissionReviewVersions: []string{"v1"},
SideEffects: &sideEffects,
FailurePolicy: &failurePolicy,
TimeoutSeconds: &timeoutSeconds,
ClientConfig: admissionregistrationv1.WebhookClientConfig{
Service: &admissionregistrationv1.ServiceReference{
Namespace: m.namespace,
Name: m.config.ServiceName,
Path: &finalizerPath,
},
CABundle: caBundle,
},
Rules: []admissionregistrationv1.RuleWithOperations{
{
Operations: []admissionregistrationv1.OperationType{
admissionregistrationv1.Update,
},
Rule: admissionregistrationv1.Rule{
APIGroups: []string{"weka.weka.io"},
APIVersions: []string{"v1alpha1"},
Resources: []string{res},
},
},
},
})
}

return vwc
}
2 changes: 2 additions & 0 deletions internal/config/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ var Config struct {
WekaHome WekaHome
DebugSleep int
MaintenanceSaName string
OperatorServiceAccountName string
MaintenanceImage string
EnvoyImage string
MaintenanceImagePullSecret string
Expand Down Expand Up @@ -390,6 +391,7 @@ func ConfigureEnv(ctx context.Context) {
Config.BindAddress.Metrics = getEnvOrFail("OPERATOR_METRICS_BIND_ADDRESS")
Config.BindAddress.HealthProbe = getEnvOrFail("HEALTH_PROBE_BIND_ADDRESS")
Config.MaintenanceSaName = getEnvOrFail("WEKA_OPERATOR_MAINTENANCE_SA_NAME")
Config.OperatorServiceAccountName = getEnvOrFail("WEKA_OPERATOR_SERVICE_ACCOUNT_NAME")
Config.OcpCompatibility.DriverToolkitSecretName = getEnvOrFail("WEKA_OCP_PULL_SECRET")
Comment on lines 391 to 395
}
Config.BindAddress.NodeAgent = getEnvOrDefault("NODE_AGENT_BIND_ADDRESS", ":8090")
Expand Down
Loading