api_keys 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 54964496d525a092f7d1c9759e10bc4e73cce2633040c3f1e3b9a172609d02c3
4
- data.tar.gz: 4cf7aabd74db8edf00e6ba06fc3cfe1536c26dc606e0485e452b7e920750ad1a
3
+ metadata.gz: 8b2979856fd86b34f632b3d738195286ae07a829ac3a74dda56c5d6bf9e8c4f9
4
+ data.tar.gz: ee8651a2eaba436ec08bbf6df184d080685fbcbbe3751c4b0dc8980c4a4409eb
5
5
  SHA512:
6
- metadata.gz: 05f939482ae7258c7bc57c2cab1b4c971e58b5db4a74338640ae063b53862cbfdbc8d5e8b4385444595d21edf6c6754842d40ba0c42b6e7afc50fc543e9ca542
7
- data.tar.gz: 03addc813dbd92704cc1dce4adcc2db22c44d5cd364c2949d83ac19c903e390e1521d389ff95b2d0536652700d16b58a91bf357133bdab55ab655a3e4fc5b5be
6
+ metadata.gz: 0b481a5b23ede9c768fe98479d65f568efca51d339228c3541feedbbe4ba26d5df896d537207687237a809504fc3dc655ab31856ad3ac2cc16a5559e08b8c1dc
7
+ data.tar.gz: 0a154501522dfcc6e83fc8a6d89b75ed2d94d8c5fcdde5d942ea0e0da0f5f2948f1db117b1332414c4844e7c5bcf544a471609a18cc52584d4c7729f2e7a670a
data/CHANGELOG.md CHANGED
@@ -1,3 +1,40 @@
1
+ ## [0.6.0] - 2026-10-18
2
+
3
+ ### Added
4
+
5
+ - Ceiling-authoritative scopes, opt-in per key type ([#28](https://github.com/rameerez/api_keys/issues/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.
6
+ - `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.
7
+ - Refused-but-identified traffic ([#38](https://github.com/rameerez/api_keys/issues/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`.
8
+ - 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.
9
+ - `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.
10
+ - `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.
11
+ - `ApiKeys::ViewHelpers#api_key_refusal_reason` explains a key's latest refusal for custom dashboards.
12
+ - `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.
13
+
14
+ ### Changed
15
+
16
+ - `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`.
17
+ - 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.
18
+ - 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.
19
+ - 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.
20
+
21
+ ### Security
22
+
23
+ - `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.
24
+ - 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.
25
+ - 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.
26
+ - 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.
27
+ - The dashboard explains a refusal with fixed sentences and never renders the stored code itself.
28
+ - 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.
29
+ - 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.
30
+
31
+ ### Upgrade notes
32
+
33
+ - 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.
34
+ - 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.
35
+ - 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.
36
+ - 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".
37
+
1
38
  ## [0.5.1] - 2026-10-18
2
39
 
3
40
  ### Security
data/README.md CHANGED
@@ -50,6 +50,15 @@ rails db:migrate
50
50
 
51
51
  New installations get this column from the start, so this is only for upgrades.
52
52
 
53
+ To record refused requests on each key (see [Refused requests](#refused-requests-why-a-key-is-not-working)) and make the one-time token page durable (see [What "one-time" guarantees](#what-one-time-guarantees)), add the 0.6.0 columns in one migration:
54
+
55
+ ```bash
56
+ rails generate api_keys:add_key_activity
57
+ rails db:migrate
58
+ ```
59
+
60
+ New installations get these columns from the start too. Until an existing app runs the migration, authentication keeps working exactly as before, refusals are simply not recorded, and the one-time token page relies on the cache marker alone, as in 0.5.1.
61
+
53
62
  ## Quick Start
54
63
 
55
64
  Just add `has_api_keys` to your desired model. For example, if you want your `User` records to have API keys, you'd have:
@@ -159,11 +168,12 @@ config.authenticate_owner_method = :ensure_team_access!
159
168
  Once configured, your users can:
160
169
  - self-issue new API keys
161
170
  - set expiration dates
162
- - attach scopes / permissions to individual keys (the edit form changes only the scopes it shows, the owner's default scopes, and keeps any other scope a key holds)
171
+ - attach scopes / permissions to individual keys (the edit form changes only the scopes it shows, the owner's default scopes, keeps any other scope a key holds, and drops a held scope its type no longer allows; keys of a `scopes: :ceiling` type show their live permissions instead, on the creation form as soon as such a type is selected, and the dashboard never takes scopes for them)
163
172
  - add and edit the key names
164
173
  - lock a key to specific web origins or IP addresses
165
174
  - revoke instantly
166
175
  - see the status of all their keys
176
+ - see when a key's requests are arriving but being refused, and why
167
177
 
168
178
  It provides an UI with everything you'd expect from an API keys dashboard, working right out of the box:
169
179
 
@@ -433,6 +443,7 @@ Methods available on `ApiKeys::ApiKey` instances:
433
443
  # Scopes
434
444
  @api_key.scopes # => ["read", "write"]
435
445
  @api_key.allows_scope?("read") # => true
446
+ @api_key.scopes_follow_ceiling? # => true for `scopes: :ceiling` key types
436
447
 
437
448
  # Request restrictions (where the key may be used from)
438
449
  @api_key.allowed_origins # => ["example.com", "*.example.com"]
@@ -446,6 +457,11 @@ Methods available on `ApiKeys::ApiKey` instances:
446
457
  @api_key.expires_at
447
458
  @api_key.last_used_at
448
459
  @api_key.requests_count # If tracking enabled
460
+
461
+ # Refused requests (the key's real token, refused by a policy)
462
+ @api_key.last_refused_at # => 2026-10-18 12:00:00 UTC, or nil
463
+ @api_key.last_refusal_code # => "origin_not_allowed", or nil
464
+ @api_key.refusals_count # If tracking enabled
449
465
  ```
450
466
 
451
467
  ---
@@ -470,21 +486,40 @@ ApiKeys::TokenSession.store(session, @api_key, key: :my_custom_key)
470
486
  )
471
487
  ```
472
488
 
473
- Treat the session payload as private implementation detail; use `store`, `available?`, and `retrieve_once` rather than reading it directly. A failed, expired, tampered, mismatched, or reused handoff returns `nil` and fails closed. Any retrieval attempt uses the handoff up, including one bound to the wrong key.
489
+ Treat the session payload as private implementation detail; use `store`, `available?`, and `retrieve_once` rather than reading it directly. A failed, expired, tampered, mismatched, or reused handoff returns `nil` and fails closed. Every retrieval attempt outside a database transaction uses the handoff up, including one bound to the wrong key; an attempt refused inside a transaction (`:inside_transaction`, below) leaves it in the session for a retry outside it.
490
+
491
+ To tell the owner *why* no token came back, use `retrieve_once_with_status`. It consumes the handoff exactly like `retrieve_once` and also returns a status: `:retrieved`, `:already_shown` (an earlier retrieval released this key's token, for example a replayed copy of the session), `:withheld` (the database or cache could not record the retrieval), `:inside_transaction` (see below), or `:missing`:
492
+
493
+ ```ruby
494
+ retrieval = ApiKeys::TokenSession.retrieve_once_with_status(session, api_key: @api_key)
495
+ if retrieval.retrieved?
496
+ @token = retrieval.token
497
+ elsif retrieval.already_shown? || @api_key.token_shown?
498
+ redirect_to keys_path, alert: "This key's token was already shown."
499
+ else
500
+ redirect_to keys_path, alert: "The token can only be shown right after creation."
501
+ end
502
+ ```
474
503
 
475
504
  #### What "one-time" guarantees
476
505
 
477
506
  Deleting the payload makes the handoff one-time within that session: a refresh, or a second visit with the same session, shows nothing. A copy of the session taken *before* the token was shown is a different session. That is what a copied `CookieStore` cookie is, and what two requests racing with the same cookie are. Deleting the payload from one copy does nothing to the other.
478
507
 
479
- So each handoff also carries a random ID inside its encrypted payload, and the first retrieval records that ID in `Rails.cache` with `write(key, true, unless_exist: true, expires_in: 10.minutes)`. While the cache still holds that record, any later retrieval of the same handoff, from any copy of the session, finds it and returns `nil`.
508
+ So from 0.6.0 the first retrieval also claims the key's `token_shown_at` column with one conditional statement:
509
+
510
+ ```sql
511
+ UPDATE api_keys SET token_shown_at = <now> WHERE id = <key id> AND token_shown_at IS NULL
512
+ ```
480
513
 
481
- - **The token is released at most once only when both conditions hold:** every application process shares a store that implements `unless_exist:` atomically (Redis, Memcached), and that store keeps the record for the whole ten-minute handoff lifetime.
482
- - **The record lives only in the cache.** If the cache is flushed, restarted without persistence, or evicts the record within those ten minutes, a copy of the session taken before retrieval can recover the token again for the rest of that window. Recording consumption durably, in a database column, is the stronger design and is planned for 0.6.0.
483
- - **`MemoryStore`** only covers the process that holds it. **`FileStore`** checks and then writes, so it stops a later replay on the same host but not two requests that arrive at the same instant.
484
- - **Solid Cache** (Rails 8's default production store) implements `unless_exist:` as a `SELECT … FOR UPDATE` followed by an upsert. On PostgreSQL that locks nothing while the key does not exist yet, so two simultaneous first retrievals can both succeed. Sequential replays are still stopped.
485
- - **With `ActiveSupport::Cache::NullStore`** (what Rails' generated development and test environments use unless you toggle caching on), or with no `Rails.cache` at all, nothing can be recorded. The gem keeps the pre-0.5.1 behaviour: the session deletion is the only protection, and a copy of the session taken before retrieval can show the token again during its ten-minute lifetime.
486
- - **If the cache raises or refuses the write**, the token is withheld. The owner sees the "shown only once" message and creates a new key, which is safer than risking a second release.
487
- - **With a shared cache, a second request for the token page with the same cookie gets nothing**, even if the first response never reached the browser. A client that drops the first response (a timeout or a cancelled load) sees "shown only once" and needs a new key. That is the intended trade-off: a token can be lost, never shown twice.
514
+ The database applies it atomically, and the claim commits before the token is returned, so exactly one retrieval of a key's handoff ever succeeds, across processes and across copies of the session, whatever happens to the cache. A later retrieval from any copy changes no row and returns `nil` (the dashboard says the token was already shown).
515
+
516
+ - **With the column, the token is released at most once per key.** A cache flush, a restart, or an eviction changes nothing, and two simultaneous first retrievals are serialized by the database: one wins. `ApiKey#token_shown_at` and `#token_shown?` report it. A key whose row no longer exists gets nothing: zero rows claimed fails closed.
517
+ - **A retrieval attempted inside a database transaction is refused, and can be retried outside it.** Inside a transaction the claim would commit only with it, after the token had been returned, and a rollback would undo it. No cache can stand in for that (a process-local store protects one process), so whatever the cache, the token is withheld with `: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: a transaction opened with raw SQL (`BEGIN`) is not detected, and one on another database's connection does not matter. The dashboard opens no transaction itself, but its controller inherits from your `ApplicationController` (or the configured parent): a host `around_action` that wraps requests in a transaction will see the refusal and must exclude the token page.
518
+ - **The column is written only by that claim.** The model marks it read-only, so no form, mass assignment, or `update` can clear it and reopen a handoff.
519
+ - **The cache marker from 0.5.1 stays as a second layer.** Each handoff carries a random ID inside its encrypted payload, and retrieval records it in `Rails.cache` with `write(key, true, unless_exist: true, expires_in: 10.minutes)`. With the column it refuses a release the claim did not see (a 0.5.1 process that served the token during a rolling deploy), and it is the only cross-copy layer for a handoff that names no key (`TokenSession.store(session, "a token string")`).
520
+ - **Without the column** (an existing install that has not run `rails generate api_keys:add_key_activity`), the gem behaves as 0.5.1 and the marker is the only cross-copy layer. The token is then released at most once only when every application process shares a store that implements `unless_exist:` atomically (Redis, Memcached), and that store keeps the marker for the whole ten-minute handoff lifetime. If the cache is flushed, restarted without persistence, or evicts the marker within those ten minutes, a copy of the session taken before retrieval can recover the token again for the rest of that window. **`MemoryStore`** only covers the process that holds it. **`FileStore`** checks and then writes, so it stops a later replay on the same host but not two requests that arrive at the same instant. **Solid Cache** (Rails 8's default production store) implements `unless_exist:` as a `SELECT … FOR UPDATE` followed by an upsert; on PostgreSQL that locks nothing while the key does not exist yet, so two simultaneous first retrievals can both succeed. **`ActiveSupport::Cache::NullStore`** (Rails' generated development and test default), or no `Rails.cache` at all, records nothing, so the session deletion is the only protection.
521
+ - **If the database or the cache raises or refuses the write**, the token is withheld. The owner sees the "shown only once" message and creates a new key, which is safer than risking a second release.
522
+ - **A second request for the token page with the same cookie gets nothing**, even if the first response never reached the browser. A client that drops the first response (a timeout or a cancelled load) sees the "already shown" message and needs a new key. That is the intended trade-off: a token can be lost, never shown twice.
488
523
 
489
524
  None of this protects a session that has been stolen. Whoever holds a copy of the owner's session cookie can use the dashboard as that owner; the handoff is one-time, the session is not.
490
525
 
@@ -543,7 +578,7 @@ Renders a select dropdown with all expiration presets:
543
578
 
544
579
  #### `api_key_scopes_checkboxes`
545
580
 
546
- Renders scope checkboxes with a block for custom markup. Before the boxes it can render one empty `api_key[scopes][]` field: browsers omit unchecked boxes, so without it a form with every box cleared would send no scopes at all, and a new key would silently get the default scopes while an edited key would keep its old ones. With it, clearing every box submits `[""]`, which `create_api_key!` and `ApiKey#scopes=` read as "no scopes". The submitted list replaces the key's scopes, so render a box for every scope the key may hold.
581
+ Renders scope checkboxes with a block for custom markup. Before the boxes it can render one empty `api_key[scopes][]` field: browsers omit unchecked boxes, so without it a form with every box cleared would send no scopes at all, and a new key would silently get the default scopes while an edited key would keep its old ones. With it, clearing every box submits `[""]`, which `create_api_key!` and `ApiKey#scopes=` read as "no scopes". The submitted list replaces the key's scopes, so render a box for every scope the key may hold, or save it through `ApiKey#scopes_after_form_edit` (below).
547
582
 
548
583
  By default the field is rendered only when there is at least one box and the form's key enforces its scopes (`@api_key.scopes_enforced?`). That is true for typed keys, whenever `key_types` or the global `config.default_scopes` are configured, and when the owner model's `has_api_keys` declares `default_scopes`. The key reads that last setting through its owner, so build it from the owner (`current_organization.api_keys.build`). A form with no object (`form_with scope: :api_key`) or on a bare `ApiKeys::ApiKey.new` cannot see per-owner defaults and renders no field: pass `include_hidden: true` there to render it whenever there is at least one box. Where scopes are not enforced at all, an empty list still means "unrestricted", so the default leaves the key's scopes as they were rather than widening it. Pass `include_hidden: false` only if you render the field yourself. When you iterate the returned data without a block, render `hidden_field_tag "api_key[scopes][]", "", id: nil` before the boxes under the same conditions.
549
584
 
@@ -567,6 +602,16 @@ By default the field is rendered only when there is at least one box and the for
567
602
  <%# checked options: :all, :none, or an array of specific scopes %>
568
603
  ```
569
604
 
605
+ If your edit form offers only some scopes, saving the submitted list as is would strip every scope the form did not show. Save `ApiKey#scopes_after_form_edit(offered:, checked:)` instead, the rule the built-in dashboard uses: offered scopes follow their boxes, every other scope the key holds is kept, a submitted value that was never offered is ignored, and held scopes outside the key's current ceiling are dropped. Pass `checked: nil` when the request carried no scopes parameter at all. A `scopes: :ceiling` key gets its stored scopes back unchanged.
606
+
607
+ ```ruby
608
+ def update
609
+ submitted = params.require(:api_key).permit(:name, scopes: [])
610
+ scopes = @api_key.scopes_after_form_edit(offered: @available_scopes, checked: submitted[:scopes])
611
+ @api_key.update(name: submitted[:name], scopes: scopes)
612
+ end
613
+ ```
614
+
570
615
  #### `api_key_token_data`
571
616
 
572
617
  Returns structured data for building token display UIs:
@@ -637,6 +682,14 @@ end
637
682
  <%# => "Test mode", "Live mode", or "Default" %>
638
683
  ```
639
684
 
685
+ #### Refusal Helper
686
+
687
+ ```erb
688
+ <%# Why the key's latest recorded refusal happened (nil if none) %>
689
+ <%= api_key_refusal_reason(@key) %>
690
+ <%# => "the request came from a web origin the key does not allow" %>
691
+ ```
692
+
640
693
  ---
641
694
 
642
695
  ### View Examples
@@ -834,7 +887,7 @@ You can check if an API key is allowed to do actions by checking its scopes:
834
887
  # => true
835
888
  ```
836
889
 
837
- An empty scope list grants nothing whenever scopes are enforced: on typed keys, and when `key_types` or `default_scopes` are configured. On an untyped key in an app that configures neither, an empty list keeps its legacy meaning and allows every scope. So a key saved from the dashboard with every permission cleared becomes unrestricted if you later remove the owner's `default_scopes` (and the global ones) without using key types.
890
+ An empty scope list grants nothing whenever scopes are enforced: on typed keys, and when `key_types` or `default_scopes` are configured. The exception is a key whose type uses `scopes: :ceiling`: its stored scopes are not consulted, so even with `scopes: []` it has exactly its type's live `permissions:` (see [Widening a ceiling does not reach existing keys](#widening-a-ceiling-does-not-reach-existing-keys)). On an untyped key in an app that configures neither, an empty list keeps its legacy meaning and allows every scope. So a key saved from the dashboard with every permission cleared becomes unrestricted if you later remove the owner's `default_scopes` (and the global ones) without using key types.
838
891
 
839
892
  ## Controllers: secure your API endpoints
840
893
 
@@ -1106,6 +1159,27 @@ config.stats_update_interval = 0 # record every successful request
1106
1159
 
1107
1160
  Enabling `track_requests_count` bypasses this debounce because every successful request must be counted. Set `enable_async_operations = false` if the application handles statistics and callbacks elsewhere and wants the gem to enqueue no background jobs.
1108
1161
 
1162
+ The debounce reads the stored `last_used_at`, which only moves once a worker has run the job. So that a burst of requests arriving before then does not enqueue one job each, the first request in an interval also reserves the enqueue for its key in `Rails.cache` with a conditional `write(..., unless_exist: true, expires_in: stats_update_interval)`, and the rest of the burst is skipped. That needs a cache every process shares whose `unless_exist:` is atomic, such as Redis or Memcached. Solid Cache implements `unless_exist:` as a `SELECT … FOR UPDATE` followed by an upsert, which on PostgreSQL locks nothing while the entry is absent (see [What "one-time" guarantees](#what-one-time-guarantees)), so requests that arrive at the same instant can each reserve and enqueue a job there. That costs a few extra stats jobs and nothing else: the rest of the interval is still deduplicated. A request is skipped only when its reservation is confirmed to exist, so a write that fails quietly (Redis answers `nil` on a handled error, Memcached `false`) never suppresses statistics. With `NullStore`, no `Rails.cache`, or a cache that fails, the stored timestamp is the only debounce, and a burst that arrives before the worker runs still enqueues one job per request.
1163
+
1164
+ ### Refused requests: why a key is not working
1165
+
1166
+ A key can be perfectly valid and still be refused: an origin or IP lock, the wrong environment, a missing scope, a revoked or expired key. Refusals never touch `last_used_at` or `requests_count` (a refused request must not look like a working integration), so without anything else a key that is being used and refused would look exactly like a key nobody ever tried.
1167
+
1168
+ The gem therefore records them separately, through the same background stats job as `last_used_at`:
1169
+
1170
+ | Column | Meaning |
1171
+ |---|---|
1172
+ | `last_refused_at` | When the most recently recorded refusal happened |
1173
+ | `last_refusal_code` | Why: the `error_code` the client received, such as `origin_not_allowed`, `ip_not_allowed`, `environment_mismatch`, `missing_scope`, `revoked_key`, or `expired_key` |
1174
+ | `refusals_count` | How many refusals. Like `requests_count`, it is only kept with `track_requests_count = true` |
1175
+
1176
+ The dashboard's key page then says *"Requests are arriving but refused: last at X, because Y"*, and the key list shows the latest refusal under "Last Used".
1177
+
1178
+ - Only refusals of an identified key are recorded: the request presented the key's real token and a policy said no. A missing, malformed, or unknown token names no key and is never recorded, so nobody can write to a key's statistics without holding its token.
1179
+ - Refusal updates are debounced by `stats_update_interval` exactly like `last_used_at` (so within one interval you see the first refusal's code), including the cache reservation that stops a burst from enqueueing one job per request before the worker runs, and every refusal is recorded when `track_requests_count` is on. A client hammering a refused key costs no more writes than a working one; without a shared cache it can still queue one job per request until the first job has run.
1180
+ - `enable_async_operations = false` turns them off with every other job.
1181
+ - New installs get the columns from the start. Existing installs run `rails generate api_keys:add_key_activity`; until then, authentication is unchanged and refusals are not recorded.
1182
+
1109
1183
  ## Key Types: Stripe-style Publishable & Secret Keys
1110
1184
 
1111
1185
  For applications that distribute software with embedded API keys (desktop apps, mobile apps, CLI tools), you may want to differentiate between key types with different permission levels. The `api_keys` gem supports Stripe-style publishable/secret key types with optional test/live environment isolation.
@@ -1131,6 +1205,7 @@ ApiKeys.configure do |config|
1131
1205
  publishable: {
1132
1206
  prefix: "pk", # Token prefix → pk_test_, pk_live_
1133
1207
  permissions: %w[read validate], # Scope ceiling (max permissions allowed)
1208
+ # scopes: :ceiling, # Optional: see "Widening a ceiling" below
1134
1209
  public: true, # Store token so it remains viewable
1135
1210
  limit: 1, # Max 1 per owner per environment
1136
1211
  restrictions: [:origins] # May be locked to domains, not to IPs
@@ -1200,6 +1275,45 @@ sk = user.create_api_key!(
1200
1275
  sk.scopes # => ["read", "validate", "issue_license", "admin"]
1201
1276
  ```
1202
1277
 
1278
+ ### Widening a ceiling does not reach existing keys
1279
+
1280
+ > [!WARNING]
1281
+ > Each key stores its `scopes` when it is created. **Widening a key type's `permissions:` later does not touch the keys that already exist.** Add `heartbeat` to your publishable keys and every existing publishable key keeps its old list: it answers `403 missing_scope` on the new endpoint while freshly created keys work. Nothing fails loudly, and the key itself is valid, so it looks like a bug in your client. (Narrowing is safe either way: a scope above the current ceiling is denied at request time.)
1282
+
1283
+ There are two ways out.
1284
+
1285
+ **Make the ceiling the contract.** For key types whose users do not pick scopes (auto-created keys, `limit: 1`, the publishable-key pattern), opt the type into ceiling-authoritative scopes:
1286
+
1287
+ ```ruby
1288
+ config.key_types = {
1289
+ publishable: {
1290
+ prefix: "pk",
1291
+ permissions: %w[read validate heartbeat],
1292
+ scopes: :ceiling, # every pk key has exactly these permissions, now and after you edit them
1293
+ public: true,
1294
+ limit: 1
1295
+ },
1296
+ secret: { prefix: "sk", permissions: :all } # stored scopes, as before
1297
+ }
1298
+ ```
1299
+
1300
+ With `scopes: :ceiling`, every scope check for keys of that type (`authenticate_api_key!(scope:)`, `allows_scope?`, `allows_permission?`) reads the live `permissions:` list instead of the key's stored scopes:
1301
+
1302
+ - Widening `permissions:` reaches every existing key on its next request, and narrowing it takes effect just as fast.
1303
+ - Every key of the type has exactly the ceiling, including a key created with an empty or narrower scope list. Do not opt in a type whose users choose scopes per key: their choices would stop mattering.
1304
+ - Stored `scopes` stay on the row as metadata and are still validated against the ceiling whenever they are written. The dashboard shows the live permissions for these keys and stops offering scope checkboxes, both on the edit form and on the creation form once such a type is selected. It never takes scopes for them: on create, submitted scopes are ignored and the key stores the type's current ceiling; on edit, submitted scopes are ignored and stored scopes above a narrowed ceiling stay as they are (other types drop them on the next save). Renaming such a key or tightening its restrictions still saves.
1305
+ - `scopes: :ceiling` requires a finite, non-empty `permissions` list. `permissions: :all` raises when the configuration loads.
1306
+ - Removing `scopes: :ceiling` later can only narrow: keys go back to their stored scopes, still capped by the ceiling.
1307
+ - Every other key type, untyped legacy keys, and keys whose type is no longer configured behave exactly as before.
1308
+
1309
+ **Or backfill the stored scopes** right after widening a type that keeps stored scopes, for example in the same deploy:
1310
+
1311
+ ```ruby
1312
+ ApiKeys.reconcile_scopes!(key_type: :publishable) # => number of keys that gained scopes
1313
+ ```
1314
+
1315
+ It adds the type's current `permissions:` to the stored scopes of every non-revoked key of that type and never removes anything. Because it also re-adds permissions a user removed on purpose, only run it for types whose users do not curate scopes. It raises for an unknown type or a `permissions: :all` ceiling, and skips any row whose stored scopes are malformed.
1316
+
1203
1317
  ### Non-Revocable Keys
1204
1318
 
1205
1319
  Keys with `revocable: false` protect against accidental deletion. Configure
data/SECURITY.md CHANGED
@@ -24,7 +24,7 @@ Do not open a public issue for an undisclosed vulnerability. We will acknowledge
24
24
  Applications remain responsible for:
25
25
 
26
26
  - trusted TLS/proxy configuration, including a `client_ip_resolver` that matches how clients reach the Rails server (see below), endpoint authorization, and request rate limiting;
27
- - encrypted, `Secure`, `HttpOnly`, appropriately `SameSite` session cookies for the one-time dashboard token handoff, and a shared cache store if that handoff must be one-time across copies of a session (see below);
27
+ - encrypted, `Secure`, `HttpOnly`, appropriately `SameSite` session cookies for the one-time dashboard token handoff, and the 0.6.0 `token_shown_at` column (or, until it is migrated, a shared cache store) if that handoff must be one-time across copies of a session (see below);
28
28
  - database, cache, queue, log, backup, and observability access controls;
29
29
  - filtering any query-parameter credential name from application and proxy logs if that opt-in transport is enabled;
30
30
  - classifying every permission on a `public: true` key type as safe for an untrusted public client and maintaining a disable/replacement procedure for non-revocable identifiers; and
@@ -43,10 +43,11 @@ Configuring `trusted_proxies` alone is not a boundary: it tells Rails which hops
43
43
 
44
44
  ### What the one-time token handoff guarantees
45
45
 
46
- The dashboard shows a new secret key once, through an encrypted session handoff that expires after ten minutes and is deleted on first retrieval. Deletion covers only the session that retrieved it. From 0.5.1 the first retrieval also records the handoff's random ID in `Rails.cache` with an atomic `write(..., unless_exist: true)`, so a copy of the session taken before retrieval (a copied cookie, or concurrent requests) gets nothing while the cache keeps that record.
46
+ The dashboard shows a new secret key once, through an encrypted session handoff that expires after ten minutes and is deleted on first retrieval. Deletion covers only the session that retrieved it, so two more layers stop a copy of the session taken before retrieval (a copied cookie, or concurrent requests).
47
47
 
48
- - That holds only when both conditions are met: every application process shares a cache store that implements `unless_exist:` atomically, such as Redis or Memcached, and the store retains the record for the ten-minute handoff lifetime. The record exists only in the cache, so a flush, a restart without persistence, or an eviction within that window lets a copy of the session taken before retrieval recover the token again until the handoff expires. Durable consumption state (a database column) is the stronger design and is planned for 0.6.0.
49
- - `MemoryStore` covers one process; `FileStore` stops later replays on one host but not simultaneous requests. Solid Cache (Rails 8's default production store) implements `unless_exist:` as `SELECT … FOR UPDATE` then an upsert, which on PostgreSQL locks nothing while the key is absent, so two simultaneous first retrievals can both succeed; sequential replays are still stopped.
50
- - With `ActiveSupport::Cache::NullStore`, or no `Rails.cache`, the gem cannot record anything and the session deletion is the only protection, as before 0.5.1.
51
- - If the cache raises or refuses the write, the token is withheld rather than risk a second release.
48
+ - **Durable, from 0.6.0.** The first retrieval claims the key's `token_shown_at` column with `UPDATE api_keys SET token_shown_at = now WHERE id = ? AND token_shown_at IS NULL`, and the claim commits before the token is returned, so exactly one retrieval of a key's handoff ever succeeds, across processes and whatever happens to the cache. Zero rows changed (already shown, or the key no longer exists) withholds the token. The model marks the column read-only, so only that claim writes it.
49
+ - **A retrieval attempted inside a database transaction is refused and can be retried outside it.** There the claim would commit only with the enclosing transaction, after the token was returned, and a rollback would undo it, so whatever the cache the token is withheld (`:inside_transaction`), nothing is recorded, and the handoff stays in the session. The check is Active Record's transaction manager on the `ApiKey` connection; a raw SQL `BEGIN` is not detected. The dashboard opens no transaction, but it inherits the host's controller: a host `around_action` that wraps requests in a transaction will see the refusal and must exclude the token page.
50
+ - **Cache marker, from 0.5.1, kept as a second layer.** Retrieval also records the handoff's random ID in `Rails.cache` with an atomic `write(..., unless_exist: true)`. With the column it refuses a release the claim did not see (a 0.5.1 process during a rolling deploy) and covers handoffs that name no key.
51
+ - **Without the column** (not migrated yet), the gem behaves as 0.5.1: the cache marker is the only cross-copy layer. It holds only when every application process shares a cache store that implements `unless_exist:` atomically, such as Redis or Memcached, and the store retains the marker for the ten-minute handoff lifetime; a flush, a restart without persistence, or an eviction within that window lets a copy of the session taken before retrieval recover the token again until the handoff expires. `MemoryStore` covers one process; `FileStore` stops later replays on one host but not simultaneous requests; Solid Cache on PostgreSQL can let two simultaneous first retrievals both succeed; with `ActiveSupport::Cache::NullStore`, or no `Rails.cache`, the session deletion is the only protection. Run `rails generate api_keys:add_key_activity` to make the handoff durable.
52
+ - If the database or the cache raises or refuses the write, the token is withheld rather than risk a second release.
52
53
  - It does not protect a stolen session: whoever holds the owner's session cookie can use the dashboard as that owner.
@@ -3,9 +3,13 @@
3
3
  module ApiKeys
4
4
  # Controller for managing API keys belonging to the current owner.
5
5
  class KeysController < ApplicationController
6
+ ALREADY_SHOWN_ALERT = "This API key's token was already shown and cannot be shown again. " \
7
+ "If you did not save it, revoke this key and create a new one."
8
+
6
9
  before_action :set_api_key, only: [:show, :edit, :update, :revoke]
10
+ helper ApiKeys::Helpers::ViewHelpers
7
11
  helper_method :key_types_feature_enabled?, :api_keys_allowed_restriction_kinds,
8
- :dashboard_scope_choices, :restriction_field_text
12
+ :dashboard_scope_choices, :restriction_field_text, :api_keys_displayed_scopes
9
13
 
10
14
  # GET /keys
11
15
  def index
@@ -36,15 +40,24 @@ module ApiKeys
36
40
  # Shows the newly generated key's plaintext token ONCE.
37
41
  # This is not a standard show action, it's used transiently after creation.
38
42
  def show
39
- # Key is set by set_api_key
40
- # We need to retrieve the plaintext token stored temporarily
41
- # after creation. This relies on how we handle creation.
42
- # We'll likely store it in the session flash or pass it directly.
43
- @plaintext_token = ApiKeys::TokenSession.retrieve_once(session, api_key: @api_key)
44
- unless @plaintext_token
45
- # If accessed directly without the token, redirect or show an error
46
- redirect_to keys_path, alert: "API key token can only be shown once immediately after creation."
47
- end
43
+ # Key is set by set_api_key. The plaintext token comes from the one-time
44
+ # session handoff written by create; it is released once, ever. This
45
+ # action opens no database transaction, so the durable claim commits
46
+ # before the token is rendered; keep it that way. A retrieval inside a
47
+ # transaction (for example a host around_action wrapping every request
48
+ # in one) is refused and the handoff kept, see ApiKeys::TokenSession.
49
+ retrieval = ApiKeys::TokenSession.retrieve_once_with_status(session, api_key: @api_key)
50
+ @plaintext_token = retrieval.token
51
+ return if @plaintext_token
52
+
53
+ # A replayed copy of the session, or a refresh after the token page,
54
+ # gets an explicit answer instead of the generic one.
55
+ alert = if retrieval.already_shown? || @api_key.token_shown?
56
+ ALREADY_SHOWN_ALERT
57
+ else
58
+ "API key token can only be shown once immediately after creation."
59
+ end
60
+ redirect_to keys_path, alert: alert
48
61
  end
49
62
 
50
63
  # GET /keys/new
@@ -59,12 +72,23 @@ module ApiKeys
59
72
  # re-checking every scope the owner had cleared.
60
73
  @submitted_scopes = submitted_params[:scopes]
61
74
 
75
+ # A `scopes: :ceiling` type is authorized by its live permissions, so
76
+ # the form offers no scopes for it and any submitted (cleared boxes, or
77
+ # a crafted request) are ignored. The key stores the type's current
78
+ # ceiling as its metadata.
79
+ type_config = requested_key_type_config(submitted_params[:key_type])
80
+ scopes = if ApiKeys::ApiKey.scopes_follow_ceiling_for?(type_config)
81
+ type_config[:permissions].map(&:to_s)
82
+ else
83
+ submitted_params[:scopes]
84
+ end
85
+
62
86
  # Use the HasApiKeys helper method to create the key
63
87
  begin
64
88
  # create_api_key! now returns the ApiKey instance
65
89
  @api_key = current_api_keys_owner.create_api_key!(
66
90
  name: submitted_params[:name],
67
- scopes: submitted_params[:scopes],
91
+ scopes: scopes,
68
92
  expires_at: parse_expiration(submitted_params[:expires_at_preset]),
69
93
  key_type: submitted_params[:key_type].presence,
70
94
  # The model normalizes these raw strings; no parser needed here.
@@ -105,12 +129,20 @@ module ApiKeys
105
129
  # PATCH/PUT /keys/:id
106
130
  def update
107
131
  attributes = api_key_update_params
108
- if attributes.key?(:scopes)
109
- attributes[:scopes] = scopes_after_editing_offered_choices(attributes[:scopes])
110
- elsif held_scopes_within_ceiling != Array(@api_key.scopes)
111
- # No scope boxes were shown, but the key still holds a scope its type
112
- # no longer allows; without dropping it no edit could ever be saved.
113
- attributes[:scopes] = held_scopes_within_ceiling
132
+ if @api_key.scopes_follow_ceiling?
133
+ # The type's live ceiling authorizes this key and its stored scopes are
134
+ # metadata. The form offers no scope boxes for it, so any submitted
135
+ # scopes (the sentinel, or a crafted request) are ignored, and held
136
+ # scopes outside a narrowed ceiling are kept: the model lets such a key
137
+ # save as long as its scopes are not written.
138
+ attributes.delete(:scopes)
139
+ else
140
+ # The form speaks only for the scopes it offered (see
141
+ # ApiKey#scopes_after_form_edit). Without a scopes parameter (no boxes
142
+ # were shown) it still drops held scopes the current ceiling excludes,
143
+ # or no edit of such a key could ever be saved.
144
+ edited = @api_key.scopes_after_form_edit(offered: dashboard_scope_choices, checked: attributes[:scopes])
145
+ attributes[:scopes] = edited if attributes.key?(:scopes) || edited != Array(@api_key.scopes)
114
146
  end
115
147
  skip_untouched_restriction_fields!(attributes)
116
148
 
@@ -176,35 +208,6 @@ module ApiKeys
176
208
  Array(current_api_keys_owner.class.api_keys_settings.dig(:default_scopes))
177
209
  end
178
210
 
179
- # The edit form can only speak for the scopes it showed. Checked choices
180
- # are granted and unchecked ones removed; any other scope the key holds
181
- # (for example one granted programmatically, or a type permission outside
182
- # the owner's defaults) is kept, in its current order. A submitted value
183
- # that was never offered is ignored.
184
- #
185
- # A held scope outside the key's current ceiling is dropped, whether
186
- # offered or not: authentication already denies it, so no access changes,
187
- # and keeping it would make every save of the key (a rename, or tightening
188
- # a non-revocable key's origins) fail the ceiling validation. A newly
189
- # checked scope outside the ceiling is kept, so validation reports it.
190
- #
191
- # @param submitted [Array<String>] Checked boxes, sentinel already stripped.
192
- # @return [Array<String>]
193
- def scopes_after_editing_offered_choices(submitted)
194
- offered = dashboard_scope_choices
195
- held = Array(@api_key.scopes)
196
- kept = held_scopes_within_ceiling.select { |scope| !offered.include?(scope) || submitted.include?(scope) }
197
- added = offered.select { |scope| submitted.include?(scope) && !held.include?(scope) }
198
- kept + added
199
- end
200
-
201
- # The key's scopes without those its current ceiling excludes.
202
- #
203
- # @return [Array<String>]
204
- def held_scopes_within_ceiling
205
- Array(@api_key.scopes).select { |scope| @api_key.scope_within_ceiling?(scope) }
206
- end
207
-
208
211
  # The text the form puts in a restriction field. Usable entries appear as
209
212
  # they are stored; anything else a stored policy holds (null, false, a
210
213
  # number, a blank string) is shown as JSON, so the owner can see it.
@@ -295,6 +298,30 @@ module ApiKeys
295
298
  end.uniq
296
299
  end
297
300
 
301
+ # The permissions a key actually has, for display. A `scopes: :ceiling` key
302
+ # is authorized by its type's live ceiling, so showing the list it stored
303
+ # at creation would misstate what it can do.
304
+ #
305
+ # @param api_key [ApiKeys::ApiKey]
306
+ # @return [Array<String>]
307
+ def api_keys_displayed_scopes(api_key)
308
+ return Array(api_key.key_type_config[:permissions]).map(&:to_s) if api_key.scopes_follow_ceiling?
309
+
310
+ api_key.scopes.is_a?(Array) ? api_key.scopes : []
311
+ end
312
+
313
+ # The configuration of the key type a create request resolves to, the way
314
+ # create_api_key! resolves it (the submitted type, else the default), or
315
+ # nil when there is none or it is unknown.
316
+ #
317
+ # @return [Hash, nil]
318
+ def requested_key_type_config(key_type_param)
319
+ type_name = key_type_param.presence || ApiKeys.configuration.default_key_type
320
+ return nil if type_name.blank?
321
+
322
+ (ApiKeys.configuration.key_types || {}).find { |name, _settings| name.to_s == type_name.to_s }&.last
323
+ end
324
+
298
325
  # Check if key types feature is enabled
299
326
  def key_types_feature_enabled?
300
327
  ApiKeys.configuration.key_types.present? && ApiKeys.configuration.key_types.any?