aws_advanced_ruby_driver_wrapper 1.0.1 → 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 (81) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +32 -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_postgresql_adapter.rb +17 -3
  7. data/lib/aws_advanced_ruby_driver_wrapper/active_record/aws_trilogy_adapter.rb +80 -0
  8. data/lib/aws_advanced_ruby_driver_wrapper/active_record/type_adapter_alias.rb +1 -1
  9. data/lib/aws_advanced_ruby_driver_wrapper/driver_dialects/driver_dialect.rb +43 -0
  10. data/lib/aws_advanced_ruby_driver_wrapper/driver_dialects/driver_dialect_manager.rb +11 -4
  11. data/lib/aws_advanced_ruby_driver_wrapper/driver_dialects/mysql2_driver_dialect.rb +129 -0
  12. data/lib/aws_advanced_ruby_driver_wrapper/driver_dialects/mysql_driver_dialect.rb +9 -91
  13. data/lib/aws_advanced_ruby_driver_wrapper/driver_dialects/pg_driver_dialect.rb +18 -0
  14. data/lib/aws_advanced_ruby_driver_wrapper/driver_dialects/trilogy_driver_dialect.rb +118 -0
  15. data/lib/aws_advanced_ruby_driver_wrapper/errors/error_handler.rb +16 -0
  16. data/lib/aws_advanced_ruby_driver_wrapper/errors/mysql_error_handler.rb +10 -5
  17. data/lib/aws_advanced_ruby_driver_wrapper/errors/trilogy_error_handler.rb +127 -0
  18. data/lib/aws_advanced_ruby_driver_wrapper/errors.rb +1 -0
  19. data/lib/aws_advanced_ruby_driver_wrapper/monitoring/cluster_topology_monitor.rb +1 -0
  20. data/lib/aws_advanced_ruby_driver_wrapper/mysql.rb +27 -9
  21. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/blue_green_plugin.rb +115 -7
  22. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/drop_blue_connections_marker.rb +84 -0
  23. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/host_mapper.rb +3 -1
  24. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/routing/base_routing.rb +7 -0
  25. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/routing/substitute_connect_routing.rb +68 -41
  26. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/routing/suspend_connect_routing.rb +4 -2
  27. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/routing/suspend_execute_routing.rb +5 -3
  28. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/routing/suspend_until_corresponding_host_found_connect_routing.rb +7 -1
  29. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/status_builder.rb +1 -1
  30. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/status_monitor.rb +6 -13
  31. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/status_provider.rb +90 -3
  32. data/lib/aws_advanced_ruby_driver_wrapper/plugins/custom_endpoint/custom_endpoint_monitor.rb +11 -1
  33. data/lib/aws_advanced_ruby_driver_wrapper/plugins/custom_endpoint/custom_endpoint_plugin.rb +4 -1
  34. data/lib/aws_advanced_ruby_driver_wrapper/plugins/default_plugin.rb +14 -3
  35. data/lib/aws_advanced_ruby_driver_wrapper/plugins/failover_plugin.rb +68 -3
  36. data/lib/aws_advanced_ruby_driver_wrapper/plugins/federated_auth/base_saml_auth_plugin.rb +266 -0
  37. data/lib/aws_advanced_ruby_driver_wrapper/plugins/federated_auth/federated_auth_plugin.rb +134 -0
  38. data/lib/aws_advanced_ruby_driver_wrapper/plugins/federated_auth/okta_auth_plugin.rb +113 -0
  39. data/lib/aws_advanced_ruby_driver_wrapper/plugins/gdb/gdb_failover_plugin.rb +30 -9
  40. data/lib/aws_advanced_ruby_driver_wrapper/plugins/iam_auth_plugin.rb +16 -2
  41. data/lib/aws_advanced_ruby_driver_wrapper/plugins/initial_connection_strategy_plugin.rb +4 -1
  42. data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/key_management_utility.rb +6 -3
  43. data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/key_manager.rb +13 -4
  44. data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/kms_encryption_plugin.rb +29 -6
  45. data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/sql/trigger_mysql.sql +108 -0
  46. data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/sql/trigger_pg.sql +54 -0
  47. data/lib/aws_advanced_ruby_driver_wrapper/plugins/secrets_manager_plugin.rb +14 -0
  48. data/lib/aws_advanced_ruby_driver_wrapper/postgresql.rb +41 -23
  49. data/lib/aws_advanced_ruby_driver_wrapper/property_definition.rb +63 -1
  50. data/lib/aws_advanced_ruby_driver_wrapper/ruby_method.rb +3 -0
  51. data/lib/aws_advanced_ruby_driver_wrapper/services/dialect_service.rb +20 -7
  52. data/lib/aws_advanced_ruby_driver_wrapper/services/host_service.rb +34 -8
  53. data/lib/aws_advanced_ruby_driver_wrapper/services/monitor_service.rb +1 -1
  54. data/lib/aws_advanced_ruby_driver_wrapper/services/plugin_manager.rb +80 -29
  55. data/lib/aws_advanced_ruby_driver_wrapper/services/service_container.rb +10 -2
  56. data/lib/aws_advanced_ruby_driver_wrapper/services/service_utility.rb +4 -1
  57. data/lib/aws_advanced_ruby_driver_wrapper/trilogy.rb +167 -0
  58. data/lib/aws_advanced_ruby_driver_wrapper/utils/connection_config.rb +1 -1
  59. data/lib/aws_advanced_ruby_driver_wrapper/utils/connection_config_parser.rb +25 -15
  60. data/lib/aws_advanced_ruby_driver_wrapper/utils/global_aurora_topology_utils.rb +2 -2
  61. data/lib/aws_advanced_ruby_driver_wrapper/utils/iam_auth_utils.rb +13 -4
  62. data/lib/aws_advanced_ruby_driver_wrapper/utils/parser/encryption_annotation_parser.rb +15 -31
  63. data/lib/aws_advanced_ruby_driver_wrapper/utils/parser/mysql_statement_analyzer.rb +55 -76
  64. data/lib/aws_advanced_ruby_driver_wrapper/utils/parser/mysql_text.rb +215 -0
  65. data/lib/aws_advanced_ruby_driver_wrapper/utils/parser/pg_statement_analyzer.rb +11 -20
  66. data/lib/aws_advanced_ruby_driver_wrapper/utils/parser/query_analysis.rb +2 -3
  67. data/lib/aws_advanced_ruby_driver_wrapper/utils/parser/sql_parser.rb +21 -14
  68. data/lib/aws_advanced_ruby_driver_wrapper/utils/retry_util.rb +2 -1
  69. data/lib/aws_advanced_ruby_driver_wrapper/utils/saml_utils.rb +133 -0
  70. data/lib/aws_advanced_ruby_driver_wrapper/utils/storage/storage_service.rb +9 -5
  71. data/lib/aws_advanced_ruby_driver_wrapper/utils/telemetry/null_telemetry.rb +89 -0
  72. data/lib/aws_advanced_ruby_driver_wrapper/utils/telemetry/open_telemetry_context.rb +124 -0
  73. data/lib/aws_advanced_ruby_driver_wrapper/utils/telemetry/open_telemetry_counter.rb +39 -0
  74. data/lib/aws_advanced_ruby_driver_wrapper/utils/telemetry/open_telemetry_factory.rb +122 -0
  75. data/lib/aws_advanced_ruby_driver_wrapper/utils/telemetry/open_telemetry_gauge.rb +36 -0
  76. data/lib/aws_advanced_ruby_driver_wrapper/utils/telemetry/telemetry_const.rb +50 -0
  77. data/lib/aws_advanced_ruby_driver_wrapper/utils/telemetry/telemetry_factory.rb +143 -0
  78. data/lib/aws_advanced_ruby_driver_wrapper/utils/topology_utils.rb +9 -2
  79. data/lib/aws_advanced_ruby_driver_wrapper/version.rb +1 -1
  80. data/lib/aws_advanced_ruby_driver_wrapper.rb +9 -2
  81. metadata +54 -2
@@ -0,0 +1,134 @@
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 'cgi/escape'
18
+ require 'uri'
19
+ require_relative 'base_saml_auth_plugin'
20
+
21
+ module AwsAdvancedRubyDriverWrapper
22
+ module Plugins
23
+ module FederatedAuth
24
+ # Federated authentication against Active Directory Federation Services
25
+ # (AD FS). Scrapes the IdP-initiated sign-on page for its form, posts the
26
+ # federated credentials, and extracts the SAML assertion from the response.
27
+ # Everything else (STS exchange, token minting, caching, retry) is handled by
28
+ # BaseSamlAuthPlugin.
29
+ class FederatedAuthPlugin < BaseSamlAuthPlugin
30
+ TELEMETRY_PREFIX = 'federated'
31
+ TELEMETRY_FETCH_SAML = 'Fetch ADFS SAML Assertion'
32
+ INPUT_TAG_PATTERN = /<input([^<>]*)>/
33
+ FORM_ACTION_PATTERN = /<form[^<>]*?\baction="([^"]+)"/
34
+
35
+ # Fetches a SAML assertion from AD FS.
36
+ #
37
+ # @return [String] the base64 SAML assertion
38
+ def self.fetch_saml_assertion(idp_endpoint:, idp_port:, idp_username:, idp_password:,
39
+ rp_identifier:, ssl_insecure:, socket_timeout_sec:, connect_timeout_sec:, **_rest)
40
+ # One cookie jar shared by the sign-on GET and the credential POST, so cookies AD FS
41
+ # (or a load balancer in front of it) sets on the sign-on page are returned with the POST.
42
+ cookies = {}
43
+ http = lambda do |method, url, body: nil, headers: {}|
44
+ Utils::SamlUtils.http_request(
45
+ method:, url:, body:, headers:, ssl_insecure:, cookies:,
46
+ open_timeout_sec: connect_timeout_sec, read_timeout_sec: socket_timeout_sec
47
+ )
48
+ end
49
+
50
+ sign_in_url = "https://#{idp_endpoint}:#{idp_port}/adfs/ls/IdpInitiatedSignOn.aspx?loginToRp=#{rp_identifier}"
51
+ sign_in_body = require_body!(http.call(:get, sign_in_url), 'sign-on page')
52
+
53
+ action = form_action(sign_in_body)
54
+ post_url = action&.start_with?('/') ? "https://#{idp_endpoint}:#{idp_port}#{action}" : sign_in_url
55
+
56
+ params = form_parameters(sign_in_body, idp_username, idp_password)
57
+ response = http.call(:post, post_url,
58
+ body: URI.encode_www_form(params),
59
+ headers: { 'Content-Type' => 'application/x-www-form-urlencoded' })
60
+ content = require_body!(response, 'sign-on POST')
61
+
62
+ match = Utils::SamlUtils::SAML_RESPONSE_PATTERN.match(content)
63
+ unless match
64
+ # AD FS answers a rejected login with the sign-on page again (no SAMLResponse); the
65
+ # body is not included as it may contain sensitive markup.
66
+ raise Errors::SamlAuthError,
67
+ 'FederatedAuthPlugin: no SAML assertion in the AD FS response - check idp_username, ' \
68
+ 'idp_password, and idp_endpoint'
69
+ end
70
+
71
+ match[1]
72
+ end
73
+
74
+ def self.require_body!(response, what)
75
+ return response.body if response.is_a?(Net::HTTPSuccess)
76
+
77
+ raise Errors::SamlAuthError,
78
+ "FederatedAuthPlugin: AD FS #{what} request failed (#{response.code} #{response.message})"
79
+ end
80
+
81
+ def self.form_action(body)
82
+ match = FORM_ACTION_PATTERN.match(body)
83
+ match && unescape_html(match[1])
84
+ end
85
+
86
+ # Builds the POST parameters from the sign-on form's input tags, filling in
87
+ # the federated username/password and preserving every other hidden field.
88
+ def self.form_parameters(body, idp_username, idp_password)
89
+ params = []
90
+ seen = {}
91
+ body.scan(INPUT_TAG_PATTERN).flatten.each do |tag|
92
+ name = value_by_key(tag, 'name')
93
+ next if name.empty?
94
+
95
+ lower = name.downcase
96
+ next if seen[lower]
97
+
98
+ seen[lower] = true
99
+ value = value_by_key(tag, 'value')
100
+
101
+ if lower.include?('username')
102
+ params << [name, idp_username]
103
+ elsif lower.include?('authmethod')
104
+ params << [name, value] unless value.empty?
105
+ elsif lower.include?('password')
106
+ params << [name, idp_password]
107
+ else
108
+ params << [name, value]
109
+ end
110
+ end
111
+ params
112
+ end
113
+
114
+ def self.value_by_key(input, key)
115
+ match = /#{Regexp.quote(key)}\s*=\s*"(.*?)"/.match(input)
116
+ match ? unescape_html(match[1]) : ''
117
+ end
118
+
119
+ def self.unescape_html(html)
120
+ CGI.unescapeHTML(html)
121
+ end
122
+
123
+ private
124
+
125
+ def saml_fetch_options(saml_props, idp_username, idp_password)
126
+ super.merge(
127
+ idp_port: PropertyDefinition::IDP_PORT.get(saml_props),
128
+ rp_identifier: PropertyDefinition::RP_IDENTIFIER.get(saml_props)
129
+ )
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,113 @@
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 'cgi/escape'
18
+ require 'json'
19
+ require_relative 'base_saml_auth_plugin'
20
+
21
+ module AwsAdvancedRubyDriverWrapper
22
+ module Plugins
23
+ module FederatedAuth
24
+ # Federated authentication against Okta. Authenticates to Okta's /authn API
25
+ # for a one-time session token, redeems it at the AWS app's SSO SAML endpoint
26
+ # for a SAML assertion, and then defers to BaseSamlAuthPlugin for the STS
27
+ # exchange, token minting, caching, and retry. Okta differs from AD FS only in
28
+ # configuration (it uses app_id, the AWS app configured on Okta) and in the
29
+ # shape of the IdP HTTP flow.
30
+ class OktaAuthPlugin < AwsAdvancedRubyDriverWrapper::Plugins::FederatedAuth::BaseSamlAuthPlugin
31
+ TELEMETRY_PREFIX = 'okta'
32
+ TELEMETRY_FETCH_SAML = 'Fetch OKTA SAML Assertion'
33
+ OKTA_AWS_APP_NAME = 'amazon_aws'
34
+ # Extracts the value attribute of the hidden SAMLResponse input regardless
35
+ # of attribute order.
36
+ SAML_INPUT_PATTERN = /<input[^>]*\bname="SAMLResponse"[^>]*>/i
37
+ VALUE_ATTR_PATTERN = /\bvalue="([^"]+)"/i
38
+
39
+ # Fetches a SAML assertion from Okta.
40
+ #
41
+ # @return [String] the base64 SAML assertion
42
+ def self.fetch_saml_assertion(idp_endpoint:, app_id:, idp_username:, idp_password:,
43
+ ssl_insecure:, socket_timeout_sec:, connect_timeout_sec:, **_rest)
44
+ http = lambda do |method, url, body: nil, headers: {}|
45
+ Utils::SamlUtils.http_request(
46
+ method:, url:, body:, headers:, ssl_insecure:,
47
+ open_timeout_sec: connect_timeout_sec, read_timeout_sec: socket_timeout_sec
48
+ )
49
+ end
50
+
51
+ session_token = session_token(http, idp_endpoint, idp_username, idp_password)
52
+ saml_url = "https://#{idp_endpoint}/app/#{OKTA_AWS_APP_NAME}/#{CGI.escape(app_id)}/sso/saml" \
53
+ "?onetimetoken=#{CGI.escape(session_token)}"
54
+ response = http.call(:get, saml_url)
55
+ unless response.is_a?(Net::HTTPSuccess)
56
+ raise Errors::SamlAuthError,
57
+ "OktaAuthPlugin: SAML request failed (#{response.code} #{response.message})"
58
+ end
59
+
60
+ tag = SAML_INPUT_PATTERN.match(response.body)
61
+ value = tag && VALUE_ATTR_PATTERN.match(tag[0])
62
+ raise Errors::SamlAuthError, 'OktaAuthPlugin: invalid SAML response from Okta' unless value
63
+
64
+ # The value attribute is HTML-entity-encoded (Okta encodes base64's trailing
65
+ # "=" padding, and "+" if present, as e.g. &#x3d; / &#x2b;); decode before
66
+ # handing the assertion to STS, matching FederatedAuthPlugin's AD FS flow.
67
+ CGI.unescapeHTML(value[1])
68
+ end
69
+
70
+ # Authenticates to Okta's /authn API and returns the one-time session token.
71
+ def self.session_token(http, idp_endpoint, idp_username, idp_password)
72
+ response = http.call(
73
+ :post, "https://#{idp_endpoint}/api/v1/authn",
74
+ body: JSON.generate(username: idp_username, password: idp_password),
75
+ headers: { 'Accept' => 'application/json', 'Content-Type' => 'application/json' }
76
+ )
77
+ unless response.is_a?(Net::HTTPSuccess)
78
+ raise Errors::SamlAuthError,
79
+ "OktaAuthPlugin: Okta session token request failed (#{response.code} #{response.message})"
80
+ end
81
+
82
+ parsed = begin
83
+ JSON.parse(response.body)
84
+ rescue JSON::ParserError
85
+ nil
86
+ end
87
+ parsed = nil unless parsed.is_a?(Hash)
88
+ token = parsed && parsed['sessionToken']
89
+ if token.nil? || token.empty?
90
+ # Okta's `status` is a non-sensitive auth-state enum (e.g. MFA_REQUIRED,
91
+ # PASSWORD_EXPIRED, LOCKED_OUT); surface it for clarity. It never carries
92
+ # credentials or the session token itself.
93
+ status = parsed && parsed['status']
94
+ detail = status.is_a?(String) && !status.empty? ? " (status: #{status})" : ''
95
+ raise Errors::SamlAuthError, "OktaAuthPlugin: Okta did not return a session token#{detail}"
96
+ end
97
+
98
+ token
99
+ end
100
+
101
+ private
102
+
103
+ def required_identity(saml_props)
104
+ super + [['app_id', PropertyDefinition::APP_ID.get(saml_props)]]
105
+ end
106
+
107
+ def saml_fetch_options(saml_props, idp_username, idp_password)
108
+ super.merge(app_id: PropertyDefinition::APP_ID.get(saml_props))
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
@@ -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 ;