aws_advanced_ruby_driver_wrapper 1.0.0 → 1.1.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.
Files changed (87) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +48 -2
  3. data/README.md +115 -6
  4. data/THIRD-PARTY-LICENSES +70 -11
  5. data/aws_advanced_ruby_driver_wrapper.gemspec +9 -0
  6. data/lib/aws_advanced_ruby_driver_wrapper/active_record/aws_action_cable_postgresql_support.rb +40 -0
  7. data/lib/aws_advanced_ruby_driver_wrapper/active_record/aws_connection_handler.rb +47 -0
  8. data/lib/aws_advanced_ruby_driver_wrapper/active_record/aws_mysql2_adapter.rb +23 -9
  9. data/lib/aws_advanced_ruby_driver_wrapper/active_record/aws_postgresql_adapter.rb +65 -10
  10. data/lib/aws_advanced_ruby_driver_wrapper/active_record/aws_trilogy_adapter.rb +80 -0
  11. data/lib/aws_advanced_ruby_driver_wrapper/active_record/type_adapter_alias.rb +61 -0
  12. data/lib/aws_advanced_ruby_driver_wrapper/driver_dialects/driver_dialect.rb +48 -0
  13. data/lib/aws_advanced_ruby_driver_wrapper/driver_dialects/driver_dialect_manager.rb +11 -4
  14. data/lib/aws_advanced_ruby_driver_wrapper/driver_dialects/mysql2_driver_dialect.rb +129 -0
  15. data/lib/aws_advanced_ruby_driver_wrapper/driver_dialects/mysql_driver_dialect.rb +9 -81
  16. data/lib/aws_advanced_ruby_driver_wrapper/driver_dialects/pg_driver_dialect.rb +28 -0
  17. data/lib/aws_advanced_ruby_driver_wrapper/driver_dialects/trilogy_driver_dialect.rb +118 -0
  18. data/lib/aws_advanced_ruby_driver_wrapper/errors/error_handler.rb +16 -0
  19. data/lib/aws_advanced_ruby_driver_wrapper/errors/mysql_error_handler.rb +10 -5
  20. data/lib/aws_advanced_ruby_driver_wrapper/errors/trilogy_error_handler.rb +127 -0
  21. data/lib/aws_advanced_ruby_driver_wrapper/errors.rb +1 -0
  22. data/lib/aws_advanced_ruby_driver_wrapper/monitoring/cluster_topology_monitor.rb +11 -0
  23. data/lib/aws_advanced_ruby_driver_wrapper/monitoring/monitor.rb +14 -0
  24. data/lib/aws_advanced_ruby_driver_wrapper/monitoring/monitor_connection.rb +6 -0
  25. data/lib/aws_advanced_ruby_driver_wrapper/mysql.rb +27 -9
  26. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/blue_green_plugin.rb +125 -7
  27. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/drop_blue_connections_marker.rb +84 -0
  28. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/host_mapper.rb +3 -1
  29. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/routing/base_routing.rb +7 -0
  30. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/routing/substitute_connect_routing.rb +68 -41
  31. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/routing/suspend_connect_routing.rb +4 -2
  32. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/routing/suspend_execute_routing.rb +5 -3
  33. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/routing/suspend_until_corresponding_host_found_connect_routing.rb +7 -1
  34. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/status_builder.rb +1 -1
  35. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/status_monitor.rb +10 -13
  36. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/status_provider.rb +95 -3
  37. data/lib/aws_advanced_ruby_driver_wrapper/plugins/custom_endpoint/custom_endpoint_monitor.rb +11 -1
  38. data/lib/aws_advanced_ruby_driver_wrapper/plugins/custom_endpoint/custom_endpoint_plugin.rb +4 -1
  39. data/lib/aws_advanced_ruby_driver_wrapper/plugins/default_plugin.rb +14 -3
  40. data/lib/aws_advanced_ruby_driver_wrapper/plugins/failover_plugin.rb +68 -3
  41. data/lib/aws_advanced_ruby_driver_wrapper/plugins/federated_auth/base_saml_auth_plugin.rb +266 -0
  42. data/lib/aws_advanced_ruby_driver_wrapper/plugins/federated_auth/federated_auth_plugin.rb +134 -0
  43. data/lib/aws_advanced_ruby_driver_wrapper/plugins/federated_auth/okta_auth_plugin.rb +113 -0
  44. data/lib/aws_advanced_ruby_driver_wrapper/plugins/gdb/gdb_failover_plugin.rb +30 -9
  45. data/lib/aws_advanced_ruby_driver_wrapper/plugins/iam_auth_plugin.rb +16 -2
  46. data/lib/aws_advanced_ruby_driver_wrapper/plugins/initial_connection_strategy_plugin.rb +4 -1
  47. data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/key_management_utility.rb +6 -3
  48. data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/key_manager.rb +13 -4
  49. data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/kms_encryption_plugin.rb +29 -6
  50. data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/sql/trigger_mysql.sql +108 -0
  51. data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/sql/trigger_pg.sql +54 -0
  52. data/lib/aws_advanced_ruby_driver_wrapper/plugins/secrets_manager_plugin.rb +23 -1
  53. data/lib/aws_advanced_ruby_driver_wrapper/postgresql.rb +49 -23
  54. data/lib/aws_advanced_ruby_driver_wrapper/property_definition.rb +63 -1
  55. data/lib/aws_advanced_ruby_driver_wrapper/ruby_method.rb +3 -0
  56. data/lib/aws_advanced_ruby_driver_wrapper/services/dialect_service.rb +20 -7
  57. data/lib/aws_advanced_ruby_driver_wrapper/services/host_service.rb +34 -8
  58. data/lib/aws_advanced_ruby_driver_wrapper/services/monitor_service.rb +23 -4
  59. data/lib/aws_advanced_ruby_driver_wrapper/services/plugin_manager.rb +80 -29
  60. data/lib/aws_advanced_ruby_driver_wrapper/services/service_container.rb +10 -2
  61. data/lib/aws_advanced_ruby_driver_wrapper/services/service_utility.rb +4 -1
  62. data/lib/aws_advanced_ruby_driver_wrapper/trilogy.rb +167 -0
  63. data/lib/aws_advanced_ruby_driver_wrapper/utils/connection_config.rb +1 -1
  64. data/lib/aws_advanced_ruby_driver_wrapper/utils/connection_config_parser.rb +25 -15
  65. data/lib/aws_advanced_ruby_driver_wrapper/utils/events/batching_event_publisher.rb +18 -7
  66. data/lib/aws_advanced_ruby_driver_wrapper/utils/global_aurora_topology_utils.rb +2 -2
  67. data/lib/aws_advanced_ruby_driver_wrapper/utils/iam_auth_utils.rb +13 -4
  68. data/lib/aws_advanced_ruby_driver_wrapper/utils/parser/encryption_annotation_parser.rb +15 -31
  69. data/lib/aws_advanced_ruby_driver_wrapper/utils/parser/mysql_statement_analyzer.rb +55 -76
  70. data/lib/aws_advanced_ruby_driver_wrapper/utils/parser/mysql_text.rb +215 -0
  71. data/lib/aws_advanced_ruby_driver_wrapper/utils/parser/pg_statement_analyzer.rb +11 -20
  72. data/lib/aws_advanced_ruby_driver_wrapper/utils/parser/query_analysis.rb +2 -3
  73. data/lib/aws_advanced_ruby_driver_wrapper/utils/parser/sql_parser.rb +21 -14
  74. data/lib/aws_advanced_ruby_driver_wrapper/utils/retry_util.rb +2 -1
  75. data/lib/aws_advanced_ruby_driver_wrapper/utils/saml_utils.rb +133 -0
  76. data/lib/aws_advanced_ruby_driver_wrapper/utils/storage/storage_service.rb +29 -8
  77. data/lib/aws_advanced_ruby_driver_wrapper/utils/telemetry/null_telemetry.rb +89 -0
  78. data/lib/aws_advanced_ruby_driver_wrapper/utils/telemetry/open_telemetry_context.rb +124 -0
  79. data/lib/aws_advanced_ruby_driver_wrapper/utils/{ar_constants.rb → telemetry/open_telemetry_counter.rb} +19 -5
  80. data/lib/aws_advanced_ruby_driver_wrapper/utils/telemetry/open_telemetry_factory.rb +122 -0
  81. data/lib/aws_advanced_ruby_driver_wrapper/utils/telemetry/open_telemetry_gauge.rb +36 -0
  82. data/lib/aws_advanced_ruby_driver_wrapper/utils/telemetry/telemetry_const.rb +50 -0
  83. data/lib/aws_advanced_ruby_driver_wrapper/utils/telemetry/telemetry_factory.rb +143 -0
  84. data/lib/aws_advanced_ruby_driver_wrapper/utils/topology_utils.rb +9 -2
  85. data/lib/aws_advanced_ruby_driver_wrapper/version.rb +1 -1
  86. data/lib/aws_advanced_ruby_driver_wrapper.rb +47 -2
  87. metadata +57 -3
@@ -38,6 +38,8 @@ module AwsAdvancedRubyDriverWrapper
38
38
  # when that endpoint carries a region. When +accessible_regions+ is set, hosts outside those
39
39
  # regions are never selected.
40
40
  class GdbFailoverPlugin < FailoverPlugin
41
+ TELEMETRY_FAILOVER = 'global database failover'
42
+
41
43
  def initialize(service_container, props = ::Concurrent::Map.new)
42
44
  super
43
45
 
@@ -176,6 +178,10 @@ module AwsAdvancedRubyDriverWrapper
176
178
  return
177
179
  end
178
180
 
181
+ with_failover_telemetry(TELEMETRY_FAILOVER) { failover_with_plan }
182
+ end
183
+
184
+ def failover_with_plan
179
185
  failover_start = Time.now
180
186
  failover_deadline = failover_start + @failover_timeout
181
187
  @regionless_hosts.clear
@@ -189,6 +195,25 @@ module AwsAdvancedRubyDriverWrapper
189
195
  "failover_timeout_sec=#{@failover_timeout}"
190
196
  end
191
197
 
198
+ writer_candidate, writer_region, mode = failover_plan
199
+ log_failover_plan(writer_candidate, writer_region, mode)
200
+ if mode == GdbFailoverMode::STRICT_WRITER
201
+ count_failover(@writer_failover_counters) { failover_to_writer(writer_candidate, writer_region, failover_deadline) }
202
+ else
203
+ count_failover(@reader_failover_counters) { failover_to_allowed_host(mode, failover_deadline) }
204
+ end
205
+ ensure
206
+ duration_ms = ((Time.now - failover_start) * 1000).round if failover_start
207
+ logger.debug { "Failover duration: #{duration_ms}ms" } if duration_ms
208
+ end
209
+
210
+ # Finds the writer the topology settled on, and the failover mode that applies to its region.
211
+ #
212
+ # When this fails, failover cannot start, and so it is not known whether it would have been a
213
+ # writer or a reader failover. It is counted as a failed writer failover.
214
+ #
215
+ # @return [Array(Host::HostInfo, String, Symbol)] the writer, its region, and the mode
216
+ def failover_plan
192
217
  # This is expected to return once the topology has stabilized, i.e. once the cluster
193
218
  # control plane has already chosen a new writer.
194
219
  unless host_service.force_refresh_host_list?(verify_writer: true, timeout_sec: @failover_timeout)
@@ -210,15 +235,11 @@ module AwsAdvancedRubyDriverWrapper
210
235
  mode = current_failover_mode(writer_region)
211
236
  end
212
237
 
213
- log_failover_plan(writer_candidate, writer_region, mode)
214
- if mode == GdbFailoverMode::STRICT_WRITER
215
- failover_to_writer(writer_candidate, writer_region, failover_deadline)
216
- else
217
- failover_to_allowed_host(mode, failover_deadline)
218
- end
219
- ensure
220
- duration_ms = ((Time.now - failover_start) * 1000).round if failover_start
221
- logger.debug { "Failover duration: #{duration_ms}ms" } if duration_ms
238
+ [writer_candidate, writer_region, mode]
239
+ rescue StandardError
240
+ @writer_failover_counters.triggered.inc
241
+ @writer_failover_counters.failed.inc
242
+ raise
222
243
  end
223
244
 
224
245
  # Explains which mode was chosen, and what the plugin will do with it.
@@ -41,6 +41,17 @@ module AwsAdvancedRubyDriverWrapper
41
41
  PropertyDefinition::IAM_EXPIRATION_SEC.validate!(expiration)
42
42
  service_container.storage_service.register(IAM_TOKEN_CACHE_NAME, ttl: expiration)
43
43
  @subscribed_methods = SUBSCRIBED_METHODS
44
+
45
+ @telemetry_factory = service_container.telemetry_factory
46
+ @fetch_token_counter = @telemetry_factory.create_counter('iam.fetch_token.count')
47
+ @telemetry_factory.create_gauge('iam.token_cache.size', &self.class.token_cache_size(service_container.storage_service))
48
+ end
49
+
50
+ # The callback of a token cache size gauge. It is built here rather than in an instance, because
51
+ # a gauge is kept for the whole process and a block built in an instance would keep that
52
+ # instance, and the connection it belongs to, alive with it.
53
+ def self.token_cache_size(storage_service)
54
+ -> { storage_service.size(IAM_TOKEN_CACHE_NAME) }
44
55
  end
45
56
 
46
57
  def connect(host_info, driver_props, _is_initial_connection, pipeline_callable)
@@ -128,8 +139,11 @@ module AwsAdvancedRubyDriverWrapper
128
139
  # and, when there is a cache key, caches it. Without a snapshot the provider is passed as is,
129
140
  # and signing fails for lack of credentials.
130
141
  def fetch_and_cache_token(region, host, port, user, credentials, cache_key, expiration)
131
- token = Aws::RDS::AuthTokenGenerator.new(credentials: credentials || @credentials_provider)
132
- .auth_token(region:, endpoint: "#{host}:#{port}", user_name: user)
142
+ @fetch_token_counter.inc
143
+ token = Utils::IamAuthUtils.generate_token(
144
+ region:, hostname: host, port:, user:, credentials_provider: credentials || @credentials_provider,
145
+ telemetry_factory: @telemetry_factory
146
+ )
133
147
  if cache_key
134
148
  @service_container.storage_service.set(
135
149
  IAM_TOKEN_CACHE_NAME, cache_key, Utils::IamAuthUtils.build_token_entry(token, expiration)
@@ -20,6 +20,7 @@ require_relative '../host/host_role'
20
20
  require_relative '../host/host_availability'
21
21
  require_relative '../logging'
22
22
  require_relative '../property_definition'
23
+ require_relative '../services/host_service'
23
24
  require_relative '../utils/accessible_regions'
24
25
  require_relative '../utils/rds_utils'
25
26
  require_relative '../utils/rds_url_type'
@@ -53,6 +54,7 @@ module AwsAdvancedRubyDriverWrapper
53
54
  @retry_interval_sec = PropertyDefinition::INITIAL_CONNECTION_RETRY_INTERVAL_SEC.get_float(props)
54
55
  @wait_for_topology_sec = PropertyDefinition::INITIAL_CONNECTION_WAIT_FOR_TOPOLOGY_SEC.get_float(props)
55
56
  @host_selector_strategy = PropertyDefinition::INITIAL_CONNECTION_HOST_SELECTOR_STRATEGY.get(props)
57
+ Services::HostService.validate_strategy!(@host_selector_strategy)
56
58
  @accessible_regions = Utils::AccessibleRegions.parse(props)
57
59
  @subscribed_methods = SUBSCRIBED_METHODS
58
60
 
@@ -323,7 +325,8 @@ module AwsAdvancedRubyDriverWrapper
323
325
  end
324
326
 
325
327
  host_service.select_host(available_hosts, target_role, @host_selector_strategy)
326
- rescue StandardError
328
+ rescue StandardError => e
329
+ logger.warn("Failed to select a host using the '#{@host_selector_strategy}' host selection strategy: #{e.message}")
327
330
  nil
328
331
  end
329
332
 
@@ -14,7 +14,7 @@
14
14
  # See the License for the specific language governing permissions and
15
15
  # limitations under the License.
16
16
 
17
- require_relative '../../driver_dialects/mysql_driver_dialect'
17
+ require_relative '../../driver_dialects/mysql2_driver_dialect'
18
18
  require_relative '../../driver_dialects/pg_driver_dialect'
19
19
  require_relative '../../logging'
20
20
  require_relative '../../property_definition'
@@ -324,7 +324,10 @@ module AwsAdvancedRubyDriverWrapper
324
324
  def dialect_for_connection(connection)
325
325
  name = connection.class.name.to_s
326
326
  return DriverDialects::PgDriverDialect.new if name.start_with?('PG::') || name.end_with?('WrapperPgConnection')
327
- return DriverDialects::MysqlDriverDialect.new if name.start_with?('Mysql2::') || name.end_with?('WrapperMysql2Client')
327
+ return DriverDialects::Mysql2DriverDialect.new if name.start_with?('Mysql2::') || name.end_with?('WrapperMysql2Client')
328
+ if name == 'Trilogy' || name.end_with?('WrapperTrilogyClient')
329
+ raise ArgumentError, 'KMS encryption does not support the trilogy driver'
330
+ end
328
331
 
329
332
  raise ArgumentError,
330
333
  "Cannot infer the driver dialect from #{name.empty? ? connection.class : name}; " \
@@ -334,7 +337,7 @@ module AwsAdvancedRubyDriverWrapper
334
337
  def dialect_for_driver(driver)
335
338
  case driver.to_sym
336
339
  when :postgresql, :pg then DriverDialects::PgDriverDialect.new
337
- when :mysql2, :mysql then DriverDialects::MysqlDriverDialect.new
340
+ when :mysql2, :mysql then DriverDialects::Mysql2DriverDialect.new
338
341
  else raise ArgumentError, "Unknown driver #{driver.inspect}; use :postgresql or :mysql2"
339
342
  end
340
343
  end
@@ -14,7 +14,6 @@
14
14
  # See the License for the specific language governing permissions and
15
15
  # limitations under the License.
16
16
 
17
- require 'base64'
18
17
  require 'digest'
19
18
  require 'securerandom'
20
19
  require_relative '../../logging'
@@ -105,7 +104,7 @@ module AwsAdvancedRubyDriverWrapper
105
104
 
106
105
  plaintext = with_retry('DECRYPT_DATA_KEY') do
107
106
  response = @kms_client.decrypt(
108
- ciphertext_blob: Base64.strict_decode64(encrypted_data_key.to_s.delete("\n")),
107
+ ciphertext_blob: strict_decode64(encrypted_data_key.to_s.delete("\n")),
109
108
  **(master_key_arn ? { key_id: master_key_arn } : {})
110
109
  )
111
110
  response.plaintext.dup.b
@@ -130,7 +129,7 @@ module AwsAdvancedRubyDriverWrapper
130
129
  response = @kms_client.generate_data_key(key_id: master_key_arn, key_spec: KMS_KEY_SPEC)
131
130
  GeneratedDataKey.new(
132
131
  plaintext: response.plaintext.dup.b,
133
- encrypted_data_key: Base64.strict_encode64(response.ciphertext_blob),
132
+ encrypted_data_key: strict_encode64(response.ciphertext_blob),
134
133
  hmac_key: SecureRandom.bytes(HMAC_KEY_LENGTH)
135
134
  )
136
135
  end
@@ -272,11 +271,21 @@ module AwsAdvancedRubyDriverWrapper
272
271
  # @param encrypted_data_key [String]
273
272
  # @return [String]
274
273
  def data_key_cache_key(encrypted_data_key)
275
- "#{DATA_KEY_CACHE_PREFIX}#{Base64.strict_encode64(Digest::SHA256.digest(encrypted_data_key.to_s))}"
274
+ "#{DATA_KEY_CACHE_PREFIX}#{strict_encode64(Digest::SHA256.digest(encrypted_data_key.to_s))}"
276
275
  end
277
276
 
278
277
  private
279
278
 
279
+ # Strict base64 (RFC 4648: no line breaks, padding required), using the encoding built into
280
+ # Array#pack rather than the base64 gem. Decoding raises ArgumentError on invalid input.
281
+ def strict_encode64(bytes)
282
+ [bytes].pack('m0')
283
+ end
284
+
285
+ def strict_decode64(encoded)
286
+ encoded.unpack1('m0')
287
+ end
288
+
280
289
  def authorize_master_key!(master_key_arn)
281
290
  return if @config.master_key_allowed?(master_key_arn)
282
291
 
@@ -15,6 +15,7 @@
15
15
  # limitations under the License.
16
16
 
17
17
  require 'concurrent'
18
+ require_relative '../../driver_dialects/trilogy_driver_dialect'
18
19
  require_relative '../../logging'
19
20
  require_relative '../../ruby_method'
20
21
  require_relative '../../utils/parser/encryption_annotation_parser'
@@ -205,6 +206,12 @@ module AwsAdvancedRubyDriverWrapper
205
206
  # @param encryption_utility [Encryption::KmsEncryptionUtility, nil] a utility to use instead
206
207
  # of building one
207
208
  def initialize(service_container, props = ::Concurrent::Map.new, encryption_utility: nil)
209
+ # Trilogy has no prepared statements, so a parameter could only reach the server written into the
210
+ # SQL, in plaintext, before it could be encrypted.
211
+ if service_container.dialect_service.driver_dialect.is_a?(DriverDialects::TrilogyDriverDialect)
212
+ raise Errors::EncryptionPluginError, 'The kms_encryption plugin does not support the trilogy driver'
213
+ end
214
+
208
215
  @service_container = service_container
209
216
  @encryption_utility = encryption_utility || Encryption::KmsEncryptionUtility.new(service_container, props)
210
217
  # Built up front so that a parser dependency the application has not installed (pg_query, on
@@ -586,9 +593,10 @@ module AwsAdvancedRubyDriverWrapper
586
593
  # @raise [Errors::MetadataError] when the statement writes a column the plugin has confirmed is
587
594
  # encrypted with a value it cannot encrypt (see {#check_write})
588
595
  def parameter_columns(sql)
589
- annotations = Utils::Parser::EncryptionAnnotationParser.parse_annotations(sql)
596
+ version = executable_comment_version
597
+ annotations = Utils::Parser::EncryptionAnnotationParser.parse_annotations(sql, server_version: version)
590
598
  stripped = stripped_sql(sql)
591
- analysis = analysis_of(stripped)
599
+ analysis = analysis_of(stripped, version)
592
600
  write = write_statement?(analysis, stripped)
593
601
  inferred = analysis.parameter_column_names
594
602
  return {} if !write && annotations.empty? && inferred.empty?
@@ -607,9 +615,10 @@ module AwsAdvancedRubyDriverWrapper
607
615
  # that writes nothing, so it is reported as nothing having been read rather than as nothing
608
616
  # being there to read.
609
617
  #
618
+ # @param version [Integer, nil] see {#executable_comment_version}
610
619
  # @return [Utils::Parser::SqlParser::SqlAnalysisResult]
611
- def analysis_of(sql)
612
- sql_parser.analyze_sql(sql)
620
+ def analysis_of(sql, version)
621
+ sql_parser.analyze_sql(sql, server_version: version)
613
622
  rescue StandardError => e
614
623
  logger.warn("The statement could not be analysed: #{e.message}")
615
624
  Utils::Parser::SqlParser::SqlAnalysisResult.new(
@@ -693,8 +702,9 @@ module AwsAdvancedRubyDriverWrapper
693
702
  def column_configs(sql)
694
703
  return {} unless ready_for_statement?(sql)
695
704
 
696
- annotations = Utils::Parser::EncryptionAnnotationParser.parse_annotations(sql)
697
- tables = statement_tables(analysis_of(stripped_sql(sql)), annotations)
705
+ version = executable_comment_version
706
+ annotations = Utils::Parser::EncryptionAnnotationParser.parse_annotations(sql, server_version: version)
707
+ tables = statement_tables(analysis_of(stripped_sql(sql), version), annotations)
698
708
  return {} if tables.empty?
699
709
 
700
710
  # When two of the statement's tables encrypt a column of the same name, the first of them
@@ -837,6 +847,19 @@ module AwsAdvancedRubyDriverWrapper
837
847
  Utils::Parser::EncryptionAnnotationParser.strip_annotations(sql)
838
848
  end
839
849
 
850
+ # The version of the MySQL server the statement is sent to, which decides whether a versioned
851
+ # /*!80023 ... */ comment is part of the statement, and so which column each placeholder after it
852
+ # belongs to (see DriverDialects::DriverDialect#executable_comment_version). It is read from the
853
+ # connection every time rather than kept, since a failover or a blue/green switchover can move
854
+ # the connection to a server of another version.
855
+ #
856
+ # @return [Integer, nil] nil on PostgreSQL, which has no executable comments, and when the
857
+ # connection cannot say
858
+ def executable_comment_version
859
+ @service_container.dialect_service.driver_dialect
860
+ .executable_comment_version(@service_container.connection_service.current_connection)
861
+ end
862
+
840
863
  # -- Lazily built collaborators --
841
864
 
842
865
  # Builds the parts of the plugin that need a database connection, the first time a statement
@@ -0,0 +1,108 @@
1
+ DELIMITER $$
2
+
3
+ -- HMAC-SHA256 built from SHA2(), which MySQL has no native HMAC for.
4
+ DROP FUNCTION IF EXISTS hmac_sha256$$
5
+ CREATE FUNCTION hmac_sha256(
6
+ message VARBINARY(65535),
7
+ key_data VARBINARY(64)
8
+ )
9
+ RETURNS VARBINARY(32)
10
+ DETERMINISTIC
11
+ NO SQL
12
+ BEGIN
13
+ DECLARE block_size INT DEFAULT 64; -- SHA-256 block size
14
+ DECLARE key_padded VARBINARY(64);
15
+ DECLARE ipad VARBINARY(64);
16
+ DECLARE opad VARBINARY(64);
17
+ DECLARE i INT DEFAULT 1;
18
+ DECLARE key_byte INT;
19
+
20
+ -- Reduce an over-long key to its hash, then zero-pad the key to the block size.
21
+ IF LENGTH(key_data) > block_size THEN
22
+ SET key_padded = CONCAT(UNHEX(SHA2(key_data, 256)), REPEAT(X'00', block_size - 32));
23
+ ELSE
24
+ SET key_padded = CONCAT(key_data, REPEAT(X'00', block_size - LENGTH(key_data)));
25
+ END IF;
26
+
27
+ -- ipad = key XOR 0x36 per byte, opad = key XOR 0x5C per byte, one raw byte at a time.
28
+ SET ipad = X'';
29
+ SET opad = X'';
30
+ WHILE i <= block_size DO
31
+ SET key_byte = ASCII(SUBSTRING(key_padded, i, 1));
32
+ SET ipad = CONCAT(ipad, UNHEX(LPAD(HEX(key_byte ^ 0x36), 2, '0')));
33
+ SET opad = CONCAT(opad, UNHEX(LPAD(HEX(key_byte ^ 0x5C), 2, '0')));
34
+ SET i = i + 1;
35
+ END WHILE;
36
+
37
+ RETURN UNHEX(SHA2(CONCAT(opad, UNHEX(SHA2(CONCAT(ipad, message), 256))), 256));
38
+ END$$
39
+
40
+ -- Verifies that a stored value carries a valid HMAC for the given HMAC key.
41
+ DROP FUNCTION IF EXISTS verify_encrypted_data_hmac$$
42
+ CREATE FUNCTION verify_encrypted_data_hmac(
43
+ data VARBINARY(65535),
44
+ hmac_key VARBINARY(32)
45
+ )
46
+ RETURNS BOOLEAN
47
+ DETERMINISTIC
48
+ NO SQL
49
+ BEGIN
50
+ -- Minimum payload: 32 (HMAC) + 4 (key id) + 1 (type) + 12 (IV) + 0 (ciphertext) + 16 (GCM tag) = 65.
51
+ IF data IS NULL OR LENGTH(data) < 65 THEN
52
+ RETURN FALSE;
53
+ END IF;
54
+
55
+ RETURN SUBSTRING(data, 1, 32) = hmac_sha256(SUBSTRING(data, 33), hmac_key);
56
+ END$$
57
+
58
+ -- Called from a BEFORE INSERT trigger (and by the BEFORE UPDATE procedure) to reject a value that is
59
+ -- not a valid kms_encryption payload. Replace SCHEMA_NAME with encryption_metadata_schema.
60
+ -- The COMMENT records which version of this SQL is installed. Check it with:
61
+ -- SELECT ROUTINE_COMMENT FROM information_schema.ROUTINES
62
+ -- WHERE ROUTINE_SCHEMA = DATABASE() AND ROUTINE_NAME = 'validate_encrypted_data_hmac_before_insert';
63
+ DROP PROCEDURE IF EXISTS validate_encrypted_data_hmac_before_insert$$
64
+ CREATE PROCEDURE validate_encrypted_data_hmac_before_insert(
65
+ IN p_table_name VARCHAR(64),
66
+ IN p_column_name VARCHAR(64),
67
+ IN column_value VARBINARY(65535)
68
+ )
69
+ COMMENT 'aws-advanced-ruby-driver-wrapper kms_encryption trigger v1'
70
+ BEGIN
71
+ DECLARE v_hmac_key VARBINARY(32);
72
+
73
+ IF column_value IS NOT NULL THEN
74
+ SELECT ks.hmac_key INTO v_hmac_key
75
+ FROM SCHEMA_NAME.encryption_metadata em
76
+ JOIN SCHEMA_NAME.key_storage ks ON em.key_id = ks.id
77
+ WHERE em.table_name = p_table_name
78
+ AND em.column_name = p_column_name
79
+ LIMIT 1;
80
+
81
+ IF v_hmac_key IS NULL THEN
82
+ SIGNAL SQLSTATE '45000'
83
+ SET MESSAGE_TEXT = 'No HMAC key is configured for the encrypted column';
84
+ END IF;
85
+
86
+ IF NOT verify_encrypted_data_hmac(column_value, v_hmac_key) THEN
87
+ SIGNAL SQLSTATE '45000'
88
+ SET MESSAGE_TEXT = 'Column does not carry a valid HMAC tag (plaintext or tampered value)';
89
+ END IF;
90
+ END IF;
91
+ END$$
92
+
93
+ -- Called from a BEFORE UPDATE trigger. An UPDATE that leaves the value as it was is not
94
+ -- re-checked; see "Updates after a key rotation".
95
+ DROP PROCEDURE IF EXISTS validate_encrypted_data_hmac_before_update$$
96
+ CREATE PROCEDURE validate_encrypted_data_hmac_before_update(
97
+ IN p_table_name VARCHAR(64),
98
+ IN p_column_name VARCHAR(64),
99
+ IN new_value VARBINARY(65535),
100
+ IN old_value VARBINARY(65535)
101
+ )
102
+ BEGIN
103
+ IF NOT (new_value <=> old_value) THEN
104
+ CALL validate_encrypted_data_hmac_before_insert(p_table_name, p_column_name, new_value);
105
+ END IF;
106
+ END$$
107
+
108
+ DELIMITER ;
@@ -0,0 +1,54 @@
1
+ -- pgcrypto provides hmac(). If it is already installed in a schema other than public,
2
+ -- replace public in public.hmac below with that schema.
3
+ CREATE EXTENSION IF NOT EXISTS pgcrypto WITH SCHEMA public;
4
+
5
+ -- Replace 'encrypt' below if encryption_metadata_schema is set to something else.
6
+ -- A trigger runs with the search_path of the session that fires it. Pinning it to pg_catalog
7
+ -- stops a session from putting its own functions or operators, such as hmac() or <>, ahead of
8
+ -- the built-in ones to get a plaintext value past this check.
9
+ CREATE OR REPLACE FUNCTION enforce_encrypted_column() RETURNS trigger
10
+ SET search_path = pg_catalog, pg_temp
11
+ AS $$
12
+ DECLARE
13
+ col_name text := TG_ARGV[0];
14
+ col_value bytea;
15
+ hmac_key bytea;
16
+ unchanged boolean;
17
+ BEGIN
18
+ -- An UPDATE that leaves the encrypted value as it was is not re-checked; see
19
+ -- "Updates after a key rotation".
20
+ IF TG_OP = 'UPDATE' THEN
21
+ EXECUTE format('SELECT ($1).%I IS NOT DISTINCT FROM ($2).%I', col_name, col_name)
22
+ INTO unchanged USING NEW, OLD;
23
+ IF unchanged THEN
24
+ RETURN NEW;
25
+ END IF;
26
+ END IF;
27
+
28
+ EXECUTE format('SELECT ($1).%I', col_name) INTO col_value USING NEW;
29
+ IF col_value IS NULL THEN
30
+ RETURN NEW;
31
+ END IF;
32
+
33
+ -- Read the column's current HMAC key from key_storage.
34
+ SELECT ks.hmac_key INTO hmac_key
35
+ FROM encrypt.encryption_metadata em
36
+ JOIN encrypt.key_storage ks ON em.key_id = ks.id
37
+ WHERE em.table_name = TG_TABLE_NAME AND em.column_name = col_name;
38
+
39
+ IF hmac_key IS NULL THEN
40
+ RAISE EXCEPTION 'No HMAC key is configured for %.%', TG_TABLE_NAME, col_name;
41
+ END IF;
42
+
43
+ IF length(col_value) < 65
44
+ OR substring(col_value from 1 for 32) <> public.hmac(substring(col_value from 33), hmac_key, 'sha256'::text) THEN
45
+ RAISE EXCEPTION 'Column %.% does not carry a valid HMAC tag (plaintext or tampered value)', TG_TABLE_NAME, col_name;
46
+ END IF;
47
+
48
+ RETURN NEW;
49
+ END;
50
+ $$ LANGUAGE plpgsql;
51
+
52
+ -- Records which version of this SQL is installed. Check it with:
53
+ -- SELECT obj_description('enforce_encrypted_column'::regproc, 'pg_proc');
54
+ COMMENT ON FUNCTION enforce_encrypted_column() IS 'aws-advanced-ruby-driver-wrapper kms_encryption trigger v2';
@@ -19,6 +19,7 @@ require_relative '../errors'
19
19
  require_relative '../logging'
20
20
  require_relative '../property_definition'
21
21
  require_relative '../utils/aws_credentials_utils'
22
+ require_relative '../utils/telemetry/telemetry_const'
22
23
 
23
24
  module AwsAdvancedRubyDriverWrapper
24
25
  module Plugins
@@ -27,6 +28,7 @@ module AwsAdvancedRubyDriverWrapper
27
28
 
28
29
  SUBSCRIBED_METHODS = Set['connect', 'internal_connect'].freeze
29
30
  SECRETS_MANAGER_CACHE_NAME = :secrets_manager
31
+ TELEMETRY_FETCH_CREDENTIALS = 'fetch credentials'
30
32
  MIN_EXPIRATION_SEC = 300
31
33
  SYNC_FETCH_TIMEOUT_SEC = 60
32
34
  # The partition segment of an ARN varies across AWS partitions, so match any of them.
@@ -79,6 +81,12 @@ module AwsAdvancedRubyDriverWrapper
79
81
  storage_service.clear(SECRETS_MANAGER_CACHE_NAME)
80
82
  end
81
83
 
84
+ # Forgets fetches inherited by a forked child. A fetch running at fork time lost its thread,
85
+ # so its future never resolves and would otherwise be returned to every later fetch for its key.
86
+ def release_pending_refreshes_after_fork
87
+ @pending_refreshes.clear
88
+ end
89
+
82
90
  # The secret's value depends on which secret is read, from which region and endpoint, and
83
91
  # with which AWS credentials, so all of them are part of the key. Connections that differ in
84
92
  # any of them neither share a cached secret nor wait on each other's fetch.
@@ -120,6 +128,8 @@ module AwsAdvancedRubyDriverWrapper
120
128
  )
121
129
 
122
130
  @subscribed_methods = SUBSCRIBED_METHODS
131
+ @telemetry_factory = service_container.telemetry_factory
132
+ @fetch_credentials_counter = @telemetry_factory.create_counter('secrets_manager.fetch_credentials.count')
123
133
  end
124
134
 
125
135
  def connect(_host_info, driver_props, _is_initial_connection, pipeline_callable)
@@ -205,7 +215,17 @@ module AwsAdvancedRubyDriverWrapper
205
215
  end
206
216
 
207
217
  # Returns true if credentials were freshly fetched from the service (suppresses login-error retry).
218
+ #
219
+ # Each lookup is counted and recorded as a span, whether it is answered from the cache or from
220
+ # the service.
208
221
  def fetch_secret_and_report_if_fresh?(force:)
222
+ @fetch_credentials_counter.inc
223
+ @telemetry_factory.in_context(TELEMETRY_FETCH_CREDENTIALS, Utils::Telemetry::TraceLevel::NESTED, record_failure: true) do
224
+ load_secret_and_report_if_fresh?(force:)
225
+ end
226
+ end
227
+
228
+ def load_secret_and_report_if_fresh?(force:)
209
229
  credentials = Utils::AwsCredentialsUtils.snapshot(@credentials_provider)
210
230
  cache_key = secret_cache_key(credentials)
211
231
  entry = cache_key && @service_container.storage_service.get(SECRETS_MANAGER_CACHE_NAME, cache_key)
@@ -244,7 +264,9 @@ module AwsAdvancedRubyDriverWrapper
244
264
  else
245
265
  Concurrent::Promises.future_on(:io) { fetch_and_store_secret(nil, credentials) }
246
266
  end
247
- future.value!(SYNC_FETCH_TIMEOUT_SEC)
267
+ # value! returns nil instead of raising when the timeout elapses; a completed fetch always
268
+ # returns an entry or raises.
269
+ future.value!(SYNC_FETCH_TIMEOUT_SEC) || raise(Timeout::Error)
248
270
  rescue Concurrent::CancelledOperationError, Timeout::Error
249
271
  raise Errors::SecretsManagerAuthError,
250
272
  "Timed out fetching secret after #{SYNC_FETCH_TIMEOUT_SEC}s"
@@ -20,6 +20,7 @@ require_relative '../aws_advanced_ruby_driver_wrapper'
20
20
  require_relative 'utils/connection_config_parser'
21
21
  require_relative 'utils/sql_encoding'
22
22
  require_relative 'services/service_utility'
23
+ require_relative 'utils/telemetry/telemetry_factory'
23
24
  require_relative 'ruby_method'
24
25
  require_relative 'errors'
25
26
 
@@ -45,17 +46,20 @@ module AwsAdvancedRubyDriverWrapper
45
46
  def initialize(*, **)
46
47
  ensure_pg!
47
48
  config = Utils::ConnectionConfigParser.parse(:postgresql, *, **)
48
- @service_container = Services::ServiceUtility.create_standard_container(config)
49
- @service_container.host_service.refresh_host_list
50
- @prepared_on = {}
51
- @prepared_sql = {}
52
- @async_conn = nil
53
- @async_sql = nil
54
- @copy_conn = nil
55
- @copy_sql = nil
56
- @lo_conn = nil
57
- conn_service = @service_container.connection_service
58
- @service_container.plugin_manager.connect(conn_service.initial_host_info, conn_service.driver_props, true)
49
+ telemetry_factory = Utils::Telemetry::TelemetryFactory.new(config.wrapper_props)
50
+ telemetry_factory.in_context("#{self.class.name}.new", Utils::Telemetry::TraceLevel::TOP_LEVEL, record_result: true) do
51
+ @service_container = Services::ServiceUtility.create_standard_container(config, telemetry_factory:)
52
+ @service_container.host_service.refresh_host_list
53
+ @prepared_on = {}
54
+ @prepared_sql = {}
55
+ @async_conn = nil
56
+ @async_sql = nil
57
+ @copy_conn = nil
58
+ @copy_sql = nil
59
+ @lo_conn = nil
60
+ conn_service = @service_container.connection_service
61
+ @service_container.plugin_manager.connect(conn_service.initial_host_info, conn_service.driver_props, true)
62
+ end
59
63
  end
60
64
 
61
65
  # Every canonical pg operation that talks to the server, and everything this class has to know about one: the
@@ -173,6 +177,10 @@ module AwsAdvancedRubyDriverWrapper
173
177
  lotell: :lo_tell, lotruncate: :lo_truncate
174
178
  }.freeze
175
179
 
180
+ # The operations pg yields the result of when given a block, clearing it once the block returns and
181
+ # returning the block's value in its place.
182
+ YIELDS_RESULT = %i[exec async_exec exec_params exec_prepared].freeze
183
+
176
184
  # A statement can also be prepared by sending a +PREPARE+ rather than by calling pg's own
177
185
  # +prepare+, and the +exec_prepared+ that runs it looks no different either way. The two are read
178
186
  # here so that a statement prepared the first way is remembered like one prepared the second, and
@@ -194,24 +202,24 @@ module AwsAdvancedRubyDriverWrapper
194
202
 
195
203
  # Explicitly define critical methods (bypass method_missing to avoid method_missing overhead).
196
204
 
197
- def exec(sql, *params)
198
- execute_operation(:exec, [sql, *params])
205
+ def exec(sql, *params, &)
206
+ execute_operation(:exec, [sql, *params], &)
199
207
  end
200
208
 
201
209
  # pg spells this operation +exec+, +query+, +async_exec+ and +async_query+, all of which run the
202
210
  # same libpq call. +query+ is defined here rather than left to method_missing because it is the
203
211
  # spelling applications use most after +exec+, and it enters the pipeline as +connection.exec+,
204
212
  # since that is the libpq operation being performed.
205
- def query(sql, *params)
206
- execute_operation(:exec, [sql, *params], spelling: :query)
213
+ def query(sql, *params, &)
214
+ execute_operation(:exec, [sql, *params], spelling: :query, &)
207
215
  end
208
216
 
209
- def exec_params(sql, params, result_format = 0, type_map = nil)
210
- execute_operation(:exec_params, [sql, params, result_format, type_map])
217
+ def exec_params(sql, params, result_format = 0, type_map = nil, &)
218
+ execute_operation(:exec_params, [sql, params, result_format, type_map], &)
211
219
  end
212
220
 
213
- def async_exec(sql, *params)
214
- execute_operation(:async_exec, [sql, *params])
221
+ def async_exec(sql, *params, &)
222
+ execute_operation(:async_exec, [sql, *params], &)
215
223
  end
216
224
 
217
225
  def transaction(&)
@@ -224,6 +232,14 @@ module AwsAdvancedRubyDriverWrapper
224
232
 
225
233
  alias finish close
226
234
 
235
+ # Stops the background topology monitor of this connection's cluster, which closes the connections it
236
+ # holds to the database it was started for. The next connection that needs topology starts a new one.
237
+ # PostgreSQL refuses to drop a database that other sessions are connected to, so this has to happen
238
+ # before dropping the database the monitor was started for.
239
+ def stop_topology_monitor
240
+ @service_container.host_service.host_list_provider&.stop_monitor
241
+ end
242
+
227
243
  # Resets the connection through the pipeline and returns this wrapper, so the reset connection stays
228
244
  # usable through it. The driver's own reset tears down and re-establishes the underlying socket, which
229
245
  # clears any server-side session state, so the tracked session state is reset to match.
@@ -242,8 +258,8 @@ module AwsAdvancedRubyDriverWrapper
242
258
  execute_operation(:send_prepare, [stmt_name, sql, param_types])
243
259
  end
244
260
 
245
- def exec_prepared(stmt_name, params = [], result_format = 0, type_map = nil)
246
- execute_operation(:exec_prepared, [stmt_name, params, result_format, type_map])
261
+ def exec_prepared(stmt_name, params = [], result_format = 0, type_map = nil, &)
262
+ execute_operation(:exec_prepared, [stmt_name, params, result_format, type_map], &)
247
263
  end
248
264
 
249
265
  def describe_prepared(stmt_name)
@@ -304,7 +320,7 @@ module AwsAdvancedRubyDriverWrapper
304
320
 
305
321
  def method_missing(method_name, *args, **kwargs, &)
306
322
  conn = current_conn
307
- raise NoMethodError, 'Connection not initialized' if conn.nil?
323
+ raise PG::ConnectionBad, 'connection is not initialized' if conn.nil?
308
324
  raise NoMethodError, "undefined method `#{method_name}' for #{self.class}" unless conn.respond_to?(method_name)
309
325
 
310
326
  operation = operation_for(method_name)
@@ -336,10 +352,14 @@ module AwsAdvancedRubyDriverWrapper
336
352
  # made, since an `after` step may be what forgets it, and it is handed to the result as well, so that a
337
353
  # plugin which has to inspect the statement still sees it when the rows are read.
338
354
  def execute_operation(operation, args = [], kwargs = {}, spelling: operation, &)
355
+ if block_given? && YIELDS_RESULT.include?(operation)
356
+ return yield_result(execute_operation(operation, args, kwargs, spelling: spelling), &)
357
+ end
358
+
339
359
  spec = OPERATIONS[operation] || { method: "connection.#{operation}" }
340
360
  conn = current_conn
341
361
  # Guard against a missing connection. Fail loudly instead.
342
- raise NoMethodError, 'Connection not initialized' if conn.nil?
362
+ raise PG::ConnectionBad, 'connection is not initialized' if conn.nil?
343
363
 
344
364
  # Only forward keyword arguments when there are any.
345
365
  sql = sql_for(spec, args)
@@ -510,6 +530,12 @@ module AwsAdvancedRubyDriverWrapper
510
530
  @network_bound_methods ||= driver_dialect.network_bound_methods
511
531
  end
512
532
 
533
+ def yield_result(result)
534
+ yield result
535
+ ensure
536
+ result.clear
537
+ end
538
+
513
539
  def wrap_pg_result(result, sql = nil)
514
540
  return result unless result.is_a?(PG::Result)
515
541