From 275d95c8798c4d375ea02f4d5a09160aaa77d68e Mon Sep 17 00:00:00 2001 From: Anton-Fil Date: Tue, 21 Jul 2026 17:55:23 +0100 Subject: [PATCH] feat: add APIKey expiration support and test Signed-off-by: Anton-Fil --- AGENTS.md | 26 ++- api/v1alpha1/apikey_types.go | 7 + api/v1alpha1/apikeyrequest_types.go | 4 + api/v1alpha1/zz_generated.deepcopy.go | 12 +- .../devportal.kuadrant.io_apikeyrequests.yaml | 4 + .../bases/devportal.kuadrant.io_apikeys.yaml | 5 + .../controller/apikey_secret_controller.go | 21 +- .../controller/apikey_status_controller.go | 49 ++++- .../apikey_status_controller_test.go | 204 ++++++++++++++++++ .../controller/apikeyrequest_controller.go | 1 + test/e2e/expiry_test.go | 204 ++++++++++++++++++ 11 files changed, 513 insertions(+), 24 deletions(-) create mode 100644 test/e2e/expiry_test.go diff --git a/AGENTS.md b/AGENTS.md index 5b14784..666453f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,7 @@ Key concepts from the design: - **Shadow resources**: APIKeyRequest mirrors APIKey in owner's namespace for RBAC-enforced discovery - **Cross-namespace references**: APIKey references APIProduct across namespaces; APIKeyApproval references APIKey across namespaces - **Secret projection**: API key values projected to status field, eliminating need for secret read permissions -- **Conditions pattern**: Uses conditions array (Pending/Approved/Denied/Failed) following CertificateSigningRequest pattern +- **Conditions pattern**: Uses conditions array (Pending/Approved/Denied/Failed/Expired) following CertificateSigningRequest pattern ## Development Commands @@ -87,7 +87,9 @@ make cleanup-test-e2e # Tear down the Kind cluster used for e2e tests - **internal/controller/**: Reconciliation logic - `apiproduct_controller.go`: APIProductReconciler for API product lifecycle - - `apikey_controller.go`: APIKeyReconciler for consumer API key requests + - `apikey_status_controller.go`: APIKeyStatusReconciler — expiry checks, condition updates, RequeueAfter scheduling + - `apikey_secret_controller.go`: APIKeySecretReconciler — creates/deletes enforcement secrets on approval/denial/expiry + - `apikey_auto_approval_controller.go`: APIKeyAutoApprovalReconciler — handles automatic approval mode - `apikeyrequest_controller.go`: APIKeyRequestReconciler for request processing - Controllers use client.Client for K8s API access - RBAC permissions defined via kubebuilder markers (`+kubebuilder:rbac`) @@ -116,12 +118,20 @@ The operator follows the standard Kubernetes controller pattern with multiple re 3. Fetches and stores OpenAPI spec 4. Updates status with discovered plans and auth scheme -**APIKeyReconciler**: -1. Watches APIKey resources (consumer namespace) -2. Creates APIKeyRequest shadow resource in owner namespace -3. Processes APIKeyApproval decisions -4. Creates API key secrets and projects values to status -5. Updates conditions (Pending/Approved/Denied) +**APIKeyStatusReconciler**: +1. Watches APIKey resources +2. Updates conditions (Pending/Approved/Denied/Failed/Expired) +3. Handles key expiration: if `spec.expiresAt` is set and has passed, sets `Expired` condition +4. Uses `RequeueAfter` to wake up exactly when a key expires + +**APIKeySecretReconciler**: +1. Watches APIKey resources +2. Creates enforcement secrets when key is approved +3. Deletes enforcement secrets when key is denied or expired + +**APIKeyAutoApprovalReconciler**: +1. Watches APIKey resources +2. Automatically approves keys when the associated APIProduct has automatic approval mode enabled **APIKeyRequestReconciler**: 1. Watches APIKeyRequest resources (owner namespace) diff --git a/api/v1alpha1/apikey_types.go b/api/v1alpha1/apikey_types.go index c866085..d7b2958 100644 --- a/api/v1alpha1/apikey_types.go +++ b/api/v1alpha1/apikey_types.go @@ -33,6 +33,9 @@ const ( // APIKeyConditionDenied indicates the APIKey request has been denied by the API owner APIKeyConditionDenied string = "Denied" + // APIKeyConditionExpired indicates the APIKey has passed its expiration date + APIKeyConditionExpired string = "Expired" + // APIKeyConditionFailed indicates the APIKey processing has failed APIKeyConditionFailed string = "Failed" @@ -70,6 +73,10 @@ type APIKeySpec struct { // RequestedBy contains information about who requested the API key // +kubebuilder:validation:Required RequestedBy RequestedBy `json:"requestedBy"` + + // ExpiresAt is the time after which the API key should be revoked + // +optional + ExpiresAt *metav1.Time `json:"expiresAt,omitempty"` } // RequestedBy contains information about the requester. diff --git a/api/v1alpha1/apikeyrequest_types.go b/api/v1alpha1/apikeyrequest_types.go index 58fa054..7e72219 100644 --- a/api/v1alpha1/apikeyrequest_types.go +++ b/api/v1alpha1/apikeyrequest_types.go @@ -58,6 +58,10 @@ type APIKeyRequestSpec struct { // Reference to the APIKey this APIKeyRequest belongs to. // +kubebuilder:validation:Required APIKeyRef APIKeyReference `json:"apiKeyRef"` + + // ExpiresAt is the expiration time requested by the consumer + // +optional + ExpiresAt *metav1.Time `json:"expiresAt,omitempty"` } // APIKeyRequestStatus defines the observed state of APIKeyRequest. diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index ede79bd..41514f4 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -33,7 +33,7 @@ func (in *APIKey) DeepCopyInto(out *APIKey) { *out = *in out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - out.Spec = in.Spec + in.Spec.DeepCopyInto(&out.Spec) in.Status.DeepCopyInto(&out.Status) } @@ -205,7 +205,7 @@ func (in *APIKeyRequest) DeepCopyInto(out *APIKeyRequest) { *out = *in out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - out.Spec = in.Spec + in.Spec.DeepCopyInto(&out.Spec) in.Status.DeepCopyInto(&out.Status) } @@ -280,6 +280,10 @@ func (in *APIKeyRequestSpec) DeepCopyInto(out *APIKeyRequestSpec) { out.APIProductRef = in.APIProductRef out.RequestedBy = in.RequestedBy out.APIKeyRef = in.APIKeyRef + if in.ExpiresAt != nil { + in, out := &in.ExpiresAt, &out.ExpiresAt + *out = (*in).DeepCopy() + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new APIKeyRequestSpec. @@ -320,6 +324,10 @@ func (in *APIKeySpec) DeepCopyInto(out *APIKeySpec) { out.APIProductRef = in.APIProductRef out.SecretRef = in.SecretRef out.RequestedBy = in.RequestedBy + if in.ExpiresAt != nil { + in, out := &in.ExpiresAt, &out.ExpiresAt + *out = (*in).DeepCopy() + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new APIKeySpec. diff --git a/config/crd/bases/devportal.kuadrant.io_apikeyrequests.yaml b/config/crd/bases/devportal.kuadrant.io_apikeyrequests.yaml index dae6312..4b557f4 100644 --- a/config/crd/bases/devportal.kuadrant.io_apikeyrequests.yaml +++ b/config/crd/bases/devportal.kuadrant.io_apikeyrequests.yaml @@ -59,6 +59,10 @@ spec: required: - name type: object + expiresAt: + description: ExpiresAt is the expiration time requested by the consumer + format: date-time + type: string planTier: description: PlanTier is the tier of the plan (e.g., "premium", "basic", "enterprise") diff --git a/config/crd/bases/devportal.kuadrant.io_apikeys.yaml b/config/crd/bases/devportal.kuadrant.io_apikeys.yaml index 4595b4c..e125c5f 100644 --- a/config/crd/bases/devportal.kuadrant.io_apikeys.yaml +++ b/config/crd/bases/devportal.kuadrant.io_apikeys.yaml @@ -67,6 +67,11 @@ spec: required: - name type: object + expiresAt: + description: ExpiresAt is the time after which the API key should + be revoked + format: date-time + type: string planTier: description: PlanTier is the tier of the plan (e.g., "premium", "basic", "enterprise") diff --git a/internal/controller/apikey_secret_controller.go b/internal/controller/apikey_secret_controller.go index af190a8..a40a91f 100644 --- a/internal/controller/apikey_secret_controller.go +++ b/internal/controller/apikey_secret_controller.go @@ -21,6 +21,7 @@ import ( "crypto/sha256" "encoding/hex" "fmt" + "time" "github.com/samber/lo" corev1 "k8s.io/api/core/v1" @@ -39,6 +40,7 @@ import ( const ( apiKeySecretAnnotationPlan = "secret.kuadrant.io/plan-id" apiKeySecretAnnotationUser = "secret.kuadrant.io/user-id" + apiKeySecretAnnotationExpiresAt = "secret.kuadrant.io/expires-at" apiKeySecretLabelAuthorinoValue = "authorino" apiKeySecretKey = "api_key" // Enforcement secret labels @@ -209,16 +211,21 @@ func (r *APIKeySecretReconciler) desiredEnforcementSecret(ctx context.Context, a secretLabels) } + annotations := map[string]string{ + apiKeySecretAnnotationPlan: apiKey.Spec.PlanTier, + apiKeySecretAnnotationUser: apiKey.Spec.RequestedBy.UserID, + } + if apiKey.Spec.ExpiresAt != nil { + annotations[apiKeySecretAnnotationExpiresAt] = apiKey.Spec.ExpiresAt.Format(time.RFC3339) + } + // Create enforcement secret in kuadrant namespace return &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ - Name: enforcementSecretName(apiKey), - Namespace: kuadrantNamespace, - Annotations: map[string]string{ - apiKeySecretAnnotationPlan: apiKey.Spec.PlanTier, - apiKeySecretAnnotationUser: apiKey.Spec.RequestedBy.UserID, - }, - Labels: secretLabels, + Name: enforcementSecretName(apiKey), + Namespace: kuadrantNamespace, + Annotations: annotations, + Labels: secretLabels, }, Type: corev1.SecretTypeOpaque, Data: map[string][]byte{ diff --git a/internal/controller/apikey_status_controller.go b/internal/controller/apikey_status_controller.go index d011e9a..76dd365 100644 --- a/internal/controller/apikey_status_controller.go +++ b/internal/controller/apikey_status_controller.go @@ -20,6 +20,7 @@ import ( "context" "fmt" "slices" + "time" "github.com/samber/lo" corev1 "k8s.io/api/core/v1" @@ -95,8 +96,11 @@ func (r *APIKeyStatusReconciler) Reconcile(ctx context.Context, _ ctrl.Request) return api.GetDeletionTimestamp() == nil }) + var requeueAfter time.Duration + now := time.Now() + for idx := range activeAPIKeyList { - err := r.reconcileStatus(ctx, &activeAPIKeyList[idx]) + err := r.reconcileStatus(ctx, &activeAPIKeyList[idx], now) if err != nil { if apierrors.IsConflict(err) { // Ignore conflicts, resource might just be outdated. @@ -105,15 +109,28 @@ func (r *APIKeyStatusReconciler) Reconcile(ctx context.Context, _ ctrl.Request) } return ctrl.Result{}, err } + + // track the earliest future expiry so we wake up exactly when needed + key := &activeAPIKeyList[idx] + if key.Spec.ExpiresAt != nil && key.Spec.ExpiresAt.After(now) { + timeUntilExpiry := key.Spec.ExpiresAt.Sub(now) + if requeueAfter == 0 || timeUntilExpiry < requeueAfter { + requeueAfter = timeUntilExpiry + } + } + } + + if requeueAfter > 0 { + return ctrl.Result{RequeueAfter: requeueAfter}, nil } return ctrl.Result{}, nil } -func (r *APIKeyStatusReconciler) reconcileStatus(ctx context.Context, apiKey *devportalv1alpha1.APIKey) error { +func (r *APIKeyStatusReconciler) reconcileStatus(ctx context.Context, apiKey *devportalv1alpha1.APIKey, now time.Time) error { logger := logf.FromContext(ctx, "apikey", client.ObjectKeyFromObject(apiKey)) - newStatus, err := r.calculateStatus(ctx, apiKey) + newStatus, err := r.calculateStatus(ctx, apiKey, now) if err != nil { return err } @@ -135,12 +152,12 @@ func (r *APIKeyStatusReconciler) reconcileStatus(ctx context.Context, apiKey *de return nil } -func (r *APIKeyStatusReconciler) calculateStatus(ctx context.Context, apiKey *devportalv1alpha1.APIKey) (*devportalv1alpha1.APIKeyStatus, error) { +func (r *APIKeyStatusReconciler) calculateStatus(ctx context.Context, apiKey *devportalv1alpha1.APIKey, now time.Time) (*devportalv1alpha1.APIKeyStatus, error) { newStatus := &devportalv1alpha1.APIKeyStatus{ ObservedGeneration: apiKey.Generation, } - newConditions, err := r.calculateStatusConditions(ctx, apiKey) + newConditions, err := r.calculateStatusConditions(ctx, apiKey, now) if err != nil { return nil, err } @@ -168,7 +185,7 @@ func (r *APIKeyStatusReconciler) calculateStatus(ctx context.Context, apiKey *de return newStatus, nil } -func (r *APIKeyStatusReconciler) calculateStatusConditions(ctx context.Context, apiKey *devportalv1alpha1.APIKey) ([]metav1.Condition, error) { +func (r *APIKeyStatusReconciler) calculateStatusConditions(ctx context.Context, apiKey *devportalv1alpha1.APIKey, now time.Time) ([]metav1.Condition, error) { conditions := slices.Clone(apiKey.Status.Conditions) // Check Failed condition first - if failed, we're done @@ -179,6 +196,7 @@ func (r *APIKeyStatusReconciler) calculateStatusConditions(ctx context.Context, if failedCondition != nil { meta.RemoveStatusCondition(&conditions, devportalv1alpha1.APIKeyConditionApproved) meta.RemoveStatusCondition(&conditions, devportalv1alpha1.APIKeyConditionDenied) + meta.RemoveStatusCondition(&conditions, devportalv1alpha1.APIKeyConditionExpired) meta.RemoveStatusCondition(&conditions, devportalv1alpha1.APIKeyConditionPending) meta.SetStatusCondition(&conditions, *failedCondition) return conditions, nil @@ -188,16 +206,32 @@ func (r *APIKeyStatusReconciler) calculateStatusConditions(ctx context.Context, deniedCondition := r.calculateDeniedCondition(ctx, apiKey) if deniedCondition != nil { meta.RemoveStatusCondition(&conditions, devportalv1alpha1.APIKeyConditionApproved) + meta.RemoveStatusCondition(&conditions, devportalv1alpha1.APIKeyConditionExpired) meta.RemoveStatusCondition(&conditions, devportalv1alpha1.APIKeyConditionFailed) meta.RemoveStatusCondition(&conditions, devportalv1alpha1.APIKeyConditionPending) meta.SetStatusCondition(&conditions, *deniedCondition) return conditions, nil } - // Check for Approved condition - if approved, we're done + // Check for Approved condition - if approved, check if also expired approvedCondition := r.calculateApprovedCondition(ctx, apiKey) if approvedCondition != nil { + if apiKey.Spec.ExpiresAt != nil && !apiKey.Spec.ExpiresAt.After(now) { + meta.RemoveStatusCondition(&conditions, devportalv1alpha1.APIKeyConditionApproved) + meta.RemoveStatusCondition(&conditions, devportalv1alpha1.APIKeyConditionDenied) + meta.RemoveStatusCondition(&conditions, devportalv1alpha1.APIKeyConditionFailed) + meta.RemoveStatusCondition(&conditions, devportalv1alpha1.APIKeyConditionPending) + meta.SetStatusCondition(&conditions, metav1.Condition{ + Type: devportalv1alpha1.APIKeyConditionExpired, + Status: metav1.ConditionTrue, + ObservedGeneration: apiKey.Generation, + Reason: "KeyExpired", + Message: fmt.Sprintf("API key expired at %s", apiKey.Spec.ExpiresAt.Format(time.RFC3339)), + }) + return conditions, nil + } meta.RemoveStatusCondition(&conditions, devportalv1alpha1.APIKeyConditionDenied) + meta.RemoveStatusCondition(&conditions, devportalv1alpha1.APIKeyConditionExpired) meta.RemoveStatusCondition(&conditions, devportalv1alpha1.APIKeyConditionFailed) meta.RemoveStatusCondition(&conditions, devportalv1alpha1.APIKeyConditionPending) meta.SetStatusCondition(&conditions, *approvedCondition) @@ -209,6 +243,7 @@ func (r *APIKeyStatusReconciler) calculateStatusConditions(ctx context.Context, if pendingCondition != nil { meta.RemoveStatusCondition(&conditions, devportalv1alpha1.APIKeyConditionApproved) meta.RemoveStatusCondition(&conditions, devportalv1alpha1.APIKeyConditionDenied) + meta.RemoveStatusCondition(&conditions, devportalv1alpha1.APIKeyConditionExpired) meta.RemoveStatusCondition(&conditions, devportalv1alpha1.APIKeyConditionFailed) meta.SetStatusCondition(&conditions, *pendingCondition) return conditions, nil diff --git a/internal/controller/apikey_status_controller_test.go b/internal/controller/apikey_status_controller_test.go index 06aa279..ad0aa9e 100644 --- a/internal/controller/apikey_status_controller_test.go +++ b/internal/controller/apikey_status_controller_test.go @@ -634,6 +634,210 @@ var _ = Describe("APIKey Status Controller", func() { Expect(approvedCondition.Message).To(ContainSubstring("Approved for production")) }) + It("should set Expired condition when approved key has passed its expiration date", func() { + controllerReconciler := &APIKeyStatusReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + } + + By("Setting expiresAt in the past on the APIKey") + Eventually(func() error { + if err := k8sClient.Get(ctx, types.NamespacedName{ + Name: apiKeyName, + Namespace: consumerNamespace, + }, apiKey); err != nil { + return err + } + pastTime := metav1.NewTime(time.Now().Add(-time.Hour)) + apiKey.Spec.ExpiresAt = &pastTime + return k8sClient.Update(ctx, apiKey) + }, time.Second*5, time.Millisecond*100).Should(Succeed()) + + By("Creating a valid APIKeyApproval") + approval := &devportalv1alpha1.APIKeyApproval{ + ObjectMeta: metav1.ObjectMeta{ + Name: "expired-test-approval", + Namespace: apiProductNamespace, + }, + Spec: devportalv1alpha1.APIKeyApprovalSpec{ + APIKeyRequestRef: devportalv1alpha1.APIKeyRequestReference{ + Name: APIKeyRequestName(apiKey), + }, + Approved: true, + ReviewedBy: "admin@example.com", + ReviewedAt: metav1.Now(), + Message: "Approved", + }, + } + Expect(k8sClient.Create(ctx, approval)).To(Succeed()) + + approval.Status.Conditions = []metav1.Condition{ + { + Type: devportalv1alpha1.APIKeyApprovalConditionValid, + Status: metav1.ConditionTrue, + ObservedGeneration: approval.Generation, + Reason: "Valid", + Message: "Valid approval", + LastTransitionTime: metav1.Now(), + }, + } + approval.Status.ObservedGeneration = approval.Generation + Expect(k8sClient.Status().Update(ctx, approval)).To(Succeed()) + + By("Running reconciliation") + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{}) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying Expired condition is set") + updatedAPIKey := &devportalv1alpha1.APIKey{} + Eventually(func() bool { + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: apiKeyName, + Namespace: consumerNamespace, + }, updatedAPIKey) + if err != nil { + return false + } + expiredCondition := meta.FindStatusCondition(updatedAPIKey.Status.Conditions, devportalv1alpha1.APIKeyConditionExpired) + return expiredCondition != nil && expiredCondition.Status == metav1.ConditionTrue + }, time.Second*10, time.Millisecond*250).Should(BeTrue()) + + By("Verifying Approved condition is not set") + approvedCondition := meta.FindStatusCondition(updatedAPIKey.Status.Conditions, devportalv1alpha1.APIKeyConditionApproved) + Expect(approvedCondition).To(BeNil()) + }) + + It("should set Approved condition when key has no expiresAt", func() { + controllerReconciler := &APIKeyStatusReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + } + + By("Creating a valid APIKeyApproval without setting expiresAt on APIKey") + approval := &devportalv1alpha1.APIKeyApproval{ + ObjectMeta: metav1.ObjectMeta{ + Name: "no-expiry-approval", + Namespace: apiProductNamespace, + }, + Spec: devportalv1alpha1.APIKeyApprovalSpec{ + APIKeyRequestRef: devportalv1alpha1.APIKeyRequestReference{ + Name: APIKeyRequestName(apiKey), + }, + Approved: true, + ReviewedBy: "admin@example.com", + ReviewedAt: metav1.Now(), + }, + } + Expect(k8sClient.Create(ctx, approval)).To(Succeed()) + + approval.Status.Conditions = []metav1.Condition{ + { + Type: devportalv1alpha1.APIKeyApprovalConditionValid, + Status: metav1.ConditionTrue, + ObservedGeneration: approval.Generation, + Reason: "Valid", + Message: "Valid approval", + LastTransitionTime: metav1.Now(), + }, + } + approval.Status.ObservedGeneration = approval.Generation + Expect(k8sClient.Status().Update(ctx, approval)).To(Succeed()) + + By("Running reconciliation") + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{}) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying Approved condition is set and Expired is not") + updatedAPIKey := &devportalv1alpha1.APIKey{} + Eventually(func() bool { + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: apiKeyName, + Namespace: consumerNamespace, + }, updatedAPIKey) + if err != nil { + return false + } + approvedCondition := meta.FindStatusCondition(updatedAPIKey.Status.Conditions, devportalv1alpha1.APIKeyConditionApproved) + return approvedCondition != nil && approvedCondition.Status == metav1.ConditionTrue + }, time.Second*10, time.Millisecond*250).Should(BeTrue()) + + By("Verifying Expired condition is not set") + expiredCondition := meta.FindStatusCondition(updatedAPIKey.Status.Conditions, devportalv1alpha1.APIKeyConditionExpired) + Expect(expiredCondition).To(BeNil()) + }) + + It("should set Approved condition when key has expiresAt in the future", func() { + controllerReconciler := &APIKeyStatusReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + } + + By("Setting expiresAt in the future on the APIKey") + Eventually(func() error { + if err := k8sClient.Get(ctx, types.NamespacedName{ + Name: apiKeyName, + Namespace: consumerNamespace, + }, apiKey); err != nil { + return err + } + futureTime := metav1.NewTime(time.Now().Add(24 * time.Hour)) + apiKey.Spec.ExpiresAt = &futureTime + return k8sClient.Update(ctx, apiKey) + }, time.Second*5, time.Millisecond*100).Should(Succeed()) + + By("Creating a valid APIKeyApproval") + approval := &devportalv1alpha1.APIKeyApproval{ + ObjectMeta: metav1.ObjectMeta{ + Name: "future-expiry-approval", + Namespace: apiProductNamespace, + }, + Spec: devportalv1alpha1.APIKeyApprovalSpec{ + APIKeyRequestRef: devportalv1alpha1.APIKeyRequestReference{ + Name: APIKeyRequestName(apiKey), + }, + Approved: true, + ReviewedBy: "admin@example.com", + ReviewedAt: metav1.Now(), + }, + } + Expect(k8sClient.Create(ctx, approval)).To(Succeed()) + + approval.Status.Conditions = []metav1.Condition{ + { + Type: devportalv1alpha1.APIKeyApprovalConditionValid, + Status: metav1.ConditionTrue, + ObservedGeneration: approval.Generation, + Reason: "Valid", + Message: "Valid approval", + LastTransitionTime: metav1.Now(), + }, + } + approval.Status.ObservedGeneration = approval.Generation + Expect(k8sClient.Status().Update(ctx, approval)).To(Succeed()) + + By("Running reconciliation") + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{}) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying Approved condition is set and Expired is not") + updatedAPIKey := &devportalv1alpha1.APIKey{} + Eventually(func() bool { + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: apiKeyName, + Namespace: consumerNamespace, + }, updatedAPIKey) + if err != nil { + return false + } + approvedCondition := meta.FindStatusCondition(updatedAPIKey.Status.Conditions, devportalv1alpha1.APIKeyConditionApproved) + return approvedCondition != nil && approvedCondition.Status == metav1.ConditionTrue + }, time.Second*10, time.Millisecond*250).Should(BeTrue()) + + By("Verifying Expired condition is not set") + expiredCondition := meta.FindStatusCondition(updatedAPIKey.Status.Conditions, devportalv1alpha1.APIKeyConditionExpired) + Expect(expiredCondition).To(BeNil()) + }) + It("should set Denied condition when denial exists", func() { controllerReconciler := &APIKeyStatusReconciler{ Client: k8sClient, diff --git a/internal/controller/apikeyrequest_controller.go b/internal/controller/apikeyrequest_controller.go index cd661dc..b86e57b 100644 --- a/internal/controller/apikeyrequest_controller.go +++ b/internal/controller/apikeyrequest_controller.go @@ -97,6 +97,7 @@ func (r *APIKeyRequestReconciler) Reconcile(ctx context.Context, req ctrl.Reques Name: apiKey.Name, Namespace: apiKey.Namespace, }, + ExpiresAt: apiKey.Spec.ExpiresAt, }, } diff --git a/test/e2e/expiry_test.go b/test/e2e/expiry_test.go new file mode 100644 index 0000000..7bc989a --- /dev/null +++ b/test/e2e/expiry_test.go @@ -0,0 +1,204 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package e2e + +import ( + "fmt" + "os/exec" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/kuadrant/developer-portal-controller/test/utils" +) + +var _ = Describe("APIKey Expiry", Ordered, func() { + const ( + ownerNamespace = "api-owner-expiry-test" + consumerNamespace = "api-consumer-expiry-test" + kuadrantNamespace = "kuadrant-expiry-ns" + controllerNamespace = "developer-portal-controller-system" + apiProductName = "expiry-test-api" + apiKeyName = "expiry-test-key" + ) + + AfterEach(func() { + LogDebugInfoOnFailure(ownerNamespace, consumerNamespace, controllerNamespace) + }) + + BeforeAll(func() { + SetDefaultEventuallyTimeout(5 * time.Minute) + SetDefaultEventuallyPollingInterval(2 * time.Second) + + By("setting up namespaces and Kuadrant instance") + SetupNamespacesAndKuadrant(ownerNamespace, consumerNamespace, kuadrantNamespace) + + By("creating HTTPRoute and AuthPolicy") + CreateHTTPRoute(ownerNamespace) + CreateAuthPolicy(ownerNamespace) + }) + + AfterAll(func() { + CleanupNamespaces(ownerNamespace, consumerNamespace, kuadrantNamespace) + }) + + It("should set Expired condition and delete enforcement secret after expiresAt passes", func() { + By("creating an APIProduct") + apiProductYAML := fmt.Sprintf(` +apiVersion: devportal.kuadrant.io/v1alpha1 +kind: APIProduct +metadata: + name: %s + namespace: %s +spec: + displayName: "Expiry Test API" + approvalMode: manual + publishStatus: Published + targetRef: + group: gateway.networking.k8s.io + kind: HTTPRoute + name: test-route +`, apiProductName, ownerNamespace) + + cmd := exec.Command("kubectl", "apply", "-f", "-") + cmd.Stdin = utils.StringReader(apiProductYAML) + _, err := utils.Run(cmd) + Expect(err).NotTo(HaveOccurred()) + + By("creating a secret with API key") + secretYAML := fmt.Sprintf(` +apiVersion: v1 +kind: Secret +metadata: + name: %s-secret + namespace: %s +type: Opaque +stringData: + api_key: test-expiry-key-value +`, apiKeyName, consumerNamespace) + + cmd = exec.Command("kubectl", "apply", "-f", "-") + cmd.Stdin = utils.StringReader(secretYAML) + _, err = utils.Run(cmd) + Expect(err).NotTo(HaveOccurred()) + + By("creating an APIKey without expiresAt") + apiKeyYAML := fmt.Sprintf(` +apiVersion: devportal.kuadrant.io/v1alpha1 +kind: APIKey +metadata: + name: %s + namespace: %s +spec: + apiProductRef: + name: %s + namespace: %s + secretRef: + name: %s-secret + planTier: premium + useCase: "expiry testing" + requestedBy: + userId: test-user + email: test@example.com +`, apiKeyName, consumerNamespace, apiProductName, ownerNamespace, apiKeyName) + + cmd = exec.Command("kubectl", "apply", "-f", "-") + cmd.Stdin = utils.StringReader(apiKeyYAML) + _, err = utils.Run(cmd) + Expect(err).NotTo(HaveOccurred()) + + By("verifying APIKey has Pending condition") + Eventually(func(g Gomega) { + cmd := exec.Command("kubectl", "get", "apikey", apiKeyName, + "-n", consumerNamespace, "-o", "jsonpath={.status.conditions[?(@.type=='Pending')].status}") + output, err := utils.Run(cmd) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(output).To(Equal("True")) + }).Should(Succeed()) + + By("getting APIKeyRequest name") + var apiKeyRequestName string + Eventually(func(g Gomega) { + cmd := exec.Command("kubectl", "get", "apikeyrequest", + "-n", ownerNamespace, + "-o", fmt.Sprintf("jsonpath={.items[?(@.spec.apiKeyRef.name=='%s')].metadata.name}", apiKeyName)) + output, err := utils.Run(cmd) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(output).NotTo(BeEmpty()) + apiKeyRequestName = output + }).Should(Succeed()) + + By("creating APIKeyApproval with approved=true") + approvalYAML := fmt.Sprintf(` +apiVersion: devportal.kuadrant.io/v1alpha1 +kind: APIKeyApproval +metadata: + name: %s-approval + namespace: %s +spec: + apiKeyRequestRef: + name: %s + approved: true + reviewedBy: test-owner + reviewedAt: "%s" + reason: "Approved for expiry test" +`, apiKeyRequestName, ownerNamespace, apiKeyRequestName, time.Now().UTC().Format(time.RFC3339)) + + cmd = exec.Command("kubectl", "apply", "-f", "-") + cmd.Stdin = utils.StringReader(approvalYAML) + _, err = utils.Run(cmd) + Expect(err).NotTo(HaveOccurred()) + + By("verifying APIKey has Approved condition") + Eventually(func(g Gomega) { + cmd := exec.Command("kubectl", "get", "apikey", apiKeyName, + "-n", consumerNamespace, "-o", "jsonpath={.status.conditions[?(@.type=='Approved')].status}") + output, err := utils.Run(cmd) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(output).To(Equal("True")) + }).Should(Succeed()) + + By("patching APIKey with expiresAt 30 seconds from now") + expiresAt := time.Now().Add(30 * time.Second).UTC().Format(time.RFC3339) + cmd = exec.Command("kubectl", "patch", "apikey", apiKeyName, + "-n", consumerNamespace, + "--type=merge", + "-p", fmt.Sprintf(`{"spec":{"expiresAt":"%s"}}`, expiresAt)) + _, err = utils.Run(cmd) + Expect(err).NotTo(HaveOccurred()) + + By("waiting for key to expire (30 seconds)") + time.Sleep(35 * time.Second) + + By("verifying APIKey has Expired condition") + Eventually(func(g Gomega) { + cmd := exec.Command("kubectl", "get", "apikey", apiKeyName, + "-n", consumerNamespace, "-o", "jsonpath={.status.conditions[?(@.type=='Expired')].status}") + output, err := utils.Run(cmd) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(output).To(Equal("True")) + }).Should(Succeed()) + + By("verifying Approved condition is gone") + cmd = exec.Command("kubectl", "get", "apikey", apiKeyName, + "-n", consumerNamespace, "-o", "jsonpath={.status.conditions[?(@.type=='Approved')].status}") + output, err := utils.Run(cmd) + Expect(err).NotTo(HaveOccurred()) + Expect(output).To(BeEmpty()) + }) +})