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 +7 -0
- data/CHANGELOG.md +14 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +104 -0
- data/Rakefile +12 -0
- data/gemfiles/rails_7.2.gemfile +6 -0
- data/gemfiles/rails_8.0.gemfile +6 -0
- data/gemfiles/rails_8.1.gemfile +6 -0
- data/lib/rails-pg-managed-identity.rb +4 -0
- data/lib/rails_pg/managed_identity/access_token.rb +41 -0
- data/lib/rails_pg/managed_identity/active_record_extensions.rb +53 -0
- data/lib/rails_pg/managed_identity/azure_cli_credential.rb +68 -0
- data/lib/rails_pg/managed_identity/default_azure_credential.rb +44 -0
- data/lib/rails_pg/managed_identity/entra_id.rb +24 -0
- data/lib/rails_pg/managed_identity/environment_credential.rb +19 -0
- data/lib/rails_pg/managed_identity/http.rb +74 -0
- data/lib/rails_pg/managed_identity/managed_identity_credential.rb +70 -0
- data/lib/rails_pg/managed_identity/postgresql_database_tasks.rb +19 -0
- data/lib/rails_pg/managed_identity/railtie.rb +14 -0
- data/lib/rails_pg/managed_identity/token_provider.rb +65 -0
- data/lib/rails_pg/managed_identity/version.rb +7 -0
- data/lib/rails_pg/managed_identity/workload_identity_credential.rb +38 -0
- data/lib/rails_pg/managed_identity.rb +121 -0
- data/sig/rails_pg/managed_identity.rbs +80 -0
- metadata +102 -0
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.
|
data/CODE_OF_CONDUCT.md
ADDED
|
@@ -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,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,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: []
|