api_keys 0.5.1 → 0.7.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 +4 -4
- data/CHANGELOG.md +69 -0
- data/README.md +402 -13
- data/SECURITY.md +7 -6
- data/app/controllers/api_keys/keys_controller.rb +73 -46
- data/app/views/api_keys/keys/_form.html.erb +56 -10
- data/app/views/api_keys/keys/_key_row.html.erb +4 -2
- data/app/views/api_keys/keys/_refusal_notice.html.erb +24 -0
- data/app/views/api_keys/keys/_show_token.html.erb +4 -7
- data/app/views/api_keys/keys/edit.html.erb +2 -0
- data/app/views/layouts/api_keys/application.html.erb +6 -0
- data/docs/reviews/2026-10-18-0-6-0/01-astra-review-of-0-6-0.md +36 -0
- data/docs/reviews/2026-10-18-0-6-0/02-astra-re-review-of-0-6-0.md +34 -0
- data/docs/reviews/2026-10-18-0-6-0/03-sol-re-check-of-0-6-0.md +41 -0
- data/lib/api_keys/authentication.rb +174 -7
- data/lib/api_keys/configuration.rb +82 -0
- data/lib/api_keys/device_flow.rb +146 -0
- data/lib/api_keys/engine.rb +9 -0
- data/lib/api_keys/errors.rb +11 -0
- data/lib/api_keys/form_builder_extensions.rb +3 -1
- data/lib/api_keys/helpers/token_session.rb +183 -23
- data/lib/api_keys/helpers/view_helpers.rb +34 -0
- data/lib/api_keys/idempotency.rb +155 -0
- data/lib/api_keys/jobs/update_stats_job.rb +45 -1
- data/lib/api_keys/models/api_key.rb +261 -3
- data/lib/api_keys/models/concerns/has_api_keys.rb +9 -1
- data/lib/api_keys/models/device_authorization.rb +333 -0
- data/lib/api_keys/models/idempotency_key.rb +184 -0
- data/lib/api_keys/version.rb +1 -1
- data/lib/api_keys.rb +13 -0
- data/lib/generators/api_keys/add_created_by_generator.rb +50 -0
- data/lib/generators/api_keys/add_device_authorizations_generator.rb +51 -0
- data/lib/generators/api_keys/add_idempotency_keys_generator.rb +52 -0
- data/lib/generators/api_keys/add_key_activity_generator.rb +59 -0
- data/lib/generators/api_keys/templates/add_created_by_to_api_keys.rb.erb +33 -0
- data/lib/generators/api_keys/templates/add_key_activity_to_api_keys.rb.erb +37 -0
- data/lib/generators/api_keys/templates/create_api_keys_device_authorizations.rb.erb +70 -0
- data/lib/generators/api_keys/templates/create_api_keys_idempotency_keys.rb.erb +52 -0
- data/lib/generators/api_keys/templates/create_api_keys_table.rb.erb +16 -0
- data/lib/generators/api_keys/templates/initializer.rb +35 -0
- metadata +17 -1
data/README.md
CHANGED
|
@@ -50,6 +50,50 @@ 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
|
+
|
|
62
|
+
To name the person behind each key (see [The person behind a key](#the-person-behind-a-key)), add the 0.7.0 `created_by` column. New installations get it from the start:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
rails generate api_keys:add_created_by
|
|
66
|
+
rails db:migrate
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The device flow and Idempotency-Key each need a table of their own. New and existing installations run these only if they use the feature:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
rails generate api_keys:add_device_authorizations # for ApiKeys::DeviceFlow
|
|
73
|
+
rails generate api_keys:add_idempotency_keys # for ApiKeys::Idempotency
|
|
74
|
+
rails db:migrate
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
#### Upgrading to 0.7.0 from 0.4.x or 0.6.x
|
|
78
|
+
|
|
79
|
+
Change the Gemfile line to `gem "api_keys", "~> 0.7.0"` and run `bundle update api_keys`. No migration is required: every migration since 0.5.0 adds an optional column or table, and the gem works without it. Run the ones for the features you want, in any order.
|
|
80
|
+
|
|
81
|
+
| From | Optional migrations | What they turn on |
|
|
82
|
+
|------|---------------------|-------------------|
|
|
83
|
+
| 0.4.x | `add_restrictions` | Origin and IP locks (0.5.0) |
|
|
84
|
+
| 0.4.x | `add_key_activity` | Refused-request stats and the durable one-time token page (0.6.0) |
|
|
85
|
+
| 0.4.x, 0.6.x | `add_created_by` | The person behind a key (0.7.0) |
|
|
86
|
+
| 0.4.x, 0.6.x | `add_device_authorizations` | The device flow (0.7.0) |
|
|
87
|
+
| 0.4.x, 0.6.x | `add_idempotency_keys` | Idempotency-Key for writes (0.7.0) |
|
|
88
|
+
|
|
89
|
+
On 0.6.x, nothing changes until you opt in to a 0.7.0 feature. A 0.6.0 job worker ignores the new `untrusted_key` refusal code with a warning, so a rolling deploy is safe.
|
|
90
|
+
|
|
91
|
+
On 0.4.x, the calls of a custom key controller keep working unchanged: `create_api_key!(name:, key_type:, expires_at:)`, `ApiKeys::TokenSession.store(session, @api_key)` and `ApiKeys::TokenSession.retrieve_once(session)`. A handoff stored by 0.4.x stays readable after the deploy. Read the [CHANGELOG](CHANGELOG.md) for 0.5.0 to 0.6.0 before you upgrade. These changes are the ones a 0.4.x app sees:
|
|
92
|
+
|
|
93
|
+
- `api_key_scopes_checkboxes` renders an empty `api_key[scopes][]` field before the boxes (0.5.1). A form with every box cleared now saves no scopes, where 0.4.x kept the old ones. If an edit form offers only some scopes, save `ApiKey#scopes_after_form_edit` (see [Form Builder Extensions](#form-builder-extensions-opt-in)).
|
|
94
|
+
- A token's one-time handoff is one-time across copies of the session (0.5.1), and once ever per key after `add_key_activity` (0.6.0). A retrieval inside an open database transaction is refused (0.6.0): exclude the token page from a host `around_action` that wraps requests in a transaction.
|
|
95
|
+
- Deploy job workers with the web processes. A refusal job from 0.6.0 or later errors on a 0.4.x worker.
|
|
96
|
+
|
|
53
97
|
## Quick Start
|
|
54
98
|
|
|
55
99
|
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 +203,12 @@ config.authenticate_owner_method = :ensure_team_access!
|
|
|
159
203
|
Once configured, your users can:
|
|
160
204
|
- self-issue new API keys
|
|
161
205
|
- set expiration dates
|
|
162
|
-
- attach scopes / permissions to individual keys (the edit form changes only the scopes it shows, the owner's default scopes,
|
|
206
|
+
- 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
207
|
- add and edit the key names
|
|
164
208
|
- lock a key to specific web origins or IP addresses
|
|
165
209
|
- revoke instantly
|
|
166
210
|
- see the status of all their keys
|
|
211
|
+
- see when a key's requests are arriving but being refused, and why
|
|
167
212
|
|
|
168
213
|
It provides an UI with everything you'd expect from an API keys dashboard, working right out of the box:
|
|
169
214
|
|
|
@@ -398,7 +443,8 @@ current_org.can_create_api_key?(key_type: :publishable)
|
|
|
398
443
|
environment: :live, # Defaults to current_environment
|
|
399
444
|
metadata: { team: "backend" }, # Optional JSON metadata
|
|
400
445
|
allowed_origins: "example.com", # Optional: lock to web origins
|
|
401
|
-
allowed_ips: "10.0.0.0/8"
|
|
446
|
+
allowed_ips: "10.0.0.0/8", # Optional: lock to IP addresses
|
|
447
|
+
created_by: current_user # Optional: the person behind the key
|
|
402
448
|
)
|
|
403
449
|
```
|
|
404
450
|
|
|
@@ -433,6 +479,7 @@ Methods available on `ApiKeys::ApiKey` instances:
|
|
|
433
479
|
# Scopes
|
|
434
480
|
@api_key.scopes # => ["read", "write"]
|
|
435
481
|
@api_key.allows_scope?("read") # => true
|
|
482
|
+
@api_key.scopes_follow_ceiling? # => true for `scopes: :ceiling` key types
|
|
436
483
|
|
|
437
484
|
# Request restrictions (where the key may be used from)
|
|
438
485
|
@api_key.allowed_origins # => ["example.com", "*.example.com"]
|
|
@@ -440,12 +487,20 @@ Methods available on `ApiKeys::ApiKey` instances:
|
|
|
440
487
|
@api_key.restricted? # => true if either list has entries
|
|
441
488
|
@api_key.restrictions # => ApiKeys::Restrictions value object
|
|
442
489
|
|
|
490
|
+
# The person behind the key (needs the created_by column)
|
|
491
|
+
@api_key.created_by # => #<User id: 1, ...>, or nil
|
|
492
|
+
|
|
443
493
|
# Metadata
|
|
444
494
|
@api_key.name # => "Production Server"
|
|
445
495
|
@api_key.created_at
|
|
446
496
|
@api_key.expires_at
|
|
447
497
|
@api_key.last_used_at
|
|
448
498
|
@api_key.requests_count # If tracking enabled
|
|
499
|
+
|
|
500
|
+
# Refused requests (the key's real token, refused by a policy)
|
|
501
|
+
@api_key.last_refused_at # => 2026-10-18 12:00:00 UTC, or nil
|
|
502
|
+
@api_key.last_refusal_code # => "origin_not_allowed", or nil
|
|
503
|
+
@api_key.refusals_count # If tracking enabled
|
|
449
504
|
```
|
|
450
505
|
|
|
451
506
|
---
|
|
@@ -470,21 +525,40 @@ ApiKeys::TokenSession.store(session, @api_key, key: :my_custom_key)
|
|
|
470
525
|
)
|
|
471
526
|
```
|
|
472
527
|
|
|
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.
|
|
528
|
+
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.
|
|
529
|
+
|
|
530
|
+
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`:
|
|
531
|
+
|
|
532
|
+
```ruby
|
|
533
|
+
retrieval = ApiKeys::TokenSession.retrieve_once_with_status(session, api_key: @api_key)
|
|
534
|
+
if retrieval.retrieved?
|
|
535
|
+
@token = retrieval.token
|
|
536
|
+
elsif retrieval.already_shown? || @api_key.token_shown?
|
|
537
|
+
redirect_to keys_path, alert: "This key's token was already shown."
|
|
538
|
+
else
|
|
539
|
+
redirect_to keys_path, alert: "The token can only be shown right after creation."
|
|
540
|
+
end
|
|
541
|
+
```
|
|
474
542
|
|
|
475
543
|
#### What "one-time" guarantees
|
|
476
544
|
|
|
477
545
|
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
546
|
|
|
479
|
-
So
|
|
547
|
+
So from 0.6.0 the first retrieval also claims the key's `token_shown_at` column with one conditional statement:
|
|
548
|
+
|
|
549
|
+
```sql
|
|
550
|
+
UPDATE api_keys SET token_shown_at = <now> WHERE id = <key id> AND token_shown_at IS NULL
|
|
551
|
+
```
|
|
480
552
|
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
-
|
|
484
|
-
- **
|
|
485
|
-
- **
|
|
486
|
-
- **
|
|
487
|
-
- **
|
|
553
|
+
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).
|
|
554
|
+
|
|
555
|
+
- **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.
|
|
556
|
+
- **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.
|
|
557
|
+
- **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.
|
|
558
|
+
- **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")`).
|
|
559
|
+
- **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.
|
|
560
|
+
- **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.
|
|
561
|
+
- **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
562
|
|
|
489
563
|
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
564
|
|
|
@@ -543,7 +617,7 @@ Renders a select dropdown with all expiration presets:
|
|
|
543
617
|
|
|
544
618
|
#### `api_key_scopes_checkboxes`
|
|
545
619
|
|
|
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.
|
|
620
|
+
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
621
|
|
|
548
622
|
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
623
|
|
|
@@ -567,6 +641,16 @@ By default the field is rendered only when there is at least one box and the for
|
|
|
567
641
|
<%# checked options: :all, :none, or an array of specific scopes %>
|
|
568
642
|
```
|
|
569
643
|
|
|
644
|
+
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.
|
|
645
|
+
|
|
646
|
+
```ruby
|
|
647
|
+
def update
|
|
648
|
+
submitted = params.require(:api_key).permit(:name, scopes: [])
|
|
649
|
+
scopes = @api_key.scopes_after_form_edit(offered: @available_scopes, checked: submitted[:scopes])
|
|
650
|
+
@api_key.update(name: submitted[:name], scopes: scopes)
|
|
651
|
+
end
|
|
652
|
+
```
|
|
653
|
+
|
|
570
654
|
#### `api_key_token_data`
|
|
571
655
|
|
|
572
656
|
Returns structured data for building token display UIs:
|
|
@@ -637,6 +721,14 @@ end
|
|
|
637
721
|
<%# => "Test mode", "Live mode", or "Default" %>
|
|
638
722
|
```
|
|
639
723
|
|
|
724
|
+
#### Refusal Helper
|
|
725
|
+
|
|
726
|
+
```erb
|
|
727
|
+
<%# Why the key's latest recorded refusal happened (nil if none) %>
|
|
728
|
+
<%= api_key_refusal_reason(@key) %>
|
|
729
|
+
<%# => "the request came from a web origin the key does not allow" %>
|
|
730
|
+
```
|
|
731
|
+
|
|
640
732
|
---
|
|
641
733
|
|
|
642
734
|
### View Examples
|
|
@@ -834,7 +926,7 @@ You can check if an API key is allowed to do actions by checking its scopes:
|
|
|
834
926
|
# => true
|
|
835
927
|
```
|
|
836
928
|
|
|
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.
|
|
929
|
+
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
930
|
|
|
839
931
|
## Controllers: secure your API endpoints
|
|
840
932
|
|
|
@@ -899,6 +991,39 @@ def write_action
|
|
|
899
991
|
end
|
|
900
992
|
```
|
|
901
993
|
|
|
994
|
+
### Require read or write by HTTP method
|
|
995
|
+
|
|
996
|
+
Most APIs give reads one scope and writes another. Pass both by HTTP method, in one line:
|
|
997
|
+
|
|
998
|
+
```ruby
|
|
999
|
+
before_action -> { authenticate_api_key!(scope: { read: "read", write: "write" }) }
|
|
1000
|
+
```
|
|
1001
|
+
|
|
1002
|
+
GET and HEAD requests need the `read` scope. Every other method needs the `write` scope. Each side takes what `scope:` takes: a scope, a list of scopes, or `nil` for none. Both sides are required, so a forgotten one never leaves its requests unchecked.
|
|
1003
|
+
|
|
1004
|
+
An action that needs another scope names it. Put the rule in a method, and let a controller override it:
|
|
1005
|
+
|
|
1006
|
+
```ruby
|
|
1007
|
+
class Api::BaseController < ActionController::API
|
|
1008
|
+
include ApiKeys::Controller
|
|
1009
|
+
before_action -> { authenticate_api_key!(scope: required_scope) }
|
|
1010
|
+
|
|
1011
|
+
private
|
|
1012
|
+
|
|
1013
|
+
def required_scope
|
|
1014
|
+
{ read: "read", write: "write" }
|
|
1015
|
+
end
|
|
1016
|
+
end
|
|
1017
|
+
|
|
1018
|
+
class Api::InvoicesController < Api::BaseController
|
|
1019
|
+
private
|
|
1020
|
+
|
|
1021
|
+
def required_scope
|
|
1022
|
+
action_name == "pdf" ? "downloads" : super
|
|
1023
|
+
end
|
|
1024
|
+
end
|
|
1025
|
+
```
|
|
1026
|
+
|
|
902
1027
|
### Rate limit your API endpoints
|
|
903
1028
|
|
|
904
1029
|
Rails 8 introduced the native, built-in `rate_limit` to easily rate limit your endpoints, so `Rack::Attack` is no longer necessary! While this is not an `api_keys` feature per se, I thought it'd be nice to include an example here because it pairs so well with `api_keys`.
|
|
@@ -922,6 +1047,209 @@ This `rate_limit` feature depends on Rails 8+ and an active, well-configured cac
|
|
|
922
1047
|
If you're still on early versions of Rails, you can still use `api_keys`! No need to implement `rate_limit` – just an idea if you're already on Rails 8!
|
|
923
1048
|
|
|
924
1049
|
|
|
1050
|
+
## The person behind a key
|
|
1051
|
+
|
|
1052
|
+
When keys belong to an organization, a key keeps working after the person who created it has left. Name that person when you create the key:
|
|
1053
|
+
|
|
1054
|
+
```ruby
|
|
1055
|
+
current_organization.create_api_key!(name: "CI", scopes: %w[read], created_by: current_user)
|
|
1056
|
+
```
|
|
1057
|
+
|
|
1058
|
+
Then tell the gem when a key stops being trusted:
|
|
1059
|
+
|
|
1060
|
+
```ruby
|
|
1061
|
+
# config/initializers/api_keys.rb
|
|
1062
|
+
config.trusted_key_check = ->(api_key) { api_key.owner.managed_by?(api_key.created_by) }
|
|
1063
|
+
```
|
|
1064
|
+
|
|
1065
|
+
After authentication and the scope check, `authenticate_api_key!` calls the lambda with the key. When it answers `false` or `nil`, the request gets `403` with `error: "untrusted_key"`, the key is not set as `current_api_key`, and the refusal is recorded on the key like every other refusal (with the 0.6.0 columns), never as a use. An error in the lambda fails the request; it never passes as trusted. Without a lambda (the default), every valid key is trusted, as before.
|
|
1066
|
+
|
|
1067
|
+
- `created_by` is polymorphic and optional. It cannot change after the key is created (a validation, and `attr_readonly`), so no update can make another person stand behind a key.
|
|
1068
|
+
- The mounted dashboard leaves `created_by` nil: it knows the key's owner, not the person. An app that needs the person behind each key creates its keys in its own controller and passes `created_by:` there (see [Building Custom Integrations](#building-custom-integrations)).
|
|
1069
|
+
- Keys created before you set `created_by` have none. Decide what the lambda does with them, or backfill the columns in a migration with SQL (`update_all`): the columns are read-only for a saved key, so no save, `update_column` or form can change them.
|
|
1070
|
+
- The refusal renders through `render_unauthorized`, so if you override it to render your own error shape, `untrusted_key` uses that shape too. Change the message with I18n: `api_keys.errors.untrusted_key`.
|
|
1071
|
+
- New installs get the columns from the start. Existing installs run `rails generate api_keys:add_created_by`; until then, passing `created_by:` raises `ApiKeys::Errors::CreatedByMigrationRequiredError`.
|
|
1072
|
+
|
|
1073
|
+
## Idempotent writes (Idempotency-Key)
|
|
1074
|
+
|
|
1075
|
+
A client that retries a write after a timeout must not create a second record. With `ApiKeys::Idempotency`, every write carries an `Idempotency-Key` header and runs once per key ([draft-ietf-httpapi-idempotency-key-header](https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/)):
|
|
1076
|
+
|
|
1077
|
+
```bash
|
|
1078
|
+
rails generate api_keys:add_idempotency_keys
|
|
1079
|
+
rails db:migrate
|
|
1080
|
+
```
|
|
1081
|
+
|
|
1082
|
+
```ruby
|
|
1083
|
+
class Api::BaseController < ActionController::API
|
|
1084
|
+
include ApiKeys::Controller
|
|
1085
|
+
include ApiKeys::Idempotency
|
|
1086
|
+
|
|
1087
|
+
before_action :authenticate_api_key!
|
|
1088
|
+
around_action :run_once_per_idempotency_key # after authentication
|
|
1089
|
+
|
|
1090
|
+
private
|
|
1091
|
+
|
|
1092
|
+
# The JSON for a record. The first answer and every retry use it.
|
|
1093
|
+
def resource_json(record, **options)
|
|
1094
|
+
record.as_json(only: %i[id name]).merge(options)
|
|
1095
|
+
end
|
|
1096
|
+
|
|
1097
|
+
# Whether this API key may read the record now. A retry runs no action,
|
|
1098
|
+
# so this is the check before a record's current state goes back.
|
|
1099
|
+
def idempotent_replay_allowed?(record)
|
|
1100
|
+
record.owner == current_api_owner
|
|
1101
|
+
end
|
|
1102
|
+
end
|
|
1103
|
+
|
|
1104
|
+
class Api::WebhooksController < Api::BaseController
|
|
1105
|
+
def create
|
|
1106
|
+
webhook = current_api_owner.webhooks.create!(url: params.require(:url))
|
|
1107
|
+
render_resource webhook, status: :created, secret: webhook.secret # the secret is in this answer only
|
|
1108
|
+
end
|
|
1109
|
+
end
|
|
1110
|
+
```
|
|
1111
|
+
|
|
1112
|
+
How it works:
|
|
1113
|
+
|
|
1114
|
+
- GET and HEAD run as they are. Every other request needs the header: a text of 1 to 255 characters, the same when the client retries.
|
|
1115
|
+
- A write answers with `render_resource(record, status:, **options)`. The key keeps the status and the record, never the response body, so a secret shown once is never stored.
|
|
1116
|
+
- The write, any rows it adds (an audit entry), and the key's answer commit in one transaction, or nothing does. A write that answers anything but 2xx, or raises, commits nothing and leaves the key free, so the corrected request can use it. That transaction is on the database of the `api_keys` tables: a write to another database does not join it.
|
|
1117
|
+
- A retry with the same key and the same request answers the record **as it is now**, through `resource_json` without the first answer's options, with the header `Idempotent-Replayed: true`. Before that, `idempotent_replay_allowed?(record)` must answer true: a record that moved to another owner, or no longer exists, raises `ActiveRecord::RecordNotFound` (Rails answers 404). Both methods are required, and the first write raises `NotImplementedError` without them.
|
|
1118
|
+
- The request is the method, the path, the query and the body. The query and the body count apart, so a name that moves from one to the other makes another request. The order of names does not matter. An uploaded file counts by its bytes, its file name and its content type, and never encodes like an ordinary value.
|
|
1119
|
+
- The around_action runs outside every open transaction: the key's claim must commit on its own, so `ApiKeys::IdempotencyKey.hold` raises inside one. Transactional tests, which open a transaction around each test, set a test-only switch in `config/environments/test.rb` or the test helper: `ApiKeys.configuration.idempotency_in_test_transactions = true`. It takes `true` only when `Rails.env` is test, and counts only while it is test.
|
|
1120
|
+
- Keys are per API key and kept for a day. Run `ApiKeys::IdempotencyKey.prune!` once a day, for example from a recurring job.
|
|
1121
|
+
|
|
1122
|
+
The refusals follow the draft (section 2.7). They render through `render_unauthorized`, like the gem's other refusals, and their messages change with I18n (`api_keys.errors.<code>`):
|
|
1123
|
+
|
|
1124
|
+
| Status | `error` | When |
|
|
1125
|
+
|--------|---------|------|
|
|
1126
|
+
| 400 | `idempotency_key_required` | A write without the header, or with one longer than 255 characters |
|
|
1127
|
+
| 409 | `idempotency_key_in_use` | A request with this key is still running |
|
|
1128
|
+
| 422 | `idempotency_key_reused` | The key was used for another request |
|
|
1129
|
+
|
|
1130
|
+
A retry waits at most two seconds for the request that holds the key, then answers 409, and two first requests with the same key wait for each other's claim at most as long. On PostgreSQL (`lock_timeout`) and MySQL (`innodb_lock_wait_timeout`) the gem sets that timeout for the claim and the lock, then puts the old value back. SQLite has no row locks: a retry waits for the database's write lock, as long as the database's busy timeout. CI checks this on PostgreSQL, MySQL and SQLite with real concurrent connections (`test/databases/check.rb`). Every successful write must answer with `render_resource`; one that does not raises and commits nothing.
|
|
1131
|
+
|
|
1132
|
+
The model works outside a controller too, for example in a tool that shares the API's keys:
|
|
1133
|
+
|
|
1134
|
+
```ruby
|
|
1135
|
+
digest = ApiKeys::IdempotencyKey.digest_of("TOOL", "create_webhook", arguments)
|
|
1136
|
+
ApiKeys::IdempotencyKey.hold(api_key, key) do |record|
|
|
1137
|
+
next replay(record) if record.answered? # compare record.request_digest with digest first
|
|
1138
|
+
|
|
1139
|
+
record.update!(request_digest: digest)
|
|
1140
|
+
webhook = create_webhook(arguments)
|
|
1141
|
+
record.answer!(200, webhook)
|
|
1142
|
+
end
|
|
1143
|
+
# raises ApiKeys::IdempotencyKey::Busy while another call holds the key
|
|
1144
|
+
```
|
|
1145
|
+
|
|
1146
|
+
## Let an agent ask for a key: the device flow (RFC 8628)
|
|
1147
|
+
|
|
1148
|
+
An agent or a CLI should not ask a person to copy a key into it. With the OAuth 2.0 Device Authorization Grant ([RFC 8628](https://www.rfc-editor.org/rfc/rfc8628)), the agent asks for a key, the person approves in your app, and the agent receives an ordinary API key:
|
|
1149
|
+
|
|
1150
|
+
1. The agent asks for codes and shows the person one link and a user code like `BCDF-GHJK`.
|
|
1151
|
+
2. The person opens the link in your app, signs in, compares the code, sees the agent's name, its device and the scopes, and approves or denies.
|
|
1152
|
+
3. The agent polls. The first poll after the approval creates the key for the owner the person chose, named "client on device", and answers with its token, once.
|
|
1153
|
+
|
|
1154
|
+
```bash
|
|
1155
|
+
rails generate api_keys:add_device_authorizations
|
|
1156
|
+
rails db:migrate
|
|
1157
|
+
```
|
|
1158
|
+
|
|
1159
|
+
**The agent's two endpoints.** Include `ApiKeys::DeviceFlow` in an API controller, name your approval page, and route the two actions:
|
|
1160
|
+
|
|
1161
|
+
```ruby
|
|
1162
|
+
class Api::DeviceAuthorizationsController < ActionController::API
|
|
1163
|
+
include ApiKeys::DeviceFlow
|
|
1164
|
+
|
|
1165
|
+
private
|
|
1166
|
+
|
|
1167
|
+
# The approval page, with the user code filled in when one is given
|
|
1168
|
+
def device_verification_uri(user_code = nil)
|
|
1169
|
+
device_approval_url(user_code: user_code)
|
|
1170
|
+
end
|
|
1171
|
+
end
|
|
1172
|
+
|
|
1173
|
+
# config/routes.rb
|
|
1174
|
+
post "api/device", to: "api/device_authorizations#create"
|
|
1175
|
+
post "api/token", to: "api/device_authorizations#token"
|
|
1176
|
+
```
|
|
1177
|
+
|
|
1178
|
+
The agent sends `client_id` (its name), `device_name` (optional) and `scope` (space-separated), and gets the [RFC 8628 §3.2](https://www.rfc-editor.org/rfc/rfc8628#section-3.2) answer:
|
|
1179
|
+
|
|
1180
|
+
```json
|
|
1181
|
+
{ "device_code": "…", "user_code": "BCDF-GHJK",
|
|
1182
|
+
"verification_uri": "https://example.com/device",
|
|
1183
|
+
"verification_uri_complete": "https://example.com/device?user_code=BCDF-GHJK",
|
|
1184
|
+
"expires_in": 600, "interval": 5 }
|
|
1185
|
+
```
|
|
1186
|
+
|
|
1187
|
+
It polls `token` with `grant_type=urn:ietf:params:oauth:grant-type:device_code`, the `device_code` and the same `client_id`. Until the person decides, it gets the [§3.5](https://www.rfc-editor.org/rfc/rfc8628#section-3.5) errors as `{ "error": …, "error_description": … }` with status 400: `authorization_pending`, `slow_down` (a poll sooner than the interval, which then grows by 5 seconds), `access_denied`, `expired_token`, and `invalid_grant` for an unknown or used device code or another `client_id`. After the approval it gets the key once:
|
|
1188
|
+
|
|
1189
|
+
```json
|
|
1190
|
+
{ "access_token": "ak_…", "token_type": "Bearer", "scope": "read write" }
|
|
1191
|
+
```
|
|
1192
|
+
|
|
1193
|
+
No answer may be cached: every answer has `Cache-Control: no-store`. An agent may ask for any scope in `config.default_scopes`. A request without `scope` fails with `invalid_scope`, unless the controller defines `device_default_scope` (for example `"read"`).
|
|
1194
|
+
|
|
1195
|
+
The engine adds `device_code`, `user_code` and `token` to `config.filter_parameters`, so request logs never show the codes or a token. If your approval page takes the user code under another parameter name, add that name too.
|
|
1196
|
+
|
|
1197
|
+
**Key types.** Every request names the type of the key it asks for: `device_key_type` in the controller, `config.default_key_type` by default. With key types configured and no type named, the request raises, because that is the app's error, not the client's. The scopes an agent may ask for are then the ones of `config.default_scopes` that the type's `permissions` allow. A type with `scopes: :ceiling` gives every key its whole ceiling, so a request must ask for all of it. The type is stored with the request and is the type of the key: an untyped request stays untyped, and a new default type never applies to it.
|
|
1198
|
+
|
|
1199
|
+
If the configuration changes after the approval so that the key can no longer be made as the person approved it, the poll answers `invalid_scope` (RFC 6749 §5.2) and closes the request (state `void`): key types turned on for an untyped request, the request's type removed or renamed, or a ceiling that grew. Any other failure to make the key closes it too. An owner at its key limit is the one case that waits: the poll answers `authorization_pending`.
|
|
1200
|
+
|
|
1201
|
+
**The person's page** stays in your app, behind your sign-in. Find the request by the code the person typed or followed, show what the agent asks for, and decide:
|
|
1202
|
+
|
|
1203
|
+
```ruby
|
|
1204
|
+
class DeviceApprovalsController < ApplicationController
|
|
1205
|
+
before_action :authenticate_user!
|
|
1206
|
+
# Look-ups and approvals share one budget, so a guess sent straight to
|
|
1207
|
+
# update costs the same as one typed on the page (RFC 8628 §5.1).
|
|
1208
|
+
rate_limit to: 10, within: 10.minutes, by: -> { current_user.id }, only: %i[show update]
|
|
1209
|
+
|
|
1210
|
+
def show
|
|
1211
|
+
@authorization = ApiKeys::DeviceAuthorization.find_undecided_by_user_code(params[:user_code]) if params[:user_code].present?
|
|
1212
|
+
# Show @authorization.client_id, .device_name, .scopes, .requested_from and the code,
|
|
1213
|
+
# and ask the person to compare the code with the one the agent shows (RFC 8628 §5.4).
|
|
1214
|
+
end
|
|
1215
|
+
|
|
1216
|
+
def update
|
|
1217
|
+
authorization = ApiKeys::DeviceAuthorization.find_undecided_by_user_code(params[:user_code])
|
|
1218
|
+
return redirect_to(device_approval_path) unless authorization
|
|
1219
|
+
|
|
1220
|
+
if params[:decision] == "approve"
|
|
1221
|
+
authorization.approve!(current_organization, by: current_user)
|
|
1222
|
+
else
|
|
1223
|
+
authorization.deny!(by: current_user)
|
|
1224
|
+
end
|
|
1225
|
+
redirect_to device_approval_path, notice: "Done. Go back to the agent."
|
|
1226
|
+
rescue ApiKeys::DeviceAuthorization::Closed
|
|
1227
|
+
redirect_to device_approval_path, alert: "This request expired or was decided already."
|
|
1228
|
+
rescue ApiKeys::DeviceAuthorization::KeyLimitReached
|
|
1229
|
+
redirect_to device_approval_path(user_code: params[:user_code]), alert: "You have the most API keys you may have. Revoke one, then approve again."
|
|
1230
|
+
end
|
|
1231
|
+
end
|
|
1232
|
+
```
|
|
1233
|
+
|
|
1234
|
+
The rules the model keeps:
|
|
1235
|
+
|
|
1236
|
+
- Both codes are stored as SHA-256 digests. The key's token is never stored: the poll that creates the key is the only answer that carries it.
|
|
1237
|
+
- A request lives ten minutes. The user code is eight letters from twenty consonants ([§6.1](https://www.rfc-editor.org/rfc/rfc8628#section-6.1)), and case, spaces and dashes do not matter when the person types it.
|
|
1238
|
+
- Polls and decisions take the request's row lock. A decided or expired request cannot be decided again: `approve!` and `deny!` raise `ApiKeys::DeviceAuthorization::Closed`.
|
|
1239
|
+
- `approve!` checks the owner's key limit first (`max_keys`, and the `limit` of the request's key type). At the limit it raises `ApiKeys::DeviceAuthorization::KeyLimitReached` and the request stays pending, so the person can revoke a key and approve again before it expires. It also checks the scopes against the configuration as it is now, and raises `ActiveRecord::RecordInvalid` for scopes that a key of the type no longer grants.
|
|
1240
|
+
- A poll never raises because the key cannot be made. If the owner reached its limit after the approval, the poll answers `authorization_pending`, nothing is kept, and a later poll makes the key once there is room. Every other failure closes the request with `invalid_scope`.
|
|
1241
|
+
- A poll with another `client_id` changes nothing ([§3.4](https://www.rfc-editor.org/rfc/rfc8628#section-3.4)), so the grant waits for its own client.
|
|
1242
|
+
- The approving person becomes the key's `created_by` when the column exists, so `config.trusted_key_check` applies to these keys too. Characters a key name cannot hold become hyphens in the key's name.
|
|
1243
|
+
|
|
1244
|
+
What stays in your app:
|
|
1245
|
+
|
|
1246
|
+
- **Rate limits.** Limit the code look-ups and the approvals on the approval page together, per person, so codes cannot be guessed ([§5.1](https://www.rfc-editor.org/rfc/rfc8628#section-5.1)), and the agent's requests per address. A rate limit's `with:` can answer in the RFC's shape: `with: -> { render_oauth_error("slow_down", "Too many requests.", status: :too_many_requests) }`.
|
|
1247
|
+
- **Who may approve,** and for which owner. When a person can switch between organizations, put the owner the page showed in the form and refuse an approval for another one.
|
|
1248
|
+
- **More fields in the token answer,** or an audit row: override `device_token_response(authorization, api_key)` and call `super`. It runs in the transaction that creates the key, so a row it writes to the same database commits with the key, or neither does.
|
|
1249
|
+
- **Housekeeping:** run `ApiKeys::DeviceAuthorization.prune!` once a day.
|
|
1250
|
+
|
|
1251
|
+
Error descriptions change with I18n: `api_keys.device_flow.errors.<error>`.
|
|
1252
|
+
|
|
925
1253
|
## Configuration and settings
|
|
926
1254
|
|
|
927
1255
|
The gem installation creates an initializer at `config/initializers/api_keys.rb`
|
|
@@ -1106,6 +1434,27 @@ config.stats_update_interval = 0 # record every successful request
|
|
|
1106
1434
|
|
|
1107
1435
|
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
1436
|
|
|
1437
|
+
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.
|
|
1438
|
+
|
|
1439
|
+
### Refused requests: why a key is not working
|
|
1440
|
+
|
|
1441
|
+
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.
|
|
1442
|
+
|
|
1443
|
+
The gem therefore records them separately, through the same background stats job as `last_used_at`:
|
|
1444
|
+
|
|
1445
|
+
| Column | Meaning |
|
|
1446
|
+
|---|---|
|
|
1447
|
+
| `last_refused_at` | When the most recently recorded refusal happened |
|
|
1448
|
+
| `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` |
|
|
1449
|
+
| `refusals_count` | How many refusals. Like `requests_count`, it is only kept with `track_requests_count = true` |
|
|
1450
|
+
|
|
1451
|
+
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".
|
|
1452
|
+
|
|
1453
|
+
- 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.
|
|
1454
|
+
- 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.
|
|
1455
|
+
- `enable_async_operations = false` turns them off with every other job.
|
|
1456
|
+
- 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.
|
|
1457
|
+
|
|
1109
1458
|
## Key Types: Stripe-style Publishable & Secret Keys
|
|
1110
1459
|
|
|
1111
1460
|
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 +1480,7 @@ ApiKeys.configure do |config|
|
|
|
1131
1480
|
publishable: {
|
|
1132
1481
|
prefix: "pk", # Token prefix → pk_test_, pk_live_
|
|
1133
1482
|
permissions: %w[read validate], # Scope ceiling (max permissions allowed)
|
|
1483
|
+
# scopes: :ceiling, # Optional: see "Widening a ceiling" below
|
|
1134
1484
|
public: true, # Store token so it remains viewable
|
|
1135
1485
|
limit: 1, # Max 1 per owner per environment
|
|
1136
1486
|
restrictions: [:origins] # May be locked to domains, not to IPs
|
|
@@ -1200,6 +1550,45 @@ sk = user.create_api_key!(
|
|
|
1200
1550
|
sk.scopes # => ["read", "validate", "issue_license", "admin"]
|
|
1201
1551
|
```
|
|
1202
1552
|
|
|
1553
|
+
### Widening a ceiling does not reach existing keys
|
|
1554
|
+
|
|
1555
|
+
> [!WARNING]
|
|
1556
|
+
> 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.)
|
|
1557
|
+
|
|
1558
|
+
There are two ways out.
|
|
1559
|
+
|
|
1560
|
+
**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:
|
|
1561
|
+
|
|
1562
|
+
```ruby
|
|
1563
|
+
config.key_types = {
|
|
1564
|
+
publishable: {
|
|
1565
|
+
prefix: "pk",
|
|
1566
|
+
permissions: %w[read validate heartbeat],
|
|
1567
|
+
scopes: :ceiling, # every pk key has exactly these permissions, now and after you edit them
|
|
1568
|
+
public: true,
|
|
1569
|
+
limit: 1
|
|
1570
|
+
},
|
|
1571
|
+
secret: { prefix: "sk", permissions: :all } # stored scopes, as before
|
|
1572
|
+
}
|
|
1573
|
+
```
|
|
1574
|
+
|
|
1575
|
+
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:
|
|
1576
|
+
|
|
1577
|
+
- Widening `permissions:` reaches every existing key on its next request, and narrowing it takes effect just as fast.
|
|
1578
|
+
- 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.
|
|
1579
|
+
- 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.
|
|
1580
|
+
- `scopes: :ceiling` requires a finite, non-empty `permissions` list. `permissions: :all` raises when the configuration loads.
|
|
1581
|
+
- Removing `scopes: :ceiling` later can only narrow: keys go back to their stored scopes, still capped by the ceiling.
|
|
1582
|
+
- Every other key type, untyped legacy keys, and keys whose type is no longer configured behave exactly as before.
|
|
1583
|
+
|
|
1584
|
+
**Or backfill the stored scopes** right after widening a type that keeps stored scopes, for example in the same deploy:
|
|
1585
|
+
|
|
1586
|
+
```ruby
|
|
1587
|
+
ApiKeys.reconcile_scopes!(key_type: :publishable) # => number of keys that gained scopes
|
|
1588
|
+
```
|
|
1589
|
+
|
|
1590
|
+
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.
|
|
1591
|
+
|
|
1203
1592
|
### Non-Revocable Keys
|
|
1204
1593
|
|
|
1205
1594
|
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
|
|
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
|
-
-
|
|
49
|
-
-
|
|
50
|
-
-
|
|
51
|
-
-
|
|
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.
|