api_keys 0.7.0 → 0.8.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 62087b06751cfbfe1b08bc1db54179fa1ce72844da86166a5198887d7b834a3f
4
- data.tar.gz: 51ddeccbaee055eaf9d1cec6677b9514b478bf0ff4764768dee7e40e731c8685
3
+ metadata.gz: dca5913a99885ff5a5560a108e15d1ed8c81ef82347f6bdc3606559cf5a47ef2
4
+ data.tar.gz: 574b8fa3df8e307bbe24c03292acb754ef44f8453dbda17719b3e3b36eae74a5
5
5
  SHA512:
6
- metadata.gz: 5b8adba5c3631e272a77bfd600f4667981bf91048e529aeac7b5f6f0c6d9b1967c989edff1ee23ee9d2a2bb9da3d83a9206e7b0a20ca26f741ebb8bae5abc586
7
- data.tar.gz: e0bbcb9c1bf0be08bdbe9ffe035e7dbe60a2cddc0520474fb94c40ca6cba53c46fc8f4ce5cb9537fd14ab4392149ac294a89d7599a61ba95e66a5f8e1864fd6b
6
+ metadata.gz: ab7dcee45ad1c6efae2156ee2c3e1e10745ad09f4a2d4b4eed1e2d120bf78e91fe9bfdb9919aeba87b91c2ad13720bf93f9e98802053fd73f3032c1033c4f945
7
+ data.tar.gz: 45672849171455b8bdb2c4af0df5413fe0f693d36556e35a053cb64c184e239ee646d11776ac9d9f169b82891839ff5ab30a867ca20d5eb3037a740c8baec826
data/CHANGELOG.md CHANGED
@@ -1,3 +1,34 @@
1
+ ## [0.8.1] - 2026-10-26
2
+
3
+ Two fixes found when an app on the fleet upgraded from 0.5 to 0.8.0. The second one now covers every upgrade generator.
4
+
5
+ ### Fixed
6
+
7
+ - The one-time token is readable in transactional tests again, once, as in production. Since 0.6.0 a retrieval inside any open transaction was refused, and Rails' transactional tests run each test inside one, so a test that created a key and read its token got nil (`:inside_transaction`), and the dashboard's token page redirected. When `Rails.env` is test and the only open transaction is an outer, non-joinable one (what Rails' transactional tests, rspec-rails and DatabaseCleaner's `:transaction` strategy open, and what a system test's server thread shares), the claim of `token_shown_at` runs in it: the first retrieval returns the token, and a second one, or one from a copy of the session, does not. A transaction opened on top of it is still refused. Outside the test environment nothing changes. Unlike `config.idempotency_in_test_transactions`, this needs no switch: apps whose tests broke on 0.6.0 must get them back without new configuration, and the exemption covers only the test's own transaction, not every depth. The check reads the shape of the transaction, so in a test without a transaction of its own, an outer `transaction(joinable: false)` that the app opens is exempt too; the README lists the cases. Apps can remove their test-only workarounds.
8
+ - Every upgrade generator now writes a migration that strong_migrations accepts on PostgreSQL. Before, it refused three of them. Those three now run with `disable_ddl_transaction!`, and each step checks what exists, so a run that stopped half way can run again. An invalid index that a failed concurrent build left on PostgreSQL counts as absent: the next run removes it and builds it again (`add_created_by`, `add_key_types`, `add_authentication_index`). On MySQL and SQLite they run as before, except where this list says otherwise. A migration that an earlier version generated and that already ran needs nothing.
9
+ - `add_created_by` (0.7.0) built the polymorphic index inside the migration's transaction, which blocks writes to `api_keys`. It now adds the columns, then builds `index_api_keys_on_created_by` with `algorithm: :concurrently` on PostgreSQL (and PostGIS).
10
+ - `add_key_types` (0.3.0) built its three indexes inside the transaction. They are now built concurrently on PostgreSQL. strong_migrations calls the four-column index a best-practice problem on every database. The install migration has the same index, so the migration keeps it inside `safety_assured` when the app uses strong_migrations.
11
+ - `add_restrictions` (0.5.0) added its check constraint and checked every row under a lock that blocks reads and writes. On PostgreSQL it now adds the constraint without that check (`validate: false`), then validates it, which does not block reads and writes. On MySQL, which refuses a literal default on a JSON column, the column now defaults to the expression `JSON_OBJECT()` (MySQL 8.0.13 or later), so the migration runs and existing keys get an empty object. On MySQL strong_migrations still refuses this migration twice: at `add_column`, for the callable default, and at the check constraint, which MySQL adds by copying the table. There, check the size of `api_keys`, then wrap the whole body of `up` in `safety_assured`, and run it when the table can be locked for the copy.
12
+ - `add_authentication_index` (0.4.0) already built its index concurrently. It now also does so on PostGIS.
13
+ - `add_key_activity`, `add_device_authorizations` and `add_idempotency_keys` already passed strong_migrations.
14
+ - Every generated migration, the install included, passes rubocop-rails-omakase, the Rails default style. Array literals of symbols are `%i[]`, the key types are two methods instead of an array, and each `case` has its own line under `expression =`. Before, four files had 16 offenses.
15
+
16
+ ### Changed
17
+
18
+ - `ApiKeys.test_environment?` says whether `Rails.env` is test. The token handoff and `config.idempotency_in_test_transactions` both use it.
19
+
20
+ ### Tests
21
+
22
+ - The database check (`test/databases/check.rb`, in CI on PostgreSQL, MySQL and SQLite) now runs all seven upgrade migrations as `rails db:migrate` runs them, with strong_migrations, from an install made before 0.3.0 that has a key. It runs them down and up again, and again after a run that stopped half way. On PostgreSQL it also leaves an invalid index from a failed concurrent build and shows that the next run builds it again.
23
+ - A generator test generates all eight migrations and runs RuboCop with the omakase config on them.
24
+ - The token handoff is tested in transactional tests (the helper and the dashboard) and with real commits.
25
+
26
+ ## [0.8.0] - 2026-10-25
27
+
28
+ ### Added
29
+
30
+ - `config.require_bearer_scheme`. When true, the authentication header is accepted only as `Bearer <token>` (any case, spaces or tabs after the scheme); a bare token, another scheme such as `Basic`, or a lone `Bearer` gets the ordinary 401 `invalid_token`, is never looked up, and never falls back to `config.query_param`. It applies to whichever header `config.header` names. The default, false, keeps accepting a bare token as every earlier version did. Asked for by ReplyBrain, whose public API should parse one credential shape only.
31
+
1
32
  ## [0.7.0] - 2026-10-24
2
33
 
3
34
  Four features that an invoicing app on the fleet runs in production today, moved into the gem with their tests so that apps on the fleet can delete their own copies. Each one is opt-in: an app that does not use it sees no change.
data/README.md CHANGED
@@ -32,6 +32,8 @@ And you're done!
32
32
 
33
33
  ### Upgrading an existing installation
34
34
 
35
+ Every upgrade generator below writes a migration that strong_migrations accepts on PostgreSQL (0.8.1). There the indexes on `api_keys` are built concurrently, and the restrictions check constraint is validated apart, outside a transaction, so writes to `api_keys` go on. If a concurrent build fails (a lock or statement timeout, for example), PostgreSQL keeps an invalid index; the next run of the migration removes it and builds it again. On MySQL strong_migrations accepts all of them except `add_restrictions`, which it refuses twice: first at `add_column`, for the callable `JSON_OBJECT()` default (`add_column_default_callable`), then at the check constraint, which MySQL adds by copying the table. On MySQL with strong_migrations, check the size of `api_keys` first. Then wrap the whole body of `up` in the generated migration (the column and the constraint) in `safety_assured do ... end`, and run it when `api_keys` can be locked for the copy, for example in a maintenance window.
36
+
35
37
  Install the bounded bcrypt authentication lookup index before deploying the current hardening changes:
36
38
 
37
39
  ```bash
@@ -66,6 +68,8 @@ rails generate api_keys:add_created_by
66
68
  rails db:migrate
67
69
  ```
68
70
 
71
+ On PostgreSQL this migration builds its index concurrently, outside a transaction, so it does not block writes to `api_keys` and strong_migrations accepts it (0.8.1). Each step checks what exists, so a run that stopped half way can run again, and an invalid index from a failed concurrent build is built again.
72
+
69
73
  The device flow and Idempotency-Key each need a table of their own. New and existing installations run these only if they use the feature:
70
74
 
71
75
  ```bash
@@ -91,7 +95,7 @@ On 0.6.x, nothing changes until you opt in to a 0.7.0 feature. A 0.6.0 job worke
91
95
  On 0.4.x, the calls of a custom key controller keep working unchanged: `create_api_key!(name:, key_type:, expires_at:)`, `ApiKeys::TokenSession.store(session, @api_key)` and `ApiKeys::TokenSession.retrieve_once(session)`. A handoff stored by 0.4.x stays readable after the deploy. Read the [CHANGELOG](CHANGELOG.md) for 0.5.0 to 0.6.0 before you upgrade. These changes are the ones a 0.4.x app sees:
92
96
 
93
97
  - `api_key_scopes_checkboxes` renders an empty `api_key[scopes][]` field before the boxes (0.5.1). A form with every box cleared now saves no scopes, where 0.4.x kept the old ones. If an edit form offers only some scopes, save `ApiKey#scopes_after_form_edit` (see [Form Builder Extensions](#form-builder-extensions-opt-in)).
94
- - A token's one-time handoff is one-time across copies of the session (0.5.1), and once ever per key after `add_key_activity` (0.6.0). A retrieval inside an open database transaction is refused (0.6.0): exclude the token page from a host `around_action` that wraps requests in a transaction.
98
+ - A token's one-time handoff is one-time across copies of the session (0.5.1), and once ever per key after `add_key_activity` (0.6.0). A retrieval inside an open database transaction is refused (0.6.0): exclude the token page from a host `around_action` that wraps requests in a transaction. The transaction of a transactional test does not count (0.8.1).
95
99
  - Deploy job workers with the web processes. A refusal job from 0.6.0 or later errors on a 0.4.x worker.
96
100
 
97
101
  ## Quick Start
@@ -554,6 +558,7 @@ The database applies it atomically, and the claim commits before the token is re
554
558
 
555
559
  - **With the column, the token is released at most once per key.** A cache flush, a restart, or an eviction changes nothing, and two simultaneous first retrievals are serialized by the database: one wins. `ApiKey#token_shown_at` and `#token_shown?` report it. A key whose row no longer exists gets nothing: zero rows claimed fails closed.
556
560
  - **A retrieval attempted inside a database transaction is refused, and can be retried outside it.** Inside a transaction the claim would commit only with it, after the token had been returned, and a rollback would undo it. No cache can stand in for that (a process-local store protects one process), so whatever the cache, the token is withheld with `:inside_transaction`, nothing is recorded, and the handoff stays in the session for a retrieval outside the transaction. The check is Active Record's transaction manager on the `ApiKey` connection: a transaction opened with raw SQL (`BEGIN`) is not detected, and one on another database's connection does not matter. The dashboard opens no transaction itself, but its controller inherits from your `ApplicationController` (or the configured parent): a host `around_action` that wraps requests in a transaction will see the refusal and must exclude the token page.
561
+ - **In a transactional test, the token is released once, as in production (0.8.1).** Rails' transactional tests (also rspec-rails and DatabaseCleaner's `:transaction` strategy) open one outer, non-joinable transaction around each test, and a system test's server thread shares it. When `Rails.env` is test and that is the only open transaction, the claim runs in it: the first retrieval in the test returns the token, and a second one, or one from a copy of the session, does not. The rollback at the end of the test removes the key with its claim. A transaction that the app or the test opens on top of it still refuses the retrieval, as in production, so a test sees a host `around_action` that would break the token page. From 0.6.0 to 0.8.0 every retrieval in a transactional test was refused. The check reads the shape of the transaction, not where it came from, so three cases follow. In a test without a transaction of its own (`use_transactional_tests = false`, or truncation), an outer `transaction(joinable: false)` that the app opens is exempt too, where production refuses it. An environment with another name (`ci`, `staging`) is never exempt: only `Rails.env` test is. A test transaction inside another one (fixtures and DatabaseCleaner's `:transaction` strategy together) is two deep, so it is refused, as in 0.8.0. No setting turns this on: apps whose tests broke on 0.6.0 need no new configuration.
557
562
  - **The column is written only by that claim.** The model marks it read-only, so no form, mass assignment, or `update` can clear it and reopen a handoff.
558
563
  - **The cache marker from 0.5.1 stays as a second layer.** Each handoff carries a random ID inside its encrypted payload, and retrieval records it in `Rails.cache` with `write(key, true, unless_exist: true, expires_in: 10.minutes)`. With the column it refuses a release the claim did not see (a 0.5.1 process that served the token during a rolling deploy), and it is the only cross-copy layer for a handoff that names no key (`TokenSession.store(session, "a token string")`).
559
564
  - **Without the column** (an existing install that has not run `rails generate api_keys:add_key_activity`), the gem behaves as 0.5.1 and the marker is the only cross-copy layer. The token is then released at most once only when every application process shares a store that implements `unless_exist:` atomically (Redis, Memcached), and that store keeps the marker for the whole ten-minute handoff lifetime. If the cache is flushed, restarted without persistence, or evicts the marker within those ten minutes, a copy of the session taken before retrieval can recover the token again for the rest of that window. **`MemoryStore`** only covers the process that holds it. **`FileStore`** checks and then writes, so it stops a later replay on the same host but not two requests that arrive at the same instant. **Solid Cache** (Rails 8's default production store) implements `unless_exist:` as a `SELECT … FOR UPDATE` followed by an upsert; on PostgreSQL that locks nothing while the key does not exist yet, so two simultaneous first retrievals can both succeed. **`ActiveSupport::Cache::NullStore`** (Rails' generated development and test default), or no `Rails.cache` at all, records nothing, so the session deletion is the only protection.
@@ -1298,9 +1303,19 @@ See the [Key Types](#key-types-stripe-style-publishable--secret-keys) section be
1298
1303
 
1299
1304
  Some highlights:
1300
1305
 
1306
+ ### Require the `Bearer` scheme
1307
+
1308
+ Clients send `Authorization: Bearer ak_123abc...`. For compatibility, a bare `Authorization: ak_123abc...` is accepted too. A public API should accept one shape only, so turn the bare token off:
1309
+
1310
+ ```ruby
1311
+ config.require_bearer_scheme = true
1312
+ ```
1313
+
1314
+ Now a header without the scheme (a bare token, `Basic …`, `Token …`) gets the same 401 `invalid_token` as any wrong key, and is never looked up. It applies to whichever header you configured in `config.header`, and a refused header never falls back to the query parameter.
1315
+
1301
1316
  ### Accept API keys via query params instead of Authentication HTTP headers
1302
1317
 
1303
- By default, the `api_key` gem expects API keys to come *exclusively* as HTTP Authentication Bearer tokens, for security purposes. But you can allow users to make requests to your endpoints with the API key token passed as a URL query param too, like this:
1318
+ By default, the `api_key` gem expects API keys to come *exclusively* in the `Authorization` header, for security purposes. But you can allow users to make requests to your endpoints with the API key token passed as a URL query param too, like this:
1304
1319
 
1305
1320
  ```
1306
1321
  https://example.com/api/endpoint?api_key=ak_123abc...
@@ -1869,6 +1884,12 @@ bundle exec appraisal rails-8.0 rake test
1869
1884
  bundle exec appraisal rails-8.1 rake test
1870
1885
  ```
1871
1886
 
1887
+ `test/databases/check.rb` runs the generated upgrade migrations as `rails db:migrate` runs them, with strong_migrations, and the row locks of Idempotency-Key and the device flow, on a real database. CI runs it on PostgreSQL, MySQL and SQLite:
1888
+
1889
+ ```bash
1890
+ BUNDLE_GEMFILE=test/databases/Gemfile DB_ADAPTER=postgresql bundle exec ruby test/databases/check.rb
1891
+ ```
1892
+
1872
1893
  ## Development
1873
1894
 
1874
1895
  After checking out the repo, run `bin/setup` to install dependencies. Then run `bundle exec rake test`. You can also run `bin/console` for an interactive prompt.
@@ -16,7 +16,13 @@ module ApiKeys
16
16
  # == Accessors ==
17
17
 
18
18
  # Core Authentication
19
- attr_reader :header, :query_param
19
+ #
20
+ # @!attribute [rw] require_bearer_scheme
21
+ # @return [Boolean] Accept the header only as "Bearer <token>". A value
22
+ # without the scheme is refused like any invalid token (401
23
+ # `invalid_token`). false (the default) also accepts the bare token,
24
+ # as every earlier version did.
25
+ attr_reader :header, :query_param, :require_bearer_scheme
20
26
 
21
27
  # Token Generation
22
28
  attr_reader :token_prefix, :token_length, :token_alphabet
@@ -192,7 +198,7 @@ module ApiKeys
192
198
  BOOLEAN_SETTINGS = %i[
193
199
  require_key_name track_requests_count https_only_production https_strict_mode
194
200
  enable_async_operations debug_logging strict_environment_isolation
195
- dashboard_allow_cross_environment
201
+ dashboard_allow_cross_environment require_bearer_scheme
196
202
  ].freeze
197
203
 
198
204
  def header=(value)
@@ -301,7 +307,7 @@ module ApiKeys
301
307
 
302
308
  def idempotency_in_test_transactions=(value)
303
309
  raise ArgumentError, "idempotency_in_test_transactions must be true or false" unless [true, false].include?(value)
304
- if value && !test_environment?
310
+ if value && !ApiKeys.test_environment?
305
311
  raise ArgumentError, "idempotency_in_test_transactions is for transactional tests only: Rails.env must be test"
306
312
  end
307
313
 
@@ -311,7 +317,7 @@ module ApiKeys
311
317
  # Whether hold may run inside an open transaction: the switch is on, and
312
318
  # Rails.env is still test when it is used.
313
319
  def idempotency_in_test_transactions?
314
- @idempotency_in_test_transactions == true && test_environment?
320
+ @idempotency_in_test_transactions == true && ApiKeys.test_environment?
315
321
  end
316
322
 
317
323
  def trusted_key_check=(value)
@@ -448,10 +454,6 @@ module ApiKeys
448
454
 
449
455
  private
450
456
 
451
- def test_environment?
452
- defined?(Rails) && Rails.respond_to?(:env) && Rails.env.test? ? true : false
453
- end
454
-
455
457
  def validate_resolved_prefix!(prefix)
456
458
  valid = prefix.is_a?(String) && prefix.present? && prefix.valid_encoding? && prefix.bytesize <= 64 &&
457
459
  prefix.each_codepoint.none? { |codepoint| codepoint <= 0x20 || codepoint == 0x7f }
@@ -633,6 +635,7 @@ module ApiKeys
633
635
  # Core Authentication
634
636
  @header = "Authorization" # Expects "Bearer <token>"
635
637
  @query_param = nil # No query param lookup by default
638
+ @require_bearer_scheme = false # Also accept a bare token in the header
636
639
 
637
640
  # Token Generation
638
641
  @token_prefix = -> { "ak_" }
@@ -43,6 +43,12 @@ module ApiKeys
43
43
  # opened with raw SQL (`BEGIN`) is not detected. The dashboard opens no
44
44
  # transaction itself; a host `around_action` that wraps its requests in
45
45
  # one sees the refusal.
46
+ #
47
+ # The transaction that a transactional test opens around each test does
48
+ # not count (0.8.1): when Rails.env is test and the only open
49
+ # transaction is an outer, non-joinable one, the claim runs in it. The
50
+ # first retrieval in the test returns the token and a second one does
51
+ # not, as in production. A transaction opened on top of it is refused.
46
52
  # 2. Cache marker (0.5.1): each handoff has a random ID, and retrieval
47
53
  # records it in `Rails.cache` with `write(..., unless_exist: true)`;
48
54
  # a later retrieval of the same handoff finds the marker and gets nil.
@@ -94,7 +100,8 @@ module ApiKeys
94
100
  # transaction on the ApiKey connection, where its one-time claim could
95
101
  # be rolled back after the token was shown. The token is withheld,
96
102
  # nothing is recorded, and the handoff stays in the session for a
97
- # retry outside the transaction.
103
+ # retry outside the transaction. The transaction of a transactional
104
+ # test does not count; one opened on top of it does.
98
105
  # - `:missing`: no usable handoff for this key (none stored, expired,
99
106
  # tampered, or bound to another key).
100
107
  #
@@ -303,7 +310,36 @@ module ApiKeys
303
310
  return false if stored_id.nil? || !defined?(::ApiKeys::ApiKey)
304
311
  return false unless ::ApiKeys::ApiKey.column_names.include?(::ApiKeys::ApiKey::TOKEN_SHOWN_AT_COLUMN)
305
312
 
306
- ::ApiKeys::ApiKey.connection_pool.with_connection(&:transaction_open?)
313
+ ::ApiKeys::ApiKey.connection_pool.with_connection do |connection|
314
+ connection.transaction_open? && !only_test_transaction_open?(connection)
315
+ end
316
+ end
317
+
318
+ # Whether the only open transaction on +connection+ is the one a
319
+ # transactional test opens around each test. Rails' transactional
320
+ # tests (and rspec-rails, and DatabaseCleaner's :transaction strategy)
321
+ # open it as one outer, non-joinable transaction, and a system test's
322
+ # server thread shares it. A claim in it is as durable as every other
323
+ # write of the test: the test sees it, a second retrieval in the same
324
+ # test is refused, and the rollback at the end of the test removes the
325
+ # key with it. A transaction that the app or the test opens on top of
326
+ # it still refuses the retrieval, as in production.
327
+ #
328
+ # The check reads the shape of the transaction, not where it came
329
+ # from, so three cases follow:
330
+ # - In a test without a transaction of its own, an outer
331
+ # `transaction(joinable: false)` that the app opens is exempt too.
332
+ # Production refuses it.
333
+ # - An environment with another name (`ci`, `staging`) is never
334
+ # exempt: only Rails.env test is.
335
+ # - A test transaction inside another one (fixtures and
336
+ # DatabaseCleaner's :transaction strategy together) is two deep, so
337
+ # it is not exempt, as in 0.8.0.
338
+ def only_test_transaction_open?(connection)
339
+ return false unless ApiKeys.test_environment?
340
+ return false unless connection.open_transactions == 1
341
+
342
+ !connection.current_transaction.joinable?
307
343
  end
308
344
 
309
345
  # Inside a transaction the claim is not durable until the host commits,
@@ -18,8 +18,9 @@ module ApiKeys
18
18
  # The longest authentication header that can still carry a valid token:
19
19
  # the token plus the "Bearer" scheme and generous separator whitespace.
20
20
  MAX_CREDENTIAL_HEADER_BYTESIZE = MAX_TOKEN_BYTESIZE + 64
21
- # Stands in for a header refused before parsing. It is not a String, so
22
- # it can never match a key and always yields :invalid_token.
21
+ # Stands in for a refused header: malformed, or without the Bearer
22
+ # scheme the app requires. It is not a String, so it can never match a
23
+ # key and always yields :invalid_token.
23
24
  MALFORMED_CREDENTIAL = Object.new.freeze
24
25
  MAX_BCRYPT_CANDIDATES = 32
25
26
  MAX_KNOWN_PREFIXES = 1_024
@@ -146,7 +147,13 @@ module ApiKeys
146
147
  log_debug "[ApiKeys Auth] Extracted token from Bearer scheme."
147
148
  return match[1]
148
149
  end
149
- # Fallback: return the raw header value if no Bearer scheme
150
+ # A bare token is accepted unless the app requires the scheme.
151
+ # Refused, it is an invalid token: the header was present, so it
152
+ # never falls through to the query parameter.
153
+ if config.require_bearer_scheme
154
+ log_debug "[ApiKeys Auth] Refused a header without the Bearer scheme."
155
+ return MALFORMED_CREDENTIAL
156
+ end
150
157
  log_debug "[ApiKeys Auth] No Bearer scheme, using raw header value as token."
151
158
  return header_value
152
159
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ApiKeys
4
- VERSION = "0.7.0"
4
+ VERSION = "0.8.1"
5
5
  end
data/lib/api_keys.rb CHANGED
@@ -43,6 +43,14 @@ module ApiKeys
43
43
  @configuration = ::ApiKeys::Configuration.new
44
44
  end
45
45
 
46
+ # Whether Rails.env is test, read at each call. The test-only exemptions
47
+ # use it: config.idempotency_in_test_transactions, and the one-time token
48
+ # handoff inside a transactional test's own transaction.
49
+ # @return [Boolean]
50
+ def test_environment?
51
+ defined?(Rails) && Rails.respond_to?(:env) && Rails.env.test? ? true : false
52
+ end
53
+
46
54
  # Add-only backfill after widening a key type's `permissions:` ceiling, for
47
55
  # types that keep stored scopes. See ApiKeys::ApiKey.reconcile_scopes!.
48
56
  # @return [Integer] How many keys gained scopes.
@@ -1,5 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # On PostgreSQL the index is built concurrently, outside a transaction, so
4
+ # api_keys takes writes while it builds, and strong_migrations accepts the
5
+ # migration. If a concurrent build fails, PostgreSQL keeps an invalid index;
6
+ # the next run removes it and builds it again.
3
7
  class AddAuthenticationIndexToApiKeys < ActiveRecord::Migration<%= migration_version %>
4
8
  INDEX_NAME = "index_api_keys_authentication_lookup"
5
9
  COLUMNS = %i[prefix last4 digest_algorithm].freeze
@@ -7,18 +11,35 @@ class AddAuthenticationIndexToApiKeys < ActiveRecord::Migration<%= migration_ver
7
11
  disable_ddl_transaction!
8
12
 
9
13
  def up
14
+ remove_invalid_index
10
15
  return if index_exists?(:api_keys, COLUMNS, name: INDEX_NAME)
11
16
 
12
- options = { name: INDEX_NAME }
13
- options[:algorithm] = :concurrently if connection.adapter_name.downcase.include?("postgres")
14
- add_index :api_keys, COLUMNS, **options
17
+ add_index :api_keys, COLUMNS, name: INDEX_NAME, **index_algorithm
15
18
  end
16
19
 
17
20
  def down
18
21
  return unless index_exists?(:api_keys, COLUMNS, name: INDEX_NAME)
19
22
 
20
- options = { name: INDEX_NAME }
21
- options[:algorithm] = :concurrently if connection.adapter_name.downcase.include?("postgres")
22
- remove_index :api_keys, **options
23
+ remove_index :api_keys, name: INDEX_NAME, **index_algorithm
24
+ end
25
+
26
+ private
27
+
28
+ # Concurrently on PostgreSQL (and PostGIS). MySQL and SQLite have no
29
+ # concurrent build and refuse the option.
30
+ def index_algorithm
31
+ connection.adapter_name.downcase.include?("postg") ? { algorithm: :concurrently } : {}
32
+ end
33
+
34
+ # A concurrent build that fails (a lock or statement timeout, for example)
35
+ # leaves an invalid index on PostgreSQL, which index_exists? still finds.
36
+ # Treat it as absent: remove it, so that the next step builds it again.
37
+ # (IndexDefinition#valid? exists from Rails 7.1. The migrator's lock keeps
38
+ # a second run from removing an index that another run still builds.)
39
+ def remove_invalid_index
40
+ index = connection.indexes(:api_keys).find { |candidate| candidate.name == INDEX_NAME }
41
+ return unless index.respond_to?(:valid?) && !index.valid?
42
+
43
+ remove_index :api_keys, name: INDEX_NAME, **index_algorithm
23
44
  end
24
45
  end
@@ -9,13 +9,29 @@
9
9
  #
10
10
  # Run this migration if you're upgrading from a version of api_keys before
11
11
  # 0.7.0. New installs get the columns from the install generator. Existing
12
- # keys keep NULL. The migration is idempotent: columns that already exist are
13
- # left alone.
12
+ # keys keep NULL.
13
+ #
14
+ # On PostgreSQL the index is built concurrently, so api_keys takes writes
15
+ # while it builds, and strong_migrations accepts the migration. A concurrent
16
+ # build cannot run inside a transaction, so this migration runs without one.
17
+ # Each step checks what exists, so a run that stopped half way can run
18
+ # again. If a concurrent build fails, PostgreSQL keeps an invalid index; the
19
+ # next run removes it and builds it again. MySQL and SQLite build the
20
+ # index as usual.
14
21
  class AddCreatedByToApiKeys < ActiveRecord::Migration<%= migration_version %>
22
+ INDEX_NAME = "index_api_keys_on_created_by"
23
+ INDEX_COLUMNS = %i[created_by_type created_by_id].freeze
24
+
25
+ disable_ddl_transaction!
26
+
15
27
  def up
16
- return if column_exists?(:api_keys, :created_by_id)
28
+ unless column_exists?(:api_keys, :created_by_id)
29
+ add_reference :api_keys, :created_by, polymorphic: true, null: true, type: foreign_key_type, index: false
30
+ end
31
+ remove_invalid_index(INDEX_COLUMNS)
32
+ return if index_exists?(:api_keys, INDEX_COLUMNS)
17
33
 
18
- add_reference :api_keys, :created_by, polymorphic: true, null: true, type: foreign_key_type
34
+ add_index :api_keys, INDEX_COLUMNS, name: INDEX_NAME, **index_algorithm
19
35
  end
20
36
 
21
37
  def down
@@ -30,4 +46,22 @@ class AddCreatedByToApiKeys < ActiveRecord::Migration<%= migration_version %>
30
46
  generators = Rails.application&.config&.generators
31
47
  generators&.options&.dig(generators.orm, :primary_key_type) || :bigint
32
48
  end
49
+
50
+ # Concurrently on PostgreSQL (and PostGIS). MySQL and SQLite have no
51
+ # concurrent build and refuse the option.
52
+ def index_algorithm
53
+ connection.adapter_name.downcase.include?("postg") ? { algorithm: :concurrently } : {}
54
+ end
55
+
56
+ # A concurrent build that fails (a lock or statement timeout, for example)
57
+ # leaves an invalid index on PostgreSQL, which index_exists? still finds.
58
+ # Treat it as absent: remove it, so that the next step builds it again.
59
+ # (IndexDefinition#valid? exists from Rails 7.1. The migrator's lock keeps
60
+ # a second run from removing an index that another run still builds.)
61
+ def remove_invalid_index(columns)
62
+ index = connection.indexes(:api_keys).find { |candidate| candidate.columns == Array(columns).map(&:to_s) }
63
+ return unless index.respond_to?(:valid?) && !index.valid?
64
+
65
+ remove_index :api_keys, name: index.name, **index_algorithm
66
+ end
33
67
  end
@@ -5,14 +5,71 @@
5
5
  #
6
6
  # Run this migration if you're upgrading from a version of api_keys that
7
7
  # didn't have key types support.
8
+ #
9
+ # On PostgreSQL the indexes are built concurrently, so api_keys takes writes
10
+ # while they build, and strong_migrations accepts the migration. A concurrent
11
+ # build cannot run inside a transaction, so this migration runs without one.
12
+ # Each step checks what exists, so a run that stopped half way can run
13
+ # again. If a concurrent build fails, PostgreSQL keeps an invalid index; the
14
+ # next run removes it and builds it again. MySQL and SQLite build the
15
+ # indexes as usual.
8
16
  class AddKeyTypesToApiKeys < ActiveRecord::Migration<%= migration_version %>
9
- def change
10
- add_column :api_keys, :key_type, :string
11
- add_column :api_keys, :environment, :string
12
-
13
- add_index :api_keys, :key_type
14
- add_index :api_keys, :environment
15
- add_index :api_keys, [:owner_type, :owner_id, :key_type, :environment],
16
- name: "index_api_keys_owner_type_env"
17
+ OWNER_TYPE_ENV_INDEX = "index_api_keys_owner_type_env"
18
+ OWNER_TYPE_ENV_COLUMNS = %i[owner_type owner_id key_type environment].freeze
19
+
20
+ disable_ddl_transaction!
21
+
22
+ def up
23
+ add_column :api_keys, :key_type, :string unless column_exists?(:api_keys, :key_type)
24
+ add_column :api_keys, :environment, :string unless column_exists?(:api_keys, :environment)
25
+
26
+ %i[key_type environment].each do |column|
27
+ remove_invalid_index(column)
28
+ add_index :api_keys, column, **index_algorithm unless index_exists?(:api_keys, column)
29
+ end
30
+ remove_invalid_index(OWNER_TYPE_ENV_COLUMNS)
31
+ return if index_exists?(:api_keys, OWNER_TYPE_ENV_COLUMNS)
32
+
33
+ # strong_migrations calls a non-unique index of more than three columns
34
+ # a best-practice problem. The install migration has the same index, so
35
+ # this migration keeps it and tells strong_migrations so.
36
+ with_safety_assured do
37
+ add_index :api_keys, OWNER_TYPE_ENV_COLUMNS, name: OWNER_TYPE_ENV_INDEX, **index_algorithm
38
+ end
39
+ end
40
+
41
+ def down
42
+ if index_exists?(:api_keys, OWNER_TYPE_ENV_COLUMNS)
43
+ remove_index :api_keys, name: OWNER_TYPE_ENV_INDEX, **index_algorithm
44
+ end
45
+ remove_index :api_keys, :environment, **index_algorithm if index_exists?(:api_keys, :environment)
46
+ remove_index :api_keys, :key_type, **index_algorithm if index_exists?(:api_keys, :key_type)
47
+ remove_column :api_keys, :environment if column_exists?(:api_keys, :environment)
48
+ remove_column :api_keys, :key_type if column_exists?(:api_keys, :key_type)
49
+ end
50
+
51
+ private
52
+
53
+ # Concurrently on PostgreSQL (and PostGIS). MySQL and SQLite have no
54
+ # concurrent build and refuse the option.
55
+ def index_algorithm
56
+ connection.adapter_name.downcase.include?("postg") ? { algorithm: :concurrently } : {}
57
+ end
58
+
59
+ # A concurrent build that fails (a lock or statement timeout, for example)
60
+ # leaves an invalid index on PostgreSQL, which index_exists? still finds.
61
+ # Treat it as absent: remove it, so that the next step builds it again.
62
+ # (IndexDefinition#valid? exists from Rails 7.1. The migrator's lock keeps
63
+ # a second run from removing an index that another run still builds.)
64
+ def remove_invalid_index(columns)
65
+ index = connection.indexes(:api_keys).find { |candidate| candidate.columns == Array(columns).map(&:to_s) }
66
+ return unless index.respond_to?(:valid?) && !index.valid?
67
+
68
+ remove_index :api_keys, name: index.name, **index_algorithm
69
+ end
70
+
71
+ # strong_migrations' safety_assured when the app uses strong_migrations.
72
+ def with_safety_assured(&block)
73
+ respond_to?(:safety_assured) ? safety_assured(&block) : yield
17
74
  end
18
75
  end
@@ -8,47 +8,86 @@
8
8
  # Run this migration if you're upgrading from a version of api_keys that
9
9
  # didn't have request restrictions support. The column defaults to an empty
10
10
  # object, which means "unrestricted", so existing keys are unaffected.
11
+ #
12
+ # On PostgreSQL the check constraint is added first without a check of the
13
+ # existing rows, then validated, which does not block reads and writes, so
14
+ # strong_migrations accepts the migration. It runs without a transaction, so
15
+ # the validation holds no lock from the earlier steps. Each step checks what
16
+ # exists, so a run that stopped half way can run again. MySQL refuses a
17
+ # literal default on a JSON column, so there the default is the expression
18
+ # JSON_OBJECT() (MySQL 8.0.13 or later). On MySQL strong_migrations refuses
19
+ # this migration twice: first the column, for its callable default, then the
20
+ # check constraint, which MySQL adds by copying the table. Check the size of
21
+ # api_keys first. Then wrap the whole body of up (the column and the
22
+ # constraint) in `safety_assured do ... end`, and run it when api_keys can be
23
+ # locked for the copy, for example in a maintenance window.
11
24
  class AddRestrictionsToApiKeys < ActiveRecord::Migration<%= migration_version %>
12
25
  CONSTRAINT_NAME = "api_keys_restrictions_is_object"
13
26
 
27
+ disable_ddl_transaction!
28
+
14
29
  def up
15
- add_column :api_keys, :restrictions, json_column_type, default: {}, null: false
30
+ unless column_exists?(:api_keys, :restrictions)
31
+ add_column :api_keys, :restrictions, json_column_type, default: restrictions_default, null: false
32
+ end
16
33
  add_restrictions_object_constraint
17
34
  end
18
35
 
19
36
  def down
20
- remove_column :api_keys, :restrictions
37
+ remove_check_constraint :api_keys, name: CONSTRAINT_NAME if restrictions_constraint_exists?
38
+ remove_column :api_keys, :restrictions if column_exists?(:api_keys, :restrictions)
21
39
  end
22
40
 
23
41
  private
24
42
 
25
43
  # 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.
44
+ # Uses :jsonb for PostgreSQL (and PostGIS) for better performance and indexing, :json otherwise.
27
45
  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
46
+ postgresql? ? :jsonb : :json
34
47
  rescue ActiveRecord::ConnectionNotEstablished
35
48
  # Fallback during initial setup or if connection isn't available
36
49
  :text
37
50
  end
38
51
 
52
+ # MySQL refuses a literal default on a JSON column, but takes an
53
+ # expression, which also fills the existing rows with an empty object.
54
+ def restrictions_default
55
+ connection.adapter_name.downcase.match?(/mysql|trilogy/) ? -> { "(JSON_OBJECT())" } : {}
56
+ end
57
+
39
58
  # JSON columns can also hold arrays and scalars. The application requires an
40
59
  # object so damaged or validation-bypassing writes cannot erase a policy.
41
60
  def add_restrictions_object_constraint
42
61
  return unless connection.supports_check_constraints?
43
62
 
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
63
+ expression =
64
+ case connection.adapter_name.downcase
65
+ when /postg/
66
+ "jsonb_typeof(restrictions) = 'object'"
67
+ when /sqlite/
68
+ "json_valid(restrictions) AND json_type(restrictions) = 'object'"
69
+ when /mysql|trilogy/
70
+ "JSON_TYPE(restrictions) = 'OBJECT'"
71
+ end
72
+ return unless expression
73
+
74
+ unless restrictions_constraint_exists?
75
+ # On PostgreSQL, add the constraint without a check of the existing
76
+ # rows: this takes a short lock only.
77
+ add_check_constraint :api_keys, expression, name: CONSTRAINT_NAME, **(postgresql? ? { validate: false } : {})
78
+ end
79
+ # Checks the existing rows without blocking reads and writes. Nothing
80
+ # happens when the constraint is valid already.
81
+ validate_check_constraint :api_keys, name: CONSTRAINT_NAME if postgresql?
82
+ end
83
+
84
+ def restrictions_constraint_exists?
85
+ return false unless connection.supports_check_constraints?
86
+
87
+ connection.check_constraints(:api_keys).any? { |constraint| constraint.name == CONSTRAINT_NAME }
88
+ end
89
+
90
+ def postgresql?
91
+ connection.adapter_name.downcase.include?("postg")
53
92
  end
54
93
  end
@@ -10,8 +10,6 @@
10
10
  # Only apps that use the device flow need this table.
11
11
  class CreateApiKeysDeviceAuthorizations < ActiveRecord::Migration<%= migration_version %>
12
12
  def change
13
- primary_key_type, foreign_key_type = primary_and_foreign_key_types
14
-
15
13
  create_table :api_keys_device_authorizations, id: primary_key_type do |t|
16
14
  t.string :device_code_digest, null: false, index: { unique: true }
17
15
  t.string :user_code_digest, null: false, index: { unique: true }
@@ -42,16 +40,23 @@ class CreateApiKeysDeviceAuthorizations < ActiveRecord::Migration<%= migration_v
42
40
 
43
41
  # The key types the install generator gives the api_keys table: the app's
44
42
  # `primary_key_type` generator setting, else Rails' defaults.
45
- def primary_and_foreign_key_types
43
+ def primary_key_type
44
+ primary_key_type_setting || :primary_key
45
+ end
46
+
47
+ def foreign_key_type
48
+ primary_key_type_setting || :bigint
49
+ end
50
+
51
+ def primary_key_type_setting
46
52
  generators = Rails.application&.config&.generators
47
- setting = generators&.options&.dig(generators.orm, :primary_key_type)
48
- [setting || :primary_key, setting || :bigint]
53
+ generators&.options&.dig(generators.orm, :primary_key_type)
49
54
  end
50
55
 
51
56
  # The type of api_keys.id, so that the foreign key matches it exactly.
52
57
  def api_key_id_type
53
58
  column = connection.columns(:api_keys).find { |candidate| candidate.name == "id" }
54
- return primary_and_foreign_key_types.last unless column
59
+ return foreign_key_type unless column
55
60
 
56
61
  sql_type = column.sql_type.to_s.downcase
57
62
  if sql_type.include?("uuid")
@@ -10,8 +10,6 @@
10
10
  # Only apps that use ApiKeys::Idempotency need this table.
11
11
  class CreateApiKeysIdempotencyKeys < ActiveRecord::Migration<%= migration_version %>
12
12
  def change
13
- primary_key_type, foreign_key_type = primary_and_foreign_key_types
14
-
15
13
  create_table :api_keys_idempotency_keys, id: primary_key_type do |t|
16
14
  # Each API key has its own keys. A deleted API key takes them with it.
17
15
  t.references :api_key, null: false, type: api_key_id_type, foreign_key: { on_delete: :cascade }, index: false
@@ -21,7 +19,7 @@ class CreateApiKeysIdempotencyKeys < ActiveRecord::Migration<%= migration_versio
21
19
  t.references :resource, polymorphic: true, null: true, type: foreign_key_type, index: false
22
20
  t.timestamps
23
21
  t.index :created_at
24
- t.index [:api_key_id, :key], unique: true
22
+ t.index %i[api_key_id key], unique: true
25
23
  end
26
24
  end
27
25
 
@@ -29,16 +27,23 @@ class CreateApiKeysIdempotencyKeys < ActiveRecord::Migration<%= migration_versio
29
27
 
30
28
  # The key types the install generator gives the api_keys table: the app's
31
29
  # `primary_key_type` generator setting, else Rails' defaults.
32
- def primary_and_foreign_key_types
30
+ def primary_key_type
31
+ primary_key_type_setting || :primary_key
32
+ end
33
+
34
+ def foreign_key_type
35
+ primary_key_type_setting || :bigint
36
+ end
37
+
38
+ def primary_key_type_setting
33
39
  generators = Rails.application&.config&.generators
34
- setting = generators&.options&.dig(generators.orm, :primary_key_type)
35
- [setting || :primary_key, setting || :bigint]
40
+ generators&.options&.dig(generators.orm, :primary_key_type)
36
41
  end
37
42
 
38
43
  # The type of api_keys.id, so that the foreign key matches it exactly.
39
44
  def api_key_id_type
40
45
  column = connection.columns(:api_keys).find { |candidate| candidate.name == "id" }
41
- return primary_and_foreign_key_types.last unless column
46
+ return foreign_key_type unless column
42
47
 
43
48
  sql_type = column.sql_type.to_s.downcase
44
49
  if sql_type.include?("uuid")
@@ -5,8 +5,6 @@ class CreateApiKeysTable < ActiveRecord::Migration<%= migration_version %>
5
5
  RESTRICTIONS_CONSTRAINT_NAME = "api_keys_restrictions_is_object"
6
6
 
7
7
  def change
8
- primary_key_type, foreign_key_type = primary_and_foreign_key_types
9
-
10
8
  create_table :api_keys, id: primary_key_type do |t|
11
9
  # Identifiable prefix (e.g., "ak_live_", "ak_test_") for env/debugging
12
10
  t.string :prefix, null: false
@@ -76,8 +74,8 @@ class CreateApiKeysTable < ActiveRecord::Migration<%= migration_version %>
76
74
  t.index :token_digest, unique: true
77
75
 
78
76
  # Index to optimize prefix-based lookups (especially for bcrypt)
79
- t.index [:prefix, :digest_algorithm], name: "index_api_keys_on_prefix_and_digest_algorithm"
80
- t.index [:prefix, :last4, :digest_algorithm], name: "index_api_keys_authentication_lookup"
77
+ t.index %i[prefix digest_algorithm], name: "index_api_keys_on_prefix_and_digest_algorithm"
78
+ t.index %i[prefix last4 digest_algorithm], name: "index_api_keys_authentication_lookup"
81
79
 
82
80
  # Optional indexes
83
81
  t.index :prefix
@@ -87,7 +85,7 @@ class CreateApiKeysTable < ActiveRecord::Migration<%= migration_version %>
87
85
  t.index :last_used_at
88
86
  t.index :key_type
89
87
  t.index :environment
90
- t.index [:owner_type, :owner_id, :key_type, :environment], name: "index_api_keys_owner_type_env"
88
+ t.index %i[owner_type owner_id key_type environment], name: "index_api_keys_owner_type_env"
91
89
  end
92
90
 
93
91
  add_restrictions_object_constraint
@@ -95,14 +93,19 @@ class CreateApiKeysTable < ActiveRecord::Migration<%= migration_version %>
95
93
 
96
94
  private
97
95
 
98
- # Helper method to determine the appropriate primary and foreign key types
96
+ # Helper methods to determine the appropriate primary and foreign key types
99
97
  # based on the Rails application's configuration.
100
- def primary_and_foreign_key_types
98
+ def primary_key_type
99
+ primary_key_type_setting || :primary_key
100
+ end
101
+
102
+ def foreign_key_type
103
+ primary_key_type_setting || :bigint # Assuming bigint is a safe default for foreign keys
104
+ end
105
+
106
+ def primary_key_type_setting
101
107
  config = Rails.configuration.generators
102
- setting = config.options[config.orm][:primary_key_type]
103
- primary_key_type = setting || :primary_key
104
- foreign_key_type = setting || :bigint # Assuming bigint is a safe default for foreign keys
105
- [primary_key_type, foreign_key_type]
108
+ config.options[config.orm][:primary_key_type]
106
109
  end
107
110
 
108
111
  # Helper method to determine the appropriate JSON column type based on the database adapter.
@@ -122,14 +125,15 @@ class CreateApiKeysTable < ActiveRecord::Migration<%= migration_version %>
122
125
  def add_restrictions_object_constraint
123
126
  return unless connection.supports_check_constraints?
124
127
 
125
- expression = case connection.adapter_name.downcase
126
- when /postgres/
127
- "jsonb_typeof(restrictions) = 'object'"
128
- when /sqlite/
129
- "json_valid(restrictions) AND json_type(restrictions) = 'object'"
130
- when /mysql|trilogy/
131
- "JSON_TYPE(restrictions) = 'OBJECT'"
132
- end
128
+ expression =
129
+ case connection.adapter_name.downcase
130
+ when /postgres/
131
+ "jsonb_typeof(restrictions) = 'object'"
132
+ when /sqlite/
133
+ "json_valid(restrictions) AND json_type(restrictions) = 'object'"
134
+ when /mysql|trilogy/
135
+ "JSON_TYPE(restrictions) = 'OBJECT'"
136
+ end
133
137
  add_check_constraint :api_keys, expression, name: RESTRICTIONS_CONSTRAINT_NAME if expression
134
138
  end
135
139
  end
@@ -9,6 +9,11 @@ ApiKeys.configure do |config|
9
9
  # Default: "Authorization" (expects "Bearer <token>")
10
10
  # config.header = "Authorization"
11
11
 
12
+ # Accept the header only as "Bearer <token>", refusing a bare token with
13
+ # 401 invalid_token. Recommended for public APIs.
14
+ # Default: false (a bare token is accepted too)
15
+ # config.require_bearer_scheme = true
16
+
12
17
  # The query parameter name to check as a fallback if the header is missing.
13
18
  # WARNING: Query parameters appear in logs, browser history, and Referer headers.
14
19
  # Only enable for development/testing. Set to nil to disable (recommended).
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.7.0
4
+ version: 0.8.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - rameerez