Skip to content

Commit c4e5a9e

Browse files
authored
feat(ingest/metabase): API key support in Metabase source (#12711)
1 parent 3083dd0 commit c4e5a9e

File tree

3 files changed

+138
-32
lines changed

3 files changed

+138
-32
lines changed

metadata-ingestion/docs/sources/metabase/metabase.yml

+1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ source:
77
# Credentials
88
username: user
99
password: pass
10+
api_key: key
1011

1112
# Options
1213
default_schema: public

metadata-ingestion/src/datahub/ingestion/source/metabase.py

+54-32
Original file line numberDiff line numberDiff line change
@@ -69,9 +69,19 @@ class MetabaseConfig(DatasetLineageProviderConfigBase, StatefulIngestionConfigBa
6969
default=None,
7070
description="optional URL to use in links (if `connect_uri` is only for ingestion)",
7171
)
72-
username: Optional[str] = Field(default=None, description="Metabase username.")
72+
username: Optional[str] = Field(
73+
default=None,
74+
description="Metabase username, used when an API key is not provided.",
75+
)
7376
password: Optional[pydantic.SecretStr] = Field(
74-
default=None, description="Metabase password."
77+
default=None,
78+
description="Metabase password, used when an API key is not provided.",
79+
)
80+
81+
# https://www.metabase.com/learn/metabase-basics/administration/administration-and-operation/metabase-api#example-get-request
82+
api_key: Optional[pydantic.SecretStr] = Field(
83+
default=None,
84+
description="Metabase API key. If provided, the username and password will be ignored. Recommended method.",
7585
)
7686
# TODO: Check and remove this if no longer needed.
7787
# Config database_alias is removed from sql sources.
@@ -178,30 +188,40 @@ def __init__(self, ctx: PipelineContext, config: MetabaseConfig):
178188
self.source_config: MetabaseConfig = config
179189

180190
def setup_session(self) -> None:
181-
login_response = requests.post(
182-
f"{self.config.connect_uri}/api/session",
183-
None,
184-
{
185-
"username": self.config.username,
186-
"password": (
187-
self.config.password.get_secret_value()
188-
if self.config.password
189-
else None
190-
),
191-
},
192-
)
191+
self.session = requests.session()
192+
if self.config.api_key:
193+
self.session.headers.update(
194+
{
195+
"x-api-key": self.config.api_key.get_secret_value(),
196+
"Content-Type": "application/json",
197+
"Accept": "*/*",
198+
}
199+
)
200+
else:
201+
# If no API key is provided, generate a session token using username and password.
202+
login_response = requests.post(
203+
f"{self.config.connect_uri}/api/session",
204+
None,
205+
{
206+
"username": self.config.username,
207+
"password": (
208+
self.config.password.get_secret_value()
209+
if self.config.password
210+
else None
211+
),
212+
},
213+
)
193214

194-
login_response.raise_for_status()
195-
self.access_token = login_response.json().get("id", "")
215+
login_response.raise_for_status()
216+
self.access_token = login_response.json().get("id", "")
196217

197-
self.session = requests.session()
198-
self.session.headers.update(
199-
{
200-
"X-Metabase-Session": f"{self.access_token}",
201-
"Content-Type": "application/json",
202-
"Accept": "*/*",
203-
}
204-
)
218+
self.session.headers.update(
219+
{
220+
"X-Metabase-Session": f"{self.access_token}",
221+
"Content-Type": "application/json",
222+
"Accept": "*/*",
223+
}
224+
)
205225

206226
# Test the connection
207227
try:
@@ -217,15 +237,17 @@ def setup_session(self) -> None:
217237
)
218238

219239
def close(self) -> None:
220-
response = requests.delete(
221-
f"{self.config.connect_uri}/api/session",
222-
headers={"X-Metabase-Session": self.access_token},
223-
)
224-
if response.status_code not in (200, 204):
225-
self.report.report_failure(
226-
title="Unable to Log User Out",
227-
message=f"Unable to logout for user {self.config.username}",
240+
# API key authentication does not require session closure.
241+
if not self.config.api_key:
242+
response = requests.delete(
243+
f"{self.config.connect_uri}/api/session",
244+
headers={"X-Metabase-Session": self.access_token},
228245
)
246+
if response.status_code not in (200, 204):
247+
self.report.report_failure(
248+
title="Unable to Log User Out",
249+
message=f"Unable to logout for user {self.config.username}",
250+
)
229251
super().close()
230252

231253
def emit_dashboard_mces(self) -> Iterable[MetadataWorkUnit]:

metadata-ingestion/tests/unit/test_metabase_source.py

+83
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
from unittest.mock import MagicMock, patch
2+
3+
import pydantic
4+
15
from datahub.ingestion.api.common import PipelineContext
26
from datahub.ingestion.source.metabase import (
37
MetabaseConfig,
@@ -52,3 +56,82 @@ def test_set_display_uri():
5256

5357
assert config.connect_uri == "localhost:3000"
5458
assert config.display_uri == display_uri
59+
60+
61+
@patch("requests.session")
62+
def test_connection_uses_api_key_if_in_config(mock_session):
63+
metabase_config = MetabaseConfig(
64+
connect_uri="localhost:3000", api_key=pydantic.SecretStr("key")
65+
)
66+
ctx = PipelineContext(run_id="metabase-test-apikey")
67+
68+
mock_session_instance = MagicMock()
69+
mock_session_instance.headers = {}
70+
mock_session.return_value = mock_session_instance
71+
72+
mock_response = MagicMock()
73+
mock_response.status_code = 200
74+
mock_session_instance.get.return_value = mock_response
75+
76+
metabase_source = MetabaseSource(ctx, metabase_config)
77+
metabase_source.close()
78+
79+
mock_session_instance.get.assert_called_once_with("localhost:3000/api/user/current")
80+
request_headers = mock_session_instance.headers
81+
assert request_headers["x-api-key"] == "key"
82+
83+
84+
@patch("requests.delete")
85+
@patch("requests.Session.get")
86+
@patch("requests.post")
87+
def test_create_session_from_config_username_password(mock_post, mock_get, mock_delete):
88+
metabase_config = MetabaseConfig(
89+
connect_uri="localhost:3000", username="un", password=pydantic.SecretStr("pwd")
90+
)
91+
ctx = PipelineContext(run_id="metabase-test")
92+
93+
mock_response = MagicMock()
94+
mock_response.status_code = 200
95+
mock_get.return_value = mock_response
96+
mock_post.return_value = mock_response
97+
mock_delete.return_value = mock_response
98+
99+
metabase_source = MetabaseSource(ctx, metabase_config)
100+
metabase_source.close()
101+
102+
kwargs_post = mock_post.call_args
103+
assert kwargs_post[0][0] == "localhost:3000/api/session"
104+
assert kwargs_post[0][2]["password"] == "pwd"
105+
assert kwargs_post[0][2]["username"] == "un"
106+
107+
kwargs_get = mock_get.call_args
108+
assert kwargs_get[0][0] == "localhost:3000/api/user/current"
109+
110+
mock_delete.assert_called_once()
111+
112+
113+
@patch("requests.delete")
114+
@patch("requests.Session.get")
115+
@patch("requests.post")
116+
def test_fail_session_delete(mock_post, mock_get, mock_delete):
117+
metabase_config = MetabaseConfig(
118+
connect_uri="localhost:3000", username="un", password=pydantic.SecretStr("pwd")
119+
)
120+
ctx = PipelineContext(run_id="metabase-test")
121+
122+
mock_response = MagicMock()
123+
mock_response.status_code = 200
124+
mock_get.return_value = mock_response
125+
mock_post.return_value = mock_response
126+
127+
mock_response_delete = MagicMock()
128+
mock_response_delete.status_code = 400
129+
mock_delete.return_value = mock_response_delete
130+
131+
mock_report = MagicMock()
132+
133+
metabase_source = MetabaseSource(ctx, metabase_config)
134+
metabase_source.report = mock_report
135+
metabase_source.close()
136+
137+
mock_report.report_failure.assert_called_once()

0 commit comments

Comments
 (0)