diff --git a/charts/weka-operator/templates/manager.yaml b/charts/weka-operator/templates/manager.yaml index 8bee87898..a015d8ccc 100644 --- a/charts/weka-operator/templates/manager.yaml +++ b/charts/weka-operator/templates/manager.yaml @@ -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 diff --git a/cmd/manager/main.go b/cmd/manager/main.go index 9df2b7cf5..87f23c803 100644 --- a/cmd/manager/main.go +++ b/cmd/manager/main.go @@ -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"}) } diff --git a/internal/admission/finalizer_protection.go b/internal/admission/finalizer_protection.go new file mode 100644 index 000000000..85b629d69 --- /dev/null +++ b/internal/admission/finalizer_protection.go @@ -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 { + // 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("") + } + + // 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()), + }, + }) +} diff --git a/internal/admission/manager.go b/internal/admission/manager.go index 478b11a7e..3c3be041c 100644 --- a/internal/admission/manager.go +++ b/internal/admission/manager.go @@ -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" @@ -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{ @@ -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{ { @@ -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 } diff --git a/internal/config/env.go b/internal/config/env.go index 350db0be7..3c8f44cc6 100644 --- a/internal/config/env.go +++ b/internal/config/env.go @@ -218,6 +218,7 @@ var Config struct { WekaHome WekaHome DebugSleep int MaintenanceSaName string + OperatorServiceAccountName string MaintenanceImage string EnvoyImage string MaintenanceImagePullSecret string @@ -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") } Config.BindAddress.NodeAgent = getEnvOrDefault("NODE_AGENT_BIND_ADDRESS", ":8090")