api_keys 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -33,7 +33,11 @@
33
33
  <% if config[:permissions] != :all %>
34
34
  <small class="api-keys-form-help">
35
35
  <strong><%= type.to_s.humanize %>:</strong>
36
- Limited to <%= config[:permissions]&.join(", ") || "no" %> permissions
36
+ <% if ApiKeys::ApiKey.scopes_follow_ceiling_for?(config) %>
37
+ Always has exactly the <%= config[:permissions].join(", ") %> permissions
38
+ <% else %>
39
+ Limited to <%= config[:permissions]&.join(", ") || "no" %> permissions
40
+ <% end %>
37
41
  <% unless config.fetch(:revocable, true) %>
38
42
  <em>(cannot be revoked)</em>
39
43
  <% end %>
@@ -65,10 +69,13 @@
65
69
  </div>
66
70
 
67
71
  <%# The offered scopes are the owner's default scopes. The controller reads %>
68
- <%# the same list (dashboard_scope_choices) when it saves the form. %>
72
+ <%# the same list (dashboard_scope_choices) when it saves the form. A key type %>
73
+ <%# with `scopes: :ceiling` has no scopes to choose: the script below hides and %>
74
+ <%# disables these boxes and shows that type's live permissions instead, and %>
75
+ <%# the controller ignores scopes submitted for it. %>
69
76
  <% @available_scopes = dashboard_scope_choices %>
70
77
  <% if @available_scopes.present? %>
71
- <div>
78
+ <%= tag.div data: { api_keys_scope_choices: "" }, hidden: api_key.scopes_follow_ceiling? do %>
72
79
  <%= form.label :scopes, "Permissions" %>
73
80
  <%# Browsers omit unchecked boxes, so clearing every box would send no scopes %>
74
81
  <%# parameter at all and the key would silently get the defaults. This empty %>
@@ -91,7 +98,21 @@
91
98
  <%= form.label "scopes_#{scope.parameterize}", class: "form-check-label" do %><code><%= scope %></code><% end %>
92
99
  </div>
93
100
  <% end %>
94
- </div>
101
+ <% end %>
102
+ <% end %>
103
+
104
+ <% ApiKeys.configuration.key_types.to_h.each do |type, config| %>
105
+ <% next unless ApiKeys::ApiKey.scopes_follow_ceiling_for?(config) %>
106
+ <%= tag.div class: "api-keys-ceiling-scopes", data: { api_keys_ceiling_contract: type.to_s },
107
+ hidden: api_key.key_type.to_s != type.to_s do %>
108
+ <p><strong>Permissions</strong></p>
109
+ <% config[:permissions].each do |scope| %>
110
+ <kbd class="tag is-small"><%= scope %></kbd>
111
+ <% end %>
112
+ <small class="api-keys-form-help">
113
+ <%= type.to_s.humanize %> keys always have exactly these permissions, and follow the key type when it changes.
114
+ </small>
115
+ <% end %>
95
116
  <% end %>
96
117
 
97
118
  <%= render "api_keys/keys/restriction_fields", form: form, api_key: api_key %>
@@ -105,18 +126,31 @@
105
126
  <%= form.text_field :name %>
106
127
  </div>
107
128
 
108
- <%# The edit form offers the owner's default scopes and edits only those: %>
109
- <%# saving keeps every other scope the key holds, except scopes its type no %>
110
- <%# longer allows (see the controller's scopes_after_editing_offered_choices). %>
129
+ <%# A `scopes: :ceiling` key is authorized by its type's live permissions and %>
130
+ <%# its stored scopes are metadata, so the form shows the live ceiling and %>
131
+ <%# offers nothing to edit; the controller ignores scopes submitted for it. %>
132
+ <%# Any other key gets the owner's default scopes as boxes, and a save edits %>
133
+ <%# only those: it keeps every other scope the key holds, except scopes its %>
134
+ <%# type no longer allows (ApiKey#scopes_after_form_edit). %>
111
135
  <% @available_scopes = dashboard_scope_choices %>
112
- <% dropped_scopes = Array(api_key.scopes).reject { |scope| api_key.scope_within_ceiling?(scope) } %>
136
+ <% dropped_scopes = api_key.scopes_follow_ceiling? ? [] : Array(api_key.scopes).reject { |scope| api_key.scope_within_ceiling?(scope) } %>
113
137
  <% if dropped_scopes.any? %>
114
138
  <small class="api-keys-form-help">
115
139
  <%= safe_join(dropped_scopes.map { |scope| content_tag(:code, scope) }, ", ") %>
116
140
  <%= dropped_scopes.one? ? "is" : "are" %> no longer allowed for this key and will be removed when you save.
117
141
  </small>
118
142
  <% end %>
119
- <% if @available_scopes.present? %>
143
+ <% if api_key.scopes_follow_ceiling? %>
144
+ <div class="api-keys-ceiling-scopes">
145
+ <p><strong>Permissions / Scopes</strong></p>
146
+ <% api_keys_displayed_scopes(api_key).each do |scope| %>
147
+ <kbd class="tag is-small"><%= scope %></kbd>
148
+ <% end %>
149
+ <small class="api-keys-form-help">
150
+ Permissions follow the <%= api_key.key_type.humanize.downcase %> key type and update automatically when it changes.
151
+ </small>
152
+ </div>
153
+ <% elsif @available_scopes.present? %>
120
154
  <div>
121
155
  <%= form.label :scopes, "Permissions / Scopes" %>
122
156
  <%# Empty sentinel: clearing every box must remove every offered scope, not keep the old ones. %>
@@ -165,7 +199,8 @@
165
199
  <% key_type_policies = ApiKeys.configuration.key_types.to_h do |type, config| %>
166
200
  <% [type.to_s, {
167
201
  restrictions: ApiKeys::ApiKey.restriction_kinds_for(config).map(&:to_s),
168
- expirable: ApiKeys::ApiKey.revocable_for(config)
202
+ expirable: ApiKeys::ApiKey.revocable_for(config),
203
+ ceiling: ApiKeys::ApiKey.scopes_follow_ceiling_for?(config)
169
204
  }] %>
170
205
  <% end %>
171
206
  <script nonce="<%= content_security_policy_nonce %>">
@@ -189,6 +224,17 @@
189
224
  container.hidden = !enabled;
190
225
  container.querySelectorAll("input, select, textarea").forEach((input) => { input.disabled = !enabled; });
191
226
  });
227
+
228
+ // A scopes: :ceiling type has no scopes to choose: hide and disable the
229
+ // boxes (so none are submitted) and show that type's live permissions.
230
+ const ceiling = Boolean(policy && policy.ceiling);
231
+ form.querySelectorAll("[data-api-keys-scope-choices]").forEach((container) => {
232
+ container.hidden = ceiling;
233
+ container.querySelectorAll("input, select, textarea").forEach((input) => { input.disabled = ceiling; });
234
+ });
235
+ form.querySelectorAll("[data-api-keys-ceiling-contract]").forEach((container) => {
236
+ container.hidden = !(ceiling && container.dataset.apiKeysCeilingContract === keyType.value);
237
+ });
192
238
  };
193
239
 
194
240
  keyType.addEventListener("change", updatePolicyFields);
@@ -40,11 +40,13 @@
40
40
  <% else %>
41
41
  <em>Never used</em>
42
42
  <% end %>
43
+ <%= render partial: 'api_keys/keys/refusal_notice', locals: { key: key, compact: true } %>
43
44
  </td>
44
45
 
45
46
  <td class="api-keys-cell-scopes">
46
- <% if key.scopes.present? %>
47
- <% key.scopes.each do |scope| %>
47
+ <% displayed_scopes = api_keys_displayed_scopes(key) %>
48
+ <% if displayed_scopes.present? %>
49
+ <% displayed_scopes.each do |scope| %>
48
50
  <kbd class="api-keys-scope-tag tag is-small"><%= scope %></kbd>
49
51
  <% end %>
50
52
  <% else %>
@@ -0,0 +1,24 @@
1
+ <%# Refused-but-identified traffic for one key: requests that presented this key's %>
2
+ <%# real token and were refused by a policy. Renders nothing until one is recorded, %>
3
+ <%# and nothing on installations without the refusal stats columns. %>
4
+ <%# Locals: key (required), compact (optional, for the key list) %>
5
+
6
+ <% refused_at = key.last_refused_at %>
7
+ <% if refused_at %>
8
+ <% reason = api_key_refusal_reason(key) %>
9
+ <% still_refused = key.last_used_at.nil? || refused_at > key.last_used_at %>
10
+ <% if local_assigns[:compact] %>
11
+ <small class="api-keys-refusal-text" title="<%= refused_at.utc.strftime('%Y-%m-%d %H:%M:%S UTC') %>">
12
+ Refused <%= time_ago_in_words(refused_at) %> ago: <%= reason %>
13
+ </small>
14
+ <% else %>
15
+ <p class="api-keys-refusal-notice" role="status">
16
+ <strong><%= still_refused ? "Requests are arriving but refused" : "Some requests were refused" %>:</strong>
17
+ last at <time datetime="<%= refused_at.utc.iso8601 %>"><%= refused_at.utc.strftime("%Y-%m-%d %H:%M:%S UTC") %></time>
18
+ (<%= time_ago_in_words(refused_at) %> ago), because <%= reason %>.
19
+ <% if ApiKeys.configuration.track_requests_count && key.refusals_count.positive? %>
20
+ <%= pluralize(key.refusals_count, "refusal") %> recorded so far.
21
+ <% end %>
22
+ </p>
23
+ <% end %>
24
+ <% end %>
@@ -35,16 +35,13 @@
35
35
  </div>
36
36
 
37
37
 
38
- <% if api_key.scopes.present? %>
38
+ <% displayed_scopes = api_keys_displayed_scopes(api_key) %>
39
+ <% if displayed_scopes.present? %>
39
40
  <div class="api-keys-token-scopes">
40
41
  <p><strong>Permissions</strong></p>
41
42
 
42
- <% if api_key.scopes.present? %>
43
- <% api_key.scopes.each do |scope| %>
44
- <kbd class="tag is-small"><%= scope %></kbd>
45
- <% end %>
46
- <% else %>
47
- &mdash;
43
+ <% displayed_scopes.each do |scope| %>
44
+ <kbd class="tag is-small"><%= scope %></kbd>
48
45
  <% end %>
49
46
  </div>
50
47
  <% end %>
@@ -1,5 +1,7 @@
1
1
  <div class="col-5">
2
2
  <h2>Edit API Key: <%= @api_key.name.presence || @api_key.masked_token %></h2>
3
3
 
4
+ <%= render 'refusal_notice', key: @api_key %>
5
+
4
6
  <%= render 'form', api_key: @api_key %>
5
7
  </div>
@@ -198,6 +198,12 @@
198
198
  [hidden] { display: none !important; }
199
199
  .api-keys-form-errors { color: var(--api-keys-danger-color); }
200
200
  .api-keys-form-help { display: block; color: var(--color-darkGrey, #666); }
201
+ .api-keys-refusal-notice {
202
+ padding: 0.8em 1em;
203
+ border-left: 4px solid var(--api-keys-warning-color);
204
+ background-color: var(--bg-secondary-color, #f9f9f9);
205
+ }
206
+ .api-keys-refusal-text { display: block; color: var(--api-keys-danger-color); }
201
207
  .api-keys-empty-state { text-align: center; padding: 2em; }
202
208
  .api-keys-expired-text { color: var(--api-keys-status-expired-color); }
203
209
  .api-keys-token-panel { padding: 1em; margin: 1em 0; border-radius: 4px; }
@@ -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.
@@ -107,6 +107,13 @@ module ApiKeys
107
107
  # - :restrictions [Array<Symbol>] Which request-restriction kinds keys of this
108
108
  # type may carry: any subset of [:origins, :ips]. Omitted means both are
109
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`).
110
117
  # @example
111
118
  # config.key_types = {
112
119
  # publishable: { prefix: "pk", permissions: %w[read], public: true, limit: 1,
@@ -147,6 +154,9 @@ module ApiKeys
147
154
  VALID_HASH_STRATEGIES = %i[sha256 bcrypt].freeze
148
155
  VALID_TOKEN_ALPHABETS = %i[base58 hex].freeze
149
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
150
160
  TOKEN_LENGTH_RANGE = (16..64)
151
161
  MAX_CONFIGURED_SCOPES = 100
152
162
  CONFIG_NAME_PATTERN = /\A[a-zA-Z0-9_-]{1,64}\z/
@@ -434,6 +444,7 @@ module ApiKeys
434
444
  end
435
445
 
436
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)
437
448
 
438
449
  next unless type_config[:public] == true
439
450
 
@@ -457,6 +468,21 @@ module ApiKeys
457
468
  "Key type '#{name}' restrictions must be an Array containing any of: #{ApiKeys::Restrictions::KIND_NAMES.join(', ')}"
458
469
  end
459
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
+
460
486
  def validate_config_name!(value, label)
461
487
  return if valid_config_name?(value)
462
488
 
@@ -71,7 +71,9 @@ module ApiKeys
71
71
  # scopes while an update would keep the old ones. With the sentinel the
72
72
  # server receives `[""]`, which `ApiKey#scopes=` and `create_api_key!`
73
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.
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.
75
77
  # - nil (default): render it when there is at least one box and the
76
78
  # form's key enforces its scopes (`ApiKey#scopes_enforced?`). Elsewhere
77
79
  # an empty list would mean "unrestricted" and clearing the boxes would