api_keys 0.4.3 ā 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +39 -0
- data/README.md +195 -27
- data/SECURITY.md +21 -2
- data/app/controllers/api_keys/keys_controller.rb +137 -11
- data/app/views/api_keys/keys/_form.html.erb +80 -17
- data/app/views/api_keys/keys/_key_badges.html.erb +7 -0
- data/app/views/api_keys/keys/_restriction_fields.html.erb +34 -0
- data/app/views/api_keys/security/best_practices.html.erb +1 -1
- data/app/views/layouts/api_keys/application.html.erb +9 -1
- data/docs/reviews/2026-10-18-0-5-1/01-astra-review-of-0-5-0.md +55 -0
- data/docs/reviews/2026-10-18-0-5-1/02-astra-re-review-of-0-5-1.md +36 -0
- data/docs/reviews/2026-10-18-0-5-1/03-opus-review-of-0-5-1.md +5 -0
- data/lib/api_keys/authentication.rb +7 -1
- data/lib/api_keys/configuration.rb +54 -8
- data/lib/api_keys/engine.rb +6 -0
- data/lib/api_keys/errors.rb +11 -0
- data/lib/api_keys/form_builder_extensions.rb +40 -3
- data/lib/api_keys/helpers/token_session.rb +92 -3
- data/lib/api_keys/models/api_key.rb +240 -11
- data/lib/api_keys/models/concerns/has_api_keys.rb +67 -10
- data/lib/api_keys/restrictions.rb +430 -0
- data/lib/api_keys/services/authenticator.rb +110 -12
- data/lib/api_keys/version.rb +1 -1
- data/lib/api_keys.rb +1 -0
- data/lib/generators/api_keys/add_key_types_generator.rb +1 -1
- data/lib/generators/api_keys/add_restrictions_generator.rb +60 -0
- data/lib/generators/api_keys/templates/add_restrictions_to_api_keys.rb.erb +54 -0
- data/lib/generators/api_keys/templates/create_api_keys_table.rb.erb +22 -0
- data/lib/generators/api_keys/templates/initializer.rb +49 -6
- metadata +8 -1
|
@@ -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
|
|
143
|
-
#
|
|
144
|
-
#
|
|
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
|
|
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
|
|
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
|
+
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
|