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
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+ require "rails/generators/active_record"
5
+
6
+ module ApiKeys
7
+ module Generators
8
+ # Rails generator for adding the `restrictions` column to the api_keys table.
9
+ # This generator is for existing installations that want to lock keys to
10
+ # specific web origins or IP addresses. New installs get the column from the
11
+ # install generator, so they never need to run this.
12
+ class AddRestrictionsGenerator < Rails::Generators::Base
13
+ include ActiveRecord::Generators::Migration
14
+
15
+ source_root File.expand_path("templates", __dir__)
16
+
17
+ # Implement the required interface for Rails::Generators::Migration.
18
+ def self.next_migration_number(dirname)
19
+ next_migration_number = current_migration_number(dirname) + 1
20
+ ActiveRecord::Migration.next_migration_number(next_migration_number)
21
+ end
22
+
23
+ # Creates the migration file using the template.
24
+ def create_migration_file
25
+ migration_template "add_restrictions_to_api_keys.rb.erb",
26
+ File.join(db_migrate_path, "add_restrictions_to_api_keys.rb")
27
+ end
28
+
29
+ # Displays helpful information to the user after installation.
30
+ def display_post_install_message
31
+ say "\n🌐 Request restrictions migration created!", :green
32
+ say "\nNext steps:"
33
+ say " 1. Run `rails db:migrate` to add the restrictions column."
34
+ say "\n 2. Lock a key to the places it may be used from:"
35
+ say " user.create_api_key!(name: 'Widget key', allowed_origins: 'example.com, *.example.com')"
36
+ say " key.allowed_ips = '203.0.113.7, 10.0.0.0/8'"
37
+ say "\n Keys without restrictions keep working from anywhere; presence is the toggle."
38
+ say "\n 3. Optionally cap which restriction kinds each key type may carry:"
39
+ say " config.key_types = {"
40
+ say " publishable: { prefix: 'pk', permissions: %w[read], public: true,"
41
+ say " restrictions: [:origins] },"
42
+ say " secret: { prefix: 'sk', permissions: :all, restrictions: [:ips] }"
43
+ say " }"
44
+ say "\n 4. Make sure the client IP is truthful before relying on allowed_ips:"
45
+ say " # Behind a proxy that is the only way in: list it in trusted_proxies, keep the default."
46
+ say " config.action_dispatch.trusted_proxies = ..."
47
+ say " # Rails directly reachable: check the socket peer, or X-Forwarded-For can be forged."
48
+ say " config.client_ip_resolver = ->(request) { request.remote_addr }"
49
+ say " # Trust a vendor header only when ingress blocks requests that bypass that vendor."
50
+ say "\nSee the api_keys README for detailed usage and examples.", :cyan
51
+ end
52
+
53
+ private
54
+
55
+ def migration_version
56
+ "[#{ActiveRecord::VERSION::STRING.to_f}]"
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Migration to add the `restrictions` column to the api_keys table.
4
+ # This enables request restrictions: locking a key to specific web origins
5
+ # (with `*.` subdomain wildcards) and/or to specific IP addresses and CIDR
6
+ # ranges.
7
+ #
8
+ # Run this migration if you're upgrading from a version of api_keys that
9
+ # didn't have request restrictions support. The column defaults to an empty
10
+ # object, which means "unrestricted", so existing keys are unaffected.
11
+ class AddRestrictionsToApiKeys < ActiveRecord::Migration<%= migration_version %>
12
+ CONSTRAINT_NAME = "api_keys_restrictions_is_object"
13
+
14
+ def up
15
+ add_column :api_keys, :restrictions, json_column_type, default: {}, null: false
16
+ add_restrictions_object_constraint
17
+ end
18
+
19
+ def down
20
+ remove_column :api_keys, :restrictions
21
+ end
22
+
23
+ private
24
+
25
+ # Helper method to determine the appropriate JSON column type based on the database adapter.
26
+ # Uses :jsonb for PostgreSQL for better performance and indexing, :json otherwise.
27
+ def json_column_type
28
+ # Check connection availability for adapter name inspection
29
+ if ActiveRecord::Base.connection.adapter_name.downcase.include?('postgresql')
30
+ :jsonb
31
+ else
32
+ :json
33
+ end
34
+ rescue ActiveRecord::ConnectionNotEstablished
35
+ # Fallback during initial setup or if connection isn't available
36
+ :text
37
+ end
38
+
39
+ # JSON columns can also hold arrays and scalars. The application requires an
40
+ # object so damaged or validation-bypassing writes cannot erase a policy.
41
+ def add_restrictions_object_constraint
42
+ return unless connection.supports_check_constraints?
43
+
44
+ expression = case connection.adapter_name.downcase
45
+ when /postgres/
46
+ "jsonb_typeof(restrictions) = 'object'"
47
+ when /sqlite/
48
+ "json_valid(restrictions) AND json_type(restrictions) = 'object'"
49
+ when /mysql|trilogy/
50
+ "JSON_TYPE(restrictions) = 'OBJECT'"
51
+ end
52
+ add_check_constraint :api_keys, expression, name: CONSTRAINT_NAME if expression
53
+ end
54
+ end
@@ -2,6 +2,8 @@
2
2
 
3
3
  # Migration responsible for creating the core api_keys table.
4
4
  class CreateApiKeysTable < ActiveRecord::Migration<%= migration_version %>
5
+ RESTRICTIONS_CONSTRAINT_NAME = "api_keys_restrictions_is_object"
6
+
5
7
  def change
6
8
  primary_key_type, foreign_key_type = primary_and_foreign_key_types
7
9
 
@@ -30,6 +32,10 @@ class CreateApiKeysTable < ActiveRecord::Migration<%= migration_version %>
30
32
  # Optional freeform metadata for tagging
31
33
  t.send(json_column_type, :metadata, default: {}, null: false)
32
34
 
35
+ # Optional request restrictions: which web origins and IP addresses may
36
+ # present this key. An empty object means the key is unrestricted.
37
+ t.send(json_column_type, :restrictions, default: {}, null: false)
38
+
33
39
  # Optional auto-expiration timestamp
34
40
  t.datetime :expires_at
35
41
 
@@ -67,6 +73,8 @@ class CreateApiKeysTable < ActiveRecord::Migration<%= migration_version %>
67
73
  t.index :environment
68
74
  t.index [:owner_type, :owner_id, :key_type, :environment], name: "index_api_keys_owner_type_env"
69
75
  end
76
+
77
+ add_restrictions_object_constraint
70
78
  end
71
79
 
72
80
  private
@@ -94,4 +102,18 @@ class CreateApiKeysTable < ActiveRecord::Migration<%= migration_version %>
94
102
  # Fallback during initial setup or if connection isn't available
95
103
  :text
96
104
  end
105
+
106
+ def add_restrictions_object_constraint
107
+ return unless connection.supports_check_constraints?
108
+
109
+ expression = case connection.adapter_name.downcase
110
+ when /postgres/
111
+ "jsonb_typeof(restrictions) = 'object'"
112
+ when /sqlite/
113
+ "json_valid(restrictions) AND json_type(restrictions) = 'object'"
114
+ when /mysql|trilogy/
115
+ "JSON_TYPE(restrictions) = 'OBJECT'"
116
+ end
117
+ add_check_constraint :api_keys, expression, name: RESTRICTIONS_CONSTRAINT_NAME if expression
118
+ end
97
119
  end
@@ -139,23 +139,28 @@ ApiKeys.configure do |config|
139
139
  # - permissions: Scope ceiling - array of allowed scopes, or :all for unrestricted
140
140
  # - revocable: Whether keys of this type can be revoked/deleted (default: true)
141
141
  # - limit: Max keys of this type per owner per environment (nil = unlimited)
142
- # - public: If true AND revocable: false, stores plaintext token in metadata
143
- # so it can be viewed again in the dashboard. Use ONLY for publishable
144
- # keys designed to be embedded in distributed apps. Public types must
142
+ # - public: If true, stores plaintext token in metadata so it can be viewed
143
+ # again in the dashboard. Use ONLY for publishable keys designed
144
+ # to be embedded in distributed apps. Public types must
145
145
  # use a finite, non-empty permissions array (never :all). (default: false)
146
146
  # SECURITY: NEVER set public: true on secret keys!
147
+ # - restrictions: Which request-restriction kinds keys of this type may carry:
148
+ # any subset of [:origins, :ips]. Omitted = both allowed.
149
+ # `restrictions: []` forbids restrictions for this type.
150
+ # See "REQUEST RESTRICTIONS" below.
147
151
  #
148
152
  # config.key_types = {
149
153
  # publishable: {
150
154
  # prefix: "pk", # → pk_test_, pk_live_
151
155
  # permissions: %w[read validate], # Can ONLY have these scopes
152
- # revocable: false, # Cannot be revoked - protects deployed apps!
153
156
  # public: true, # Store token for later viewing in dashboard
154
- # limit: 1 # Only 1 publishable key per environment
157
+ # limit: 1, # Only 1 publishable key per environment
158
+ # restrictions: [:origins] # Browser keys lock to domains, not IPs
155
159
  # },
156
160
  # secret: {
157
161
  # prefix: "sk", # → sk_test_, sk_live_
158
- # permissions: :all # No scope restrictions
162
+ # permissions: :all, # No scope restrictions
163
+ # restrictions: [:ips] # Server keys lock to addresses, not domains
159
164
  # # revocable: true (default)
160
165
  # # public: false (default) - NEVER store secret keys!
161
166
  # # limit: nil (default = unlimited)
@@ -275,6 +280,44 @@ ApiKeys.configure do |config|
275
280
  # Default: true
276
281
  # config.https_strict_mode = true
277
282
 
283
+ # ============================================================================
284
+ # REQUEST RESTRICTIONS (origin and IP allowlists)
285
+ # ============================================================================
286
+ #
287
+ # Any key can be locked to the places it may be used from:
288
+ #
289
+ # user.create_api_key!(name: "Widget key", allowed_origins: "example.com, *.example.com")
290
+ # key.allowed_ips = "203.0.113.7, 10.0.0.0/8"
291
+ #
292
+ # Origins are matched against the browser's Origin header (falling back to
293
+ # Referer); IPs are matched with CIDR support. Within a list any entry
294
+ # admits the request; every list that is set must pass. Keys with no
295
+ # restrictions work from anywhere, so nothing changes until you opt in.
296
+ # Policy mismatches answer 403 with `origin_not_allowed` / `ip_not_allowed`.
297
+ # Malformed stored policy also fails closed with `restriction_misconfigured`.
298
+ #
299
+ # Requires the restrictions column:
300
+ # rails generate api_keys:add_restrictions && rails db:migrate
301
+ # ============================================================================
302
+
303
+ # How the client IP is resolved for `allowed_ips` checks. Pick the line that
304
+ # matches how clients reach this Rails server:
305
+ #
306
+ # Behind a load balancer, reverse proxy, or CDN that is the ONLY way in and
307
+ # that overwrites or appends X-Forwarded-For, with its addresses covered by
308
+ # config.action_dispatch.trusted_proxies: keep the default.
309
+ # config.client_ip_resolver = ->(request) { request.remote_ip } # default
310
+ #
311
+ # Directly reachable (no proxy, or a proxy that can be bypassed): check the
312
+ # socket peer. With remote_ip, a client could send
313
+ # `X-Forwarded-For: <an allowed address>` and pass the check.
314
+ # config.client_ip_resolver = ->(request) { request.remote_addr }
315
+ #
316
+ # trusted_proxies alone is not a boundary: it does not stop a client that
317
+ # bypasses the proxy from sending the header. Trust a vendor header only
318
+ # when your network ingress rejects requests that bypass that vendor:
319
+ # config.client_ip_resolver = ->(request) { request.headers.fetch("CF-Connecting-IP") }
320
+
278
321
  # ============================================================================
279
322
  # BACKGROUND JOBS & CALLBACKS
280
323
  # ============================================================================
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: api_keys
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.3
4
+ version: 0.5.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - rameerez
@@ -84,6 +84,7 @@ files:
84
84
  - app/views/api_keys/keys/_key_status.html.erb
85
85
  - app/views/api_keys/keys/_keys_table.html.erb
86
86
  - app/views/api_keys/keys/_publishable_keys.html.erb
87
+ - app/views/api_keys/keys/_restriction_fields.html.erb
87
88
  - app/views/api_keys/keys/_secret_keys.html.erb
88
89
  - app/views/api_keys/keys/_show_token.html.erb
89
90
  - app/views/api_keys/keys/_token_display.html.erb
@@ -94,6 +95,9 @@ files:
94
95
  - app/views/api_keys/security/best_practices.html.erb
95
96
  - app/views/layouts/api_keys/application.html.erb
96
97
  - config/routes.rb
98
+ - docs/reviews/2026-10-18-0-5-1/01-astra-review-of-0-5-0.md
99
+ - docs/reviews/2026-10-18-0-5-1/02-astra-re-review-of-0-5-1.md
100
+ - docs/reviews/2026-10-18-0-5-1/03-opus-review-of-0-5-1.md
97
101
  - lib/api_keys.rb
98
102
  - lib/api_keys/authentication.rb
99
103
  - lib/api_keys/configuration.rb
@@ -109,6 +113,7 @@ files:
109
113
  - lib/api_keys/logging.rb
110
114
  - lib/api_keys/models/api_key.rb
111
115
  - lib/api_keys/models/concerns/has_api_keys.rb
116
+ - lib/api_keys/restrictions.rb
112
117
  - lib/api_keys/services/authenticator.rb
113
118
  - lib/api_keys/services/digestor.rb
114
119
  - lib/api_keys/services/token_generator.rb
@@ -116,9 +121,11 @@ files:
116
121
  - lib/api_keys/version.rb
117
122
  - lib/generators/api_keys/add_authentication_index_generator.rb
118
123
  - lib/generators/api_keys/add_key_types_generator.rb
124
+ - lib/generators/api_keys/add_restrictions_generator.rb
119
125
  - lib/generators/api_keys/install_generator.rb
120
126
  - lib/generators/api_keys/templates/add_authentication_index_to_api_keys.rb.erb
121
127
  - lib/generators/api_keys/templates/add_key_types_to_api_keys.rb.erb
128
+ - lib/generators/api_keys/templates/add_restrictions_to_api_keys.rb.erb
122
129
  - lib/generators/api_keys/templates/create_api_keys_table.rb.erb
123
130
  - lib/generators/api_keys/templates/initializer.rb
124
131
  homepage: https://github.com/rameerez/api_keys