Skip to content

api_keys 0.6.0

Choose a tag to compare

@rameerez rameerez released this 18 Oct 22:13
· 4 commits to main since this release
Immutable release. Only release title and notes can be modified.
0b0640b

Added

  • Ceiling-authoritative scopes, opt-in per key type (#28). key_types[...][:scopes] = :ceiling makes the type's live permissions: list the contract for every key of that type, so widening it reaches existing keys and narrowing it takes effect on the next request. Every scope check consults it (authenticate_api_key!(scope:), allows_scope?, allows_permission?); stored scopes stay as metadata. Requires a finite, non-empty permissions list. ApiKey#scopes_follow_ceiling? reports it, and the dashboard shows these keys' live permissions instead of scope checkboxes that would change nothing.
  • ApiKeys.reconcile_scopes!(key_type:): an add-only backfill that merges a type's current ceiling into the stored scopes of its non-revoked keys, for types that keep stored scopes after their ceiling was widened. Each key is rechecked under its row lock, so a key revoked while the backfill runs is skipped.
  • Refused-but-identified traffic (#38). last_refused_at, refusals_count, and last_refusal_code on api_keys, stamped through the same async UpdateStatsJob path as last_used_at whenever a request presents a key's real token and a policy refuses it: revoked, expired, type/environment configuration, environment isolation, origin/IP restrictions, and missing scope. The dashboard's key page says "Requests are arriving but refused: last at X, because Y", and the key list shows the latest refusal under "Last Used". refusals_count follows track_requests_count, like requests_count.
  • Durable one-time token handoff (promised in 0.5.1). A token_shown_at column on api_keys, claimed by the first retrieval of a key's token through ApiKeys::TokenSession with one conditional UPDATE ... WHERE id = ? AND token_shown_at IS NULL. The claim commits before the token is returned; a retrieval attempted inside an open database transaction is refused instead (see Security). ApiKey#token_shown_at and #token_shown? report it.
  • ApiKeys::TokenSession.retrieve_once_with_status consumes a handoff exactly like retrieve_once and also says why no token came back (:already_shown, :withheld, :inside_transaction, :missing). The dashboard's token page uses it: a replayed copy of the session, or a refresh after the token was shown, now says the token was already shown. Its inspect, to_s, pretty-print and JSON forms redact the token, like the authentication result's.
  • rails generate api_keys:add_key_activity for existing installations: one idempotent migration with every 0.6.0 column (last_refused_at, refusals_count, last_refusal_code, token_shown_at). New installs create the columns from the start.
  • ApiKeys::ViewHelpers#api_key_refusal_reason explains a key's latest refusal for custom dashboards.
  • ApiKey#scopes_after_form_edit(offered:, checked:): the dashboard's 0.5.1 edit rule as a public method for host forms built with api_key_scopes_checkboxes. Offered scopes follow their boxes, every other held scope is kept, an unoffered submitted value is ignored, and held scopes outside the current ceiling are dropped; a scopes: :ceiling key gets its stored scopes back unchanged. The dashboard now calls it.

Changed

  • ApiKeys::Jobs::UpdateStatsJob#perform takes an optional refusal_code: keyword. Jobs enqueued by earlier versions run unchanged. A job carrying any refusal marker, recognized or not, never touches last_used_at or requests_count.
  • The scope-ceiling validation no longer blocks saving an existing scopes: :ceiling key whose stored scopes exceed a narrowed ceiling, as long as the scopes themselves are not being written. Those stored scopes authorize nothing, and the validation would otherwise stop owners from renaming the key or tightening its request restrictions.
  • Dashboard edits of a scopes: :ceiling key never write its stored scopes. The form shows the type's live permissions with no scope boxes, the update ignores any submitted scopes, and stored scopes above a narrowed ceiling are kept as metadata rather than dropped. Every other key type keeps 0.5.1's rules: the form edits only the scopes it offers, keeps the rest, and drops held scopes outside the current ceiling.
  • The dashboard's creation form hides and disables the scope boxes when a scopes: :ceiling type is selected and shows that type's live permissions instead, so an owner can no longer clear every box and still get a key with the whole ceiling. A create for such a type ignores any submitted scopes and stores the type's current ceiling as the key's metadata.

Security

  • scopes: :ceiling widens nothing it was not asked to. Types that do not opt in, untyped keys, and keys whose type is no longer configured keep their exact previous behavior. :ceiling against permissions: :all or an empty list is rejected when the configuration loads, and ignored at authorization time should a configuration bypass that validation. Damaged stored scopes on a ceiling key deny every scope.
  • A refusal is never a use: refused traffic cannot move last_used_at or requests_count, so integrations that detect a first working request from last_used_at stay correct.
  • Refusal statistics are written only after the presented token is verified, only for an allowlist of policy refusal codes, and debounced exactly like last_used_at. They add no unauthenticated write path.
  • Stats jobs, for successful and for refused requests, are now deduplicated before a worker runs. The debounce read only the persisted timestamp, so N requests arriving before the first job ran enqueued N jobs. With a shared Rails.cache, the first request in a stats_update_interval reserves the enqueue for its key and kind with an atomic write(..., unless_exist: true, expires_in: interval) and the rest of the burst is skipped. A request is skipped only when the reservation is confirmed to exist (the write answered false and the key is present), so a store that fails a write quietly (Redis nil, Memcached false) never suppresses statistics; a reservation whose job fails to enqueue (an exception, or perform_later answering false) is released. Exact counting (track_requests_count) is never deduplicated. On a store whose unless_exist: is not atomic for an absent entry (Solid Cache on PostgreSQL), simultaneous first requests can each enqueue a job, which is harmless. Remaining limitation: with NullStore, no cache, or a cache error, only the persisted timestamp debounces, and such a burst still enqueues one job per request.
  • The dashboard explains a refusal with fixed sentences and never renders the stored code itself.
  • The one-time token handoff no longer depends on the cache keeping its marker. 0.5.1 recorded consumption only in Rails.cache, so a cache flush, restart, or eviction within the ten-minute handoff lifetime let a copy of the session taken before retrieval recover the token again. With token_shown_at, the claim commits before the token is returned, so the database admits exactly one retrieval per key, whatever happens to the cache and with any cache store, including NullStore. The cache marker stays as a second layer, made after the durable claim. Zero rows claimed (already shown, or the key no longer exists) and any database or cache error withhold the token. The model marks token_shown_at read-only, so no form or update can clear it and reopen a handoff. Without the column the handoff behaves exactly as in 0.5.1.
  • A retrieval attempted inside an open database transaction is refused, whatever the cache: there the claim would commit only with the transaction, after the token was returned, and a rollback would let another copy of the session, or another process, release the token again. The token is withheld as :inside_transaction, nothing is recorded, and the handoff stays in the session for a retrieval outside the transaction. The check is Active Record's transaction manager on the ApiKey connection, so a raw SQL BEGIN is not detected. The dashboard opens no transaction itself, but its controller inherits the host's: a host around_action that wraps requests in a transaction must exclude the token page.

Upgrade notes

  • Run rails generate api_keys:add_key_activity and rails db:migrate to start recording refusals and make the token handoff durable. Until then, authentication is unchanged, refusals are not recorded, and the handoff relies on the cache marker alone, as in 0.5.1. Migrate before deploying the code, or deploy and then migrate: both orders are safe, and a process started before the migration picks up the columns when it restarts.
  • Each key's token can now be handed off once, ever. A host that stores two handoffs for the same key (for example under two session keys) gets the token from the first retrieval only.
  • Deploy job workers together with web processes. A refusal job enqueued by 0.6.0 errors on a 0.5.x worker, whose perform takes no keyword; depending on your backend it is retried or dropped, and it is never recorded as a use.
  • If you have widened a key type's permissions: since its keys were created, those keys still carry the old list. Opt the type into scopes: :ceiling when its users do not curate scopes, or run ApiKeys.reconcile_scopes!(key_type: ...). See the README section "Widening a ceiling does not reach existing keys".