cloud_sql_ruby_connector 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright 2026 Martin Milo
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
+ # https://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 "net/http"
18
+ require "json"
19
+
20
+ module CloudSQLRubyConnector
21
+ module Credentials
22
+ # GCE/Cloud Run metadata server credentials
23
+ class Metadata < Base
24
+ METADATA_BASE = "http://169.254.169.254/computeMetadata/v1/instance/service-accounts/default"
25
+
26
+ protected
27
+
28
+ def refresh_token(scope)
29
+ uri = URI("#{METADATA_BASE}/token?scopes=#{SCOPES[scope]}")
30
+ http = Net::HTTP.new(uri.host, uri.port)
31
+ http.open_timeout = 2
32
+ http.read_timeout = 2
33
+
34
+ request = Net::HTTP::Get.new(uri)
35
+ request["Metadata-Flavor"] = "Google"
36
+
37
+ response = http.request(request)
38
+ data = JSON.parse(response.body)
39
+
40
+ raise AuthenticationError, "Failed to get metadata token: #{response.body}" unless response.code.to_i == 200
41
+
42
+ store_token(scope, data["access_token"], data["expires_in"])
43
+ rescue Errno::EHOSTUNREACH, Errno::ECONNREFUSED, Net::OpenTimeout, Net::ReadTimeout
44
+ raise AuthenticationError, "Not running on GCE/Cloud Run and no credentials found"
45
+ rescue JSON::ParserError => e
46
+ raise AuthenticationError, "Invalid metadata response: #{e.message}"
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright 2026 Martin Milo
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
+ # https://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 "openssl"
18
+ require "net/http"
19
+ require "json"
20
+ require "base64"
21
+
22
+ module CloudSQLRubyConnector
23
+ module Credentials
24
+ # Service Account credentials from JSON key file
25
+ class ServiceAccount < Base
26
+ attr_reader :client_email
27
+
28
+ # Load credentials from a JSON file
29
+ # @param path [String] path to the JSON key file
30
+ # @return [ServiceAccount, UserCredentials] depending on the credential type
31
+ def self.from_file(path)
32
+ data = JSON.parse(File.read(path))
33
+
34
+ if data["type"] == "authorized_user"
35
+ UserCredentials.new(data)
36
+ else
37
+ new(data)
38
+ end
39
+ end
40
+
41
+ def initialize(data)
42
+ super()
43
+ validate_required_fields!(data)
44
+ @client_email = data["client_email"]
45
+ @private_key = OpenSSL::PKey::RSA.new(data["private_key"])
46
+ end
47
+
48
+ private
49
+
50
+ def validate_required_fields!(data)
51
+ raise ConfigurationError, "Missing 'client_email' in service account credentials" if data["client_email"].nil?
52
+ raise ConfigurationError, "Missing 'private_key' in service account credentials" if data["private_key"].nil?
53
+ end
54
+
55
+ protected
56
+
57
+ def refresh_token(scope)
58
+ now = Time.now.to_i
59
+ payload = {
60
+ iss: @client_email,
61
+ scope: SCOPES[scope],
62
+ aud: TOKEN_URI,
63
+ iat: now,
64
+ exp: now + 3600
65
+ }
66
+
67
+ jwt = encode_jwt(payload)
68
+
69
+ uri = URI(TOKEN_URI)
70
+ http = Net::HTTP.new(uri.host, uri.port)
71
+ http.use_ssl = true
72
+ http.open_timeout = 10
73
+ http.read_timeout = 10
74
+
75
+ request = Net::HTTP::Post.new(uri)
76
+ request["Content-Type"] = "application/x-www-form-urlencoded"
77
+ request.body = URI.encode_www_form(
78
+ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
79
+ assertion: jwt
80
+ )
81
+
82
+ response = http.request(request)
83
+ data = JSON.parse(response.body)
84
+
85
+ unless response.code.to_i == 200
86
+ raise AuthenticationError, "Failed to get access token: #{data["error_description"] || data["error"]}"
87
+ end
88
+
89
+ store_token(scope, data["access_token"], data["expires_in"])
90
+ rescue Net::OpenTimeout, Net::ReadTimeout => e
91
+ raise AuthenticationError, "Token request timed out: #{e.message}"
92
+ rescue JSON::ParserError => e
93
+ raise AuthenticationError, "Invalid token response: #{e.message}"
94
+ end
95
+
96
+ private
97
+
98
+ def encode_jwt(payload)
99
+ header = { alg: "RS256", typ: "JWT" }
100
+ segments = [
101
+ base64url_encode(header.to_json),
102
+ base64url_encode(payload.to_json)
103
+ ]
104
+ signing_input = segments.join(".")
105
+ signature = @private_key.sign("SHA256", signing_input)
106
+ segments << base64url_encode(signature)
107
+ segments.join(".")
108
+ end
109
+
110
+ def base64url_encode(data)
111
+ Base64.urlsafe_encode64(data, padding: false)
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright 2026 Martin Milo
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
+ # https://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 "net/http"
18
+ require "json"
19
+
20
+ module CloudSQLRubyConnector
21
+ module Credentials
22
+ # User credentials from gcloud auth application-default login
23
+ class UserCredentials < Base
24
+ def initialize(data)
25
+ super()
26
+ validate_required_fields!(data)
27
+ @client_id = data["client_id"]
28
+ @client_secret = data["client_secret"]
29
+ @refresh_token_value = data["refresh_token"]
30
+ end
31
+
32
+ private
33
+
34
+ def validate_required_fields!(data)
35
+ raise ConfigurationError, "Missing 'client_id' in user credentials" if data["client_id"].nil?
36
+ raise ConfigurationError, "Missing 'client_secret' in user credentials" if data["client_secret"].nil?
37
+ raise ConfigurationError, "Missing 'refresh_token' in user credentials" if data["refresh_token"].nil?
38
+ end
39
+
40
+ protected
41
+
42
+ def refresh_token(scope)
43
+ uri = URI(TOKEN_URI)
44
+ http = Net::HTTP.new(uri.host, uri.port)
45
+ http.use_ssl = true
46
+ http.open_timeout = 10
47
+ http.read_timeout = 10
48
+
49
+ request = Net::HTTP::Post.new(uri)
50
+ request["Content-Type"] = "application/x-www-form-urlencoded"
51
+ request.body = URI.encode_www_form(
52
+ client_id: @client_id,
53
+ client_secret: @client_secret,
54
+ refresh_token: @refresh_token_value,
55
+ grant_type: "refresh_token"
56
+ )
57
+
58
+ response = http.request(request)
59
+ data = JSON.parse(response.body)
60
+
61
+ unless response.code.to_i == 200
62
+ raise AuthenticationError, "Failed to refresh token: #{data["error_description"] || data["error"]}"
63
+ end
64
+
65
+ store_token(scope, data["access_token"], data["expires_in"])
66
+ rescue Net::OpenTimeout, Net::ReadTimeout => e
67
+ raise AuthenticationError, "Token refresh timed out: #{e.message}"
68
+ rescue JSON::ParserError => e
69
+ raise AuthenticationError, "Invalid token response: #{e.message}"
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright 2026 Martin Milo
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
+ # https://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
+ module CloudSQLRubyConnector
18
+ # Base error class for all Cloud SQL Connector errors
19
+ class Error < StandardError
20
+ attr_reader :code
21
+
22
+ def initialize(message, code: nil)
23
+ @code = code
24
+ super(message)
25
+ end
26
+ end
27
+
28
+ # Raised when authentication fails
29
+ class AuthenticationError < Error
30
+ def initialize(message, code: "EAUTH")
31
+ super
32
+ end
33
+ end
34
+
35
+ # Raised when connection to Cloud SQL instance fails
36
+ class ConnectionError < Error
37
+ def initialize(message, code: "ECONNECTION")
38
+ super
39
+ end
40
+ end
41
+
42
+ # Raised when configuration is invalid
43
+ class ConfigurationError < Error
44
+ def initialize(message, code: "ECONFIG")
45
+ super
46
+ end
47
+ end
48
+
49
+ # Raised when IP address is not available
50
+ class IpAddressError < Error
51
+ def initialize(message, code: "ENOIPADDRESS")
52
+ super
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright 2026 Martin Milo
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
+ # https://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
+ module CloudSQLRubyConnector
18
+ # IP address types for Cloud SQL connections
19
+ module IpAddressTypes
20
+ PUBLIC = "PUBLIC"
21
+ PRIVATE = "PRIVATE"
22
+ PSC = "PSC"
23
+
24
+ ALL = [PUBLIC, PRIVATE, PSC].freeze
25
+
26
+ class << self
27
+ def valid?(type)
28
+ ALL.include?(type.to_s.upcase)
29
+ end
30
+
31
+ def normalize(type)
32
+ normalized = type.to_s.upcase
33
+ unless valid?(normalized)
34
+ raise ConfigurationError,
35
+ "Invalid IP address type: #{type}. Valid types: #{ALL.join(", ")}"
36
+ end
37
+
38
+ normalized
39
+ end
40
+
41
+ # Returns the API key used in Cloud SQL Admin API response
42
+ def api_key(type)
43
+ case normalize(type)
44
+ when PUBLIC then "PRIMARY"
45
+ when PRIVATE then "PRIVATE"
46
+ when PSC then "PSC"
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,255 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright 2026 Martin Milo
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
+ # https://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 "openssl"
18
+ require "socket"
19
+
20
+ module CloudSQLRubyConnector
21
+ module PostgreSQL
22
+ # PostgreSQL connector for Cloud SQL connections
23
+ #
24
+ # Provides secure, authenticated connections to Cloud SQL PostgreSQL instances
25
+ # using ephemeral certificates and optional IAM authentication.
26
+ #
27
+ # @example Basic usage
28
+ # connector = CloudSQLRubyConnector::PostgreSQL::Connector.new("my-project:us-central1:my-instance")
29
+ # conn = connector.connect(user: "myuser", password: "mypass", dbname: "mydb")
30
+ # result = conn.exec("SELECT NOW()")
31
+ # conn.close
32
+ # connector.close
33
+ #
34
+ # @example Using IAM authentication
35
+ # connector = CloudSQLRubyConnector::PostgreSQL::Connector.new(
36
+ # "my-project:us-central1:my-instance",
37
+ # auth_type: :iam
38
+ # )
39
+ # conn = connector.connect(user: "sa@project.iam", dbname: "mydb")
40
+ #
41
+ class Connector
42
+ CLOUD_SQL_PORT = 3307
43
+ CERT_REFRESH_BUFFER = 300 # Refresh certificate 5 minutes before expiration
44
+
45
+ attr_reader :project, :region, :instance_name, :ip_type, :auth_type
46
+
47
+ # Initialize a new connector
48
+ #
49
+ # @param instance_connection_name [String] Cloud SQL instance connection name (PROJECT:REGION:INSTANCE)
50
+ # @param credentials [Credentials::Base] Optional credentials object
51
+ # @param ip_type [String, Symbol] IP address type: :public, :private, or :psc (default: :public)
52
+ # @param auth_type [String, Symbol] Authentication type: :password or :iam (default: :password)
53
+ # @param api_endpoint [String] Optional custom API endpoint
54
+ def initialize(instance_connection_name, credentials: nil, ip_type: IpAddressTypes::PUBLIC,
55
+ auth_type: AuthTypes::PASSWORD, api_endpoint: nil)
56
+ @project, @region, @instance_name = parse_connection_name(instance_connection_name)
57
+ @ip_type = IpAddressTypes.normalize(ip_type)
58
+ @auth_type = AuthTypes.normalize(auth_type)
59
+ @credentials = credentials || default_credentials
60
+ @api_endpoint = api_endpoint
61
+
62
+ # Generate RSA key pair once per connector instance
63
+ @private_key, @public_key = generate_keys
64
+
65
+ # Certificate cache with expiration tracking
66
+ @cached_info = nil
67
+ @cert_expiration = Time.at(0)
68
+ @lock = Mutex.new
69
+
70
+ # SQL Admin API fetcher
71
+ @fetcher = SQLAdminFetcher.new(credentials: @credentials, api_endpoint: @api_endpoint)
72
+
73
+ # Track active proxies for cleanup
74
+ @proxies = []
75
+ end
76
+
77
+ # Create a connected PG::Connection
78
+ #
79
+ # @param user [String] Database username
80
+ # @param password [String] Database password (optional for IAM auth)
81
+ # @param dbname [String] Database name
82
+ # @param extra_options [Hash] Additional options to pass to PG.connect
83
+ # @return [PG::Connection] Connected PostgreSQL connection
84
+ def connect(user:, dbname:, password: nil, **extra_options)
85
+ require "pg"
86
+
87
+ validate_connection_params!(user: user)
88
+ conn_info = ensure_valid_connection_info!
89
+
90
+ effective_user = @auth_type == AuthTypes::IAM ? format_iam_user(user) : user
91
+ effective_password = @auth_type == AuthTypes::IAM ? @credentials.access_token(scope: :login) : password
92
+
93
+ ssl_socket = create_ssl_connection(conn_info)
94
+ proxy = SslProxy.new(ssl_socket)
95
+ proxy.start
96
+ @lock.synchronize { @proxies << proxy }
97
+
98
+ begin
99
+ PG.connect(
100
+ host: "127.0.0.1",
101
+ port: proxy.port,
102
+ user: effective_user,
103
+ password: effective_password,
104
+ dbname: dbname,
105
+ sslmode: "disable",
106
+ **extra_options
107
+ )
108
+ rescue StandardError => e
109
+ proxy.stop
110
+ raise e
111
+ end
112
+ end
113
+
114
+ # Get connection options that can be used with PG.connect
115
+ # This is an alternative to using #connect directly
116
+ #
117
+ # @return [Hash] Connection options including :stream proc
118
+ def get_options
119
+ conn_info = ensure_valid_connection_info!
120
+
121
+ {
122
+ stream: -> { create_ssl_connection(conn_info) },
123
+ ip_address: conn_info[:ip_address],
124
+ server_ca_cert: conn_info[:server_ca_cert],
125
+ client_cert: conn_info[:client_cert],
126
+ private_key: conn_info[:private_key]
127
+ }
128
+ end
129
+
130
+ # Get the IP address for the instance
131
+ #
132
+ # @return [String] IP address
133
+ def ip_address
134
+ ensure_valid_connection_info![:ip_address]
135
+ end
136
+
137
+ # Close the connector and clean up resources
138
+ def close
139
+ @lock.synchronize do
140
+ @proxies.each(&:stop)
141
+ @proxies.clear
142
+ @cached_info = nil
143
+ end
144
+ end
145
+
146
+ private
147
+
148
+ def parse_connection_name(name)
149
+ parts = name.to_s.split(":")
150
+ unless parts.length == 3
151
+ raise ConfigurationError,
152
+ "Invalid instance connection name '#{name}'. Expected format: PROJECT:REGION:INSTANCE"
153
+ end
154
+
155
+ parts
156
+ end
157
+
158
+ def generate_keys
159
+ key = OpenSSL::PKey::RSA.new(2048)
160
+ [key.to_pem, key.public_key.to_pem]
161
+ end
162
+
163
+ def default_credentials
164
+ if ENV["GOOGLE_APPLICATION_CREDENTIALS"]
165
+ return Credentials::ServiceAccount.from_file(ENV["GOOGLE_APPLICATION_CREDENTIALS"])
166
+ end
167
+
168
+ gcloud_path = File.expand_path("~/.config/gcloud/application_default_credentials.json")
169
+ return Credentials::ServiceAccount.from_file(gcloud_path) if File.exist?(gcloud_path)
170
+
171
+ Credentials::Metadata.new
172
+ rescue JSON::ParserError => e
173
+ raise ConfigurationError, "Invalid credentials file: #{e.message}"
174
+ end
175
+
176
+ def ensure_valid_connection_info!
177
+ @lock.synchronize do
178
+ refresh_connection_info! if @cached_info.nil? || Time.now > (@cert_expiration - CERT_REFRESH_BUFFER)
179
+ @cached_info
180
+ end
181
+ end
182
+
183
+ def refresh_connection_info!
184
+ metadata = @fetcher.fetch_metadata(
185
+ project: @project,
186
+ region: @region,
187
+ instance: @instance_name
188
+ )
189
+
190
+ cert_data = @fetcher.fetch_ephemeral_cert(
191
+ project: @project,
192
+ instance: @instance_name,
193
+ public_key: @public_key,
194
+ auth_type: @auth_type
195
+ )
196
+
197
+ ip_address = select_ip_address(metadata[:ip_addresses])
198
+
199
+ @cert_expiration = cert_data[:expiration]
200
+ @cached_info = {
201
+ ip_address: ip_address,
202
+ server_ca_cert: metadata[:server_ca_cert],
203
+ client_cert: cert_data[:cert],
204
+ private_key: @private_key
205
+ }
206
+ end
207
+
208
+ def select_ip_address(ip_addresses)
209
+ api_key = IpAddressTypes.api_key(@ip_type)
210
+ ip = ip_addresses[api_key]
211
+
212
+ unless ip
213
+ raise IpAddressError.new(
214
+ "Cannot connect to instance, #{@ip_type} IP address not found",
215
+ code: "ENO#{@ip_type}IPADDRESS"
216
+ )
217
+ end
218
+
219
+ ip
220
+ end
221
+
222
+ def validate_connection_params!(user:)
223
+ raise ConfigurationError, "User is required for database connection" if user.nil? || user.empty?
224
+ end
225
+
226
+ def format_iam_user(user)
227
+ suffix = ".gserviceaccount.com"
228
+ user.end_with?(suffix) ? user[0...-suffix.length] : user
229
+ end
230
+
231
+ def create_ssl_connection(conn_info)
232
+ ssl_context = OpenSSL::SSL::SSLContext.new
233
+ ssl_context.min_version = OpenSSL::SSL::TLS1_3_VERSION
234
+ ssl_context.verify_mode = OpenSSL::SSL::VERIFY_PEER
235
+
236
+ ssl_context.cert = OpenSSL::X509::Certificate.new(conn_info[:client_cert])
237
+ ssl_context.key = OpenSSL::PKey::RSA.new(conn_info[:private_key])
238
+
239
+ cert_store = OpenSSL::X509::Store.new
240
+ cert_store.add_cert(OpenSSL::X509::Certificate.new(conn_info[:server_ca_cert]))
241
+ ssl_context.cert_store = cert_store
242
+
243
+ tcp_socket = Socket.tcp(conn_info[:ip_address], CLOUD_SQL_PORT, connect_timeout: 30)
244
+
245
+ ssl_socket = OpenSSL::SSL::SSLSocket.new(tcp_socket, ssl_context)
246
+ ssl_socket.sync_close = true
247
+ ssl_socket.connect
248
+
249
+ ssl_socket
250
+ rescue Errno::ETIMEDOUT, Errno::ECONNREFUSED, Errno::EHOSTUNREACH => e
251
+ raise ConnectionError, "Failed to connect to Cloud SQL instance: #{e.message}"
252
+ end
253
+ end
254
+ end
255
+ end