diff --git a/app/components/admin/users/show.rb b/app/components/admin/users/show.rb index 1b6c5df..d0d7070 100644 --- a/app/components/admin/users/show.rb +++ b/app/components/admin/users/show.rb @@ -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? diff --git a/app/components/api_keys/_row.rb b/app/components/api_keys/_row.rb index 7d9601e..be4e626 100644 --- a/app/components/api_keys/_row.rb +++ b/app/components/api_keys/_row.rb @@ -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 diff --git a/app/controllers/api/v4/application_controller.rb b/app/controllers/api/v4/application_controller.rb index 5d88b7f..d253f60 100644 --- a/app/controllers/api/v4/application_controller.rb +++ b/app/controllers/api/v4/application_controller.rb @@ -31,6 +31,7 @@ def authenticate! end @current_user = @current_token.user + @current_token.touch_last_used! end def set_sentry_context diff --git a/app/models/api_key.rb b/app/models/api_key.rb index b8db22c..31d1f2f 100644 --- a/app/models/api_key.rb +++ b/app/models/api_key.rb @@ -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 diff --git a/db/migrate/20260916000000_add_last_used_at_to_api_keys.rb b/db/migrate/20260916000000_add_last_used_at_to_api_keys.rb new file mode 100644 index 0000000..cdb1cbf --- /dev/null +++ b/db/migrate/20260916000000_add_last_used_at_to_api_keys.rb @@ -0,0 +1,5 @@ +class AddLastUsedAtToAPIKeys < ActiveRecord::Migration[8.0] + def change + add_column :api_keys, :last_used_at, :datetime + end +end diff --git a/db/schema.rb b/db/schema.rb index 6719375..7dd6dc7 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_09_03_021358) do +ActiveRecord::Schema[8.1].define(version: 2026_09_16_000000) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -44,6 +44,7 @@ create_table "api_keys", force: :cascade do |t| t.datetime "created_at", null: false + t.datetime "last_used_at" t.string "name", null: false t.boolean "revoked", default: false, null: false t.datetime "revoked_at" diff --git a/test/controllers/api/v4/users_controller_test.rb b/test/controllers/api/v4/users_controller_test.rb index 26eb12a..bda81fa 100644 --- a/test/controllers/api/v4/users_controller_test.rb +++ b/test/controllers/api/v4/users_controller_test.rb @@ -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 diff --git a/test/controllers/api_keys_controller_test.rb b/test/controllers/api_keys_controller_test.rb index 5e13bec..ea2c947 100644 --- a/test/controllers/api_keys_controller_test.rb +++ b/test/controllers/api_keys_controller_test.rb @@ -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" } } diff --git a/test/models/api_key_test.rb b/test/models/api_key_test.rb index 5da6544..8110da7 100644 --- a/test/models/api_key_test.rb +++ b/test/models/api_key_test.rb @@ -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")