From a0890b23b96e141749fa7b9f6d148bed009f2e20 Mon Sep 17 00:00:00 2001 From: Javi R <4920956+rameerez@users.noreply.github.com> Date: Sat, 24 Jan 2026 03:01:49 +0000 Subject: [PATCH] Add usage analytics scopes for admin dashboards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add scopes to help admin dashboards analyze API key usage patterns: - `never_used` - keys that have never been used - `used` - keys that have been used at least once - `by_requests` - order by requests_count descending - `by_last_used` - order by last_used_at descending (nulls last) - `stale(period)` - active keys not used within period (default 30 days) - `inactive_for_30_days` - alias for stale(30.days) Also adds friendly aliases: - `most_used` → `by_requests` - `recently_used` → `by_last_used` These scopes integrate seamlessly with Madmin and other admin frameworks. Co-Authored-By: Claude Opus 4.5 --- lib/api_keys/models/api_key.rb | 34 +++++++++ test/models/api_key_test.rb | 125 +++++++++++++++++++++++++++++++++ 2 files changed, 159 insertions(+) diff --git a/lib/api_keys/models/api_key.rb b/lib/api_keys/models/api_key.rb index 8301266..7fe1439 100644 --- a/lib/api_keys/models/api_key.rb +++ b/lib/api_keys/models/api_key.rb @@ -79,6 +79,40 @@ def scopes=(value) scope :publishable, -> { where(key_type: "publishable") } scope :secret, -> { where.not(key_type: "publishable") } + # === Usage Analytics Scopes === + # These scopes help admin dashboards analyze API key usage patterns. + # Useful for identifying unused keys, high-traffic keys, and stale keys that may need cleanup. + + # Keys that have never been used (last_used_at is nil) + scope :never_used, -> { where(last_used_at: nil) } + + # Keys that have been used at least once + scope :used, -> { where.not(last_used_at: nil) } + + # Order by usage count (highest first) - useful for finding most active keys + scope :by_requests, -> { order(requests_count: :desc) } + + # Order by last used time (most recent first, nulls last) + # Uses NULLS LAST for PostgreSQL compatibility; SQLite sorts nulls last by default with DESC + scope :by_last_used, -> { order(Arel.sql("CASE WHEN last_used_at IS NULL THEN 1 ELSE 0 END, last_used_at DESC")) } + + # Active keys that haven't been used within the specified period. + # Useful for identifying keys that may have been abandoned or forgotten. + # Excludes revoked/expired keys since those are already inactive. + # @param period [ActiveSupport::Duration] The inactivity threshold (default: 30 days) + scope :stale, ->(period = 30.days) { + active.where("last_used_at < :threshold OR last_used_at IS NULL", threshold: period.ago) + } + + # Aliases for common admin dashboard naming conventions + class << self + alias_method :most_used, :by_requests + alias_method :recently_used, :by_last_used + end + + # Convenience scope for 30-day stale keys (common admin filter) + scope :inactive_for_30_days, -> { stale(30.days) } + # == Instance Methods == def revoke! diff --git a/test/models/api_key_test.rb b/test/models/api_key_test.rb index 98b54c8..c224987 100644 --- a/test/models/api_key_test.rb +++ b/test/models/api_key_test.rb @@ -120,6 +120,131 @@ def setup assert_not_includes expired_keys, active_key end + # === Usage Analytics Scopes === + # These scopes help admin dashboards analyze API key usage patterns + + test ".never_used scope returns keys that have never been used" do + used_key = ApiKeys::ApiKey.create!(owner: @user, name: "Used") + used_key.update_column(:last_used_at, 1.day.ago) + + never_used_key = ApiKeys::ApiKey.create!(owner: @user, name: "Never Used") + # last_used_at is nil by default + + never_used_keys = ApiKeys::ApiKey.never_used.to_a + assert_includes never_used_keys, never_used_key + assert_not_includes never_used_keys, used_key + end + + test ".used scope returns keys that have been used at least once" do + used_key = ApiKeys::ApiKey.create!(owner: @user, name: "Used") + used_key.update_column(:last_used_at, 1.day.ago) + + never_used_key = ApiKeys::ApiKey.create!(owner: @user, name: "Never Used") + + used_keys = ApiKeys::ApiKey.used.to_a + assert_includes used_keys, used_key + assert_not_includes used_keys, never_used_key + end + + test ".by_requests scope orders by requests_count descending" do + low_usage = ApiKeys::ApiKey.create!(owner: @user, name: "Low") + low_usage.update_column(:requests_count, 10) + + high_usage = ApiKeys::ApiKey.create!(owner: @user, name: "High") + high_usage.update_column(:requests_count, 1000) + + medium_usage = ApiKeys::ApiKey.create!(owner: @user, name: "Medium") + medium_usage.update_column(:requests_count, 100) + + ordered = ApiKeys::ApiKey.by_requests.to_a + assert_equal [high_usage, medium_usage, low_usage], ordered + end + + test ".by_last_used scope orders by last_used_at descending with nulls last" do + old_key = ApiKeys::ApiKey.create!(owner: @user, name: "Old") + old_key.update_column(:last_used_at, 7.days.ago) + + recent_key = ApiKeys::ApiKey.create!(owner: @user, name: "Recent") + recent_key.update_column(:last_used_at, 1.hour.ago) + + never_used_key = ApiKeys::ApiKey.create!(owner: @user, name: "Never") + # last_used_at is nil + + ordered = ApiKeys::ApiKey.by_last_used.to_a + # Recent should come first, then old, then never used (nulls last) + assert_equal recent_key, ordered.first + assert_equal old_key, ordered.second + assert_equal never_used_key, ordered.last + end + + test ".stale scope returns active keys not used in specified period" do + # Active key used recently - should NOT be stale + recent_key = ApiKeys::ApiKey.create!(owner: @user, name: "Recent") + recent_key.update_column(:last_used_at, 5.days.ago) + + # Active key not used in 30+ days - should be stale + stale_key = ApiKeys::ApiKey.create!(owner: @user, name: "Stale") + stale_key.update_column(:last_used_at, 45.days.ago) + + # Active key never used - should be stale + never_used_key = ApiKeys::ApiKey.create!(owner: @user, name: "Never Used") + + # Revoked key not used in 30+ days - should NOT be stale (already inactive) + revoked_key = ApiKeys::ApiKey.create!(owner: @user, name: "Revoked") + revoked_key.update_column(:last_used_at, 60.days.ago) + revoked_key.revoke! + + stale_keys = ApiKeys::ApiKey.stale(30.days).to_a + assert_includes stale_keys, stale_key + assert_includes stale_keys, never_used_key + assert_not_includes stale_keys, recent_key + assert_not_includes stale_keys, revoked_key + end + + test ".stale scope defaults to 30 days" do + stale_key = ApiKeys::ApiKey.create!(owner: @user, name: "Stale") + stale_key.update_column(:last_used_at, 31.days.ago) + + recent_key = ApiKeys::ApiKey.create!(owner: @user, name: "Recent") + recent_key.update_column(:last_used_at, 29.days.ago) + + stale_keys = ApiKeys::ApiKey.stale.to_a + assert_includes stale_keys, stale_key + assert_not_includes stale_keys, recent_key + end + + test ".most_used is an alias for .by_requests" do + low_usage = ApiKeys::ApiKey.create!(owner: @user, name: "Low") + low_usage.update_column(:requests_count, 10) + + high_usage = ApiKeys::ApiKey.create!(owner: @user, name: "High") + high_usage.update_column(:requests_count, 1000) + + assert_equal ApiKeys::ApiKey.by_requests.to_a, ApiKeys::ApiKey.most_used.to_a + end + + test ".recently_used is an alias for .by_last_used" do + old_key = ApiKeys::ApiKey.create!(owner: @user, name: "Old") + old_key.update_column(:last_used_at, 7.days.ago) + + recent_key = ApiKeys::ApiKey.create!(owner: @user, name: "Recent") + recent_key.update_column(:last_used_at, 1.hour.ago) + + assert_equal ApiKeys::ApiKey.by_last_used.to_a, ApiKeys::ApiKey.recently_used.to_a + end + + test ".inactive_for_30_days scope is equivalent to .stale with 30 days" do + stale_key = ApiKeys::ApiKey.create!(owner: @user, name: "Stale") + stale_key.update_column(:last_used_at, 31.days.ago) + + recent_key = ApiKeys::ApiKey.create!(owner: @user, name: "Recent") + recent_key.update_column(:last_used_at, 29.days.ago) + + assert_equal ApiKeys::ApiKey.stale(30.days).to_a, ApiKeys::ApiKey.inactive_for_30_days.to_a + assert_includes ApiKeys::ApiKey.inactive_for_30_days.to_a, stale_key + assert_not_includes ApiKeys::ApiKey.inactive_for_30_days.to_a, recent_key + end + test "revoke! sets revoked_at timestamp" do api_key = ApiKeys::ApiKey.create!(owner: @user, name: "To Revoke") assert_nil api_key.revoked_at