Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 18 additions & 8 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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`)
Expand Down Expand Up @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions api/v1alpha1/apikey_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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"`
Comment thread
Anton-Fil marked this conversation as resolved.
}

// RequestedBy contains information about the requester.
Expand Down
4 changes: 4 additions & 0 deletions api/v1alpha1/apikeyrequest_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 10 additions & 2 deletions api/v1alpha1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions config/crd/bases/devportal.kuadrant.io_apikeyrequests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
5 changes: 5 additions & 0 deletions config/crd/bases/devportal.kuadrant.io_apikeys.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
21 changes: 14 additions & 7 deletions internal/controller/apikey_secret_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"crypto/sha256"
"encoding/hex"
"fmt"
"time"

"github.com/samber/lo"
corev1 "k8s.io/api/core/v1"
Expand All @@ -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
Expand Down Expand Up @@ -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{
Expand Down
49 changes: 42 additions & 7 deletions internal/controller/apikey_status_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"context"
"fmt"
"slices"
"time"

"github.com/samber/lo"
corev1 "k8s.io/api/core/v1"
Expand Down Expand Up @@ -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.
Expand All @@ -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
}
Expand All @@ -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
}
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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
Expand Down
Loading
Loading