active_enquo 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e643c69efa8243d95e44c59a6d49f20c40a23dc3a914da1d79342fa5d3969ca9
4
- data.tar.gz: fcb70caad8b2d949fca672198e269e574924cd63748dfa221164ec6a1eb7842d
3
+ metadata.gz: 9d538e223b9e7485c4b08cf8ac38f2cc6a08f5d3c3b72426167606fdc7a5fdf5
4
+ data.tar.gz: 33e80addf4beb8bbe8f5328cbfd093cb70264ec2804d148026cbdabbd36cd1b8
5
5
  SHA512:
6
- metadata.gz: 7ce8dd63cae62013d026ddf7200878975471bf30c157e059e50b269124e886a219ebb526581038bed3fed1de00500416bfdee7740497c1af2b775c90c46dfd53
7
- data.tar.gz: 02a1944917d45d1517cd5143dbe56c05c28f8c8a2fffe5840f317ce865a944aa563a04560f3e39773eadb7f2ca5a0f54835bdab2f4765e5aacc456c9a88f88b8
6
+ metadata.gz: af6a5e20a215234ce3bce1656a7e02e94f536bf1a3c8d674732d7d49cd8ab5265fe4c306ea730cfe60bba707df5436cf9048d9ce8b85f021fc92639835a0424d
7
+ data.tar.gz: e9f6b9d0f5f5f5fb5b6fdecd8ba7fdcadf5b7be0c363787d78a2a989bb753b506d9d914122041614aad4cb3032803d6f5075ec02019a81457104471decd4fec7
data/README.md CHANGED
@@ -116,7 +116,7 @@ You can now, without any further ado, use that attribute in your models as you w
116
116
  For example, you can insert a new record:
117
117
 
118
118
  ```ruby
119
- User.create!([{name: "Clara Bloggs", username: "cbloggs", date_of_birth: Date(1970, 1, 1)}])
119
+ User.create!([{name: "Clara Bloggs", username: "cbloggs", date_of_birth: Date.new(1970, 1, 1)}])
120
120
  ```
121
121
 
122
122
  When you retrieve a record, the value is there for you to read:
@@ -130,18 +130,18 @@ User.where(username: "cbloggs").first.date_of_birth.to_s # => "1970-01-01"
130
130
 
131
131
  This is where things get *neat*.
132
132
 
133
- Performing a query on Enquo-encrypted data is done the same way as on unencrypted data, with one exception: you need to wrap the values of the query in `<Model>.enquo` calls, as in the examples below.
133
+ Performing a query on Enquo-encrypted data is done the same way as on unencrypted data.
134
134
 
135
135
  You can query for records that have the exact value you're looking for:
136
136
 
137
137
  ```ruby
138
- User.where(age: User.enquo(:date_of_birth, Date(1970, 1, 1)))
138
+ User.where(date_of_birth: Date(1970, 1, 1))
139
139
  ```
140
140
 
141
141
  Or you can query for users born less than 50 years ago:
142
142
 
143
143
  ```ruby
144
- User.where(age: User.enquo(:date, Date.today - 50.years)..)
144
+ User.where(date_of_birth: (Date.today - 50.years))..)
145
145
  ```
146
146
 
147
147
  This doesn't seem so magical, until you take a peek in the database, and realise that *all the data is still encrypted*:
@@ -154,6 +154,12 @@ psql> SELECT date_of_birth FROM users WHERE username='cbloggs';
154
154
  ```
155
155
 
156
156
 
157
+ ## Migrating Existing Data to Encrypted Form
158
+
159
+ This is a topic on which a lot of words can be written.
160
+ For the sake of tidiness, these words are in [a guide of their own](docs/MIGRATION.md).
161
+
162
+
157
163
  ## Indexing and Ordering
158
164
 
159
165
  To maintain [security by default](https://enquo.org/about/threat-models#snapshot-security), ActiveEnquo doesn't provide the ability to `ORDER BY` or index columns by default.
@@ -168,6 +174,7 @@ class User < ApplicationRecord
168
174
  end
169
175
  ```
170
176
 
177
+
171
178
  ### Security Considerations
172
179
 
173
180
  As the name implies, "reduced security operations" require that the security of the data in the column be lower than [Enquo's default security properties](https://enquo.org/about/threat-models#snapshot-security).
data/active_enquo.gemspec CHANGED
@@ -27,7 +27,7 @@ Gem::Specification.new do |s|
27
27
 
28
28
  s.required_ruby_version = ">= 2.7.0"
29
29
 
30
- s.add_runtime_dependency "enquo-core", "~> 0.6"
30
+ s.add_runtime_dependency "enquo-core", "~> 0.7"
31
31
  s.add_runtime_dependency "activerecord", ">= 6"
32
32
 
33
33
  s.add_development_dependency "bundler"
data/docs/MIGRATION.md ADDED
@@ -0,0 +1,107 @@
1
+ If you have data already in your database that you wish to protect using ActiveEnquo, you can migrate the data into an encrypted form.
2
+
3
+ There are two approaches that can be used:
4
+
5
+ * [Direct Migration](#data-migration-with-downtime), which is straightforward but involves application downtime; or
6
+
7
+ * [Live Migration](#live-data-migration), which is more complicated, but can be done while keeping the application available at all times.
8
+
9
+
10
+ # Data Migration (with Downtime)
11
+
12
+ If your application can withstand being down for a period of time, you can use a simple migration process that encrypts the data and modifies the application appropriate in a single pass.
13
+ It relies on their being a period with nothing accessing the column(s) being migrated, which usually means that the entire application (including background workers and periodic tasks) being shut down, before being restarted.
14
+ The total downtime will depend on how long it takes to encrypt and write all the data being migrated, which is mostly a function of the amount of data being stored.
15
+
16
+
17
+ ## Step 1: Configure the Encrypted Column(s)
18
+
19
+ Create an ActiveRecord migration which renames the existing column and creates a new `enquo_*` column with the old name.
20
+ For example, if you already had a `date_of_birth` column, your migration would look like this:
21
+
22
+ ```ruby
23
+ class EncryptUsersDateOfBirth < ActiveRecord::Migration[7.0]
24
+ def change
25
+ rename_column :users, :date_of_birth, :date_of_birth_plaintext
26
+ add_column :users, :date_of_birth, :enquo_date
27
+ User.enquo_encrypt_columns(date_of_birth_plaintext: :date_of_birth)
28
+ remove_column :users, :date_of_birth_plaintext
29
+ end
30
+ end
31
+ ```
32
+
33
+ The `Model.encrypt_columns` method loads all the records in the table, and encrypts the value in the plaintext column and writes it to the corresponding encrypted column.
34
+
35
+ If you want to encrypt several columns in a single model, you can do so in a single migration, by renaming all the columns and adding `enquo_*` type columns, and then providing the mapping of all columns together in a single `Model.encrypt_columns` call.
36
+ This is the recommended approach, as it improves efficiency because the records only have to be loaded and saved once.
37
+
38
+ If you want to encrypt columns in multiple models in one downtime, just repeat the above steps for each table and model involved.
39
+
40
+
41
+ ## Step 2: Modify Queries
42
+
43
+ When providing data to a query on an encrypted column, you need to make a call to `Model.enquo` in order to encrypt the value for querying.
44
+
45
+ To continue our `date_of_birth` example above, you need to find any queries that reference the `date_of_birth` column, and modify the code to pass the value for the `date_of_birth` column through a call to `User.enquo(:date_of_birth, <value>)`.
46
+
47
+ For a query that found all users with a date of birth equal to a query parameter, that originally looked like this:
48
+
49
+ ```ruby
50
+ User.where(date_of_birth: params[:dob])
51
+ ```
52
+
53
+ You'd modify it to look like this, instead:
54
+
55
+ ```ruby
56
+ User.where(date_of_birth: User.enquo(:date_of_birth, params[:dob]))
57
+ ```
58
+
59
+ If the value for the query was passed in as a positional parameter, you just encrypt the value the same way, so that a query might look like this:
60
+
61
+ ```ruby
62
+ User.where("date_of_birth > ? OR date_of_birth IS NULL", User.enquo(:date_of_birth, params[:dob]))
63
+ ```
64
+
65
+
66
+ ## Step 3: Deploy
67
+
68
+ Once the above changes are all made and committed to revision control, it's time to commence the downtime.
69
+ Shutdown all the application servers, background job workers, and anything else that accesses the database, then perform a normal deployment -- running the database migration process before starting the application again.
70
+
71
+ The migration may take some time to run, if the table is large.
72
+
73
+
74
+ ## Step 4: Enjoy Fully Encrypted Data
75
+
76
+ The column(s) you migrated are now fully protected by Enquo's queryable encryption.
77
+ Relax and enjoy your preferred beverage in celebration!
78
+
79
+
80
+ # Live Data Migration
81
+
82
+ Converting the data in a column to be fully encrypted, while avoiding any application downtime, requires making several changes to the application and database schema in alternating sequence.
83
+ This is necessary to ensure that parts of the application stack running both older and newer code versions can work with the database schema in place at all times.
84
+
85
+ > # WORK IN PROGRESS
86
+ >
87
+ > This section has not been written out in detail.
88
+ > The short version is:
89
+ >
90
+ > 1. Rename the unencrypted column:
91
+ > 1. `create_column :users, :date_of_birth_plaintext, :date`
92
+ > 2. Modify the model to write changes to `date_of_birth` to `date_of_birth_plaintext` as well
93
+ > 3. Deploy
94
+ > 4. Migration to copy all `date_of_birth` values to `date_of_birth_plaintext`
95
+ > 5. Deploy
96
+ > 6. Modify app to read/query from `date_of_birth_plaintext`, add `date_of_birth` to `User.ignored_columns`
97
+ > 7. Deploy
98
+ > 8. `drop_column :users, :date_of_birth`
99
+ > 2. Create the encrypted column:
100
+ > 1. `create_column :users, :date_of_birth, :enquo_date`
101
+ > 2. Modify the model to write changes to `date_of_birth_plaintext` to `date_of_birth` as well, remove `date_of_birth` from `User.ignored_columns`
102
+ > 3. Deploy
103
+ > 4. Migration to encrypt all `date_of_birth_plaintext` values into `date_of_birth`
104
+ > 5. Deploy
105
+ > 6. Modify app to read/encrypted query from `date_of_birth`, add `date_of_birth_plaintext` to `User.ignored_columns`
106
+ > 7. Deploy
107
+ > 8. `drop_column :users, :date_of_birth_plaintext`
@@ -0,0 +1 @@
1
+ /.pgdbenv
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "active_record"
4
+ require "active_enquo"
5
+ require "pg"
6
+
7
+ require_relative "../init"
8
+
9
+ def assert_eq(expected, actual)
10
+ unless expected == actual
11
+ $stderr.puts "Expected #{expected.inspect} to equal #{actual.inspect}"
12
+ exit 1
13
+ end
14
+ end
15
+
16
+ def value(f, v)
17
+ if ENV.key?("USING_ENQUO")
18
+ People.enquo(f, v)
19
+ else
20
+ v
21
+ end
22
+ end
23
+
24
+ ActiveRecord::Base.establish_connection(adapter: ENV.fetch("DBTYPE"))
25
+
26
+ assert_eq(["Meyers"], People.where(first_name: value(:first_name, "Seth")).all.map { |p| p.last_name })
27
+ assert_eq(8, People.where(date_of_birth: value(:date_of_birth, "1980-01-01")..).count)
@@ -0,0 +1,11 @@
1
+ class CreatePeopleTable < ActiveRecord::Migration[ENV.fetch("AR_VERSION", "7.0").to_f]
2
+ def change
3
+ create_table :people do |t|
4
+ t.string :first_name
5
+ t.string :last_name
6
+ t.date :date_of_birth
7
+
8
+ t.timestamps
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,25 @@
1
+ class EncryptPeopleData < ActiveRecord::Migration[ENV.fetch("AR_VERSION", "7.0").to_f]
2
+ def up
3
+ rename_column :people, :first_name, :first_name_plaintext
4
+ rename_column :people, :last_name, :last_name_plaintext
5
+ rename_column :people, :date_of_birth, :date_of_birth_plaintext
6
+
7
+ add_column :people, :first_name, :enquo_text
8
+ add_column :people, :last_name, :enquo_text
9
+ add_column :people, :date_of_birth, :enquo_date
10
+
11
+ People.enquo_encrypt_columns(
12
+ {
13
+ first_name_plaintext: :first_name,
14
+ last_name_plaintext: :last_name,
15
+ date_of_birth_plaintext: :date_of_birth,
16
+ },
17
+ # Smol batch size exercises the batching functionality
18
+ batch_size: 5
19
+ )
20
+
21
+ remove_column :people, :first_name_plaintext
22
+ remove_column :people, :last_name_plaintext
23
+ remove_column :people, :date_of_birth_plaintext
24
+ end
25
+ end
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env bash
2
+
3
+ set -euo pipefail
4
+
5
+ . ../helper.sh
6
+
7
+ export DBTYPE="postgresql"
8
+
9
+ check_enquo_pg_db
10
+ clear_pg_db
11
+
12
+ ar_db_migrate "001"
13
+ load_people
14
+
15
+ ./exercise_model
16
+
17
+ ar_db_migrate "002"
18
+ USING_ENQUO="y" ./exercise_model
@@ -0,0 +1,34 @@
1
+ if [ -f "../.pgdbenv" ]; then
2
+ . ../.pgdbenv
3
+ fi
4
+
5
+ check_enquo_pg_db() {
6
+ if [ "$(psql -tAc "select count(extname) FROM pg_catalog.pg_extension WHERE extname='pg_enquo'")" != "1" ]; then
7
+ echo "Specified PostgreSQL database does not have the pg_enquo extension." >&2
8
+ echo "Check your PG* env vars for correctness, and install the extension if needed." >&2
9
+ exit 1
10
+ fi
11
+ }
12
+
13
+ clear_pg_db() {
14
+ for tbl in $(psql -tAc "select relname from pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n on n.oid = c.relnamespace where c.relkind='r' and n.nspname = 'public'"); do
15
+ psql -c "DROP TABLE $tbl" >/dev/null
16
+ done
17
+ for seq in $(psql -tAc "select relname from pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n on n.oid = c.relnamespace where c.relkind='S' and n.nspname = 'public'"); do
18
+ psql -c "DROP SEQUENCE $seq" >/dev/null
19
+ done
20
+ }
21
+
22
+ run_ruby() {
23
+ ruby -r ../init "$@"
24
+ }
25
+
26
+ ar_db_migrate() {
27
+ local target_version="$1"
28
+
29
+ run_ruby -e "ActiveRecord::MigrationContext.new(['migrations']).up($target_version)" >/dev/null
30
+ }
31
+
32
+ load_people() {
33
+ run_ruby -e "People.create!(JSON.parse(File.read('../people.json')))"
34
+ }
data/e2e_tests/init.rb ADDED
@@ -0,0 +1,18 @@
1
+ require "active_record"
2
+ require "active_enquo"
3
+
4
+ ActiveEnquo.root_key = Enquo::RootKey::Static.new("f91c5017a2d946403cc90a688266ff32d186aa2a00efd34dcaa86be802e179d0")
5
+
6
+ DBTYPE = ENV.fetch("DBTYPE")
7
+
8
+ case DBTYPE
9
+ when "postgresql"
10
+ require "pg"
11
+ else
12
+ raise "Unsupported DBTYPE: #{DBTYPE.inspect}"
13
+ end
14
+
15
+ class People < ActiveRecord::Base
16
+ end
17
+
18
+ ActiveRecord::Base.establish_connection(adapter: DBTYPE)
@@ -0,0 +1 @@
1
+ [{"first_name":"Sergio","last_name":"Leone","date_of_birth":"1929-01-03"},{"first_name":"Bradley","last_name":"Cooper","date_of_birth":"1975-01-05"},{"first_name":"Robert","last_name":"Duvall","date_of_birth":"1931-01-05"},{"first_name":"Kate","last_name":"Moss","date_of_birth":"1974-01-16"},{"first_name":"Laura","last_name":"Schlessinger","date_of_birth":"1947-01-16"},{"first_name":"Jason","last_name":"Segel","date_of_birth":"1980-01-18"},{"first_name":"Pete","last_name":"Buttigieg","date_of_birth":"1982-01-19"},{"first_name":"Mischa","last_name":"Barton","date_of_birth":"1986-01-24"},{"first_name":"Shirley","last_name":"Mason","date_of_birth":"1923-01-25"},{"first_name":"Boris","last_name":"Yeltsin","date_of_birth":"1931-02-01"},{"first_name":"Tom","last_name":"Wilkinson","date_of_birth":"1948-02-05"},{"first_name":"Tony","last_name":"Iommi","date_of_birth":"1948-02-19"},{"first_name":"Sri","last_name":"Srinivasan","date_of_birth":"1967-02-23"},{"first_name":"Tony","last_name":"Randall","date_of_birth":"1920-02-26"},{"first_name":"David","last_name":"Blaine","date_of_birth":"1973-04-04"},{"first_name":"Muddy","last_name":"Waters","date_of_birth":"1915-04-04"},{"first_name":"Danny","last_name":"Almonte","date_of_birth":"1987-04-07"},{"first_name":"Meghann","last_name":"Shaughnessy","date_of_birth":"1979-04-13"},{"first_name":"Maisie","last_name":"Williams","date_of_birth":"1997-04-15"},{"first_name":"Immanuel","last_name":"Kant","date_of_birth":"1724-04-22"},{"first_name":"John","last_name":"Oliver","date_of_birth":"1977-04-23"},{"first_name":"Penelope","last_name":"Cruz","date_of_birth":"1974-04-28"},{"first_name":"Anne","last_name":"Parillaud","date_of_birth":"1961-05-06"},{"first_name":"Cate","last_name":"Blanchett","date_of_birth":"1969-05-14"},{"first_name":"Tucker","last_name":"Carlson","date_of_birth":"1969-05-16"},{"first_name":"Nancy","last_name":"Kwan","date_of_birth":"1939-05-19"},{"first_name":"Melissa","last_name":"Etheridge","date_of_birth":"1961-05-29"},{"first_name":"Marissa","last_name":"Mayer","date_of_birth":"1975-05-30"},{"first_name":"Marvin","last_name":"Hamlisch","date_of_birth":"1944-06-02"},{"first_name":"Gwendolyn","last_name":"Brooks","date_of_birth":"1917-06-07"},{"first_name":"Paul","last_name":"Lynde","date_of_birth":"1927-06-13"},{"first_name":"Paul","last_name":"McCartney","date_of_birth":"1942-06-18"},{"first_name":"Susan","last_name":"Hayward","date_of_birth":"1917-06-30"},{"first_name":"Léa","last_name":"Seydoux","date_of_birth":"1985-07-01"},{"first_name":"Amanda","last_name":"Knox","date_of_birth":"1987-07-09"},{"first_name":"Cindy","last_name":"Sheehan","date_of_birth":"1957-07-10"},{"first_name":"Bill","last_name":"Cosby","date_of_birth":"1937-07-12"},{"first_name":"Raymond","last_name":"Chandler","date_of_birth":"1888-07-23"},{"first_name":"Christopher","last_name":"Nolan","date_of_birth":"1970-07-30"},{"first_name":"Melanie","last_name":"Griffith","date_of_birth":"1957-08-09"},{"first_name":"Freddie","last_name":"Gray","date_of_birth":"1989-08-16"},{"first_name":"Steve","last_name":"Case","date_of_birth":"1958-08-21"},{"first_name":"Cal","last_name":"Ripken","date_of_birth":"1960-08-24"},{"first_name":"Regis","last_name":"Philbin","date_of_birth":"1931-08-25"},{"first_name":"Shania","last_name":"Twain","date_of_birth":"1965-08-28"},{"first_name":"Adam","last_name":"Curry","date_of_birth":"1964-09-03"},{"first_name":"Rachel","last_name":"Hunter","date_of_birth":"1969-09-09"},{"first_name":"Bashar","last_name":"al-Assad","date_of_birth":"1965-09-11"},{"first_name":"Amy","last_name":"Poehler","date_of_birth":"1971-09-16"},{"first_name":"Jim","last_name":"Thompson","date_of_birth":"1906-09-27"},{"first_name":"Matt","last_name":"Damon","date_of_birth":"1970-10-08"},{"first_name":"Leopold","last_name":"Senghor","date_of_birth":"1906-10-09"},{"first_name":"William","last_name":"Penn","date_of_birth":"1644-10-14"},{"first_name":"Rebecca","last_name":"Romijn","date_of_birth":"1971-11-06"},{"first_name":"Auguste","last_name":"Rodin","date_of_birth":"1840-11-12"},{"first_name":"Nate","last_name":"Parker","date_of_birth":"1979-11-18"},{"first_name":"Larry","last_name":"Bird","date_of_birth":"1956-12-07"},{"first_name":"Bobby","last_name":"Flay","date_of_birth":"1964-12-10"},{"first_name":"Chris","last_name":"Evert","date_of_birth":"1954-12-21"},{"first_name":"Frank","last_name":"Zappa","date_of_birth":"1940-12-21"},{"first_name":"Estella","last_name":"Warren","date_of_birth":"1978-12-23"},{"first_name":"Carlos","last_name":"Castaneda","date_of_birth":"1925-12-25"},{"first_name":"Seth","last_name":"Meyers","date_of_birth":"1973-12-28"}]
data/e2e_tests/run ADDED
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env bash
2
+
3
+ set -euo pipefail
4
+
5
+ cd "$(dirname "${BASH_SOURCE[0]}")"
6
+
7
+ for t in [0-9]*; do
8
+ rv="0"
9
+ cd "$t"
10
+
11
+ ./run || rv="$?"
12
+
13
+ if [ "$rv" != "0" ]; then
14
+ echo "Test $i failed with exit code $rv" >&2
15
+ exit 1
16
+ fi
17
+ done
18
+
19
+ echo "All tests passed."
data/lib/active_enquo.rb CHANGED
@@ -21,7 +21,57 @@ module ActiveEnquo
21
21
  RootKey = Enquo::RootKey
22
22
 
23
23
  module ActiveRecord
24
- module ModelExtension
24
+ module QueryFilterMangler
25
+ class Ciphertext < String
26
+ end
27
+ private_constant :Ciphertext
28
+
29
+ private
30
+
31
+ def mangle_query_filter(a)
32
+ args = a.first
33
+ if args.is_a?(Hash)
34
+ args.keys.each do |attr|
35
+ next unless enquo_attr?(attr)
36
+
37
+ if args[attr].is_a?(Array)
38
+ args[attr] = args[attr].map { |v| maybe_enquo(attr, v) }
39
+ elsif args[attr].is_a?(Range)
40
+ r = args[attr]
41
+ args[attr] = if r.exclude_end?
42
+ if r.begin.nil?
43
+ ...maybe_enquo(attr, r.end)
44
+ elsif r.end.nil?
45
+ (maybe_enquo(attr.r.begin)...)
46
+ else
47
+ maybe_enquo(attr.r.begin)...maybe_enquo(attr, r.end)
48
+ end
49
+ else
50
+ if r.begin.nil?
51
+ ..maybe_enquo(attr, r.end)
52
+ elsif r.end.nil?
53
+ maybe_enquo(attr.r.begin)..
54
+ else
55
+ maybe_enquo(attr.r.begin)..maybe_enquo(attr, r.end)
56
+ end
57
+ end
58
+ else
59
+ args[attr] = maybe_enquo(attr, args[attr])
60
+ end
61
+ end
62
+ end
63
+ end
64
+
65
+ def maybe_enquo(attr, v)
66
+ if v.nil? || v.is_a?(Ciphertext) || v.is_a?(::ActiveRecord::StatementCache::Substitute)
67
+ v
68
+ else
69
+ Ciphertext.new(enquo(attr, v))
70
+ end
71
+ end
72
+ end
73
+
74
+ module BaseExtension
25
75
  extend ActiveSupport::Concern
26
76
 
27
77
  def _read_attribute(attr_name, &block)
@@ -51,10 +101,7 @@ module ActiveEnquo
51
101
  relation = self.class.arel_table.name
52
102
  field = ::ActiveEnquo.root.field(relation, attr_name)
53
103
  attr_opts = self.class.enquo_attribute_options.fetch(attr_name.to_sym, {})
54
- safety = if attr_opts[:enable_reduced_security_operations]
55
- :unsafe
56
- end
57
- db_value = t.encrypt(value, @attributes.fetch_value(@primary_key).to_s, field, safety: safety, no_query: attr_opts[:no_query])
104
+ db_value = t.encrypt(value, @attributes.fetch_value(@primary_key).to_s, field, **attr_opts)
58
105
  @attributes.write_from_user(attr_name, db_value)
59
106
  else
60
107
  super
@@ -62,28 +109,114 @@ module ActiveEnquo
62
109
  end
63
110
 
64
111
  module ClassMethods
65
- def enquo(attr_name, value)
112
+ include QueryFilterMangler
113
+
114
+ def find_by(*a)
115
+ mangle_query_filter(a)
116
+ super
117
+ end
118
+
119
+ def enquo(attr_name, value_or_meta_id, maybe_value = nil)
120
+ meta_id, value = if value_or_meta_id.is_a?(Symbol)
121
+ [value_or_meta_id, maybe_value]
122
+ else
123
+ [nil, value_or_meta_id]
124
+ end
125
+
66
126
  t = self.attribute_types[attr_name.to_s]
67
127
  if t.is_a?(::ActiveEnquo::Type)
68
128
  relation = self.arel_table.name
69
129
  field = ::ActiveEnquo.root.field(relation, attr_name)
70
- t.encrypt(value, "", field, safety: :unsafe)
130
+ if meta_id.nil?
131
+ t.encrypt(value, "", field, enable_reduced_security_operations: true)
132
+ else
133
+ t.encrypt_metadata_value(meta_id, value, field)
134
+ end
71
135
  else
72
136
  raise ArgumentError, "Cannot produce encrypted value on a non-enquo attribute '#{attr_name}'"
73
137
  end
74
138
  end
75
139
 
140
+ def unenquo(attr_name, value, ctx)
141
+ t = self.attribute_types[attr_name.to_s]
142
+ if t.is_a?(::ActiveEnquo::Type)
143
+ relation = self.arel_table.name
144
+ field = ::ActiveEnquo.root.field(relation, attr_name)
145
+ begin
146
+ t.decrypt(value, ctx, field)
147
+ rescue Enquo::Error
148
+ t.decrypt(value, "", field)
149
+ end
150
+ else
151
+ raise ArgumentError, "Cannot decrypt value on a non-enquo attribute '#{attr_name}'"
152
+ end
153
+ end
154
+
155
+ def enquo_attr?(attr_name)
156
+ self.attribute_types[attr_name.to_s].is_a?(::ActiveEnquo::Type)
157
+ end
158
+
76
159
  def enquo_attr(attr_name, opts)
160
+ if opts.key?(:default)
161
+ default_value = opts.delete(:default)
162
+ after_initialize do
163
+ next if persisted?
164
+ next unless self.send(attr_name).nil?
165
+ self.send(:"#{attr_name}=", default_value.duplicable? ? default_value.dup : default_value)
166
+ end
167
+ end
168
+
77
169
  enquo_attribute_options[attr_name] = @enquo_attribute_options[attr_name].merge(opts)
78
170
  end
79
171
 
80
172
  def enquo_attribute_options
81
173
  @enquo_attribute_options ||= Hash.new({})
82
174
  end
175
+
176
+ def enquo_encrypt_columns(column_map, batch_size: 10_000)
177
+ plaintext_columns = column_map.keys
178
+ relation = self.arel_table.name
179
+ in_progress = true
180
+ self.reset_column_information
181
+
182
+ while in_progress
183
+ self.transaction do
184
+ # The .where("0=1") here is a dummy condition so that the q.or in the .each will work properly
185
+ q = self.select(self.primary_key).select(plaintext_columns).where("0=1")
186
+ column_map.each do |pt_col, ct_col|
187
+ q = q.or(self.where(ct_col => nil).where.not(pt_col => nil))
188
+ end
189
+
190
+ q = q.limit(batch_size).lock
191
+
192
+ rows = ::ActiveRecord::Base.connection.exec_query(q.to_sql)
193
+ if rows.length == 0
194
+ in_progress = false
195
+ else
196
+ rows.each do |row|
197
+ values = Hash[column_map.map do |pt_col, ct_col|
198
+ field = ::ActiveEnquo.root.field(relation, ct_col)
199
+ attr_opts = self.enquo_attribute_options.fetch(ct_col.to_sym, {})
200
+ t = self.attribute_types[ct_col.to_s]
201
+ db_value = t.encrypt(row[pt_col.to_s], row[self.primary_key].to_s, field, **attr_opts)
202
+
203
+ [ct_col, db_value]
204
+ end]
205
+
206
+ self.where(self.primary_key => row[self.primary_key]).update_all(values)
207
+ end
208
+ end
209
+ end
210
+ end
211
+ end
83
212
  end
84
213
  end
85
214
 
86
215
  module TableDefinitionExtension
216
+ def enquo_boolean(name, **options)
217
+ column(name, :enquo_boolean, **options)
218
+ end
219
+
87
220
  def enquo_bigint(name, **options)
88
221
  column(name, :enquo_bigint, **options)
89
222
  end
@@ -96,11 +229,28 @@ module ActiveEnquo
96
229
  column(name, :enquo_text, **options)
97
230
  end
98
231
  end
232
+
233
+ module RelationExtension
234
+ include QueryFilterMangler
235
+ extend ActiveSupport::Concern
236
+
237
+ def where(*a)
238
+ mangle_query_filter(a)
239
+ super
240
+ end
241
+
242
+ def exists?(*a)
243
+ mangle_query_filter(a)
244
+ super
245
+ end
246
+
247
+ end
99
248
  end
100
249
 
101
250
  module Postgres
102
251
  module ConnectionAdapter
103
252
  def initialize_type_map(m = type_map)
253
+ m.register_type "enquo_boolean", ActiveEnquo::Type::Boolean.new
104
254
  m.register_type "enquo_bigint", ActiveEnquo::Type::Bigint.new
105
255
  m.register_type "enquo_date", ActiveEnquo::Type::Date.new
106
256
  m.register_type "enquo_text", ActiveEnquo::Type::Text.new
@@ -111,13 +261,35 @@ module ActiveEnquo
111
261
  end
112
262
 
113
263
  class Type < ::ActiveRecord::Type::Value
264
+ class Boolean < Type
265
+ def type
266
+ :enquo_boolean
267
+ end
268
+
269
+ def encrypt(value, context, field, enable_reduced_security_operations: false, no_query: false)
270
+ if value.nil? || value.is_a?(::ActiveRecord::StatementCache::Substitute)
271
+ value
272
+ else
273
+ field.encrypt_boolean(value, context, safety: enable_reduced_security_operations ? :unsafe : true, no_query: no_query)
274
+ end
275
+ end
276
+
277
+ def decrypt(value, context, field)
278
+ field.decrypt_boolean(value, context)
279
+ end
280
+ end
281
+
114
282
  class Bigint < Type
115
283
  def type
116
284
  :enquo_bigint
117
285
  end
118
286
 
119
- def encrypt(value, context, field, safety: true, no_query: false)
120
- field.encrypt_i64(value, context, safety: safety, no_query: no_query)
287
+ def encrypt(value, context, field, enable_reduced_security_operations: false, no_query: false)
288
+ if value.nil? || value.is_a?(::ActiveRecord::StatementCache::Substitute)
289
+ value
290
+ else
291
+ field.encrypt_i64(value, context, safety: enable_reduced_security_operations ? :unsafe : true, no_query: no_query)
292
+ end
121
293
  end
122
294
 
123
295
  def decrypt(value, context, field)
@@ -130,9 +302,13 @@ module ActiveEnquo
130
302
  :enquo_date
131
303
  end
132
304
 
133
- def encrypt(value, context, field, safety: true, no_query: false)
305
+ def encrypt(value, context, field, enable_reduced_security_operations: false, no_query: false)
134
306
  value = cast_to_date(value)
135
- field.encrypt_date(value, context, safety: safety, no_query: no_query)
307
+ if value.nil?
308
+ value
309
+ else
310
+ field.encrypt_date(value, context, safety: enable_reduced_security_operations ? :unsafe : true, no_query: no_query)
311
+ end
136
312
  end
137
313
 
138
314
  def decrypt(value, context, field)
@@ -146,6 +322,8 @@ module ActiveEnquo
146
322
  value
147
323
  elsif value.respond_to?(:to_date)
148
324
  value.to_date
325
+ elsif value.nil? || (value.respond_to?(:empty?) && value.empty?) || value.is_a?(::ActiveRecord::StatementCache::Substitute)
326
+ nil
149
327
  else
150
328
  Time.parse(value.to_s).to_date
151
329
  end
@@ -157,12 +335,33 @@ module ActiveEnquo
157
335
  :enquo_text
158
336
  end
159
337
 
160
- def encrypt(value, context, field, safety: true, no_query: false)
161
- field.encrypt_text(value, context, safety: safety, no_query: no_query)
338
+ def encrypt(value, context, field, enable_reduced_security_operations: false, no_query: false, enable_ordering: false)
339
+ if enable_ordering && !enable_reduced_security_operations
340
+ raise ArgumentError, "Cannot enable ordering on an Enquo attribute unless Reduced Security Operations are enabled"
341
+ end
342
+
343
+ if value.nil? || value.is_a?(::ActiveRecord::StatementCache::Substitute)
344
+ value
345
+ else
346
+ field.encrypt_text(value.respond_to?(:encode) ? value.encode("UTF-8") : value, context, safety: enable_reduced_security_operations ? :unsafe : true, no_query: no_query, order_prefix_length: enable_ordering ? 8 : nil)
347
+ end
348
+ end
349
+
350
+ def encrypt_metadata_value(name, value, field)
351
+ case name
352
+ when :length
353
+ field.encrypt_text_length_query(value)
354
+ else
355
+ raise ArgumentError, "Unknown metadata name for Text field: #{name.inspect}"
356
+ end
162
357
  end
163
358
 
164
359
  def decrypt(value, context, field)
165
- field.decrypt_text(value, context)
360
+ if value.nil?
361
+ nil
362
+ else
363
+ field.decrypt_text(value, context)
364
+ end
166
365
  end
167
366
  end
168
367
  end
@@ -171,11 +370,21 @@ module ActiveEnquo
171
370
  class Initializer < Rails::Railtie
172
371
  initializer "active_enquo.root_key" do |app|
173
372
  if app
174
- if root_key = app.credentials.active_enquo.root_key
175
- ActiveEnquo.root_key = Enquo::RootKey::Static.new(root_key)
373
+ if app.credentials
374
+ if app.credentials.active_enquo
375
+ if root_key = app.credentials.active_enquo.root_key
376
+ ActiveEnquo.root_key = Enquo::RootKey::Static.new(root_key)
377
+ else
378
+ Rails.logger.warn "Could not initialize ActiveEnquo, as no active_enquo.root_key credential was found for this environment"
379
+ end
380
+ else
381
+ Rails.logger.warn "Could not initialize ActiveEnquo, as no active_enquo credentials were found for this environment"
382
+ end
176
383
  else
177
- Rails.warn "Could not initialize ActiveEnquo, as no active_enquo.root_key credential was found for this environment"
384
+ Rails.logger.warn "Could not initialize ActiveEnquo, as no credentials were found for this environment"
178
385
  end
386
+ else
387
+ Rails.logger.warn "Could not initialize ActiveEnquo, as no app was found for this environment"
179
388
  end
180
389
  end
181
390
  end
@@ -183,13 +392,15 @@ module ActiveEnquo
183
392
  end
184
393
 
185
394
  ActiveSupport.on_load(:active_record) do
186
- ::ActiveRecord::Base.send :include, ActiveEnquo::ActiveRecord::ModelExtension
395
+ ::ActiveRecord::Relation.prepend ActiveEnquo::ActiveRecord::RelationExtension
396
+ ::ActiveRecord::Base.include ActiveEnquo::ActiveRecord::BaseExtension
187
397
 
188
398
  ::ActiveRecord::ConnectionAdapters::Table.include ActiveEnquo::ActiveRecord::TableDefinitionExtension
189
399
  ::ActiveRecord::ConnectionAdapters::TableDefinition.include ActiveEnquo::ActiveRecord::TableDefinitionExtension
190
400
 
191
401
  ::ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.prepend ActiveEnquo::Postgres::ConnectionAdapter
192
402
 
403
+ ::ActiveRecord::ConnectionAdapters::PostgreSQLAdapter::NATIVE_DATABASE_TYPES[:enquo_boolean] = { name: "enquo_boolean" }
193
404
  ::ActiveRecord::ConnectionAdapters::PostgreSQLAdapter::NATIVE_DATABASE_TYPES[:enquo_bigint] = { name: "enquo_bigint" }
194
405
  ::ActiveRecord::ConnectionAdapters::PostgreSQLAdapter::NATIVE_DATABASE_TYPES[:enquo_date] = { name: "enquo_date" }
195
406
  ::ActiveRecord::ConnectionAdapters::PostgreSQLAdapter::NATIVE_DATABASE_TYPES[:enquo_text] = { name: "enquo_text" }
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: active_enquo
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matt Palmer
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2022-11-08 00:00:00.000000000 Z
11
+ date: 2023-04-14 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: enquo-core
@@ -16,14 +16,14 @@ dependencies:
16
16
  requirements:
17
17
  - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: '0.6'
19
+ version: '0.7'
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
- version: '0.6'
26
+ version: '0.7'
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: activerecord
29
29
  requirement: !ruby/object:Gem::Requirement
@@ -195,6 +195,16 @@ files:
195
195
  - README.md
196
196
  - active_enquo.gemspec
197
197
  - docs/DEVELOPMENT.md
198
+ - docs/MIGRATION.md
199
+ - e2e_tests/.gitignore
200
+ - e2e_tests/001_direct_migration/exercise_model
201
+ - e2e_tests/001_direct_migration/migrations/001_create_people_table.rb
202
+ - e2e_tests/001_direct_migration/migrations/002_encrypt_people_data.rb
203
+ - e2e_tests/001_direct_migration/run
204
+ - e2e_tests/helper.sh
205
+ - e2e_tests/init.rb
206
+ - e2e_tests/people.json
207
+ - e2e_tests/run
198
208
  - lib/active_enquo.rb
199
209
  homepage: https://enquo.org
200
210
  licenses: []