-
Notifications
You must be signed in to change notification settings - Fork 197
ROX-37350: enforce unique policyName across SecurityPolicy CRs #23208
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: master
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 |
|---|---|---|
|
|
@@ -169,6 +169,38 @@ func (r *SecurityPolicyReconciler) Reconcile(ctx context.Context, req ctrl.Reque | |
| return ctrl.Result{}, nil | ||
| } | ||
|
|
||
| // Enforce uniqueness of spec.policyName across SecurityPolicy CRs. Because the | ||
| // controller resolves policies by name, two CRs sharing a policyName would both | ||
| // adopt the same Central policy: the second CR silently overwrites the first and | ||
| // the "loser" later gets stuck in deletion (ROX-37350). To guarantee a single, | ||
| // deterministic winner even when CRs are applied concurrently, the oldest CR | ||
| // keeps the name while any other CR is rejected with a clear condition. The | ||
| // rejected CR is requeued so it can reclaim the name once the winner goes away. | ||
| if conflicting, err := r.findConflictingSeniorCR(ctx, policyCR); err != nil { | ||
| return ctrl.Result{}, errors.Wrap(err, "Failed to check policyName uniqueness") | ||
| } else if conflicting != nil { | ||
| retErr := fmt.Errorf("duplicate policyName %q is already managed by SecurityPolicy %q; policyName must be unique", policyCR.Spec.PolicyName, conflicting.GetName()) | ||
| // Clear any recorded policy ID so a rejected CR can never delete a policy it | ||
| // does not own; the winning CR is solely responsible for that policy. | ||
| policyCR.Status.PolicyId = "" | ||
|
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. 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift Preserve ownership of a distinct policy during a rejected rename. If an accepted CR changes 🤖 Prompt for AI AgentsSource: Path instructions |
||
| policyCR.Status.Conditions.UpdateCondition(configstackroxiov1alpha1.SecurityPolicyCondition{ | ||
| Type: configstackroxiov1alpha1.PolicyValidated, | ||
| Status: "False", | ||
| Message: retErr.Error(), | ||
| }) | ||
| policyCR.Status.Conditions.UpdateCondition(configstackroxiov1alpha1.SecurityPolicyCondition{ | ||
| Type: configstackroxiov1alpha1.AcceptedByCentral, | ||
| Status: "False", | ||
| Message: retErr.Error(), | ||
| }) | ||
| if err := r.K8sClient.Status().Update(ctx, policyCR); err != nil { | ||
| return ctrl.Result{}, errors.Wrapf(err, "error updating status for securitypolicy %q", policyCR.GetName()) | ||
| } | ||
| log.Warnf("Rejecting SecurityPolicy %q: %v", policyCR.GetName(), retErr) | ||
| // Requeue so the CR can reclaim the name once the conflicting CR is removed. | ||
| return ctrl.Result{RequeueAfter: env.ConfigControllerReconcileInterval.DurationSetting()}, nil | ||
| } | ||
|
|
||
| if exists && existingPolicy.GetIsDefault() { | ||
| retErr := errors.New(fmt.Sprintf("Failed to reconcile: existing default policy with the same name '%s' exists", desiredState.GetName())) | ||
| policyCR.Status.Conditions.UpdateCondition(configstackroxiov1alpha1.SecurityPolicyCondition{ | ||
|
|
@@ -275,6 +307,45 @@ func (r *SecurityPolicyReconciler) UpdateCentralCaches(policyCR *configstackroxi | |
| return ctrl.Result{}, nil | ||
| } | ||
|
|
||
| // findConflictingSeniorCR returns another SecurityPolicy CR that shares policyCR's | ||
| // spec.policyName and that wins the ownership tie-break (i.e. policyCR must yield to | ||
| // it). It returns nil when policyCR may keep the name. CRs that are themselves being | ||
| // deleted are ignored, since they are relinquishing their name. | ||
| func (r *SecurityPolicyReconciler) findConflictingSeniorCR(ctx context.Context, policyCR *configstackroxiov1alpha1.SecurityPolicy) (*configstackroxiov1alpha1.SecurityPolicy, error) { | ||
| var list configstackroxiov1alpha1.SecurityPolicyList | ||
| if err := r.K8sClient.List(ctx, &list); err != nil { | ||
| return nil, errors.Wrap(err, "failed to list SecurityPolicy resources") | ||
| } | ||
| for i := range list.Items { | ||
| other := &list.Items[i] | ||
| if other.GetUID() == policyCR.GetUID() { | ||
| continue | ||
| } | ||
| if !other.ObjectMeta.DeletionTimestamp.IsZero() { | ||
| continue | ||
|
Comment on lines
+324
to
+325
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. 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift Keep the senior CR authoritative until its Central deletion finishes. A deletion timestamp does not mean the senior CR’s Central policy has been deleted. While its finalizer is still pending, this check lets the junior CR adopt and update that policy; the senior CR can then delete the same ID. The junior CR retains an accepted status for a policy that no longer exists. Wait until the senior CR has completed deletion before allowing the junior CR to claim the name. As per path instructions, “Focus on major issues impacting performance, readability, maintainability and security. Avoid nitpicks and avoid verbosity.” 🤖 Prompt for AI AgentsSource: Path instructions |
||
| } | ||
| if other.Spec.PolicyName != policyCR.Spec.PolicyName { | ||
| continue | ||
| } | ||
| if isSeniorTo(other, policyCR) { | ||
| return other, nil | ||
| } | ||
| } | ||
| return nil, nil | ||
| } | ||
|
|
||
| // isSeniorTo reports whether candidate wins a policyName collision over current. The | ||
| // older CR (by creation timestamp) wins; ties are broken deterministically by the | ||
| // lexicographically smaller UID so that exactly one CR is ever the winner. | ||
| func isSeniorTo(candidate, current *configstackroxiov1alpha1.SecurityPolicy) bool { | ||
| ct := candidate.GetCreationTimestamp() | ||
| cur := current.GetCreationTimestamp() | ||
| if !ct.Equal(&cur) { | ||
| return ct.Before(&cur) | ||
| } | ||
| return candidate.GetUID() < current.GetUID() | ||
| } | ||
|
|
||
| func getEventFilter() predicate.Funcs { | ||
| return predicate.Funcs{ | ||
| UpdateFunc: func(e event.UpdateEvent) bool { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,113 @@ | ||
| package controller | ||
|
|
||
| import ( | ||
| "context" | ||
| "testing" | ||
| "time" | ||
|
|
||
| configstackroxiov1alpha1 "github.com/stackrox/rox/config-controller/api/v1alpha1" | ||
| "github.com/stretchr/testify/assert" | ||
| "github.com/stretchr/testify/require" | ||
| metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" | ||
| "k8s.io/apimachinery/pkg/runtime" | ||
| "k8s.io/apimachinery/pkg/types" | ||
| "sigs.k8s.io/controller-runtime/pkg/client/fake" | ||
| ) | ||
|
|
||
| var baseTime = time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) | ||
|
|
||
| func makeCR(name, policyName, uid string, created time.Time, deleting bool) *configstackroxiov1alpha1.SecurityPolicy { | ||
| cr := &configstackroxiov1alpha1.SecurityPolicy{ | ||
| ObjectMeta: metav1.ObjectMeta{ | ||
| Name: name, | ||
| Namespace: "stackrox", | ||
| UID: types.UID(uid), | ||
| CreationTimestamp: metav1.NewTime(created), | ||
| }, | ||
| Spec: configstackroxiov1alpha1.SecurityPolicySpec{PolicyName: policyName}, | ||
| } | ||
| if deleting { | ||
| ts := metav1.NewTime(created) | ||
| cr.ObjectMeta.DeletionTimestamp = &ts | ||
| // A fake client requires a finalizer on objects created with a deletion timestamp. | ||
| cr.ObjectMeta.Finalizers = []string{policyFinalizer} | ||
| } | ||
| return cr | ||
| } | ||
|
|
||
| func newTestReconciler(t *testing.T, objs ...runtime.Object) *SecurityPolicyReconciler { | ||
| scheme := runtime.NewScheme() | ||
| require.NoError(t, configstackroxiov1alpha1.AddToScheme(scheme)) | ||
| cl := fake.NewClientBuilder().WithScheme(scheme).WithRuntimeObjects(objs...).Build() | ||
| return &SecurityPolicyReconciler{K8sClient: cl} | ||
| } | ||
|
|
||
| // TestFindConflictingSeniorCR validates policyName uniqueness resolution across CRs. | ||
| func TestFindConflictingSeniorCR(t *testing.T) { | ||
| cases := map[string]struct { | ||
| subject *configstackroxiov1alpha1.SecurityPolicy | ||
| others []runtime.Object | ||
| expectConfli bool | ||
| expectName string | ||
| }{ | ||
| "no other CRs - may keep name": { | ||
| subject: makeCR("cr1", "Test-Policy-A", "uid-1", baseTime, false), | ||
| others: nil, | ||
| expectConfli: false, | ||
| }, | ||
| "different policyName - no conflict": { | ||
| subject: makeCR("cr2", "Test-Policy-B", "uid-2", baseTime.Add(time.Hour), false), | ||
| others: []runtime.Object{ | ||
| makeCR("cr1", "Test-Policy-A", "uid-1", baseTime, false), | ||
| }, | ||
| expectConfli: false, | ||
| }, | ||
| "junior yields to senior": { | ||
| subject: makeCR("cr2", "Test-Policy-A", "uid-2", baseTime.Add(time.Hour), false), | ||
| others: []runtime.Object{ | ||
| makeCR("cr1", "Test-Policy-A", "uid-1", baseTime, false), | ||
| }, | ||
| expectConfli: true, | ||
| expectName: "cr1", | ||
| }, | ||
| "senior keeps name over junior": { | ||
| subject: makeCR("cr1", "Test-Policy-A", "uid-1", baseTime, false), | ||
| others: []runtime.Object{ | ||
| makeCR("cr2", "Test-Policy-A", "uid-2", baseTime.Add(time.Hour), false), | ||
| }, | ||
| expectConfli: false, | ||
| }, | ||
| "same timestamp broken by smaller UID": { | ||
| subject: makeCR("cr-b", "Test-Policy-A", "uid-b", baseTime, false), | ||
| others: []runtime.Object{ | ||
| makeCR("cr-a", "Test-Policy-A", "uid-a", baseTime, false), | ||
| }, | ||
| expectConfli: true, | ||
| expectName: "cr-a", | ||
| }, | ||
| "conflicting CR being deleted is ignored": { | ||
| subject: makeCR("cr2", "Test-Policy-A", "uid-2", baseTime.Add(time.Hour), false), | ||
| others: []runtime.Object{ | ||
| makeCR("cr1", "Test-Policy-A", "uid-1", baseTime, true), | ||
| }, | ||
| expectConfli: false, | ||
| }, | ||
| } | ||
|
|
||
| for name, tc := range cases { | ||
| t.Run(name, func(t *testing.T) { | ||
| objs := append([]runtime.Object{tc.subject}, tc.others...) | ||
| r := newTestReconciler(t, objs...) | ||
|
|
||
| conflicting, err := r.findConflictingSeniorCR(context.Background(), tc.subject) | ||
| require.NoError(t, err) | ||
|
|
||
| if tc.expectConfli { | ||
| require.NotNil(t, conflicting, "expected a conflicting senior CR") | ||
| assert.Equal(t, tc.expectName, conflicting.GetName()) | ||
| } else { | ||
| assert.Nil(t, conflicting, "expected no conflict") | ||
| } | ||
| }) | ||
| } | ||
| } |
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.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
Check ownership before deleting a previously accepted duplicate.
The deletion branch runs before
findConflictingSeniorCR. If two CRs already record the same Central policy ID, deleting the junior CR before it reaches this new check callsDeletePolicyand can remove the senior CR’s policy. Check duplicate ownership on the deletion path before deleting a recorded ID. As per path instructions, “Focus on major issues impacting performance, readability, maintainability and security. Avoid nitpicks and avoid verbosity.”🤖 Prompt for AI Agents
Source: Path instructions