aws_advanced_ruby_driver_wrapper 1.0.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 +7 -0
- data/CHANGELOG.md +23 -0
- data/LICENSE +175 -0
- data/NOTICE +1 -0
- data/README.md +168 -0
- data/THIRD-PARTY-LICENSES +473 -0
- data/aws_advanced_ruby_driver_wrapper.gemspec +73 -0
- data/lib/aws_advanced_ruby_driver_wrapper/active_record/aws_mysql2_adapter.rb +73 -0
- data/lib/aws_advanced_ruby_driver_wrapper/active_record/aws_postgresql_adapter.rb +95 -0
- data/lib/aws_advanced_ruby_driver_wrapper/custom_configuration.rb +58 -0
- data/lib/aws_advanced_ruby_driver_wrapper/db_dialects/aurora_mysql_dialect.rb +103 -0
- data/lib/aws_advanced_ruby_driver_wrapper/db_dialects/aurora_pg_dialect.rb +124 -0
- data/lib/aws_advanced_ruby_driver_wrapper/db_dialects/dialect_codes.rb +38 -0
- data/lib/aws_advanced_ruby_driver_wrapper/db_dialects/global_mysql_dialect.rb +91 -0
- data/lib/aws_advanced_ruby_driver_wrapper/db_dialects/global_pg_dialect.rb +92 -0
- data/lib/aws_advanced_ruby_driver_wrapper/db_dialects/multi_az_cluster_mysql_dialect.rb +95 -0
- data/lib/aws_advanced_ruby_driver_wrapper/db_dialects/multi_az_cluster_pg_dialect.rb +86 -0
- data/lib/aws_advanced_ruby_driver_wrapper/db_dialects/mysql_dialect.rb +98 -0
- data/lib/aws_advanced_ruby_driver_wrapper/db_dialects/pg_dialect.rb +95 -0
- data/lib/aws_advanced_ruby_driver_wrapper/db_dialects/rds_mysql_dialect.rb +88 -0
- data/lib/aws_advanced_ruby_driver_wrapper/db_dialects/rds_pg_dialect.rb +86 -0
- data/lib/aws_advanced_ruby_driver_wrapper/db_dialects/unknown_dialect.rb +72 -0
- data/lib/aws_advanced_ruby_driver_wrapper/db_dialects/utils/dialect_utils.rb +71 -0
- data/lib/aws_advanced_ruby_driver_wrapper/driver_dialects/driver_dialect.rb +154 -0
- data/lib/aws_advanced_ruby_driver_wrapper/driver_dialects/driver_dialect_manager.rb +55 -0
- data/lib/aws_advanced_ruby_driver_wrapper/driver_dialects/mysql_driver_dialect.rb +165 -0
- data/lib/aws_advanced_ruby_driver_wrapper/driver_dialects/pg_driver_dialect.rb +201 -0
- data/lib/aws_advanced_ruby_driver_wrapper/errors/error_handler.rb +62 -0
- data/lib/aws_advanced_ruby_driver_wrapper/errors/mysql_error_handler.rb +80 -0
- data/lib/aws_advanced_ruby_driver_wrapper/errors/pg_error_handler.rb +126 -0
- data/lib/aws_advanced_ruby_driver_wrapper/errors.rb +59 -0
- data/lib/aws_advanced_ruby_driver_wrapper/host/connection_string_host_list_provider.rb +95 -0
- data/lib/aws_advanced_ruby_driver_wrapper/host/global_aurora_host_list_provider.rb +65 -0
- data/lib/aws_advanced_ruby_driver_wrapper/host/host_availability.rb +24 -0
- data/lib/aws_advanced_ruby_driver_wrapper/host/host_availability_strategy.rb +27 -0
- data/lib/aws_advanced_ruby_driver_wrapper/host/host_info.rb +137 -0
- data/lib/aws_advanced_ruby_driver_wrapper/host/host_role.rb +25 -0
- data/lib/aws_advanced_ruby_driver_wrapper/host/random_host_selector.rb +40 -0
- data/lib/aws_advanced_ruby_driver_wrapper/host/rds_host_list_provider.rb +206 -0
- data/lib/aws_advanced_ruby_driver_wrapper/logging.rb +110 -0
- data/lib/aws_advanced_ruby_driver_wrapper/monitoring/cluster_topology_monitor.rb +709 -0
- data/lib/aws_advanced_ruby_driver_wrapper/monitoring/global_cluster_topology_monitor.rb +72 -0
- data/lib/aws_advanced_ruby_driver_wrapper/monitoring/monitor.rb +99 -0
- data/lib/aws_advanced_ruby_driver_wrapper/monitoring/monitor_connection.rb +57 -0
- data/lib/aws_advanced_ruby_driver_wrapper/monitoring/monitor_state.rb +25 -0
- data/lib/aws_advanced_ruby_driver_wrapper/mysql.rb +429 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/blue_green_plugin.rb +205 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/host_mapper.rb +132 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/iam_host_tracker.rb +84 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/interim_status.rb +92 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/interval_rate.rb +27 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/phase.rb +69 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/phase_event_log.rb +85 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/phase_time_info.rb +25 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/role.rb +38 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/routing/base_routing.rb +83 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/routing/reject_connect_routing.rb +40 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/routing/substitute_connect_routing.rb +136 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/routing/suspend_connect_routing.rb +53 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/routing/suspend_execute_routing.rb +52 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/routing/suspend_until_corresponding_host_found_connect_routing.rb +83 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/status.rb +68 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/status_builder.rb +244 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/status_info.rb +30 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/status_monitor.rb +564 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/status_provider.rb +414 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/switchover_state.rb +98 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/switchover_timer.rb +46 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/custom_endpoint/custom_endpoint_monitor.rb +266 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/custom_endpoint/custom_endpoint_plugin.rb +158 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/custom_endpoint/info.rb +111 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/custom_endpoint/member_list_type.rb +31 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/custom_endpoint/role.rb +45 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/default_plugin.rb +108 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/failover_mode.rb +43 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/failover_plugin.rb +467 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/gdb/gdb_failover_mode.rb +68 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/gdb/gdb_failover_plugin.rb +403 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/iam_auth_plugin.rb +159 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/initial_connection_strategy_plugin.rb +485 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/audit_logger.rb +157 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/column_cipher.rb +159 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/column_encryption_config.rb +61 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/connection_source.rb +91 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/data_key_cache.rb +220 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/encryption_algorithm.rb +75 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/encryption_config.rb +146 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/encryption_service.rb +391 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/error_context.rb +198 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/errors.rb +259 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/key_management_utility.rb +435 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/key_manager.rb +378 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/key_metadata.rb +86 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/kms_encryption_plugin.rb +890 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/kms_encryption_utility.rb +281 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/metadata_manager.rb +332 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/sanitizer.rb +147 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/schema_name.rb +70 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/schema_validator.rb +211 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/sql_runner.rb +147 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/type_marker.rb +109 -0
- data/lib/aws_advanced_ruby_driver_wrapper/plugins/secrets_manager_plugin.rb +358 -0
- data/lib/aws_advanced_ruby_driver_wrapper/postgresql.rb +659 -0
- data/lib/aws_advanced_ruby_driver_wrapper/property_definition.rb +409 -0
- data/lib/aws_advanced_ruby_driver_wrapper/ruby_method.rb +122 -0
- data/lib/aws_advanced_ruby_driver_wrapper/services/connection_service.rb +143 -0
- data/lib/aws_advanced_ruby_driver_wrapper/services/dialect_service.rb +267 -0
- data/lib/aws_advanced_ruby_driver_wrapper/services/host_service.rb +199 -0
- data/lib/aws_advanced_ruby_driver_wrapper/services/monitor_service.rb +186 -0
- data/lib/aws_advanced_ruby_driver_wrapper/services/plugin_call_context.rb +63 -0
- data/lib/aws_advanced_ruby_driver_wrapper/services/plugin_manager.rb +273 -0
- data/lib/aws_advanced_ruby_driver_wrapper/services/service_container.rb +30 -0
- data/lib/aws_advanced_ruby_driver_wrapper/services/service_utility.rb +78 -0
- data/lib/aws_advanced_ruby_driver_wrapper/services/session_state_service.rb +56 -0
- data/lib/aws_advanced_ruby_driver_wrapper/utils/accessible_regions.rb +52 -0
- data/lib/aws_advanced_ruby_driver_wrapper/utils/ar_constants.rb +25 -0
- data/lib/aws_advanced_ruby_driver_wrapper/utils/aurora_topology_utils.rb +99 -0
- data/lib/aws_advanced_ruby_driver_wrapper/utils/aws_credentials_utils.rb +62 -0
- data/lib/aws_advanced_ruby_driver_wrapper/utils/connection_config.rb +91 -0
- data/lib/aws_advanced_ruby_driver_wrapper/utils/connection_config_parser.rb +368 -0
- data/lib/aws_advanced_ruby_driver_wrapper/utils/conversion_utils.rb +51 -0
- data/lib/aws_advanced_ruby_driver_wrapper/utils/events/batching_event_publisher.rb +119 -0
- data/lib/aws_advanced_ruby_driver_wrapper/utils/events/data_access_event.rb +26 -0
- data/lib/aws_advanced_ruby_driver_wrapper/utils/events/monitor_reset_event.rb +26 -0
- data/lib/aws_advanced_ruby_driver_wrapper/utils/global_aurora_topology_utils.rb +185 -0
- data/lib/aws_advanced_ruby_driver_wrapper/utils/host_list_utils.rb +27 -0
- data/lib/aws_advanced_ruby_driver_wrapper/utils/iam_auth_utils.rb +112 -0
- data/lib/aws_advanced_ruby_driver_wrapper/utils/multi_az_topology_utils.rb +117 -0
- data/lib/aws_advanced_ruby_driver_wrapper/utils/parser/encryption_annotation_parser.rb +99 -0
- data/lib/aws_advanced_ruby_driver_wrapper/utils/parser/mysql_statement_analyzer.rb +641 -0
- data/lib/aws_advanced_ruby_driver_wrapper/utils/parser/pg_statement_analyzer.rb +502 -0
- data/lib/aws_advanced_ruby_driver_wrapper/utils/parser/query_analysis.rb +63 -0
- data/lib/aws_advanced_ruby_driver_wrapper/utils/parser/query_type.rb +35 -0
- data/lib/aws_advanced_ruby_driver_wrapper/utils/parser/routing_hint.rb +27 -0
- data/lib/aws_advanced_ruby_driver_wrapper/utils/parser/routing_hint_parser.rb +50 -0
- data/lib/aws_advanced_ruby_driver_wrapper/utils/parser/sql_parser.rb +139 -0
- data/lib/aws_advanced_ruby_driver_wrapper/utils/rds_url_type.rb +71 -0
- data/lib/aws_advanced_ruby_driver_wrapper/utils/rds_utils.rb +575 -0
- data/lib/aws_advanced_ruby_driver_wrapper/utils/retry_util.rb +153 -0
- data/lib/aws_advanced_ruby_driver_wrapper/utils/sql_encoding.rb +56 -0
- data/lib/aws_advanced_ruby_driver_wrapper/utils/sql_method_analyzer.rb +195 -0
- data/lib/aws_advanced_ruby_driver_wrapper/utils/storage/cache_entry.rb +56 -0
- data/lib/aws_advanced_ruby_driver_wrapper/utils/storage/expiration_cache.rb +108 -0
- data/lib/aws_advanced_ruby_driver_wrapper/utils/storage/sliding_expiration_cache.rb +137 -0
- data/lib/aws_advanced_ruby_driver_wrapper/utils/storage/storage_service.rb +172 -0
- data/lib/aws_advanced_ruby_driver_wrapper/utils/topology_utils.rb +127 -0
- data/lib/aws_advanced_ruby_driver_wrapper/version.rb +19 -0
- data/lib/aws_advanced_ruby_driver_wrapper/wrapper_property.rb +64 -0
- data/lib/aws_advanced_ruby_driver_wrapper.rb +116 -0
- metadata +227 -0
|
@@ -0,0 +1,890 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
|
4
|
+
#
|
|
5
|
+
# Licensed under the Apache License, Version 2.0 (the "License").
|
|
6
|
+
# You may not use this file except in compliance with the License.
|
|
7
|
+
# You may obtain a copy of the License at
|
|
8
|
+
#
|
|
9
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
#
|
|
11
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
# See the License for the specific language governing permissions and
|
|
15
|
+
# limitations under the License.
|
|
16
|
+
|
|
17
|
+
require 'concurrent'
|
|
18
|
+
require_relative '../../logging'
|
|
19
|
+
require_relative '../../ruby_method'
|
|
20
|
+
require_relative '../../utils/parser/encryption_annotation_parser'
|
|
21
|
+
require_relative '../../utils/parser/query_type'
|
|
22
|
+
require_relative '../../utils/parser/sql_parser'
|
|
23
|
+
require_relative '../../utils/sql_encoding'
|
|
24
|
+
require_relative 'column_cipher'
|
|
25
|
+
require_relative 'errors'
|
|
26
|
+
require_relative 'kms_encryption_utility'
|
|
27
|
+
|
|
28
|
+
module AwsAdvancedRubyDriverWrapper
|
|
29
|
+
module Plugins
|
|
30
|
+
# Encrypts and decrypts individual table columns with keys held in AWS KMS, without the
|
|
31
|
+
# application having to know about it.
|
|
32
|
+
#
|
|
33
|
+
# Which columns are encrypted is configured in the database rather than in application code, so
|
|
34
|
+
# it can change without redeploying the application. That configuration is managed through
|
|
35
|
+
# {Encryption::KeyManagementUtility} rather than by editing the tables by hand. When a
|
|
36
|
+
# statement writes to one of those columns the plugin encrypts the bind parameter on its way to
|
|
37
|
+
# the server, and when a statement reads one back it decrypts the value on its way to the
|
|
38
|
+
# application. The plaintext never reaches the server, and the data keys never reach it either:
|
|
39
|
+
# only a KMS encrypted copy of each data key is stored, in +key_storage+.
|
|
40
|
+
#
|
|
41
|
+
# Encryption applies to bind parameters, so a value can only be encrypted when it is bound
|
|
42
|
+
# rather than written into the SQL text:
|
|
43
|
+
#
|
|
44
|
+
# # pg
|
|
45
|
+
# conn.exec_params('INSERT INTO users (name, ssn) VALUES ($1, $2)', ['Jo', '123-45-6789'])
|
|
46
|
+
# conn.exec_params('SELECT ssn FROM users WHERE name = $1', ['Jo']).each { |row| row['ssn'] }
|
|
47
|
+
#
|
|
48
|
+
# # mysql2
|
|
49
|
+
# client.prepare('INSERT INTO users (name, ssn) VALUES (?, ?)').execute('Jo', '123-45-6789')
|
|
50
|
+
#
|
|
51
|
+
# The plugin works out which parameter belongs to which column by parsing the statement. When
|
|
52
|
+
# the statement is too involved for that, the column can be named explicitly with an
|
|
53
|
+
# annotation, which takes precedence over anything the parser found:
|
|
54
|
+
#
|
|
55
|
+
# conn.exec_params('INSERT INTO users (name, ssn) VALUES ($1, /*@encrypt:users.ssn*/ $2)', ...)
|
|
56
|
+
#
|
|
57
|
+
# Decrypted values are always returned as strings, whatever type the value had when it was
|
|
58
|
+
# written. An encrypted column is a binary column (+bytea+ or +VARBINARY+), which is what both
|
|
59
|
+
# drivers return as a string, so a string keeps the read consistent with the column's real type
|
|
60
|
+
# and with how ActiveRecord treats it. Cast the value on read when the application needs another
|
|
61
|
+
# type, for example +row['age'].to_i+.
|
|
62
|
+
#
|
|
63
|
+
# A row is decrypted however it is read. A row read as a hash is matched to its columns by name;
|
|
64
|
+
# a row (or single cell) read as bare values is matched by position, through the field list the
|
|
65
|
+
# result reports, which is what lets ActiveRecord's reads decrypt even though it fetches rows as
|
|
66
|
+
# arrays. This covers +PG::Result+'s +#each+, +#each_row+, +#to_a+, +#[]+, +#values+,
|
|
67
|
+
# +#field_values+, +#column_values+, +#tuple+, +#tuple_values+ and +#getvalue+, its single-row
|
|
68
|
+
# streaming +#stream_each+ / +#stream_each_row+ / +#stream_each_tuple+, and mysql2's hash and
|
|
69
|
+
# array results alike. A +COPY ... TO+ is the exception: its rows are a stream rather than values
|
|
70
|
+
# the plugin can replace, so it hands out what the column holds.
|
|
71
|
+
#
|
|
72
|
+
# The plugin does not try to guarantee that an encrypted column never holds a plaintext - it
|
|
73
|
+
# cannot, since it only sees the statements this wrapper sends over a connection that has it
|
|
74
|
+
# enabled, and only the ones it can read that far. That guarantee is the database's to make, with
|
|
75
|
+
# a trigger that checks a value's integrity tag as it goes in (it can do so without the data key,
|
|
76
|
+
# because the HMAC key is stored in +key_storage+), and installing one is required; see the
|
|
77
|
+
# plugin docs. The plugin's job is to encrypt every value it can confidently place into an
|
|
78
|
+
# encrypted column and to stay out of the way otherwise.
|
|
79
|
+
#
|
|
80
|
+
# So when the plugin cannot read a statement well enough to be sure - the tables it writes cannot
|
|
81
|
+
# be established, or a table has encrypted columns but which of them this statement writes cannot
|
|
82
|
+
# be enumerated, or a value is bound to a prepared statement whose text the connection never saw -
|
|
83
|
+
# it passes the statement through rather than refusing it, and leaves a plaintext for the database
|
|
84
|
+
# trigger to reject. Refusing here would reject legitimate statements that never touch an
|
|
85
|
+
# encrypted column at all.
|
|
86
|
+
#
|
|
87
|
+
# The one write-side case it still fails closed on is the one it can be certain about: a column it
|
|
88
|
+
# has confirmed is encrypted, written with something other than a bind parameter (a literal, an
|
|
89
|
+
# expression, a DEFAULT, a +COPY+ stream), which cannot be encrypted. That is almost always a
|
|
90
|
+
# mistake, so it is refused with advice to bind or annotate the value. An annotation naming the
|
|
91
|
+
# column takes it off the plugin's hands; a +COPY+ has no parameter for one to name, so it is
|
|
92
|
+
# steered to +INSERT+ instead.
|
|
93
|
+
#
|
|
94
|
+
# A prepared statement is run by name, so the plugin reads the statement the connection remembers
|
|
95
|
+
# preparing under that name, whether it was prepared by the driver's own +prepare+ or by a
|
|
96
|
+
# +PREPARE+ sent as a statement. A +PREPARE+ is also checked as it is sent, and not only when its
|
|
97
|
+
# name is later run, since a value written into the statement it carries rather than left as a
|
|
98
|
+
# parameter is only in hand while the +PREPARE+ itself is.
|
|
99
|
+
class KmsEncryptionPlugin
|
|
100
|
+
include Logging
|
|
101
|
+
|
|
102
|
+
# Statement methods whose bind parameters may have to be encrypted, mapped to the position of
|
|
103
|
+
# the parameter array in the argument list. A nil position means the arguments are themselves
|
|
104
|
+
# the parameters.
|
|
105
|
+
PARAMETER_METHODS = {
|
|
106
|
+
RubyMethod::CONNECTION_EXEC.name => 1,
|
|
107
|
+
RubyMethod::CONNECTION_ASYNC_EXEC.name => 1,
|
|
108
|
+
RubyMethod::CONNECTION_EXEC_PARAMS.name => 1,
|
|
109
|
+
RubyMethod::CONNECTION_SEND_QUERY.name => 1,
|
|
110
|
+
RubyMethod::CONNECTION_SEND_QUERY_PARAMS.name => 1,
|
|
111
|
+
RubyMethod::CONNECTION_EXEC_PREPARED.name => 1,
|
|
112
|
+
RubyMethod::CONNECTION_SEND_QUERY_PREPARED.name => 1,
|
|
113
|
+
RubyMethod::STATEMENT_EXECUTE.name => nil
|
|
114
|
+
}.freeze
|
|
115
|
+
|
|
116
|
+
# Statement methods that take no bind parameters. There is nothing to encrypt for these, but a
|
|
117
|
+
# statement that carries its values outside its bind parameters is exactly the one that could
|
|
118
|
+
# write a confirmed encrypted column unencryptably, so they are still run through the write
|
|
119
|
+
# check.
|
|
120
|
+
#
|
|
121
|
+
# mysql2's +query+ covers its asynchronous path as well, since that is the same call with
|
|
122
|
+
# +async: true+ passed to it, and the statement is inspected when it is sent either way.
|
|
123
|
+
#
|
|
124
|
+
# pg's +copy_data+ is here because it opens its COPY on the driver's own connection rather than
|
|
125
|
+
# through the wrapper, so the statement would otherwise never be seen. Checking it when the COPY
|
|
126
|
+
# is opened is what makes checking the calls that feed it unnecessary: a COPY that names a
|
|
127
|
+
# confirmed encrypted column is refused before there is anywhere to put a row.
|
|
128
|
+
WRITE_CHECK_METHODS = Set[
|
|
129
|
+
RubyMethod::CONNECTION_QUERY.name,
|
|
130
|
+
RubyMethod::CONNECTION_COPY_DATA.name
|
|
131
|
+
].freeze
|
|
132
|
+
|
|
133
|
+
# Result methods that yield rows to a block, one at a time. The +stream_+ variants are the
|
|
134
|
+
# single-row-mode iterators, which read rows off the wire one at a time but hand out the same
|
|
135
|
+
# row shapes.
|
|
136
|
+
ROW_BLOCK_METHODS = Set[
|
|
137
|
+
RubyMethod::RESULT_EACH.name,
|
|
138
|
+
RubyMethod::RESULT_EACH_ROW.name,
|
|
139
|
+
RubyMethod::RESULT_STREAM_EACH.name,
|
|
140
|
+
RubyMethod::RESULT_STREAM_EACH_ROW.name,
|
|
141
|
+
RubyMethod::RESULT_STREAM_EACH_TUPLE.name
|
|
142
|
+
].freeze
|
|
143
|
+
|
|
144
|
+
# Result methods that return every row at once, as an array of rows.
|
|
145
|
+
ROW_COLLECTION_METHODS = Set[
|
|
146
|
+
RubyMethod::RESULT_TO_A.name,
|
|
147
|
+
RubyMethod::RESULT_VALUES.name
|
|
148
|
+
].freeze
|
|
149
|
+
|
|
150
|
+
# Result methods that return a single row.
|
|
151
|
+
ROW_SINGLE_METHODS = Set[
|
|
152
|
+
RubyMethod::RESULT_BRACKET.name,
|
|
153
|
+
RubyMethod::RESULT_TUPLE.name,
|
|
154
|
+
RubyMethod::RESULT_TUPLE_VALUES.name
|
|
155
|
+
].freeze
|
|
156
|
+
|
|
157
|
+
# Every result method that hands out whole rows, however it does so. A row may arrive as a hash
|
|
158
|
+
# keyed by column name or as an array of bare values, and either is decrypted: a hash by name,
|
|
159
|
+
# an array by matching each position to a column through the result's field list.
|
|
160
|
+
ROW_METHODS = (ROW_BLOCK_METHODS + ROW_COLLECTION_METHODS + ROW_SINGLE_METHODS).freeze
|
|
161
|
+
|
|
162
|
+
# Result methods that hand out every value of one column. One names the column; the other gives
|
|
163
|
+
# its position in the result, which is matched to a name through the field list.
|
|
164
|
+
COLUMN_BY_NAME_METHOD = RubyMethod::RESULT_FIELD_VALUES.name
|
|
165
|
+
COLUMN_BY_INDEX_METHOD = RubyMethod::RESULT_COLUMN_VALUES.name
|
|
166
|
+
|
|
167
|
+
# The result method that hands out a single cell by row and column position.
|
|
168
|
+
VALUE_BY_INDEX_METHOD = RubyMethod::RESULT_GETVALUE.name
|
|
169
|
+
|
|
170
|
+
# Statement types that store values. These are the ones run through the write check, which
|
|
171
|
+
# refuses a statement that writes a confirmed encrypted column with a value it cannot encrypt
|
|
172
|
+
# and otherwise leaves the statement to the database's own enforcement.
|
|
173
|
+
WRITE_QUERY_TYPES = Set[
|
|
174
|
+
Utils::Parser::QueryType::INSERT,
|
|
175
|
+
Utils::Parser::QueryType::UPDATE,
|
|
176
|
+
Utils::Parser::QueryType::COPY
|
|
177
|
+
].freeze
|
|
178
|
+
|
|
179
|
+
# A statement that stores values, as far as its keywords go. The keywords are looked at as well
|
|
180
|
+
# as the parse, because a statement the parser could not read has no query type, and that is
|
|
181
|
+
# precisely the case that must not be mistaken for a read.
|
|
182
|
+
#
|
|
183
|
+
# The keyword is not always the first thing in the text. Query instrumentation prepends a
|
|
184
|
+
# comment routinely, and a common table expression can come in front of a statement that
|
|
185
|
+
# writes, so both are stepped over before the keyword is looked for.
|
|
186
|
+
WRITE_KEYWORDS = %r{
|
|
187
|
+
\A(?:\s|\(|/\*.*?\*/|--[^\n]*|\#[^\n]*)* # comments and whitespace in front of it
|
|
188
|
+
(?:WITH\s.*?\s)? # a common table expression in front of it
|
|
189
|
+
(?:INSERT|UPDATE|REPLACE|UPSERT|MERGE)\b
|
|
190
|
+
}imx
|
|
191
|
+
|
|
192
|
+
# What a character is replaced with in the copy of the SQL the plugin reads when the SQL's
|
|
193
|
+
# encoding has no UTF-8 form for it (see {Utils::SqlEncoding.inspectable}).
|
|
194
|
+
UNREADABLE_CHARACTER = "\uFFFD"
|
|
195
|
+
|
|
196
|
+
SUBSCRIBED_METHODS = (
|
|
197
|
+
Set[RubyMethod::CONNECTION_CLOSE.name, COLUMN_BY_NAME_METHOD, COLUMN_BY_INDEX_METHOD, VALUE_BY_INDEX_METHOD] +
|
|
198
|
+
PARAMETER_METHODS.keys + WRITE_CHECK_METHODS + ROW_METHODS
|
|
199
|
+
).freeze
|
|
200
|
+
|
|
201
|
+
attr_reader :subscribed_methods, :encryption_utility
|
|
202
|
+
|
|
203
|
+
# @param service_container [Services::ServiceContainer]
|
|
204
|
+
# @param props [Concurrent::Map, Hash] the wrapper properties
|
|
205
|
+
# @param encryption_utility [Encryption::KmsEncryptionUtility, nil] a utility to use instead
|
|
206
|
+
# of building one
|
|
207
|
+
def initialize(service_container, props = ::Concurrent::Map.new, encryption_utility: nil)
|
|
208
|
+
@service_container = service_container
|
|
209
|
+
@encryption_utility = encryption_utility || Encryption::KmsEncryptionUtility.new(service_container, props)
|
|
210
|
+
# Built up front so that a parser dependency the application has not installed (pg_query, on
|
|
211
|
+
# PostgreSQL) fails the connection as it is set up rather than its first statement.
|
|
212
|
+
@sql_parser = Utils::Parser::SqlParser.new(service_container.dialect_service.driver_dialect)
|
|
213
|
+
@subscribed_methods = SUBSCRIBED_METHODS
|
|
214
|
+
# The names already warned about by {#unreadable_name?}, which is asked on every lookup and so
|
|
215
|
+
# would otherwise repeat the same warning for every row and statement that touches the name.
|
|
216
|
+
@unreadable_names = ::Concurrent::Set.new
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# The call's block is taken from the call context rather than from a block parameter, since
|
|
220
|
+
# that is where the pipeline reads the block it passes on.
|
|
221
|
+
def execute(method_name, pipeline_callable, *args, **_kwargs)
|
|
222
|
+
return close_connection(pipeline_callable) if method_name == RubyMethod::CONNECTION_CLOSE.name
|
|
223
|
+
|
|
224
|
+
context = @service_container.plugin_manager.current_call_context
|
|
225
|
+
sql = context&.sql
|
|
226
|
+
|
|
227
|
+
if PARAMETER_METHODS.key?(method_name)
|
|
228
|
+
encrypt_parameters(method_name, args, context, sql)
|
|
229
|
+
pipeline_callable.call
|
|
230
|
+
elsif WRITE_CHECK_METHODS.include?(method_name)
|
|
231
|
+
verify_statement(sql)
|
|
232
|
+
pipeline_callable.call
|
|
233
|
+
elsif ROW_METHODS.include?(method_name)
|
|
234
|
+
read_rows(method_name, pipeline_callable, context, sql)
|
|
235
|
+
elsif method_name == COLUMN_BY_NAME_METHOD
|
|
236
|
+
read_named_column(pipeline_callable, args.first, sql)
|
|
237
|
+
elsif method_name == COLUMN_BY_INDEX_METHOD
|
|
238
|
+
read_indexed_column(pipeline_callable, args.first, context, sql)
|
|
239
|
+
elsif method_name == VALUE_BY_INDEX_METHOD
|
|
240
|
+
read_indexed_value(pipeline_callable, args, context, sql)
|
|
241
|
+
else
|
|
242
|
+
pipeline_callable.call
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
private
|
|
247
|
+
|
|
248
|
+
def close_connection(pipeline_callable)
|
|
249
|
+
@encryption_utility.cleanup
|
|
250
|
+
pipeline_callable.call
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# -- Writing --
|
|
254
|
+
|
|
255
|
+
# Replaces every bind parameter that belongs to an encrypted column with its encrypted form.
|
|
256
|
+
# The parameters are handed back through the call context, so that the application's own
|
|
257
|
+
# array is left as it was.
|
|
258
|
+
def encrypt_parameters(method_name, args, context, sql)
|
|
259
|
+
return if context.nil?
|
|
260
|
+
|
|
261
|
+
position = PARAMETER_METHODS[method_name]
|
|
262
|
+
parameters = position.nil? ? args : args[position]
|
|
263
|
+
parameters = [] unless parameters.is_a?(Array)
|
|
264
|
+
return note_unknown_statement(parameters) if sql.nil?
|
|
265
|
+
|
|
266
|
+
# Asked for even when there is nothing to bind, since that is what says whether the
|
|
267
|
+
# statement is storing a value the plugin cannot reach.
|
|
268
|
+
columns = parameter_columns(sql)
|
|
269
|
+
return if columns.empty? || parameters.empty?
|
|
270
|
+
|
|
271
|
+
encrypted = encrypt_each(parameters, columns)
|
|
272
|
+
return if encrypted.nil?
|
|
273
|
+
|
|
274
|
+
if position.nil?
|
|
275
|
+
context.args = encrypted
|
|
276
|
+
else
|
|
277
|
+
rewritten = args.dup
|
|
278
|
+
rewritten[position] = encrypted
|
|
279
|
+
context.args = rewritten
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# A call whose statement the wrapper never saw: a prepared statement run by name after something
|
|
284
|
+
# other than a +prepare+ or a +PREPARE+ brought it into being (one prepared inside a
|
|
285
|
+
# multi-statement string, on the driver's connection directly, or by a client library of its
|
|
286
|
+
# own devising). The plugin cannot tell which column each bound value fills, so it leaves them
|
|
287
|
+
# to the database rather than refusing the call; if any targets an encrypted column, the
|
|
288
|
+
# required server-side enforcement is what catches a plaintext bound this way. Logged at debug
|
|
289
|
+
# since the great majority of such statements touch no encrypted column at all.
|
|
290
|
+
def note_unknown_statement(parameters)
|
|
291
|
+
return if parameters.empty?
|
|
292
|
+
|
|
293
|
+
logger.debug { 'The kms_encryption plugin is binding values to a statement it never saw; leaving them to the database' }
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# A statement with no bind parameters has nothing to encrypt, so only its safety is at stake.
|
|
297
|
+
def verify_statement(sql)
|
|
298
|
+
return if sql.nil?
|
|
299
|
+
|
|
300
|
+
parameter_columns(sql) # nothing to bind; called for the checks it makes
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# @return [Array, nil] the parameters with the encrypted ones replaced, nil when none were
|
|
304
|
+
def encrypt_each(parameters, columns)
|
|
305
|
+
cipher = new_cipher
|
|
306
|
+
encrypted = nil
|
|
307
|
+
|
|
308
|
+
begin
|
|
309
|
+
parameters.each_with_index do |parameter, index|
|
|
310
|
+
config = columns[index + 1]
|
|
311
|
+
value = parameter_value(parameter)
|
|
312
|
+
next if config.nil? || value.nil?
|
|
313
|
+
|
|
314
|
+
encrypted ||= parameters.dup
|
|
315
|
+
encrypted[index] = bind_value(encrypt_value(value, config, cipher))
|
|
316
|
+
end
|
|
317
|
+
ensure
|
|
318
|
+
cipher.release
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
encrypted
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# pg also takes a parameter as a hash that carries its value along with its format and type, and
|
|
325
|
+
# ActiveRecord binds every binary column that way. Only the value is encrypted: the ciphertext
|
|
326
|
+
# is bound as binary whatever format the hash named, and a hash with no value stays null.
|
|
327
|
+
def parameter_value(parameter)
|
|
328
|
+
parameter.is_a?(Hash) && sql_runner.pg? ? parameter[:value] : parameter
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def encrypt_value(value, config, cipher)
|
|
332
|
+
cipher.encrypt(value, config)
|
|
333
|
+
rescue StandardError => e
|
|
334
|
+
audit_logger.log_encryption(
|
|
335
|
+
table_name: config.table_name, column_name: config.column_name, key_id: config.key_id,
|
|
336
|
+
success: false, error_message: e.message
|
|
337
|
+
)
|
|
338
|
+
raise
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# An encrypted value is binary, and neither driver would send a Ruby string as binary on its
|
|
342
|
+
# own: pg needs to be told the parameter format, and mysql2 needs the encoding.
|
|
343
|
+
def bind_value(encrypted)
|
|
344
|
+
sql_runner.binary_param(encrypted)
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
# -- Reading --
|
|
348
|
+
|
|
349
|
+
def read_rows(method_name, pipeline_callable, context, sql)
|
|
350
|
+
columns = column_configs(sql)
|
|
351
|
+
return pipeline_callable.call if columns.empty?
|
|
352
|
+
|
|
353
|
+
cipher = new_cipher
|
|
354
|
+
field_names = context&.field_names
|
|
355
|
+
columns = keyed_by_field_names(columns, field_names)
|
|
356
|
+
# A row read as an array of values is matched to its columns by position; a row read as a
|
|
357
|
+
# hash is matched by name and never needs this. It is worked out once for the whole result.
|
|
358
|
+
positions = encrypted_positions(columns, field_names)
|
|
359
|
+
caller_block = context&.block
|
|
360
|
+
|
|
361
|
+
begin
|
|
362
|
+
if ROW_BLOCK_METHODS.include?(method_name) && caller_block
|
|
363
|
+
# each and each_row yield the rows rather than returning them, so the block is what has
|
|
364
|
+
# to be decrypted through. Replacing it in the call context leaves the caller's own block
|
|
365
|
+
# untouched. A lambda is used rather than a proc so that a row yielded as an array of
|
|
366
|
+
# values is passed on whole, instead of being splatted across the block's parameters.
|
|
367
|
+
context.block = ->(row, *rest) { caller_block.call(decrypt_row(row, columns, positions, cipher), *rest) }
|
|
368
|
+
pipeline_callable.call
|
|
369
|
+
elsif ROW_BLOCK_METHODS.include?(method_name)
|
|
370
|
+
# Called without a block, each and each_row return an Enumerator over the rows. The rows
|
|
371
|
+
# are decrypted eagerly and handed back as an enumerator over the results, because the
|
|
372
|
+
# cipher is released as soon as this method returns and a lazy wrapper would decrypt with
|
|
373
|
+
# a spent cipher.
|
|
374
|
+
result = pipeline_callable.call
|
|
375
|
+
result.respond_to?(:map) ? result.map { |row| decrypt_row(row, columns, positions, cipher) }.each : result
|
|
376
|
+
elsif ROW_COLLECTION_METHODS.include?(method_name)
|
|
377
|
+
rows = pipeline_callable.call
|
|
378
|
+
rows.is_a?(Array) ? rows.map { |row| decrypt_row(row, columns, positions, cipher) } : rows
|
|
379
|
+
else
|
|
380
|
+
decrypt_row(pipeline_callable.call, columns, positions, cipher)
|
|
381
|
+
end
|
|
382
|
+
ensure
|
|
383
|
+
cipher.release
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# field_values hands back one named column's values, so the column is looked up by name.
|
|
388
|
+
def read_named_column(pipeline_callable, field_name, sql)
|
|
389
|
+
config = field_name.nil? ? nil : config_named(column_configs(sql), field_name)
|
|
390
|
+
decrypt_column(pipeline_callable, config)
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
# column_values hands back one column's values by position, so the position is matched to a
|
|
394
|
+
# column name through the result's field list before the values are decrypted.
|
|
395
|
+
def read_indexed_column(pipeline_callable, index, context, sql)
|
|
396
|
+
columns = column_configs(sql)
|
|
397
|
+
config = columns.empty? ? nil : column_at(index, context&.field_names, columns)
|
|
398
|
+
decrypt_column(pipeline_callable, config)
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
# getvalue hands back a single cell by row and column position (+args+ is +[row, column]+), so
|
|
402
|
+
# the column position is matched to a name through the field list before the value is decrypted.
|
|
403
|
+
def read_indexed_value(pipeline_callable, args, context, sql)
|
|
404
|
+
columns = column_configs(sql)
|
|
405
|
+
config = columns.empty? ? nil : column_at(args[1], context&.field_names, columns)
|
|
406
|
+
return pipeline_callable.call if config.nil?
|
|
407
|
+
|
|
408
|
+
cipher = new_cipher
|
|
409
|
+
|
|
410
|
+
begin
|
|
411
|
+
decrypt_value(pipeline_callable.call, config, cipher)
|
|
412
|
+
ensure
|
|
413
|
+
cipher.release
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
def decrypt_column(pipeline_callable, config)
|
|
418
|
+
return pipeline_callable.call if config.nil?
|
|
419
|
+
|
|
420
|
+
cipher = new_cipher
|
|
421
|
+
|
|
422
|
+
begin
|
|
423
|
+
values = pipeline_callable.call
|
|
424
|
+
values.is_a?(Array) ? values.map { |value| decrypt_value(value, config, cipher) } : values
|
|
425
|
+
ensure
|
|
426
|
+
cipher.release
|
|
427
|
+
end
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
# @param columns [Hash{String => ColumnEncryptionConfig}] the encrypted columns of the
|
|
431
|
+
# statement, by name, for a row read as a hash
|
|
432
|
+
# @param positions [Array<Array(Integer, ColumnEncryptionConfig)>] the encrypted columns by
|
|
433
|
+
# position, for a row read as an array of values
|
|
434
|
+
# @return [Object] the row, with a new hash or array substituted only when something was
|
|
435
|
+
# decrypted
|
|
436
|
+
def decrypt_row(row, columns, positions, cipher)
|
|
437
|
+
case row
|
|
438
|
+
when Hash then decrypt_named_row(row, columns, cipher)
|
|
439
|
+
when Array then decrypt_indexed_row(row, positions, cipher)
|
|
440
|
+
else pg_tuple?(row) ? decrypt_tuple(row, columns, cipher) : row
|
|
441
|
+
end
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
# A row read as a hash: the encrypted columns are found by name.
|
|
445
|
+
def decrypt_named_row(row, columns, cipher)
|
|
446
|
+
decrypted = nil
|
|
447
|
+
columns.each do |column_name, config|
|
|
448
|
+
next unless row.key?(column_name)
|
|
449
|
+
|
|
450
|
+
raw = row[column_name]
|
|
451
|
+
value = decrypt_value(raw, config, cipher)
|
|
452
|
+
next if value.equal?(raw)
|
|
453
|
+
|
|
454
|
+
decrypted ||= row.dup
|
|
455
|
+
decrypted[column_name] = value
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
decrypted || row
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
# A row read as an array of values: the encrypted columns are found by position.
|
|
462
|
+
def decrypt_indexed_row(row, positions, cipher)
|
|
463
|
+
decrypted = nil
|
|
464
|
+
positions.each do |index, config|
|
|
465
|
+
raw = row[index]
|
|
466
|
+
value = decrypt_value(raw, config, cipher)
|
|
467
|
+
next if value.equal?(raw)
|
|
468
|
+
|
|
469
|
+
decrypted ||= row.dup
|
|
470
|
+
decrypted[index] = value
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
decrypted || row
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
# A pg tuple is read-only and keyed by column name, so a decrypted copy is handed back as a
|
|
477
|
+
# plain hash, and only when a column was actually decrypted so an unaffected tuple keeps its
|
|
478
|
+
# type.
|
|
479
|
+
def decrypt_tuple(row, columns, cipher)
|
|
480
|
+
keys = row.keys
|
|
481
|
+
copy = nil
|
|
482
|
+
columns.each do |column_name, config|
|
|
483
|
+
next unless keys.include?(column_name)
|
|
484
|
+
|
|
485
|
+
raw = row[column_name]
|
|
486
|
+
value = decrypt_value(raw, config, cipher)
|
|
487
|
+
next if value.equal?(raw)
|
|
488
|
+
|
|
489
|
+
copy ||= keys.zip(row.values).to_h
|
|
490
|
+
copy[column_name] = value
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
copy || row
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
# Matches each encrypted column to its position in a result read as arrays of values, so a row
|
|
497
|
+
# can be decrypted by index. Empty when the call carried no field list (a row read as a hash
|
|
498
|
+
# never needs it) or none of the result's columns is encrypted.
|
|
499
|
+
#
|
|
500
|
+
# @param columns [Hash{String => ColumnEncryptionConfig}] encrypted columns by name
|
|
501
|
+
# @param field_names [Array<String>, nil] the result's columns in order
|
|
502
|
+
# @return [Array<Array(Integer, ColumnEncryptionConfig)>]
|
|
503
|
+
def encrypted_positions(columns, field_names)
|
|
504
|
+
return [] if field_names.nil? || columns.empty?
|
|
505
|
+
|
|
506
|
+
field_names.each_with_index.with_object([]) do |(name, index), positions|
|
|
507
|
+
config = columns[name.to_s]
|
|
508
|
+
positions << [index, config] if config
|
|
509
|
+
end
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
# @return [ColumnEncryptionConfig, nil] the encrypted column at a position in the result, nil
|
|
513
|
+
# when the position is out of range or the column there is not encrypted
|
|
514
|
+
def column_at(index, field_names, columns)
|
|
515
|
+
return nil unless index.is_a?(Integer) && field_names
|
|
516
|
+
|
|
517
|
+
name = field_names[index]
|
|
518
|
+
name.nil? ? nil : config_named(columns, name)
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
# A result names its columns in the connection's encoding, while the configuration names them in
|
|
522
|
+
# UTF-8, so on a connection that is not UTF-8 a column whose name is not ASCII is named
|
|
523
|
+
# differently by each. The columns are keyed by the names the result uses as well, once for the
|
|
524
|
+
# whole result, so that each row is looked up by the name it actually carries. The columns are
|
|
525
|
+
# only copied when a name differs, so a result on a UTF-8 connection gets them as they are.
|
|
526
|
+
#
|
|
527
|
+
# @param columns [Hash{String => ColumnEncryptionConfig}] encrypted columns by UTF-8 name
|
|
528
|
+
# @param field_names [Array<String>, nil] the result's columns in order
|
|
529
|
+
# @return [Hash{String => ColumnEncryptionConfig}]
|
|
530
|
+
def keyed_by_field_names(columns, field_names)
|
|
531
|
+
return columns if field_names.nil?
|
|
532
|
+
|
|
533
|
+
keyed = nil
|
|
534
|
+
field_names.each do |name|
|
|
535
|
+
name = name.to_s
|
|
536
|
+
next if columns.key?(name)
|
|
537
|
+
|
|
538
|
+
config = columns[Utils::SqlEncoding.inspectable(name)]
|
|
539
|
+
(keyed ||= columns.dup)[name] = config if config
|
|
540
|
+
end
|
|
541
|
+
keyed || columns
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
# @return [ColumnEncryptionConfig, nil] the encrypted column a result or the application names,
|
|
545
|
+
# whatever the encoding of the name
|
|
546
|
+
def config_named(columns, name)
|
|
547
|
+
name = name.to_s
|
|
548
|
+
columns[name] || columns[Utils::SqlEncoding.inspectable(name)]
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
def pg_tuple?(row)
|
|
552
|
+
defined?(PG::Tuple) && row.is_a?(PG::Tuple)
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
def decrypt_value(raw, config, cipher)
|
|
556
|
+
cipher.decrypt(raw, config)
|
|
557
|
+
rescue StandardError => e
|
|
558
|
+
# In lenient mode a value that cannot be confirmed to be this column's encrypted data - too
|
|
559
|
+
# short to be a payload, or a failed HMAC - is returned as it is stored, so a column holding
|
|
560
|
+
# values written before kms_encryption was enabled still reads back. A value that verifies
|
|
561
|
+
# but will not decrypt (a GCM/data-key failure) is never returned unverified: it signals a
|
|
562
|
+
# real key problem, so only an integrity-check failure is eligible. Returning +raw+ is how
|
|
563
|
+
# the row readers see "not decrypted, leave as-is".
|
|
564
|
+
lenient = return_unverified_data? && e.is_a?(Errors::EncryptionError) &&
|
|
565
|
+
e.code == Errors::EncryptionError::INTEGRITY_CHECK_FAILED
|
|
566
|
+
audit_logger.log_decryption(
|
|
567
|
+
table_name: config.table_name, column_name: config.column_name, key_id: config.key_id,
|
|
568
|
+
success: false, error_message: lenient ? "returned unverified: #{e.message}" : e.message
|
|
569
|
+
)
|
|
570
|
+
return raw if lenient
|
|
571
|
+
|
|
572
|
+
raise
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
# @return [Boolean] whether a value that cannot be verified on read is returned as it is stored
|
|
576
|
+
# rather than raised on. Off by default; not for production - see the property's documentation.
|
|
577
|
+
def return_unverified_data?
|
|
578
|
+
@encryption_utility.config.return_unverified_data
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
# -- Statement analysis --
|
|
582
|
+
|
|
583
|
+
# The encrypted columns a statement's bind parameters write to.
|
|
584
|
+
#
|
|
585
|
+
# @return [Hash{Integer => ColumnEncryptionConfig}] by 1-based parameter index
|
|
586
|
+
# @raise [Errors::MetadataError] when the statement writes a column the plugin has confirmed is
|
|
587
|
+
# encrypted with a value it cannot encrypt (see {#check_write})
|
|
588
|
+
def parameter_columns(sql)
|
|
589
|
+
annotations = Utils::Parser::EncryptionAnnotationParser.parse_annotations(sql)
|
|
590
|
+
stripped = stripped_sql(sql)
|
|
591
|
+
analysis = analysis_of(stripped)
|
|
592
|
+
write = write_statement?(analysis, stripped)
|
|
593
|
+
inferred = analysis.parameter_column_names
|
|
594
|
+
return {} if !write && annotations.empty? && inferred.empty?
|
|
595
|
+
return {} unless ready_for_statement?(sql)
|
|
596
|
+
|
|
597
|
+
tables = statement_tables(analysis, annotations)
|
|
598
|
+
check_write(analysis, tables, annotations) if write
|
|
599
|
+
|
|
600
|
+
inferred.merge(annotations).each_with_object({}) do |(index, reference), columns|
|
|
601
|
+
config = resolve_column(reference, tables)
|
|
602
|
+
columns[index] = config if config
|
|
603
|
+
end
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
# The statement as far as it could be read. A parse that fails is not the same as a statement
|
|
607
|
+
# that writes nothing, so it is reported as nothing having been read rather than as nothing
|
|
608
|
+
# being there to read.
|
|
609
|
+
#
|
|
610
|
+
# @return [Utils::Parser::SqlParser::SqlAnalysisResult]
|
|
611
|
+
def analysis_of(sql)
|
|
612
|
+
sql_parser.analyze_sql(sql)
|
|
613
|
+
rescue StandardError => e
|
|
614
|
+
logger.warn("The statement could not be analysed: #{e.message}")
|
|
615
|
+
Utils::Parser::SqlParser::SqlAnalysisResult.new(
|
|
616
|
+
query_type: Utils::Parser::QueryType::UNKNOWN,
|
|
617
|
+
affected_tables: Set.new,
|
|
618
|
+
write_columns_complete: false
|
|
619
|
+
)
|
|
620
|
+
end
|
|
621
|
+
|
|
622
|
+
def write_statement?(analysis, sql)
|
|
623
|
+
WRITE_QUERY_TYPES.include?(analysis.query_type) || WRITE_KEYWORDS.match?(sql.to_s)
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
# The one write-side check the plugin fails closed on: a column it has confirmed is
|
|
627
|
+
# encrypted, but that this statement writes with something other than a bind parameter (a
|
|
628
|
+
# literal, an expression, a DEFAULT, a COPY stream), which cannot be encrypted. That is almost
|
|
629
|
+
# always a mistake, and storing a plaintext in an encrypted column reads back clean ever after,
|
|
630
|
+
# so it is refused with advice to bind or annotate the value. An annotation naming the column
|
|
631
|
+
# takes it off the plugin's hands; a COPY has no parameter for one to name.
|
|
632
|
+
#
|
|
633
|
+
# Everything else about a write the plugin cannot fully read is left to the database rather
|
|
634
|
+
# than refused. When the tables cannot be established at all, or when a table has encrypted
|
|
635
|
+
# columns but which of them this statement writes cannot be enumerated, the plugin cannot be
|
|
636
|
+
# sure a plaintext is at stake, and guessing wrong by refusing would reject legitimate
|
|
637
|
+
# statements that never touch an encrypted column. The required server-side enforcement is what
|
|
638
|
+
# actually guarantees no plaintext reaches an encrypted column; see the plugin docs. The
|
|
639
|
+
# "columns could not be enumerated" case is warned about (the table does hold encrypted
|
|
640
|
+
# columns, so it is worth a line), the "tables unknown" case is left to debug (it usually is
|
|
641
|
+
# not relevant at all).
|
|
642
|
+
#
|
|
643
|
+
# @param tables [Array<String>] the tables the statement writes to
|
|
644
|
+
# @raise [Errors::MetadataError] only for the confirmed-encrypted, cannot-encrypt case above
|
|
645
|
+
def check_write(analysis, tables, annotations)
|
|
646
|
+
copy = analysis.query_type == Utils::Parser::QueryType::COPY
|
|
647
|
+
|
|
648
|
+
if tables.empty?
|
|
649
|
+
logger.debug { 'The kms_encryption plugin could not establish the tables a write targets; leaving it to the database' }
|
|
650
|
+
return
|
|
651
|
+
end
|
|
652
|
+
|
|
653
|
+
# A COPY has no bind parameter for an annotation to name, so an annotation cannot make one of
|
|
654
|
+
# its columns encryptable and does not excuse it.
|
|
655
|
+
named = copy ? Set.new : annotated_columns(annotations, tables)
|
|
656
|
+
# An unbound column with no table of its own is matched against the statement's tables in
|
|
657
|
+
# order (see resolve_column). In the rare multi-table write where the same bare column name is
|
|
658
|
+
# encrypted in one table but written unbound in another, this can refuse a write that never
|
|
659
|
+
# touches the encrypted column. That is fail-closed and the annotation is the way out of it, so
|
|
660
|
+
# it is left as is rather than complicated further.
|
|
661
|
+
unencryptable = analysis.unbound_write_columns.find do |column|
|
|
662
|
+
config = encrypted_column(column, tables)
|
|
663
|
+
config && !named.include?(config.column_identifier)
|
|
664
|
+
end
|
|
665
|
+
raise unencryptable_write_error(unencryptable, copy: copy) if unencryptable
|
|
666
|
+
return if analysis.write_columns_complete || (annotations.any? && !copy)
|
|
667
|
+
|
|
668
|
+
hidden = tables.find { |table| encrypted_columns_of(table).any? }
|
|
669
|
+
logger.warn(unreadable_columns_warning(hidden)) if hidden
|
|
670
|
+
end
|
|
671
|
+
|
|
672
|
+
# The encrypted columns the statement's own annotations name.
|
|
673
|
+
#
|
|
674
|
+
# @return [Set<String>] column identifiers, empty when nothing was annotated
|
|
675
|
+
def annotated_columns(annotations, tables)
|
|
676
|
+
annotations.each_value.with_object(Set.new) do |reference, named|
|
|
677
|
+
config = resolve_column(reference, tables)
|
|
678
|
+
named << config.column_identifier if config
|
|
679
|
+
end
|
|
680
|
+
end
|
|
681
|
+
|
|
682
|
+
# @param column [Utils::Parser::ColumnInfo]
|
|
683
|
+
# @return [ColumnEncryptionConfig, nil] nil when the column is not encrypted
|
|
684
|
+
def encrypted_column(column, tables)
|
|
685
|
+
reference = column.table_name.nil? ? column.column_name : "#{column.table_name}.#{column.column_name}"
|
|
686
|
+
resolve_column(reference, tables)
|
|
687
|
+
end
|
|
688
|
+
|
|
689
|
+
# The encrypted columns a statement reads. Always lenient: a column whose configuration
|
|
690
|
+
# cannot be read is simply not decrypted.
|
|
691
|
+
#
|
|
692
|
+
# @return [Hash{String => ColumnEncryptionConfig}] by column name
|
|
693
|
+
def column_configs(sql)
|
|
694
|
+
return {} unless ready_for_statement?(sql)
|
|
695
|
+
|
|
696
|
+
annotations = Utils::Parser::EncryptionAnnotationParser.parse_annotations(sql)
|
|
697
|
+
tables = statement_tables(analysis_of(stripped_sql(sql)), annotations)
|
|
698
|
+
return {} if tables.empty?
|
|
699
|
+
|
|
700
|
+
# When two of the statement's tables encrypt a column of the same name, the first of them
|
|
701
|
+
# wins: without the column's table qualifier in the row there is nothing better to go on.
|
|
702
|
+
tables.each_with_object({}) do |table, columns|
|
|
703
|
+
encrypted_columns_of(table).each do |config|
|
|
704
|
+
next unless usable?(config)
|
|
705
|
+
|
|
706
|
+
columns[config.column_name] ||= config
|
|
707
|
+
end
|
|
708
|
+
end
|
|
709
|
+
end
|
|
710
|
+
|
|
711
|
+
# The tables a statement could be encrypting a column of. An annotation's table qualifier is
|
|
712
|
+
# included as well, since it may name a table the parser did not report.
|
|
713
|
+
#
|
|
714
|
+
# @param analysis [Utils::Parser::SqlParser::SqlAnalysisResult]
|
|
715
|
+
# @return [Array<String>]
|
|
716
|
+
def statement_tables(analysis, annotations)
|
|
717
|
+
tables = analysis.affected_tables.to_a
|
|
718
|
+
annotations.each_value do |reference|
|
|
719
|
+
table = reference.to_s.rpartition('.').first
|
|
720
|
+
next if table.empty?
|
|
721
|
+
|
|
722
|
+
table = table.split('.').last
|
|
723
|
+
tables << table unless tables.include?(table)
|
|
724
|
+
end
|
|
725
|
+
|
|
726
|
+
tables
|
|
727
|
+
end
|
|
728
|
+
|
|
729
|
+
# @param reference [String] either +"column"+ or +"table.column"+
|
|
730
|
+
# @param tables [Array<String>] the tables the statement touches, tried in order
|
|
731
|
+
# @return [ColumnEncryptionConfig, nil] nil when the column is not encrypted
|
|
732
|
+
def resolve_column(reference, tables)
|
|
733
|
+
table, _, column = reference.to_s.rpartition('.')
|
|
734
|
+
return nil if column.empty?
|
|
735
|
+
|
|
736
|
+
# What qualifies a column is tried as a table first, and the statement's own tables after
|
|
737
|
+
# it, because the qualifier may be an alias: +/*@encrypt:u.ssn*/+ on a statement that says
|
|
738
|
+
# +UPDATE users u+ names a real column of a real table, and looking only for a table called
|
|
739
|
+
# +u+ would find nothing and leave the value unencrypted.
|
|
740
|
+
candidates = table.empty? ? tables : [table.split('.').last, *tables].uniq
|
|
741
|
+
candidates.each do |candidate|
|
|
742
|
+
config = column_config(candidate, column)
|
|
743
|
+
return config if config
|
|
744
|
+
end
|
|
745
|
+
|
|
746
|
+
nil
|
|
747
|
+
end
|
|
748
|
+
|
|
749
|
+
def column_config(table, column)
|
|
750
|
+
return nil if unreadable_name?("#{table}.#{column}")
|
|
751
|
+
|
|
752
|
+
config = metadata_lookup("#{table}.#{column}") { |manager| manager.column_config(table, column) }
|
|
753
|
+
usable?(config) ? config : nil
|
|
754
|
+
end
|
|
755
|
+
|
|
756
|
+
def encrypted_columns_of(table)
|
|
757
|
+
return [] if unreadable_name?(table)
|
|
758
|
+
|
|
759
|
+
metadata_lookup(table) { |manager| manager.table_configs(table) } || []
|
|
760
|
+
end
|
|
761
|
+
|
|
762
|
+
# A name with a character the SQL's encoding had no UTF-8 form for cannot be matched against the
|
|
763
|
+
# configuration reliably: looking it up would miss, and the column would be treated as though it
|
|
764
|
+
# were not encrypted without anyone knowing. So it is not looked up. The column is left as the
|
|
765
|
+
# database holds it, which is where the required server-side enforcement stops a plaintext being
|
|
766
|
+
# stored, and a warning says why, once for each name, since it only happens when the connection's
|
|
767
|
+
# encoding is one Ruby cannot fully read.
|
|
768
|
+
#
|
|
769
|
+
# @param name [String] a table, or a +"table.column"+ reference
|
|
770
|
+
def unreadable_name?(name)
|
|
771
|
+
return false unless name.include?(UNREADABLE_CHARACTER)
|
|
772
|
+
return true unless @unreadable_names.add?(name)
|
|
773
|
+
|
|
774
|
+
logger.warn(
|
|
775
|
+
"The kms_encryption plugin cannot read the name #{name} in the connection's encoding, so it cannot " \
|
|
776
|
+
'tell whether it is encrypted; leaving it to the database. Use a UTF-8 connection, or ASCII names ' \
|
|
777
|
+
'for encrypted tables and columns.'
|
|
778
|
+
)
|
|
779
|
+
true
|
|
780
|
+
end
|
|
781
|
+
|
|
782
|
+
# A lookup that fails is never allowed to take the application's statement down with it: the
|
|
783
|
+
# column is left as the database holds it, which is what the application would have got without
|
|
784
|
+
# the plugin, and the required server-side enforcement is what stops a plaintext being stored.
|
|
785
|
+
#
|
|
786
|
+
# @param described [String] what was being looked up, named in the log message
|
|
787
|
+
def metadata_lookup(described)
|
|
788
|
+
manager = metadata_manager
|
|
789
|
+
return nil if manager.nil?
|
|
790
|
+
|
|
791
|
+
yield(manager)
|
|
792
|
+
rescue Errors::MetadataError => e
|
|
793
|
+
logger.warn("Could not read the kms_encryption configuration of #{described}: #{e.message}")
|
|
794
|
+
nil
|
|
795
|
+
end
|
|
796
|
+
|
|
797
|
+
def usable?(config)
|
|
798
|
+
return false if config.nil?
|
|
799
|
+
return true if config.usable?
|
|
800
|
+
|
|
801
|
+
logger.warn("Skipping #{config.column_identifier}: its kms_encryption configuration is incomplete")
|
|
802
|
+
false
|
|
803
|
+
end
|
|
804
|
+
|
|
805
|
+
# A column the plugin confirmed is encrypted but that this statement writes with something
|
|
806
|
+
# other than a bind parameter. The value cannot be encrypted, so the write is refused rather
|
|
807
|
+
# than storing a plaintext in a column configured to be encrypted - the one write-side case the
|
|
808
|
+
# plugin fails closed on, since it has positively identified the problem.
|
|
809
|
+
#
|
|
810
|
+
# @param column [Utils::Parser::ColumnInfo] the confirmed-encrypted column written unencryptably
|
|
811
|
+
# @param copy [Boolean] whether the statement is a COPY, which no annotation can rescue
|
|
812
|
+
def unencryptable_write_error(column, copy:)
|
|
813
|
+
advice = if copy
|
|
814
|
+
'a COPY sends its rows to the server as a stream rather than as bind parameters, which ' \
|
|
815
|
+
'cannot be encrypted. Write the rows with INSERT and bind the values instead.'
|
|
816
|
+
else
|
|
817
|
+
'this statement writes it with something other than a bind parameter, which cannot be ' \
|
|
818
|
+
'encrypted. Bind the value, or name the parameter it belongs to with an ' \
|
|
819
|
+
'/*@encrypt:table.column*/ annotation.'
|
|
820
|
+
end
|
|
821
|
+
Errors::MetadataError
|
|
822
|
+
.validation_failed("#{column.column_name} is configured for kms_encryption, but #{advice}")
|
|
823
|
+
.with_table(column.table_name)
|
|
824
|
+
.with_column(column.column_name)
|
|
825
|
+
end
|
|
826
|
+
|
|
827
|
+
# @param table [String] a table of the statement that has encrypted columns
|
|
828
|
+
# @return [String] the warning logged when a write's columns cannot be enumerated
|
|
829
|
+
def unreadable_columns_warning(table)
|
|
830
|
+
"#{table} has columns configured for kms_encryption and which of them this statement writes could " \
|
|
831
|
+
'not be established, so the plugin cannot encrypt them; relying on the database to reject a ' \
|
|
832
|
+
'plaintext. Name the columns the statement writes, or annotate the parameters, to have the plugin ' \
|
|
833
|
+
'encrypt them.'
|
|
834
|
+
end
|
|
835
|
+
|
|
836
|
+
def stripped_sql(sql)
|
|
837
|
+
Utils::Parser::EncryptionAnnotationParser.strip_annotations(sql)
|
|
838
|
+
end
|
|
839
|
+
|
|
840
|
+
# -- Lazily built collaborators --
|
|
841
|
+
|
|
842
|
+
# Builds the parts of the plugin that need a database connection, the first time a statement
|
|
843
|
+
# could touch an encrypted column.
|
|
844
|
+
#
|
|
845
|
+
# @return [Boolean] whether the plugin is ready to encrypt and decrypt
|
|
846
|
+
def ready_for_statement?(sql)
|
|
847
|
+
return false if sql.nil? || sql.to_s.strip.empty?
|
|
848
|
+
|
|
849
|
+
error = readiness_error
|
|
850
|
+
return true if error.nil?
|
|
851
|
+
|
|
852
|
+
# The application's statement is never the place to report that the kms_encryption tables
|
|
853
|
+
# cannot be read: every column stays as the database holds it until they can, and the
|
|
854
|
+
# required server-side enforcement is what stops a plaintext being stored meanwhile.
|
|
855
|
+
logger.warn("The kms_encryption plugin is not ready, leaving columns as they are: #{error.message}")
|
|
856
|
+
false
|
|
857
|
+
end
|
|
858
|
+
|
|
859
|
+
# @return [Errors::MetadataError, nil] nil once the plugin is ready to encrypt and decrypt
|
|
860
|
+
def readiness_error
|
|
861
|
+
@encryption_utility.ensure_initialized
|
|
862
|
+
return nil unless @encryption_utility.metadata_manager.nil?
|
|
863
|
+
|
|
864
|
+
Errors::MetadataError.load_failed('The kms_encryption metadata manager could not be built')
|
|
865
|
+
rescue Errors::MetadataError => e
|
|
866
|
+
e
|
|
867
|
+
rescue StandardError => e
|
|
868
|
+
Errors::MetadataError.load_failed("The kms_encryption plugin is not ready: #{e.message}")
|
|
869
|
+
end
|
|
870
|
+
|
|
871
|
+
def new_cipher
|
|
872
|
+
Encryption::ColumnCipher.new(key_manager: @encryption_utility.key_manager, sql_runner: sql_runner)
|
|
873
|
+
end
|
|
874
|
+
|
|
875
|
+
def metadata_manager
|
|
876
|
+
@encryption_utility.metadata_manager
|
|
877
|
+
end
|
|
878
|
+
|
|
879
|
+
def audit_logger
|
|
880
|
+
@encryption_utility.audit_logger
|
|
881
|
+
end
|
|
882
|
+
|
|
883
|
+
def sql_runner
|
|
884
|
+
@encryption_utility.sql_runner
|
|
885
|
+
end
|
|
886
|
+
|
|
887
|
+
attr_reader :sql_parser
|
|
888
|
+
end
|
|
889
|
+
end
|
|
890
|
+
end
|