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.
Files changed (31) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +39 -0
  3. data/README.md +195 -27
  4. data/SECURITY.md +21 -2
  5. data/app/controllers/api_keys/keys_controller.rb +137 -11
  6. data/app/views/api_keys/keys/_form.html.erb +80 -17
  7. data/app/views/api_keys/keys/_key_badges.html.erb +7 -0
  8. data/app/views/api_keys/keys/_restriction_fields.html.erb +34 -0
  9. data/app/views/api_keys/security/best_practices.html.erb +1 -1
  10. data/app/views/layouts/api_keys/application.html.erb +9 -1
  11. data/docs/reviews/2026-10-18-0-5-1/01-astra-review-of-0-5-0.md +55 -0
  12. data/docs/reviews/2026-10-18-0-5-1/02-astra-re-review-of-0-5-1.md +36 -0
  13. data/docs/reviews/2026-10-18-0-5-1/03-opus-review-of-0-5-1.md +5 -0
  14. data/lib/api_keys/authentication.rb +7 -1
  15. data/lib/api_keys/configuration.rb +54 -8
  16. data/lib/api_keys/engine.rb +6 -0
  17. data/lib/api_keys/errors.rb +11 -0
  18. data/lib/api_keys/form_builder_extensions.rb +40 -3
  19. data/lib/api_keys/helpers/token_session.rb +92 -3
  20. data/lib/api_keys/models/api_key.rb +240 -11
  21. data/lib/api_keys/models/concerns/has_api_keys.rb +67 -10
  22. data/lib/api_keys/restrictions.rb +430 -0
  23. data/lib/api_keys/services/authenticator.rb +110 -12
  24. data/lib/api_keys/version.rb +1 -1
  25. data/lib/api_keys.rb +1 -0
  26. data/lib/generators/api_keys/add_key_types_generator.rb +1 -1
  27. data/lib/generators/api_keys/add_restrictions_generator.rb +60 -0
  28. data/lib/generators/api_keys/templates/add_restrictions_to_api_keys.rb.erb +54 -0
  29. data/lib/generators/api_keys/templates/create_api_keys_table.rb.erb +22 -0
  30. data/lib/generators/api_keys/templates/initializer.rb +49 -6
  31. 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
- if @api_key.update(api_key_update_params)
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, scopes: [])
137
- permitted_params[:scopes]&.reject!(&:blank?) # Filter out blank strings
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
- # Only allow updating name and scopes.
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
- permitted_params[:scopes]&.reject!(&:blank?) # Filter out blank strings
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), local: true) do |form| %>
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
- <%# This assumes a variable `@available_scopes` is passed to the view, %>
67
- <%# containing an array of scope strings allowed for this context. %>
68
- <%# This list might come from global configuration or owner-specific settings. %>
69
- <%# Example: @available_scopes = ["read", "write", "admin"] %>
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: true,
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
- <%# Define available scopes for the edit form context %>
100
- <%# This assumes the available scopes are the same as the default ones. %>
101
- <%# Consider passing @available_scopes from the controller if logic is more complex. %>
102
- <% @available_scopes = current_api_keys_owner.class.api_keys_settings.dig(:default_scopes) %>
103
- <% if defined?(@available_scopes) && @available_scopes.present? %>
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), # Check if scope is already assigned
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>Your API key will only be shown once after creation. <strong>Your key cannot be recovered:</strong> copy it immediately and store it securely.</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> &mdash; you can view the full key anytime in your dashboard</li>
27
- <li><strong>Cannot be revoked individually</strong> &mdash; maintain an application-level disable/replacement procedure</li>
27
+ <li><strong>Revoke and replace a leaked key</strong> &mdash; 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
- render_unauthorized(error_code: result.error_code, message: result.message)
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