Skip to content

feat: add APIKey expiration support and test - #88

Merged
R-Lawton merged 1 commit into
Kuadrant:mainfrom
Anton-Fil:feat/apikey-expiration
Jul 24, 2026
Merged

feat: add APIKey expiration support and test#88
R-Lawton merged 1 commit into
Kuadrant:mainfrom
Anton-Fil:feat/apikey-expiration

Conversation

@Anton-Fil

@Anton-Fil Anton-Fil commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Summary

Add expiresAt field to APIKeySpec so consumers can set a desired expiration when requesting an API key. The controller automatically revokes the key when it expires.

Closes #87


Design

  • Consumer sets spec.expiresAt at request time
  • Owner can deny the request if the expiration is too high
  • On expiry the controller sets Expired condition and the enforcement secret is deleted — Authorino immediately stops accepting the key
  • Consumer's original secret is preserved for audit

Changes

api/v1alpha1/apikey_types.go

  • Added ExpiresAt *metav1.Time field to APIKeySpec
  • Added APIKeyConditionExpired constant

internal/controller/apikey_status_controller.go

  • Added Expired condition check in calculateStatusConditions — when a key has a valid approval AND expiresAt has passed, the controller sets Expired=True instead of Approved
  • Added RequeueAfter logic in Reconcile — finds the earliest future expiresAt across all approved keys and schedules a wakeup at that exact moment

internal/controller/apikey_secret_controller.go

  • Added secret.kuadrant.io/expires-at annotation on enforcement secret for audit purposes

Expired logic

calculateStatusConditions priority:
Failed → Denied → Approved? → expiresAt passed? → Expired
→ not passed? → Approved
→ Pending

When Expired=True:

  • APIKeySecretReconciler sees the key is no longer Approved
  • Deletes the enforcement secret from Kuadrant namespace
  • Authorino stops accepting requests with this key

Tests added

Unit tests (internal/controller/apikey_status_controller_test.go):

E2E test (test/e2e/expiry_test.go):

  • Creates APIKey without expiresAt
  • Approves it → verifies Approved=True
  • Patches expiresAt = now + 30s
  • Waits 35 seconds
  • Verifies Expired=True and Approved gone

How to manually verify

  1. Start Kind cluster
    make setup-test-e2e

  2. Run controller locally
    make run

  3. In new terminal — create namespaces and Kuadrant
    kubectl create ns owner-ns consumer-ns kuadrant-ns
    kubectl apply -f hack/test-expiry/kuadrant.yaml

  4. Create APIProduct, Secret, APIKey with short expiresAt
    kubectl create secret generic my-secret
    --from-literal=api_key=test-value -n consumer-ns

kubectl apply -f - <<EOF
apiVersion: devportal.kuadrant.io/v1alpha1
kind: APIKey
metadata:
name: my-key
namespace: consumer-ns
spec:
apiProductRef:
name: my-api
namespace: owner-ns
secretRef:
name: my-secret
planTier: basic
requestedBy:
userId: test-user
email: test@example.com
expiresAt: "$(date -u -v+120S '+%Y-%m-%dT%H:%M:%SZ')"
EOF

  1. Approve the key
    kubectl apply -f hack/test-expiry/approval.yaml

  2. Watch status
    kubectl get apikey my-key -n consumer-ns -o jsonpath='{.status.conditions}' | python3 -m json.tool

  3. After 2 minutes — verify Expired
    kubectl get apikey my-key -n consumer-ns -o jsonpath='{.status.conditions}' | python3 -m json.tool
    kubectl get secrets -n kuadrant-ns -l devportal.kuadrant.io/apikey=my-key

Expected: Expired=True, no secrets

  1. Cleanup
    make cleanup-test-e2e

Summary by CodeRabbit

  • New Features
    • API keys can now include an optional expiry time (expiresAt), alongside API key requests.
    • Expired API keys are set to an Expired condition and no longer considered Approved.
    • Enforcement credentials include the expiry timestamp when configured.
  • Bug Fixes
    • API key status now reevaluates promptly at the next upcoming expiry, and expiry evaluation is deterministic per reconcile loop.
  • Tests
    • Added end-to-end and reconciliation coverage for expired, non-expiring, and future-expiry scenarios.

@Anton-Fil
Anton-Fil requested a review from R-Lawton July 21, 2026 17:00
@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@Anton-Fil, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 47 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: dbf9ddd8-6a46-4eaa-a5e2-f38a9f704dcb

📥 Commits

Reviewing files that changed from the base of the PR and between d640b3e and 275d95c.

📒 Files selected for processing (11)
  • AGENTS.md
  • api/v1alpha1/apikey_types.go
  • api/v1alpha1/apikeyrequest_types.go
  • api/v1alpha1/zz_generated.deepcopy.go
  • config/crd/bases/devportal.kuadrant.io_apikeyrequests.yaml
  • config/crd/bases/devportal.kuadrant.io_apikeys.yaml
  • internal/controller/apikey_secret_controller.go
  • internal/controller/apikey_status_controller.go
  • internal/controller/apikey_status_controller_test.go
  • internal/controller/apikeyrequest_controller.go
  • test/e2e/expiry_test.go
📝 Walkthrough

Walkthrough

APIKey and APIKeyRequest resources gain optional expiresAt timestamps and an Expired condition. Controllers propagate expiry metadata, schedule reconciliation, and mark expired approved keys. Unit and end-to-end tests cover the lifecycle.

Changes

APIKey expiry lifecycle

Layer / File(s) Summary
Expiry API contract
api/v1alpha1/apikey_types.go, api/v1alpha1/apikeyrequest_types.go, api/v1alpha1/zz_generated.deepcopy.go, config/crd/bases/..., AGENTS.md
Adds expiry fields, the Expired condition, deepcopy handling, CRD schemas, and updated controller documentation.
Enforcement secret expiry annotation
internal/controller/apikeyrequest_controller.go, internal/controller/apikey_secret_controller.go
Propagates expiresAt to shadow requests and adds the RFC3339 expiry annotation to enforcement secrets.
Expiry reconciliation and validation
internal/controller/apikey_status_controller.go, internal/controller/apikey_status_controller_test.go, test/e2e/expiry_test.go
Schedules reconciliation for the earliest future expiry, sets expired status for past approved keys, clears stale expiry conditions, and validates the lifecycle.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant APIKey
  participant SecretController
  participant EnforcementSecret
  participant StatusController
  APIKey->>SecretController: Provide expiresAt
  SecretController->>EnforcementSecret: Set expires-at annotation
  StatusController->>APIKey: Reconcile at expiry
  StatusController->>APIKey: Set Expired condition
Loading

Possibly related issues

  • Kuadrant/kuadrant-console-plugin#659 — Uses the expiry field and Expired condition for console expiry display and handling.

Possibly related PRs

Suggested reviewers: eguzki, r-lawton

Poem

A rabbit marks the sunset time,
With carrot-clock precision fine.
The key hops on, then softly rests,
Expired beneath the moonlit crest.
Secret notes record the date—
Expiry waits beside the gate.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 inconclusive)

Check name Status Explanation Resolution
Linked Issues check ❓ Inconclusive Issue [#87]: most expiry support is implemented, but the summaries do not clearly confirm enforcement-secret deletion and original-secret preservation. Verify the expiry reconcile path deletes the enforcement secret and leaves the consumer's original secret intact.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately captures the main change: APIKey expiration support.
Out of Scope Changes check ✅ Passed The added APIKeyRequest field, tests, docs, and controller updates all relate directly to APIKey expiration support.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@Anton-Fil
Anton-Fil requested a review from jasonmadigan July 21, 2026 17:00

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/controller/apikey_status_controller.go (1)

191-246: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Expired condition is never cleared once set.

All four other branches of calculateStatusConditions (Failed, Denied, Approved-not-expired, Pending) omit meta.RemoveStatusCondition(&conditions, devportalv1alpha1.APIKeyConditionExpired). Once a key transitions to Expired=True, extending expiresAt (or any other state change) will leave the stale Expired condition set alongside the new condition, e.g. Approved=True and Expired=True at once.

🐛 Proposed fix — add Expired removal to every branch
 	if failedCondition != nil {
 		meta.RemoveStatusCondition(&conditions, devportalv1alpha1.APIKeyConditionApproved)
 		meta.RemoveStatusCondition(&conditions, devportalv1alpha1.APIKeyConditionDenied)
 		meta.RemoveStatusCondition(&conditions, devportalv1alpha1.APIKeyConditionPending)
+		meta.RemoveStatusCondition(&conditions, devportalv1alpha1.APIKeyConditionExpired)
 		meta.SetStatusCondition(&conditions, *failedCondition)
 		return conditions, nil
 	}
 
 	// Check for Denied condition - if denied, we're done
 	deniedCondition := r.calculateDeniedCondition(ctx, apiKey)
 	if deniedCondition != nil {
 		meta.RemoveStatusCondition(&conditions, devportalv1alpha1.APIKeyConditionApproved)
 		meta.RemoveStatusCondition(&conditions, devportalv1alpha1.APIKeyConditionFailed)
 		meta.RemoveStatusCondition(&conditions, devportalv1alpha1.APIKeyConditionPending)
+		meta.RemoveStatusCondition(&conditions, devportalv1alpha1.APIKeyConditionExpired)
 		meta.SetStatusCondition(&conditions, *deniedCondition)
 		return conditions, nil
 	}
 
 	// Check for Approved condition - if approved, check if also expired
 	approvedCondition := r.calculateApprovedCondition(ctx, apiKey)
 	if approvedCondition != nil {
 		if apiKey.Spec.ExpiresAt != nil && time.Now().After(apiKey.Spec.ExpiresAt.Time) {
 			...
 			return conditions, nil
 		}
 		meta.RemoveStatusCondition(&conditions, devportalv1alpha1.APIKeyConditionDenied)
 		meta.RemoveStatusCondition(&conditions, devportalv1alpha1.APIKeyConditionFailed)
 		meta.RemoveStatusCondition(&conditions, devportalv1alpha1.APIKeyConditionPending)
+		meta.RemoveStatusCondition(&conditions, devportalv1alpha1.APIKeyConditionExpired)
 		meta.SetStatusCondition(&conditions, *approvedCondition)
 		return conditions, nil
 	}
 
 	// Check for Pending condition - if no approval, denial, or failure
 	pendingCondition := r.calculatePendingCondition(ctx, apiKey)
 	if pendingCondition != nil {
 		meta.RemoveStatusCondition(&conditions, devportalv1alpha1.APIKeyConditionApproved)
 		meta.RemoveStatusCondition(&conditions, devportalv1alpha1.APIKeyConditionDenied)
 		meta.RemoveStatusCondition(&conditions, devportalv1alpha1.APIKeyConditionFailed)
+		meta.RemoveStatusCondition(&conditions, devportalv1alpha1.APIKeyConditionExpired)
 		meta.SetStatusCondition(&conditions, *pendingCondition)
 		return conditions, nil
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/controller/apikey_status_controller.go` around lines 191 - 246, Add
meta.RemoveStatusCondition(&conditions,
devportalv1alpha1.APIKeyConditionExpired) to each non-expired branch of
calculateStatusConditions: the failedCondition, deniedCondition,
approvedCondition when not expired, and pendingCondition paths. Preserve the
existing expired path while ensuring any transition back to Failed, Denied,
Approved, or Pending clears the stale Expired condition.
🧹 Nitpick comments (1)
test/e2e/expiry_test.go (1)

60-203: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Test doesn't verify secret deletion/preservation despite its name.

The test title states it verifies enforcement-secret deletion, but only APIKey status conditions are asserted (lines 188-202). Consider adding checks that the enforcement secret (label-selected in kuadrantNamespace by APIKey name/namespace, per apikey_secret_controller.go) is deleted after expiry, and that the consumer's original secret (%s-secret in consumerNamespace) still exists — both are explicit requirements from the linked issue.

By("verifying enforcement secret is deleted")
Eventually(func(g Gomega) {
    cmd := exec.Command("kubectl", "get", "secrets", "-n", kuadrantNamespace,
        "-l", fmt.Sprintf("devportal.kuadrant.io/apikey-name=%s", apiKeyName), // adjust label key to match controller
        "-o", "jsonpath={.items}")
    output, err := utils.Run(cmd)
    g.Expect(err).NotTo(HaveOccurred())
    g.Expect(output).To(Equal("[]"))
}).Should(Succeed())

By("verifying consumer's original secret is preserved")
cmd = exec.Command("kubectl", "get", "secret", apiKeyName+"-secret", "-n", consumerNamespace)
_, err = utils.Run(cmd)
Expect(err).NotTo(HaveOccurred())
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/e2e/expiry_test.go` around lines 60 - 203, Extend the expiry test after
verifying the Expired condition to assert that the controller-generated
enforcement secret selected by the APIKey name/namespace labels in
kuadrantNamespace is deleted. Also verify the original consumer secret created
as apiKeyName+"-secret" in consumerNamespace still exists, using the
controller’s exact label keys and preserving the existing condition assertions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@internal/controller/apikey_status_controller.go`:
- Around line 191-246: Add meta.RemoveStatusCondition(&conditions,
devportalv1alpha1.APIKeyConditionExpired) to each non-expired branch of
calculateStatusConditions: the failedCondition, deniedCondition,
approvedCondition when not expired, and pendingCondition paths. Preserve the
existing expired path while ensuring any transition back to Failed, Denied,
Approved, or Pending clears the stale Expired condition.

---

Nitpick comments:
In `@test/e2e/expiry_test.go`:
- Around line 60-203: Extend the expiry test after verifying the Expired
condition to assert that the controller-generated enforcement secret selected by
the APIKey name/namespace labels in kuadrantNamespace is deleted. Also verify
the original consumer secret created as apiKeyName+"-secret" in
consumerNamespace still exists, using the controller’s exact label keys and
preserving the existing condition assertions.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 4eff3671-f489-484d-ae2c-10217e7218b0

📥 Commits

Reviewing files that changed from the base of the PR and between 3268bed and 2150993.

📒 Files selected for processing (7)
  • api/v1alpha1/apikey_types.go
  • api/v1alpha1/zz_generated.deepcopy.go
  • config/crd/bases/devportal.kuadrant.io_apikeys.yaml
  • internal/controller/apikey_secret_controller.go
  • internal/controller/apikey_status_controller.go
  • internal/controller/apikey_status_controller_test.go
  • test/e2e/expiry_test.go

@R-Lawton

Copy link
Copy Markdown
Contributor

👀

Comment thread internal/controller/apikey_status_controller.go Outdated
Comment thread api/v1alpha1/apikey_types.go

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@AGENTS.md`:
- Around line 124-125: Update the AGENTS.md “Controller Pattern” section and its
related internal/controller list to replace the nonexistent APIKeyReconciler
with APIKeyStatusReconciler for expiry and condition handling, and
APIKeySecretReconciler for enforcement-secret creation and deletion; ensure the
documented reconciler names and responsibilities match the registered
implementations.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 10c347e9-5bac-4b4b-8668-4783bc0cafb5

📥 Commits

Reviewing files that changed from the base of the PR and between aefab67 and d640b3e.

📒 Files selected for processing (6)
  • AGENTS.md
  • api/v1alpha1/apikeyrequest_types.go
  • api/v1alpha1/zz_generated.deepcopy.go
  • config/crd/bases/devportal.kuadrant.io_apikeyrequests.yaml
  • internal/controller/apikey_status_controller.go
  • internal/controller/apikeyrequest_controller.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • internal/controller/apikey_status_controller.go

Comment thread AGENTS.md Outdated
Signed-off-by: Anton-Fil <a.filkach@gmail.com>
@Anton-Fil
Anton-Fil force-pushed the feat/apikey-expiration branch from 1bc41c4 to 275d95c Compare July 24, 2026 13:45
@Anton-Fil
Anton-Fil requested a review from R-Lawton July 24, 2026 14:00
@R-Lawton

R-Lawton commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

hack/test-expiry/ directory is missing — the manual verification steps can't be followed as written. do you want to add them? not a big deal as not in docs

@R-Lawton R-Lawton left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Really nice work Antonio, Verified it works as expected

@R-Lawton
R-Lawton merged commit 57c98bc into Kuadrant:main Jul 24, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

APIKey: key expiration support

2 participants