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
@@ -20,9 +20,11 @@ require_relative '../host/host_info'
20
20
  require_relative '../host/host_role'
21
21
  require_relative '../host/host_availability'
22
22
  require_relative '../logging'
23
+ require_relative '../services/host_service'
23
24
  require_relative '../utils/rds_utils'
24
25
  require_relative '../utils/rds_url_type'
25
26
  require_relative '../utils/retry_util'
27
+ require_relative '../utils/telemetry/telemetry_const'
26
28
  require_relative '../wrapper_property'
27
29
  require_relative 'failover_mode'
28
30
 
@@ -32,14 +34,25 @@ module AwsAdvancedRubyDriverWrapper
32
34
  include Logging
33
35
 
34
36
  ReaderFailoverResult = Data.define(:connection, :host_info)
37
+ FailoverCounters = Data.define(:triggered, :succeeded, :failed)
38
+
39
+ SUCCESSFUL_FAILOVER_ERRORS = [Errors::FailoverSuccessError, Errors::TransactionStateUnknownError].freeze
40
+
41
+ TELEMETRY_WRITER_FAILOVER = 'failover to writer host'
42
+ TELEMETRY_READER_FAILOVER = 'failover to replica'
35
43
 
36
44
  def initialize(service_container, props = ::Concurrent::Map.new)
37
45
  @service_container = service_container
38
46
  @wrapper_props = props
47
+ @telemetry_factory = service_container.telemetry_factory
48
+ @telemetry_failover_additional_top_trace = PropertyDefinition::TELEMETRY_FAILOVER_ADDITIONAL_TOP_TRACE.get_bool(props)
49
+ @writer_failover_counters = failover_counters('writer_failover')
50
+ @reader_failover_counters = failover_counters('reader_failover')
39
51
 
40
52
  @retry_util = Utils::RetryUtil.new(service_container)
41
53
  @failover_timeout = PropertyDefinition::FAILOVER_TIMEOUT_SEC.get_float(props)
42
54
  @reader_selector_strategy = PropertyDefinition::FAILOVER_READER_HOST_SELECTOR_STRATEGY.get(props)
55
+ Services::HostService.validate_strategy!(@reader_selector_strategy)
43
56
  @failover_mode = nil
44
57
  @rds_url_type = nil
45
58
  @closed_explicitly = false
@@ -235,9 +248,60 @@ module AwsAdvancedRubyDriverWrapper
235
248
  end
236
249
 
237
250
  if @failover_mode == FailoverMode::STRICT_WRITER
238
- failover_writer
251
+ with_failover_telemetry(TELEMETRY_WRITER_FAILOVER) { count_failover(@writer_failover_counters) { failover_writer } }
239
252
  else
240
- failover_reader
253
+ with_failover_telemetry(TELEMETRY_READER_FAILOVER) { count_failover(@reader_failover_counters) { failover_reader } }
254
+ end
255
+ end
256
+
257
+ def failover_counters(prefix)
258
+ FailoverCounters.new(
259
+ triggered: @telemetry_factory.create_counter("#{prefix}.triggered.count"),
260
+ succeeded: @telemetry_factory.create_counter("#{prefix}.completed.success.count"),
261
+ failed: @telemetry_factory.create_counter("#{prefix}.completed.failed.count")
262
+ )
263
+ end
264
+
265
+ # Records a failover as a span. Failover always ends in an error: a successful one ends in one
266
+ # of SUCCESSFUL_FAILOVER_ERRORS, which marks the span as succeeded, and any other error marks it
267
+ # as failed. With telemetry_failover_additional_top_trace enabled, the span is also submitted as
268
+ # a trace of its own.
269
+ def with_failover_telemetry(span_name)
270
+ telemetry_context = nil
271
+ @telemetry_factory.in_context(span_name, Utils::Telemetry::TraceLevel::NESTED) do |context|
272
+ telemetry_context = context
273
+ yield
274
+ rescue *SUCCESSFUL_FAILOVER_ERRORS => e
275
+ context.success = true
276
+ context.record_exception(e)
277
+ raise
278
+ rescue StandardError => e
279
+ context.success = false
280
+ context.record_exception(e)
281
+ raise
282
+ end
283
+ ensure
284
+ submit_failover_copy(telemetry_context) if telemetry_context && @telemetry_failover_additional_top_trace
285
+ end
286
+
287
+ # Runs while the failover's outcome is propagating, so an error here must not replace it.
288
+ def submit_failover_copy(telemetry_context)
289
+ @telemetry_factory.post_copy(telemetry_context, Utils::Telemetry::TraceLevel::FORCE_TOP_LEVEL)
290
+ rescue StandardError => e
291
+ logger.debug { "Unable to submit the failover trace as a trace of its own: #{e.message}" }
292
+ end
293
+
294
+ # Counts a failover as triggered, and then as succeeded or failed according to how it ends.
295
+ def count_failover(counters)
296
+ counters.triggered.inc
297
+ begin
298
+ yield
299
+ rescue *SUCCESSFUL_FAILOVER_ERRORS
300
+ counters.succeeded.inc
301
+ raise
302
+ rescue StandardError
303
+ counters.failed.inc
304
+ raise
241
305
  end
242
306
  end
243
307
 
@@ -414,7 +478,8 @@ module AwsAdvancedRubyDriverWrapper
414
478
  Host::HostRole::READER,
415
479
  @reader_selector_strategy
416
480
  )
417
- rescue StandardError
481
+ rescue StandardError => e
482
+ logger.warn("Failed to select a reader using the '#{@reader_selector_strategy}' host selection strategy: #{e.message}")
418
483
  nil
419
484
  end
420
485
 
@@ -0,0 +1,266 @@
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 '../../errors'
19
+ require_relative '../../property_definition'
20
+ require_relative '../iam_auth_plugin'
21
+ require_relative '../../utils/iam_auth_utils'
22
+ require_relative '../../utils/rds_utils'
23
+ require_relative '../../utils/rds_url_type'
24
+ require_relative '../../utils/saml_utils'
25
+ require_relative '../../utils/telemetry/telemetry_const'
26
+
27
+ module AwsAdvancedRubyDriverWrapper
28
+ module Plugins
29
+ module FederatedAuth
30
+ # Shared connect flow for the SAML-based authentication plugins (AD FS and
31
+ # Okta).
32
+ #
33
+ # A subclass defines +self.fetch_saml_assertion+, +TELEMETRY_PREFIX+ (the prefix of its metric
34
+ # names) and +TELEMETRY_FETCH_SAML+ (the name of the span its SAML assertion fetch is recorded as).
35
+ class BaseSamlAuthPlugin
36
+ SUBSCRIBED_METHODS = Set['connect', 'internal_connect'].freeze
37
+ # Reuse the IAM plugin's cache name.
38
+ IAM_TOKEN_CACHE_NAME = AwsAdvancedRubyDriverWrapper::Plugins::IamAuthPlugin::IAM_TOKEN_CACHE_NAME
39
+
40
+ attr_reader :subscribed_methods
41
+
42
+ def initialize(service_container, props = ::Concurrent::Map.new)
43
+ ensure_aws_sdk!
44
+ @service_container = service_container
45
+ @wrapper_props = props
46
+ expiration = PropertyDefinition::IAM_EXPIRATION_SEC.get_float(props)
47
+ PropertyDefinition::IAM_EXPIRATION_SEC.validate!(expiration)
48
+ service_container.storage_service.register(IAM_TOKEN_CACHE_NAME, ttl: expiration)
49
+ @subscribed_methods = SUBSCRIBED_METHODS
50
+
51
+ # Each subclass names its metrics after itself, and its SAML assertion span after its IdP.
52
+ @telemetry_factory = service_container.telemetry_factory
53
+ @fetch_token_counter = @telemetry_factory.create_counter("#{self.class::TELEMETRY_PREFIX}.fetch_token.count")
54
+ @telemetry_factory.create_gauge(
55
+ "#{self.class::TELEMETRY_PREFIX}.token_cache.size",
56
+ &IamAuthPlugin.token_cache_size(service_container.storage_service)
57
+ )
58
+ end
59
+
60
+ def connect(host_info, driver_props, _is_initial_connection, pipeline_callable)
61
+ saml_connect(host_info, driver_props, pipeline_callable)
62
+ end
63
+
64
+ def internal_connect(host_info, driver_props, wrapper_props_override, _is_initial_connection, pipeline_callable)
65
+ saml_connect(host_info, driver_props, pipeline_callable, wrapper_props_override || @wrapper_props)
66
+ end
67
+
68
+ def self.clear_cache(storage_service)
69
+ storage_service.clear(IAM_TOKEN_CACHE_NAME)
70
+ end
71
+
72
+ # Exchanges a SAML assertion for temporary AWS credentials via STS
73
+ # AssumeRoleWithSAML.
74
+ #
75
+ # @return [Aws::Credentials]
76
+ def self.credentials_from_saml(saml_assertion, role_arn:, idp_arn:, region:)
77
+ require 'aws-sdk-core'
78
+ # AssumeRoleWithSAML is unsigned, so no AWS credentials are needed. Passing
79
+ # credentials: nil keeps the SDK from running the default credential chain (which
80
+ # on a credential-less, non-EC2 host wastes ~1s probing IMDS and logs a misleading
81
+ # "Error retrieving instance profile credentials").
82
+ client_opts = { credentials: nil }
83
+ client_opts[:region] = region if region && !region.to_s.empty?
84
+ resp = Aws::STS::Client.new(**client_opts).assume_role_with_saml(
85
+ role_arn:, principal_arn: idp_arn, saml_assertion:
86
+ )
87
+ creds = resp.credentials
88
+ Aws::Credentials.new(creds.access_key_id, creds.secret_access_key, creds.session_token)
89
+ end
90
+
91
+ private
92
+
93
+ def saml_connect(host_info, driver_props, pipeline_callable, wrapper_props_override = nil)
94
+ wrapper_props_override ||= @wrapper_props
95
+ saml_props = wrapper_props_override
96
+
97
+ validate_required!(saml_props)
98
+
99
+ # The connection's driver_props :user/:password double as the fallback IdP login when
100
+ # idp_username / idp_password are not set. We overwrite driver_props with db_user and the
101
+ # RDS token further down, and that hash is reused across reconnects (failover, retry,
102
+ # Blue/Green rerouting), so capture the fallback into the wrapper props once, up front.
103
+ # A later reconnect then still logs in to the IdP with the original credentials rather
104
+ # than db_user and a stale token.
105
+ persist_idp_credentials_fallback(saml_props, driver_props)
106
+ db_user = PropertyDefinition::DB_USER.get(saml_props)
107
+ idp_username = PropertyDefinition::IDP_USERNAME.get(saml_props)
108
+ idp_password = PropertyDefinition::IDP_PASSWORD.get(saml_props)
109
+
110
+ host = Utils::IamAuthUtils.resolve_host(
111
+ PropertyDefinition::IAM_HOST.get(wrapper_props_override), host_info
112
+ )
113
+ port = Utils::IamAuthUtils.resolve_port(
114
+ PropertyDefinition::IAM_PORT.get(wrapper_props_override),
115
+ host_info,
116
+ @service_container.dialect_service.db_dialect.default_port
117
+ )
118
+ rds_type = Utils::RdsUtils.identify_rds_type(host)
119
+
120
+ # Only a global writer cluster with no explicit iam_region needs AWS credentials to look
121
+ # its region up. In that one case the SAML exchange has to happen before the region lookup
122
+ # (and so before the token-cache check); every other case - including a global cluster
123
+ # whose iam_region is set - defers the IdP round-trip to the token-cache miss path, so a
124
+ # pool or failover storm does not trigger one IdP login per connection.
125
+ explicit_region = PropertyDefinition::IAM_REGION.get(wrapper_props_override)
126
+ preassumed_credentials = nil
127
+ if rds_type == Utils::RdsUrlType::RDS_GLOBAL_WRITER_CLUSTER && (explicit_region.nil? || explicit_region.to_s.empty?)
128
+ preassumed_credentials = obtain_credentials(saml_props, nil, idp_username, idp_password)
129
+ end
130
+
131
+ region = Utils::IamAuthUtils.region_for(
132
+ host:, props: wrapper_props_override, rds_type:,
133
+ credentials_provider: preassumed_credentials,
134
+ rds_client_func: preassumed_credentials ? -> { Aws::RDS::Client.new(credentials: preassumed_credentials) } : nil
135
+ )
136
+ unless region
137
+ raise Errors::SamlAuthError,
138
+ "#{self.class.name}: unable to determine connection region. " \
139
+ "If you are using a non-standard RDS URL, please set the 'iam_region' property."
140
+ end
141
+
142
+ fingerprint = Utils::SamlUtils.principal_fingerprint(
143
+ PropertyDefinition::IAM_ROLE_ARN.get(saml_props),
144
+ PropertyDefinition::IAM_IDP_ARN.get(saml_props)
145
+ )
146
+ cache_key = Utils::IamAuthUtils.cache_key(region, host, port, db_user, fingerprint)
147
+ entry = @service_container.storage_service.get(IAM_TOKEN_CACHE_NAME, cache_key)
148
+ expiration = PropertyDefinition::IAM_EXPIRATION_SEC.get_float(wrapper_props_override)
149
+ PropertyDefinition::IAM_EXPIRATION_SEC.validate!(expiration)
150
+
151
+ if Utils::IamAuthUtils.valid_entry?(entry)
152
+ token = entry.token
153
+ is_cached_token = true
154
+ else
155
+ token = fetch_and_cache_token(saml_props, region, host, port, db_user, cache_key, expiration,
156
+ idp_username, idp_password, preassumed_credentials)
157
+ is_cached_token = false
158
+ end
159
+
160
+ user_key = @service_container.dialect_service.driver_dialect.user_property_key
161
+ driver_props[user_key] = db_user
162
+ driver_props[:password] = token
163
+
164
+ begin
165
+ pipeline_callable.call
166
+ rescue StandardError => e
167
+ raise unless is_cached_token && @service_container.dialect_service.login_error?(e)
168
+
169
+ driver_props[:password] = fetch_and_cache_token(saml_props, region, host, port, db_user, cache_key,
170
+ expiration, idp_username, idp_password, nil)
171
+ pipeline_callable.call
172
+ end
173
+ end
174
+
175
+ # Mints a fresh RDS token signed with SAML-derived credentials and caches it.
176
+ def fetch_and_cache_token(saml_props, region, host, port, db_user, cache_key, expiration,
177
+ idp_username, idp_password, credentials)
178
+ credentials ||= obtain_credentials(saml_props, region, idp_username, idp_password)
179
+ @fetch_token_counter.inc
180
+ token = Utils::IamAuthUtils.generate_token(
181
+ region:, hostname: host, port:, user: db_user, credentials_provider: credentials,
182
+ telemetry_factory: @telemetry_factory
183
+ )
184
+ @service_container.storage_service.set(
185
+ IAM_TOKEN_CACHE_NAME, cache_key, Utils::IamAuthUtils.build_token_entry(token, expiration)
186
+ )
187
+ token
188
+ end
189
+
190
+ # Captures the connection's user/password as the IdP login when idp_username /
191
+ # idp_password are unset, persisting them into the wrapper props so the value survives
192
+ # the driver_props overwrite later in the flow and any subsequent reconnect. Never stores
193
+ # a nil (Concurrent::Map rejects nil values), so a connection with no fallback simply
194
+ # keeps idp_username / idp_password unset.
195
+ def persist_idp_credentials_fallback(saml_props, driver_props)
196
+ if PropertyDefinition::IDP_USERNAME.get(saml_props).nil?
197
+ fallback_user = driver_props[:user] || driver_props[:username]
198
+ PropertyDefinition::IDP_USERNAME.set(saml_props, fallback_user) unless fallback_user.nil?
199
+ end
200
+ return unless PropertyDefinition::IDP_PASSWORD.get(saml_props).nil?
201
+
202
+ fallback_password = driver_props[:password]
203
+ PropertyDefinition::IDP_PASSWORD.set(saml_props, fallback_password) unless fallback_password.nil?
204
+ end
205
+
206
+ # Fetches a SAML assertion from the IdP and exchanges it for AWS credentials.
207
+ def obtain_credentials(saml_props, region, idp_username, idp_password)
208
+ assertion = @telemetry_factory.in_context(self.class::TELEMETRY_FETCH_SAML, Utils::Telemetry::TraceLevel::NESTED,
209
+ record_failure: true) do
210
+ self.class.fetch_saml_assertion(**saml_fetch_options(saml_props, idp_username, idp_password))
211
+ end
212
+ self.class.credentials_from_saml(
213
+ assertion,
214
+ role_arn: PropertyDefinition::IAM_ROLE_ARN.get(saml_props),
215
+ idp_arn: PropertyDefinition::IAM_IDP_ARN.get(saml_props),
216
+ region:
217
+ )
218
+ rescue Errors::SamlAuthError
219
+ raise
220
+ rescue StandardError => e
221
+ # Surface IdP/STS failures (DNS, TLS, STS errors) as a SamlAuthError.
222
+ raise Errors::SamlAuthError, "#{self.class.name}: failed to obtain credentials via SAML: #{e.message}"
223
+ end
224
+
225
+ def validate_required!(saml_props)
226
+ missing = required_identity(saml_props).select { |_name, value| value.nil? || value.to_s.empty? }
227
+ return if missing.empty?
228
+
229
+ names = missing.map(&:first)
230
+ raise Errors::SamlAuthError,
231
+ "#{self.class.name}: #{names.join(', ')} #{names.length > 1 ? 'are' : 'is'} required"
232
+ end
233
+
234
+ # The properties that must be present for this IdP. Subclasses override to
235
+ # append their own (e.g. Okta's app_id).
236
+ def required_identity(saml_props)
237
+ [
238
+ ['db_user', PropertyDefinition::DB_USER.get(saml_props)],
239
+ ['idp_endpoint', PropertyDefinition::IDP_ENDPOINT.get(saml_props)],
240
+ ['iam_role_arn', PropertyDefinition::IAM_ROLE_ARN.get(saml_props)],
241
+ ['iam_idp_arn', PropertyDefinition::IAM_IDP_ARN.get(saml_props)]
242
+ ]
243
+ end
244
+
245
+ # The keyword arguments passed to .fetch_saml_assertion. Subclasses override.
246
+ def saml_fetch_options(saml_props, idp_username, idp_password)
247
+ {
248
+ idp_endpoint: PropertyDefinition::IDP_ENDPOINT.get(saml_props),
249
+ idp_username:,
250
+ idp_password:,
251
+ ssl_insecure: PropertyDefinition::SSL_INSECURE.get_bool(saml_props),
252
+ socket_timeout_sec: PropertyDefinition::HTTP_CLIENT_SOCKET_TIMEOUT_SEC.get_float(saml_props),
253
+ connect_timeout_sec: PropertyDefinition::HTTP_CLIENT_CONNECT_TIMEOUT_SEC.get_float(saml_props)
254
+ }
255
+ end
256
+
257
+ def ensure_aws_sdk!
258
+ require 'aws-sdk-rds'
259
+ rescue LoadError
260
+ raise LoadError,
261
+ "The SAML auth plugins require 'aws-sdk-rds'. Add it to your Gemfile: gem 'aws-sdk-rds'"
262
+ end
263
+ end
264
+ end
265
+ end
266
+ end
@@ -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