api_keys 0.8.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 +4 -4
- data/CHANGELOG.md +25 -0
- data/README.md +12 -1
- data/lib/api_keys/configuration.rb +2 -6
- data/lib/api_keys/helpers/token_session.rb +38 -2
- data/lib/api_keys/version.rb +1 -1
- data/lib/api_keys.rb +8 -0
- data/lib/generators/api_keys/templates/add_authentication_index_to_api_keys.rb.erb +27 -6
- data/lib/generators/api_keys/templates/add_created_by_to_api_keys.rb.erb +38 -4
- data/lib/generators/api_keys/templates/add_key_types_to_api_keys.rb.erb +65 -8
- data/lib/generators/api_keys/templates/add_restrictions_to_api_keys.rb.erb +57 -18
- data/lib/generators/api_keys/templates/create_api_keys_device_authorizations.rb.erb +11 -6
- data/lib/generators/api_keys/templates/create_api_keys_idempotency_keys.rb.erb +12 -7
- data/lib/generators/api_keys/templates/create_api_keys_table.rb.erb +23 -19
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dca5913a99885ff5a5560a108e15d1ed8c81ef82347f6bdc3606559cf5a47ef2
|
|
4
|
+
data.tar.gz: 574b8fa3df8e307bbe24c03292acb754ef44f8453dbda17719b3e3b36eae74a5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ab7dcee45ad1c6efae2156ee2c3e1e10745ad09f4a2d4b4eed1e2d120bf78e91fe9bfdb9919aeba87b91c2ad13720bf93f9e98802053fd73f3032c1033c4f945
|
|
7
|
+
data.tar.gz: 45672849171455b8bdb2c4af0df5413fe0f693d36556e35a053cb64c184e239ee646d11776ac9d9f169b82891839ff5ab30a867ca20d5eb3037a740c8baec826
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,28 @@
|
|
|
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
|
+
|
|
1
26
|
## [0.8.0] - 2026-10-25
|
|
2
27
|
|
|
3
28
|
### Added
|
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.
|
|
@@ -1879,6 +1884,12 @@ bundle exec appraisal rails-8.0 rake test
|
|
|
1879
1884
|
bundle exec appraisal rails-8.1 rake test
|
|
1880
1885
|
```
|
|
1881
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
|
+
|
|
1882
1893
|
## Development
|
|
1883
1894
|
|
|
1884
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.
|
|
@@ -307,7 +307,7 @@ module ApiKeys
|
|
|
307
307
|
|
|
308
308
|
def idempotency_in_test_transactions=(value)
|
|
309
309
|
raise ArgumentError, "idempotency_in_test_transactions must be true or false" unless [true, false].include?(value)
|
|
310
|
-
if value && !test_environment?
|
|
310
|
+
if value && !ApiKeys.test_environment?
|
|
311
311
|
raise ArgumentError, "idempotency_in_test_transactions is for transactional tests only: Rails.env must be test"
|
|
312
312
|
end
|
|
313
313
|
|
|
@@ -317,7 +317,7 @@ module ApiKeys
|
|
|
317
317
|
# Whether hold may run inside an open transaction: the switch is on, and
|
|
318
318
|
# Rails.env is still test when it is used.
|
|
319
319
|
def idempotency_in_test_transactions?
|
|
320
|
-
@idempotency_in_test_transactions == true && test_environment?
|
|
320
|
+
@idempotency_in_test_transactions == true && ApiKeys.test_environment?
|
|
321
321
|
end
|
|
322
322
|
|
|
323
323
|
def trusted_key_check=(value)
|
|
@@ -454,10 +454,6 @@ module ApiKeys
|
|
|
454
454
|
|
|
455
455
|
private
|
|
456
456
|
|
|
457
|
-
def test_environment?
|
|
458
|
-
defined?(Rails) && Rails.respond_to?(:env) && Rails.env.test? ? true : false
|
|
459
|
-
end
|
|
460
|
-
|
|
461
457
|
def validate_resolved_prefix!(prefix)
|
|
462
458
|
valid = prefix.is_a?(String) && prefix.present? && prefix.valid_encoding? && prefix.bytesize <= 64 &&
|
|
463
459
|
prefix.each_codepoint.none? { |codepoint| codepoint <= 0x20 || codepoint == 0x7f }
|
|
@@ -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
|
|
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,
|
data/lib/api_keys/version.rb
CHANGED
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
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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.
|
|
13
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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 [
|
|
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
|
|
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
|
-
|
|
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
|
|
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 [
|
|
80
|
-
t.index [
|
|
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 [
|
|
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
|
|
96
|
+
# Helper methods to determine the appropriate primary and foreign key types
|
|
99
97
|
# based on the Rails application's configuration.
|
|
100
|
-
def
|
|
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
|
-
|
|
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 =
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|