pg-azure_workload_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/.yardopts +6 -0
- data/CHANGELOG.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +160 -0
- data/lib/pg/azure_workload_identity/active_record_postgresql_adapter.rb +33 -0
- data/lib/pg/azure_workload_identity/active_record_postgresql_database_tasks.rb +33 -0
- data/lib/pg/azure_workload_identity/auth_token.rb +71 -0
- data/lib/pg/azure_workload_identity/auth_token_generator.rb +151 -0
- data/lib/pg/azure_workload_identity/auth_token_injector.rb +66 -0
- data/lib/pg/azure_workload_identity/connection.rb +83 -0
- data/lib/pg/azure_workload_identity/connection_info/active_record_configuration_hash.rb +47 -0
- data/lib/pg/azure_workload_identity/connection_info/key_value_string.rb +207 -0
- data/lib/pg/azure_workload_identity/connection_info/uri.rb +91 -0
- data/lib/pg/azure_workload_identity/connection_info.rb +46 -0
- data/lib/pg/azure_workload_identity/error.rb +9 -0
- data/lib/pg/azure_workload_identity/version.rb +7 -0
- data/lib/pg/azure_workload_identity.rb +44 -0
- data/pg-azure_workload_identity.gemspec +43 -0
- metadata +76 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 76a51f189884a5080a32166397a3cde091c99c1db2b4fc6b3e25d14ae8a24c53
|
|
4
|
+
data.tar.gz: 82c6a01050444c0a9d4d99993098dc69aef71402b64774de6ebfe0a6c8f4c452
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 2c3e82ebca506d35b28d02a128e0a9a1ea6ffc21295afe28341d4291820f1a917babd3ceea8a295725328a20e39b1feddf50e4b07d8b8c890b58a90700a17c4b
|
|
7
|
+
data.tar.gz: c11bee40a5458703988050efd757f90a3e154aec5bda21604fc5d2dc3c26afe69540930fc97c2841185d71c7b226d44f8b3c0d7a0b9d470684f1f3dae1725e18
|
data/.yardopts
ADDED
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [0.1.0] - 2026-05-19
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
* A plugin for the [`pg` gem](https://rubygems.org/gems/pg) that adds support for [Entra ID Authentication](https://learn.microsoft.com/azure/postgresql/security/security-entra-concepts) when connecting to PostgreSQL flexible database servers in Microsoft Azure. ([#1](https://github.com/atpoint-cloud/pg-azure_workload_identity/pull/1))
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Simon Schmid
|
|
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,160 @@
|
|
|
1
|
+
# PG::AzureWorkloadIdentity
|
|
2
|
+
|
|
3
|
+
[](https://rubygems.org/gems/pg-azure_workload_identity)
|
|
4
|
+
 
|
|
5
|
+
[](https://www.rubydoc.info/gems/pg-azure_workload_identity)
|
|
6
|
+
|
|
7
|
+
`PG::AzureWorkloadIdentity` is a plugin for the [`pg` gem](https://rubygems.org/gems/pg) that adds support for [Microsoft Entra ID authentication](https://learn.microsoft.com/azure/postgresql/security/security-entra-concepts) when connecting to [Azure Database for PostgreSQL flexible server](https://learn.microsoft.com/azure/postgresql/flexible-server/overview), using [Microsoft Entra Workload Identity](https://learn.microsoft.com/entra/workload-id/workload-identity-federation) — typically as projected into pods by [AKS Workload Identity](https://learn.microsoft.com/azure/aks/workload-identity-overview).
|
|
8
|
+
|
|
9
|
+
Entra ID authentication lets your application connect to the database using short-lived access tokens instead of a fixed password. Combined with Workload Identity, your pods never carry a long-lived secret at all: the Kubernetes service account JWT is automatically exchanged for an Entra access token via [federated credentials](https://learn.microsoft.com/entra/workload-id/workload-identity-federation), and that token is used as the database password. No client secrets, no certificate rotation, no password storage.
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
Install manually:
|
|
14
|
+
|
|
15
|
+
```console
|
|
16
|
+
$ gem install pg-azure_workload_identity
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
or with Bundler:
|
|
20
|
+
|
|
21
|
+
```console
|
|
22
|
+
$ bundle add pg-azure_workload_identity
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
To use Workload Identity authentication for your database connections, you need to
|
|
28
|
+
|
|
29
|
+
1. enable Microsoft Entra authentication on your Azure Database for PostgreSQL flexible server,
|
|
30
|
+
2. provide your application with a Workload Identity, and
|
|
31
|
+
3. configure your application to use the gem's token generator.
|
|
32
|
+
|
|
33
|
+
### 1. Enable Microsoft Entra authentication on your database
|
|
34
|
+
|
|
35
|
+
Configure your Azure Database for PostgreSQL flexible server to allow Microsoft Entra authentication, either during server provisioning or on an existing server through the **Security → Authentication** pane. See [Use Microsoft Entra ID for authentication with Azure Database for PostgreSQL](https://learn.microsoft.com/azure/postgresql/security/security-entra-configure) for the step-by-step instructions.
|
|
36
|
+
|
|
37
|
+
Choose between **Microsoft Entra authentication only** (no password-based logins) and **PostgreSQL and Microsoft Entra authentication** (both modes available), then add at least one Microsoft Entra administrator. Only an Entra administrator can subsequently create additional Entra-mapped database roles.
|
|
38
|
+
|
|
39
|
+
Connect to the database as an Entra administrator and create a role for your application's identity. For a user-assigned managed identity, use its object (principal) ID:
|
|
40
|
+
|
|
41
|
+
```sql
|
|
42
|
+
select * from pgaadauth_create_principal_with_oid('my-app', '<managed-identity-object-id>', 'service', false, false);
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Grant the role whatever privileges your application needs.
|
|
46
|
+
|
|
47
|
+
### 2. Provide your application with a Workload Identity
|
|
48
|
+
|
|
49
|
+
The recommended way to provide a credential to a pod in AKS is [Microsoft Entra Workload ID](https://learn.microsoft.com/azure/aks/workload-identity-overview). It works by federating a Kubernetes service account with an Entra ID identity, so a JWT projected into the pod can be exchanged for an Entra access token without ever sharing a long-lived secret.
|
|
50
|
+
|
|
51
|
+
The setup steps are (see [Deploy and configure an AKS cluster with Workload ID](https://learn.microsoft.com/azure/aks/workload-identity-deploy-cluster) for the full walkthrough):
|
|
52
|
+
|
|
53
|
+
1. Enable the OIDC issuer and Workload Identity add-on on your AKS cluster.
|
|
54
|
+
2. Create a user-assigned managed identity (or use an existing one).
|
|
55
|
+
3. Create a [federated identity credential](https://learn.microsoft.com/entra/workload-id/workload-identity-federation) on the managed identity, with the cluster's OIDC issuer as the `issuer`, `api://AzureADTokenExchange` as the `audience`, and `system:serviceaccount:<namespace>:<service-account-name>` as the `subject`.
|
|
56
|
+
4. Create a Kubernetes service account annotated with `azure.workload.identity/client-id: <managed-identity-client-id>` and use it for your application's pods.
|
|
57
|
+
5. Add `azure.workload.identity/use: "true"` to your pod template's labels.
|
|
58
|
+
|
|
59
|
+
Once that's in place, the Workload Identity mutating webhook automatically injects the following environment variables into your pod, which the gem uses to acquire access tokens:
|
|
60
|
+
|
|
61
|
+
| Variable | Provided by |
|
|
62
|
+
| --- | --- |
|
|
63
|
+
| `AZURE_TENANT_ID` | Workload Identity webhook |
|
|
64
|
+
| `AZURE_CLIENT_ID` | Workload Identity webhook (from the service-account annotation) |
|
|
65
|
+
| `AZURE_FEDERATED_TOKEN_FILE` | Workload Identity webhook (path to the projected service-account JWT) |
|
|
66
|
+
|
|
67
|
+
### 3. Configure your application to use the workload-identity token generator
|
|
68
|
+
|
|
69
|
+
Set the non-standard `azure_workload_identity` connection parameter to a truthy value (e.g. `true`). When the gem sees this parameter on a connection, it acquires an Entra access token using the environment variables above and injects it as the database password before handing the connection string to `libpq`.
|
|
70
|
+
|
|
71
|
+
You can set this parameter in
|
|
72
|
+
|
|
73
|
+
* the query string of a connection URI:
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
postgresql://my-app@my-server.postgres.database.azure.com:5432/appdb?sslmode=require&azure_workload_identity=true
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
* a `key=value` pair in a connection string:
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
user=my-app host=my-server.postgres.database.azure.com port=5432 dbname=appdb sslmode=require azure_workload_identity=true
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
* a `key: value` pair in a connection hash:
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
PG.connect(
|
|
89
|
+
user: "my-app",
|
|
90
|
+
host: "my-server.postgres.database.azure.com",
|
|
91
|
+
port: 5432,
|
|
92
|
+
dbname: "appdb",
|
|
93
|
+
sslmode: "require",
|
|
94
|
+
azure_workload_identity: "true"
|
|
95
|
+
)
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
* `database.yml`, if you're using Rails:
|
|
99
|
+
|
|
100
|
+
```yaml
|
|
101
|
+
production:
|
|
102
|
+
adapter: postgresql
|
|
103
|
+
username: my-app
|
|
104
|
+
host: my-server.postgres.database.azure.com
|
|
105
|
+
port: 5432
|
|
106
|
+
database: appdb
|
|
107
|
+
sslmode: require
|
|
108
|
+
azure_workload_identity: true
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
The gem caches access tokens in memory for their reported lifetime (with a small refresh threshold), so concurrent and back-to-back connections share a single token exchange. Tokens are refreshed automatically on demand before they expire.
|
|
112
|
+
|
|
113
|
+
If the defaults don't match your environment (for example, you're using a sovereign cloud or a non-OSS-RDBMS scope), you can install a custom token generator at boot:
|
|
114
|
+
|
|
115
|
+
```ruby
|
|
116
|
+
PG::AzureWorkloadIdentity.auth_token_generator = PG::AzureWorkloadIdentity::AuthTokenGenerator.new(
|
|
117
|
+
identity_endpoint: "https://login.microsoftonline.us/<tenant-id>/oauth2/v2.0/token",
|
|
118
|
+
client_id: "<client-id>",
|
|
119
|
+
scope: "https://ossrdbms-aad.database.usgovcloudapi.net/.default",
|
|
120
|
+
grant_type: "client_credentials",
|
|
121
|
+
client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
|
|
122
|
+
federated_token_file: "/var/run/secrets/azure/tokens/azure-identity-token"
|
|
123
|
+
)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
The object you assign just needs to respond to `#call` and return a string token, so you can swap in an entirely different implementation when needed.
|
|
127
|
+
|
|
128
|
+
### 4. Set `sslmode` to `verify-full` (recommended)
|
|
129
|
+
|
|
130
|
+
Although Azure Database for PostgreSQL flexible server requires TLS by default (`sslmode=require`), the strongest setting is `sslmode=verify-full` — this also verifies the server's certificate chain and hostname, preventing man-in-the-middle attacks. You'll need to point `sslrootcert` at the Azure-issued certificate bundle; see [TLS certificates for Azure Database for PostgreSQL flexible server](https://learn.microsoft.com/azure/postgresql/flexible-server/concepts-networking-ssl-tls#downloading-root-ca-certificates-and-updating-application-clients-in-certificate-pinning-scenarios) for the current chain and download instructions. Note that this might no be possible when using private endpoints with entries in private DNS zones.
|
|
131
|
+
|
|
132
|
+
## Development
|
|
133
|
+
|
|
134
|
+
After checking out the repo, run `bundle install` to install dependencies.
|
|
135
|
+
Then, run `bundle exec rake` to run the linter and unit tests.
|
|
136
|
+
|
|
137
|
+
The acceptance suite spins up a local PostgreSQL container via Docker Compose and exercises the full connection flow (with WebMock stubbing the Entra token endpoint) — run it with:
|
|
138
|
+
|
|
139
|
+
```console
|
|
140
|
+
$ bundle exec rake test:acceptance
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
To release a new version:
|
|
144
|
+
|
|
145
|
+
1. Update the version number in [version.rb](lib/pg/azure_workload_identity/version.rb), and run `bundle install` to update [Gemfile.lock](Gemfile.lock).
|
|
146
|
+
2. Update [CHANGELOG.md](CHANGELOG.md).
|
|
147
|
+
3. Submit the changes as a pull request.
|
|
148
|
+
4. Once merged, tag the release and push the tag to GitHub.
|
|
149
|
+
|
|
150
|
+
## Contributing
|
|
151
|
+
|
|
152
|
+
Bug reports and pull requests are welcome [on GitHub](https://github.com/atpoint-cloud/pg-azure_workload_identity).
|
|
153
|
+
|
|
154
|
+
## Credits
|
|
155
|
+
|
|
156
|
+
This gem is built on the design and structure of [`pg-aws_rds_iam`](https://github.com/haines/pg-aws_rds_iam) which provides the equivalent functionality for AWS RDS IAM authentication.
|
|
157
|
+
|
|
158
|
+
## License
|
|
159
|
+
|
|
160
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "auth_token_injector"
|
|
4
|
+
|
|
5
|
+
module PG
|
|
6
|
+
module AzureWorkloadIdentity
|
|
7
|
+
# Patch prepended to
|
|
8
|
+
# `ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.singleton_class`
|
|
9
|
+
# that wires Azure Workload Identity authentication into the
|
|
10
|
+
# `rails dbconsole` / `rails db` flow.
|
|
11
|
+
#
|
|
12
|
+
# The adapter's `dbconsole` class method is responsible for launching
|
|
13
|
+
# `psql` against the configured database; without this patch, `psql`
|
|
14
|
+
# would prompt for (or fail without) a password when the configuration
|
|
15
|
+
# opts into workload identity. The override generates an auth token and
|
|
16
|
+
# exposes it via `PGPASSWORD` before delegating to the original
|
|
17
|
+
# implementation.
|
|
18
|
+
module ActiveRecordPostgreSQLAdapter
|
|
19
|
+
# Sets `PGPASSWORD` on the environment to a freshly generated Azure
|
|
20
|
+
# Workload Identity auth token when the configuration enables it, then
|
|
21
|
+
# delegates to the original `dbconsole` implementation.
|
|
22
|
+
#
|
|
23
|
+
# @param config [ActiveRecord::DatabaseConfigurations::DatabaseConfig]
|
|
24
|
+
# the configuration for the database `psql` is being launched
|
|
25
|
+
# against.
|
|
26
|
+
# @return [void]
|
|
27
|
+
def dbconsole(config, options = {})
|
|
28
|
+
AuthTokenInjector.new.inject_into_psql_env! config.configuration_hash, ENV
|
|
29
|
+
super
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "auth_token_injector"
|
|
4
|
+
|
|
5
|
+
module PG
|
|
6
|
+
module AzureWorkloadIdentity
|
|
7
|
+
# Patch prepended to
|
|
8
|
+
# `ActiveRecord::Tasks::PostgreSQLDatabaseTasks` that wires Azure
|
|
9
|
+
# Workload Identity authentication into rake tasks that shell out to
|
|
10
|
+
# `psql` (e.g. `db:structure:load`, `db:structure:dump`).
|
|
11
|
+
#
|
|
12
|
+
# ActiveRecord builds an environment hash for the `psql` subprocess via
|
|
13
|
+
# the private `#psql_env` method; this override generates an auth token
|
|
14
|
+
# and adds it to that hash as `PGPASSWORD` whenever the configuration
|
|
15
|
+
# opts into workload identity, so the `psql` invocation authenticates
|
|
16
|
+
# without prompting.
|
|
17
|
+
module ActiveRecordPostgreSQLDatabaseTasks
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
# Builds the environment hash for the `psql` subprocess, injecting a
|
|
21
|
+
# freshly generated Azure Workload Identity auth token as
|
|
22
|
+
# `PGPASSWORD` when the configuration enables it.
|
|
23
|
+
#
|
|
24
|
+
# @return [Hash{String => String}] the environment hash to pass to
|
|
25
|
+
# the `psql` subprocess.
|
|
26
|
+
def psql_env
|
|
27
|
+
super.tap do |psql_env|
|
|
28
|
+
AuthTokenInjector.new.inject_into_psql_env! configuration_hash, psql_env
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
require_relative "error"
|
|
6
|
+
|
|
7
|
+
module PG
|
|
8
|
+
module AzureWorkloadIdentity
|
|
9
|
+
# Wraps a fetched OAuth access token together with the moment it was
|
|
10
|
+
# generated, and answers whether it is still safely usable. Validity
|
|
11
|
+
# is measured against the monotonic clock so the answer is not
|
|
12
|
+
# affected by wall-clock jumps (NTP slews, VM clock corrections).
|
|
13
|
+
class AuthToken
|
|
14
|
+
# Seconds before the reported expiry at which the token is
|
|
15
|
+
# considered stale, so callers proactively refresh before it
|
|
16
|
+
# actually expires in flight.
|
|
17
|
+
REFRESH_THRESHOLD_SECONDS = 60
|
|
18
|
+
|
|
19
|
+
# @return [String] the bearer access token.
|
|
20
|
+
attr_reader :access_token
|
|
21
|
+
|
|
22
|
+
# Parses a token response from the Azure AD token endpoint and builds
|
|
23
|
+
# an {AuthToken} instance.
|
|
24
|
+
#
|
|
25
|
+
# @param json [String] the raw JSON response body from the token endpoint.
|
|
26
|
+
# @return [AuthToken] the parsed token.
|
|
27
|
+
# @raise [Error] when the JSON is invalid or lacks the expected fields.
|
|
28
|
+
def self.from_json(json)
|
|
29
|
+
JSON.parse(json).then do |data|
|
|
30
|
+
new(
|
|
31
|
+
access_token: data.fetch("access_token").to_s,
|
|
32
|
+
expires_in: data.fetch("expires_in").to_i
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
rescue JSON::ParserError => e
|
|
36
|
+
raise Error, "Failed to parse token response from JSON: #{e.message}"
|
|
37
|
+
rescue KeyError => e
|
|
38
|
+
raise Error, "Token response is missing key #{e.key} in #{e.receiver}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @param access_token [String] the bearer access token returned by
|
|
42
|
+
# the token endpoint.
|
|
43
|
+
# @param expires_in [Integer, String] seconds-until-expiry as
|
|
44
|
+
# reported by the token endpoint.
|
|
45
|
+
# @param refresh_threshold [Integer] seconds before the reported
|
|
46
|
+
# expiry at which the token should be considered stale.
|
|
47
|
+
def initialize(
|
|
48
|
+
access_token:,
|
|
49
|
+
expires_in:,
|
|
50
|
+
refresh_threshold: REFRESH_THRESHOLD_SECONDS
|
|
51
|
+
)
|
|
52
|
+
@access_token = access_token
|
|
53
|
+
@expiry = expires_in
|
|
54
|
+
@generated_at = now
|
|
55
|
+
@refresh_threshold = refresh_threshold
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# @return [Boolean] `true` if the token is still valid with the
|
|
59
|
+
# refresh threshold applied.
|
|
60
|
+
def valid?
|
|
61
|
+
(now - @generated_at) < (@expiry - @refresh_threshold)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def now
|
|
67
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
require_relative "auth_token"
|
|
8
|
+
require_relative "error"
|
|
9
|
+
|
|
10
|
+
module PG
|
|
11
|
+
module AzureWorkloadIdentity
|
|
12
|
+
# Exchanges a Kubernetes-projected federated identity token (as mounted
|
|
13
|
+
# into pods by AKS Workload Identity) for an Azure AD access token via the
|
|
14
|
+
# OAuth 2.0 client-credentials flow with a JWT bearer client assertion.
|
|
15
|
+
#
|
|
16
|
+
# Instances are callable via {#call}, which returns a cached access token
|
|
17
|
+
# while it is still valid (with a refresh threshold applied) and otherwise
|
|
18
|
+
# fetches a new one. Token fetches are guarded by a mutex so concurrent
|
|
19
|
+
# callers share a single in-flight request.
|
|
20
|
+
#
|
|
21
|
+
# Configuration may be passed explicitly to {#initialize} or populated
|
|
22
|
+
# from the standard Azure Workload Identity environment variables via
|
|
23
|
+
# {.default}.
|
|
24
|
+
class AuthTokenGenerator
|
|
25
|
+
IDENTITY_ENDPOINT = "https://login.microsoftonline.com/%<tenant_id>s/oauth2/v2.0/token"
|
|
26
|
+
SCOPE = "https://ossrdbms-aad.database.windows.net/.default"
|
|
27
|
+
GRANT_TYPE = "client_credentials"
|
|
28
|
+
CLIENT_ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
|
|
29
|
+
|
|
30
|
+
# Builds an {AuthTokenGenerator} using the standard Azure Workload
|
|
31
|
+
# Identity environment variables (`AZURE_TENANT_ID`,
|
|
32
|
+
# `AZURE_CLIENT_ID`, `AZURE_FEDERATED_TOKEN_FILE`) and conventional
|
|
33
|
+
# OAuth defaults.
|
|
34
|
+
#
|
|
35
|
+
# @return [AuthTokenGenerator]
|
|
36
|
+
def self.default
|
|
37
|
+
new(
|
|
38
|
+
identity_endpoint: format(IDENTITY_ENDPOINT, tenant_id: ENV.fetch("AZURE_TENANT_ID")),
|
|
39
|
+
client_id: ENV.fetch("AZURE_CLIENT_ID"),
|
|
40
|
+
scope: SCOPE,
|
|
41
|
+
grant_type: GRANT_TYPE,
|
|
42
|
+
client_assertion_type: CLIENT_ASSERTION_TYPE,
|
|
43
|
+
federated_token_file: ENV.fetch("AZURE_FEDERATED_TOKEN_FILE")
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @return [String] the Azure AD token endpoint URL.
|
|
48
|
+
attr_reader :identity_endpoint
|
|
49
|
+
|
|
50
|
+
# @return [String] the AAD application/client id.
|
|
51
|
+
attr_reader :client_id
|
|
52
|
+
|
|
53
|
+
# @return [String] the requested OAuth scope.
|
|
54
|
+
attr_reader :scope
|
|
55
|
+
|
|
56
|
+
# @return [String] the OAuth grant type
|
|
57
|
+
# (typically `"client_credentials"`).
|
|
58
|
+
attr_reader :grant_type
|
|
59
|
+
|
|
60
|
+
# @return [String] the client-assertion type
|
|
61
|
+
# (typically `"urn:ietf:params:oauth:client-assertion-type:jwt-bearer"`).
|
|
62
|
+
attr_reader :client_assertion_type
|
|
63
|
+
|
|
64
|
+
# @return [String] absolute path to the file containing the
|
|
65
|
+
# Kubernetes-projected federated identity JWT.
|
|
66
|
+
attr_reader :federated_token_file
|
|
67
|
+
|
|
68
|
+
# @param identity_endpoint [String] the Azure AD token endpoint.
|
|
69
|
+
# @param client_id [String] the AAD application id.
|
|
70
|
+
# @param scope [String] the requested OAuth scope.
|
|
71
|
+
# @param grant_type [String] the OAuth grant type.
|
|
72
|
+
# @param client_assertion_type [String] the client-assertion type.
|
|
73
|
+
# @param federated_token_file [String] path to the projected
|
|
74
|
+
# federated identity JWT.
|
|
75
|
+
def initialize( # rubocop:disable Metrics/ParameterLists
|
|
76
|
+
identity_endpoint:,
|
|
77
|
+
client_id:,
|
|
78
|
+
scope:,
|
|
79
|
+
grant_type:,
|
|
80
|
+
client_assertion_type:,
|
|
81
|
+
federated_token_file:
|
|
82
|
+
)
|
|
83
|
+
@identity_endpoint = URI.parse(identity_endpoint)
|
|
84
|
+
@client_id = client_id
|
|
85
|
+
@scope = scope
|
|
86
|
+
@grant_type = grant_type
|
|
87
|
+
@client_assertion_type = client_assertion_type
|
|
88
|
+
@federated_token_file = federated_token_file
|
|
89
|
+
@mutex = Mutex.new
|
|
90
|
+
@token = nil
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Returns a currently valid Azure AD access token, fetching a new one
|
|
94
|
+
# from the identity endpoint when the cached token is missing or about
|
|
95
|
+
# to expire. Thread-safe: concurrent callers share a single in-flight
|
|
96
|
+
# fetch.
|
|
97
|
+
#
|
|
98
|
+
# @return [String] the bearer access token.
|
|
99
|
+
# @raise [Error] when the federated token cannot be read, the HTTP
|
|
100
|
+
# request fails, the response is non-2xx, or the response body is
|
|
101
|
+
# not valid JSON / lacks the expected fields.
|
|
102
|
+
def call
|
|
103
|
+
@mutex.synchronize do
|
|
104
|
+
@token = refresh unless @token&.valid?
|
|
105
|
+
@token.access_token
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
def refresh
|
|
112
|
+
response = request_token
|
|
113
|
+
return AuthToken.from_json(response.body) if response.is_a?(Net::HTTPSuccess)
|
|
114
|
+
|
|
115
|
+
raise Error, "Azure AD token endpoint responded with #{response.code} #{response.message}: #{response.body}"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def request_token
|
|
119
|
+
Net::HTTP.start(
|
|
120
|
+
@identity_endpoint.hostname.to_s,
|
|
121
|
+
@identity_endpoint.port,
|
|
122
|
+
use_ssl: @identity_endpoint.scheme == "https"
|
|
123
|
+
) do |http|
|
|
124
|
+
http.request(build_request)
|
|
125
|
+
end
|
|
126
|
+
rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout,
|
|
127
|
+
SocketError, IOError, SystemCallError, OpenSSL::SSL::SSLError => e
|
|
128
|
+
raise Error, "Failed to reach Azure AD token endpoint: #{e.class}: #{e.message}"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def build_request
|
|
132
|
+
Net::HTTP::Post.new(@identity_endpoint).tap do |request|
|
|
133
|
+
request["Content-Type"] = "application/x-www-form-urlencoded"
|
|
134
|
+
request.body = URI.encode_www_form(
|
|
135
|
+
client_id: client_id,
|
|
136
|
+
scope: scope,
|
|
137
|
+
client_assertion_type: client_assertion_type,
|
|
138
|
+
client_assertion: read_federated_token,
|
|
139
|
+
grant_type: grant_type
|
|
140
|
+
)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def read_federated_token
|
|
145
|
+
File.read(federated_token_file).strip
|
|
146
|
+
rescue SystemCallError, IOError => e
|
|
147
|
+
raise Error, "Failed to read federated token file #{federated_token_file.inspect}: #{e.class}: #{e.message}"
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pg"
|
|
4
|
+
require_relative "connection_info"
|
|
5
|
+
|
|
6
|
+
module PG
|
|
7
|
+
module AzureWorkloadIdentity
|
|
8
|
+
# AuthTokenInjector is responsible for injecting auth tokens into connection
|
|
9
|
+
# strings and PSQL environment variables when required.
|
|
10
|
+
class AuthTokenInjector
|
|
11
|
+
# Initializes a new AuthTokenInjector.
|
|
12
|
+
#
|
|
13
|
+
# The generator is resolved lazily — only on the code paths that
|
|
14
|
+
# actually need a token — so that constructing an injector is cheap
|
|
15
|
+
# and side-effect-free for connections that don't opt into workload
|
|
16
|
+
# identity.
|
|
17
|
+
#
|
|
18
|
+
# @param auth_token_generator [#call, nil] A callable that generates
|
|
19
|
+
# an auth token. Defaults to the module-level generator at the time
|
|
20
|
+
# a token is first needed.
|
|
21
|
+
def initialize(auth_token_generator: nil)
|
|
22
|
+
@auth_token_generator = auth_token_generator
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Injects an auth token into the given connection string as password if required.
|
|
26
|
+
#
|
|
27
|
+
# @param connection_string [String] The original connection string.
|
|
28
|
+
# @return [String] The modified connection string with the auth token injected if required.
|
|
29
|
+
def inject_into_connection_string(connection_string)
|
|
30
|
+
connection_info = ConnectionInfo.from_connection_string(connection_string)
|
|
31
|
+
return connection_string unless generate_auth_token?(connection_info)
|
|
32
|
+
|
|
33
|
+
connection_info.password = auth_token_generator.call
|
|
34
|
+
connection_info.to_s
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Injects an auth token into the given PSQL environment hash if required.
|
|
38
|
+
# This is used for the db:console rake task.
|
|
39
|
+
#
|
|
40
|
+
# @param configuration_hash [Hash] The ActiveRecord connection configuration hash from database.yml
|
|
41
|
+
# @param psql_env [Hash] The environment hash to be passed to the PSQL process.
|
|
42
|
+
def inject_into_psql_env!(configuration_hash, psql_env)
|
|
43
|
+
connection_info = ConnectionInfo.from_active_record_configuration_hash(configuration_hash)
|
|
44
|
+
return unless generate_auth_token?(connection_info)
|
|
45
|
+
|
|
46
|
+
psql_env["PGPASSWORD"] = auth_token_generator.call
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
# Resolves the auth token generator lazily, falling back to the
|
|
52
|
+
# module-level default only when a token is actually needed.
|
|
53
|
+
def auth_token_generator
|
|
54
|
+
@auth_token_generator ||= AzureWorkloadIdentity.auth_token_generator
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Determines whether an auth token should be generated based on the connection information.
|
|
58
|
+
#
|
|
59
|
+
# @param connection_info [ConnectionInfo] The parsed connection information.
|
|
60
|
+
# @return [Boolean] true if `azure_workload_identity` is set, false otherwise.
|
|
61
|
+
def generate_auth_token?(connection_info)
|
|
62
|
+
connection_info.azure_workload_identity
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "auth_token_injector"
|
|
4
|
+
require_relative "connection_info"
|
|
5
|
+
|
|
6
|
+
module PG
|
|
7
|
+
module AzureWorkloadIdentity
|
|
8
|
+
# Patch prepended to `PG::Connection.singleton_class` that teaches libpq's
|
|
9
|
+
# connection-parameter machinery about the non-standard
|
|
10
|
+
# `azure_workload_identity` option and injects a freshly generated auth
|
|
11
|
+
# token as the password when that option is set.
|
|
12
|
+
#
|
|
13
|
+
# The three overrides cooperate:
|
|
14
|
+
# - {#conndefaults} adds `azure_workload_identity` to the list of known
|
|
15
|
+
# parameters so it is recognized rather than rejected as unknown.
|
|
16
|
+
# - {#conninfo_parse} parses a connection string while preserving the
|
|
17
|
+
# value of `azure_workload_identity` in the returned parameter list
|
|
18
|
+
# (libpq would otherwise drop it).
|
|
19
|
+
# - {#parse_connect_args} runs the final connection string through
|
|
20
|
+
# {AuthTokenInjector} so the password is replaced with a generated
|
|
21
|
+
# token whenever `azure_workload_identity` is set.
|
|
22
|
+
module Connection
|
|
23
|
+
# Lists libpq's connection parameter defaults, with the
|
|
24
|
+
# `azure_workload_identity` option appended.
|
|
25
|
+
#
|
|
26
|
+
# @return [Array<Hash>] the connection parameter defaults.
|
|
27
|
+
def conndefaults
|
|
28
|
+
super + [conndefault_azure_workload_identity]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Parses a connection string into the libpq parameter array, preserving
|
|
32
|
+
# the `azure_workload_identity` option.
|
|
33
|
+
#
|
|
34
|
+
# The input is first round-tripped through {ConnectionInfo} so the
|
|
35
|
+
# custom option is stripped before being handed to libpq's parser, and
|
|
36
|
+
# then re-appended to the parsed result with its original value.
|
|
37
|
+
#
|
|
38
|
+
# @param connection_string [String] either a URI-style or key=value
|
|
39
|
+
# connection string.
|
|
40
|
+
# @return [Array<Hash>] the parsed parameter array, including the
|
|
41
|
+
# `azure_workload_identity` entry when set.
|
|
42
|
+
def conninfo_parse(connection_string)
|
|
43
|
+
connection_info = ConnectionInfo.from_connection_string(connection_string)
|
|
44
|
+
|
|
45
|
+
super(connection_info.to_s).tap do |result|
|
|
46
|
+
if connection_info.azure_workload_identity
|
|
47
|
+
result << conndefault_azure_workload_identity.merge(val: connection_info.azure_workload_identity)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Normalizes connection arguments and injects an Azure Workload Identity
|
|
53
|
+
# auth token as the password when `azure_workload_identity` is set.
|
|
54
|
+
#
|
|
55
|
+
# @param args [Array] the arguments passed to `PG::Connection.new` /
|
|
56
|
+
# `PG.connect` (forwarded to `super`).
|
|
57
|
+
# @return [String] the connection string with the auth token injected
|
|
58
|
+
# if applicable, otherwise unchanged.
|
|
59
|
+
def parse_connect_args(...)
|
|
60
|
+
AuthTokenInjector.new.inject_into_connection_string(super)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
# Connection-parameter descriptor for the `azure_workload_identity`
|
|
66
|
+
# option, matching the shape of entries returned by libpq's
|
|
67
|
+
# `PQconndefaults`.
|
|
68
|
+
#
|
|
69
|
+
# @return [Hash] the descriptor hash.
|
|
70
|
+
def conndefault_azure_workload_identity
|
|
71
|
+
{
|
|
72
|
+
keyword: "azure_workload_identity",
|
|
73
|
+
envvar: nil,
|
|
74
|
+
compiled: nil,
|
|
75
|
+
val: nil,
|
|
76
|
+
label: "Azure-Workload-Identity",
|
|
77
|
+
dispchar: "",
|
|
78
|
+
dispsize: 64
|
|
79
|
+
}
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PG
|
|
4
|
+
module AzureWorkloadIdentity
|
|
5
|
+
module ConnectionInfo
|
|
6
|
+
# Wraps an ActiveRecord database configuration hash (as produced from
|
|
7
|
+
# `config/database.yml`) and exposes the connection parameters relevant
|
|
8
|
+
# to Azure Workload Identity authentication, including the non-standard
|
|
9
|
+
# `azure_workload_identity` flag.
|
|
10
|
+
#
|
|
11
|
+
# Accessor names follow this gem's connection-info contract (`user`,
|
|
12
|
+
# `host`, `port`) rather than ActiveRecord's keys (note the
|
|
13
|
+
# `:username` -> `#user` mapping).
|
|
14
|
+
class ActiveRecordConfigurationHash
|
|
15
|
+
# @param configuration_hash [Hash{Symbol => Object}] an ActiveRecord
|
|
16
|
+
# database configuration hash (symbol keys, as found under an
|
|
17
|
+
# environment in `database.yml`).
|
|
18
|
+
def initialize(configuration_hash)
|
|
19
|
+
@configuration_hash = configuration_hash
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @return [Object, nil] the value of the `azure_workload_identity` key,
|
|
23
|
+
# or `nil` if not set. Truthy values opt the connection into Azure
|
|
24
|
+
# Workload Identity authentication.
|
|
25
|
+
def azure_workload_identity
|
|
26
|
+
@configuration_hash[:azure_workload_identity]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @return [String, nil] the database username (ActiveRecord's
|
|
30
|
+
# `:username` key).
|
|
31
|
+
def user
|
|
32
|
+
@configuration_hash[:username]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @return [String, nil] the database host.
|
|
36
|
+
def host
|
|
37
|
+
@configuration_hash[:host]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @return [Integer, String, nil] the database port.
|
|
41
|
+
def port
|
|
42
|
+
@configuration_hash[:port]
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "strscan"
|
|
4
|
+
require "pg"
|
|
5
|
+
|
|
6
|
+
require_relative "../error"
|
|
7
|
+
|
|
8
|
+
module PG
|
|
9
|
+
module AzureWorkloadIdentity
|
|
10
|
+
module ConnectionInfo
|
|
11
|
+
# Parses and manipulates a PostgreSQL connection string in libpq's
|
|
12
|
+
# key=value format (e.g. `"host=localhost user=admin port=5432"`).
|
|
13
|
+
#
|
|
14
|
+
# The parser preserves functional parity with libpq's `conninfo_parse`,
|
|
15
|
+
# supporting whitespace around the `=`, single-quoted values, and
|
|
16
|
+
# backslash escapes (`\\X` -> `X`) in both quoted and unquoted values.
|
|
17
|
+
#
|
|
18
|
+
# The non-standard `azure_workload_identity` parameter is recognized and
|
|
19
|
+
# extracted on construction so it can drive Azure Workload Identity
|
|
20
|
+
# authentication without being forwarded to libpq.
|
|
21
|
+
#
|
|
22
|
+
# @see https://github.com/postgres/postgres/blob/REL_18_4/src/interfaces/libpq/fe-connect.c#L6289-L6444
|
|
23
|
+
class KeyValueString
|
|
24
|
+
# @return [String, nil] the value of the `azure_workload_identity`
|
|
25
|
+
# parameter that was present in the original connection string, or
|
|
26
|
+
# `nil` if the parameter was not set.
|
|
27
|
+
attr_reader :azure_workload_identity
|
|
28
|
+
|
|
29
|
+
# Parses the given connection string and extracts the
|
|
30
|
+
# `azure_workload_identity` parameter (if any) so it is not forwarded to
|
|
31
|
+
# libpq.
|
|
32
|
+
#
|
|
33
|
+
# @param connection_string [String] a PostgreSQL key=value connection
|
|
34
|
+
# string.
|
|
35
|
+
# @raise [Error] if the connection string is malformed.
|
|
36
|
+
def initialize(connection_string)
|
|
37
|
+
@params = Parser.new(connection_string).parse
|
|
38
|
+
@azure_workload_identity = @params.delete(:azure_workload_identity)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @return [String, nil] the value of the `user` parameter.
|
|
42
|
+
def user
|
|
43
|
+
@params[:user]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @return [String, nil] the value of the `host` parameter.
|
|
47
|
+
def host
|
|
48
|
+
@params[:host]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @return [String, nil] the value of the `port` parameter.
|
|
52
|
+
def port
|
|
53
|
+
@params[:port]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Sets the `password` parameter.
|
|
57
|
+
#
|
|
58
|
+
# @param value [String] the password to set.
|
|
59
|
+
# @return [String] the value that was set.
|
|
60
|
+
def password=(value)
|
|
61
|
+
@params[:password] = value
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Serializes the parameters back to a libpq key=value connection
|
|
65
|
+
# string. Values are quoted/escaped via `PG::Connection.quote_connstr`
|
|
66
|
+
# so they remain valid when whitespace or special characters are
|
|
67
|
+
# present. The `azure_workload_identity` parameter extracted at
|
|
68
|
+
# construction is not included.
|
|
69
|
+
#
|
|
70
|
+
# @return [String] the connection string.
|
|
71
|
+
def to_s
|
|
72
|
+
@params.map { |key, value| "#{key}=#{PG::Connection.quote_connstr(value)}" }.join(" ")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# @return [Hash{Symbol => String}] a copy of the parsed parameters
|
|
76
|
+
# (excluding `azure_workload_identity`, which was extracted at
|
|
77
|
+
# construction).
|
|
78
|
+
def to_h
|
|
79
|
+
@params.dup
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Tokenizes a libpq key=value connection string into a hash of
|
|
83
|
+
# symbol-keyed parameters.
|
|
84
|
+
#
|
|
85
|
+
# The grammar mirrors `conninfo_parse`: whitespace separates pairs,
|
|
86
|
+
# whitespace is allowed around `=`, values may be single-quoted, and
|
|
87
|
+
# `\\X` unescapes to `X` in both quoted and unquoted values. A
|
|
88
|
+
# trailing backslash at EOF inside an unquoted value is silently
|
|
89
|
+
# dropped (matching libpq).
|
|
90
|
+
#
|
|
91
|
+
# @see https://github.com/postgres/postgres/blob/REL_18_4/src/interfaces/libpq/fe-connect.c#L6289-L6444
|
|
92
|
+
class Parser
|
|
93
|
+
# @param connection_string [String] the connection string to parse.
|
|
94
|
+
def initialize(connection_string)
|
|
95
|
+
@buffer = StringScanner.new(connection_string)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Parses the connection string passed to the constructor.
|
|
99
|
+
#
|
|
100
|
+
# @return [Hash{Symbol => String}] the parsed parameters, in the
|
|
101
|
+
# order they appeared in the input. Later occurrences of the same
|
|
102
|
+
# key overwrite earlier ones.
|
|
103
|
+
# @raise [Error] when a keyword is missing, `=` is missing
|
|
104
|
+
# after a keyword, or a quoted value is not terminated.
|
|
105
|
+
def parse # rubocop:disable Metrics/MethodLength
|
|
106
|
+
result = {} # : Hash[Symbol, String]
|
|
107
|
+
|
|
108
|
+
skip_whitespace
|
|
109
|
+
until @buffer.eos?
|
|
110
|
+
key = parse_key
|
|
111
|
+
skip_whitespace
|
|
112
|
+
assert_followed_by_equals_sign key
|
|
113
|
+
skip_whitespace
|
|
114
|
+
value = parse_value
|
|
115
|
+
skip_whitespace
|
|
116
|
+
|
|
117
|
+
result[key] = value
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
@buffer.reset
|
|
121
|
+
result
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
private
|
|
125
|
+
|
|
126
|
+
# Advances the scanner past any run of whitespace characters.
|
|
127
|
+
#
|
|
128
|
+
# @return [Integer, nil] the number of characters consumed, or
|
|
129
|
+
# `nil` if none matched.
|
|
130
|
+
def skip_whitespace
|
|
131
|
+
@buffer.skip(/\s+/)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Reads the next keyword (any run of non-whitespace, non-`=`
|
|
135
|
+
# characters) and returns it as a symbol.
|
|
136
|
+
#
|
|
137
|
+
# @return [Symbol] the keyword.
|
|
138
|
+
# @raise [Error] if no keyword characters are present at the
|
|
139
|
+
# current position.
|
|
140
|
+
def parse_key
|
|
141
|
+
key = @buffer.scan(/[^=\s]+/)
|
|
142
|
+
raise Error, %(Missing parameter name before "#{@buffer.rest}") unless key
|
|
143
|
+
|
|
144
|
+
key.to_sym
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Consumes a single `=` character at the current position.
|
|
148
|
+
#
|
|
149
|
+
# @param key [Symbol] the keyword that should be followed by `=`
|
|
150
|
+
# (used in the error message).
|
|
151
|
+
# @raise [Error] if the next character is not `=`.
|
|
152
|
+
# @return [void]
|
|
153
|
+
def assert_followed_by_equals_sign(key)
|
|
154
|
+
raise Error, %(Missing "=" after "#{key}") unless @buffer.getch == "="
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Reads a value, dispatching to the quoted or unquoted form based
|
|
158
|
+
# on whether the next character is a single quote.
|
|
159
|
+
#
|
|
160
|
+
# @return [String] the parsed value (may be empty).
|
|
161
|
+
# @raise [Error] if a quoted value is not terminated.
|
|
162
|
+
def parse_value
|
|
163
|
+
if @buffer.peek(1) == "'"
|
|
164
|
+
parse_quoted_value
|
|
165
|
+
else
|
|
166
|
+
parse_unquoted_value
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Reads a single-quoted value, applying `\\X` -> `X` escape
|
|
171
|
+
# handling.
|
|
172
|
+
#
|
|
173
|
+
# @return [String] the value with quotes stripped and escapes
|
|
174
|
+
# applied.
|
|
175
|
+
# @raise [Error] if the closing quote is missing.
|
|
176
|
+
def parse_quoted_value
|
|
177
|
+
value = +""
|
|
178
|
+
@buffer.skip(/'/)
|
|
179
|
+
loop do
|
|
180
|
+
value << (@buffer.scan(/[^'\\]+/) || "")
|
|
181
|
+
case @buffer.getch
|
|
182
|
+
when "'" then return value
|
|
183
|
+
when "\\" then value << (@buffer.getch || "")
|
|
184
|
+
else raise Error, "Unterminated quoted value"
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Reads an unquoted value, terminating at the next whitespace and
|
|
190
|
+
# applying `\\X` -> `X` escape handling. A trailing backslash at
|
|
191
|
+
# EOF is silently dropped (matching libpq).
|
|
192
|
+
#
|
|
193
|
+
# @return [String] the value with escapes applied (may be empty).
|
|
194
|
+
def parse_unquoted_value
|
|
195
|
+
value = +""
|
|
196
|
+
loop do
|
|
197
|
+
value << (@buffer.scan(/[^\s\\]+/) || "")
|
|
198
|
+
return value unless @buffer.getch == "\\"
|
|
199
|
+
|
|
200
|
+
value << (@buffer.getch || "")
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module PG
|
|
6
|
+
module AzureWorkloadIdentity
|
|
7
|
+
module ConnectionInfo
|
|
8
|
+
# Parses and manipulates a PostgreSQL connection string in URI format
|
|
9
|
+
# (e.g. `postgres://user@host:5432/db?param=value`).
|
|
10
|
+
#
|
|
11
|
+
# Connection parameters may be provided either as URI components (userinfo,
|
|
12
|
+
# host, port) or as query-string parameters. Accessors prefer the URI
|
|
13
|
+
# component and fall back to the query string when absent.
|
|
14
|
+
#
|
|
15
|
+
# The non-standard `azure_workload_identity` query parameter is recognized
|
|
16
|
+
# and extracted on construction so the value can drive Azure Workload
|
|
17
|
+
# Identity authentication while keeping the rest of the connection string
|
|
18
|
+
# valid for libpq.
|
|
19
|
+
class URI
|
|
20
|
+
# Tests whether a connection string is in URI format.
|
|
21
|
+
#
|
|
22
|
+
# @param connection_string [String] the connection string to test.
|
|
23
|
+
# @return [Boolean] `true` if the string matches the RFC 2396 absolute
|
|
24
|
+
# URI reference grammar, `false` otherwise.
|
|
25
|
+
def self.matches?(connection_string)
|
|
26
|
+
/\A#{::URI::RFC2396_PARSER.regexp[:ABS_URI_REF]}\z/.match?(connection_string)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @return [String, nil] the value of the `azure_workload_identity` query
|
|
30
|
+
# parameter that was present in the original connection string, or
|
|
31
|
+
# `nil` if the parameter was not set.
|
|
32
|
+
attr_reader :azure_workload_identity
|
|
33
|
+
|
|
34
|
+
# Parses the given connection string and extracts the
|
|
35
|
+
# `azure_workload_identity` query parameter (if any) so it is not
|
|
36
|
+
# forwarded to libpq.
|
|
37
|
+
#
|
|
38
|
+
# @param connection_string [String] a PostgreSQL connection URI.
|
|
39
|
+
def initialize(connection_string)
|
|
40
|
+
@uri = ::URI.parse(connection_string)
|
|
41
|
+
@query = @uri.query ? ::URI.decode_www_form(@uri.query).to_h : {} # : Hash[String, String]
|
|
42
|
+
@azure_workload_identity = @query.delete("azure_workload_identity")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @return [String, nil] the username from the URI userinfo, falling back
|
|
46
|
+
# to the `user` query parameter.
|
|
47
|
+
def user
|
|
48
|
+
@uri.user || @query["user"]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @return [String, nil] the host from the URI authority, falling back to
|
|
52
|
+
# the `host` query parameter when the URI host is missing or empty.
|
|
53
|
+
def host
|
|
54
|
+
return @query["host"] if @uri.host.nil? || @uri.host.empty?
|
|
55
|
+
|
|
56
|
+
@uri.host
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @return [Integer, String, nil] the port from the URI authority,
|
|
60
|
+
# falling back to the `port` query parameter.
|
|
61
|
+
def port
|
|
62
|
+
@uri.port || @query["port"]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Sets the password, storing it as a `password` query parameter and
|
|
66
|
+
# clearing any password embedded in the URI userinfo.
|
|
67
|
+
#
|
|
68
|
+
# @param value [String] the password to set.
|
|
69
|
+
# @return [String] the value that was set.
|
|
70
|
+
def password=(value)
|
|
71
|
+
@uri.password = nil
|
|
72
|
+
@query["password"] = value
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Serializes the connection info back to a URI string.
|
|
76
|
+
#
|
|
77
|
+
# The query string is rebuilt from the current parameters (so the
|
|
78
|
+
# `azure_workload_identity` flag is omitted) and the scheme is normalized
|
|
79
|
+
# to always include the `"//"` authority separator, ensuring the result
|
|
80
|
+
# is a valid libpq connection URI.
|
|
81
|
+
#
|
|
82
|
+
# @return [String] the connection URI.
|
|
83
|
+
def to_s
|
|
84
|
+
@uri.query = ::URI.encode_www_form(@query)
|
|
85
|
+
|
|
86
|
+
@uri.to_s.sub(%r{^#{@uri.scheme}:(?!//)}, "#{@uri.scheme}://")
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pg"
|
|
4
|
+
|
|
5
|
+
require_relative "connection_info/uri"
|
|
6
|
+
require_relative "connection_info/key_value_string"
|
|
7
|
+
require_relative "connection_info/active_record_configuration_hash"
|
|
8
|
+
|
|
9
|
+
module PG
|
|
10
|
+
module AzureWorkloadIdentity
|
|
11
|
+
# Factory methods that wrap a PostgreSQL connection description (a
|
|
12
|
+
# connection string or an ActiveRecord configuration hash) in an object
|
|
13
|
+
# exposing a uniform accessor contract (`user`, `host`, `port`,
|
|
14
|
+
# `azure_workload_identity`, ...).
|
|
15
|
+
#
|
|
16
|
+
# Concrete return types are {URI}, {KeyValueString}, and
|
|
17
|
+
# {ActiveRecordConfigurationHash}.
|
|
18
|
+
module ConnectionInfo
|
|
19
|
+
# Wraps a PostgreSQL connection string, dispatching to the URI or
|
|
20
|
+
# key=value parser based on its format.
|
|
21
|
+
#
|
|
22
|
+
# @param connection_string [String] either a URI-style connection
|
|
23
|
+
# string (e.g. `"postgres://user@host/db"`) or a libpq key=value
|
|
24
|
+
# string (e.g. `"host=localhost user=admin"`).
|
|
25
|
+
# @return [URI, KeyValueString] a wrapper around the parsed
|
|
26
|
+
# connection parameters.
|
|
27
|
+
def self.from_connection_string(connection_string)
|
|
28
|
+
if URI.matches?(connection_string)
|
|
29
|
+
URI.new(connection_string)
|
|
30
|
+
else
|
|
31
|
+
KeyValueString.new(connection_string)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Wraps an ActiveRecord database configuration hash.
|
|
36
|
+
#
|
|
37
|
+
# @param configuration_hash [Hash{Symbol => Object}] an ActiveRecord
|
|
38
|
+
# database configuration hash (as produced from `database.yml`).
|
|
39
|
+
# @return [ActiveRecordConfigurationHash] a wrapper around the
|
|
40
|
+
# configuration hash.
|
|
41
|
+
def self.from_active_record_configuration_hash(configuration_hash)
|
|
42
|
+
ActiveRecordConfigurationHash.new(configuration_hash)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "azure_workload_identity/auth_token_generator"
|
|
4
|
+
require_relative "azure_workload_identity/connection"
|
|
5
|
+
require_relative "azure_workload_identity/error"
|
|
6
|
+
require_relative "azure_workload_identity/version"
|
|
7
|
+
|
|
8
|
+
module PG
|
|
9
|
+
# Namespace for the pg-azure_workload_identity gem, which provides Azure
|
|
10
|
+
# Workload Identity authentication support for the pg gem and related tools.
|
|
11
|
+
module AzureWorkloadIdentity
|
|
12
|
+
# Returns the process-wide {AuthTokenGenerator}, lazily instantiated on
|
|
13
|
+
# first access via {AuthTokenGenerator.default} so that env-loading
|
|
14
|
+
# libraries (e.g. dotenv) have a chance to populate `ENV` before the
|
|
15
|
+
# generator captures its configuration.
|
|
16
|
+
#
|
|
17
|
+
# @return [AuthTokenGenerator]
|
|
18
|
+
def self.auth_token_generator
|
|
19
|
+
@auth_token_generator ||= AuthTokenGenerator.default
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Overrides the process-wide {AuthTokenGenerator}. Mainly useful for
|
|
23
|
+
# tests or for callers that want to provide a custom-configured
|
|
24
|
+
# generator instead of the env-driven default.
|
|
25
|
+
#
|
|
26
|
+
# @param generator [#call] any callable that returns an access token.
|
|
27
|
+
# @return [#call] the generator that was set.
|
|
28
|
+
def self.auth_token_generator=(generator)
|
|
29
|
+
@auth_token_generator = generator
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
PG::Connection.singleton_class.prepend Connection
|
|
33
|
+
|
|
34
|
+
if defined?(ActiveRecord)
|
|
35
|
+
require "active_record/connection_adapters/postgresql_adapter"
|
|
36
|
+
|
|
37
|
+
require_relative "azure_workload_identity/active_record_postgresql_adapter"
|
|
38
|
+
require_relative "azure_workload_identity/active_record_postgresql_database_tasks"
|
|
39
|
+
|
|
40
|
+
ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.singleton_class.prepend ActiveRecordPostgreSQLAdapter
|
|
41
|
+
ActiveRecord::Tasks::PostgreSQLDatabaseTasks.prepend ActiveRecordPostgreSQLDatabaseTasks
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lib/pg/azure_workload_identity/version"
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = "pg-azure_workload_identity"
|
|
7
|
+
spec.version = PG::AzureWorkloadIdentity::VERSION
|
|
8
|
+
spec.authors = ["Simon Schmid"]
|
|
9
|
+
spec.email = ["simon@at-point.ch"]
|
|
10
|
+
|
|
11
|
+
spec.summary = "Workload identity authentication for Azure PostgreSQL flexible server/"
|
|
12
|
+
spec.homepage = "https://github.com/atpoint-cloud/pg-azure_workload_identity"
|
|
13
|
+
spec.license = "MIT"
|
|
14
|
+
spec.required_ruby_version = ">= 3.3"
|
|
15
|
+
spec.metadata["bug_tracker_uri"] = "#{spec.homepage}/issues"
|
|
16
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
|
17
|
+
spec.metadata["source_code_uri"] = "#{spec.homepage}/blob/main"
|
|
18
|
+
spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
|
|
19
|
+
|
|
20
|
+
# Uncomment the line below to require MFA for gem pushes.
|
|
21
|
+
# This helps protect your gem from supply chain attacks by ensuring
|
|
22
|
+
# no one can publish a new version without multi-factor authentication.
|
|
23
|
+
# See: https://guides.rubygems.org/mfa-requirement-opt-in/
|
|
24
|
+
spec.metadata["rubygems_mfa_required"] = "true"
|
|
25
|
+
|
|
26
|
+
# Specify which files should be added to the gem when it is released.
|
|
27
|
+
spec.files = Dir[
|
|
28
|
+
"lib/**/*.rb",
|
|
29
|
+
".yardopts",
|
|
30
|
+
"CHANGELOG.md",
|
|
31
|
+
"LICENSE.txt",
|
|
32
|
+
"README.md",
|
|
33
|
+
"pg-azure_workload_identity.gemspec"
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
spec.require_paths = ["lib"]
|
|
37
|
+
|
|
38
|
+
# Uncomment to register a new dependency of your gem
|
|
39
|
+
spec.add_dependency "pg", "~> 1.5"
|
|
40
|
+
|
|
41
|
+
# For more information and examples about making a new gem, check out our
|
|
42
|
+
# guide at: https://guides.rubygems.org/make-your-own-gem/
|
|
43
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: pg-azure_workload_identity
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Simon Schmid
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: pg
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '1.5'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '1.5'
|
|
26
|
+
email:
|
|
27
|
+
- simon@at-point.ch
|
|
28
|
+
executables: []
|
|
29
|
+
extensions: []
|
|
30
|
+
extra_rdoc_files: []
|
|
31
|
+
files:
|
|
32
|
+
- ".yardopts"
|
|
33
|
+
- CHANGELOG.md
|
|
34
|
+
- LICENSE.txt
|
|
35
|
+
- README.md
|
|
36
|
+
- lib/pg/azure_workload_identity.rb
|
|
37
|
+
- lib/pg/azure_workload_identity/active_record_postgresql_adapter.rb
|
|
38
|
+
- lib/pg/azure_workload_identity/active_record_postgresql_database_tasks.rb
|
|
39
|
+
- lib/pg/azure_workload_identity/auth_token.rb
|
|
40
|
+
- lib/pg/azure_workload_identity/auth_token_generator.rb
|
|
41
|
+
- lib/pg/azure_workload_identity/auth_token_injector.rb
|
|
42
|
+
- lib/pg/azure_workload_identity/connection.rb
|
|
43
|
+
- lib/pg/azure_workload_identity/connection_info.rb
|
|
44
|
+
- lib/pg/azure_workload_identity/connection_info/active_record_configuration_hash.rb
|
|
45
|
+
- lib/pg/azure_workload_identity/connection_info/key_value_string.rb
|
|
46
|
+
- lib/pg/azure_workload_identity/connection_info/uri.rb
|
|
47
|
+
- lib/pg/azure_workload_identity/error.rb
|
|
48
|
+
- lib/pg/azure_workload_identity/version.rb
|
|
49
|
+
- pg-azure_workload_identity.gemspec
|
|
50
|
+
homepage: https://github.com/atpoint-cloud/pg-azure_workload_identity
|
|
51
|
+
licenses:
|
|
52
|
+
- MIT
|
|
53
|
+
metadata:
|
|
54
|
+
bug_tracker_uri: https://github.com/atpoint-cloud/pg-azure_workload_identity/issues
|
|
55
|
+
homepage_uri: https://github.com/atpoint-cloud/pg-azure_workload_identity
|
|
56
|
+
source_code_uri: https://github.com/atpoint-cloud/pg-azure_workload_identity/blob/main
|
|
57
|
+
changelog_uri: https://github.com/atpoint-cloud/pg-azure_workload_identity/blob/main/CHANGELOG.md
|
|
58
|
+
rubygems_mfa_required: 'true'
|
|
59
|
+
rdoc_options: []
|
|
60
|
+
require_paths:
|
|
61
|
+
- lib
|
|
62
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
63
|
+
requirements:
|
|
64
|
+
- - ">="
|
|
65
|
+
- !ruby/object:Gem::Version
|
|
66
|
+
version: '3.3'
|
|
67
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
68
|
+
requirements:
|
|
69
|
+
- - ">="
|
|
70
|
+
- !ruby/object:Gem::Version
|
|
71
|
+
version: '0'
|
|
72
|
+
requirements: []
|
|
73
|
+
rubygems_version: 4.0.10
|
|
74
|
+
specification_version: 4
|
|
75
|
+
summary: Workload identity authentication for Azure PostgreSQL flexible server/
|
|
76
|
+
test_files: []
|