Skip to content

Commit c850111

Browse files
fix(ingestion): refresh RDS IAM auth token per connection for MySQL (open-metadata#28730)
* fix(ingestion): refresh RDS IAM auth token per connection for MySQL RDS IAM auth tokens expire after ~15 minutes. The token was generated once in get_connection_url_common and baked into the SQLAlchemy URL, so a single engine reused one frozen token for every pooled connection and never refreshed it. With max_overflow=-1 and multi-threaded extraction, any connection opened after the 15-minute TTL — common on large catalogs and during profiling — authenticated with a stale token and failed mid run with "Access denied". Add RdsIamAuthTokenManager (caches the token, parses its expiry from the presigned-URL params, refreshes before it lapses) and wire a do_connect event listener in the MySQL connection handler that injects a fresh token on every new connection instead of embedding it in the URL. SSL is forced on for IAM (PyMySQL requires it) while preserving any existing SSL config. Scoped to the MySQL connector. The shared builders.py IAM path is still token-frozen for the other RDS connectors (Postgres, Redshift, Greenplum, Timescale); the reusable token manager lives in aws_client.py so they can adopt it next. * fix: Thread-safety in RdsIamAuthTokenManager, username not URL-encoded * fix: base_url drops databaseSchema + connectionOptions * fix(ingestion): enforce required TLS for MySQL RDS IAM connections The do_connect listener set cparams["ssl"] = {} when no SSL config was present. An empty dict is falsy, so PyMySQL only enables TLS in PREFERRED mode (self.ssl=True, _ssl_required=False), which silently falls back to plaintext if the server doesn't offer TLS. RDS IAM auth mandates TLS, so this is the wrong guarantee. Inject {"check_hostname": True} instead: a truthy dict makes PyMySQL treat SSL as required (_ssl_required=True) and verifies the RDS server cert. Explicitly provided ssl config is still preserved. Update test_listener_enables_ssl_required_by_pymysql_for_iam to assert a truthy value, and add test_injected_ssl_makes_pymysql_require_tls which drives the value through real PyMySQL and asserts conn.ssl is True and conn._ssl_required is True, so the test verifies actual TLS-required behavior rather than the mock value. --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 4a6c5ff commit c850111

4 files changed

Lines changed: 601 additions & 1 deletion

File tree

ingestion/src/metadata/clients/aws_client.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,11 @@
1313
"""
1414

1515
import datetime
16+
import threading
1617
from enum import Enum
1718
from functools import partial
1819
from typing import Any, Callable, Dict, Optional, Type, TypeVar # noqa: UP035
20+
from urllib.parse import parse_qs, urlparse
1921

2022
import boto3
2123
import botocore.session
@@ -263,3 +265,88 @@ def get_redshift_serverless_client(self):
263265

264266
def get_mwaa_client(self):
265267
return self.get_client(AWSServices.MWAA.value)
268+
269+
270+
RDS_IAM_TOKEN_DEFAULT_TTL = datetime.timedelta(minutes=15)
271+
RDS_IAM_TOKEN_REFRESH_THRESHOLD = datetime.timedelta(minutes=5)
272+
273+
274+
class RdsIamAuthTokenManager:
275+
"""
276+
Manages the lifecycle of an AWS RDS IAM authentication token.
277+
278+
RDS IAM tokens are short-lived (~15 minutes) presigned URLs. A long ingestion
279+
that opens new pooled connections after the token expires would otherwise
280+
authenticate with a stale token and fail. This manager caches the current
281+
token, derives its expiry from the presigned URL, and regenerates it shortly
282+
before it lapses so every connection receives a valid token.
283+
"""
284+
285+
def __init__(
286+
self,
287+
host: str,
288+
port: str,
289+
username: str,
290+
aws_config: AWSCredentials,
291+
refresh_threshold: datetime.timedelta = RDS_IAM_TOKEN_REFRESH_THRESHOLD,
292+
):
293+
self.host = host
294+
self.port = port
295+
self.username = username
296+
self.aws_config = aws_config
297+
self.refresh_threshold = refresh_threshold
298+
self._token: Optional[str] = None # noqa: UP045
299+
self._expires_at: Optional[datetime.datetime] = None # noqa: UP045
300+
self._lock = threading.Lock()
301+
302+
def get_token(self) -> str:
303+
"""Return a valid token, refreshing if needed.
304+
305+
The check-and-refresh is serialized: the engine shares one manager across
306+
all worker threads (each calls ``engine.connect()`` from the ``do_connect``
307+
listener), so without the lock multiple threads could refresh concurrently
308+
and observe a token paired with a stale expiry.
309+
"""
310+
with self._lock:
311+
if self._needs_refresh():
312+
self._refresh_token()
313+
if self._token is None:
314+
raise RuntimeError("Failed to generate RDS IAM authentication token")
315+
return self._token
316+
317+
def _needs_refresh(self) -> bool:
318+
needs_refresh = True
319+
if self._token is not None and self._expires_at is not None:
320+
time_left = self._expires_at - datetime.datetime.now(datetime.timezone.utc)
321+
needs_refresh = time_left <= self.refresh_threshold
322+
return needs_refresh
323+
324+
def _refresh_token(self) -> None:
325+
logger.debug(f"Generating RDS IAM auth token for {self.username}@{self.host}")
326+
rds_client = AWSClient(config=self.aws_config).get_rds_client()
327+
token = rds_client.generate_db_auth_token(
328+
DBHostname=self.host,
329+
Port=self.port,
330+
DBUsername=self.username,
331+
Region=self.aws_config.awsRegion,
332+
)
333+
self._token = token
334+
self._expires_at = self._parse_token_expiry(token)
335+
336+
def _parse_token_expiry(self, token: str) -> datetime.datetime:
337+
"""Derive token expiry from the presigned URL's X-Amz-Date / X-Amz-Expires.
338+
339+
Falls back to a conservative default TTL if the token can't be parsed so a
340+
malformed token still triggers periodic refresh rather than never expiring.
341+
"""
342+
now = datetime.datetime.now(datetime.timezone.utc)
343+
expires_at = now + RDS_IAM_TOKEN_DEFAULT_TTL
344+
try:
345+
query_params = parse_qs(urlparse(token).query)
346+
amz_date = query_params["X-Amz-Date"][0]
347+
amz_expires = int(query_params["X-Amz-Expires"][0])
348+
issued_at = datetime.datetime.strptime(amz_date, "%Y%m%dT%H%M%SZ").replace(tzinfo=datetime.timezone.utc)
349+
expires_at = issued_at + datetime.timedelta(seconds=amz_expires)
350+
except (KeyError, ValueError, IndexError) as exc:
351+
logger.warning(f"Could not parse RDS IAM token expiry, using default TTL: {exc}")
352+
return expires_at

ingestion/src/metadata/ingestion/source/database/mysql/connection.py

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,12 @@
1313
Source connection handler
1414
"""
1515

16-
from typing import Optional
16+
from typing import Any, Dict, Optional, cast # noqa: UP035
1717

1818
from sqlalchemy.engine import Engine
19+
from sqlalchemy.event import listen
1920

21+
from metadata.clients.aws_client import RdsIamAuthTokenManager
2022
from metadata.generated.schema.entity.automations.workflow import (
2123
Workflow as AutomationWorkflow,
2224
)
@@ -29,6 +31,9 @@
2931
from metadata.generated.schema.entity.services.connections.database.common.gcpCloudSqlConfig import (
3032
GcpCloudsqlConfigurationSource,
3133
)
34+
from metadata.generated.schema.entity.services.connections.database.common.iamAuthConfig import (
35+
IamAuthConfigurationSource,
36+
)
3237
from metadata.generated.schema.entity.services.connections.database.mysqlConnection import (
3338
MysqlConnection as MySQLConnectionConfig,
3439
)
@@ -72,12 +77,63 @@ def _get_client(self) -> Engine:
7277
if isinstance(connection.authType, GcpCloudsqlConfigurationSource):
7378
return self._get_cloudsql_engine(connection)
7479

80+
if isinstance(connection.authType, IamAuthConfigurationSource):
81+
return self._get_iam_engine(connection)
82+
7583
return create_generic_db_connection(
7684
connection=connection,
7785
get_connection_url_fn=get_connection_url_common,
7886
get_connection_args_fn=get_connection_args_common,
7987
)
8088

89+
def _get_iam_engine(self, connection: MySQLConnectionConfig) -> Engine:
90+
"""Build an engine that refreshes the RDS IAM token per connection.
91+
92+
RDS IAM tokens expire after ~15 minutes. Rather than baking a single token
93+
into the connection URL (which would go stale for connections opened later
94+
in a long ingestion), a ``do_connect`` listener injects a freshly minted
95+
token on every new connection.
96+
"""
97+
auth_type = cast("IamAuthConfigurationSource", connection.authType)
98+
if auth_type.awsConfig is None:
99+
raise ValueError("awsConfig is required for MySQL RDS IAM authentication")
100+
101+
host, port = connection.hostPort.split(":")
102+
token_manager = RdsIamAuthTokenManager(
103+
host=host,
104+
port=port,
105+
username=connection.username,
106+
aws_config=auth_type.awsConfig,
107+
)
108+
engine = create_generic_db_connection(
109+
connection=connection,
110+
get_connection_url_fn=self._build_iam_url,
111+
get_connection_args_fn=get_connection_args_common,
112+
)
113+
114+
def inject_iam_token(_dialect, _conn_rec, _cargs, cparams: Dict[str, Any]): # noqa: UP006
115+
cparams["password"] = token_manager.get_token()
116+
# RDS IAM auth requires TLS. A truthy ssl dict makes PyMySQL treat SSL
117+
# as required (an empty dict only yields PREFERRED, which can silently
118+
# fall back to plaintext). check_hostname also verifies the RDS cert.
119+
# Any explicitly provided ssl config is preserved.
120+
if "ssl" not in cparams:
121+
cparams["ssl"] = {"check_hostname": True}
122+
123+
listen(engine, "do_connect", inject_iam_token)
124+
return engine
125+
126+
@staticmethod
127+
def _build_iam_url(connection: MySQLConnectionConfig) -> str:
128+
"""Build the connection URL exactly like ``get_connection_url_common`` but
129+
without a password/token: the ``do_connect`` listener injects a fresh RDS
130+
IAM token per connection. Reusing the common helper with the IAM auth
131+
neutralized keeps databaseSchema and connectionOptions handling in sync.
132+
"""
133+
url_connection = connection.model_copy()
134+
url_connection.authType = BasicAuth(password="") # type: ignore
135+
return get_connection_url_common(url_connection)
136+
81137
def _get_cloudsql_engine(self, connection: MySQLConnectionConfig) -> Engine:
82138
try:
83139
from google.cloud.sql.connectors import Connector # noqa: PLC0415
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
# Copyright 2025 Collate
2+
# Licensed under the Collate Community License, Version 1.0 (the "License");
3+
# you may not use this file except in compliance with the License.
4+
# You may obtain a copy of the License at
5+
# https://github.com/open-metadata/OpenMetadata/blob/main/ingestion/LICENSE
6+
# Unless required by applicable law or agreed to in writing, software
7+
# distributed under the License is distributed on an "AS IS" BASIS,
8+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
9+
# See the License for the specific language governing permissions and
10+
# limitations under the License.
11+
"""
12+
Characterization tests for the SHARED RDS IAM auth path in ``builders.py``.
13+
14+
RDS IAM auth tokens expire after ~15 minutes. ``get_connection_url_common`` mints
15+
the token once and bakes it into the connection URL string, so a single engine
16+
reuses one frozen token for every pooled connection and cannot refresh it.
17+
18+
The MySQL connector now works around this at the connector level (it builds the
19+
engine without a token in the URL and attaches a ``do_connect`` listener that
20+
injects a fresh token per connection) — see
21+
``tests/unit/source/database/test_mysql_iam.py``.
22+
23+
These tests pin the behaviour of the SHARED helper, which is still token-frozen
24+
and is the remaining gap for the other RDS connectors that go through it
25+
(Postgres, Redshift, Greenplum, Timescale). They are expected to FAIL — and
26+
should be flipped to assert refreshed behaviour — once the shared path is fixed
27+
too. ``test_iam_token_is_used_for_authentication`` is the invariant that holds
28+
either way.
29+
"""
30+
31+
from unittest.mock import MagicMock, patch
32+
33+
import pytest
34+
35+
from metadata.generated.schema.entity.services.connections.database.common.iamAuthConfig import (
36+
IamAuthConfigurationSource,
37+
)
38+
from metadata.generated.schema.entity.services.connections.database.mysqlConnection import (
39+
MysqlConnection,
40+
)
41+
from metadata.generated.schema.security.credentials.awsCredentials import (
42+
AWSCredentials,
43+
)
44+
from metadata.ingestion.connections import builders
45+
46+
IAM_TOKEN = "iam-token-v1"
47+
HOST = "myrds.abc.us-east-1.rds.amazonaws.com"
48+
PORT = "3306"
49+
USERNAME = "iam_user"
50+
REGION = "us-east-1"
51+
52+
53+
def _iam_connection() -> MysqlConnection:
54+
return MysqlConnection(
55+
username=USERNAME,
56+
hostPort=f"{HOST}:{PORT}",
57+
authType=IamAuthConfigurationSource(awsConfig=AWSCredentials(awsRegion=REGION)),
58+
)
59+
60+
61+
@pytest.fixture
62+
def mock_rds_token():
63+
"""Patch AWSClient so generate_db_auth_token returns a fixed token and is counted."""
64+
with patch.object(builders, "AWSClient") as mock_aws_client:
65+
rds_client = MagicMock()
66+
rds_client.generate_db_auth_token.return_value = IAM_TOKEN
67+
mock_aws_client.return_value.get_rds_client.return_value = rds_client
68+
yield rds_client
69+
70+
71+
class TestRdsIamTokenRefresh:
72+
def test_iam_token_is_used_for_authentication(self, mock_rds_token):
73+
"""Invariant: the generated IAM token authenticates the connection.
74+
75+
Holds before and after the B1 fix.
76+
"""
77+
url = builders.get_connection_url_common(_iam_connection())
78+
79+
assert IAM_TOKEN in url
80+
assert mock_rds_token.generate_db_auth_token.call_args.kwargs == {
81+
"DBHostname": HOST,
82+
"Port": PORT,
83+
"DBUsername": USERNAME,
84+
"Region": REGION,
85+
}
86+
87+
def test_iam_token_is_generated_once_and_frozen_in_url(self, mock_rds_token):
88+
"""Documents the remaining shared-path gap: token baked into the URL string.
89+
90+
``get_connection_url_common`` embeds the token in the URL, so every engine
91+
built from it reuses that single token for its whole lifetime — it is never
92+
refreshed and goes stale after the ~15 min RDS IAM token TTL. The MySQL
93+
connector bypasses this path; the connectors that still rely on it do not.
94+
95+
EXPECTED TO FAIL once the shared path is fixed to mint a token per
96+
connection rather than embedding it in the URL.
97+
"""
98+
conn = _iam_connection()
99+
100+
first_url = builders.get_connection_url_common(conn)
101+
second_url = builders.get_connection_url_common(conn)
102+
103+
assert IAM_TOKEN in first_url, "token is embedded directly in the URL (the bug)"
104+
assert first_url == second_url, "same frozen token is reused, never refreshed"
105+
106+
def test_no_do_connect_listener_is_registered_for_iam(self, mock_rds_token):
107+
"""Documents the shared-path gap: no per-connection token injection hook.
108+
109+
The shared ``create_generic_db_connection`` does not attach a ``do_connect``
110+
listener for IAM, so an engine built purely through it cannot refresh the
111+
baked-in token once it expires. (The MySQL connector adds this listener
112+
itself; see test_mysql_iam.py.)
113+
114+
EXPECTED TO FAIL once the shared path grows its own IAM listener.
115+
"""
116+
with patch.object(builders, "listen") as mock_listen:
117+
builders.create_generic_db_connection(
118+
connection=_iam_connection(),
119+
get_connection_url_fn=builders.get_connection_url_common,
120+
get_connection_args_fn=builders.get_connection_args_common,
121+
)
122+
123+
do_connect_registrations = [call for call in mock_listen.call_args_list if "do_connect" in call.args]
124+
assert not do_connect_registrations, (
125+
"no do_connect listener is wired for IAM today — this is the gap B1 "
126+
"describes; once fixed, invert this assertion"
127+
)

0 commit comments

Comments
 (0)