Skip to content
Open
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
7 changes: 7 additions & 0 deletions app/components/admin/users/show.rb
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,13 @@ def api_key_row(api_key)
div do
div(style: "font-weight: 500;") { api_key.name }
code(style: "font-size: 12px; color: var(--fgColor-muted);") { api_key.masked_token }
div(style: "font-size: 12px; color: var(--fgColor-muted);") do
if api_key.last_used_at
plain "Last used #{api_key.last_used_at.strftime('%b %d, %Y')}"
else
plain "Never used"
end
end
end
div(style: "display: flex; align-items: center; gap: 12px;") do
if api_key.revoked?
Expand Down
6 changes: 6 additions & 0 deletions app/components/api_keys/_row.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ def view_template
code(style: "font-size: 12px; color: var(--fgColor-muted, #656d76);") { api_key.masked_token }
div(style: "font-size: 12px; color: var(--fgColor-muted, #656d76); margin-top: 4px;") do
plain "Created #{time_ago_in_words(api_key.created_at)} ago"
plain " · "
if api_key.last_used_at
plain "Last used #{time_ago_in_words(api_key.last_used_at)} ago"
else
span(style: "color: var(--fgColor-attention, #9a6700);") { "Never used" }
end
end
end

Expand Down
1 change: 1 addition & 0 deletions app/controllers/api/v4/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ def authenticate!
end

@current_user = @current_token.user
@current_token.touch_last_used!
end

def set_sentry_context
Expand Down
11 changes: 11 additions & 0 deletions app/models/api_key.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,21 @@ def self.find_by_token(token)
find_by(token: token) # Blind index handles lookup
end

# How stale last_used_at is allowed to get before we write to the database.
# Keeps hot keys from issuing an UPDATE on every single API request.
USAGE_TRACKING_PRECISION = 5.minutes

def revoke!
update!(revoked: true, revoked_at: Time.current)
end

def touch_last_used!
now = Time.current
return if last_used_at.present? && last_used_at > now - USAGE_TRACKING_PRECISION

update_column(:last_used_at, now)
end

def active?
!revoked
end
Expand Down
5 changes: 5 additions & 0 deletions db/migrate/20260916000000_add_last_used_at_to_api_keys.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class AddLastUsedAtToAPIKeys < ActiveRecord::Migration[8.0]
def change
add_column :api_keys, :last_used_at, :datetime
end
end
3 changes: 2 additions & 1 deletion db/schema.rb

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

9 changes: 9 additions & 0 deletions test/controllers/api/v4/users_controller_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@ class API::V4::UsersControllerTest < ActionDispatch::IntegrationTest
assert_equal @user.name, json["name"]
end

test "authenticated request records last used at on the key" do
assert_nil @api_key.last_used_at

get api_v4_me_url, headers: { "Authorization" => "Bearer #{@token}" }

assert_response :success
assert @api_key.reload.last_used_at.present?
end

test "should reject request without token" do
get api_v4_me_url

Expand Down
11 changes: 11 additions & 0 deletions test/controllers/api_keys_controller_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,17 @@ class APIKeysControllerTest < ActionDispatch::IntegrationTest
assert_response :success
end

test "index shows last used for used keys and flags unused ones" do
@user.api_keys.create!(name: "Unused Key")
@user.api_keys.create!(name: "Used Key", last_used_at: 2.days.ago)

get api_keys_url

assert_response :success
assert_match "Never used", response.body
assert_match "Last used 2 days ago", response.body
end

test "should create api key" do
assert_difference("APIKey.count", 1) do
post api_keys_url, params: { api_key: { name: "New Key" } }
Expand Down
33 changes: 33 additions & 0 deletions test/models/api_key_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,39 @@ class APIKeyTest < ActiveSupport::TestCase
assert api_key.revoked_at.present?
end

test "touch_last_used! records first use" do
api_key = users(:one).api_keys.create!(name: "Test Key")
assert_nil api_key.last_used_at

api_key.touch_last_used!

assert api_key.reload.last_used_at.present?
end

test "touch_last_used! skips writes within the tracking precision window" do
api_key = users(:one).api_keys.create!(name: "Test Key")
api_key.touch_last_used!
first_use = api_key.reload.last_used_at

travel 1.minute do
api_key.touch_last_used!
end

assert_equal first_use, api_key.reload.last_used_at
end

test "touch_last_used! writes again once last_used_at is stale" do
api_key = users(:one).api_keys.create!(name: "Test Key")
api_key.touch_last_used!
first_use = api_key.reload.last_used_at

travel APIKey::USAGE_TRACKING_PRECISION + 1.minute do
api_key.touch_last_used!
end

assert api_key.reload.last_used_at > first_use
end

test "masked_token shows prefix and suffix" do
user = users(:one)
api_key = user.api_keys.create!(name: "Test Key")
Expand Down