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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +37 -0
- data/README.md +126 -12
- data/SECURITY.md +7 -6
- data/app/controllers/api_keys/keys_controller.rb +73 -46
- data/app/views/api_keys/keys/_form.html.erb +56 -10
- data/app/views/api_keys/keys/_key_row.html.erb +4 -2
- data/app/views/api_keys/keys/_refusal_notice.html.erb +24 -0
- data/app/views/api_keys/keys/_show_token.html.erb +4 -7
- data/app/views/api_keys/keys/edit.html.erb +2 -0
- data/app/views/layouts/api_keys/application.html.erb +6 -0
- data/docs/reviews/2026-10-18-0-6-0/01-astra-review-of-0-6-0.md +36 -0
- data/docs/reviews/2026-10-18-0-6-0/02-astra-re-review-of-0-6-0.md +34 -0
- data/docs/reviews/2026-10-18-0-6-0/03-sol-re-check-of-0-6-0.md +41 -0
- data/lib/api_keys/authentication.rb +123 -7
- data/lib/api_keys/configuration.rb +26 -0
- data/lib/api_keys/form_builder_extensions.rb +3 -1
- data/lib/api_keys/helpers/token_session.rb +183 -23
- data/lib/api_keys/helpers/view_helpers.rb +33 -0
- data/lib/api_keys/jobs/update_stats_job.rb +45 -1
- data/lib/api_keys/models/api_key.rb +231 -0
- data/lib/api_keys/version.rb +1 -1
- data/lib/api_keys.rb +7 -0
- data/lib/generators/api_keys/add_key_activity_generator.rb +59 -0
- data/lib/generators/api_keys/templates/add_key_activity_to_api_keys.rb.erb +37 -0
- data/lib/generators/api_keys/templates/create_api_keys_table.rb.erb +12 -0
- data/lib/generators/api_keys/templates/initializer.rb +17 -0
- metadata +7 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
<%#
|
|
109
|
-
<%#
|
|
110
|
-
<%#
|
|
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
|
|
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
|
-
<%
|
|
47
|
-
|
|
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
|
-
<%
|
|
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
|
-
<%
|
|
43
|
-
|
|
44
|
-
<kbd class="tag is-small"><%= scope %></kbd>
|
|
45
|
-
<% end %>
|
|
46
|
-
<% else %>
|
|
47
|
-
—
|
|
43
|
+
<% displayed_scopes.each do |scope| %>
|
|
44
|
+
<kbd class="tag is-small"><%= scope %></kbd>
|
|
48
45
|
<% end %>
|
|
49
46
|
</div>
|
|
50
47
|
<% end %>
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
161
|
-
|
|
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
|
-
|
|
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
|