api_keys 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +54 -0
- data/README.md +158 -9
- data/SECURITY.md +22 -2
- data/app/controllers/api_keys/keys_controller.rb +136 -14
- data/app/views/api_keys/keys/_form.html.erb +85 -17
- 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/_restriction_fields.html.erb +4 -2
- 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/api_keys/security/best_practices.html.erb +1 -1
- data/app/views/layouts/api_keys/application.html.erb +6 -0
- data/docs/reviews/2026-10-18-0-5-1/01-astra-review-of-0-5-0.md +55 -0
- data/docs/reviews/2026-10-18-0-5-1/02-astra-re-review-of-0-5-1.md +36 -0
- data/docs/reviews/2026-10-18-0-5-1/03-opus-review-of-0-5-1.md +5 -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 +37 -6
- data/lib/api_keys/form_builder_extensions.rb +42 -3
- data/lib/api_keys/helpers/token_session.rb +257 -8
- 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 +293 -7
- data/lib/api_keys/models/concerns/has_api_keys.rb +10 -2
- data/lib/api_keys/restrictions.rb +75 -24
- data/lib/api_keys/services/authenticator.rb +24 -2
- 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/add_restrictions_generator.rb +4 -1
- 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 +32 -7
- metadata +10 -1
|
@@ -3,8 +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]
|
|
7
|
-
|
|
10
|
+
helper ApiKeys::Helpers::ViewHelpers
|
|
11
|
+
helper_method :key_types_feature_enabled?, :api_keys_allowed_restriction_kinds,
|
|
12
|
+
:dashboard_scope_choices, :restriction_field_text, :api_keys_displayed_scopes
|
|
8
13
|
|
|
9
14
|
# GET /keys
|
|
10
15
|
def index
|
|
@@ -35,15 +40,24 @@ module ApiKeys
|
|
|
35
40
|
# Shows the newly generated key's plaintext token ONCE.
|
|
36
41
|
# This is not a standard show action, it's used transiently after creation.
|
|
37
42
|
def show
|
|
38
|
-
# Key is set by set_api_key
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
47
61
|
end
|
|
48
62
|
|
|
49
63
|
# GET /keys/new
|
|
@@ -54,13 +68,27 @@ module ApiKeys
|
|
|
54
68
|
# POST /keys
|
|
55
69
|
def create
|
|
56
70
|
submitted_params = api_key_params
|
|
71
|
+
# If creation fails, the re-rendered form shows these instead of
|
|
72
|
+
# re-checking every scope the owner had cleared.
|
|
73
|
+
@submitted_scopes = submitted_params[:scopes]
|
|
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
|
|
57
85
|
|
|
58
86
|
# Use the HasApiKeys helper method to create the key
|
|
59
87
|
begin
|
|
60
88
|
# create_api_key! now returns the ApiKey instance
|
|
61
89
|
@api_key = current_api_keys_owner.create_api_key!(
|
|
62
90
|
name: submitted_params[:name],
|
|
63
|
-
scopes:
|
|
91
|
+
scopes: scopes,
|
|
64
92
|
expires_at: parse_expiration(submitted_params[:expires_at_preset]),
|
|
65
93
|
key_type: submitted_params[:key_type].presence,
|
|
66
94
|
# The model normalizes these raw strings; no parser needed here.
|
|
@@ -100,7 +128,25 @@ module ApiKeys
|
|
|
100
128
|
|
|
101
129
|
# PATCH/PUT /keys/:id
|
|
102
130
|
def update
|
|
103
|
-
|
|
131
|
+
attributes = api_key_update_params
|
|
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)
|
|
146
|
+
end
|
|
147
|
+
skip_untouched_restriction_fields!(attributes)
|
|
148
|
+
|
|
149
|
+
if @api_key.update(attributes)
|
|
104
150
|
redirect_to keys_path, notice: "API key updated successfully."
|
|
105
151
|
else
|
|
106
152
|
flash.now[:alert] = "Failed to update API key: #{@api_key.errors.full_messages.join(', ')}"
|
|
@@ -139,10 +185,62 @@ module ApiKeys
|
|
|
139
185
|
|
|
140
186
|
permitted_params = submitted.permit(:name, :expires_at_preset, :key_type,
|
|
141
187
|
:allowed_origins, :allowed_ips, scopes: [])
|
|
142
|
-
|
|
188
|
+
normalize_submitted_scopes!(permitted_params)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Each scope checkbox group renders an empty sentinel (`scopes[]=""`), so a
|
|
192
|
+
# submitted form always carries the parameter. Stripping blanks turns the
|
|
193
|
+
# sentinel alone into `[]`: the owner cleared every box and means "none",
|
|
194
|
+
# and the model's validations decide whether that is allowed. Only an
|
|
195
|
+
# absent parameter (`nil`) means "not specified": create_api_key! then
|
|
196
|
+
# applies the default scopes and update leaves the current ones alone.
|
|
197
|
+
def normalize_submitted_scopes!(permitted_params)
|
|
198
|
+
permitted_params[:scopes]&.reject!(&:blank?)
|
|
143
199
|
permitted_params
|
|
144
200
|
end
|
|
145
201
|
|
|
202
|
+
# The scopes the dashboard offers as checkboxes: the owner's configured
|
|
203
|
+
# default scopes. The form renders exactly this list, and an update edits
|
|
204
|
+
# only scopes in it.
|
|
205
|
+
#
|
|
206
|
+
# @return [Array<String>]
|
|
207
|
+
def dashboard_scope_choices
|
|
208
|
+
Array(current_api_keys_owner.class.api_keys_settings.dig(:default_scopes))
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# The text the form puts in a restriction field. Usable entries appear as
|
|
212
|
+
# they are stored; anything else a stored policy holds (null, false, a
|
|
213
|
+
# number, a blank string) is shown as JSON, so the owner can see it.
|
|
214
|
+
#
|
|
215
|
+
# @return [String]
|
|
216
|
+
def restriction_field_text(api_key, kind)
|
|
217
|
+
api_key.restrictions.public_send(kind).map do |entry|
|
|
218
|
+
if entry.is_a?(String) && entry.valid_encoding? && !entry.strip.empty?
|
|
219
|
+
entry
|
|
220
|
+
else
|
|
221
|
+
begin
|
|
222
|
+
JSON.generate(entry)
|
|
223
|
+
rescue StandardError
|
|
224
|
+
entry.inspect
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end.join(", ")
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# A restriction field submitted exactly as it was rendered was not
|
|
231
|
+
# touched, so its kind is left alone. Re-parsing the text would rewrite
|
|
232
|
+
# the stored list, and a malformed stored entry could come back as an
|
|
233
|
+
# empty (unrestricted) list or as a different, valid one. Left alone, a
|
|
234
|
+
# malformed stored policy keeps failing validation, so the save is refused
|
|
235
|
+
# until the owner corrects the field.
|
|
236
|
+
def skip_untouched_restriction_fields!(attributes)
|
|
237
|
+
{ allowed_origins: :origins, allowed_ips: :ips }.each do |field, kind|
|
|
238
|
+
next unless attributes.key?(field)
|
|
239
|
+
|
|
240
|
+
attributes.delete(field) if attributes[field] == restriction_field_text(@api_key, kind)
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
146
244
|
# Only allow updating name, scopes, and request restrictions.
|
|
147
245
|
# Restriction edits stay available on non-revocable keys so an owner can
|
|
148
246
|
# still tighten the policy even when lifecycle operations are disabled.
|
|
@@ -151,7 +249,7 @@ module ApiKeys
|
|
|
151
249
|
raise ActionController::ParameterMissing, :api_key unless submitted.respond_to?(:permit)
|
|
152
250
|
|
|
153
251
|
permitted_params = submitted.permit(:name, :allowed_origins, :allowed_ips, scopes: [])
|
|
154
|
-
|
|
252
|
+
normalize_submitted_scopes!(permitted_params)
|
|
155
253
|
permitted_params.delete(:allowed_origins) unless ApiKeys::ApiKey.restrictions_column?
|
|
156
254
|
permitted_params.delete(:allowed_ips) unless ApiKeys::ApiKey.restrictions_column?
|
|
157
255
|
permitted_params
|
|
@@ -200,6 +298,30 @@ module ApiKeys
|
|
|
200
298
|
end.uniq
|
|
201
299
|
end
|
|
202
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
|
+
|
|
203
325
|
# Check if key types feature is enabled
|
|
204
326
|
def key_types_feature_enabled?
|
|
205
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 %>
|
|
@@ -64,20 +68,29 @@
|
|
|
64
68
|
%>
|
|
65
69
|
</div>
|
|
66
70
|
|
|
67
|
-
<%#
|
|
68
|
-
<%#
|
|
69
|
-
<%#
|
|
70
|
-
<%#
|
|
71
|
-
|
|
72
|
-
<%
|
|
73
|
-
|
|
71
|
+
<%# The offered scopes are the owner's default scopes. The controller reads %>
|
|
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. %>
|
|
76
|
+
<% @available_scopes = dashboard_scope_choices %>
|
|
77
|
+
<% if @available_scopes.present? %>
|
|
78
|
+
<%= tag.div data: { api_keys_scope_choices: "" }, hidden: api_key.scopes_follow_ceiling? do %>
|
|
74
79
|
<%= form.label :scopes, "Permissions" %>
|
|
80
|
+
<%# Browsers omit unchecked boxes, so clearing every box would send no scopes %>
|
|
81
|
+
<%# parameter at all and the key would silently get the defaults. This empty %>
|
|
82
|
+
<%# sentinel makes "none" explicit; blank values are stripped server-side. %>
|
|
83
|
+
<%# (Scopes are configured here, so an empty list grants nothing.) %>
|
|
84
|
+
<%= hidden_field_tag "#{form.object_name}[scopes][]", "", id: nil if api_key.scopes_enforced? %>
|
|
85
|
+
<%# A fresh form starts with every scope checked; after a failed submission %>
|
|
86
|
+
<%# it shows exactly what was submitted, so a cleared box stays cleared. %>
|
|
87
|
+
<% submitted_scopes = @submitted_scopes %>
|
|
75
88
|
<% @available_scopes.each do |scope| %>
|
|
76
89
|
<div class="form-check">
|
|
77
90
|
<%= form.check_box :scopes,
|
|
78
91
|
{ multiple: true, # Submits as an array
|
|
79
92
|
class: "form-check-input",
|
|
80
|
-
checked:
|
|
93
|
+
checked: submitted_scopes.nil? || submitted_scopes.include?(scope),
|
|
81
94
|
id: "api_key_scopes_#{scope.parameterize}" }, # Unique ID
|
|
82
95
|
scope, # Value submitted when checked
|
|
83
96
|
nil # Value submitted when unchecked (not needed here)
|
|
@@ -85,7 +98,21 @@
|
|
|
85
98
|
<%= form.label "scopes_#{scope.parameterize}", class: "form-check-label" do %><code><%= scope %></code><% end %>
|
|
86
99
|
</div>
|
|
87
100
|
<% end %>
|
|
88
|
-
|
|
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 %>
|
|
89
116
|
<% end %>
|
|
90
117
|
|
|
91
118
|
<%= render "api_keys/keys/restriction_fields", form: form, api_key: api_key %>
|
|
@@ -99,19 +126,48 @@
|
|
|
99
126
|
<%= form.text_field :name %>
|
|
100
127
|
</div>
|
|
101
128
|
|
|
102
|
-
<%#
|
|
103
|
-
<%#
|
|
104
|
-
<%#
|
|
105
|
-
|
|
106
|
-
|
|
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). %>
|
|
135
|
+
<% @available_scopes = dashboard_scope_choices %>
|
|
136
|
+
<% dropped_scopes = api_key.scopes_follow_ceiling? ? [] : Array(api_key.scopes).reject { |scope| api_key.scope_within_ceiling?(scope) } %>
|
|
137
|
+
<% if dropped_scopes.any? %>
|
|
138
|
+
<small class="api-keys-form-help">
|
|
139
|
+
<%= safe_join(dropped_scopes.map { |scope| content_tag(:code, scope) }, ", ") %>
|
|
140
|
+
<%= dropped_scopes.one? ? "is" : "are" %> no longer allowed for this key and will be removed when you save.
|
|
141
|
+
</small>
|
|
142
|
+
<% end %>
|
|
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? %>
|
|
107
154
|
<div>
|
|
108
155
|
<%= form.label :scopes, "Permissions / Scopes" %>
|
|
156
|
+
<%# Empty sentinel: clearing every box must remove every offered scope, not keep the old ones. %>
|
|
157
|
+
<%= hidden_field_tag "#{form.object_name}[scopes][]", "", id: nil if api_key.scopes_enforced? %>
|
|
158
|
+
<% kept_scopes = Array(api_key.scopes) - @available_scopes - dropped_scopes %>
|
|
159
|
+
<% if kept_scopes.any? %>
|
|
160
|
+
<small class="api-keys-form-help">
|
|
161
|
+
This key also holds <%= safe_join(kept_scopes.map { |scope| content_tag(:code, scope) }, ", ") %>,
|
|
162
|
+
which this form does not change.
|
|
163
|
+
</small>
|
|
164
|
+
<% end %>
|
|
109
165
|
<% @available_scopes.each do |scope| %>
|
|
110
166
|
<div class="form-check">
|
|
111
167
|
<%= form.check_box :scopes,
|
|
112
168
|
{ multiple: true, # Submits as an array
|
|
113
169
|
class: "form-check-input",
|
|
114
|
-
checked: api_key.scopes&.include?(scope)
|
|
170
|
+
checked: api_key.scopes&.include?(scope) && !dropped_scopes.include?(scope),
|
|
115
171
|
id: "api_key_scopes_#{scope.parameterize}_edit" }, # Unique ID for edit form
|
|
116
172
|
scope, # Value submitted when checked
|
|
117
173
|
nil # Value submitted when unchecked (not needed here)
|
|
@@ -143,7 +199,8 @@
|
|
|
143
199
|
<% key_type_policies = ApiKeys.configuration.key_types.to_h do |type, config| %>
|
|
144
200
|
<% [type.to_s, {
|
|
145
201
|
restrictions: ApiKeys::ApiKey.restriction_kinds_for(config).map(&:to_s),
|
|
146
|
-
expirable: ApiKeys::ApiKey.revocable_for(config)
|
|
202
|
+
expirable: ApiKeys::ApiKey.revocable_for(config),
|
|
203
|
+
ceiling: ApiKeys::ApiKey.scopes_follow_ceiling_for?(config)
|
|
147
204
|
}] %>
|
|
148
205
|
<% end %>
|
|
149
206
|
<script nonce="<%= content_security_policy_nonce %>">
|
|
@@ -167,6 +224,17 @@
|
|
|
167
224
|
container.hidden = !enabled;
|
|
168
225
|
container.querySelectorAll("input, select, textarea").forEach((input) => { input.disabled = !enabled; });
|
|
169
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
|
+
});
|
|
170
238
|
};
|
|
171
239
|
|
|
172
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 %>
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
<%# Optional request restriction fields: where a key may be used from. %>
|
|
2
2
|
<%# Locals: form (required), api_key (required) %>
|
|
3
3
|
<%# Only rendered when the restrictions column exists and the key's type allows the kind. %>
|
|
4
|
+
<%# Values come from restriction_field_text: unusable stored entries (null, false, numbers, %>
|
|
5
|
+
<%# blanks) show as JSON, and an untouched field leaves the stored list as it is on save. %>
|
|
4
6
|
|
|
5
7
|
<% if ApiKeys::ApiKey.restrictions_column? %>
|
|
6
8
|
<% allowed_kinds = api_keys_allowed_restriction_kinds(api_key) %>
|
|
@@ -9,7 +11,7 @@
|
|
|
9
11
|
<div data-api-keys-restriction-kind="origins">
|
|
10
12
|
<%= form.label :allowed_origins, "Allowed web origins (optional)" %>
|
|
11
13
|
<%= form.text_field :allowed_origins,
|
|
12
|
-
value: api_key
|
|
14
|
+
value: restriction_field_text(api_key, :origins),
|
|
13
15
|
placeholder: "example.com, *.example.com" %>
|
|
14
16
|
<small class="api-keys-form-help">
|
|
15
17
|
Leave empty to allow any origin. Requests from a browser must come from one of these hosts.
|
|
@@ -22,7 +24,7 @@
|
|
|
22
24
|
<div data-api-keys-restriction-kind="ips">
|
|
23
25
|
<%= form.label :allowed_ips, "Allowed IP addresses (optional)" %>
|
|
24
26
|
<%= form.text_field :allowed_ips,
|
|
25
|
-
value: api_key
|
|
27
|
+
value: restriction_field_text(api_key, :ips),
|
|
26
28
|
placeholder: "203.0.113.7, 10.0.0.0/8" %>
|
|
27
29
|
<small class="api-keys-form-help">
|
|
28
30
|
Leave empty to allow any address. Accepts single IPv4/IPv6 addresses and CIDR ranges.
|
|
@@ -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 %>
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
<li><strong>Assume anyone can copy and abuse them</strong> from browser JavaScript, mobile apps, or public code</li>
|
|
25
25
|
<li><strong>Grant only public-safe operations</strong> and enforce authorization server-side for every permission</li>
|
|
26
26
|
<li><strong>Always visible</strong> — you can view the full key anytime in your dashboard</li>
|
|
27
|
-
<li><strong>
|
|
27
|
+
<li><strong>Revoke and replace a leaked key</strong> — publishable keys can be revoked like any other key, unless their key type is configured with <code>revocable: false</code>; keep an application-level disable/replacement procedure for those</li>
|
|
28
28
|
</ul>
|
|
29
29
|
</section>
|
|
30
30
|
<% 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,55 @@
|
|
|
1
|
+
# Adversarial security review of api_keys 0.5.0 (Codex Astra, 2026-10-18, at main ab6cc19)
|
|
2
|
+
|
|
3
|
+
Scope: `lib/`, the dashboard under `app/`, generators and migration templates, SECURITY.md and the README's security claims, read-only, against the threat model in the prompt (an unauthenticated client, a low-privilege or other-tenant key holder, a timing observer, a misconfigured host app). Verdict: BLOCK; every finding below was answered in 0.5.1 (PR #64), see 02 and 03.
|
|
4
|
+
|
|
5
|
+
Reviewed `ab6cc19` read-only, including all requested source files. Confirmed the principal edge cases with isolated, in-memory Ruby checks; did not run the full suite.
|
|
6
|
+
|
|
7
|
+
## Blocking
|
|
8
|
+
|
|
9
|
+
1. **IP restrictions are bypassable when Rails is directly reachable without a sanitizing proxy.** The default resolver trusts `request.remote_ip` ([configuration.rb:597](lib/api_keys/configuration.rb:597)), and authentication accepts its result ([authenticator.rb:479](lib/api_keys/services/authenticator.rb:479)).
|
|
10
|
+
|
|
11
|
+
**Attack:** Hold a valid key restricted to `203.0.113.7`; connect from a disallowed address; send `X-Forwarded-For: 203.0.113.7`. In the reproduction, adding that header changed authentication from `ip_not_allowed` to success. This is deployment-dependent and falls within the stated misconfiguration threat model. Rails explicitly documents this behavior in [RemoteIp](https://api.rubyonrails.org/classes/ActionDispatch/RemoteIp.html).
|
|
12
|
+
|
|
13
|
+
**Fix:** Document and test direct-server deployments explicitly. Without a proxy, resolve the socket peer or strip forwarding headers before Rails. Behind a proxy, prevent direct access and sanitize forwarding headers; configuring `trusted_proxies` alone does not establish that boundary.
|
|
14
|
+
|
|
15
|
+
2. **Clearing every dashboard scope silently preserves permissions.** Both checkbox groups suppress unchecked values ([form:77](app/views/api_keys/keys/_form.html.erb:77), [form:111](app/views/api_keys/keys/_form.html.erb:111)). Consequently, clearing everything submits no `scopes` parameter. Updating leaves existing scopes untouched ([keys_controller.rb:103](app/controllers/api_keys/keys_controller.rb:103)); creation substitutes default scopes ([has_api_keys.rb:283](lib/api_keys/models/concerns/has_api_keys.rb:283)).
|
|
16
|
+
|
|
17
|
+
**Attack:** A client holds a read-scoped key. Its owner unchecks every permission and saves successfully. The client continues accessing read-protected endpoints. Creating a supposedly permissionless key similarly grants defaults.
|
|
18
|
+
|
|
19
|
+
**Fix:** Submit an explicit empty-array sentinel and normalize it to `[]`. Test the actual rendered form submission.
|
|
20
|
+
|
|
21
|
+
## Should fix
|
|
22
|
+
|
|
23
|
+
- **Malformed restriction values can become unrestricted.** `filter_map` drops `false` and `nil` rather than preserving them for validation ([restrictions.rb:140](lib/api_keys/restrictions.rb:140), [restrictions.rb:205](lib/api_keys/restrictions.rb:205)). Verified that `{"ips":false}`, `{"ips":[false]}`, and `{"ips":[null]}` become unrestricted; `key.update!(allowed_ips: false)` persists `{}`. The same applies to origins. This contradicts [CHANGELOG.md:21](CHANGELOG.md:21). Preserve invalid entries and reject them; distinguish an intentionally empty list from malformed data.
|
|
24
|
+
|
|
25
|
+
- **Policy changes can prevent revocation of an active key.** `revoke!` performs a fully validated update ([api_key.rb:215](lib/api_keys/models/api_key.rb:215)). After reducing a ceiling from `read,write` to `read`, an existing `read,write` key fails revocation validation ([api_key.rb:652](lib/api_keys/models/api_key.rb:652)) while remaining active and authorized for `read` ([api_key.rb:349](lib/api_keys/models/api_key.rb:349)). Confirmed in memory. Make revocation an atomic operation independent of unrelated validations, retaining the explicit revocability guard.
|
|
26
|
+
|
|
27
|
+
- **The token handoff is replayable, despite the one-time claim.** Retrieval only deletes the payload from the supplied session ([token_session.rb:89](lib/api_keys/helpers/token_session.rb:89)). Replaying a pre-consumption CookieStore cookie—or making concurrent requests with it—can recover the same token during its ten-minute lifetime. A copied-session reproduction returned the token twice. This contradicts the reused-handoff guarantee at [README.md:473](README.md:473); it does not bypass dashboard ownership. Use an atomic server-side consume operation for a genuine one-time guarantee, or qualify the documentation.
|
|
28
|
+
|
|
29
|
+
- **Header parsing precedes encoding and size validation.** The Bearer regex executes at [authenticator.rb:129](lib/api_keys/services/authenticator.rb:129), before the checks at [authenticator.rb:345](lib/api_keys/services/authenticator.rb:345). An invalid UTF-8 header string raises an uncaught `ArgumentError`; oversized headers are scanned before rejection. Validate the raw header first and return a controlled credential failure. Internet reachability of the encoding case depends on the server/middleware’s string handling.
|
|
30
|
+
|
|
31
|
+
## Nits
|
|
32
|
+
|
|
33
|
+
- The dashboard security page says publishable keys cannot be revoked individually ([best_practices:27](app/views/api_keys/security/best_practices.html.erb:27)), although revocability defaults to true ([api_key.rb:402](lib/api_keys/models/api_key.rb:402)).
|
|
34
|
+
- The prefix table still promises untyped creation when key types are configured ([README.md:942](README.md:942)); creation now requires a supplied or default type ([has_api_keys.rb:257](lib/api_keys/models/concerns/has_api_keys.rb:257)).
|
|
35
|
+
|
|
36
|
+
## Missing tests
|
|
37
|
+
|
|
38
|
+
- **Real proxy resolution:** restriction tests supply an already-resolved fake `remote_ip`, missing forwarding-header spoofing and proxy-chain behavior ([request_restrictions_test.rb:12](test/request_restrictions_test.rb:12)).
|
|
39
|
+
- **Dashboard abuse:** actual all-unchecked form submissions; CSRF rejection with protection enabled; cross-owner update/revoke attempts. Current tests disable CSRF and exercise cross-owner show only ([keys_controller_test.rb:50](test/controllers/keys_controller_test.rb:50), [keys_controller_test.rb:330](test/controllers/keys_controller_test.rb:330)).
|
|
40
|
+
- **Malformed restriction matrices:** false/null scalars and array members. Existing non-string tests use integers ([restrictions_test.rb:344](test/restrictions_test.rb:344)).
|
|
41
|
+
- **Handoff replay/concurrency and browser history restoration:** current retrieval tests mutate one Hash ([token_session_test.rb:61](test/helpers/token_session_test.rb:61)).
|
|
42
|
+
- **Revocation after ceiling changes and malformed header encodings:** existing tests cover ordinary revocation and oversized tokens separately ([api_key_test.rb:486](test/models/api_key_test.rb:486), [authenticator_test.rb:272](test/services/authenticator_test.rb:272)).
|
|
43
|
+
- **Production database behavior:** quota concurrency, generated constraints, and authentication-index execution on PostgreSQL/MySQL. The suite uses an independently defined in-memory SQLite schema ([test_helper.rb:51](test/test_helper.rb:51)).
|
|
44
|
+
|
|
45
|
+
## Blue team
|
|
46
|
+
|
|
47
|
+
- **Enforce tenant and role authorization yourself.** Tenant resolution returns an object or `nil`; it does not compare against a requested tenant ([tenant_resolution.rb:26](lib/api_keys/tenant_resolution.rb:26)). Every dashboard owner can select every configured key type ([form:23](app/views/api_keys/keys/_form.html.erb:23)). Gate dashboard membership and type issuance appropriately, and scope resource queries to the authenticated owner.
|
|
48
|
+
- **Treat origins as browser-context restrictions.** Non-browser clients can forge them, as acknowledged at [README.md:1409](README.md:1409). Public permissions must remain safe under arbitrary replay.
|
|
49
|
+
- **Rate-limit before authentication and queue insertion.** Bcrypt guesses matching a prefix/last-four bucket trigger expensive comparisons, exposing bucket existence through timing ([authenticator.rb:218](lib/api_keys/services/authenticator.rb:218)). Distinct revoked/expired responses identify already-known complete tokens, not their missing characters ([authenticator.rb:104](lib/api_keys/services/authenticator.rb:104)).
|
|
50
|
+
- **Do not use callbacks or counters as exactly-once business events.** Attempts enqueue callbacks; statistics debounce depends on a completed database update, so queue lag permits bursts. Duplicate job delivery increments counters again ([authentication.rb:154](lib/api_keys/authentication.rb:154), [authentication.rb:186](lib/api_keys/authentication.rb:186), [update_stats_job.rb:53](lib/api_keys/jobs/update_stats_job.rb:53)). Monitor queue depth and make consequential consumers idempotent.
|
|
51
|
+
- **Apply deployment controls independently:** TLS, protected sessions, credential filtering, and maintained dependencies. Installable dependency floors are broader than security support ([api_keys.gemspec:40](api_keys.gemspec:40), [SECURITY.md:7](SECURITY.md:7)); operational requirements are listed at [SECURITY.md:26](SECURITY.md:26).
|
|
52
|
+
|
|
53
|
+
VERDICT: BLOCK
|
|
54
|
+
|
|
55
|
+
The blocking concerns are authorization behavior at integration boundaries: spoofable client-IP resolution in an explicitly in-scope deployment and a dashboard action that silently fails to remove permissions. The malformed-policy, revocation, and handoff findings also weaken stated security guarantees. Address these with focused regression tests before treating 0.5.0 as ready under this threat model.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Re-review of the 0.5.1 fixes (Codex Astra, 2026-10-18, at f247984, PR #64)
|
|
2
|
+
|
|
3
|
+
Scope: `git diff fd598aa..f247984`, the answer to review 01, read-only. Verdict: BLOCK only on the handoff guarantee being overstated (the consumption marker lives in the cache, so cache loss within the handoff lifetime re-opens replay for a copied cookie), plus one documentation nit; both were applied in the next commit (wording in README, SECURITY.md and the CHANGELOG; a test that pins the cache-loss replay; the blank-member sentence qualified). Durable consumption state is planned for 0.6.0. Every original attack is closed; the client-IP finding is closed by documentation and tests of the deployment shapes, with the default unchanged.
|
|
4
|
+
|
|
5
|
+
## Blocking
|
|
6
|
+
|
|
7
|
+
**Scope:** The requested checkout remains at `fd598aa`; its diff is empty. I reviewed the available candidate `fd598aa..f247984` in `/Users/javi/GitHub/api_keys-security` instead. This was code inspection only; “would fail on 0.5.0” below is a static assessment.
|
|
8
|
+
|
|
9
|
+
- **IP spoofing: documented mitigation, unchanged default.** The real middleware tests perform the original attack and explicitly expect spoofed authentication to succeed; the socket-peer and proxy-chain tests verify the documented deployment configurations. These tests would also pass on 0.5.0: they establish deployment requirements, not changed protection. This satisfies the requested documentation/test remedy, but directly reachable installations must apply it. No new runtime behavior. [request_restrictions_test.rb:852](test/request_restrictions_test.rb:852)
|
|
10
|
+
|
|
11
|
+
- **All-unchecked dashboard scopes: closed.** Tests serialize the rendered form after removing every checkbox’s checked attribute, submit it, and verify permissions disappear on creation and update. Both would fail on 0.5.0. Updates now preserve scopes outside the displayed choices and ignore submitted additions outside those choices; the form explicitly identifies retained scopes. [keys_controller_test.rb:357](test/controllers/keys_controller_test.rb:357), [keys_controller.rb:181](app/controllers/api_keys/keys_controller.rb:181), [_form.html.erb:118](app/views/api_keys/keys/_form.html.erb:118)
|
|
12
|
+
|
|
13
|
+
## Should fix
|
|
14
|
+
|
|
15
|
+
- **Malformed restrictions: original attacks closed.** False/null members survive coercion and fail validation; stored malformed values refuse authentication. Tests exercise both stored corruption and writer updates, including the original false/null cases, and would fail on 0.5.0. Scalar `nil` remains an intentional clearing operation on input. Untouched malformed dashboard fields retain their stored meaning instead of becoming unrestricted. [restrictions.rb:232](lib/api_keys/restrictions.rb:232), [request_restrictions_test.rb:698](test/request_restrictions_test.rb:698), [keys_controller.rb:214](app/controllers/api_keys/keys_controller.rb:214)
|
|
16
|
+
|
|
17
|
+
- **Revocation after policy changes: closed for clean loaded records.** The tests narrow an issued key’s ceiling, establish that validation fails, then revoke it—including through a warm authentication cache. They would fail on 0.5.0. The revocability guard and save callbacks remain; instances carrying other pending edits still validate and can fail revocation. That limitation is explicit and tested, rather than silently saving those edits unvalidated. [api_key.rb:232](lib/api_keys/models/api_key.rb:232), [authenticator_test.rb:319](test/services/authenticator_test.rb:319), [api_key_test.rb:553](test/models/api_key_test.rb:553)
|
|
18
|
+
|
|
19
|
+
- **Handoff replay: improved, but the guarantee remains overstated.** Copied-session and threaded tests perform the attack and would fail on 0.5.0. However, consumption exists only as a cache marker: consume once, clear the cache, then replay the still-valid copied session, and the write succeeds again. Shared, atomic storage alone therefore does not establish the README’s “at most once” guarantee; marker retention is also required. Document cache-loss replay or use durable consumption state. Cache failure now withholds the token—a deliberate availability change. [token_session.rb:202](lib/api_keys/helpers/token_session.rb:202), [token_session_test.rb:215](test/helpers/token_session_test.rb:215), [README.md:481](README.md:481)
|
|
20
|
+
|
|
21
|
+
- **Header parsing: original attacks closed.** Raw size/encoding checks precede the regex. Tests supply invalid UTF-8 and explicitly forbid matching an oversized header; both would fail on 0.5.0. The new compatibility boundary is a 576-byte complete header limit, including Bearer whitespace. [authenticator.rb:138](lib/api_keys/services/authenticator.rb:138), [authenticator_test.rb:350](test/services/authenticator_test.rb:350)
|
|
22
|
+
|
|
23
|
+
## Nits
|
|
24
|
+
|
|
25
|
+
- Both previous documentation errors are corrected. [best_practices.html.erb:27](app/views/api_keys/security/best_practices.html.erb:27), [README.md:961](README.md:961)
|
|
26
|
+
- The new claim that blank array members fail validation is too broad: the writer test explicitly accepts `allowed_ips: [""]` and clears restrictions. Qualify the input-path distinction. [README.md:1386](README.md:1386), [request_restrictions_test.rb:768](test/request_restrictions_test.rb:768)
|
|
27
|
+
|
|
28
|
+
## Missing tests
|
|
29
|
+
|
|
30
|
+
- Add consumption-marker loss followed by replay. Current concurrency coverage uses one process and `MemoryStore`, not a production shared backend. [token_session_test.rb:225](test/helpers/token_session_test.rb:225)
|
|
31
|
+
- CSRF rejection and cross-owner writes are now meaningfully covered; these establish existing protections and should pass on 0.5.0. Browser-history restoration remains uncovered by the controller replay test. [keys_controller_test.rb:562](test/controllers/keys_controller_test.rb:562), [keys_controller_test.rb:596](test/controllers/keys_controller_test.rb:596), [keys_controller_test.rb:638](test/controllers/keys_controller_test.rb:638)
|
|
32
|
+
- PostgreSQL/MySQL concurrency, migration constraints, and index execution remain unverified; the suite still defines an in-memory SQLite schema. [test_helper.rb:51](test/test_helper.rb:51)
|
|
33
|
+
|
|
34
|
+
VERDICT: BLOCK
|
|
35
|
+
|
|
36
|
+
The candidate substantially addresses the original review, but I would correct the remaining handoff guarantee and add cache-loss replay coverage before publishing it as the security-fix release. The documented IP configuration remains mandatory. This assessment covers `f247984`, not the requested merged `main`, which currently contains none of these fixes.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# Independent review of the 0.5.1 fixes (Claude Opus 5.5, 2026-10-18, at 0a3183b then f247984, PR #64)
|
|
2
|
+
|
|
3
|
+
Scope: the branch against Astra's findings (01), with the suite run and the attack tests checked against the 0.5.0 sources (35 of the 53 added tests fail there). Verdict: PASS, with four should-fix items that were applied in the second commit: dashboard edits change only the scopes the form offered and keep the rest (a rename no longer strips a scope the owner's defaults do not list); nil and blank restriction inputs clear a kind as the list writers do, while stored rows stay strictly read; the form builder's sentinel takes an explicit `include_hidden:` override and the README says per-owner defaults need a key built from its owner; Solid Cache's `unless_exist` (SELECT FOR UPDATE then upsert, which locks nothing for an absent key on PostgreSQL) is named next to FileStore. Nits applied: a stored malformed policy round-trips through the edit form as JSON and refuses to save until corrected; a README line on a cleared key becoming unrestricted if scope configuration is later removed; a README line on a second GET of the token page getting nothing; a test for `available?` when the cache read raises. Noted for 0.6: resolving the client IP through `remote_ip` only when `remote_addr` is a trusted proxy.
|
|
4
|
+
|
|
5
|
+
The review's own text is summarised above; its numbered findings and verdict were delivered in the session transcript rather than as a file.
|