Skip to content

Commit 84cb954

Browse files
[DBMON-5707] Add support for refreshing cloud provider tokens in Postgres integration (DataDog#21503)
* Update connection pool to handle cloud provider token refreshing * run format * Fix kwarg usernname * fixup * Add token provider tests * Lint * Add changelog --------- Co-authored-by: Seth Samuel <seth.samuel@datadoghq.com>
1 parent 32e0fdf commit 84cb954

5 files changed

Lines changed: 402 additions & 26 deletions

File tree

postgres/changelog.d/21503.fixed

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fixed support for refreshing IAM authentication and Azure Managed Identity tokens

postgres/datadog_checks/postgres/azure.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,16 @@
22
# All rights reserved
33
# Licensed under a 3-clause BSD style license (see LICENSE)
44

5+
from azure.core.credentials import AccessToken
56
from azure.identity import ManagedIdentityCredential
67

78
DEFAULT_PERMISSION_SCOPE = "https://ossrdbms-aad.database.windows.net/.default"
89

910

1011
# Use the azure identity API to generate a token that will be used
1112
# authenticate with either a system or user assigned managed identity
12-
def generate_managed_identity_token(client_id: str, identity_scope: str = None):
13+
def generate_managed_identity_token(client_id: str, identity_scope: str = None) -> AccessToken:
1314
credential = ManagedIdentityCredential(client_id=client_id)
1415
if not identity_scope:
1516
identity_scope = DEFAULT_PERMISSION_SCOPE
16-
return credential.get_token(identity_scope).token
17+
return credential.get_token(identity_scope)

postgres/datadog_checks/postgres/connection_pool.py

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import threading
66
import time
7+
from abc import ABC, abstractmethod
78
from collections import OrderedDict
89
from dataclasses import dataclass
910
from typing import Any, Dict, Optional, Tuple, Union
@@ -14,6 +15,100 @@
1415
from .cursor import CommenterCursor, SQLASCIITextLoader
1516

1617

18+
class TokenProvider(ABC):
19+
"""
20+
Interface for providing a token for managed authentication.
21+
"""
22+
23+
def __init__(self, *, skew_seconds: int = 60):
24+
self._skew = skew_seconds
25+
self._lock = threading.Lock()
26+
self._token: str | None = None
27+
self._expires_at: float = 0.0
28+
29+
def get_token(self) -> str:
30+
"""
31+
Get a token for managed authentication.
32+
"""
33+
now = time.time()
34+
with self._lock:
35+
if self._token is None or now >= self._expires_at - self._skew:
36+
token, expires_at = self._fetch_token()
37+
self._token = token
38+
self._expires_at = float(expires_at)
39+
return self._token # type: ignore[return-value]
40+
41+
@abstractmethod
42+
def _fetch_token(self) -> Tuple[str, float]:
43+
"""
44+
Return (token, expires_at_epoch_seconds).
45+
Implementations should return the absolute expiry; if the provider
46+
has a fixed TTL, compute expires_at = time.time() + ttl_seconds.
47+
"""
48+
49+
50+
class AWSTokenProvider(TokenProvider):
51+
"""
52+
Token provider for AWS IAM authentication.
53+
"""
54+
55+
TOKEN_TTL_SECONDS = 900 # 15 minutes
56+
57+
def __init__(
58+
self, host: str, port: int, username: str, region: str, *, role_arn: str = None, skew_seconds: int = 60
59+
):
60+
super().__init__(skew_seconds=skew_seconds)
61+
self.host = host
62+
self.port = port
63+
self.username = username
64+
self.region = region
65+
self.role_arn = role_arn
66+
67+
def _fetch_token(self) -> Tuple[str, float]:
68+
# Import aws only when this method is called
69+
from .aws import generate_rds_iam_token
70+
71+
token = generate_rds_iam_token(
72+
host=self.host, port=self.port, username=self.username, region=self.region, role_arn=self.role_arn
73+
)
74+
return token, time.time() + self.TOKEN_TTL_SECONDS
75+
76+
77+
class AzureTokenProvider(TokenProvider):
78+
"""
79+
Token provider for Azure Managed Identity.
80+
"""
81+
82+
def __init__(self, client_id: str, identity_scope: str = None, skew_seconds: int = 60):
83+
super().__init__(skew_seconds=skew_seconds)
84+
self.client_id = client_id
85+
self.identity_scope = identity_scope
86+
87+
def _fetch_token(self) -> Tuple[str, float]:
88+
# Import azure only when this method is called
89+
from .azure import generate_managed_identity_token
90+
91+
token = generate_managed_identity_token(client_id=self.client_id, identity_scope=self.identity_scope)
92+
return token.token, float(token.expires_at)
93+
94+
95+
class TokenAwareConnection(Connection):
96+
"""
97+
Connection that can be used for managed authentication.
98+
"""
99+
100+
token_provider: Optional[TokenProvider] = None
101+
102+
@classmethod
103+
def connect(cls, *args, **kwargs):
104+
"""
105+
Override the connection method to pass a refreshable token as the connection password.
106+
"""
107+
if cls.token_provider:
108+
kwargs["password"] = cls.token_provider.get_token()
109+
return super().connect(*args, **kwargs)
110+
111+
17112
@dataclass(frozen=True)
18113
class PostgresConnectionArgs:
19114
"""
@@ -25,6 +120,7 @@ class PostgresConnectionArgs:
25120
host: Optional[str] = None
26121
port: Optional[int] = None
27122
password: Optional[str] = None
123+
token_provider: Optional[TokenProvider] = None
28124
ssl_mode: Optional[str] = "allow"
29125
ssl_cert: Optional[str] = None
30126
ssl_root_cert: Optional[str] = None
@@ -86,6 +182,7 @@ def __init__(
86182
pool_config: Optional[Dict[str, Any]] = None,
87183
statement_timeout: Optional[int] = None, # milliseconds
88184
sqlascii_encodings: Optional[list[str]] = None,
185+
token_provider: Optional[TokenProvider] = None,
89186
) -> None:
90187
"""
91188
Initialize the pool manager.
@@ -101,13 +198,17 @@ def __init__(
101198
self.base_conn_args = base_conn_args
102199
self.statement_timeout = statement_timeout
103200
self.sqlascii_encodings = sqlascii_encodings
201+
self.token_provider = token_provider
104202

105203
self.pool_config = {
106204
**(pool_config or {}),
107205
"min_size": 0,
108206
"max_size": 2,
109207
"open": True,
110208
}
209+
210+
TokenAwareConnection.token_provider = self.token_provider
211+
111212
self.lock = threading.Lock()
112213
self.pools: OrderedDict[str, Tuple[ConnectionPool, float, bool]] = OrderedDict()
113214
self._closed = False
@@ -139,7 +240,12 @@ def _create_pool(self, dbname: str) -> ConnectionPool:
139240
"""
140241
kwargs = self.base_conn_args.as_kwargs(dbname=dbname)
141242

142-
return ConnectionPool(kwargs=kwargs, configure=self._configure_connection, **self.pool_config)
243+
return ConnectionPool(
244+
kwargs=kwargs,
245+
configure=self._configure_connection,
246+
connection_class=TokenAwareConnection,
247+
**self.pool_config,
248+
)
143249

144250
def get_connection(self, dbname: str, persistent: bool = False):
145251
"""

postgres/datadog_checks/postgres/postgres.py

Lines changed: 28 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,14 @@
2121
)
2222
from datadog_checks.base.utils.db.utils import resolve_db_host as agent_host_resolver
2323
from datadog_checks.base.utils.serialization import json
24-
from datadog_checks.postgres import aws, azure
25-
from datadog_checks.postgres.connection_pool import LRUConnectionPoolManager, PostgresConnectionArgs
24+
from datadog_checks.postgres.connection_pool import (
25+
AWSTokenProvider,
26+
AzureTokenProvider,
27+
LRUConnectionPoolManager,
28+
PostgresConnectionArgs,
29+
TokenAwareConnection,
30+
TokenProvider,
31+
)
2632
from datadog_checks.postgres.discovery import PostgresAutodiscovery
2733
from datadog_checks.postgres.health import PostgresHealth
2834
from datadog_checks.postgres.metadata import PostgresMetadata
@@ -165,6 +171,7 @@ def __init__(self, name, init_config, instances):
165171
base_conn_args=self.build_connection_args(),
166172
statement_timeout=self._config.query_timeout,
167173
sqlascii_encodings=self._config.query_encodings,
174+
token_provider=self.build_token_provider(),
168175
)
169176
self.metrics_cache = PostgresMetricsCache(self._config)
170177
self.statement_metrics = PostgresStatementMetrics(self, self._config)
@@ -913,38 +920,36 @@ def _collect_stats(self, instance_tags):
913920
for dynamic_query in self.dynamic_queries:
914921
dynamic_query.execute()
915922

923+
def build_token_provider(self) -> TokenProvider:
924+
if self._config.aws.managed_authentication.enabled:
925+
return AWSTokenProvider(
926+
host=self._config.host,
927+
port=self._config.port,
928+
username=self._config.username,
929+
region=self._config.aws.region,
930+
role_arn=self._config.aws.managed_authentication.role_arn,
931+
)
932+
elif self._config.azure.managed_authentication.enabled:
933+
return AzureTokenProvider(
934+
client_id=self._config.azure.managed_authentication.client_id,
935+
identity_scope=self._config.azure.managed_authentication.identity_scope,
936+
)
937+
else:
938+
return None
939+
916940
def build_connection_args(self) -> PostgresConnectionArgs:
917941
if self._config.host == 'localhost' and self._config.password == '':
918942
return PostgresConnectionArgs(
919943
application_name=self._config.application_name,
920944
username=self._config.username,
921945
)
922946
else:
923-
password = self._config.password
924-
if self._config.aws.managed_authentication.enabled:
925-
password = aws.generate_rds_iam_token(
926-
host=self._config.host,
927-
username=self._config.username,
928-
port=self._config.port,
929-
region=self._config.aws.region,
930-
role_arn=self._config.aws.managed_authentication.role_arn,
931-
)
932-
elif self._config.azure.managed_authentication.enabled:
933-
client_id = self._config.azure.managed_authentication.client_id
934-
identity_scope = self._config.azure.managed_authentication.identity_scope
935-
password = azure.generate_managed_identity_token(client_id=client_id, identity_scope=identity_scope)
936-
937-
self.log.debug(
938-
"Try to connect to %s with %s",
939-
self._config.host,
940-
"password" if password == self._config.password else "token",
941-
)
942947
return PostgresConnectionArgs(
943948
application_name=self._config.application_name,
944949
username=self._config.username,
945950
host=self._config.host,
946951
port=self._config.port,
947-
password=password,
952+
password=self._config.password,
948953
ssl_mode=self._config.ssl,
949954
ssl_cert=self._config.ssl_cert,
950955
ssl_root_cert=self._config.ssl_root_cert,
@@ -956,7 +961,7 @@ def _new_connection(self, dbname):
956961
# TODO: Keeping this main connection outside of the pool for now to keep existing behavior.
957962
# We should move this to the pool in the future.
958963
conn_args = self.build_connection_args()
959-
conn = psycopg.connect(**conn_args.as_kwargs(dbname=dbname))
964+
conn = TokenAwareConnection.connect(**conn_args.as_kwargs(dbname=dbname))
960965
self.db_pool._configure_connection(conn)
961966
return conn
962967

0 commit comments

Comments
 (0)