Skip to content

Commit de748ab

Browse files
fix: fixing bug with resources deletion (authPolicy and TRLP) after removing model ref (#611)
JIRA - https://redhat.atlassian.net/browse/RHOAIENG-55153 Both reconcilers only iterated over current spec.modelRefs. When a model was removed from the spec, its generated AuthPolicy / TokenRateLimitPolicy was never cleaned up - leaving stale authpolicy and/or trlp on the cluster. The fix adds a cleanup step after the main reconcile loop. It lists all managed generated policies, checks their annotation to see if this CR contributed, and deletes any whose model is no longer in spec.modelRefs. The existing watch mechanism then triggers remaining CRs to rebuild the aggregated policy. The same cleanup runs in the finalizer deletion path as well. Four unit tests cover single-policy removal and multi-policy aggregation for both controllers. All existing tests pass. The fix was verified on a live OC cluster with three scenarios: single AuthPolicy removal, single TRLP removal, and two-policy aggregation rebuild. All passed. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Automatic cleanup of orphaned authentication policies and token-rate-limit policies when model references are removed. * **Changes** * Authentication now uses API-key validation exclusively; Kubernetes token path removed. * Authorization rules converted to pattern-matching over API-key validation. * Response headers limited to API-key validation fields; subscription header injection removed. * **Tests** * Added reconciliation tests covering removal and aggregation behavior for auth and TRLP resources. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Signed-off-by: Dmytro Zaharnytskyi <zdmytro@redhat.com>
1 parent d160b6c commit de748ab

File tree

4 files changed

+473
-1
lines changed

4 files changed

+473
-1
lines changed

maas-controller/pkg/controller/maas/maasauthpolicy_controller.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"encoding/json"
2222
"errors"
2323
"fmt"
24+
"slices"
2425
"sort"
2526
"strings"
2627

@@ -561,9 +562,56 @@ allow {
561562
}
562563
}
563564
}
565+
if err := r.cleanupStaleAuthPolicies(ctx, log, policy); err != nil {
566+
return nil, err
567+
}
568+
564569
return refs, nil
565570
}
566571

572+
// cleanupStaleAuthPolicies deletes aggregated AuthPolicies for models that this
573+
// policy previously contributed to but no longer references in spec.modelRefs.
574+
// Generated AuthPolicies track contributing policies in the
575+
// "maas.opendatahub.io/auth-policies" annotation (namespace-qualified: "ns/name").
576+
func (r *MaaSAuthPolicyReconciler) cleanupStaleAuthPolicies(ctx context.Context, log logr.Logger, policy *maasv1alpha1.MaaSAuthPolicy) error {
577+
currentModels := make(map[string]bool, len(policy.Spec.ModelRefs))
578+
for _, ref := range policy.Spec.ModelRefs {
579+
currentModels[ref.Namespace+"/"+ref.Name] = true
580+
}
581+
582+
allManaged := &unstructured.UnstructuredList{}
583+
allManaged.SetGroupVersionKind(schema.GroupVersionKind{Group: "kuadrant.io", Version: "v1", Kind: "AuthPolicyList"})
584+
if err := r.List(ctx, allManaged, client.MatchingLabels{
585+
"app.kubernetes.io/managed-by": "maas-controller",
586+
"app.kubernetes.io/part-of": "maas-auth-policy",
587+
}); err != nil {
588+
if apierrors.IsNotFound(err) || apimeta.IsNoMatchError(err) {
589+
return nil
590+
}
591+
return fmt.Errorf("failed to list managed AuthPolicies for stale cleanup: %w", err)
592+
}
593+
594+
for i := range allManaged.Items {
595+
ap := &allManaged.Items[i]
596+
modelName := ap.GetLabels()["maas.opendatahub.io/model"]
597+
if modelName == "" {
598+
continue
599+
}
600+
modelKey := ap.GetNamespace() + "/" + modelName
601+
if currentModels[modelKey] {
602+
continue
603+
}
604+
if !slices.Contains(strings.Split(ap.GetAnnotations()["maas.opendatahub.io/auth-policies"], ","), policy.Name) {
605+
continue
606+
}
607+
log.Info("Cleaning up stale AuthPolicy for removed modelRef", "model", modelKey, "authPolicy", ap.GetName())
608+
if err := r.deleteModelAuthPolicy(ctx, log, ap.GetNamespace(), modelName); err != nil {
609+
return fmt.Errorf("failed to clean up stale AuthPolicy for removed model %s: %w", modelKey, err)
610+
}
611+
}
612+
return nil
613+
}
614+
567615
// deleteModelAuthPolicy deletes the aggregated AuthPolicy for a model in the given namespace.
568616
func (r *MaaSAuthPolicyReconciler) deleteModelAuthPolicy(ctx context.Context, log logr.Logger, modelNamespace, modelName string) error {
569617
// Always delete the aggregated AuthPolicy so remaining MaaSAuthPolicies rebuild it
@@ -605,6 +653,11 @@ func (r *MaaSAuthPolicyReconciler) handleDeletion(ctx context.Context, log logr.
605653
return ctrl.Result{}, err
606654
}
607655
}
656+
// Also clean up stale AuthPolicies from modelRefs that were removed
657+
// before the CR was deleted (edge case: edit + delete before reconcile).
658+
if err := r.cleanupStaleAuthPolicies(ctx, log, policy); err != nil {
659+
return ctrl.Result{}, err
660+
}
608661
controllerutil.RemoveFinalizer(policy, maasAuthPolicyFinalizer)
609662
if err := r.Update(ctx, policy); err != nil {
610663
return ctrl.Result{}, err

maas-controller/pkg/controller/maas/maasauthpolicy_controller_test.go

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,179 @@ func TestMaaSAuthPolicyReconciler_DeleteAnnotation(t *testing.T) {
271271
})
272272
}
273273
}
274+
// TestMaaSAuthPolicyReconciler_RemoveModelRef verifies that removing a modelRef from
275+
// a MaaSAuthPolicy deletes the aggregated AuthPolicy for the removed model while
276+
// keeping the AuthPolicy for the remaining model intact.
277+
func TestMaaSAuthPolicyReconciler_RemoveModelRef(t *testing.T) {
278+
const (
279+
modelA = "model-a"
280+
modelB = "model-b"
281+
namespace = "default"
282+
httpRouteA = "maas-model-" + modelA
283+
httpRouteB = "maas-model-" + modelB
284+
authPolicyA = "maas-auth-" + modelA
285+
authPolicyB = "maas-auth-" + modelB
286+
maasPolicyName = "policy-1"
287+
)
288+
289+
modelRefA := newMaaSModelRef(modelA, namespace, "ExternalModel", modelA)
290+
modelRefB := newMaaSModelRef(modelB, namespace, "ExternalModel", modelB)
291+
routeA := newHTTPRoute(httpRouteA, namespace)
292+
routeB := newHTTPRoute(httpRouteB, namespace)
293+
294+
maasPolicy := newMaaSAuthPolicy(maasPolicyName, namespace, "team-a",
295+
maasv1alpha1.ModelRef{Name: modelA, Namespace: namespace},
296+
maasv1alpha1.ModelRef{Name: modelB, Namespace: namespace},
297+
)
298+
299+
c := fake.NewClientBuilder().
300+
WithScheme(scheme).
301+
WithRESTMapper(testRESTMapper()).
302+
WithObjects(modelRefA, modelRefB, routeA, routeB, maasPolicy).
303+
WithStatusSubresource(&maasv1alpha1.MaaSAuthPolicy{}).
304+
Build()
305+
306+
r := &MaaSAuthPolicyReconciler{Client: c, Scheme: scheme, MaaSAPINamespace: "maas-system"}
307+
ctx := context.Background()
308+
req := ctrl.Request{NamespacedName: types.NamespacedName{Name: maasPolicyName, Namespace: namespace}}
309+
310+
if _, err := r.Reconcile(ctx, req); err != nil {
311+
t.Fatalf("Initial reconcile: %v", err)
312+
}
313+
314+
// Both AuthPolicies should exist
315+
for _, name := range []string{authPolicyA, authPolicyB} {
316+
ap := &unstructured.Unstructured{}
317+
ap.SetGroupVersionKind(schema.GroupVersionKind{Group: "kuadrant.io", Version: "v1", Kind: "AuthPolicy"})
318+
if err := c.Get(ctx, types.NamespacedName{Name: name, Namespace: namespace}, ap); err != nil {
319+
t.Fatalf("AuthPolicy %q not found after initial reconcile: %v", name, err)
320+
}
321+
}
322+
323+
// Remove model B from the policy
324+
freshPolicy := &maasv1alpha1.MaaSAuthPolicy{}
325+
if err := c.Get(ctx, types.NamespacedName{Name: maasPolicyName, Namespace: namespace}, freshPolicy); err != nil {
326+
t.Fatalf("Get MaaSAuthPolicy: %v", err)
327+
}
328+
freshPolicy.Spec.ModelRefs = []maasv1alpha1.ModelRef{{Name: modelA, Namespace: namespace}}
329+
if err := c.Update(ctx, freshPolicy); err != nil {
330+
t.Fatalf("Update MaaSAuthPolicy: %v", err)
331+
}
332+
333+
if _, err := r.Reconcile(ctx, req); err != nil {
334+
t.Fatalf("Reconcile after removing modelRef: %v", err)
335+
}
336+
337+
// AuthPolicy for model A should still exist
338+
apA := &unstructured.Unstructured{}
339+
apA.SetGroupVersionKind(schema.GroupVersionKind{Group: "kuadrant.io", Version: "v1", Kind: "AuthPolicy"})
340+
if err := c.Get(ctx, types.NamespacedName{Name: authPolicyA, Namespace: namespace}, apA); err != nil {
341+
t.Errorf("AuthPolicy for model A should still exist: %v", err)
342+
}
343+
344+
// AuthPolicy for model B should be DELETED
345+
apB := &unstructured.Unstructured{}
346+
apB.SetGroupVersionKind(schema.GroupVersionKind{Group: "kuadrant.io", Version: "v1", Kind: "AuthPolicy"})
347+
err := c.Get(ctx, types.NamespacedName{Name: authPolicyB, Namespace: namespace}, apB)
348+
if !apierrors.IsNotFound(err) {
349+
t.Errorf("AuthPolicy for model B should be deleted after removing modelRef, but got: %v", err)
350+
}
351+
}
352+
353+
// TestMaaSAuthPolicyReconciler_RemoveModelRef_Aggregation verifies that when one policy
354+
// drops a model that another policy still references, the aggregated AuthPolicy is deleted
355+
// (forcing a rebuild by the remaining policy) without affecting unrelated models.
356+
func TestMaaSAuthPolicyReconciler_RemoveModelRef_Aggregation(t *testing.T) {
357+
const (
358+
modelA = "model-a"
359+
modelB = "model-b"
360+
namespace = "default"
361+
httpRouteA = "maas-model-" + modelA
362+
httpRouteB = "maas-model-" + modelB
363+
authPolicyB = "maas-auth-" + modelB
364+
)
365+
366+
modelRefA := newMaaSModelRef(modelA, namespace, "ExternalModel", modelA)
367+
modelRefB := newMaaSModelRef(modelB, namespace, "ExternalModel", modelB)
368+
routeA := newHTTPRoute(httpRouteA, namespace)
369+
routeB := newHTTPRoute(httpRouteB, namespace)
370+
371+
// ap1 references [A, B], ap2 references [B]
372+
ap1 := newMaaSAuthPolicy("ap1", namespace, "team-1",
373+
maasv1alpha1.ModelRef{Name: modelA, Namespace: namespace},
374+
maasv1alpha1.ModelRef{Name: modelB, Namespace: namespace},
375+
)
376+
ap2 := newMaaSAuthPolicy("ap2", namespace, "team-2",
377+
maasv1alpha1.ModelRef{Name: modelB, Namespace: namespace},
378+
)
379+
380+
c := fake.NewClientBuilder().
381+
WithScheme(scheme).
382+
WithRESTMapper(testRESTMapper()).
383+
WithObjects(modelRefA, modelRefB, routeA, routeB, ap1, ap2).
384+
WithStatusSubresource(&maasv1alpha1.MaaSAuthPolicy{}).
385+
Build()
386+
387+
r := &MaaSAuthPolicyReconciler{Client: c, Scheme: scheme, MaaSAPINamespace: "maas-system"}
388+
ctx := context.Background()
389+
390+
// Reconcile both policies to create aggregated AuthPolicies
391+
req1 := ctrl.Request{NamespacedName: types.NamespacedName{Name: "ap1", Namespace: namespace}}
392+
req2 := ctrl.Request{NamespacedName: types.NamespacedName{Name: "ap2", Namespace: namespace}}
393+
if _, err := r.Reconcile(ctx, req1); err != nil {
394+
t.Fatalf("Reconcile ap1: %v", err)
395+
}
396+
if _, err := r.Reconcile(ctx, req2); err != nil {
397+
t.Fatalf("Reconcile ap2: %v", err)
398+
}
399+
400+
// AuthPolicy for model B should exist with both policies contributing
401+
apB := &unstructured.Unstructured{}
402+
apB.SetGroupVersionKind(schema.GroupVersionKind{Group: "kuadrant.io", Version: "v1", Kind: "AuthPolicy"})
403+
if err := c.Get(ctx, types.NamespacedName{Name: authPolicyB, Namespace: namespace}, apB); err != nil {
404+
t.Fatalf("AuthPolicy for model B not found: %v", err)
405+
}
406+
407+
// Remove model B from ap1 (ap2 still references it)
408+
freshAP1 := &maasv1alpha1.MaaSAuthPolicy{}
409+
if err := c.Get(ctx, types.NamespacedName{Name: "ap1", Namespace: namespace}, freshAP1); err != nil {
410+
t.Fatalf("Get ap1: %v", err)
411+
}
412+
freshAP1.Spec.ModelRefs = []maasv1alpha1.ModelRef{{Name: modelA, Namespace: namespace}}
413+
if err := c.Update(ctx, freshAP1); err != nil {
414+
t.Fatalf("Update ap1: %v", err)
415+
}
416+
417+
// Reconcile ap1 → should delete stale AuthPolicy for model B
418+
if _, err := r.Reconcile(ctx, req1); err != nil {
419+
t.Fatalf("Reconcile ap1 after removing modelRef: %v", err)
420+
}
421+
422+
// AuthPolicy for model B should be deleted (stale from ap1's perspective)
423+
apB = &unstructured.Unstructured{}
424+
apB.SetGroupVersionKind(schema.GroupVersionKind{Group: "kuadrant.io", Version: "v1", Kind: "AuthPolicy"})
425+
err := c.Get(ctx, types.NamespacedName{Name: authPolicyB, Namespace: namespace}, apB)
426+
if !apierrors.IsNotFound(err) {
427+
t.Fatalf("AuthPolicy for model B should be deleted after ap1 drops it, but got: %v", err)
428+
}
429+
430+
// Reconcile ap2 → should recreate AuthPolicy for model B with only ap2's subjects
431+
if _, err := r.Reconcile(ctx, req2); err != nil {
432+
t.Fatalf("Reconcile ap2 after ap1 drop: %v", err)
433+
}
434+
435+
apB = &unstructured.Unstructured{}
436+
apB.SetGroupVersionKind(schema.GroupVersionKind{Group: "kuadrant.io", Version: "v1", Kind: "AuthPolicy"})
437+
if err := c.Get(ctx, types.NamespacedName{Name: authPolicyB, Namespace: namespace}, apB); err != nil {
438+
t.Errorf("AuthPolicy for model B should be rebuilt by ap2: %v", err)
439+
}
440+
441+
// Verify only ap2 is in the annotation (ap1 no longer contributes)
442+
ann := apB.GetAnnotations()["maas.opendatahub.io/auth-policies"]
443+
if ann != "ap2" {
444+
t.Errorf("AuthPolicy annotation = %q, want %q (only ap2 should contribute)", ann, "ap2")
445+
}
446+
}
274447

275448
// TestMaaSAuthPolicyReconciler_MultiplePoliciesDeletion verifies that when multiple
276449
// MaaSAuthPolicies reference the same model, deleting one does not delete the aggregated

maas-controller/pkg/controller/maas/maassubscription_controller.go

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"errors"
2222
"fmt"
2323
"sort"
24+
"slices"
2425
"strings"
2526

2627
"github.com/go-logr/logr"
@@ -128,6 +129,9 @@ func (r *MaaSSubscriptionReconciler) reconcileTokenRateLimitPolicies(ctx context
128129
return err
129130
}
130131
}
132+
if err := r.cleanupStaleTRLPs(ctx, log, subscription); err != nil {
133+
return err
134+
}
131135
return nil
132136
}
133137

@@ -346,6 +350,49 @@ func (r *MaaSSubscriptionReconciler) reconcileTRLPForModel(ctx context.Context,
346350
return nil
347351
}
348352

353+
// cleanupStaleTRLPs deletes aggregated TokenRateLimitPolicies for models that this
354+
// subscription previously contributed to but no longer references in spec.modelRefs.
355+
// Generated TRLPs track contributing subscriptions in the
356+
// "maas.opendatahub.io/subscriptions" annotation.
357+
func (r *MaaSSubscriptionReconciler) cleanupStaleTRLPs(ctx context.Context, log logr.Logger, subscription *maasv1alpha1.MaaSSubscription) error {
358+
currentModels := make(map[string]bool, len(subscription.Spec.ModelRefs))
359+
for _, ref := range subscription.Spec.ModelRefs {
360+
currentModels[ref.Namespace+"/"+ref.Name] = true
361+
}
362+
363+
allManaged := &unstructured.UnstructuredList{}
364+
allManaged.SetGroupVersionKind(schema.GroupVersionKind{Group: "kuadrant.io", Version: "v1alpha1", Kind: "TokenRateLimitPolicyList"})
365+
if err := r.List(ctx, allManaged, client.MatchingLabels{
366+
"app.kubernetes.io/managed-by": "maas-controller",
367+
"app.kubernetes.io/part-of": "maas-subscription",
368+
}); err != nil {
369+
if apierrors.IsNotFound(err) || apimeta.IsNoMatchError(err) {
370+
return nil
371+
}
372+
return fmt.Errorf("failed to list managed TokenRateLimitPolicies for stale cleanup: %w", err)
373+
}
374+
375+
for i := range allManaged.Items {
376+
trlp := &allManaged.Items[i]
377+
modelName := trlp.GetLabels()["maas.opendatahub.io/model"]
378+
if modelName == "" {
379+
continue
380+
}
381+
modelKey := trlp.GetNamespace() + "/" + modelName
382+
if currentModels[modelKey] {
383+
continue
384+
}
385+
if !slices.Contains(strings.Split(trlp.GetAnnotations()["maas.opendatahub.io/subscriptions"], ","), subscription.Name) {
386+
continue
387+
}
388+
log.Info("Cleaning up stale TokenRateLimitPolicy for removed modelRef", "model", modelKey, "trlp", trlp.GetName())
389+
if err := r.deleteModelTRLP(ctx, log, trlp.GetNamespace(), modelName); err != nil {
390+
return fmt.Errorf("failed to clean up stale TokenRateLimitPolicy for removed model %s: %w", modelKey, err)
391+
}
392+
}
393+
return nil
394+
}
395+
349396
// deleteModelTRLP deletes the aggregated TokenRateLimitPolicy for a model in the given namespace.
350397
func (r *MaaSSubscriptionReconciler) deleteModelTRLP(ctx context.Context, log logr.Logger, modelNamespace, modelName string) error {
351398
// Always delete the aggregated TokenRateLimitPolicy so remaining MaaSSubscriptions rebuild it
@@ -400,7 +447,11 @@ func (r *MaaSSubscriptionReconciler) handleDeletion(ctx context.Context, log log
400447
return ctrl.Result{}, err
401448
}
402449
}
403-
450+
// Also clean up stale TRLPs from modelRefs that were removed
451+
// before the CR was deleted (edge case: edit + delete before reconcile).
452+
if err := r.cleanupStaleTRLPs(ctx, log, subscription); err != nil {
453+
return ctrl.Result{}, err
454+
}
404455
controllerutil.RemoveFinalizer(subscription, maasSubscriptionFinalizer)
405456
if err := r.Update(ctx, subscription); err != nil {
406457
return ctrl.Result{}, err

0 commit comments

Comments
 (0)