mirror of
https://github.com/lukaszraczylo/kubemirror.git
synced 2026-07-11 15:11:59 +00:00
fix(controller): guard mirror deletion + enforce secret blacklist
C1: deleteAllMirrors used to issue a blind Delete on every namespace matching the source name+GVK, which would destroy unrelated resources (e.g. a 'default' SA, 'ca-bundle' ConfigMap) sharing the source name. Now reads each candidate, verifies managed-by label and source-reference annotation, and only deletes confirmed mirrors. M1: BlacklistedSecretTypes was declared but never enforced. Enabling mirroring on a service-account-token / bootstrap-token / helm release Secret would mirror credentials cluster-wide. Now refused at Reconcile. M3: deleteAllMirrors swallowed per-namespace errors and returned nil, so callers removed the finalizer even on partial failure (orphans). Errors are now joined and returned.
This commit is contained in:
@@ -3,6 +3,7 @@ package controller
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
stderrors "errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"slices"
|
"slices"
|
||||||
"time"
|
"time"
|
||||||
@@ -197,6 +198,22 @@ func (r *SourceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr
|
|||||||
return ctrl.Result{}, nil
|
return ctrl.Result{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Refuse to mirror sensitive Secret types (service-account tokens, bootstrap
|
||||||
|
// tokens, helm release blobs). Mirroring these to other namespaces is a
|
||||||
|
// credential exposure path. If a finalizer is already set (the resource was
|
||||||
|
// enabled before we started enforcing this list), tear down via handleDisabled
|
||||||
|
// so any prior mirrors are cleaned up.
|
||||||
|
if isBlacklistedSecret(sourceObj) {
|
||||||
|
secretType, _, _ := unstructured.NestedString(sourceObj.Object, "type")
|
||||||
|
logger.Info("refusing to mirror blacklisted Secret type",
|
||||||
|
"type", secretType,
|
||||||
|
"reason", "credential exposure risk")
|
||||||
|
if slices.Contains(sourceObj.GetFinalizers(), constants.FinalizerName) {
|
||||||
|
return r.handleDisabled(ctx, sourceObj)
|
||||||
|
}
|
||||||
|
return ctrl.Result{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Add finalizer if not present
|
// Add finalizer if not present
|
||||||
if !slices.Contains(sourceObj.GetFinalizers(), constants.FinalizerName) {
|
if !slices.Contains(sourceObj.GetFinalizers(), constants.FinalizerName) {
|
||||||
logger.Info("adding finalizer to source resource")
|
logger.Info("adding finalizer to source resource")
|
||||||
@@ -401,44 +418,65 @@ func (r *SourceReconciler) reconcileMirror(ctx context.Context, source runtime.O
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// deleteAllMirrors deletes all mirrors for a source resource.
|
// deleteAllMirrors deletes all mirrors that this source owns across the cluster.
|
||||||
|
// It verifies ownership (managed-by label + source-reference annotation) before
|
||||||
|
// deleting anything to avoid destroying unrelated resources that happen to share
|
||||||
|
// the source's name. Per-namespace failures are aggregated so callers can defer
|
||||||
|
// finalizer removal until cleanup actually succeeds.
|
||||||
func (r *SourceReconciler) deleteAllMirrors(ctx context.Context, sourceObj metav1.Object) error {
|
func (r *SourceReconciler) deleteAllMirrors(ctx context.Context, sourceObj metav1.Object) error {
|
||||||
logger := log.FromContext(ctx)
|
logger := log.FromContext(ctx)
|
||||||
|
|
||||||
// List all namespaces
|
|
||||||
allNamespaces, err := r.NamespaceLister.ListNamespaces(ctx)
|
allNamespaces, err := r.NamespaceLister.ListNamespaces(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to list namespaces: %w", err)
|
return fmt.Errorf("failed to list namespaces: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get GVK from source object
|
|
||||||
sourceUnstructured, ok := sourceObj.(*unstructured.Unstructured)
|
sourceUnstructured, ok := sourceObj.(*unstructured.Unstructured)
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("source object is not unstructured")
|
return fmt.Errorf("source object is not unstructured")
|
||||||
}
|
}
|
||||||
|
|
||||||
var deleteCount int
|
var (
|
||||||
|
deleteCount int
|
||||||
|
deleteErrs []error
|
||||||
|
)
|
||||||
for _, ns := range allNamespaces {
|
for _, ns := range allNamespaces {
|
||||||
// Skip source namespace
|
|
||||||
if ns == sourceObj.GetNamespace() {
|
if ns == sourceObj.GetNamespace() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create mirror reference for deletion
|
existing := &unstructured.Unstructured{}
|
||||||
mirror := &unstructured.Unstructured{}
|
existing.SetGroupVersionKind(sourceUnstructured.GroupVersionKind())
|
||||||
mirror.SetGroupVersionKind(sourceUnstructured.GroupVersionKind())
|
getErr := r.Get(ctx, client.ObjectKey{Namespace: ns, Name: sourceObj.GetName()}, existing)
|
||||||
mirror.SetNamespace(ns)
|
if errors.IsNotFound(getErr) {
|
||||||
mirror.SetName(sourceObj.GetName())
|
continue
|
||||||
|
|
||||||
err := r.Delete(ctx, mirror)
|
|
||||||
if err == nil {
|
|
||||||
deleteCount++
|
|
||||||
} else if !errors.IsNotFound(err) {
|
|
||||||
logger.Error(err, "failed to delete mirror", "namespace", ns)
|
|
||||||
}
|
}
|
||||||
|
if getErr != nil {
|
||||||
|
logger.Error(getErr, "failed to fetch potential mirror", "namespace", ns)
|
||||||
|
deleteErrs = append(deleteErrs, fmt.Errorf("get mirror %s/%s: %w", ns, sourceObj.GetName(), getErr))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !IsManagedByUs(existing) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
srcNs, srcName, _, found := GetSourceReference(existing)
|
||||||
|
if !found || srcNs != sourceObj.GetNamespace() || srcName != sourceObj.GetName() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if delErr := r.Delete(ctx, existing); delErr != nil && !errors.IsNotFound(delErr) {
|
||||||
|
logger.Error(delErr, "failed to delete mirror", "namespace", ns)
|
||||||
|
deleteErrs = append(deleteErrs, fmt.Errorf("delete mirror %s/%s: %w", ns, sourceObj.GetName(), delErr))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
deleteCount++
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Info("deleted mirrors", "count", deleteCount)
|
logger.Info("deleted mirrors", "count", deleteCount, "errors", len(deleteErrs))
|
||||||
|
if len(deleteErrs) > 0 {
|
||||||
|
return stderrors.Join(deleteErrs...)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -603,6 +641,19 @@ func (r *SourceReconciler) updateLastSyncStatus(ctx context.Context, source runt
|
|||||||
return r.Update(ctx, source.(*unstructured.Unstructured))
|
return r.Update(ctx, source.(*unstructured.Unstructured))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isBlacklistedSecret reports whether the given source is a core/v1 Secret with
|
||||||
|
// a Type that must never be mirrored across namespaces.
|
||||||
|
func isBlacklistedSecret(obj *unstructured.Unstructured) bool {
|
||||||
|
if obj.GetKind() != "Secret" || obj.GetAPIVersion() != "v1" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
secretType, found, err := unstructured.NestedString(obj.Object, "type")
|
||||||
|
if err != nil || !found {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return slices.Contains(constants.BlacklistedSecretTypes, secretType)
|
||||||
|
}
|
||||||
|
|
||||||
// isEnabledForMirroring checks if a resource has both the label and annotation for mirroring.
|
// isEnabledForMirroring checks if a resource has both the label and annotation for mirroring.
|
||||||
func isEnabledForMirroring(obj metav1.Object) bool {
|
func isEnabledForMirroring(obj metav1.Object) bool {
|
||||||
// Check label
|
// Check label
|
||||||
|
|||||||
@@ -879,3 +879,227 @@ func TestSourceReconciler_Reconcile_AnnotationChange_PatternChange(t *testing.T)
|
|||||||
mockClient.AssertExpectations(t)
|
mockClient.AssertExpectations(t)
|
||||||
mockLister.AssertExpectations(t)
|
mockLister.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
|
func TestSourceReconciler_deleteAllMirrors_skipsUnmanagedResources(t *testing.T) {
|
||||||
|
// Regression test: deleteAllMirrors must NOT delete a resource it does not own,
|
||||||
|
// even if the name and GVK happen to match the source.
|
||||||
|
sourceObj := &unstructured.Unstructured{
|
||||||
|
Object: map[string]interface{}{
|
||||||
|
"apiVersion": "v1",
|
||||||
|
"kind": "Secret",
|
||||||
|
"metadata": map[string]interface{}{
|
||||||
|
"name": "shared-name",
|
||||||
|
"namespace": "default",
|
||||||
|
"uid": "source-uid",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
mockClient := new(MockClient)
|
||||||
|
mockLister := new(MockNamespaceLister)
|
||||||
|
mockLister.On("ListNamespaces", mock.Anything).Return([]string{"default", "ns-other"}, nil)
|
||||||
|
|
||||||
|
// In ns-other a resource with the same name/GVK exists but is NOT managed
|
||||||
|
// by kubemirror — pretend it's a regular Secret created by another operator.
|
||||||
|
otherSecret := &unstructured.Unstructured{
|
||||||
|
Object: map[string]interface{}{
|
||||||
|
"apiVersion": "v1",
|
||||||
|
"kind": "Secret",
|
||||||
|
"metadata": map[string]interface{}{
|
||||||
|
"name": "shared-name",
|
||||||
|
"namespace": "ns-other",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
mockClient.On("Get", mock.Anything,
|
||||||
|
types.NamespacedName{Namespace: "ns-other", Name: "shared-name"},
|
||||||
|
mock.AnythingOfType("*unstructured.Unstructured")).
|
||||||
|
Return(nil, otherSecret)
|
||||||
|
|
||||||
|
r := &SourceReconciler{Client: mockClient, NamespaceLister: mockLister}
|
||||||
|
|
||||||
|
err := r.deleteAllMirrors(context.Background(), sourceObj)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// The critical assertion: Delete was NEVER called.
|
||||||
|
mockClient.AssertNotCalled(t, "Delete", mock.Anything, mock.Anything, mock.Anything)
|
||||||
|
mockLister.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSourceReconciler_deleteAllMirrors_aggregatesErrors(t *testing.T) {
|
||||||
|
// Regression test: per-namespace deletion failures must be returned (joined),
|
||||||
|
// otherwise callers will remove the finalizer and orphan the failed mirrors.
|
||||||
|
sourceObj := &unstructured.Unstructured{
|
||||||
|
Object: map[string]interface{}{
|
||||||
|
"apiVersion": "v1",
|
||||||
|
"kind": "Secret",
|
||||||
|
"metadata": map[string]interface{}{
|
||||||
|
"name": "test-secret",
|
||||||
|
"namespace": "default",
|
||||||
|
"uid": "source-uid",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
managedMirror := func(ns string) *unstructured.Unstructured {
|
||||||
|
return &unstructured.Unstructured{
|
||||||
|
Object: map[string]interface{}{
|
||||||
|
"apiVersion": "v1",
|
||||||
|
"kind": "Secret",
|
||||||
|
"metadata": map[string]interface{}{
|
||||||
|
"name": "test-secret",
|
||||||
|
"namespace": ns,
|
||||||
|
"labels": map[string]interface{}{
|
||||||
|
constants.LabelManagedBy: constants.ControllerName,
|
||||||
|
constants.LabelMirror: "true",
|
||||||
|
},
|
||||||
|
"annotations": map[string]interface{}{
|
||||||
|
constants.AnnotationSourceNamespace: "default",
|
||||||
|
constants.AnnotationSourceName: "test-secret",
|
||||||
|
constants.AnnotationSourceUID: "source-uid",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mockClient := new(MockClient)
|
||||||
|
mockLister := new(MockNamespaceLister)
|
||||||
|
mockLister.On("ListNamespaces", mock.Anything).Return([]string{"default", "ns-ok", "ns-fail"}, nil)
|
||||||
|
|
||||||
|
mockClient.On("Get", mock.Anything,
|
||||||
|
types.NamespacedName{Namespace: "ns-ok", Name: "test-secret"},
|
||||||
|
mock.AnythingOfType("*unstructured.Unstructured")).
|
||||||
|
Return(nil, managedMirror("ns-ok"))
|
||||||
|
mockClient.On("Get", mock.Anything,
|
||||||
|
types.NamespacedName{Namespace: "ns-fail", Name: "test-secret"},
|
||||||
|
mock.AnythingOfType("*unstructured.Unstructured")).
|
||||||
|
Return(nil, managedMirror("ns-fail"))
|
||||||
|
|
||||||
|
mockClient.On("Delete", mock.Anything, mock.MatchedBy(func(obj client.Object) bool {
|
||||||
|
u, ok := obj.(*unstructured.Unstructured)
|
||||||
|
return ok && u.GetNamespace() == "ns-ok"
|
||||||
|
}), mock.Anything).Return(nil)
|
||||||
|
|
||||||
|
mockClient.On("Delete", mock.Anything, mock.MatchedBy(func(obj client.Object) bool {
|
||||||
|
u, ok := obj.(*unstructured.Unstructured)
|
||||||
|
return ok && u.GetNamespace() == "ns-fail"
|
||||||
|
}), mock.Anything).Return(fmt.Errorf("webhook denied"))
|
||||||
|
|
||||||
|
r := &SourceReconciler{Client: mockClient, NamespaceLister: mockLister}
|
||||||
|
err := r.deleteAllMirrors(context.Background(), sourceObj)
|
||||||
|
require.Error(t, err, "must surface deletion failure so finalizer is retained")
|
||||||
|
assert.Contains(t, err.Error(), "ns-fail")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsBlacklistedSecret(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
obj *unstructured.Unstructured
|
||||||
|
name string
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "service-account-token blacklisted",
|
||||||
|
obj: &unstructured.Unstructured{Object: map[string]interface{}{
|
||||||
|
"apiVersion": "v1", "kind": "Secret",
|
||||||
|
"type": "kubernetes.io/service-account-token",
|
||||||
|
}},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bootstrap token blacklisted",
|
||||||
|
obj: &unstructured.Unstructured{Object: map[string]interface{}{
|
||||||
|
"apiVersion": "v1", "kind": "Secret",
|
||||||
|
"type": "bootstrap.kubernetes.io/token",
|
||||||
|
}},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "helm release blacklisted",
|
||||||
|
obj: &unstructured.Unstructured{Object: map[string]interface{}{
|
||||||
|
"apiVersion": "v1", "kind": "Secret",
|
||||||
|
"type": "helm.sh/release.v1",
|
||||||
|
}},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "opaque secret allowed",
|
||||||
|
obj: &unstructured.Unstructured{Object: map[string]interface{}{
|
||||||
|
"apiVersion": "v1", "kind": "Secret",
|
||||||
|
"type": "Opaque",
|
||||||
|
}},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "secret without type allowed",
|
||||||
|
obj: &unstructured.Unstructured{Object: map[string]interface{}{
|
||||||
|
"apiVersion": "v1", "kind": "Secret",
|
||||||
|
}},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "configmap with matching type ignored",
|
||||||
|
obj: &unstructured.Unstructured{Object: map[string]interface{}{
|
||||||
|
"apiVersion": "v1", "kind": "ConfigMap",
|
||||||
|
"type": "kubernetes.io/service-account-token",
|
||||||
|
}},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tc.expected, isBlacklistedSecret(tc.obj))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSourceReconciler_Reconcile_RefusesBlacklistedSecret(t *testing.T) {
|
||||||
|
// Regression test: enabling mirroring on a service-account-token Secret
|
||||||
|
// must NOT cause it to be mirrored anywhere.
|
||||||
|
mockClient := new(MockClient)
|
||||||
|
mockLister := new(MockNamespaceLister)
|
||||||
|
|
||||||
|
tokenSecret := &unstructured.Unstructured{
|
||||||
|
Object: map[string]interface{}{
|
||||||
|
"apiVersion": "v1",
|
||||||
|
"kind": "Secret",
|
||||||
|
"metadata": map[string]interface{}{
|
||||||
|
"name": "sa-token",
|
||||||
|
"namespace": "default",
|
||||||
|
"labels": map[string]interface{}{
|
||||||
|
constants.LabelEnabled: "true",
|
||||||
|
},
|
||||||
|
"annotations": map[string]interface{}{
|
||||||
|
constants.AnnotationSync: "true",
|
||||||
|
constants.AnnotationTargetNamespaces: "all",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"type": "kubernetes.io/service-account-token",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
mockClient.On("Get", mock.Anything,
|
||||||
|
types.NamespacedName{Namespace: "default", Name: "sa-token"},
|
||||||
|
mock.AnythingOfType("*unstructured.Unstructured")).
|
||||||
|
Return(nil, tokenSecret)
|
||||||
|
|
||||||
|
r := &SourceReconciler{
|
||||||
|
Client: mockClient,
|
||||||
|
Scheme: runtime.NewScheme(),
|
||||||
|
Config: &config.Config{},
|
||||||
|
Filter: filter.NewNamespaceFilter([]string{}, []string{}),
|
||||||
|
NamespaceLister: mockLister,
|
||||||
|
GVK: schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Secret"},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := r.Reconcile(context.Background(),
|
||||||
|
ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "default", Name: "sa-token"}})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, ctrl.Result{}, result)
|
||||||
|
|
||||||
|
// Critical: no namespace listing, no Create, no Update — the Secret was
|
||||||
|
// rejected before anything mirroring-related happened.
|
||||||
|
mockLister.AssertNotCalled(t, "ListNamespacesWithLabels", mock.Anything)
|
||||||
|
mockClient.AssertNotCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user