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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -0
  3. data/README.md +158 -9
  4. data/SECURITY.md +22 -2
  5. data/app/controllers/api_keys/keys_controller.rb +136 -14
  6. data/app/views/api_keys/keys/_form.html.erb +85 -17
  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/_restriction_fields.html.erb +4 -2
  10. data/app/views/api_keys/keys/_show_token.html.erb +4 -7
  11. data/app/views/api_keys/keys/edit.html.erb +2 -0
  12. data/app/views/api_keys/security/best_practices.html.erb +1 -1
  13. data/app/views/layouts/api_keys/application.html.erb +6 -0
  14. data/docs/reviews/2026-10-18-0-5-1/01-astra-review-of-0-5-0.md +55 -0
  15. data/docs/reviews/2026-10-18-0-5-1/02-astra-re-review-of-0-5-1.md +36 -0
  16. data/docs/reviews/2026-10-18-0-5-1/03-opus-review-of-0-5-1.md +5 -0
  17. data/docs/reviews/2026-10-18-0-6-0/01-astra-review-of-0-6-0.md +36 -0
  18. data/docs/reviews/2026-10-18-0-6-0/02-astra-re-review-of-0-6-0.md +34 -0
  19. data/docs/reviews/2026-10-18-0-6-0/03-sol-re-check-of-0-6-0.md +41 -0
  20. data/lib/api_keys/authentication.rb +123 -7
  21. data/lib/api_keys/configuration.rb +37 -6
  22. data/lib/api_keys/form_builder_extensions.rb +42 -3
  23. data/lib/api_keys/helpers/token_session.rb +257 -8
  24. data/lib/api_keys/helpers/view_helpers.rb +33 -0
  25. data/lib/api_keys/jobs/update_stats_job.rb +45 -1
  26. data/lib/api_keys/models/api_key.rb +293 -7
  27. data/lib/api_keys/models/concerns/has_api_keys.rb +10 -2
  28. data/lib/api_keys/restrictions.rb +75 -24
  29. data/lib/api_keys/services/authenticator.rb +24 -2
  30. data/lib/api_keys/version.rb +1 -1
  31. data/lib/api_keys.rb +7 -0
  32. data/lib/generators/api_keys/add_key_activity_generator.rb +59 -0
  33. data/lib/generators/api_keys/add_restrictions_generator.rb +4 -1
  34. data/lib/generators/api_keys/templates/add_key_activity_to_api_keys.rb.erb +37 -0
  35. data/lib/generators/api_keys/templates/create_api_keys_table.rb.erb +12 -0
  36. data/lib/generators/api_keys/templates/initializer.rb +32 -7
  37. metadata +10 -1
@@ -0,0 +1,36 @@
1
+ # Security review of the 0.6.0 branch (Codex Astra, 2026-10-18, at 720e401, PR #66)
2
+
3
+ Scope: `git diff origin/main..720e401` on the 0.5.1 main: ceiling-authoritative scopes (#28), refused-traffic stats (#38), the durable one-time handoff (`token_shown_at`), `ApiKey#scopes_after_form_edit`, migrations and docs, read-only, under the 0.5.0 threat model. Verdict: BLOCK on the durable handoff claim being undone by a host transaction that rolls back after the plaintext was released; four should-fixes (creation form still offers scope boxes for ceiling types; stats debouncing does not bound pending jobs; the retrieval result's inspect printed the token; reconciliation did not recheck revocation inside the lock) and two nits. All were applied in the next commits; see the disposition at the end.
4
+
5
+ ## Blocking
6
+
7
+ - **The durable handoff can be rolled back after releasing the token.** The claim uses the caller’s database connection without ensuring its transaction has committed, and retrieval immediately returns the plaintext. Calling retrieval inside a transaction that subsequently rolls back restores `token_shown_at = NULL`. Verified in memory with `NullStore`: retrieval → rollback → copied-session replay returned the same token twice. A host controller wrapping display in a transaction can therefore violate the advertised guarantee. Fail closed inside an existing transaction, or ensure consumption commits independently before releasing plaintext. The unconditional durability claims must also be corrected. [api_key.rb:601](lib/api_keys/models/api_key.rb:601), [token_session.rb:167](lib/api_keys/helpers/token_session.rb:167), [README.md:514](README.md:514)
8
+
9
+ ## Should fix
10
+
11
+ - **The creation form still offers ineffective scope checkboxes for ceiling types.** Only the persisted-key form suppresses them; the creation form always offers owner scopes, and its JavaScript handles restrictions and expiration only. An owner can clear every permission yet create a key authorized for the entire ceiling. Hide/disable those controls and display the live contract when selecting a ceiling type. Recommending `scopes: :ceiling` for publishable keys is appropriate under the initializer’s stated “users do not pick scopes” condition, but this form contradicts that condition. [_form.html.erb:73](app/views/api_keys/keys/_form.html.erb:73), [_form.html.erb:182](app/views/api_keys/keys/_form.html.erb:182), [initializer.rb:156](lib/generators/api_keys/templates/initializer.rb:156)
12
+
13
+ - **Refusal debouncing does not bound pending jobs.** It checks only the persisted timestamp; enqueueing reserves nothing. With counting disabled and a one-minute interval, 100 requests carrying a revoked token queued 100 refusal jobs before worker execution. Prefix/last-four knowledge alone cannot stamp a key: verification checks the full token, and forged requests queued no stats jobs. Document the pending-job limitation and consider atomic enqueue deduplication; successful-use debouncing has the same limitation. [authentication.rb:172](lib/api_keys/authentication.rb:172), [authenticator.rb:260](lib/api_keys/services/authenticator.rb:260), [CHANGELOG.md:24](CHANGELOG.md:24)
14
+
15
+ - **The new retrieval result exposes plaintext through inspection.** `Retrieval` inherits `Struct#inspect`/`to_s`, which includes its token on successful retrieval. Verified directly. Add credential-redacting inspection, consistent with the existing authentication result. The `already_shown` result itself correctly contains no token. [token_session.rb:83](lib/api_keys/helpers/token_session.rb:83), [authenticator.rb:46](lib/api_keys/services/authenticator.rb:46)
16
+
17
+ - **Reconciliation does not recheck revocation after locking.** Selection filters `revoked_at: nil`, but a revocation between selection and `with_lock` is ignored after the row reloads. Verified that such a key still gains scopes and contributes to the changed count. Recheck eligibility inside the lock. This does not reactivate the key, but violates the documented exclusion of revoked keys. [api_key.rb:649](lib/api_keys/models/api_key.rb:649), [README.md:1312](README.md:1312)
18
+
19
+ ## Nits
20
+
21
+ - The general statement that empty scopes grant nothing on typed keys needs an explicit ceiling-type exception. Ceiling keys intentionally receive their live permissions even with `scopes: []`. [README.md:889](README.md:889), [api_key.rb:388](lib/api_keys/models/api_key.rb:388)
22
+ - The gem still identifies itself as `0.5.1`; bump it before publishing 0.6.0. The new runtime files are tracked and included by the gemspec. [version.rb:4](lib/api_keys/version.rb:4), [api_keys.gemspec:29](api_keys.gemspec:29)
23
+
24
+ ## Missing tests
25
+
26
+ - **Committed, independent-connection handoff races and rollback replay.** The concurrent tests pin all threads to one SQLite connection and explicitly roll the transaction back afterward. They do not establish durability or independent-connection behavior across SQLite, PostgreSQL, and MySQL. Add those races with `NullStore`, followed by replay after commit/cache loss, plus the rollback regression above. [token_session_test.rb:40](test/helpers/token_session_test.rb:40)
27
+ - **Refusal bursts before worker execution.** Current debounce coverage pre-populates `last_refused_at`; it misses pending-job amplification. Also exercise the forged-prefix/last-four refusal test with bcrypt, since the current test uses the default hashing configuration. [refusal_stats_test.rb:204](test/refusal_stats_test.rb:204), [refusal_stats_test.rb:176](test/refusal_stats_test.rb:176)
28
+
29
+ VERDICT: BLOCK
30
+
31
+ 0.6.0 should not be published with its current unconditional durable-handoff guarantee: transaction rollback demonstrably permits a second release. Resolve that blocker and add the regression coverage before release. This review covered the full diff and used isolated in-memory checks without repository changes; the full suite was not run. Both requested GitHub issue fetches failed, and the two referenced `.cursor/rules` files were absent, so those sources could not be reviewed.
32
+ ## Disposition (lead)
33
+
34
+ Applied in the six commits that followed (3a9b044 to 45afec4): the handoff claim is not trusted inside an open transaction (with a real cache the marker carries the guarantee; with none the token is withheld as `:inside_transaction`, nothing is recorded and the handoff stays for a retry outside the transaction; the dashboard opens no transaction; README, SECURITY.md and CHANGELOG corrected; the rollback replay tests and an 8-thread independent-connection race on a file-backed SQLite database); the creation form hides and disables scope boxes for a ceiling type and create ignores submitted scopes for it; success and refusal enqueues are deduplicated per key and kind with an atomic cache write when a real cache exists, documented otherwise; the retrieval result redacts the token in every representation; reconciliation rechecks revocation and type inside the lock; the README names the ceiling-type exception. Not available here: PostgreSQL and MySQL races (SQLite only). The version bump is the release step.
35
+
36
+ Superseded in round 2: a retrieval inside an open transaction is now refused whatever the cache (a process-local cache could not stop another process after a rollback), and the README no longer says the dashboard always retrieves outside transactions, since its controller inherits the host's.
@@ -0,0 +1,34 @@
1
+ # Re-review of the 0.6.0 fixes, round 2 (Codex Astra, 2026-10-18, at fe28e9c, PR #66)
2
+
3
+ Scope: `git diff 720e401..fe28e9c`, the six fixes answering review 01, read-only. Verdict: BLOCK on the handoff still releasing the plaintext inside a transaction whenever any non-NullStore cache was present (a process-local MemoryStore included), plus two should-fixes on the enqueue deduplication (a cache write answering nil treated as an existing reservation; a false `perform_later` not releasing the reservation) and docs nits. All applied in the five commits that followed (ab89492 to 497879e): retrieval inside any open Active Record transaction on the ApiKey pool is refused whatever the cache; suppression only on a confirmed reservation; release on a false enqueue; Solid Cache qualified in the stats section; the listed tests added. See 03.
4
+
5
+ ## Blocking
6
+
7
+ - **Durable handoff: partially fixed, still open.** NullStore retrieval inside an Active Record transaction now withholds plaintext and preserves the handoff; the rollback tests genuinely exercise that scenario. But any non-NullStore cache enables release before commit—including process-local `MemoryStore`. After rollback, another process can release the token again without any cache flush. The new cache-loss test explicitly expects two releases. Documenting this limitation does not implement the requested fail-closed or independently committed consumption. Withhold inside transactions regardless of cache, or commit consumption independently. [token_session.rb:312](lib/api_keys/helpers/token_session.rb:312), [token_session.rb:395](lib/api_keys/helpers/token_session.rb:395), [token_session_test.rb:351](test/helpers/token_session_test.rb:351), [token_session_test.rb:420](test/helpers/token_session_test.rb:420)
8
+
9
+ ## Should fix
10
+
11
+ - **Enqueue deduplication: partially closed, with a new failure mode.** Reservations address pending bursts when the cache supports atomic conditional writes. However, nil/false writes are treated as existing reservations, suppressing statistics even when nothing was reserved. Only exceptions trigger fallback. The failing-cache test raises `IOError`, so it misses this case; locally inspected RedisCacheStore returns nil for handled write failures. Check reservation presence before suppressing, and enqueue when reservation status is unavailable. [authentication.rb:234](lib/api_keys/authentication.rb:234), [refusal_stats_test.rb:289](test/refusal_stats_test.rb:289), [redis_cache_store.rb:397](rails source)
12
+
13
+ - **Failed enqueues can retain reservations.** Both paths release only on exceptions, ignoring `perform_later` returning false. Active Job can return false after an enqueue error, suppressing subsequent attempts until expiration. The new tests cover raised exceptions only. [authentication.rb:160](lib/api_keys/authentication.rb:160), [authentication.rb:191](lib/api_keys/authentication.rb:191), [refusal_stats_test.rb:310](test/refusal_stats_test.rb:310), [enqueuing.rb:82](rails source)
14
+
15
+ - **Creation form: closed in the inspected implementation.** JavaScript hides/disables scope controls and displays the selected ceiling. Server-side creation replaces submitted scopes with that ceiling, resolving omitted types through the configured default. Tests genuinely POST cleared, narrower, excessive, and ordinary-type scopes. No new authorization defect found; the rendering test does not execute selection changes. [keys_controller.rb:78](app/controllers/api_keys/keys_controller.rb:78), [keys_controller.rb:317](app/controllers/api_keys/keys_controller.rb:317), [_form.html.erb:230](app/views/api_keys/keys/_form.html.erb:230), [keys_controller_test.rb:774](test/controllers/keys_controller_test.rb:774)
16
+
17
+ - **Retrieval inspection: closed.** Inspection, string conversion, pretty-printing, and JSON redact plaintext. The test obtains a successful retrieval and exercises those representations, including nested inspection. No new defect found in this change. [token_session.rb:111](lib/api_keys/helpers/token_session.rb:111), [token_session_test.rb:733](test/helpers/token_session_test.rb:733)
18
+
19
+ - **Reconcile eligibility: closed.** Revocation and type are rechecked after the locked reload. The test performs a real database update after selection but before locking, leaving the selected object stale; it verifies unchanged scopes and exclusion from the count. This is a valid controlled interleaving, though not another connection. No new defect found. [api_key.rb:650](lib/api_keys/models/api_key.rb:650), [reconcile_scopes_test.rb:13](test/reconcile_scopes_test.rb:13), [reconcile_scopes_test.rb:48](test/reconcile_scopes_test.rb:48)
20
+
21
+ ## Nits
22
+
23
+ - **Empty-scope wording: closed. Version bump: outstanding.** The ceiling exception is explicit; the gem still identifies as `0.5.1`. [README.md:890](README.md:890), [version.rb:4](lib/api_keys/version.rb:4)
24
+ - **Documentation still overstates guarantees.** “The dashboard always” retrieves outside transactions is unsupported because its controller inherits the host controller. The stats section also promises atomic deduplication with Solid Cache while the handoff section describes its non-atomic absent-entry behavior. Qualify both, and update the filed disposition accordingly. [README.md:514](README.md:514), [application_controller.rb:10](app/controllers/api_keys/application_controller.rb:10), [README.md:1162](README.md:1162), [README.md:520](README.md:520), [review trace:34](docs/reviews/2026-10-18-0-6-0/01-astra-review-of-0-6-0.md:34)
25
+
26
+ ## Missing tests
27
+
28
+ - **Handoff coverage improved materially, but remains SQLite-only.** The file-backed test uses autocommit pool connections, NullStore, and post-race replay against the committed marker. Its gate precedes checkout, so it does not guarantee distinct simultaneous connections; it also accepts any nil-producing loser. Add a barrier after checkout, assert loser statuses, and cover PostgreSQL/MySQL. [token_session_test.rb:71](test/helpers/token_session_test.rb:71), [token_session_test.rb:318](test/helpers/token_session_test.rb:318)
29
+ - **Transaction-check boundaries remain untested.** It correctly checks the ApiKey pool, avoiding unrelated-pool false positives; lazy transactions and savepoints legitimately count. Its boundary is Active Record’s transaction manager: raw SQL `BEGIN` is not detected. Add separate-pool and nested-transaction coverage, and document that boundary. [token_session.rb:299](lib/api_keys/helpers/token_session.rb:299), [database_statements.rb:398](rails source)
30
+ - **Original burst and bcrypt gaps: closed by meaningful scenarios.** Bursts enqueue before workers execute; bcrypt forgery preserves prefix/last-four while changing the secret. Still missing: non-raising cache/enqueue failures, browser type-switch execution, default-ceiling POST with omitted scopes/type, and reconciliation after retyping. [refusal_stats_test.rb:192](test/refusal_stats_test.rb:192), [refusal_stats_test.rb:239](test/refusal_stats_test.rb:239), [keys_controller_test.rb:774](test/controllers/keys_controller_test.rb:774), [reconcile_scopes_test.rb:48](test/reconcile_scopes_test.rb:48)
31
+
32
+ VERDICT: BLOCK
33
+
34
+ 0.6.0 is not ready to publish: transactional token release remains replayable, and enqueue deduplication introduces silent statistics loss on non-raising failures. The form, redaction, reconciliation, and empty-scope fixes close their original findings. I read the entire delta through `fe28e9c` and checked every touched area; this was a static review, with no tests executed or repository changes. Both requested `.cursor/rules` files remain absent.
@@ -0,0 +1,41 @@
1
+ # Re-check of the 0.6.0 fixes, round 3 (Codex Sol, 2026-10-18, at 497879e, PR #66)
2
+
3
+ Scope: `git diff fe28e9c..497879e`, the answer to review 02, read-only. Verdict: PASS; the two small items (a README sentence that still said every retrieval attempt uses the handoff up; two transaction-boundary tests that did not create the state they named) were applied in the next commit. Open by nature of the environment: PostgreSQL and MySQL races, and a browser test of switching the key type.
4
+
5
+ ## Blocking
6
+
7
+ - **Durable handoff: closed for Active Record transactions.** The guard checks the **ApiKey pool’s current connection**, before either consumption write, regardless of cache. An unconsumed handoff returns `:inside_transaction` and is restored; a previously committed claim returns `:already_shown` and stays removed. Commit, rollback, cache replacement and cache-flush tests meaningfully exercise those paths. Cache replacement simulates another process; it does not launch one. [token_session.rb:186](lib/api_keys/helpers/token_session.rb:186), [token_session.rb:286](lib/api_keys/helpers/token_session.rb:286), [token_session_test.rb:353](test/helpers/token_session_test.rb:353), [token_session_test.rb:382](test/helpers/token_session_test.rb:382), [token_session_test.rb:499](test/helpers/token_session_test.rb:499)
8
+
9
+ - **Compatibility boundary: verified.** Without the column, or without a named key, transaction refusal is bypassed and the cache-only behavior remains. The unmigrated transaction test genuinely drops the column. Raw SQL `BEGIN` remains undetected; its test actually releases plaintext and then rolls back the claim. That limitation is now explicit. [token_session.rb:302](lib/api_keys/helpers/token_session.rb:302), [token_session_test.rb:484](test/helpers/token_session_test.rb:484), [token_session_test.rb:514](test/helpers/token_session_test.rb:514), [README.md:517](README.md:517)
10
+
11
+ ## Should fix
12
+
13
+ - **Non-raising cache-write failures: closed.** Suppression requires both `written == false` and a present reservation. Nil, absent entries and exceptions enqueue. The added tests exercise nil writes, false writes without storage, and failing existence checks through both successful and refused authentication. Existing burst tests exercise genuine suppression before workers run. [authentication.rb:243](lib/api_keys/authentication.rb:243), [refusal_stats_test.rb:239](test/refusal_stats_test.rb:239), [refusal_stats_test.rb:303](test/refusal_stats_test.rb:303)
14
+
15
+ - **False enqueue results: closed with a working cache.** Both paths release reservations on false results and exceptions. The four-call expectation genuinely requires another attempt for each kind after its first false result. Cleanup remains best effort: a failed cache deletion can retain the reservation until expiry, because false deletion results are ignored and exceptions swallowed. The enqueue failure itself is logged. [authentication.rb:162](lib/api_keys/authentication.rb:162), [authentication.rb:198](lib/api_keys/authentication.rb:198), [authentication.rb:262](lib/api_keys/authentication.rb:262), [refusal_stats_test.rb:359](test/refusal_stats_test.rb:359)
16
+
17
+ - **Creation form: remains closed.** Server-side creation resolves the default type and replaces submitted ceiling scopes. The new test genuinely POSTs with **both type and scopes omitted**, then with omitted type and crafted scopes. [keys_controller.rb:79](app/controllers/api_keys/keys_controller.rb:79), [keys_controller_test.rb:826](test/controllers/keys_controller_test.rb:826)
18
+
19
+ - **Retrieval inspection: remains closed.** The test obtains plaintext successfully before checking string, inspection, nested inspection, pretty-print and JSON representations. [token_session_test.rb:782](test/helpers/token_session_test.rb:782)
20
+
21
+ - **Reconciliation eligibility: remains closed.** The new retyping test updates the database after selection and before `with_lock` reloads the stale object, then checks unchanged scopes and exclusion from the count. This is a meaningful controlled interleaving on one connection. [reconcile_scopes_test.rb:13](test/reconcile_scopes_test.rb:13), [reconcile_scopes_test.rb:62](test/reconcile_scopes_test.rb:62), [api_key.rb:650](lib/api_keys/models/api_key.rb:650)
22
+
23
+ ## Nits
24
+
25
+ - **Solid Cache documentation: closed.** The stats section now distinguishes conditional writes from atomic absent-entry reservation and explicitly permits simultaneous extra jobs on Solid Cache/PostgreSQL. Cache failures still intentionally permit one job per request before timestamp persistence. [README.md:1162](README.md:1162), [CHANGELOG.md:26](CHANGELOG.md:26)
26
+
27
+ - **Transaction documentation: substantially closed.** README, SECURITY and CHANGELOG acknowledge host controller transactions and raw SQL boundaries; the filed disposition explicitly supersedes the earlier cache-dependent answer. One remaining inconsistency: “Any retrieval attempt uses the handoff up” contradicts the new retry-preserving status. Qualify it. [README.md:489](README.md:489), [SECURITY.md:49](SECURITY.md:49), [CHANGELOG.md:29](CHANGELOG.md:29), [review trace:36](docs/reviews/2026-10-18-0-6-0/01-astra-review-of-0-6-0.md:36)
28
+
29
+ - **Empty-scope wording: closed. Version bump: outstanding.** The ceiling exception remains explicit; the runtime version remains `0.5.1`. [README.md:890](README.md:890), [version.rb:4](lib/api_keys/version.rb:4)
30
+
31
+ ## Missing tests
32
+
33
+ - **SQLite races: closed; PostgreSQL/MySQL coverage remains open.** Both races now hold distinct autocommit connections before opening the barrier and require exactly one `:retrieved` plus seven `:already_shown` results. The NullStore race also checks committed state and later replay. [token_session_test.rb:46](test/helpers/token_session_test.rb:46), [token_session_test.rb:77](test/helpers/token_session_test.rb:77), [token_session_test.rb:309](test/helpers/token_session_test.rb:309)
34
+
35
+ - **Transaction boundaries: partially closed.** Separate-pool and raw-SQL scenarios are real. However, the “non-joinable inner transaction” joins the existing joinable transaction because it omits `requires_new: true`. Also, on the inspected Rails 8.1.4 source, the untouched outer transaction lets the `requires_new` case use `RestartParentTransaction`, so it does not establish a physical savepoint. Add a real non-joinable transaction and dirty the outer transaction before testing a savepoint. The implementation’s `transaction_open?` check itself covers these states. [token_session_test.rb:433](test/helpers/token_session_test.rb:433), [token_session_test.rb:461](test/helpers/token_session_test.rb:461), [token_session_test.rb:465](test/helpers/token_session_test.rb:465), [database_statements.rb:372](rails source), [transaction.rb:538](rails source)
36
+
37
+ - **Original burst/bcrypt gaps: remain closed. Browser type switching: still unexecuted.** The rendering test inspects markup/configuration and server-rendered defaults; it never executes the change listener. [refusal_stats_test.rb:192](test/refusal_stats_test.rb:192), [refusal_stats_test.rb:239](test/refusal_stats_test.rb:239), [keys_controller_test.rb:774](test/controllers/keys_controller_test.rb:774)
38
+
39
+ VERDICT: PASS
40
+
41
+ The round-2 release blockers are closed under the documented Active Record transaction boundary. I found no new blocking defect across the complete five-commit delta. The remaining documentation and coverage gaps are nonblocking; bump the version before publishing 0.6.0. This was a static review: no tests executed or repository changes made. Both requested `.cursor/rules` files remain absent.
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "active_support/cache"
3
4
  require "active_support/concern"
4
5
  require_relative "services/authenticator"
5
6
  require_relative "logging"
@@ -17,6 +18,9 @@ module ApiKeys
17
18
  # The key itself is fine, so these answer 403 rather than 401 — the same
18
19
  # distinction `:missing_scope` already makes.
19
20
  FORBIDDEN_ERROR_CODES = %i[origin_not_allowed ip_not_allowed restriction_misconfigured].freeze
21
+ # Names the per-key reservation that lets one stats job per kind be
22
+ # enqueued within a debounce interval (see reserve_stats_enqueue?).
23
+ STATS_ENQUEUE_NAMESPACE = "api_keys:v1:stats_enqueue"
20
24
 
21
25
  included do
22
26
  # Helper methods to access the authenticated key and its owner
@@ -78,6 +82,7 @@ module ApiKeys
78
82
  after_auth_context[:required_scope_check] = { required: scope, passed: false }
79
83
  after_auth_context[:success] = false
80
84
  after_auth_context[:error_code] = :missing_scope
85
+ refused_key = @current_api_key
81
86
  @current_api_key = nil
82
87
  render_unauthorized(
83
88
  error_code: :missing_scope,
@@ -85,6 +90,7 @@ module ApiKeys
85
90
  status: :forbidden,
86
91
  required_scope: scope
87
92
  )
93
+ record_key_refusal(refused_key, :missing_scope)
88
94
  else
89
95
  after_auth_context[:required_scope_check] = { required: scope, passed: true } if scope
90
96
  # Authentication and scope check successful, enqueue stats update
@@ -95,6 +101,8 @@ module ApiKeys
95
101
  log_debug "[ApiKeys Auth] Authentication failed. Error: #{result.error_code}, Message: #{result.message}"
96
102
  status = FORBIDDEN_ERROR_CODES.include?(result.error_code) ? :forbidden : :unauthorized
97
103
  render_unauthorized(error_code: result.error_code, message: result.message, status: status)
104
+ # Only identified refusals carry a key; lookup failures have none to stamp.
105
+ record_key_refusal(result.api_key, result.error_code) if result.api_key
98
106
  end
99
107
 
100
108
  # Enqueue after_authentication callback asynchronously regardless of success/failure
@@ -129,10 +137,14 @@ module ApiKeys
129
137
  return unless ApiKeys.configuration.enable_async_operations
130
138
  return unless current_api_key
131
139
 
132
- if stats_update_debounced?
140
+ if stats_update_debounced?(current_api_key.last_used_at)
133
141
  log_debug "[ApiKeys Auth] Skipping a recently recorded last-used update for ApiKey ID: #{current_api_key.id}"
134
142
  return
135
143
  end
144
+ unless reserve_stats_enqueue?(:used, current_api_key.id)
145
+ log_debug "[ApiKeys Auth] Skipping a last-used update already enqueued for ApiKey ID: #{current_api_key.id}"
146
+ return
147
+ end
136
148
 
137
149
  # Check ActiveJob configuration and warn if using suboptimal adapters
138
150
  adapter = ActiveJob::Base.queue_adapter
@@ -145,22 +157,126 @@ module ApiKeys
145
157
  begin
146
158
  timestamp = Time.current # Capture time once for the job
147
159
  log_debug "[ApiKeys Auth] Enqueuing UpdateStatsJob for ApiKey ID: #{current_api_key.id} at #{timestamp}"
148
- ApiKeys::Jobs::UpdateStatsJob.perform_later(current_api_key.id, timestamp)
160
+ # Active Job answers false (instead of raising) when the adapter
161
+ # failed to enqueue; either way the reservation is freed.
162
+ unless ApiKeys::Jobs::UpdateStatsJob.perform_later(current_api_key.id, timestamp)
163
+ release_stats_enqueue(:used, current_api_key.id)
164
+ log_error "[ApiKeys Auth] UpdateStatsJob was not enqueued for key #{current_api_key.id}."
165
+ end
149
166
  rescue StandardError => error
167
+ release_stats_enqueue(:used, current_api_key.id)
150
168
  log_error "[ApiKeys Auth] Failed to enqueue UpdateStatsJob for key #{current_api_key.id} (#{error.class})."
151
169
  end
152
170
  end
153
171
 
154
- def stats_update_debounced?
172
+ # Enqueues the refusal half of UpdateStatsJob for a key that presented its
173
+ # real token and was refused by a policy. It moves only last_refused_at,
174
+ # last_refusal_code and (with track_requests_count) refusals_count; a
175
+ # refusal never counts as a use. Debounced exactly like last_used_at, so a
176
+ # client hammering a refused key costs no more writes than a working one.
177
+ # Never raises: the refusal response has already been rendered.
178
+ def record_key_refusal(api_key, error_code)
179
+ return unless ApiKeys.configuration.enable_async_operations
180
+ return unless api_key&.id
181
+
182
+ code = error_code.to_s
183
+ return unless ApiKeys::ApiKey::REFUSAL_CODES.include?(code)
184
+ return unless ApiKeys::ApiKey.refusal_stats_columns?
185
+
186
+ if stats_update_debounced?(api_key.last_refused_at)
187
+ log_debug "[ApiKeys Auth] Skipping a recently recorded refusal update for ApiKey ID: #{api_key.id}"
188
+ return
189
+ end
190
+ unless reserve_stats_enqueue?(:refused, api_key.id)
191
+ log_debug "[ApiKeys Auth] Skipping a refusal update already enqueued for ApiKey ID: #{api_key.id}"
192
+ return
193
+ end
194
+
195
+ begin
196
+ # Active Job answers false (instead of raising) when the adapter
197
+ # failed to enqueue; either way the reservation is freed.
198
+ unless ApiKeys::Jobs::UpdateStatsJob.perform_later(api_key.id, Time.current, refusal_code: code)
199
+ release_stats_enqueue(:refused, api_key.id)
200
+ log_error "[ApiKeys Auth] A refusal stats update was not enqueued for key #{api_key.id}."
201
+ end
202
+ rescue StandardError
203
+ release_stats_enqueue(:refused, api_key.id)
204
+ raise
205
+ end
206
+ rescue StandardError => error
207
+ log_error "[ApiKeys Auth] Failed to enqueue a refusal stats update for key #{api_key&.id} (#{error.class})."
208
+ end
209
+
210
+ def stats_update_debounced?(last_recorded_at)
211
+ seconds = stats_debounce_seconds
212
+ return false unless seconds && last_recorded_at
213
+
214
+ last_recorded_at >= Time.current - seconds
215
+ end
216
+
217
+ # The debounce interval in seconds, or nil when every request must be
218
+ # recorded (exact counting with track_requests_count, or no interval).
219
+ def stats_debounce_seconds
155
220
  config = ApiKeys.configuration
156
- return false if config.track_requests_count
221
+ return nil if config.track_requests_count
157
222
 
158
223
  interval = config.stats_update_interval
159
224
  seconds = interval.to_f if interval.respond_to?(:to_f)
160
- return false unless seconds&.finite? && seconds.positive?
161
- return false unless current_api_key.last_used_at
225
+ seconds&.finite? && seconds.positive? ? seconds : nil
226
+ end
227
+
228
+ # The persisted timestamp only debounces once a worker has written it, so
229
+ # a burst of requests arriving before that would each enqueue a job. With
230
+ # a shared cache, the first request in an interval reserves the enqueue
231
+ # for its key and kind (:used or :refused) atomically, with
232
+ # `write(..., unless_exist: true, expires_in: interval)`, and the rest of
233
+ # the burst is skipped.
234
+ #
235
+ # A request is skipped only when the reservation is confirmed to exist:
236
+ # the write answered false (not stored) and the key is then present.
237
+ # Stores also answer false or nil when the write itself failed (Redis
238
+ # returns nil on a handled error, Memcached false), and suppressing the
239
+ # job then would lose statistics silently. So a nil answer, an absent
240
+ # key, or any error enqueues. It also enqueues when debouncing does not
241
+ # apply or there is no shared cache (`NullStore`, or no Rails.cache):
242
+ # the persisted timestamp is then the only debounce, as before 0.6.0.
243
+ def reserve_stats_enqueue?(kind, api_key_id)
244
+ seconds = stats_debounce_seconds
245
+ return true unless seconds
246
+
247
+ cache = stats_enqueue_cache
248
+ return true unless cache
249
+
250
+ key = stats_enqueue_key(kind, api_key_id)
251
+ written = cache.write(key, true, unless_exist: true, expires_in: seconds)
252
+ return true unless written == false
253
+
254
+ cache.exist?(key) ? false : true
255
+ rescue StandardError => error
256
+ log_debug "[ApiKeys Auth] Stats enqueue reservation unavailable (#{error.class}); relying on the persisted timestamp."
257
+ true
258
+ end
259
+
260
+ # Frees a reservation whose job could not be enqueued, so the next
261
+ # request can try again.
262
+ def release_stats_enqueue(kind, api_key_id)
263
+ return unless stats_debounce_seconds
264
+
265
+ stats_enqueue_cache&.delete(stats_enqueue_key(kind, api_key_id))
266
+ rescue StandardError
267
+ nil
268
+ end
269
+
270
+ def stats_enqueue_key(kind, api_key_id)
271
+ "#{STATS_ENQUEUE_NAMESPACE}:#{kind}:#{api_key_id}"
272
+ end
273
+
274
+ # A cache every process shares can deduplicate; NullStore remembers nothing.
275
+ def stats_enqueue_cache
276
+ cache = Rails.cache if defined?(Rails) && Rails.respond_to?(:cache)
277
+ return nil if cache.nil? || cache.is_a?(ActiveSupport::Cache::NullStore)
162
278
 
163
- current_api_key.last_used_at >= Time.current - seconds
279
+ cache
164
280
  end
165
281
 
166
282
  # Helper to safely enqueue callback jobs.
@@ -46,12 +46,17 @@ module ApiKeys
46
46
  #
47
47
  # @!attribute [rw] client_ip_resolver
48
48
  # @return [#call] Callable receiving the request and returning the client
49
- # IP address used to evaluate a key's `allowed_ips` list. The default
50
- # honors Rails' trusted-proxy handling via `request.remote_ip`; behind a
51
- # CDN, configure `config.action_dispatch.trusted_proxies` whenever
52
- # possible. A resolver that trusts a vendor header is safe only when
53
- # network ingress rejects requests that bypass that vendor.
54
- # @example
49
+ # IP address used to evaluate a key's `allowed_ips` list. The default,
50
+ # `request.remote_ip`, is right only behind a proxy that is the sole way
51
+ # in, rewrites `X-Forwarded-For`, and is covered by
52
+ # `config.action_dispatch.trusted_proxies`. When the Rails server is
53
+ # directly reachable, a client can forge `X-Forwarded-For`; resolve the
54
+ # socket peer with `request.remote_addr` instead. A resolver that trusts
55
+ # a vendor header is safe only when network ingress rejects requests
56
+ # that bypass that vendor.
57
+ # @example Directly reachable server
58
+ # config.client_ip_resolver = ->(request) { request.remote_addr }
59
+ # @example Behind Cloudflare, with direct origin access blocked
55
60
  # config.client_ip_resolver = ->(request) { request.headers.fetch("CF-Connecting-IP") }
56
61
  attr_reader :client_ip_resolver
57
62
 
@@ -102,6 +107,13 @@ module ApiKeys
102
107
  # - :restrictions [Array<Symbol>] Which request-restriction kinds keys of this
103
108
  # type may carry: any subset of [:origins, :ips]. Omitted means both are
104
109
  # allowed; `[]` forbids restrictions entirely for this type.
110
+ # - :scopes [Symbol] Where scope checks for keys of this type look.
111
+ # `:stored` (default) checks the scopes each key stored at creation,
112
+ # capped by the type's permissions. `:ceiling` makes the type's live
113
+ # `permissions` list the contract: every key of the type is authorized
114
+ # for exactly that list, so widening or narrowing it applies to existing
115
+ # keys immediately, and stored scopes become metadata. `:ceiling`
116
+ # requires a finite, non-empty permissions list (never `:all`).
105
117
  # @example
106
118
  # config.key_types = {
107
119
  # publishable: { prefix: "pk", permissions: %w[read], public: true, limit: 1,
@@ -142,6 +154,9 @@ module ApiKeys
142
154
  VALID_HASH_STRATEGIES = %i[sha256 bcrypt].freeze
143
155
  VALID_TOKEN_ALPHABETS = %i[base58 hex].freeze
144
156
  VALID_DASHBOARD_CONTENT_SECURITY_POLICIES = [:default, :strict, false, nil].freeze
157
+ # Where a key type's scope checks look: the scopes each key stored at
158
+ # creation (default), or the type's live permission ceiling.
159
+ KEY_TYPE_SCOPE_SOURCES = %w[stored ceiling].freeze
145
160
  TOKEN_LENGTH_RANGE = (16..64)
146
161
  MAX_CONFIGURED_SCOPES = 100
147
162
  CONFIG_NAME_PATTERN = /\A[a-zA-Z0-9_-]{1,64}\z/
@@ -429,6 +444,7 @@ module ApiKeys
429
444
  end
430
445
 
431
446
  validate_restriction_kinds!(name, type_config[:restrictions]) if type_config.key?(:restrictions)
447
+ validate_scope_source!(name, type_config[:scopes], permissions) if type_config.key?(:scopes)
432
448
 
433
449
  next unless type_config[:public] == true
434
450
 
@@ -452,6 +468,21 @@ module ApiKeys
452
468
  "Key type '#{name}' restrictions must be an Array containing any of: #{ApiKeys::Restrictions::KIND_NAMES.join(', ')}"
453
469
  end
454
470
 
471
+ # `scopes: :ceiling` makes the type's permission list authoritative for every
472
+ # existing key of the type. Against `:all` (or an empty list) that would turn
473
+ # each key into an unbounded or useless credential, so only a finite,
474
+ # non-empty ceiling may be the contract.
475
+ def validate_scope_source!(name, source, permissions)
476
+ unless (source.is_a?(Symbol) || source.is_a?(String)) && KEY_TYPE_SCOPE_SOURCES.include?(source.to_s)
477
+ raise ArgumentError, "Key type '#{name}' scopes must be :stored or :ceiling"
478
+ end
479
+ return unless source.to_s == "ceiling"
480
+ return if permissions.is_a?(Array) && permissions.any?
481
+
482
+ raise ArgumentError,
483
+ "Key type '#{name}' uses scopes: :ceiling, so its permissions must be a finite, non-empty list (never :all)"
484
+ end
485
+
455
486
  def validate_config_name!(value, label)
456
487
  return if valid_config_name?(value)
457
488
 
@@ -21,6 +21,8 @@ module ApiKeys
21
21
  # <%= scope %>
22
22
  # </label>
23
23
  # <% end %>
24
+ # (The helper prepends an empty `api_key[scopes][]` sentinel so that
25
+ # clearing every box submits "no scopes" instead of nothing.)
24
26
  #
25
27
  module FormBuilderExtensions
26
28
  # Renders a select field for API key expiration presets.
@@ -62,6 +64,27 @@ module ApiKeys
62
64
  # - :none - no scopes checked
63
65
  # - Array - specific scopes to check
64
66
  # - nil - uses the object's current scopes
67
+ # @param include_hidden [Boolean, nil] With a block, whether to render an
68
+ # empty `name="api_key[scopes][]" value=""` sentinel before the boxes.
69
+ # Browsers omit unchecked boxes, so without it clearing every box submits
70
+ # no scopes parameter at all, and a create would silently get the default
71
+ # scopes while an update would keep the old ones. With the sentinel the
72
+ # server receives `[""]`, which `ApiKey#scopes=` and `create_api_key!`
73
+ # read as "no scopes". The submitted list replaces the key's scopes, so
74
+ # render a box for every scope the key may hold, or save it through
75
+ # `ApiKey#scopes_after_form_edit(offered:, checked:)`, which edits only
76
+ # the offered scopes and keeps the rest.
77
+ # - nil (default): render it when there is at least one box and the
78
+ # form's key enforces its scopes (`ApiKey#scopes_enforced?`). Elsewhere
79
+ # an empty list would mean "unrestricted" and clearing the boxes would
80
+ # widen the key. Per-owner default scopes are read through the key's
81
+ # `owner`, so build the form's key from its owner
82
+ # (`owner.api_keys.build`): a form with no object, or on a bare
83
+ # `ApiKey.new`, cannot see them and renders no sentinel.
84
+ # - true: render it whenever there is at least one box, without asking
85
+ # the key. Use it on those ownerless forms when an empty list grants
86
+ # nothing in your app.
87
+ # - false: never render it.
65
88
  # @return [String, Array] HTML if block given, otherwise array of scope data
66
89
  #
67
90
  # @example With block (recommended for custom styling)
@@ -72,27 +95,30 @@ module ApiKeys
72
95
  # </label>
73
96
  # <% end %>
74
97
  #
75
- # @example Simple rendering without block
98
+ # @example Simple rendering without block (render the sentinel yourself)
99
+ # <%= hidden_field_tag "api_key[scopes][]", "", id: nil %>
76
100
  # <% form.api_key_scopes_checkboxes(@scopes).each do |scope_data| %>
77
101
  # <%= check_box_tag "api_key[scopes][]", scope_data[:value], scope_data[:checked] %>
78
102
  # <%= scope_data[:value] %>
79
103
  # <% end %>
80
104
  #
81
- def api_key_scopes_checkboxes(scopes, checked: nil, &block)
105
+ def api_key_scopes_checkboxes(scopes, checked: nil, include_hidden: nil, &block)
82
106
  # Determine which scopes should be checked
83
107
  checked_scopes = resolve_checked_scopes(scopes, checked)
108
+ field_name = "#{object_name}[scopes][]"
84
109
 
85
110
  scope_data = scopes.map do |scope|
86
111
  {
87
112
  value: scope,
88
113
  checked: checked_scopes.include?(scope),
89
- field_name: "#{object_name}[scopes][]"
114
+ field_name: field_name
90
115
  }
91
116
  end
92
117
 
93
118
  if block_given?
94
119
  # Yield each scope for custom rendering
95
120
  safe_buffer = ActiveSupport::SafeBuffer.new
121
+ safe_buffer << @template.hidden_field_tag(field_name, "", id: nil) if render_scope_sentinel?(scopes, include_hidden)
96
122
  scope_data.each do |data|
97
123
  safe_buffer << @template.capture { yield(data[:value], data[:checked]) }
98
124
  end
@@ -135,6 +161,19 @@ module ApiKeys
135
161
 
136
162
  private
137
163
 
164
+ # The sentinel is only safe where an empty list grants nothing. With no
165
+ # boxes there is nothing to clear, so it is never rendered then. By
166
+ # default it also needs a key that enforces its scopes; where the key does
167
+ # not, an empty list would mean "unrestricted", so the parameter is left
168
+ # out and the key keeps the scopes it has. `include_hidden: true` skips
169
+ # that question for forms whose key cannot answer it.
170
+ def render_scope_sentinel?(scopes, include_hidden)
171
+ return false if include_hidden == false || scopes.empty?
172
+ return true if include_hidden == true
173
+
174
+ object.respond_to?(:scopes_enforced?) && object.scopes_enforced?
175
+ end
176
+
138
177
  def safe_key_type(value)
139
178
  case value.to_s
140
179
  when "publishable" then :publishable