api_keys 0.5.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +69 -0
- data/README.md +402 -13
- 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 +174 -7
- data/lib/api_keys/configuration.rb +82 -0
- data/lib/api_keys/device_flow.rb +146 -0
- data/lib/api_keys/engine.rb +9 -0
- data/lib/api_keys/errors.rb +11 -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 +34 -0
- data/lib/api_keys/idempotency.rb +155 -0
- data/lib/api_keys/jobs/update_stats_job.rb +45 -1
- data/lib/api_keys/models/api_key.rb +261 -3
- data/lib/api_keys/models/concerns/has_api_keys.rb +9 -1
- data/lib/api_keys/models/device_authorization.rb +333 -0
- data/lib/api_keys/models/idempotency_key.rb +184 -0
- data/lib/api_keys/version.rb +1 -1
- data/lib/api_keys.rb +13 -0
- data/lib/generators/api_keys/add_created_by_generator.rb +50 -0
- data/lib/generators/api_keys/add_device_authorizations_generator.rb +51 -0
- data/lib/generators/api_keys/add_idempotency_keys_generator.rb +52 -0
- data/lib/generators/api_keys/add_key_activity_generator.rb +59 -0
- data/lib/generators/api_keys/templates/add_created_by_to_api_keys.rb.erb +33 -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_device_authorizations.rb.erb +70 -0
- data/lib/generators/api_keys/templates/create_api_keys_idempotency_keys.rb.erb +52 -0
- data/lib/generators/api_keys/templates/create_api_keys_table.rb.erb +16 -0
- data/lib/generators/api_keys/templates/initializer.rb +35 -0
- metadata +17 -1
|
@@ -3,9 +3,13 @@
|
|
|
3
3
|
module ApiKeys
|
|
4
4
|
# Controller for managing API keys belonging to the current owner.
|
|
5
5
|
class KeysController < ApplicationController
|
|
6
|
+
ALREADY_SHOWN_ALERT = "This API key's token was already shown and cannot be shown again. " \
|
|
7
|
+
"If you did not save it, revoke this key and create a new one."
|
|
8
|
+
|
|
6
9
|
before_action :set_api_key, only: [:show, :edit, :update, :revoke]
|
|
10
|
+
helper ApiKeys::Helpers::ViewHelpers
|
|
7
11
|
helper_method :key_types_feature_enabled?, :api_keys_allowed_restriction_kinds,
|
|
8
|
-
:dashboard_scope_choices, :restriction_field_text
|
|
12
|
+
:dashboard_scope_choices, :restriction_field_text, :api_keys_displayed_scopes
|
|
9
13
|
|
|
10
14
|
# GET /keys
|
|
11
15
|
def index
|
|
@@ -36,15 +40,24 @@ module ApiKeys
|
|
|
36
40
|
# Shows the newly generated key's plaintext token ONCE.
|
|
37
41
|
# This is not a standard show action, it's used transiently after creation.
|
|
38
42
|
def show
|
|
39
|
-
# Key is set by set_api_key
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
#
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
43
|
+
# Key is set by set_api_key. The plaintext token comes from the one-time
|
|
44
|
+
# session handoff written by create; it is released once, ever. This
|
|
45
|
+
# action opens no database transaction, so the durable claim commits
|
|
46
|
+
# before the token is rendered; keep it that way. A retrieval inside a
|
|
47
|
+
# transaction (for example a host around_action wrapping every request
|
|
48
|
+
# in one) is refused and the handoff kept, see ApiKeys::TokenSession.
|
|
49
|
+
retrieval = ApiKeys::TokenSession.retrieve_once_with_status(session, api_key: @api_key)
|
|
50
|
+
@plaintext_token = retrieval.token
|
|
51
|
+
return if @plaintext_token
|
|
52
|
+
|
|
53
|
+
# A replayed copy of the session, or a refresh after the token page,
|
|
54
|
+
# gets an explicit answer instead of the generic one.
|
|
55
|
+
alert = if retrieval.already_shown? || @api_key.token_shown?
|
|
56
|
+
ALREADY_SHOWN_ALERT
|
|
57
|
+
else
|
|
58
|
+
"API key token can only be shown once immediately after creation."
|
|
59
|
+
end
|
|
60
|
+
redirect_to keys_path, alert: alert
|
|
48
61
|
end
|
|
49
62
|
|
|
50
63
|
# GET /keys/new
|
|
@@ -59,12 +72,23 @@ module ApiKeys
|
|
|
59
72
|
# re-checking every scope the owner had cleared.
|
|
60
73
|
@submitted_scopes = submitted_params[:scopes]
|
|
61
74
|
|
|
75
|
+
# A `scopes: :ceiling` type is authorized by its live permissions, so
|
|
76
|
+
# the form offers no scopes for it and any submitted (cleared boxes, or
|
|
77
|
+
# a crafted request) are ignored. The key stores the type's current
|
|
78
|
+
# ceiling as its metadata.
|
|
79
|
+
type_config = requested_key_type_config(submitted_params[:key_type])
|
|
80
|
+
scopes = if ApiKeys::ApiKey.scopes_follow_ceiling_for?(type_config)
|
|
81
|
+
type_config[:permissions].map(&:to_s)
|
|
82
|
+
else
|
|
83
|
+
submitted_params[:scopes]
|
|
84
|
+
end
|
|
85
|
+
|
|
62
86
|
# Use the HasApiKeys helper method to create the key
|
|
63
87
|
begin
|
|
64
88
|
# create_api_key! now returns the ApiKey instance
|
|
65
89
|
@api_key = current_api_keys_owner.create_api_key!(
|
|
66
90
|
name: submitted_params[:name],
|
|
67
|
-
scopes:
|
|
91
|
+
scopes: scopes,
|
|
68
92
|
expires_at: parse_expiration(submitted_params[:expires_at_preset]),
|
|
69
93
|
key_type: submitted_params[:key_type].presence,
|
|
70
94
|
# The model normalizes these raw strings; no parser needed here.
|
|
@@ -105,12 +129,20 @@ module ApiKeys
|
|
|
105
129
|
# PATCH/PUT /keys/:id
|
|
106
130
|
def update
|
|
107
131
|
attributes = api_key_update_params
|
|
108
|
-
if
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
#
|
|
112
|
-
#
|
|
113
|
-
|
|
132
|
+
if @api_key.scopes_follow_ceiling?
|
|
133
|
+
# The type's live ceiling authorizes this key and its stored scopes are
|
|
134
|
+
# metadata. The form offers no scope boxes for it, so any submitted
|
|
135
|
+
# scopes (the sentinel, or a crafted request) are ignored, and held
|
|
136
|
+
# scopes outside a narrowed ceiling are kept: the model lets such a key
|
|
137
|
+
# save as long as its scopes are not written.
|
|
138
|
+
attributes.delete(:scopes)
|
|
139
|
+
else
|
|
140
|
+
# The form speaks only for the scopes it offered (see
|
|
141
|
+
# ApiKey#scopes_after_form_edit). Without a scopes parameter (no boxes
|
|
142
|
+
# were shown) it still drops held scopes the current ceiling excludes,
|
|
143
|
+
# or no edit of such a key could ever be saved.
|
|
144
|
+
edited = @api_key.scopes_after_form_edit(offered: dashboard_scope_choices, checked: attributes[:scopes])
|
|
145
|
+
attributes[:scopes] = edited if attributes.key?(:scopes) || edited != Array(@api_key.scopes)
|
|
114
146
|
end
|
|
115
147
|
skip_untouched_restriction_fields!(attributes)
|
|
116
148
|
|
|
@@ -176,35 +208,6 @@ module ApiKeys
|
|
|
176
208
|
Array(current_api_keys_owner.class.api_keys_settings.dig(:default_scopes))
|
|
177
209
|
end
|
|
178
210
|
|
|
179
|
-
# The edit form can only speak for the scopes it showed. Checked choices
|
|
180
|
-
# are granted and unchecked ones removed; any other scope the key holds
|
|
181
|
-
# (for example one granted programmatically, or a type permission outside
|
|
182
|
-
# the owner's defaults) is kept, in its current order. A submitted value
|
|
183
|
-
# that was never offered is ignored.
|
|
184
|
-
#
|
|
185
|
-
# A held scope outside the key's current ceiling is dropped, whether
|
|
186
|
-
# offered or not: authentication already denies it, so no access changes,
|
|
187
|
-
# and keeping it would make every save of the key (a rename, or tightening
|
|
188
|
-
# a non-revocable key's origins) fail the ceiling validation. A newly
|
|
189
|
-
# checked scope outside the ceiling is kept, so validation reports it.
|
|
190
|
-
#
|
|
191
|
-
# @param submitted [Array<String>] Checked boxes, sentinel already stripped.
|
|
192
|
-
# @return [Array<String>]
|
|
193
|
-
def scopes_after_editing_offered_choices(submitted)
|
|
194
|
-
offered = dashboard_scope_choices
|
|
195
|
-
held = Array(@api_key.scopes)
|
|
196
|
-
kept = held_scopes_within_ceiling.select { |scope| !offered.include?(scope) || submitted.include?(scope) }
|
|
197
|
-
added = offered.select { |scope| submitted.include?(scope) && !held.include?(scope) }
|
|
198
|
-
kept + added
|
|
199
|
-
end
|
|
200
|
-
|
|
201
|
-
# The key's scopes without those its current ceiling excludes.
|
|
202
|
-
#
|
|
203
|
-
# @return [Array<String>]
|
|
204
|
-
def held_scopes_within_ceiling
|
|
205
|
-
Array(@api_key.scopes).select { |scope| @api_key.scope_within_ceiling?(scope) }
|
|
206
|
-
end
|
|
207
|
-
|
|
208
211
|
# The text the form puts in a restriction field. Usable entries appear as
|
|
209
212
|
# they are stored; anything else a stored policy holds (null, false, a
|
|
210
213
|
# number, a blank string) is shown as JSON, so the owner can see it.
|
|
@@ -295,6 +298,30 @@ module ApiKeys
|
|
|
295
298
|
end.uniq
|
|
296
299
|
end
|
|
297
300
|
|
|
301
|
+
# The permissions a key actually has, for display. A `scopes: :ceiling` key
|
|
302
|
+
# is authorized by its type's live ceiling, so showing the list it stored
|
|
303
|
+
# at creation would misstate what it can do.
|
|
304
|
+
#
|
|
305
|
+
# @param api_key [ApiKeys::ApiKey]
|
|
306
|
+
# @return [Array<String>]
|
|
307
|
+
def api_keys_displayed_scopes(api_key)
|
|
308
|
+
return Array(api_key.key_type_config[:permissions]).map(&:to_s) if api_key.scopes_follow_ceiling?
|
|
309
|
+
|
|
310
|
+
api_key.scopes.is_a?(Array) ? api_key.scopes : []
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# The configuration of the key type a create request resolves to, the way
|
|
314
|
+
# create_api_key! resolves it (the submitted type, else the default), or
|
|
315
|
+
# nil when there is none or it is unknown.
|
|
316
|
+
#
|
|
317
|
+
# @return [Hash, nil]
|
|
318
|
+
def requested_key_type_config(key_type_param)
|
|
319
|
+
type_name = key_type_param.presence || ApiKeys.configuration.default_key_type
|
|
320
|
+
return nil if type_name.blank?
|
|
321
|
+
|
|
322
|
+
(ApiKeys.configuration.key_types || {}).find { |name, _settings| name.to_s == type_name.to_s }&.last
|
|
323
|
+
end
|
|
324
|
+
|
|
298
325
|
# Check if key types feature is enabled
|
|
299
326
|
def key_types_feature_enabled?
|
|
300
327
|
ApiKeys.configuration.key_types.present? && ApiKeys.configuration.key_types.any?
|
|
@@ -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.
|