api_keys 0.5.0 → 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 +4 -4
- data/CHANGELOG.md +54 -0
- data/README.md +158 -9
- data/SECURITY.md +22 -2
- data/app/controllers/api_keys/keys_controller.rb +136 -14
- data/app/views/api_keys/keys/_form.html.erb +85 -17
- 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/_restriction_fields.html.erb +4 -2
- 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/api_keys/security/best_practices.html.erb +1 -1
- data/app/views/layouts/api_keys/application.html.erb +6 -0
- data/docs/reviews/2026-10-18-0-5-1/01-astra-review-of-0-5-0.md +55 -0
- data/docs/reviews/2026-10-18-0-5-1/02-astra-re-review-of-0-5-1.md +36 -0
- data/docs/reviews/2026-10-18-0-5-1/03-opus-review-of-0-5-1.md +5 -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 +123 -7
- data/lib/api_keys/configuration.rb +37 -6
- data/lib/api_keys/form_builder_extensions.rb +42 -3
- data/lib/api_keys/helpers/token_session.rb +257 -8
- data/lib/api_keys/helpers/view_helpers.rb +33 -0
- data/lib/api_keys/jobs/update_stats_job.rb +45 -1
- data/lib/api_keys/models/api_key.rb +293 -7
- data/lib/api_keys/models/concerns/has_api_keys.rb +10 -2
- data/lib/api_keys/restrictions.rb +75 -24
- data/lib/api_keys/services/authenticator.rb +24 -2
- data/lib/api_keys/version.rb +1 -1
- data/lib/api_keys.rb +7 -0
- data/lib/generators/api_keys/add_key_activity_generator.rb +59 -0
- data/lib/generators/api_keys/add_restrictions_generator.rb +4 -1
- 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_table.rb.erb +12 -0
- data/lib/generators/api_keys/templates/initializer.rb +32 -7
- metadata +10 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8b2979856fd86b34f632b3d738195286ae07a829ac3a74dda56c5d6bf9e8c4f9
|
|
4
|
+
data.tar.gz: ee8651a2eaba436ec08bbf6df184d080685fbcbbe3751c4b0dc8980c4a4409eb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0b481a5b23ede9c768fe98479d65f568efca51d339228c3541feedbbe4ba26d5df896d537207687237a809504fc3dc655ab31856ad3ac2cc16a5559e08b8c1dc
|
|
7
|
+
data.tar.gz: 0a154501522dfcc6e83fc8a6d89b75ed2d94d8c5fcdde5d942ea0e0da0f5f2948f1db117b1332414c4844e7c5bcf544a471609a18cc52584d4c7729f2e7a670a
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,57 @@
|
|
|
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
|
+
|
|
38
|
+
## [0.5.1] - 2026-10-18
|
|
39
|
+
|
|
40
|
+
### Security
|
|
41
|
+
|
|
42
|
+
- Clearing every permission in the dashboard now removes the key's scopes. With every box unchecked the form submitted no `scopes` parameter, so an update kept the old scopes and a create applied the default scopes. Both checkbox groups now render an empty `api_key[scopes][]` sentinel: an empty selection means "no scopes" and the model's validations decide, while defaults still apply when the parameter is absent. The edit form changes only the scopes it offers (the owner's default scopes) and keeps every other scope a key holds, so a rename no longer strips a scope the form never showed. A failed create re-renders the boxes as submitted instead of re-checking all of them.
|
|
43
|
+
- `FormBuilderExtensions#api_key_scopes_checkboxes` renders the same sentinel before the boxes when a block is given. Host forms will see one extra hidden input. By default it is rendered only when there is at least one box and the form's key enforces its scopes (new `ApiKey#scopes_enforced?`), because where scopes are not enforced an empty list still means "unrestricted". Per-owner default scopes are read through the key's owner, so a form with no object or on a bare `ApiKey.new` renders no sentinel unless you pass `include_hidden: true`; build the key with `owner.api_keys.build`. `include_hidden: false` turns it off.
|
|
44
|
+
- `ApiKey#revoke!` no longer depends on unrelated validations. A key issued before its type's permission or restriction ceiling was narrowed kept authenticating but could not be revoked. Revocation keeps the revocability guard and the save callbacks, writes only `revoked_at` (and `updated_at`), and still validates any other unsaved edits on the same instance. The next request is refused as `revoked_key`, including through the token cache.
|
|
45
|
+
- Malformed restriction values fail closed. Coercion dropped `false`, `nil`, and blank entries, so stored `{"ips": false}`, `{"ips": [false]}`, or `{"ips": [null]}` read as unrestricted and `update!(allowed_ips: false)` stored `{}`; the same applied to origins. Such values are now kept, rejected by validation, and refuse authentication with `restriction_misconfigured` when found in the database; a stored `null` or blank kind is refused too. Input keeps its 0.5.0 meaning where it was unambiguous: a kind given as `nil` or blank text to `restrictions=`, `create_api_key!(restrictions:)`, or the `allowed_origins=` / `allowed_ips=` writers clears that list, so `create_api_key!(restrictions: { ips: nil })` still creates an unrestricted key. `false`, wrong types, and `null` or blank members inside a list are rejected on input. A malformed `restrictions:` argument to `create_api_key!` now reaches validation instead of being normalized to `{}`. The dashboard shows a stored policy's malformed entries instead of empty fields and leaves an untouched field's list as stored, so a rename can no longer turn a malformed policy into an unrestricted one; the save is refused until the owner corrects it.
|
|
46
|
+
- The authentication header is checked for encoding and size before it is parsed. An invalid UTF-8 header raised `ArgumentError` from the Bearer match; it now answers `invalid_token`, and a header longer than the token limit plus 64 bytes is refused without being scanned.
|
|
47
|
+
- The one-time token handoff is now one-time across copies of the session. Retrieval deleted the payload only from the session it was given, so a copied pre-retrieval cookie, or two concurrent requests, could recover the token twice within its ten-minute lifetime. Each handoff now carries a random ID, and the first retrieval records it with an atomic `Rails.cache.write(..., unless_exist: true)`; later retrievals get `nil` while the cache keeps that record, and a cache error withholds the token. The guarantee needs both a shared store with atomic `unless_exist:` and retention of the record for the ten-minute handoff lifetime: if the cache is flushed, restarted, or evicts the record within that window, a copied pre-retrieval cookie recovers the token again. Durable consumption state is planned for 0.6.0. With `ActiveSupport::Cache::NullStore` or no cache, the session deletion remains the only protection. With Solid Cache on PostgreSQL, two simultaneous first retrievals can both succeed; sequential replays are stopped. The handoff format is unchanged, so 0.5.0 and 0.5.1 processes can read each other's handoffs during a rolling deploy.
|
|
48
|
+
- IP restrictions: the README, SECURITY.md, the generated initializer, and the `add_restrictions` generator now state that the default `request.remote_ip` resolver trusts `X-Forwarded-For`. Behind a proxy that is the only way in, keep it; when the Rails server is directly reachable, use `->(request) { request.remote_addr }`. `trusted_proxies` alone is not a boundary. The suite covers both shapes through Rails' `ActionDispatch::RemoteIp`. The default is unchanged.
|
|
49
|
+
|
|
50
|
+
### Fixed
|
|
51
|
+
|
|
52
|
+
- The dashboard's security page no longer says publishable keys cannot be revoked individually; since 0.5.0 they can, unless their key type sets `revocable: false`.
|
|
53
|
+
- The README's prefix table no longer says a key can be created without a type when key types are configured. Such a key takes `default_key_type`, or creation raises.
|
|
54
|
+
|
|
1
55
|
## [0.5.0] - 2026-08-25
|
|
2
56
|
|
|
3
57
|
### Added
|
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
|
|
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
|
|
|
@@ -392,7 +402,7 @@ current_org.can_create_api_key?(key_type: :publishable)
|
|
|
392
402
|
@api_key = current_org.create_api_key!(
|
|
393
403
|
name: "My Key",
|
|
394
404
|
key_type: :secret, # or :publishable
|
|
395
|
-
scopes: ["read", "write"], # Blank values auto-removed
|
|
405
|
+
scopes: ["read", "write"], # Blank values auto-removed; [] means none, omit for defaults
|
|
396
406
|
expires_at: 30.days.from_now, # Explicit date
|
|
397
407
|
expires_at_preset: "30_days", # OR use preset (takes precedence)
|
|
398
408
|
environment: :live, # Defaults to current_environment
|
|
@@ -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,7 +486,42 @@ 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.
|
|
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
|
+
```
|
|
503
|
+
|
|
504
|
+
#### What "one-time" guarantees
|
|
505
|
+
|
|
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.
|
|
507
|
+
|
|
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
|
+
```
|
|
513
|
+
|
|
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.
|
|
523
|
+
|
|
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.
|
|
474
525
|
|
|
475
526
|
---
|
|
476
527
|
|
|
@@ -527,7 +578,9 @@ Renders a select dropdown with all expiration presets:
|
|
|
527
578
|
|
|
528
579
|
#### `api_key_scopes_checkboxes`
|
|
529
580
|
|
|
530
|
-
Renders scope checkboxes with a block for custom markup:
|
|
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).
|
|
582
|
+
|
|
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.
|
|
531
584
|
|
|
532
585
|
```erb
|
|
533
586
|
<%# With block - you control the HTML, gem handles the logic %>
|
|
@@ -549,6 +602,16 @@ Renders scope checkboxes with a block for custom markup:
|
|
|
549
602
|
<%# checked options: :all, :none, or an array of specific scopes %>
|
|
550
603
|
```
|
|
551
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
|
+
|
|
552
615
|
#### `api_key_token_data`
|
|
553
616
|
|
|
554
617
|
Returns structured data for building token display UIs:
|
|
@@ -619,6 +682,14 @@ end
|
|
|
619
682
|
<%# => "Test mode", "Live mode", or "Default" %>
|
|
620
683
|
```
|
|
621
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
|
+
|
|
622
693
|
---
|
|
623
694
|
|
|
624
695
|
### View Examples
|
|
@@ -816,6 +887,8 @@ You can check if an API key is allowed to do actions by checking its scopes:
|
|
|
816
887
|
# => true
|
|
817
888
|
```
|
|
818
889
|
|
|
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.
|
|
891
|
+
|
|
819
892
|
## Controllers: secure your API endpoints
|
|
820
893
|
|
|
821
894
|
To add the `api_keys` functionality to your controllers, just use the `ApiKeys::Controller` concern and you'll have all controller methods available:
|
|
@@ -939,11 +1012,12 @@ When using key types, here's how the prefix is determined:
|
|
|
939
1012
|
| `key_types` Config | `key_type` Param | Resulting Prefix |
|
|
940
1013
|
|-------------------|------------------|------------------|
|
|
941
1014
|
| `{}` (disabled) | any | Uses `token_prefix` → `"ak_..."` |
|
|
942
|
-
| Configured | `nil` | Uses
|
|
1015
|
+
| Configured | `nil`, with `default_key_type` set | Uses the default type's config → e.g. `"sk_live_..."` |
|
|
1016
|
+
| Configured | `nil`, no `default_key_type` | Not created: `create_api_key!` raises `ArgumentError` ("key_type is required when key types are configured") |
|
|
943
1017
|
| Configured | `:publishable` | Uses type config → `"pk_test_..."` |
|
|
944
1018
|
| Configured | `:secret` | Uses type config → `"sk_live_..."` |
|
|
945
1019
|
|
|
946
|
-
In short: `token_prefix` is only used when key types are not configured
|
|
1020
|
+
In short: `token_prefix` is only used when key types are not configured. Once you configure `key_types`, every new key needs a type, either passed as `key_type` or taken from `config.default_key_type`, and its prefix comes entirely from that type's configuration. Untyped keys issued before you configured key types keep the prefix they were issued with and keep authenticating.
|
|
947
1021
|
|
|
948
1022
|
See the [Key Types](#key-types-stripe-style-publishable--secret-keys) section below for full details.
|
|
949
1023
|
|
|
@@ -1085,6 +1159,27 @@ config.stats_update_interval = 0 # record every successful request
|
|
|
1085
1159
|
|
|
1086
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.
|
|
1087
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
|
+
|
|
1088
1183
|
## Key Types: Stripe-style Publishable & Secret Keys
|
|
1089
1184
|
|
|
1090
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.
|
|
@@ -1110,6 +1205,7 @@ ApiKeys.configure do |config|
|
|
|
1110
1205
|
publishable: {
|
|
1111
1206
|
prefix: "pk", # Token prefix → pk_test_, pk_live_
|
|
1112
1207
|
permissions: %w[read validate], # Scope ceiling (max permissions allowed)
|
|
1208
|
+
# scopes: :ceiling, # Optional: see "Widening a ceiling" below
|
|
1113
1209
|
public: true, # Store token so it remains viewable
|
|
1114
1210
|
limit: 1, # Max 1 per owner per environment
|
|
1115
1211
|
restrictions: [:origins] # May be locked to domains, not to IPs
|
|
@@ -1179,6 +1275,45 @@ sk = user.create_api_key!(
|
|
|
1179
1275
|
sk.scopes # => ["read", "validate", "issue_license", "admin"]
|
|
1180
1276
|
```
|
|
1181
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
|
+
|
|
1182
1317
|
### Non-Revocable Keys
|
|
1183
1318
|
|
|
1184
1319
|
Keys with `revocable: false` protect against accidental deletion. Configure
|
|
@@ -1362,7 +1497,8 @@ Origin input is deliberately forgiving: full URLs, trailing slashes, ports, comm
|
|
|
1362
1497
|
|---|---|
|
|
1363
1498
|
| Within one list | **OR** — any entry that matches admits the request |
|
|
1364
1499
|
| Across both lists | **AND** — every list that has entries must pass |
|
|
1365
|
-
| Empty (or absent) lists | **Unrestricted** — presence is the toggle, so existing keys are unaffected |
|
|
1500
|
+
| Empty (or absent) lists | **Unrestricted** — presence is the toggle, so existing keys are unaffected. A kind given as `nil` or blank text (to `allowed_origins=` / `allowed_ips=`, `restrictions=`, or `create_api_key!(restrictions:)`) clears that list. In a stored row only `[]` or an absent kind means empty: a stored `null` is malformed |
|
|
1501
|
+
| Malformed values | It depends on the path. The `allowed_origins=` / `allowed_ips=` writers parse raw text, so `nil`, blank text, and blank members such as `[""]` clear the list, while `false`, numbers, objects, and `nil` members fail validation. A restrictions hash (`restrictions=`, `create_api_key!(restrictions:)`) clears a kind given as `nil` or blank text, and refuses a kind given as `false`, a number, or an object, and `null`, `false`, numbers, objects, or blank strings inside a list. Stored rows are read strictly: a row holding any of these, or a `null` or blank kind, refuses every request with `restriction_misconfigured`, and the dashboard shows the stored entries so a save is refused until they are corrected. Malformed never means unrestricted |
|
|
1366
1502
|
| `example.com` | Matches that exact host. Case-insensitive, port-blind, scheme-blind |
|
|
1367
1503
|
| `*.example.com` | Matches `a.example.com` and `a.b.example.com`, but **not** the apex `example.com`. List both to cover both |
|
|
1368
1504
|
| `*` alone | Invalid. An empty list already means "anywhere" |
|
|
@@ -1389,7 +1525,20 @@ Omitting `restrictions:` allows both kinds. `restrictions: []` forbids restricti
|
|
|
1389
1525
|
|
|
1390
1526
|
### Resolving the client IP
|
|
1391
1527
|
|
|
1392
|
-
IP
|
|
1528
|
+
An IP allowlist is only as truthful as the address it is checked against, and the gem cannot see how your servers are deployed. The default resolver is `request.remote_ip`. Rails computes it from the `X-Forwarded-For` header whenever that header is present, so it is right in one deployment shape and spoofable in the other.
|
|
1529
|
+
|
|
1530
|
+
**Behind a proxy (keep the default).** Your load balancer, reverse proxy, or CDN is the only way to reach Rails. It overwrites `X-Forwarded-For`, or appends the address it actually saw, and its addresses are covered by `config.action_dispatch.trusted_proxies` (Rails already trusts private and loopback ranges). Rails then skips the trusted hops and resolves the real client, and an entry the client forged ends up behind the one your proxy added.
|
|
1531
|
+
|
|
1532
|
+
**Directly reachable (use the socket peer).** Clients connect straight to Puma or another Rails server, or your proxy can be bypassed. Then a client can send `X-Forwarded-For: 203.0.113.7` and `request.remote_ip` answers `203.0.113.7`, passing an allowlist that contains it. Resolve the TCP peer instead, which a client cannot choose:
|
|
1533
|
+
|
|
1534
|
+
```ruby
|
|
1535
|
+
# config/initializers/api_keys.rb
|
|
1536
|
+
config.client_ip_resolver = ->(request) { request.remote_addr }
|
|
1537
|
+
```
|
|
1538
|
+
|
|
1539
|
+
**`trusted_proxies` alone is not a boundary.** It tells Rails which hops to skip; it does not stop a client that reaches Rails directly from sending the header. If your proxy can be bypassed, close that path at the network layer (firewall or security group) or use the socket-peer resolver.
|
|
1540
|
+
|
|
1541
|
+
The test suite exercises both shapes through Rails' real `ActionDispatch::RemoteIp` middleware.
|
|
1393
1542
|
|
|
1394
1543
|
Only read a vendor header directly when your network ingress rejects requests that did not come through that vendor. Otherwise a client can send the same header and choose the address your allowlist sees:
|
|
1395
1544
|
|
|
@@ -1407,7 +1556,7 @@ The mounted dashboard renders only the expiration and request-restriction fields
|
|
|
1407
1556
|
### Security notes
|
|
1408
1557
|
|
|
1409
1558
|
- `Origin` and `Referer` are **browser-enforced** headers. They are trustworthy coming from a real browser and trivially forged by `curl`. Origin restrictions are a browser-context control: they stop a lifted public key from working on someone else's *website*. They are not secrecy. Pair them with keys that cannot spend anything dangerous.
|
|
1410
|
-
- IP restrictions inherit the truthfulness of their resolver.
|
|
1559
|
+
- IP restrictions inherit the truthfulness of their resolver. Behind a proxy that is the only way in, keep `request.remote_ip` and list the proxy in `trusted_proxies`. If Rails is reachable directly, resolve `request.remote_addr`, or callers can choose the address being checked with `X-Forwarded-For` (see [Resolving the client IP](#resolving-the-client-ip)). Only trust a CDN-supplied header when direct access to the origin is blocked.
|
|
1411
1560
|
- Everything fails closed: a locked list plus an unreadable request context, an unknown policy kind, or malformed stored policy data is a refusal, never a pass.
|
|
1412
1561
|
- Refusals never echo the configured allowlist back to the caller. Reflecting your domains to an unauthenticated attacker would be a reconnaissance gift. If you want a more explicit message, override it through i18n (`api_keys.errors.origin_not_allowed`).
|
|
1413
1562
|
- Restriction checks read the current database row on every request, cache or no cache. Tightening the origins of a leaked publishable key takes effect on the very next call.
|
data/SECURITY.md
CHANGED
|
@@ -23,11 +23,31 @@ Do not open a public issue for an undisclosed vulnerability. We will acknowledge
|
|
|
23
23
|
|
|
24
24
|
Applications remain responsible for:
|
|
25
25
|
|
|
26
|
-
- trusted TLS/proxy configuration, endpoint authorization, and request rate limiting;
|
|
27
|
-
- encrypted, `Secure`, `HttpOnly`, appropriately `SameSite` session cookies for the one-time dashboard token handoff;
|
|
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 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
|
|
31
31
|
- prompt dependency/runtime patching and a durable background-job backend where callbacks or usage statistics are required.
|
|
32
32
|
|
|
33
33
|
The gem bounds authentication work, but applications should still rate-limit credential endpoints. Do not treat a key prefix, last four characters, cache entry, or public key as proof of authorization.
|
|
34
|
+
|
|
35
|
+
### Client IP restrictions depend on your deployment
|
|
36
|
+
|
|
37
|
+
`allowed_ips` is checked against `config.client_ip_resolver`, which defaults to `request.remote_ip`. Rails derives that value from `X-Forwarded-For`, so it is only truthful in one of two shapes:
|
|
38
|
+
|
|
39
|
+
- **Behind a proxy that is the only way in.** The proxy overwrites `X-Forwarded-For` or appends the address it saw, and its addresses are covered by `config.action_dispatch.trusted_proxies`. Keep the default resolver.
|
|
40
|
+
- **Directly reachable.** If clients can connect to the Rails server itself, a client can send `X-Forwarded-For: <an allowed address>` and pass the check. Set `config.client_ip_resolver = ->(request) { request.remote_addr }` so the socket peer is checked.
|
|
41
|
+
|
|
42
|
+
Configuring `trusted_proxies` alone is not a boundary: it tells Rails which hops to skip, and does nothing about a client that bypasses the proxy. Block that path at the network layer or use the socket-peer resolver. The gem does not change the default, because the right answer depends on a network it cannot see.
|
|
43
|
+
|
|
44
|
+
### What the one-time token handoff guarantees
|
|
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, so two more layers stop a copy of the session taken before retrieval (a copied cookie, or concurrent requests).
|
|
47
|
+
|
|
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.
|
|
53
|
+
- It does not protect a stolen session: whoever holds the owner's session cookie can use the dashboard as that owner.
|