api_keys 0.4.3 → 0.5.1
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 +39 -0
- data/README.md +195 -27
- data/SECURITY.md +21 -2
- data/app/controllers/api_keys/keys_controller.rb +137 -11
- data/app/views/api_keys/keys/_form.html.erb +80 -17
- data/app/views/api_keys/keys/_key_badges.html.erb +7 -0
- data/app/views/api_keys/keys/_restriction_fields.html.erb +34 -0
- data/app/views/api_keys/security/best_practices.html.erb +1 -1
- data/app/views/layouts/api_keys/application.html.erb +9 -1
- 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/lib/api_keys/authentication.rb +7 -1
- data/lib/api_keys/configuration.rb +54 -8
- data/lib/api_keys/engine.rb +6 -0
- data/lib/api_keys/errors.rb +11 -0
- data/lib/api_keys/form_builder_extensions.rb +40 -3
- data/lib/api_keys/helpers/token_session.rb +92 -3
- data/lib/api_keys/models/api_key.rb +240 -11
- data/lib/api_keys/models/concerns/has_api_keys.rb +67 -10
- data/lib/api_keys/restrictions.rb +430 -0
- data/lib/api_keys/services/authenticator.rb +110 -12
- data/lib/api_keys/version.rb +1 -1
- data/lib/api_keys.rb +1 -0
- data/lib/generators/api_keys/add_key_types_generator.rb +1 -1
- data/lib/generators/api_keys/add_restrictions_generator.rb +60 -0
- data/lib/generators/api_keys/templates/add_restrictions_to_api_keys.rb.erb +54 -0
- data/lib/generators/api_keys/templates/create_api_keys_table.rb.erb +22 -0
- data/lib/generators/api_keys/templates/initializer.rb +49 -6
- metadata +8 -1
|
@@ -4,7 +4,8 @@ module ApiKeys
|
|
|
4
4
|
# Controller for managing API keys belonging to the current owner.
|
|
5
5
|
class KeysController < ApplicationController
|
|
6
6
|
before_action :set_api_key, only: [:show, :edit, :update, :revoke]
|
|
7
|
-
helper_method :key_types_feature_enabled
|
|
7
|
+
helper_method :key_types_feature_enabled?, :api_keys_allowed_restriction_kinds,
|
|
8
|
+
:dashboard_scope_choices, :restriction_field_text
|
|
8
9
|
|
|
9
10
|
# GET /keys
|
|
10
11
|
def index
|
|
@@ -48,12 +49,15 @@ module ApiKeys
|
|
|
48
49
|
|
|
49
50
|
# GET /keys/new
|
|
50
51
|
def new
|
|
51
|
-
@api_key = current_api_keys_owner.api_keys.build
|
|
52
|
+
@api_key = current_api_keys_owner.api_keys.build(key_type: ApiKeys.configuration.default_key_type)
|
|
52
53
|
end
|
|
53
54
|
|
|
54
55
|
# POST /keys
|
|
55
56
|
def create
|
|
56
57
|
submitted_params = api_key_params
|
|
58
|
+
# If creation fails, the re-rendered form shows these instead of
|
|
59
|
+
# re-checking every scope the owner had cleared.
|
|
60
|
+
@submitted_scopes = submitted_params[:scopes]
|
|
57
61
|
|
|
58
62
|
# Use the HasApiKeys helper method to create the key
|
|
59
63
|
begin
|
|
@@ -62,7 +66,10 @@ module ApiKeys
|
|
|
62
66
|
name: submitted_params[:name],
|
|
63
67
|
scopes: submitted_params[:scopes],
|
|
64
68
|
expires_at: parse_expiration(submitted_params[:expires_at_preset]),
|
|
65
|
-
key_type: submitted_params[:key_type].presence
|
|
69
|
+
key_type: submitted_params[:key_type].presence,
|
|
70
|
+
# The model normalizes these raw strings; no parser needed here.
|
|
71
|
+
allowed_origins: submitted_params[:allowed_origins],
|
|
72
|
+
allowed_ips: submitted_params[:allowed_ips]
|
|
66
73
|
# Metadata could be added here if needed
|
|
67
74
|
)
|
|
68
75
|
|
|
@@ -73,6 +80,7 @@ module ApiKeys
|
|
|
73
80
|
rescue ActiveRecord::RecordInvalid => e
|
|
74
81
|
# If create! fails due to validation (e.g., quota exceeded)
|
|
75
82
|
@api_key = e.record # Get the invalid ApiKey instance
|
|
83
|
+
@api_key.expires_at_preset = submitted_params[:expires_at_preset]
|
|
76
84
|
flash.now[:alert] = "Failed to create API key: #{e.record.errors.full_messages.join(', ')}"
|
|
77
85
|
render :new, status: :unprocessable_entity
|
|
78
86
|
rescue ArgumentError
|
|
@@ -96,7 +104,17 @@ module ApiKeys
|
|
|
96
104
|
|
|
97
105
|
# PATCH/PUT /keys/:id
|
|
98
106
|
def update
|
|
99
|
-
|
|
107
|
+
attributes = api_key_update_params
|
|
108
|
+
if attributes.key?(:scopes)
|
|
109
|
+
attributes[:scopes] = scopes_after_editing_offered_choices(attributes[:scopes])
|
|
110
|
+
elsif held_scopes_within_ceiling != Array(@api_key.scopes)
|
|
111
|
+
# No scope boxes were shown, but the key still holds a scope its type
|
|
112
|
+
# no longer allows; without dropping it no edit could ever be saved.
|
|
113
|
+
attributes[:scopes] = held_scopes_within_ceiling
|
|
114
|
+
end
|
|
115
|
+
skip_untouched_restriction_fields!(attributes)
|
|
116
|
+
|
|
117
|
+
if @api_key.update(attributes)
|
|
100
118
|
redirect_to keys_path, notice: "API key updated successfully."
|
|
101
119
|
else
|
|
102
120
|
flash.now[:alert] = "Failed to update API key: #{@api_key.errors.full_messages.join(', ')}"
|
|
@@ -133,18 +151,104 @@ module ApiKeys
|
|
|
133
151
|
submitted = params.require(:api_key)
|
|
134
152
|
raise ActionController::ParameterMissing, :api_key unless submitted.respond_to?(:permit)
|
|
135
153
|
|
|
136
|
-
permitted_params = submitted.permit(:name, :expires_at_preset, :key_type,
|
|
137
|
-
|
|
154
|
+
permitted_params = submitted.permit(:name, :expires_at_preset, :key_type,
|
|
155
|
+
:allowed_origins, :allowed_ips, scopes: [])
|
|
156
|
+
normalize_submitted_scopes!(permitted_params)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Each scope checkbox group renders an empty sentinel (`scopes[]=""`), so a
|
|
160
|
+
# submitted form always carries the parameter. Stripping blanks turns the
|
|
161
|
+
# sentinel alone into `[]`: the owner cleared every box and means "none",
|
|
162
|
+
# and the model's validations decide whether that is allowed. Only an
|
|
163
|
+
# absent parameter (`nil`) means "not specified": create_api_key! then
|
|
164
|
+
# applies the default scopes and update leaves the current ones alone.
|
|
165
|
+
def normalize_submitted_scopes!(permitted_params)
|
|
166
|
+
permitted_params[:scopes]&.reject!(&:blank?)
|
|
138
167
|
permitted_params
|
|
139
168
|
end
|
|
140
169
|
|
|
141
|
-
#
|
|
170
|
+
# The scopes the dashboard offers as checkboxes: the owner's configured
|
|
171
|
+
# default scopes. The form renders exactly this list, and an update edits
|
|
172
|
+
# only scopes in it.
|
|
173
|
+
#
|
|
174
|
+
# @return [Array<String>]
|
|
175
|
+
def dashboard_scope_choices
|
|
176
|
+
Array(current_api_keys_owner.class.api_keys_settings.dig(:default_scopes))
|
|
177
|
+
end
|
|
178
|
+
|
|
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
|
+
# The text the form puts in a restriction field. Usable entries appear as
|
|
209
|
+
# they are stored; anything else a stored policy holds (null, false, a
|
|
210
|
+
# number, a blank string) is shown as JSON, so the owner can see it.
|
|
211
|
+
#
|
|
212
|
+
# @return [String]
|
|
213
|
+
def restriction_field_text(api_key, kind)
|
|
214
|
+
api_key.restrictions.public_send(kind).map do |entry|
|
|
215
|
+
if entry.is_a?(String) && entry.valid_encoding? && !entry.strip.empty?
|
|
216
|
+
entry
|
|
217
|
+
else
|
|
218
|
+
begin
|
|
219
|
+
JSON.generate(entry)
|
|
220
|
+
rescue StandardError
|
|
221
|
+
entry.inspect
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end.join(", ")
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# A restriction field submitted exactly as it was rendered was not
|
|
228
|
+
# touched, so its kind is left alone. Re-parsing the text would rewrite
|
|
229
|
+
# the stored list, and a malformed stored entry could come back as an
|
|
230
|
+
# empty (unrestricted) list or as a different, valid one. Left alone, a
|
|
231
|
+
# malformed stored policy keeps failing validation, so the save is refused
|
|
232
|
+
# until the owner corrects the field.
|
|
233
|
+
def skip_untouched_restriction_fields!(attributes)
|
|
234
|
+
{ allowed_origins: :origins, allowed_ips: :ips }.each do |field, kind|
|
|
235
|
+
next unless attributes.key?(field)
|
|
236
|
+
|
|
237
|
+
attributes.delete(field) if attributes[field] == restriction_field_text(@api_key, kind)
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Only allow updating name, scopes, and request restrictions.
|
|
242
|
+
# Restriction edits stay available on non-revocable keys so an owner can
|
|
243
|
+
# still tighten the policy even when lifecycle operations are disabled.
|
|
142
244
|
def api_key_update_params
|
|
143
245
|
submitted = params.require(:api_key)
|
|
144
246
|
raise ActionController::ParameterMissing, :api_key unless submitted.respond_to?(:permit)
|
|
145
247
|
|
|
146
|
-
permitted_params = submitted.permit(:name, scopes: [])
|
|
147
|
-
|
|
248
|
+
permitted_params = submitted.permit(:name, :allowed_origins, :allowed_ips, scopes: [])
|
|
249
|
+
normalize_submitted_scopes!(permitted_params)
|
|
250
|
+
permitted_params.delete(:allowed_origins) unless ApiKeys::ApiKey.restrictions_column?
|
|
251
|
+
permitted_params.delete(:allowed_ips) unless ApiKeys::ApiKey.restrictions_column?
|
|
148
252
|
permitted_params
|
|
149
253
|
end
|
|
150
254
|
|
|
@@ -163,10 +267,32 @@ module ApiKeys
|
|
|
163
267
|
end
|
|
164
268
|
|
|
165
269
|
def rebuild_api_key_for_form(submitted_params)
|
|
166
|
-
current_api_keys_owner.api_keys.build(
|
|
270
|
+
api_key = current_api_keys_owner.api_keys.build(
|
|
167
271
|
name: submitted_params[:name],
|
|
168
|
-
scopes: submitted_params[:scopes]
|
|
272
|
+
scopes: submitted_params[:scopes],
|
|
273
|
+
key_type: submitted_params[:key_type]
|
|
169
274
|
)
|
|
275
|
+
api_key.expires_at_preset = submitted_params[:expires_at_preset]
|
|
276
|
+
if ApiKeys::ApiKey.restrictions_column?
|
|
277
|
+
api_key.allowed_origins = submitted_params[:allowed_origins] unless submitted_params[:allowed_origins].nil?
|
|
278
|
+
api_key.allowed_ips = submitted_params[:allowed_ips] unless submitted_params[:allowed_ips].nil?
|
|
279
|
+
end
|
|
280
|
+
api_key
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Which restriction kinds the form may offer for a given key.
|
|
284
|
+
# A typed key answers with its own ceiling; an unsaved key that has not
|
|
285
|
+
# picked a type yet offers everything any configured type allows.
|
|
286
|
+
#
|
|
287
|
+
# @param api_key [ApiKeys::ApiKey]
|
|
288
|
+
# @return [Array<Symbol>]
|
|
289
|
+
def api_keys_allowed_restriction_kinds(api_key)
|
|
290
|
+
return api_key.allowed_restriction_kinds if api_key.persisted?
|
|
291
|
+
return ApiKeys::Restrictions::KINDS.dup unless key_types_feature_enabled?
|
|
292
|
+
|
|
293
|
+
ApiKeys.configuration.key_types.flat_map do |_type, settings|
|
|
294
|
+
ApiKeys::ApiKey.restriction_kinds_for(settings)
|
|
295
|
+
end.uniq
|
|
170
296
|
end
|
|
171
297
|
|
|
172
298
|
# Check if key types feature is enabled
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<%# Shared form for creating and editing API Keys %>
|
|
2
|
-
<%= form_with(model: [:keys, api_key], url: (api_key.persisted? ? key_path(api_key) : keys_path),
|
|
2
|
+
<%= form_with(model: [:keys, api_key], url: (api_key.persisted? ? key_path(api_key) : keys_path),
|
|
3
|
+
local: true, html: { id: "api-keys-key-form" }) do |form| %>
|
|
3
4
|
<% if api_key.errors.any? %>
|
|
4
5
|
<div class="api-keys-form-errors">
|
|
5
6
|
<strong><%= pluralize(api_key.errors.count, "error") %> prohibited this API key from being saved:</strong>
|
|
@@ -47,7 +48,7 @@
|
|
|
47
48
|
<%= form.text_field :name, placeholder: "e.g., myproject-production-key" %>
|
|
48
49
|
</div>
|
|
49
50
|
|
|
50
|
-
<div>
|
|
51
|
+
<div data-api-keys-expiration>
|
|
51
52
|
<%= form.label :expires_at_preset, "Expiration" %>
|
|
52
53
|
<%= form.select :expires_at_preset,
|
|
53
54
|
options_for_select([
|
|
@@ -57,26 +58,32 @@
|
|
|
57
58
|
["60 days", "60_days"],
|
|
58
59
|
["90 days", "90_days"],
|
|
59
60
|
["365 days", "365_days"] # Common presets
|
|
60
|
-
], api_key.expires_at.present? ? nil : "no_expiration"), # Default selection
|
|
61
|
+
], api_key.expires_at_preset.presence || (api_key.expires_at.present? ? nil : "no_expiration")), # Default selection
|
|
61
62
|
{}, # html options
|
|
62
63
|
{} # data attributes
|
|
63
64
|
%>
|
|
64
65
|
</div>
|
|
65
66
|
|
|
66
|
-
<%#
|
|
67
|
-
<%#
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
<% @available_scopes = current_api_keys_owner.class.api_keys_settings.dig(:default_scopes) %>
|
|
71
|
-
<% if defined?(@available_scopes) && @available_scopes.present? %>
|
|
67
|
+
<%# The offered scopes are the owner's default scopes. The controller reads %>
|
|
68
|
+
<%# the same list (dashboard_scope_choices) when it saves the form. %>
|
|
69
|
+
<% @available_scopes = dashboard_scope_choices %>
|
|
70
|
+
<% if @available_scopes.present? %>
|
|
72
71
|
<div>
|
|
73
72
|
<%= form.label :scopes, "Permissions" %>
|
|
73
|
+
<%# Browsers omit unchecked boxes, so clearing every box would send no scopes %>
|
|
74
|
+
<%# parameter at all and the key would silently get the defaults. This empty %>
|
|
75
|
+
<%# sentinel makes "none" explicit; blank values are stripped server-side. %>
|
|
76
|
+
<%# (Scopes are configured here, so an empty list grants nothing.) %>
|
|
77
|
+
<%= hidden_field_tag "#{form.object_name}[scopes][]", "", id: nil if api_key.scopes_enforced? %>
|
|
78
|
+
<%# A fresh form starts with every scope checked; after a failed submission %>
|
|
79
|
+
<%# it shows exactly what was submitted, so a cleared box stays cleared. %>
|
|
80
|
+
<% submitted_scopes = @submitted_scopes %>
|
|
74
81
|
<% @available_scopes.each do |scope| %>
|
|
75
82
|
<div class="form-check">
|
|
76
83
|
<%= form.check_box :scopes,
|
|
77
84
|
{ multiple: true, # Submits as an array
|
|
78
85
|
class: "form-check-input",
|
|
79
|
-
checked:
|
|
86
|
+
checked: submitted_scopes.nil? || submitted_scopes.include?(scope),
|
|
80
87
|
id: "api_key_scopes_#{scope.parameterize}" }, # Unique ID
|
|
81
88
|
scope, # Value submitted when checked
|
|
82
89
|
nil # Value submitted when unchecked (not needed here)
|
|
@@ -87,6 +94,8 @@
|
|
|
87
94
|
</div>
|
|
88
95
|
<% end %>
|
|
89
96
|
|
|
97
|
+
<%= render "api_keys/keys/restriction_fields", form: form, api_key: api_key %>
|
|
98
|
+
|
|
90
99
|
<% end %>
|
|
91
100
|
|
|
92
101
|
<%# Fields editable on EDIT %>
|
|
@@ -96,19 +105,35 @@
|
|
|
96
105
|
<%= form.text_field :name %>
|
|
97
106
|
</div>
|
|
98
107
|
|
|
99
|
-
<%#
|
|
100
|
-
<%#
|
|
101
|
-
<%#
|
|
102
|
-
<% @available_scopes =
|
|
103
|
-
<%
|
|
108
|
+
<%# The edit form offers the owner's default scopes and edits only those: %>
|
|
109
|
+
<%# saving keeps every other scope the key holds, except scopes its type no %>
|
|
110
|
+
<%# longer allows (see the controller's scopes_after_editing_offered_choices). %>
|
|
111
|
+
<% @available_scopes = dashboard_scope_choices %>
|
|
112
|
+
<% dropped_scopes = Array(api_key.scopes).reject { |scope| api_key.scope_within_ceiling?(scope) } %>
|
|
113
|
+
<% if dropped_scopes.any? %>
|
|
114
|
+
<small class="api-keys-form-help">
|
|
115
|
+
<%= safe_join(dropped_scopes.map { |scope| content_tag(:code, scope) }, ", ") %>
|
|
116
|
+
<%= dropped_scopes.one? ? "is" : "are" %> no longer allowed for this key and will be removed when you save.
|
|
117
|
+
</small>
|
|
118
|
+
<% end %>
|
|
119
|
+
<% if @available_scopes.present? %>
|
|
104
120
|
<div>
|
|
105
121
|
<%= form.label :scopes, "Permissions / Scopes" %>
|
|
122
|
+
<%# Empty sentinel: clearing every box must remove every offered scope, not keep the old ones. %>
|
|
123
|
+
<%= hidden_field_tag "#{form.object_name}[scopes][]", "", id: nil if api_key.scopes_enforced? %>
|
|
124
|
+
<% kept_scopes = Array(api_key.scopes) - @available_scopes - dropped_scopes %>
|
|
125
|
+
<% if kept_scopes.any? %>
|
|
126
|
+
<small class="api-keys-form-help">
|
|
127
|
+
This key also holds <%= safe_join(kept_scopes.map { |scope| content_tag(:code, scope) }, ", ") %>,
|
|
128
|
+
which this form does not change.
|
|
129
|
+
</small>
|
|
130
|
+
<% end %>
|
|
106
131
|
<% @available_scopes.each do |scope| %>
|
|
107
132
|
<div class="form-check">
|
|
108
133
|
<%= form.check_box :scopes,
|
|
109
134
|
{ multiple: true, # Submits as an array
|
|
110
135
|
class: "form-check-input",
|
|
111
|
-
checked: api_key.scopes&.include?(scope)
|
|
136
|
+
checked: api_key.scopes&.include?(scope) && !dropped_scopes.include?(scope),
|
|
112
137
|
id: "api_key_scopes_#{scope.parameterize}_edit" }, # Unique ID for edit form
|
|
113
138
|
scope, # Value submitted when checked
|
|
114
139
|
nil # Value submitted when unchecked (not needed here)
|
|
@@ -119,6 +144,8 @@
|
|
|
119
144
|
<% end %>
|
|
120
145
|
</div>
|
|
121
146
|
<% end %>
|
|
147
|
+
|
|
148
|
+
<%= render "api_keys/keys/restriction_fields", form: form, api_key: api_key %>
|
|
122
149
|
<% end %>
|
|
123
150
|
|
|
124
151
|
<div>
|
|
@@ -127,9 +154,45 @@
|
|
|
127
154
|
<%= link_to "Cancel", keys_path %>
|
|
128
155
|
<% else %>
|
|
129
156
|
<h4><strong>Keep it safe</strong></h4>
|
|
130
|
-
<p>
|
|
157
|
+
<p>Secret API keys are only shown once after creation. Copy the new key immediately and store it securely.</p>
|
|
131
158
|
<%= form.submit "Create API Key" %>
|
|
132
159
|
<%= link_to "Cancel", keys_path %>
|
|
133
160
|
<% end %>
|
|
134
161
|
</div>
|
|
135
162
|
<% end %>
|
|
163
|
+
|
|
164
|
+
<% if !api_key.persisted? && ApiKeys.configuration.key_types.present? %>
|
|
165
|
+
<% key_type_policies = ApiKeys.configuration.key_types.to_h do |type, config| %>
|
|
166
|
+
<% [type.to_s, {
|
|
167
|
+
restrictions: ApiKeys::ApiKey.restriction_kinds_for(config).map(&:to_s),
|
|
168
|
+
expirable: ApiKeys::ApiKey.revocable_for(config)
|
|
169
|
+
}] %>
|
|
170
|
+
<% end %>
|
|
171
|
+
<script nonce="<%= content_security_policy_nonce %>">
|
|
172
|
+
(() => {
|
|
173
|
+
const form = document.getElementById("api-keys-key-form");
|
|
174
|
+
const keyType = form && form.querySelector("#api_key_key_type");
|
|
175
|
+
if (!form || !keyType) return;
|
|
176
|
+
|
|
177
|
+
const policies = <%= raw json_escape(key_type_policies.to_json) %>;
|
|
178
|
+
const updatePolicyFields = () => {
|
|
179
|
+
const policy = policies[keyType.value];
|
|
180
|
+
|
|
181
|
+
form.querySelectorAll("[data-api-keys-restriction-kind]").forEach((container) => {
|
|
182
|
+
const enabled = policy && policy.restrictions.includes(container.dataset.apiKeysRestrictionKind);
|
|
183
|
+
container.hidden = !enabled;
|
|
184
|
+
container.querySelectorAll("input, select, textarea").forEach((input) => { input.disabled = !enabled; });
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
form.querySelectorAll("[data-api-keys-expiration]").forEach((container) => {
|
|
188
|
+
const enabled = policy && policy.expirable;
|
|
189
|
+
container.hidden = !enabled;
|
|
190
|
+
container.querySelectorAll("input, select, textarea").forEach((input) => { input.disabled = !enabled; });
|
|
191
|
+
});
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
keyType.addEventListener("change", updatePolicyFields);
|
|
195
|
+
updatePolicyFields();
|
|
196
|
+
})();
|
|
197
|
+
</script>
|
|
198
|
+
<% end %>
|
|
@@ -9,6 +9,13 @@
|
|
|
9
9
|
</span>
|
|
10
10
|
<% end %>
|
|
11
11
|
|
|
12
|
+
<% if key.restricted? %>
|
|
13
|
+
<span class="api-keys-badge api-keys-badge-restricted"
|
|
14
|
+
title="Usable only from specific <%= key.restrictions.kinds.map { |kind| kind == :ips ? 'IP addresses' : 'web origins' }.to_sentence %>">
|
|
15
|
+
Restricted
|
|
16
|
+
</span>
|
|
17
|
+
<% end %>
|
|
18
|
+
|
|
12
19
|
<% if key.environment.present? %>
|
|
13
20
|
<% is_live = key.environment == 'live' %>
|
|
14
21
|
<span class="api-keys-badge api-keys-badge-env <%= is_live ? 'api-keys-badge-live' : 'api-keys-badge-test' %>">
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<%# Optional request restriction fields: where a key may be used from. %>
|
|
2
|
+
<%# Locals: form (required), api_key (required) %>
|
|
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. %>
|
|
6
|
+
|
|
7
|
+
<% if ApiKeys::ApiKey.restrictions_column? %>
|
|
8
|
+
<% allowed_kinds = api_keys_allowed_restriction_kinds(api_key) %>
|
|
9
|
+
|
|
10
|
+
<% if allowed_kinds.include?(:origins) %>
|
|
11
|
+
<div data-api-keys-restriction-kind="origins">
|
|
12
|
+
<%= form.label :allowed_origins, "Allowed web origins (optional)" %>
|
|
13
|
+
<%= form.text_field :allowed_origins,
|
|
14
|
+
value: restriction_field_text(api_key, :origins),
|
|
15
|
+
placeholder: "example.com, *.example.com" %>
|
|
16
|
+
<small class="api-keys-form-help">
|
|
17
|
+
Leave empty to allow any origin. Requests from a browser must come from one of these hosts.
|
|
18
|
+
Use <code>*.example.com</code> to allow every subdomain.
|
|
19
|
+
</small>
|
|
20
|
+
</div>
|
|
21
|
+
<% end %>
|
|
22
|
+
|
|
23
|
+
<% if allowed_kinds.include?(:ips) %>
|
|
24
|
+
<div data-api-keys-restriction-kind="ips">
|
|
25
|
+
<%= form.label :allowed_ips, "Allowed IP addresses (optional)" %>
|
|
26
|
+
<%= form.text_field :allowed_ips,
|
|
27
|
+
value: restriction_field_text(api_key, :ips),
|
|
28
|
+
placeholder: "203.0.113.7, 10.0.0.0/8" %>
|
|
29
|
+
<small class="api-keys-form-help">
|
|
30
|
+
Leave empty to allow any address. Accepts single IPv4/IPv6 addresses and CIDR ranges.
|
|
31
|
+
</small>
|
|
32
|
+
</div>
|
|
33
|
+
<% end %>
|
|
34
|
+
<% 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 %>
|
|
@@ -36,6 +36,8 @@
|
|
|
36
36
|
--api-keys-badge-live-color: #155724;
|
|
37
37
|
--api-keys-badge-test-bg: #f8d7da;
|
|
38
38
|
--api-keys-badge-test-color: #721c24;
|
|
39
|
+
--api-keys-badge-restricted-bg: #e2e3e5;
|
|
40
|
+
--api-keys-badge-restricted-color: #383d41;
|
|
39
41
|
|
|
40
42
|
/* Status colors */
|
|
41
43
|
--api-keys-status-active-color: green;
|
|
@@ -152,6 +154,8 @@
|
|
|
152
154
|
--api-keys-badge-live-color: #9ae6b4;
|
|
153
155
|
--api-keys-badge-test-bg: #742a2a;
|
|
154
156
|
--api-keys-badge-test-color: #feb2b2;
|
|
157
|
+
--api-keys-badge-restricted-bg: #2d3748;
|
|
158
|
+
--api-keys-badge-restricted-color: #cbd5e0;
|
|
155
159
|
}
|
|
156
160
|
|
|
157
161
|
body {
|
|
@@ -212,7 +216,7 @@
|
|
|
212
216
|
.api-keys-status-active { color: var(--api-keys-status-active-color); }
|
|
213
217
|
.api-keys-status-revoked { color: var(--api-keys-status-revoked-color); }
|
|
214
218
|
.api-keys-status-expired { color: var(--api-keys-status-expired-color); }
|
|
215
|
-
.api-keys-badge-type, .api-keys-badge-env {
|
|
219
|
+
.api-keys-badge-type, .api-keys-badge-env, .api-keys-badge-restricted {
|
|
216
220
|
margin-left: 0.25rem;
|
|
217
221
|
padding: 0.15rem 0.4rem;
|
|
218
222
|
border-radius: 3px;
|
|
@@ -234,6 +238,10 @@
|
|
|
234
238
|
color: var(--api-keys-badge-test-color);
|
|
235
239
|
background-color: var(--api-keys-badge-test-bg);
|
|
236
240
|
}
|
|
241
|
+
.api-keys-badge-restricted {
|
|
242
|
+
color: var(--api-keys-badge-restricted-color);
|
|
243
|
+
background-color: var(--api-keys-badge-restricted-bg);
|
|
244
|
+
}
|
|
237
245
|
|
|
238
246
|
.api-keys-button-text {
|
|
239
247
|
padding-left: 0.2em;
|
|
@@ -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.
|
|
@@ -13,6 +13,11 @@ module ApiKeys
|
|
|
13
13
|
extend ActiveSupport::Concern
|
|
14
14
|
include ApiKeys::Logging
|
|
15
15
|
|
|
16
|
+
# Failures where the credential is valid but the request context is refused.
|
|
17
|
+
# The key itself is fine, so these answer 403 rather than 401 — the same
|
|
18
|
+
# distinction `:missing_scope` already makes.
|
|
19
|
+
FORBIDDEN_ERROR_CODES = %i[origin_not_allowed ip_not_allowed restriction_misconfigured].freeze
|
|
20
|
+
|
|
16
21
|
included do
|
|
17
22
|
# Helper methods to access the authenticated key and its owner
|
|
18
23
|
helper_method :current_api_key, :current_api_owner, :current_api_user
|
|
@@ -88,7 +93,8 @@ module ApiKeys
|
|
|
88
93
|
else
|
|
89
94
|
# Authentication failed
|
|
90
95
|
log_debug "[ApiKeys Auth] Authentication failed. Error: #{result.error_code}, Message: #{result.message}"
|
|
91
|
-
|
|
96
|
+
status = FORBIDDEN_ERROR_CODES.include?(result.error_code) ? :forbidden : :unauthorized
|
|
97
|
+
render_unauthorized(error_code: result.error_code, message: result.message, status: status)
|
|
92
98
|
end
|
|
93
99
|
|
|
94
100
|
# Enqueue after_authentication callback asynchronously regardless of success/failure
|