rails-pg-managed-identity 0.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c9fc9ac2aab390952a05fe1ee9473b6b15474216ea9684a7cca163f1201193f2
4
+ data.tar.gz: 89cbc58e3bbd6c1d6351cf8231fa4b6410f9c09494fc7a71d89a44a975e4efcb
5
+ SHA512:
6
+ metadata.gz: 70c882a93e9b9cdaf6020c9830e67d9bc8124e01d8e3a93622e9a5192d9b7698831bd648779400ba605c7eafcbf0e69e35f322c54890a46cf1b11585023a77ed
7
+ data.tar.gz: 6e34664a2acfc61f760f13de130cefa4a8a0667f52ba76ca3e0b8b6f38bf3f4371c43061025989c5e946606b0c2654e9ced9f2303485a60019f580b8b81cc26b
data/CHANGELOG.md ADDED
@@ -0,0 +1,14 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2026-10-15
4
+
5
+ - Initial release. Supports Rails 7.2, 8.0 and 8.1 on Ruby 3.3+.
6
+ - `azure_managed_identity` option in `config/database.yml` for the PostgreSQL adapter: the password is a
7
+ Microsoft Entra access token fetched when a connection is opened, cached, and refreshed 5 minutes before
8
+ it expires.
9
+ - Credentials: managed identity (App Service, Functions, Container Apps, IMDS on VMs, scale sets, AKS
10
+ nodes), AKS workload identity, service principal with a client secret, and the Azure CLI, chained like
11
+ azure-identity's `DefaultAzureCredential`.
12
+ - Reconnects open a new connection with the current token instead of `PG::Connection#reset`.
13
+ - `db:schema:dump`/`db:schema:load` with `structure.sql` and `bin/rails dbconsole` get the token too.
14
+ - Tokens never show up in logs, `inspect` or `pp`/IRB output.
@@ -0,0 +1,10 @@
1
+ # Code of Conduct
2
+
3
+ "rails-pg-managed-identity" follows [The Ruby Community Conduct Guideline](https://www.ruby-lang.org/en/conduct) in all "collaborative space", which is defined as community communications channels (such as mailing lists, submitted patches, commit comments, etc.):
4
+
5
+ * Participants will be tolerant of opposing views.
6
+ * Participants must ensure that their language and actions are free of personal attacks and disparaging personal remarks.
7
+ * When interpreting the words and actions of others, participants should always assume good intentions.
8
+ * Behaviour which can be reasonably considered harassment will not be tolerated.
9
+
10
+ If you have any concerns about behaviour within this project, please contact us at ["fst@last.dk"](mailto:"fst@last.dk").
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Frederik Erbs Spang Thomsen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,104 @@
1
+ # rails-pg-managed-identity
2
+
3
+ > [!WARNING]
4
+ > Not yet published. Not yet tested in real life world.
5
+
6
+ Passwordless connections from Rails (7.2+, Ruby 3.3+) to **Azure Database for PostgreSQL flexible server** using Microsoft Entra access tokens:
7
+ the app's managed identity, AKS workload identity, a service principal, or your `az login` during development.
8
+
9
+ ## Installation
10
+
11
+ ```ruby
12
+ # Gemfile
13
+ gem "rails-pg-managed-identity"
14
+ ```
15
+
16
+ ## Usage
17
+
18
+ Add `azure_managed_identity` to the database configuration and leave out the password:
19
+
20
+ ```yaml
21
+ # config/database.yml
22
+ production:
23
+ adapter: postgresql
24
+ host: my-server.postgres.database.azure.com
25
+ database: my_app
26
+ username: my-app-identity # the identity's PostgreSQL role, see "Azure setup"
27
+ sslmode: require
28
+ azure_managed_identity: true
29
+ ```
30
+
31
+ That's it. Booting the app requests nothing; the first query opens a connection, which fetches a token.
32
+ Every connection in every pool shares the cached token.
33
+
34
+ Options, instead of `true`:
35
+
36
+ ```yaml
37
+ azure_managed_identity:
38
+ client_id: <%= ENV["AZURE_CLIENT_ID"] %> # user-assigned managed identity (default: AZURE_CLIENT_ID)
39
+ credential: managed_identity # use only this credential (default: "default", the chain below)
40
+ scope: https://ossrdbms-aad.database.usgovcloudapi.net/.default # other clouds
41
+ ```
42
+
43
+ `DATABASE_URL` works too: `postgres://my-app-identity@my-server.postgres.database.azure.com/my_app?sslmode=require&azure_managed_identity=true`.
44
+
45
+ ### Where tokens come from
46
+
47
+ `credential: default` tries these in order, like azure-identity's `DefaultAzureCredential`, and sticks
48
+ with the first one that works:
49
+
50
+ | `credential:` | Where | Configured by |
51
+ | --- | --- | --- |
52
+ | `environment` | Service principal with a secret, anywhere | `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET` |
53
+ | `workload_identity` | AKS with Microsoft Entra Workload ID | `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_FEDERATED_TOKEN_FILE` (injected by the webhook) |
54
+ | `managed_identity` | App Service, Functions, Container Apps | `IDENTITY_ENDPOINT`, `IDENTITY_HEADER` (set by the platform) |
55
+ | `managed_identity` | VMs, scale sets, AKS nodes | Instance Metadata Service at `169.254.169.254` |
56
+ | `azure_cli` | Your machine | `az login` |
57
+
58
+ Azure Arc and Service Fabric managed identities aren't supported.
59
+
60
+ ### Azure setup
61
+
62
+ 1. Enable Microsoft Entra authentication on the server and add yourself as Microsoft Entra administrator.
63
+ 2. Give the app an identity (system- or user-assigned managed identity, or workload identity).
64
+ 3. As the Entra administrator, connected to the `postgres` database, create a role for the identity, named after it:
65
+
66
+ ```sql
67
+ select * from pgaadauth_create_principal('my-app-identity', false, false);
68
+ ```
69
+
70
+ 4. Grant that role what the app needs, and use its name as `username` in `database.yml`.
71
+
72
+ For local development against Azure, `az login` and use your own user principal name (or an Entra group you're a member of) as `username`.
73
+
74
+ ### Token lifecycle
75
+
76
+ - Tokens are fetched lazily, when a connection is opened, and reused until 5 minutes before expiry (`RailsPg::ManagedIdentity.refresh_offset = 300`).
77
+ - While the cached token is still valid, a new one is requested at most every 30 seconds.
78
+ If that request fails, the cached token is used and the failure is logged. A token is never handed out with less than 10 seconds left.
79
+
80
+ `bin/rails dbconsole`, and `db:schema:dump`/`db:schema:load` with `structure.sql` (`pg_dump`/`psql`), get the token through `PGPASSWORD`.
81
+ The database tasks are only hooked when rake tasks are loaded, so app and job processes don't load that code.
82
+
83
+ ## Development
84
+
85
+ ```bash
86
+ bin/setup
87
+ bundle exec rake # tests and RuboCop
88
+
89
+ # Against another supported Rails version (CI runs Ruby 3.3, 3.4 and 4.0 with Rails 7.2, 8.0 and 8.1):
90
+ BUNDLE_GEMFILE=gemfiles/rails_7.2.gemfile bundle install
91
+ BUNDLE_GEMFILE=gemfiles/rails_7.2.gemfile bundle exec rake test
92
+ ```
93
+
94
+ The tests need PostgreSQL's server binaries (`initdb`, `pg_ctl`); they're found through `pg_config`, or set `PG_BINDIR`.
95
+ No running server or Azure account is needed:
96
+
97
+ ## Contributing
98
+
99
+ Bug reports and pull requests are welcome. This project is intended to be a safe, welcoming space for
100
+ collaboration, and contributors are expected to adhere to the [code of conduct](CODE_OF_CONDUCT.md).
101
+
102
+ ## License
103
+
104
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "minitest/test_task"
5
+
6
+ Minitest::TestTask.create
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[test rubocop]
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # CI runs the tests against each supported Rails version: BUNDLE_GEMFILE=gemfiles/rails_7.2.gemfile
4
+ eval_gemfile "../Gemfile"
5
+
6
+ gem "activerecord", "~> 7.2.0"
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # CI runs the tests against each supported Rails version: BUNDLE_GEMFILE=gemfiles/rails_8.0.gemfile
4
+ eval_gemfile "../Gemfile"
5
+
6
+ gem "activerecord", "~> 8.0.0"
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # CI runs the tests against each supported Rails version: BUNDLE_GEMFILE=gemfiles/rails_8.1.gemfile
4
+ eval_gemfile "../Gemfile"
5
+
6
+ gem "activerecord", "~> 8.1.0"
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Bundler requires a gem by its name; the code lives in RailsPg::ManagedIdentity.
4
+ require_relative "rails_pg/managed_identity"
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module RailsPg
6
+ module ManagedIdentity
7
+ # An access token and its expiry in seconds since the epoch.
8
+ AccessToken = Data.define(:token, :expires_on) do
9
+ # Builds a token from a JSON token response. Managed identity endpoints send expires_on,
10
+ # Microsoft Entra ID sends expires_in.
11
+ def self.from_response(response, source)
12
+ unless response.status == 200
13
+ raise AuthenticationError, "#{source} returned HTTP #{response.status}: #{response.error_message}"
14
+ end
15
+
16
+ token = response.json["access_token"]
17
+ expires_on = Integer(response.json["expires_on"], exception: false)
18
+ expires_in = Integer(response.json["expires_in"], exception: false)
19
+ expires_on ||= Time.now.to_i + expires_in if expires_in
20
+ raise AuthenticationError, "#{source} returned an incomplete token response" unless token && expires_on
21
+
22
+ new(token:, expires_on:)
23
+ end
24
+
25
+ def expires_in
26
+ expires_on - Time.now.to_i
27
+ end
28
+
29
+ # Never show the token itself in logs, consoles or exception messages.
30
+ def inspect
31
+ "#<#{self.class.name} expires_on=#{Time.at(expires_on).utc.iso8601}>"
32
+ end
33
+ alias_method :to_s, :inspect
34
+
35
+ # pp and IRB use this rather than #inspect.
36
+ def pretty_print(printer)
37
+ printer.text(inspect)
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsPg
4
+ module ManagedIdentity
5
+ # Prepended to ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.
6
+ module PostgreSQLAdapterExtension
7
+ private
8
+
9
+ # Every physical connection, first or re-established, is opened here, so this is where
10
+ # the password is resolved.
11
+ def connect
12
+ if (provider = ManagedIdentity.token_provider_for(@config))
13
+ @connection_parameters = @connection_parameters.merge(password: provider.token)
14
+ end
15
+ super
16
+ rescue Error => e
17
+ raise e.set_pool(@pool)
18
+ end
19
+
20
+ # The stock implementation tries PG::Connection#reset first, which re-authenticates with the
21
+ # password the connection was first opened with, possibly an expired token by now. Without a
22
+ # raw connection it goes straight to #connect.
23
+ def reconnect
24
+ if ManagedIdentity.token_provider_for(@config)
25
+ begin
26
+ @raw_connection&.close
27
+ rescue ::PG::Error
28
+ nil
29
+ end
30
+ @raw_connection = nil
31
+ end
32
+ super
33
+ end
34
+ end
35
+
36
+ # Extends PostgreSQLAdapter's class methods.
37
+ module PostgreSQLAdapterClassExtension
38
+ # `bin/rails dbconsole`: psql can't prompt for a password as long as an access token, so it
39
+ # always gets the token through PGPASSWORD.
40
+ def dbconsole(config, options = {})
41
+ if (provider = ManagedIdentity.token_provider_for(config.configuration_hash))
42
+ ENV["PGPASSWORD"] = provider.token
43
+ end
44
+ super
45
+ end
46
+ end
47
+ end
48
+ end
49
+
50
+ ActiveSupport.on_load(:active_record_postgresqladapter) do
51
+ prepend RailsPg::ManagedIdentity::PostgreSQLAdapterExtension
52
+ singleton_class.prepend RailsPg::ManagedIdentity::PostgreSQLAdapterClassExtension
53
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module RailsPg
6
+ module ManagedIdentity
7
+ # The account signed in to the Azure CLI (`az login`), for local development.
8
+ class AzureCliCredential
9
+ # `az` is usually a shell script running Python. In its own process group, a timeout can kill both.
10
+ SPAWN_OPTIONS = Gem.win_platform? ? {}.freeze : { pgroup: true }.freeze
11
+
12
+ def initialize(process_timeout: 10)
13
+ @process_timeout = process_timeout
14
+ end
15
+
16
+ def get_token(scope)
17
+ stdout, stderr, status = run("az", "account", "get-access-token", "--output", "json", "--scope", scope)
18
+ unless status.success?
19
+ raise CredentialUnavailableError, "Azure CLI isn't signed in; run `az login`" if stderr.include?("az login")
20
+
21
+ raise AuthenticationError, "Azure CLI failed: #{stderr.strip.lines.first}"
22
+ end
23
+
24
+ parse(stdout)
25
+ end
26
+
27
+ private
28
+
29
+ def run(*command)
30
+ Open3.popen3(*command, **SPAWN_OPTIONS) do |stdin, stdout, stderr, wait_thread|
31
+ stdin.close
32
+ out = reader(stdout)
33
+ err = reader(stderr)
34
+ unless wait_thread.join(@process_timeout)
35
+ kill(wait_thread.pid)
36
+ raise AuthenticationError, "Azure CLI didn't respond within #{@process_timeout} seconds"
37
+ end
38
+ [out.value, err.value, wait_thread.value]
39
+ end
40
+ rescue Errno::ENOENT
41
+ raise CredentialUnavailableError, "Azure CLI (az) not found on PATH"
42
+ end
43
+
44
+ # On a timeout the pipes are closed under the reader; that's expected, not worth a report.
45
+ def reader(io)
46
+ Thread.new do
47
+ Thread.current.report_on_exception = false
48
+ io.read
49
+ end
50
+ end
51
+
52
+ def kill(pid)
53
+ Process.kill("KILL", SPAWN_OPTIONS[:pgroup] ? -pid : pid)
54
+ rescue Errno::ESRCH
55
+ nil # exited in the meantime
56
+ end
57
+
58
+ # Azure CLI 2.54+ sends expires_on (epoch seconds); older versions only expiresOn in local time.
59
+ def parse(stdout)
60
+ json = JSON.parse(stdout)
61
+ expires_on = Integer(json["expires_on"], exception: false) || Time.parse(json.fetch("expiresOn")).to_i
62
+ AccessToken.new(token: json.fetch("accessToken"), expires_on:)
63
+ rescue JSON::ParserError, KeyError, ArgumentError => e
64
+ raise AuthenticationError, "Unexpected output from Azure CLI: #{e.message}"
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsPg
4
+ module ManagedIdentity
5
+ # Tries credentials in the same order as azure-identity's DefaultAzureCredential (minus the
6
+ # ones that need a desktop or an IDE): environment, workload identity, managed identity,
7
+ # Azure CLI. The first credential that returns a token is used from then on.
8
+ class DefaultAzureCredential
9
+ def initialize(client_id: nil)
10
+ @credentials = [
11
+ EnvironmentCredential.new,
12
+ WorkloadIdentityCredential.new(client_id:),
13
+ ManagedIdentityCredential.new(client_id:),
14
+ AzureCliCredential.new
15
+ ]
16
+ @selected = nil
17
+ end
18
+
19
+ def get_token(scope)
20
+ return @selected.get_token(scope) if @selected
21
+
22
+ attempts = []
23
+ @credentials.each do |credential|
24
+ token = credential.get_token(scope)
25
+ @selected = credential
26
+ ManagedIdentity.logger&.info { "[#{self.class.name}] Using #{credential.class.name.demodulize}" }
27
+ return token
28
+ rescue Error => e
29
+ attempts << "#{credential.class.name.demodulize}: #{e.message}"
30
+ # Unavailable means not applicable here, so try the next one; any other failure stops the
31
+ # chain, like azure-identity does.
32
+ raise AuthenticationError, failure_message(attempts) unless e.is_a?(CredentialUnavailableError)
33
+ end
34
+ raise CredentialUnavailableError, failure_message(attempts)
35
+ end
36
+
37
+ private
38
+
39
+ def failure_message(attempts)
40
+ "DefaultAzureCredential failed to get an access token:\n #{attempts.join("\n ")}"
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsPg
4
+ module ManagedIdentity
5
+ # OAuth 2.0 client credentials requests against Microsoft Entra ID's v2.0 token endpoint.
6
+ module EntraID
7
+ DEFAULT_AUTHORITY_HOST = "https://login.microsoftonline.com"
8
+
9
+ # params authenticate the client: client_secret, or client_assertion and client_assertion_type.
10
+ def self.client_credentials_token(tenant_id:, client_id:, scope:, **params)
11
+ uri = URI("#{authority_host}/#{tenant_id}/oauth2/v2.0/token")
12
+ form = { grant_type: "client_credentials", client_id:, scope:, **params }
13
+ AccessToken.from_response(HTTP.post_form(uri, form), "Microsoft Entra ID")
14
+ end
15
+
16
+ # AZURE_AUTHORITY_HOST selects a sovereign cloud; the AKS workload identity webhook sets it too.
17
+ def self.authority_host
18
+ host = ENV["AZURE_AUTHORITY_HOST"].to_s.strip.presence || DEFAULT_AUTHORITY_HOST
19
+ host = "https://#{host}" unless host.include?("://")
20
+ host.chomp("/")
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsPg
4
+ module ManagedIdentity
5
+ # A service principal with a client secret, configured through AZURE_TENANT_ID,
6
+ # AZURE_CLIENT_ID and AZURE_CLIENT_SECRET.
7
+ class EnvironmentCredential
8
+ def get_token(scope)
9
+ tenant_id, client_id, client_secret = ENV.values_at("AZURE_TENANT_ID", "AZURE_CLIENT_ID", "AZURE_CLIENT_SECRET")
10
+ if [tenant_id, client_id, client_secret].any?(&:blank?)
11
+ raise CredentialUnavailableError,
12
+ "EnvironmentCredential requires AZURE_TENANT_ID, AZURE_CLIENT_ID and AZURE_CLIENT_SECRET"
13
+ end
14
+
15
+ EntraID.client_credentials_token(tenant_id:, client_id:, scope:, client_secret:)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "openssl"
6
+
7
+ module RailsPg
8
+ module ManagedIdentity
9
+ # Minimal JSON-over-HTTP client for the token endpoints, with retries for transient failures.
10
+ module HTTP
11
+ NETWORK_ERRORS = [SystemCallError, IOError, SocketError, Timeout::Error, OpenSSL::SSL::SSLError].freeze
12
+ # The endpoint couldn't be reached at all.
13
+ class UnreachableError < AuthenticationError; end
14
+ RETRY_STATUSES = [429, 500, 502, 503, 504].freeze
15
+ MAX_RETRIES = 2
16
+
17
+ Response = Data.define(:status, :json, :body) do
18
+ def error_message
19
+ json["error_description"] || json["message"] || json["error"] || body.to_s[0, 200]
20
+ end
21
+ end
22
+
23
+ @retry_delay = 0.5
24
+
25
+ class << self
26
+ # Base delay in seconds before retrying; doubled for every further attempt.
27
+ attr_accessor :retry_delay
28
+
29
+ # Pass proxy: false for managed identity endpoints: they're local to the host, and Microsoft
30
+ # doesn't support reaching IMDS through a proxy.
31
+ def get(uri, headers: {}, open_timeout: 5, retry_statuses: RETRY_STATUSES, proxy: true)
32
+ request(Net::HTTP::Get.new(uri, headers), open_timeout:, retry_statuses:, proxy:)
33
+ end
34
+
35
+ def post_form(uri, form)
36
+ post = Net::HTTP::Post.new(uri)
37
+ post.set_form_data(form)
38
+ request(post, open_timeout: 5, retry_statuses: RETRY_STATUSES, proxy: true)
39
+ end
40
+
41
+ private
42
+
43
+ def request(request, open_timeout:, retry_statuses:, proxy:)
44
+ attempt = 0
45
+ loop do
46
+ response = send_request(request, open_timeout:, proxy:)
47
+ status = response.code.to_i
48
+ if retry_statuses.include?(status) && attempt < MAX_RETRIES
49
+ sleep(retry_delay * (2**attempt))
50
+ attempt += 1
51
+ next
52
+ end
53
+ return Response.new(status:, json: parse(response.body), body: response.body)
54
+ end
55
+ end
56
+
57
+ def send_request(request, open_timeout:, proxy:)
58
+ uri = request.uri
59
+ options = { use_ssl: uri.scheme == "https", open_timeout:, read_timeout: 10 }
60
+ Net::HTTP.start(uri.host, uri.port, (proxy ? :ENV : nil), options) { |http| http.request(request) }
61
+ rescue *NETWORK_ERRORS => e
62
+ raise UnreachableError, "#{uri.host}:#{uri.port} unreachable (#{e.class}: #{e.message})"
63
+ end
64
+
65
+ def parse(body)
66
+ parsed = JSON.parse(body.to_s)
67
+ parsed.is_a?(Hash) ? parsed : {}
68
+ rescue JSON::ParserError
69
+ {}
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsPg
4
+ module ManagedIdentity
5
+ # The managed identity of the Azure resource the app runs on: system-assigned by default, or
6
+ # the user-assigned identity with the given client ID (default: AZURE_CLIENT_ID).
7
+ #
8
+ # App Service, Azure Functions and Container Apps expose a local token endpoint through
9
+ # IDENTITY_ENDPOINT and IDENTITY_HEADER. Everywhere else (VMs, scale sets, AKS nodes) tokens
10
+ # come from the Azure Instance Metadata Service (IMDS).
11
+ class ManagedIdentityCredential
12
+ IMDS_HOST = "http://169.254.169.254"
13
+ IMDS_PATH = "/metadata/identity/oauth2/token"
14
+ # Microsoft documents 404 and 410 as transient for managed identity endpoints, too.
15
+ RETRY_STATUSES = (HTTP::RETRY_STATUSES + [404, 410]).freeze
16
+
17
+ def initialize(client_id: nil)
18
+ @client_id = client_id
19
+ end
20
+
21
+ def get_token(scope)
22
+ resource = scope.delete_suffix("/.default")
23
+ if ENV["IDENTITY_ENDPOINT"].blank? || ENV["IDENTITY_HEADER"].blank?
24
+ imds_token(resource)
25
+ else
26
+ app_service_token(resource)
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def client_id
33
+ @client_id.presence || ENV.fetch("AZURE_CLIENT_ID", nil).presence
34
+ end
35
+
36
+ def token_uri(base, api_version, resource)
37
+ uri = URI(base)
38
+ uri.query = URI.encode_www_form({ "api-version" => api_version, "resource" => resource,
39
+ "client_id" => client_id }.compact)
40
+ uri
41
+ end
42
+
43
+ def app_service_token(resource)
44
+ uri = token_uri(ENV.fetch("IDENTITY_ENDPOINT"), "2019-08-01", resource)
45
+ response = HTTP.get(uri, headers: { "X-IDENTITY-HEADER" => ENV.fetch("IDENTITY_HEADER") },
46
+ retry_statuses: RETRY_STATUSES, proxy: false)
47
+ AccessToken.from_response(response, "Managed identity endpoint (IDENTITY_ENDPOINT)")
48
+ end
49
+
50
+ def imds_token(resource)
51
+ uri = token_uri("#{imds_host}#{IMDS_PATH}", "2018-02-01", resource)
52
+ # A short connect timeout: off Azure, IMDS is simply absent and the chain should move on.
53
+ response = HTTP.get(uri, headers: { "Metadata" => "true" }, open_timeout: 1,
54
+ retry_statuses: RETRY_STATUSES, proxy: false)
55
+ if response.status == 400
56
+ raise CredentialUnavailableError, "#{client_id ? "The requested identity has" : "No identity has"} " \
57
+ "been assigned to this resource (IMDS: #{response.error_message})"
58
+ end
59
+ AccessToken.from_response(response, "Azure Instance Metadata Service")
60
+ rescue HTTP::UnreachableError => e
61
+ raise CredentialUnavailableError, "No managed identity endpoint found (IMDS at #{e.message})"
62
+ end
63
+
64
+ # Same override as azure-identity, which is handy for tests.
65
+ def imds_host
66
+ (ENV["AZURE_POD_IDENTITY_AUTHORITY_HOST"].presence || IMDS_HOST).chomp("/")
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsPg
4
+ module ManagedIdentity
5
+ # Runs psql and pg_dump (db:schema:load and db:schema:dump with structure.sql) with the access
6
+ # token as PGPASSWORD. Registered for PostgreSQL databases by the Railtie.
7
+ class PostgreSQLDatabaseTasks < ActiveRecord::Tasks::PostgreSQLDatabaseTasks
8
+ private
9
+
10
+ def psql_env
11
+ env = super
12
+ if (provider = ManagedIdentity.token_provider_for(configuration_hash))
13
+ env["PGPASSWORD"] = provider.token
14
+ end
15
+ env
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsPg
4
+ module ManagedIdentity
5
+ # Hooks into the database tasks only when rake tasks are loaded, so app and job processes never
6
+ # load the task code. Rails runs schema maintenance through rake too (bin/rails db:test:prepare).
7
+ class Railtie < ::Rails::Railtie
8
+ rake_tasks do
9
+ require_relative "postgresql_database_tasks"
10
+ ActiveRecord::Tasks::DatabaseTasks.register_task(/postgresql/, PostgreSQLDatabaseTasks)
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsPg
4
+ module ManagedIdentity
5
+ # Hands out access tokens for one credential and scope. A token is reused until it is
6
+ # ManagedIdentity.refresh_offset seconds from expiry, then replaced. If replacing it fails while it
7
+ # is still valid, the old token keeps being used. Shared by every connection using the same
8
+ # database configuration, so it is thread-safe and fetches at most one token at a time.
9
+ class TokenProvider
10
+ # While the cached token is usable, ask for a new one at most this often, whether the last
11
+ # request succeeded or failed (azure-identity does the same).
12
+ REFRESH_RETRY_DELAY = 30
13
+ # Never hand out a token that's about to expire mid-handshake.
14
+ MINIMUM_VALIDITY = 10
15
+
16
+ attr_reader :credential, :scope
17
+
18
+ def initialize(credential, scope:)
19
+ @credential = credential
20
+ @scope = scope
21
+ @mutex = Mutex.new
22
+ @access_token = nil
23
+ @requested_at = nil
24
+ end
25
+
26
+ def token
27
+ @mutex.synchronize do
28
+ refresh if refresh_due?
29
+ @access_token.token
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def refresh_due?
36
+ return true unless usable?
37
+ return false if @access_token.expires_in > ManagedIdentity.refresh_offset
38
+
39
+ Time.now.to_i - @requested_at >= REFRESH_RETRY_DELAY
40
+ end
41
+
42
+ def refresh
43
+ @requested_at = Time.now.to_i
44
+ @access_token = credential.get_token(scope)
45
+ ManagedIdentity.logger&.info do
46
+ "[#{self.class.name}] Acquired access token for #{scope}, #{@access_token.inspect}"
47
+ end
48
+ rescue StandardError => e
49
+ # Whatever went wrong (a garbled response, a malformed endpoint URL, a failed `az` spawn),
50
+ # it's a failed token request: fall back to the cached token, or fail the connection attempt.
51
+ error = e.is_a?(Error) ? e : AuthenticationError.new("#{e.class}: #{e.message}")
52
+ raise error unless usable?
53
+
54
+ ManagedIdentity.logger&.warn do
55
+ "[#{self.class.name}] Couldn't refresh access token, using the cached one " \
56
+ "(#{@access_token.inspect}): #{error.message}"
57
+ end
58
+ end
59
+
60
+ def usable?
61
+ @access_token && @access_token.expires_in > MINIMUM_VALIDITY
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsPg
4
+ module ManagedIdentity
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsPg
4
+ module ManagedIdentity
5
+ # Microsoft Entra Workload ID on Kubernetes (AKS). The workload identity webhook injects
6
+ # AZURE_TENANT_ID, AZURE_CLIENT_ID and AZURE_FEDERATED_TOKEN_FILE; the projected service account
7
+ # token in that file is exchanged for an access token.
8
+ class WorkloadIdentityCredential
9
+ JWT_BEARER = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
10
+
11
+ def initialize(client_id: nil)
12
+ @client_id = client_id
13
+ end
14
+
15
+ def get_token(scope)
16
+ tenant_id, token_file = ENV.values_at("AZURE_TENANT_ID", "AZURE_FEDERATED_TOKEN_FILE")
17
+ client_id = @client_id.presence || ENV.fetch("AZURE_CLIENT_ID", nil)
18
+ if [tenant_id, client_id, token_file].any?(&:blank?)
19
+ raise CredentialUnavailableError, "WorkloadIdentityCredential requires AZURE_TENANT_ID, " \
20
+ "AZURE_CLIENT_ID and AZURE_FEDERATED_TOKEN_FILE"
21
+ end
22
+
23
+ EntraID.client_credentials_token(tenant_id:, client_id:, scope:,
24
+ client_assertion_type: JWT_BEARER,
25
+ client_assertion: read_assertion(token_file))
26
+ end
27
+
28
+ private
29
+
30
+ # Kubernetes rotates the projected token in place, so it's read again for every exchange.
31
+ def read_assertion(token_file)
32
+ File.read(token_file).strip
33
+ rescue SystemCallError => e
34
+ raise AuthenticationError, "Can't read the federated token file #{token_file}: #{e.message}"
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+ require "active_support/core_ext/enumerable"
5
+ require "active_support/core_ext/object/blank"
6
+ require "active_support/core_ext/string/inflections"
7
+
8
+ require_relative "managed_identity/version"
9
+
10
+ module RailsPg
11
+ # Microsoft Entra authentication (managed identity, workload identity, ...) for Azure
12
+ # Database for PostgreSQL flexible server. Enable it per database in config/database.yml:
13
+ #
14
+ # production:
15
+ # adapter: postgresql
16
+ # host: my-server.postgres.database.azure.com
17
+ # database: my_app
18
+ # username: my-app-identity # the Entra principal's PostgreSQL role
19
+ # sslmode: require
20
+ # azure_managed_identity: true
21
+ #
22
+ # The password is then an access token resolved each time a connection is opened, rather than
23
+ # a value fixed when database.yml is loaded. Tokens are cached and replaced shortly before they
24
+ # expire.
25
+ module ManagedIdentity
26
+ # Raised when no access token can be obtained. A ConnectionNotEstablished, so Active Record
27
+ # handles it like any other failed connection attempt.
28
+ class Error < ActiveRecord::ConnectionNotEstablished; end
29
+
30
+ # The credential doesn't apply to this environment (e.g. no managed identity endpoint), so
31
+ # DefaultAzureCredential moves on to the next one.
32
+ class CredentialUnavailableError < Error; end
33
+
34
+ # The credential applies to this environment, but the token request failed.
35
+ class AuthenticationError < Error; end
36
+
37
+ CONFIG_KEY = :azure_managed_identity
38
+ # Azure Database for PostgreSQL's Microsoft Entra resource (public cloud).
39
+ DEFAULT_SCOPE = "https://ossrdbms-aad.database.windows.net/.default"
40
+ OPTIONS = %i[client_id credential scope].freeze
41
+
42
+ @providers = {}
43
+ @providers_mutex = Mutex.new
44
+ @refresh_offset = 300
45
+
46
+ class << self
47
+ attr_writer :logger
48
+
49
+ # Seconds before expiry at which a cached token is replaced. azure-identity uses 5 minutes.
50
+ attr_accessor :refresh_offset
51
+
52
+ def logger
53
+ @logger || ActiveRecord::Base.logger
54
+ end
55
+
56
+ # Returns the TokenProvider for a database configuration hash, or nil when the
57
+ # configuration doesn't enable Microsoft Entra authentication. Configurations with the same
58
+ # options share a provider, and therefore a cached token.
59
+ def token_provider_for(config)
60
+ options = parse_options(config[CONFIG_KEY])
61
+ return unless options
62
+
63
+ @providers_mutex.synchronize do
64
+ @providers[options] ||= TokenProvider.new(
65
+ build_credential(options.fetch(:credential, "default"), client_id: options[:client_id]),
66
+ scope: options.fetch(:scope, DEFAULT_SCOPE)
67
+ )
68
+ end
69
+ end
70
+
71
+ # Forgets all cached tokens.
72
+ def reset!
73
+ @providers_mutex.synchronize { @providers.clear }
74
+ end
75
+
76
+ private
77
+
78
+ def parse_options(value)
79
+ case value
80
+ when nil, false, "false", "" then nil
81
+ when true, "true" then {}
82
+ when Hash
83
+ options = value.to_h.transform_keys(&:to_sym).compact_blank
84
+ unknown = options.keys - OPTIONS
85
+ raise ArgumentError, "Unknown #{CONFIG_KEY} option(s): #{unknown.join(", ")}" if unknown.any?
86
+
87
+ options
88
+ else
89
+ raise ArgumentError, "#{CONFIG_KEY} must be true, false or a hash of options, got #{value.inspect}"
90
+ end
91
+ end
92
+
93
+ def build_credential(name, client_id:)
94
+ case name.to_s
95
+ when "default" then DefaultAzureCredential.new(client_id:)
96
+ when "managed_identity" then ManagedIdentityCredential.new(client_id:)
97
+ when "workload_identity" then WorkloadIdentityCredential.new(client_id:)
98
+ when "environment" then EnvironmentCredential.new
99
+ when "azure_cli" then AzureCliCredential.new
100
+ else
101
+ raise ArgumentError, "Unknown #{CONFIG_KEY} credential #{name.inspect}; expected one of " \
102
+ "default, managed_identity, workload_identity, environment, azure_cli"
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
108
+
109
+ require_relative "managed_identity/access_token"
110
+ require_relative "managed_identity/http"
111
+ require_relative "managed_identity/entra_id"
112
+ require_relative "managed_identity/environment_credential"
113
+ require_relative "managed_identity/workload_identity_credential"
114
+ require_relative "managed_identity/managed_identity_credential"
115
+ require_relative "managed_identity/azure_cli_credential"
116
+ require_relative "managed_identity/default_azure_credential"
117
+ require_relative "managed_identity/token_provider"
118
+ require_relative "managed_identity/active_record_extensions"
119
+
120
+ # Runs when the Rails application class is defined, whether this gem was loaded before or after Rails.
121
+ ActiveSupport.on_load(:before_configuration) { require_relative "managed_identity/railtie" }
@@ -0,0 +1,80 @@
1
+ module RailsPg
2
+ module ManagedIdentity
3
+ VERSION: String
4
+ CONFIG_KEY: Symbol
5
+ DEFAULT_SCOPE: String
6
+
7
+ class Error < ActiveRecord::ConnectionNotEstablished
8
+ end
9
+
10
+ class CredentialUnavailableError < Error
11
+ end
12
+
13
+ class AuthenticationError < Error
14
+ end
15
+
16
+ interface _Credential
17
+ def get_token: (String scope) -> AccessToken
18
+ end
19
+
20
+ attr_accessor self.refresh_offset: Integer
21
+ attr_writer self.logger: Logger?
22
+
23
+ def self.logger: () -> Logger?
24
+ def self.token_provider_for: (Hash[Symbol, untyped] config) -> TokenProvider?
25
+ def self.reset!: () -> void
26
+
27
+ class AccessToken
28
+ attr_reader token: String
29
+ attr_reader expires_on: Integer
30
+
31
+ def initialize: (token: String, expires_on: Integer) -> void
32
+ def expires_in: () -> Integer
33
+ end
34
+
35
+ class TokenProvider
36
+ REFRESH_RETRY_DELAY: Integer
37
+ MINIMUM_VALIDITY: Integer
38
+
39
+ attr_reader credential: _Credential
40
+ attr_reader scope: String
41
+
42
+ def initialize: (_Credential credential, scope: String) -> void
43
+ def token: () -> String
44
+ end
45
+
46
+ class DefaultAzureCredential
47
+ include _Credential
48
+
49
+ def initialize: (?client_id: String?) -> void
50
+ end
51
+
52
+ class ManagedIdentityCredential
53
+ include _Credential
54
+
55
+ def initialize: (?client_id: String?) -> void
56
+ end
57
+
58
+ class WorkloadIdentityCredential
59
+ include _Credential
60
+
61
+ def initialize: (?client_id: String?) -> void
62
+ end
63
+
64
+ class EnvironmentCredential
65
+ include _Credential
66
+ end
67
+
68
+ class AzureCliCredential
69
+ include _Credential
70
+
71
+ def initialize: (?process_timeout: Numeric) -> void
72
+ end
73
+
74
+ class PostgreSQLDatabaseTasks < ActiveRecord::Tasks::PostgreSQLDatabaseTasks
75
+ end
76
+
77
+ class Railtie < ::Rails::Railtie
78
+ end
79
+ end
80
+ end
metadata ADDED
@@ -0,0 +1,102 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rails-pg-managed-identity
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Frederik Erbs Spang Thomsen
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activerecord
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.2'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '9'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '7.2'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '9'
32
+ - !ruby/object:Gem::Dependency
33
+ name: pg
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: '1.5'
39
+ type: :runtime
40
+ prerelease: false
41
+ version_requirements: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - ">="
44
+ - !ruby/object:Gem::Version
45
+ version: '1.5'
46
+ description: Connects Active Record's PostgreSQL adapter to Azure Database for PostgreSQL
47
+ flexible server with Microsoft Entra access tokens from managed identity, workload
48
+ identity, a service principal or the Azure CLI. Tokens are fetched when connections
49
+ are opened and refreshed before they expire, instead of a static password in database.yml.
50
+ email:
51
+ - fst@last.dk
52
+ executables: []
53
+ extensions: []
54
+ extra_rdoc_files: []
55
+ files:
56
+ - CHANGELOG.md
57
+ - CODE_OF_CONDUCT.md
58
+ - LICENSE.txt
59
+ - README.md
60
+ - Rakefile
61
+ - gemfiles/rails_7.2.gemfile
62
+ - gemfiles/rails_8.0.gemfile
63
+ - gemfiles/rails_8.1.gemfile
64
+ - lib/rails-pg-managed-identity.rb
65
+ - lib/rails_pg/managed_identity.rb
66
+ - lib/rails_pg/managed_identity/access_token.rb
67
+ - lib/rails_pg/managed_identity/active_record_extensions.rb
68
+ - lib/rails_pg/managed_identity/azure_cli_credential.rb
69
+ - lib/rails_pg/managed_identity/default_azure_credential.rb
70
+ - lib/rails_pg/managed_identity/entra_id.rb
71
+ - lib/rails_pg/managed_identity/environment_credential.rb
72
+ - lib/rails_pg/managed_identity/http.rb
73
+ - lib/rails_pg/managed_identity/managed_identity_credential.rb
74
+ - lib/rails_pg/managed_identity/postgresql_database_tasks.rb
75
+ - lib/rails_pg/managed_identity/railtie.rb
76
+ - lib/rails_pg/managed_identity/token_provider.rb
77
+ - lib/rails_pg/managed_identity/version.rb
78
+ - lib/rails_pg/managed_identity/workload_identity_credential.rb
79
+ - sig/rails_pg/managed_identity.rbs
80
+ licenses:
81
+ - MIT
82
+ metadata:
83
+ rubygems_mfa_required: 'true'
84
+ rdoc_options: []
85
+ require_paths:
86
+ - lib
87
+ required_ruby_version: !ruby/object:Gem::Requirement
88
+ requirements:
89
+ - - ">="
90
+ - !ruby/object:Gem::Version
91
+ version: 3.3.0
92
+ required_rubygems_version: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ requirements: []
98
+ rubygems_version: 4.0.21
99
+ specification_version: 4
100
+ summary: Azure managed identity (Microsoft Entra) authentication for Rails and Azure
101
+ Database for PostgreSQL
102
+ test_files: []