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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +69 -0
  3. data/README.md +402 -13
  4. data/SECURITY.md +7 -6
  5. data/app/controllers/api_keys/keys_controller.rb +73 -46
  6. data/app/views/api_keys/keys/_form.html.erb +56 -10
  7. data/app/views/api_keys/keys/_key_row.html.erb +4 -2
  8. data/app/views/api_keys/keys/_refusal_notice.html.erb +24 -0
  9. data/app/views/api_keys/keys/_show_token.html.erb +4 -7
  10. data/app/views/api_keys/keys/edit.html.erb +2 -0
  11. data/app/views/layouts/api_keys/application.html.erb +6 -0
  12. data/docs/reviews/2026-10-18-0-6-0/01-astra-review-of-0-6-0.md +36 -0
  13. data/docs/reviews/2026-10-18-0-6-0/02-astra-re-review-of-0-6-0.md +34 -0
  14. data/docs/reviews/2026-10-18-0-6-0/03-sol-re-check-of-0-6-0.md +41 -0
  15. data/lib/api_keys/authentication.rb +174 -7
  16. data/lib/api_keys/configuration.rb +82 -0
  17. data/lib/api_keys/device_flow.rb +146 -0
  18. data/lib/api_keys/engine.rb +9 -0
  19. data/lib/api_keys/errors.rb +11 -0
  20. data/lib/api_keys/form_builder_extensions.rb +3 -1
  21. data/lib/api_keys/helpers/token_session.rb +183 -23
  22. data/lib/api_keys/helpers/view_helpers.rb +34 -0
  23. data/lib/api_keys/idempotency.rb +155 -0
  24. data/lib/api_keys/jobs/update_stats_job.rb +45 -1
  25. data/lib/api_keys/models/api_key.rb +261 -3
  26. data/lib/api_keys/models/concerns/has_api_keys.rb +9 -1
  27. data/lib/api_keys/models/device_authorization.rb +333 -0
  28. data/lib/api_keys/models/idempotency_key.rb +184 -0
  29. data/lib/api_keys/version.rb +1 -1
  30. data/lib/api_keys.rb +13 -0
  31. data/lib/generators/api_keys/add_created_by_generator.rb +50 -0
  32. data/lib/generators/api_keys/add_device_authorizations_generator.rb +51 -0
  33. data/lib/generators/api_keys/add_idempotency_keys_generator.rb +52 -0
  34. data/lib/generators/api_keys/add_key_activity_generator.rb +59 -0
  35. data/lib/generators/api_keys/templates/add_created_by_to_api_keys.rb.erb +33 -0
  36. data/lib/generators/api_keys/templates/add_key_activity_to_api_keys.rb.erb +37 -0
  37. data/lib/generators/api_keys/templates/create_api_keys_device_authorizations.rb.erb +70 -0
  38. data/lib/generators/api_keys/templates/create_api_keys_idempotency_keys.rb.erb +52 -0
  39. data/lib/generators/api_keys/templates/create_api_keys_table.rb.erb +16 -0
  40. data/lib/generators/api_keys/templates/initializer.rb +35 -0
  41. metadata +17 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 54964496d525a092f7d1c9759e10bc4e73cce2633040c3f1e3b9a172609d02c3
4
- data.tar.gz: 4cf7aabd74db8edf00e6ba06fc3cfe1536c26dc606e0485e452b7e920750ad1a
3
+ metadata.gz: 62087b06751cfbfe1b08bc1db54179fa1ce72844da86166a5198887d7b834a3f
4
+ data.tar.gz: 51ddeccbaee055eaf9d1cec6677b9514b478bf0ff4764768dee7e40e731c8685
5
5
  SHA512:
6
- metadata.gz: 05f939482ae7258c7bc57c2cab1b4c971e58b5db4a74338640ae063b53862cbfdbc8d5e8b4385444595d21edf6c6754842d40ba0c42b6e7afc50fc543e9ca542
7
- data.tar.gz: 03addc813dbd92704cc1dce4adcc2db22c44d5cd364c2949d83ac19c903e390e1521d389ff95b2d0536652700d16b58a91bf357133bdab55ab655a3e4fc5b5be
6
+ metadata.gz: 5b8adba5c3631e272a77bfd600f4667981bf91048e529aeac7b5f6f0c6d9b1967c989edff1ee23ee9d2a2bb9da3d83a9206e7b0a20ca26f741ebb8bae5abc586
7
+ data.tar.gz: e0bbcb9c1bf0be08bdbe9ffe035e7dbe60a2cddc0520474fb94c40ca6cba53c46fc8f4ce5cb9537fd14ab4392149ac294a89d7599a61ba95e66a5f8e1864fd6b
data/CHANGELOG.md CHANGED
@@ -1,3 +1,72 @@
1
+ ## [0.7.0] - 2026-10-24
2
+
3
+ Four features that an invoicing app on the fleet runs in production today, moved into the gem with their tests so that apps on the fleet can delete their own copies. Each one is opt-in: an app that does not use it sees no change.
4
+
5
+ ### Added
6
+
7
+ - The device authorization grant (RFC 8628), so an agent or a CLI can ask for an API key and get it after a person approves in the app, with no key copied by hand. `ApiKeys::DeviceAuthorization` (`request!`, `find_undecided_by_user_code`, `find_by_device_code`, `approve!(owner, by:)`, `deny!(by:)`, `poll!(client_id:)`, `prune!`) keeps both codes as SHA-256 digests, lives ten minutes, uses an eight-letter user code from twenty consonants that is case- and dash-insensitive, takes the row lock for polls and decisions, and never decides a decided or expired request again. Each request names the type of its key (`device_key_type` in the controller, `config.default_key_type` by default); with key types and no type, the request raises as the app's error. `approve!` checks the owner's key limit for that type and raises `KeyLimitReached` at the limit; the request stays pending. The type is stored with the request and is never resolved again: an untyped request stays untyped. A `scopes: :ceiling` type grants its whole ceiling, so a request must ask for all of it. A configuration change that makes the approved key impossible (key types turned on for an untyped request, a type removed or renamed, a ceiling that grew) makes the poll answer `invalid_scope` and closes the request (`void`). The first poll after the approval creates an ordinary key for the owner, named "client on device", and answers with its token once. A poll never raises because the key cannot be made. At the owner's key limit it answers `authorization_pending`, and a later poll makes the key once there is room; any other failure closes the request. `ApiKeys::DeviceFlow` gives an API controller the `create` and `token` actions with the RFC 8628 answers, the RFC 6749 error shape and `Cache-Control: no-store`; the host names its approval page in `device_verification_uri`, and may add fields to the token answer in `device_token_response`, which runs in the transaction that creates the key. The engine adds `device_code`, `user_code` and `token` to `config.filter_parameters`. `rails generate api_keys:add_device_authorizations` creates the table. Its JSON column has no literal default, which MySQL refuses; the model gives the default.
8
+ - The person behind a key, so a key whose owner is an organization stops when the person who created it no longer stands behind it. `created_by`, an optional polymorphic reference on `api_keys`, is set by `create_api_key!(created_by:)` and cannot change after creation: a validation, and `attr_readonly` for `update_column` and saves without validations. `config.trusted_key_check` is a lambda that `authenticate_api_key!` calls with the key after the scope check; a false answer refuses the request with 403 `untrusted_key` through `render_unauthorized`. The device flow names the approving person as `created_by`. The mounted dashboard leaves `created_by` nil; an app that needs it creates keys in its own controller. New installs get the columns; existing installs run `rails generate api_keys:add_created_by`.
9
+ - Idempotency-Key for writes, so a client can retry a write after a timeout without making a second record. `ApiKeys::IdempotencyKey` (`hold(api_key, key)`, `digest_of`, `answer!`, `answered?`, `prune!`, `Busy`) claims the key's row in a commit of its own, runs the write under the row lock, and commits the write with its answer (the status and the record, never a body), or nothing. Keys are per API key and kept a day. `ApiKeys::Idempotency` gives an API controller the `run_once_per_idempotency_key` around_action and `render_resource`; the host defines `resource_json` and `idempotent_replay_allowed?`. A retry answers the record as it is now with `Idempotent-Replayed: true`, and only a record the API key may still read: one moved to another owner is not found. The request digest counts the query and the body apart, and an upload by its bytes, file name and content type, in an encoding that no ordinary value shares. `hold` refuses every open transaction; transactional tests opt in with the test-only `config.idempotency_in_test_transactions`, which takes `true` only when `Rails.env` is test and counts only while it is test. A hash that names a key both as a symbol and as a text is refused. The claim of a new key runs in a transaction of its own under the lock timeout. The refusals are 400 `idempotency_key_required`, 409 `idempotency_key_in_use` and 422 `idempotency_key_reused`, after draft-ietf-httpapi-idempotency-key-header section 2.7. `rails generate api_keys:add_idempotency_keys` creates the table.
10
+ - Scope by HTTP method, so an API asks reads for one scope and writes for another in one line: `authenticate_api_key!(scope: { read: "read", write: "write" })`. GET and HEAD read; every other method writes. Both sides are required.
11
+
12
+ ### Changed
13
+
14
+ - `untrusted_key` joins the refusal codes that are stamped on a key (`last_refusal_code`) and explained by `api_key_refusal_reason`. It is stamped only when `config.trusted_key_check` refuses a key.
15
+ - `created_by_type` and `created_by_id` join the attributes that cannot change after a key is created.
16
+ - `ApiKey::MAX_NAME_LENGTH` and `ApiKey::NAME_FORMAT` name the existing key name rule, which the device flow uses to name its keys.
17
+
18
+ ### Security
19
+
20
+ - Device flow: the key's token is never stored, and the poll that creates the key is the only answer that carries it. A poll with another `client_id` changes nothing. Two simultaneous polls of one approved request make one key; this was checked with concurrent connections on PostgreSQL, MySQL and SQLite.
21
+ - Idempotency-Key: a retry waits at most two seconds for the request that holds the key, and two first requests wait at most as long for each other's claim (PostgreSQL `lock_timeout`, MySQL `innodb_lock_wait_timeout`; on SQLite, the database's busy timeout), then answer 409. SQLite has no row locks, so there an UPDATE takes the database's write lock first. The device flow's row lock does the same. A new CI job runs the generated migrations and these locks on PostgreSQL, MySQL and SQLite with real concurrent connections.
22
+ - `config.trusted_key_check` fails closed: an error in the lambda fails the request and never passes it as trusted.
23
+
24
+ ### Upgrade notes
25
+
26
+ - Nothing changes until an app opts in. Every 0.7.0 migration is optional: `add_created_by` for the person behind a key, `add_device_authorizations` for the device flow, `add_idempotency_keys` for Idempotency-Key. Run them in any order, before or after the deploy.
27
+ - From 0.4.x, the 0.5.0 and 0.6.0 migrations (`add_restrictions`, `add_key_activity`) stay optional too. The calls of a custom key controller keep working: `create_api_key!`, `TokenSession.store(session, api_key)` and `TokenSession.retrieve_once(session)`, and a handoff stored by 0.4.x stays readable. Read the 0.5.1 and 0.6.0 notes: the scope checkboxes render an empty field before the boxes, a token's handoff is one-time across copies of the session, and a retrieval inside an open transaction is refused. The README section "Upgrading to 0.7.0 from 0.4.x or 0.6.x" lists them.
28
+ - A 0.6.0 job worker ignores the new `untrusted_key` refusal code with a warning. A 0.4.x or 0.5.x worker errors on every refusal job of 0.6.0 or later: deploy job workers with the web processes.
29
+ - Keys created before `add_created_by` have no `created_by`. If `config.trusted_key_check` needs one, backfill the columns in a migration with SQL (`update_all`): the columns are read-only for a saved key.
30
+ - `config.filter_parameters` gains `device_code`, `user_code` and `token`.
31
+ - An app whose tests are transactional and use `ApiKeys::Idempotency` sets `ApiKeys.configuration.idempotency_in_test_transactions = true` in its test environment.
32
+
33
+ ## [0.6.0] - 2026-10-18
34
+
35
+ ### Added
36
+
37
+ - 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.
38
+ - `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.
39
+ - 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`.
40
+ - 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.
41
+ - `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.
42
+ - `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.
43
+ - `ApiKeys::ViewHelpers#api_key_refusal_reason` explains a key's latest refusal for custom dashboards.
44
+ - `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.
45
+
46
+ ### Changed
47
+
48
+ - `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`.
49
+ - 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.
50
+ - 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.
51
+ - 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.
52
+
53
+ ### Security
54
+
55
+ - `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.
56
+ - 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.
57
+ - 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.
58
+ - 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.
59
+ - The dashboard explains a refusal with fixed sentences and never renders the stored code itself.
60
+ - 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.
61
+ - 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.
62
+
63
+ ### Upgrade notes
64
+
65
+ - 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.
66
+ - 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.
67
+ - 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.
68
+ - 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".
69
+
1
70
  ## [0.5.1] - 2026-10-18
2
71
 
3
72
  ### Security