From 293a318aa6f098b82dfde942ce111d200cba73d4 Mon Sep 17 00:00:00 2001 From: Christopher Bonilla Date: Mon, 25 Nov 2024 16:06:36 +0100 Subject: [PATCH] feat: M2M (client id+secret) auth for Databricks JIRA: LX-617 risk: low --- .../en/latest/data/data-source/_index.md | 24 ++++++++++++++++--- gooddata-sdk/gooddata_sdk/__init__.py | 1 + .../declarative_model/data_source.py | 12 ++++++++++ .../data_source/entity_model/data_source.py | 2 ++ gooddata-sdk/gooddata_sdk/catalog/entity.py | 22 +++++++++++++++++ 5 files changed, 58 insertions(+), 3 deletions(-) diff --git a/docs/content/en/latest/data/data-source/_index.md b/docs/content/en/latest/data/data-source/_index.md index 14a33d005..a9066e47d 100644 --- a/docs/content/en/latest/data/data-source/_index.md +++ b/docs/content/en/latest/data/data-source/_index.md @@ -183,6 +183,7 @@ CatalogDataSourceMsSql( ### Databricks +Using Machine-to-Machine (M2M) authentication (client_id + client_secret): ```python CatalogDataSourceDatabricks( id=data_source_id, @@ -193,9 +194,26 @@ CatalogDataSourceDatabricks( ), schema=xyz, parameters=[{"name":"catalog", "value": os.environ["DATABRICKS_CATALOG"]}], - credentials=BasicCredentials( - username=os.environ["DATABRICKS_USER"], - password=os.environ["DATABRICKS_PASSWORD"], + credentials=ClientSecretCredentials( + client_id=os.environ["DATABRICKS_CLIENT_ID"], + client_secret=os.environ["DATABRICKS_CLIENT_SECRET"], + ), +) +``` + +Using personal access token authentication: +```python +CatalogDataSourceDatabricks( + id=data_source_id, + name=data_source_name, + db_specific_attributes=DatabricksAttributes( + host=os.environ["DATABRICKS_HOST"], + http_path=os.environ["DATABRICKS_HTTP_PATH"] + ), + schema=xyz, + parameters=[{"name":"catalog", "value": os.environ["DATABRICKS_CATALOG"]}], + credentials=TokenCredentials( + token=os.environ["DATABRICKS_PERSONAL_ACCESS_TOKEN"] ), ) ``` diff --git a/gooddata-sdk/gooddata_sdk/__init__.py b/gooddata-sdk/gooddata_sdk/__init__.py index e2358f71e..014062e9f 100644 --- a/gooddata-sdk/gooddata_sdk/__init__.py +++ b/gooddata-sdk/gooddata_sdk/__init__.py @@ -55,6 +55,7 @@ from gooddata_sdk.catalog.entity import ( AttrCatalogEntity, BasicCredentials, + ClientSecretCredentials, KeyPairCredentials, TokenCredentialsFromEnvVar, TokenCredentialsFromFile, diff --git a/gooddata-sdk/gooddata_sdk/catalog/data_source/declarative_model/data_source.py b/gooddata-sdk/gooddata_sdk/catalog/data_source/declarative_model/data_source.py index 1ff410c45..80cd4781f 100644 --- a/gooddata-sdk/gooddata_sdk/catalog/data_source/declarative_model/data_source.py +++ b/gooddata-sdk/gooddata_sdk/catalog/data_source/declarative_model/data_source.py @@ -111,6 +111,8 @@ def to_test_request( token: Optional[str] = None, private_key: Optional[str] = None, private_key_passphrase: Optional[str] = None, + client_id: Optional[str] = None, + client_secret: Optional[str] = None, ) -> TestDefinitionRequest: kwargs: dict[str, Any] = {"schema": self.schema} if password is not None: @@ -123,6 +125,10 @@ def to_test_request( kwargs["private_key"] = private_key if private_key_passphrase is not None: kwargs["private_key_passphrase"] = private_key + if client_id is not None: + kwargs["client_id"] = client_id + if client_secret is not None: + kwargs["client_secret"] = client_secret return TestDefinitionRequest(type=self.type, url=self.url, **kwargs) @staticmethod @@ -141,6 +147,8 @@ def to_api( token: Optional[str] = None, private_key: Optional[str] = None, private_key_passphrase: Optional[str] = None, + client_id: Optional[str] = None, + client_secret: Optional[str] = None, ) -> DeclarativeDataSource: dictionary = self._get_snake_dict() if password is not None: @@ -151,6 +159,10 @@ def to_api( dictionary["private_key"] = private_key if private_key_passphrase is not None: dictionary["private_key_passphrase"] = private_key_passphrase + if client_id is not None: + dictionary["client_id"] = client_id + if client_secret is not None: + dictionary["client_secret"] = client_secret return self.client_class().from_dict(dictionary) def store_to_disk(self, data_sources_folder: Path) -> None: diff --git a/gooddata-sdk/gooddata_sdk/catalog/data_source/entity_model/data_source.py b/gooddata-sdk/gooddata_sdk/catalog/data_source/entity_model/data_source.py index e1383fe83..73ff6ab28 100644 --- a/gooddata-sdk/gooddata_sdk/catalog/data_source/entity_model/data_source.py +++ b/gooddata-sdk/gooddata_sdk/catalog/data_source/entity_model/data_source.py @@ -16,6 +16,7 @@ from gooddata_sdk.catalog.base import Base, value_in_allowed from gooddata_sdk.catalog.entity import ( BasicCredentials, + ClientSecretCredentials, Credentials, KeyPairCredentials, TokenCredentials, @@ -34,6 +35,7 @@ def db_attrs_with_template(instance: CatalogDataSource, *args: Any) -> None: class CatalogDataSourceBase(Base): _SUPPORTED_CREDENTIALS: ClassVar[list[type[Credentials]]] = [ BasicCredentials, + ClientSecretCredentials, TokenCredentials, TokenCredentialsFromFile, KeyPairCredentials, diff --git a/gooddata-sdk/gooddata_sdk/catalog/entity.py b/gooddata-sdk/gooddata_sdk/catalog/entity.py index 453b3b0ff..6d1ef069c 100644 --- a/gooddata-sdk/gooddata_sdk/catalog/entity.py +++ b/gooddata-sdk/gooddata_sdk/catalog/entity.py @@ -121,6 +121,8 @@ class Credentials(Base): PASSWORD_KEY: ClassVar[str] = "password" PRIVATE_KEY: ClassVar[str] = "private_key" PRIVATE_KEY_PASSPHRASE: ClassVar[str] = "private_key_passphrase" + CLIENT_ID: ClassVar[str] = "client_id" + CLIENT_SECRET: ClassVar[str] = "client_secret" def to_api_args(self) -> dict[str, Any]: return attr.asdict(self) @@ -252,3 +254,23 @@ def from_api(cls, attributes: dict[str, Any]) -> KeyPairCredentials: # You have to fill it to keep it or update it private_key="", ) + + +@attr.s(auto_attribs=True, kw_only=True) +class ClientSecretCredentials(Credentials): + client_id: str + client_secret: str = attr.field(repr=lambda value: "***") + + @classmethod + def is_part_of_api(cls, entity: dict[str, Any]) -> bool: + return cls.CLIENT_ID in entity and cls.CLIENT_SECRET in entity + + @classmethod + def from_api(cls, attributes: dict[str, Any]) -> ClientSecretCredentials: + # Credentials are not returned for security reasons + return cls( + client_id=attributes[cls.CLIENT_ID], + # Client secret is not returned from API (security) + # You have to fill it to keep it or update it + client_secret="", + )