From 9dad3b9086a9a70141f51bc07c74eed69b2682ea Mon Sep 17 00:00:00 2001 From: Mike Gouline <1960272+gouline@users.noreply.github.com> Date: Tue, 2 Apr 2024 14:13:50 +1100 Subject: [PATCH] Support for API keys and session ID deprecation (#249) --- README.md | 21 +++++++++++++-------- dbtmetabase/__main__.py | 17 ++++++++++++++--- dbtmetabase/core.py | 9 ++++++--- dbtmetabase/metabase.py | 30 ++++++++++++++++++------------ tests/_mocks.py | 1 + 5 files changed, 52 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 0c1ae5f..63be135 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,15 @@ Once `dbt compile` finishes, `manifest.json` can be found in the `target/` direc See [dbt documentation](https://docs.getdbt.com/docs/running-a-dbt-project/run-your-dbt-projects) for more information. +## Metabase API + +All commands require authentication against the [Metabase API](https://www.metabase.com/docs/latest/api-documentation) using one of these methods: + +* API key (`--metabase-api-key`) + - Strongly **recommended** for automation, see [documentation](https://www.metabase.com/docs/latest/people-and-groups/api-keys) (Metabase 49 or later). +* Username and password (`--metabase-username` / `--metabase-password`) + - Fallback for older versions of Metabase and smaller instances. + ## Exporting Models Let's start by defining a short sample `schema.yml` as below. @@ -81,8 +90,7 @@ This is already enough to propagate the primary keys, foreign keys and descripti dbt-metabase models \ --manifest-path target/manifest.json \ --metabase-url https://metabase.example.com \ - --metabase-username user@example.com \ - --metabase-password Password123 \ + --metabase-api-key mb_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX= \ --metabase-database business \ --include-schemas public ``` @@ -208,8 +216,7 @@ dbt-metabase allows you to extract questions and dashboards from Metabase as [db dbt-metabase exposures \ --manifest-path ./target/manifest.json \ --metabase-url https://metabase.example.com \ - --metabase-username user@example.com \ - --metabase-password Password123 \ + --metabase-api-key mb_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX= \ --output-path models/ \ --exclude-collections "temp*" ``` @@ -259,8 +266,7 @@ A configuration file can be created in `~/.dbt-metabase/config.yml` for dbt-meta config: manifest_path: target/manifest.json metabase_url: https://metabase.example.com - metabase_username: user@example.com - metabase_password: Password123 + metabase_api_key: mb_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX= # Configuration specific to models command models: metabase_database: business @@ -282,8 +288,7 @@ from dbtmetabase import DbtMetabase, Filter c = DbtMetabase( manifest_path="target/manifest.json", metabase_url="https://metabase.example.com", - metabase_username="user@example.com", - metabase_password="Password123", + metabase_api_key="mb_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX=", ) # Exporting models diff --git a/dbtmetabase/__main__.py b/dbtmetabase/__main__.py index 779c5c5..d8771b6 100644 --- a/dbtmetabase/__main__.py +++ b/dbtmetabase/__main__.py @@ -95,13 +95,21 @@ def _add_setup(func: Callable) -> Callable: type=click.STRING, help="Metabase URL, e.g. 'https://metabase.example.com'.", ) + @click.option( + "--metabase-api-key", + metavar="API_KEY", + envvar="METABASE_API_KEY", + show_envvar=True, + type=click.STRING, + help="Metabase API key (required unless providing username/password).", + ) @click.option( "--metabase-username", metavar="USERNAME", envvar="METABASE_USERNAME", show_envvar=True, type=click.STRING, - help="Metabase username (required unless providing session ID).", + help="Metabase username (required unless providing API key).", ) @click.option( "--metabase-password", @@ -109,7 +117,7 @@ def _add_setup(func: Callable) -> Callable: envvar="METABASE_PASSWORD", show_envvar=True, type=click.STRING, - help="Metabase password (required unless providing session ID).", + help="Metabase password (required unless providing API key).", ) @click.option( "--metabase-session-id", @@ -117,7 +125,8 @@ def _add_setup(func: Callable) -> Callable: envvar="METABASE_SESSION_ID", show_envvar=True, type=click.STRING, - help="Metabase session ID (alternative to username/password).", + help="Metabase session ID (deprecated and will be removed in future).", + hidden=True, ) @click.option( "--skip-verify", @@ -160,6 +169,7 @@ def _add_setup(func: Callable) -> Callable: def wrapper( manifest_path: str, metabase_url: str, + metabase_api_key: str, metabase_username: str, metabase_password: str, metabase_session_id: Optional[str], @@ -179,6 +189,7 @@ def wrapper( core=DbtMetabase( manifest_path=manifest_path, metabase_url=metabase_url, + metabase_api_key=metabase_api_key, metabase_username=metabase_username, metabase_password=metabase_password, metabase_session_id=metabase_session_id, diff --git a/dbtmetabase/core.py b/dbtmetabase/core.py index 7812ce8..3aee5a7 100644 --- a/dbtmetabase/core.py +++ b/dbtmetabase/core.py @@ -23,6 +23,7 @@ def __init__( self, manifest_path: Union[str, Path], metabase_url: str, + metabase_api_key: Optional[str] = None, metabase_username: Optional[str] = None, metabase_password: Optional[str] = None, metabase_session_id: Optional[str] = None, @@ -37,9 +38,10 @@ def __init__( Args: manifest_path (Union[str,Path]): Path to dbt manifest.json, usually in target/ directory after compilation. metabase_url (str): Metabase URL, e.g. "https://metabase.example.com". - metabase_username (Optional[str], optional): Metabase username (required unless providing session ID). Defaults to None. - metabase_password (Optional[str], optional): Metabase password (required unless providing session ID). Defaults to None. - metabase_session_id (Optional[str], optional): Metabase session ID. Defaults to None. + metabase_api_key (Optional[str], optional): Metabase API key (required unless providing username/password or session ID). Defaults to None. + metabase_username (Optional[str], optional): Metabase username (required unless providing API key or session ID). Defaults to None. + metabase_password (Optional[str], optional): Metabase password (required unless providing API key or session ID). Defaults to None. + metabase_session_id (Optional[str], optional): Metabase session ID (deprecated and will be removed in future). Defaults to None. skip_verify (bool, optional): Skip TLS certificate verification (not recommended). Defaults to False. cert (Optional[Union[str, Tuple[str, str]]], optional): Path to a custom certificate. Defaults to None. http_timeout (int, optional): HTTP request timeout in secs. Defaults to 15. @@ -52,6 +54,7 @@ def __init__( ) self._metabase = Metabase( url=metabase_url, + api_key=metabase_api_key, username=metabase_username, password=metabase_password, session_id=metabase_session_id, diff --git a/dbtmetabase/metabase.py b/dbtmetabase/metabase.py index 5e66de9..8410d93 100644 --- a/dbtmetabase/metabase.py +++ b/dbtmetabase/metabase.py @@ -13,6 +13,7 @@ class Metabase: def __init__( self, url: str, + api_key: Optional[str], username: Optional[str], password: Optional[str], session_id: Optional[str], @@ -38,19 +39,24 @@ def __init__( http_adapter or HTTPAdapter(max_retries=Retry(total=3, backoff_factor=1)), ) - if not session_id: - if username and password: - session = dict( - self._api( - method="post", - path="/api/session", - json={"username": username, "password": password}, - ) + if api_key: + self.session.headers["X-API-KEY"] = api_key + elif username and password: + session = dict( + self._api( + method="post", + path="/api/session", + json={"username": username, "password": password}, ) - session_id = str(session["id"]) - else: - raise ArgumentError("Metabase credentials or session ID required") - self.session.headers["X-Metabase-Session"] = session_id + ) + self.session.headers["X-Metabase-Session"] = str(session["id"]) + elif session_id: + _logger.warning( + "Metabase session ID is deprecated and will be removed in future, use API key or username/password instead" + ) + self.session.headers["X-Metabase-Session"] = session_id + else: + raise ArgumentError("Metabase API key or username/password required") _logger.info("Metabase session established") diff --git a/tests/_mocks.py b/tests/_mocks.py index a222702..246f463 100644 --- a/tests/_mocks.py +++ b/tests/_mocks.py @@ -16,6 +16,7 @@ class MockMetabase(Metabase): def __init__(self, url: str): super().__init__( url=url, + api_key=None, username=None, password=None, session_id="dummy",