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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -0
  3. data/README.md +158 -9
  4. data/SECURITY.md +22 -2
  5. data/app/controllers/api_keys/keys_controller.rb +136 -14
  6. data/app/views/api_keys/keys/_form.html.erb +85 -17
  7. data/app/views/api_keys/keys/_key_row.html.erb +4 -2
  8. data/app/views/api_keys/keys/_refusal_notice.html.erb +24 -0
  9. data/app/views/api_keys/keys/_restriction_fields.html.erb +4 -2
  10. data/app/views/api_keys/keys/_show_token.html.erb +4 -7
  11. data/app/views/api_keys/keys/edit.html.erb +2 -0
  12. data/app/views/api_keys/security/best_practices.html.erb +1 -1
  13. data/app/views/layouts/api_keys/application.html.erb +6 -0
  14. data/docs/reviews/2026-10-18-0-5-1/01-astra-review-of-0-5-0.md +55 -0
  15. data/docs/reviews/2026-10-18-0-5-1/02-astra-re-review-of-0-5-1.md +36 -0
  16. data/docs/reviews/2026-10-18-0-5-1/03-opus-review-of-0-5-1.md +5 -0
  17. data/docs/reviews/2026-10-18-0-6-0/01-astra-review-of-0-6-0.md +36 -0
  18. data/docs/reviews/2026-10-18-0-6-0/02-astra-re-review-of-0-6-0.md +34 -0
  19. data/docs/reviews/2026-10-18-0-6-0/03-sol-re-check-of-0-6-0.md +41 -0
  20. data/lib/api_keys/authentication.rb +123 -7
  21. data/lib/api_keys/configuration.rb +37 -6
  22. data/lib/api_keys/form_builder_extensions.rb +42 -3
  23. data/lib/api_keys/helpers/token_session.rb +257 -8
  24. data/lib/api_keys/helpers/view_helpers.rb +33 -0
  25. data/lib/api_keys/jobs/update_stats_job.rb +45 -1
  26. data/lib/api_keys/models/api_key.rb +293 -7
  27. data/lib/api_keys/models/concerns/has_api_keys.rb +10 -2
  28. data/lib/api_keys/restrictions.rb +75 -24
  29. data/lib/api_keys/services/authenticator.rb +24 -2
  30. data/lib/api_keys/version.rb +1 -1
  31. data/lib/api_keys.rb +7 -0
  32. data/lib/generators/api_keys/add_key_activity_generator.rb +59 -0
  33. data/lib/generators/api_keys/add_restrictions_generator.rb +4 -1
  34. data/lib/generators/api_keys/templates/add_key_activity_to_api_keys.rb.erb +37 -0
  35. data/lib/generators/api_keys/templates/create_api_keys_table.rb.erb +12 -0
  36. data/lib/generators/api_keys/templates/initializer.rb +32 -7
  37. 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
- helper_method :key_types_feature_enabled?, :api_keys_allowed_restriction_kinds
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
- # We need to retrieve the plaintext token stored temporarily
40
- # after creation. This relies on how we handle creation.
41
- # We'll likely store it in the session flash or pass it directly.
42
- @plaintext_token = ApiKeys::TokenSession.retrieve_once(session, api_key: @api_key)
43
- unless @plaintext_token
44
- # If accessed directly without the token, redirect or show an error
45
- redirect_to keys_path, alert: "API key token can only be shown once immediately after creation."
46
- end
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: submitted_params[: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
- if @api_key.update(api_key_update_params)
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
- permitted_params[:scopes]&.reject!(&:blank?) # Filter out blank strings
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
- permitted_params[:scopes]&.reject!(&:blank?) # Filter out blank strings
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
- Limited to <%= config[:permissions]&.join(", ") || "no" %> permissions
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
- <%# This assumes a variable `@available_scopes` is passed to the view, %>
68
- <%# containing an array of scope strings allowed for this context. %>
69
- <%# This list might come from global configuration or owner-specific settings. %>
70
- <%# Example: @available_scopes = ["read", "write", "admin"] %>
71
- <% @available_scopes = current_api_keys_owner.class.api_keys_settings.dig(:default_scopes) %>
72
- <% if defined?(@available_scopes) && @available_scopes.present? %>
73
- <div>
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: true,
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
- </div>
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
- <%# Define available scopes for the edit form context %>
103
- <%# This assumes the available scopes are the same as the default ones. %>
104
- <%# Consider passing @available_scopes from the controller if logic is more complex. %>
105
- <% @available_scopes = current_api_keys_owner.class.api_keys_settings.dig(:default_scopes) %>
106
- <% if defined?(@available_scopes) && @available_scopes.present? %>
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), # Check if scope is already assigned
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
- <% if key.scopes.present? %>
47
- <% key.scopes.each do |scope| %>
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.allowed_origins.join(", "),
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.allowed_ips.join(", "),
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
- <% if api_key.scopes.present? %>
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
- <% if api_key.scopes.present? %>
43
- <% api_key.scopes.each do |scope| %>
44
- <kbd class="tag is-small"><%= scope %></kbd>
45
- <% end %>
46
- <% else %>
47
- &mdash;
43
+ <% displayed_scopes.each do |scope| %>
44
+ <kbd class="tag is-small"><%= scope %></kbd>
48
45
  <% end %>
49
46
  </div>
50
47
  <% end %>
@@ -1,5 +1,7 @@
1
1
  <div class="col-5">
2
2
  <h2>Edit API Key: <%= @api_key.name.presence || @api_key.masked_token %></h2>
3
3
 
4
+ <%= render 'refusal_notice', key: @api_key %>
5
+
4
6
  <%= render 'form', api_key: @api_key %>
5
7
  </div>
@@ -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 %>
@@ -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.