From 67c7af36a4becc3806ec92da7c7a7a92ad354a21 Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Wed, 27 Nov 2024 06:20:15 -0500 Subject: [PATCH 01/21] initial proposal for client credentials support in python sdk --- src/posit/connect/external/databricks.py | 40 ++++++++++++++++++++++++ src/posit/connect/oauth/oauth.py | 37 ++++++++++++++++++++-- 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/src/posit/connect/external/databricks.py b/src/posit/connect/external/databricks.py index a76ede39..fed20b95 100644 --- a/src/posit/connect/external/databricks.py +++ b/src/posit/connect/external/databricks.py @@ -28,6 +28,46 @@ def auth_type(self) -> str: def __call__(self, *args, **kwargs) -> CredentialsProvider: raise NotImplementedError +# TODO: Refactor common behavior across different cred providers. + +class PositContentCredentialsProvider: + def __init__(self, client: Client): + self._client = client + + def __call__(self) -> Dict[str, str]: + credentials = self._client.oauth.get_content_credentials() + access_token = credentials.get("access_token") + if access_token is None: + raise ValueError("Missing value for field 'access_token' in credentials.") + return {"Authorization": f"Bearer {access_token}"} + +class PositContentCredentialsStrategy: + def __init__( + self, + local_strategy: CredentialsStrategy, + client: Optional[Client] = None, + ): + self._local_strategy = local_strategy + self._client = client + + def sql_credentials_provider(self, *args, **kwargs): + return lambda: self.__call__(*args, **kwargs) + + def auth_type(self) -> str: + if is_local(): + return self._local_strategy.auth_type() + else: + return "posit-oauth-integration" + + def __call__(self, *args, **kwargs) -> CredentialsProvider: + if is_local(): + return self._local_strategy(*args, **kwargs) + + if self._client is None: + self._client = Client() + + return PositContentCredentialsProvider(self._client) + class PositCredentialsProvider: def __init__(self, client: Client, user_session_token: str): diff --git a/src/posit/connect/oauth/oauth.py b/src/posit/connect/oauth/oauth.py index 306170b8..03187bfe 100644 --- a/src/posit/connect/oauth/oauth.py +++ b/src/posit/connect/oauth/oauth.py @@ -1,5 +1,6 @@ from __future__ import annotations +import os from typing import Optional from typing_extensions import TypedDict @@ -8,6 +9,27 @@ from .integrations import Integrations from .sessions import Sessions +GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange" +USER_SESSION_TOKEN_TYPE = "urn:posit:connect:user-session-token" +CONTENT_SESSION_TOKEN_TYPE = "urn:posit:connect:content-session-token" + +def _get_content_session_token() -> str: + """Return the content session token. + + Reads the environment variable 'CONNECT_CONTENT_SESSION_TOKEN'. + + Raises + ------ + ValueError: If CONNECT_CONTENT_SESSION_TOKEN is not set or invalid + + Returns + ------- + str + """ + value = os.environ.get("CONNECT_CONTENT_SESSION_TOKEN") + if not value: + raise ValueError("Invalid value for 'CONNECT_CONTENT_SESSION_TOKEN': Must be a non-empty string.") + return value class OAuth(Resources): def __init__(self, params: ResourceParameters, api_key: str) -> None: @@ -27,14 +49,25 @@ def get_credentials(self, user_session_token: Optional[str] = None) -> Credentia # craft a credential exchange request data = {} - data["grant_type"] = "urn:ietf:params:oauth:grant-type:token-exchange" - data["subject_token_type"] = "urn:posit:connect:user-session-token" + data["grant_type"] = GRANT_TYPE + data["subject_token_type"] = USER_SESSION_TOKEN_TYPE if user_session_token: data["subject_token"] = user_session_token response = self.params.session.post(url, data=data) return Credentials(**response.json()) + def get_content_credentials(self, content_session_token: Optional[str] = None) -> Credentials: + url = self.params.url + "v1/oauth/integrations/credentials" + + # craft a credential exchange request + data = {} + data["grant_type"] = GRANT_TYPE + data["subject_token_type"] = CONTENT_SESSION_TOKEN_TYPE + data["subject_token"] = content_session_token or _get_content_session_token() + + response = self.params.session.post(url, data=data) + return Credentials(**response.json()) class Credentials(TypedDict, total=False): access_token: str From 67edfbbeed7e82b96bfc41423c5bfbc1953e34ee Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Wed, 27 Nov 2024 08:11:36 -0500 Subject: [PATCH 02/21] a bit of refactoring + some more documentation --- src/posit/connect/oauth/oauth.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/posit/connect/oauth/oauth.py b/src/posit/connect/oauth/oauth.py index 03187bfe..e7f16342 100644 --- a/src/posit/connect/oauth/oauth.py +++ b/src/posit/connect/oauth/oauth.py @@ -36,6 +36,9 @@ def __init__(self, params: ResourceParameters, api_key: str) -> None: super().__init__(params) self.api_key = api_key + def _get_credentials_url(self) -> str: + return self.params.url + "v1/oauth/integrations/credentials" + @property def integrations(self): return Integrations(self.params) @@ -45,7 +48,7 @@ def sessions(self): return Sessions(self.params) def get_credentials(self, user_session_token: Optional[str] = None) -> Credentials: - url = self.params.url + "v1/oauth/integrations/credentials" + """Perform an oauth credential exchange for a viewer's access token.""" # craft a credential exchange request data = {} @@ -54,19 +57,19 @@ def get_credentials(self, user_session_token: Optional[str] = None) -> Credentia if user_session_token: data["subject_token"] = user_session_token - response = self.params.session.post(url, data=data) + response = self.params.session.post(self._get_credentials_url(), data=data) return Credentials(**response.json()) def get_content_credentials(self, content_session_token: Optional[str] = None) -> Credentials: - url = self.params.url + "v1/oauth/integrations/credentials" - + """Perform an oauth credential exchange for a service account's access token.""" + # craft a credential exchange request data = {} data["grant_type"] = GRANT_TYPE data["subject_token_type"] = CONTENT_SESSION_TOKEN_TYPE data["subject_token"] = content_session_token or _get_content_session_token() - response = self.params.session.post(url, data=data) + response = self.params.session.post(self._get_credentials_url(), data=data) return Credentials(**response.json()) class Credentials(TypedDict, total=False): From 08796e2ab5c963fb78fae3f062824934eee880ce Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Mon, 2 Dec 2024 15:48:51 -0500 Subject: [PATCH 03/21] test coverage, linting --- src/posit/connect/external/databricks.py | 118 ++++++++++++------ src/posit/connect/oauth/__init__.py | 1 + src/posit/connect/oauth/oauth.py | 6 +- .../posit/connect/external/test_databricks.py | 86 +++++++++++++ tests/posit/connect/oauth/test_oauth.py | 64 ++++++++++ 5 files changed, 230 insertions(+), 45 deletions(-) diff --git a/src/posit/connect/external/databricks.py b/src/posit/connect/external/databricks.py index fed20b95..37ec8158 100644 --- a/src/posit/connect/external/databricks.py +++ b/src/posit/connect/external/databricks.py @@ -2,6 +2,7 @@ from typing import Callable, Dict, Optional from ..client import Client +from ..oauth import Credentials from .external import is_local """ @@ -9,10 +10,11 @@ https://github.com/databricks/databricks-sdk-py#interface-stability """ +POSIT_OAUTH_INTEGRATION_AUTH_TYPE = "posit-oauth-integration" + # The Databricks SDK CredentialsProvider == Databricks SQL HeaderFactory CredentialsProvider = Callable[[], Dict[str, str]] - class CredentialsStrategy(abc.ABC): """Maintain compatibility with the Databricks SQL/SDK client libraries. @@ -28,20 +30,74 @@ def auth_type(self) -> str: def __call__(self, *args, **kwargs) -> CredentialsProvider: raise NotImplementedError -# TODO: Refactor common behavior across different cred providers. + +def _new_bearer_authorization_header(credentials: Credentials) -> Dict[str, str]: + """Helper to transform an Credentials object into the Bearer auth header consumed by databricks. + + Raises + ------ + ValueError: If provided Credentials object does not contain an access token + + Returns + ------- + Dict[str, str] + """ + access_token = credentials.get("access_token") + if access_token is None: + raise ValueError("Missing value for field 'access_token' in credentials.") + return {"Authorization": f"Bearer {access_token}"} + +def _get_auth_type(local_auth_type: str) -> str: + """Returns the auth type currently in use. + + The databricks-sdk client uses the configurated auth_type to create + a user-agent string which is used for attribution. We should only + overwrite the auth_type if we are using the PositCredentialsStrategy (non-local), + otherwise, we should return the auth_type of the configured local_strategy instead + to avoid breaking someone elses attribution. + + https://github.com/databricks/databricks-sdk-py/blob/v0.29.0/databricks/sdk/config.py#L261-L269 + + NOTE: The databricks-sql client does not use auth_type to set the user-agent. + https://github.com/databricks/databricks-sql-python/blob/v3.3.0/src/databricks/sql/client.py#L214-L219 + + Returns + ------- + str + """ + if is_local(): + return local_auth_type + + return POSIT_OAUTH_INTEGRATION_AUTH_TYPE + + class PositContentCredentialsProvider: + """CredentialsProvider implementation which initiates a credential exchange using a content-session-token.""" + def __init__(self, client: Client): self._client = client def __call__(self) -> Dict[str, str]: credentials = self._client.oauth.get_content_credentials() - access_token = credentials.get("access_token") - if access_token is None: - raise ValueError("Missing value for field 'access_token' in credentials.") - return {"Authorization": f"Bearer {access_token}"} + return _new_bearer_authorization_header(credentials) + + +class PositCredentialsProvider: + """CredentialsProvider implementation which initiates a credential exchange using a user-session-token.""" + + def __init__(self, client: Client, user_session_token: str): + self._client = client + self._user_session_token = user_session_token + + def __call__(self) -> Dict[str, str]: + credentials = self._client.oauth.get_credentials(self._user_session_token) + return _new_bearer_authorization_header(credentials) + + +class PositContentCredentialsStrategy(CredentialsStrategy): + """CredentialsStrategy implementation which returns a PositContentCredentialsProvider when called.""" -class PositContentCredentialsStrategy: def __init__( self, local_strategy: CredentialsStrategy, @@ -51,15 +107,22 @@ def __init__( self._client = client def sql_credentials_provider(self, *args, **kwargs): + """The sql connector attempts to call the credentials provider w/o any args. + + The SQL client's `ExternalAuthProvider` is not compatible w/ the SDK's implementation of + `CredentialsProvider`, so create a no-arg lambda that wraps the args defined by the real caller. + This way we can pass in a databricks `Config` object required by most of the SDK's `CredentialsProvider` + implementations from where `sql.connect` is called. + + https://github.com/databricks/databricks-sql-python/issues/148#issuecomment-2271561365 + """ return lambda: self.__call__(*args, **kwargs) def auth_type(self) -> str: - if is_local(): - return self._local_strategy.auth_type() - else: - return "posit-oauth-integration" + return _get_auth_type(self._local_strategy.auth_type()) def __call__(self, *args, **kwargs) -> CredentialsProvider: + # If the content is not running on Connect then fall back to local_strategy if is_local(): return self._local_strategy(*args, **kwargs) @@ -69,20 +132,9 @@ def __call__(self, *args, **kwargs) -> CredentialsProvider: return PositContentCredentialsProvider(self._client) -class PositCredentialsProvider: - def __init__(self, client: Client, user_session_token: str): - self._client = client - self._user_session_token = user_session_token - - def __call__(self) -> Dict[str, str]: - credentials = self._client.oauth.get_credentials(self._user_session_token) - access_token = credentials.get("access_token") - if access_token is None: - raise ValueError("Missing value for field 'access_token' in credentials.") - return {"Authorization": f"Bearer {access_token}"} - - class PositCredentialsStrategy(CredentialsStrategy): + """CredentialsStrategy implementation which returns a PositContentCredentialsProvider when called.""" + def __init__( self, local_strategy: CredentialsStrategy, @@ -106,23 +158,7 @@ def sql_credentials_provider(self, *args, **kwargs): return lambda: self.__call__(*args, **kwargs) def auth_type(self) -> str: - """Returns the auth type currently in use. - - The databricks-sdk client uses the configurated auth_type to create - a user-agent string which is used for attribution. We should only - overwrite the auth_type if we are using the PositCredentialsStrategy (non-local), - otherwise, we should return the auth_type of the configured local_strategy instead - to avoid breaking someone elses attribution. - - https://github.com/databricks/databricks-sdk-py/blob/v0.29.0/databricks/sdk/config.py#L261-L269 - - NOTE: The databricks-sql client does not use auth_type to set the user-agent. - https://github.com/databricks/databricks-sql-python/blob/v3.3.0/src/databricks/sql/client.py#L214-L219 - """ - if is_local(): - return self._local_strategy.auth_type() - else: - return "posit-oauth-integration" + return _get_auth_type(self._local_strategy.auth_type()) def __call__(self, *args, **kwargs) -> CredentialsProvider: # If the content is not running on Connect then fall back to local_strategy diff --git a/src/posit/connect/oauth/__init__.py b/src/posit/connect/oauth/__init__.py index 15274e67..453aafb7 100644 --- a/src/posit/connect/oauth/__init__.py +++ b/src/posit/connect/oauth/__init__.py @@ -1 +1,2 @@ +from .oauth import Credentials as Credentials from .oauth import OAuth as OAuth diff --git a/src/posit/connect/oauth/oauth.py b/src/posit/connect/oauth/oauth.py index e7f16342..82cd1fe3 100644 --- a/src/posit/connect/oauth/oauth.py +++ b/src/posit/connect/oauth/oauth.py @@ -48,8 +48,7 @@ def sessions(self): return Sessions(self.params) def get_credentials(self, user_session_token: Optional[str] = None) -> Credentials: - """Perform an oauth credential exchange for a viewer's access token.""" - + """Perform an oauth credential exchange with a user-session-token.""" # craft a credential exchange request data = {} data["grant_type"] = GRANT_TYPE @@ -61,8 +60,7 @@ def get_credentials(self, user_session_token: Optional[str] = None) -> Credentia return Credentials(**response.json()) def get_content_credentials(self, content_session_token: Optional[str] = None) -> Credentials: - """Perform an oauth credential exchange for a service account's access token.""" - + """Perform an oauth credential exchange with a content-session-token.""" # craft a credential exchange request data = {} data["grant_type"] = GRANT_TYPE diff --git a/tests/posit/connect/external/test_databricks.py b/tests/posit/connect/external/test_databricks.py index 4cb83fd1..1a53c3cd 100644 --- a/tests/posit/connect/external/test_databricks.py +++ b/tests/posit/connect/external/test_databricks.py @@ -1,15 +1,22 @@ from typing import Dict from unittest.mock import patch +import pytest import responses from posit.connect import Client from posit.connect.external.databricks import ( + POSIT_OAUTH_INTEGRATION_AUTH_TYPE, CredentialsProvider, CredentialsStrategy, + PositContentCredentialsProvider, + PositContentCredentialsStrategy, PositCredentialsProvider, PositCredentialsStrategy, + _get_auth_type, + _new_bearer_authorization_header, ) +from posit.connect.oauth import Credentials class mock_strategy(CredentialsStrategy): @@ -42,8 +49,59 @@ def register_mocks(): }, ) + responses.post( + "https://connect.example/__api__/v1/oauth/integrations/credentials", + match=[ + responses.matchers.urlencoded_params_matcher( + { + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "subject_token_type": "urn:posit:connect:content-session-token", + "subject_token": "cit", + }, + ), + ], + json={ + "access_token": "content-access-token", + "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", + "token_type": "Bearer", + }, + ) + + + class TestPositCredentialsHelpers: + + def test_new_bearer_authorization_header(self): + credential = Credentials() + credential["token_type"] = "token_type" + credential["issued_token_type"] = "issued_token_type" + + with pytest.raises(ValueError): + _new_bearer_authorization_header(credential) + + credential["access_token"] = "access_token" + result = _new_bearer_authorization_header(credential) + assert result == {"Authorization": "Bearer access_token"} + + def test_get_auth_type_local(self): + assert _get_auth_type("local-auth") == "local-auth" + + + @patch.dict("os.environ", {"RSTUDIO_PRODUCT": "CONNECT"}) + def test_get_auth_type_connect(self): + assert _get_auth_type("local-auth") == POSIT_OAUTH_INTEGRATION_AUTH_TYPE + + @patch.dict("os.environ", {"CONNECT_CONTENT_SESSION_TOKEN": "cit"}) + @responses.activate + def test_posit_content_credentials_provider(self): + register_mocks() + + client = Client(api_key="12345", url="https://connect.example/") + client._ctx.version = None + cp = PositContentCredentialsProvider(client=client) + assert cp() == {"Authorization": "Bearer content-access-token"} + @responses.activate def test_posit_credentials_provider(self): register_mocks() @@ -53,6 +111,23 @@ def test_posit_credentials_provider(self): cp = PositCredentialsProvider(client=client, user_session_token="cit") assert cp() == {"Authorization": "Bearer dynamic-viewer-access-token"} + @patch.dict("os.environ", {"CONNECT_CONTENT_SESSION_TOKEN": "cit"}) + @responses.activate + @patch.dict("os.environ", {"RSTUDIO_PRODUCT": "CONNECT"}) + def test_posit_content_credentials_strategy(self): + register_mocks() + + client = Client(api_key="12345", url="https://connect.example/") + client._ctx.version = None + cs = PositContentCredentialsStrategy( + local_strategy=mock_strategy(), + client=client, + ) + cp = cs() + assert cs.auth_type() == "posit-oauth-integration" + assert cp() == {"Authorization": "Bearer content-access-token"} + + @responses.activate @patch.dict("os.environ", {"RSTUDIO_PRODUCT": "CONNECT"}) def test_posit_credentials_strategy(self): @@ -69,6 +144,17 @@ def test_posit_credentials_strategy(self): assert cs.auth_type() == "posit-oauth-integration" assert cp() == {"Authorization": "Bearer dynamic-viewer-access-token"} + def test_posit_content_credentials_strategy_fallback(self): + # local_strategy is used when the content is running locally + client = Client(api_key="12345", url="https://connect.example/") + cs = PositContentCredentialsStrategy( + local_strategy=mock_strategy(), + client=client, + ) + cp = cs() + assert cs.auth_type() == "local" + assert cp() == {"Authorization": "Bearer static-pat-token"} + def test_posit_credentials_strategy_fallback(self): # local_strategy is used when the content is running locally client = Client(api_key="12345", url="https://connect.example/") diff --git a/tests/posit/connect/oauth/test_oauth.py b/tests/posit/connect/oauth/test_oauth.py index 702f6716..96966f5b 100644 --- a/tests/posit/connect/oauth/test_oauth.py +++ b/tests/posit/connect/oauth/test_oauth.py @@ -1,9 +1,22 @@ +from unittest.mock import patch + +import pytest import responses from posit.connect import Client +from posit.connect.oauth.oauth import _get_content_session_token class TestOAuthIntegrations: + + @patch.dict("os.environ", {"CONNECT_CONTENT_SESSION_TOKEN": "cit"}) + def test_get_content_session_token_success(self): + assert _get_content_session_token() == "cit" + + def test_get_content_session_token_failure(self): + with pytest.raises(ValueError): + _get_content_session_token() + @responses.activate def test_get_credentials(self): responses.post( @@ -28,3 +41,54 @@ def test_get_credentials(self): creds = c.oauth.get_credentials("cit") assert "access_token" in creds assert creds["access_token"] == "viewer-token" + + @responses.activate + def test_get_content_credentials(self): + responses.post( + "https://connect.example/__api__/v1/oauth/integrations/credentials", + match=[ + responses.matchers.urlencoded_params_matcher( + { + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "subject_token_type": "urn:posit:connect:content-session-token", + "subject_token": "cit", + }, + ), + ], + json={ + "access_token": "content-token", + "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", + "token_type": "Bearer", + }, + ) + c = Client(api_key="12345", url="https://connect.example/") + c._ctx.version = None + creds = c.oauth.get_content_credentials("cit") + assert "access_token" in creds + assert creds["access_token"] == "content-token" + + @patch.dict("os.environ", {"CONNECT_CONTENT_SESSION_TOKEN": "cit"}) + @responses.activate + def test_get_content_credentials_env_var(self): + responses.post( + "https://connect.example/__api__/v1/oauth/integrations/credentials", + match=[ + responses.matchers.urlencoded_params_matcher( + { + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "subject_token_type": "urn:posit:connect:content-session-token", + "subject_token": "cit", + }, + ), + ], + json={ + "access_token": "content-token", + "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", + "token_type": "Bearer", + }, + ) + c = Client(api_key="12345", url="https://connect.example/") + c._ctx.version = None + creds = c.oauth.get_content_credentials() + assert "access_token" in creds + assert creds["access_token"] == "content-token" From e654b0be064a137d036521d7a2bc95ba3a96386a Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Tue, 3 Dec 2024 16:45:06 -0500 Subject: [PATCH 04/21] Update src/posit/connect/external/databricks.py Co-authored-by: Barret Schloerke --- src/posit/connect/external/databricks.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/posit/connect/external/databricks.py b/src/posit/connect/external/databricks.py index 37ec8158..379bbb20 100644 --- a/src/posit/connect/external/databricks.py +++ b/src/posit/connect/external/databricks.py @@ -153,7 +153,9 @@ def sql_credentials_provider(self, *args, **kwargs): This way we can pass in a databricks `Config` object required by most of the SDK's `CredentialsProvider` implementations from where `sql.connect` is called. - https://github.com/databricks/databricks-sql-python/issues/148#issuecomment-2271561365 + See Also + -------- + * https://github.com/databricks/databricks-sql-python/issues/148#issuecomment-2271561365 """ return lambda: self.__call__(*args, **kwargs) From dd02020b3703bb619a90c7b556a5ef9dee086f64 Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Tue, 3 Dec 2024 16:45:13 -0500 Subject: [PATCH 05/21] Update src/posit/connect/external/databricks.py Co-authored-by: Barret Schloerke --- src/posit/connect/external/databricks.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/posit/connect/external/databricks.py b/src/posit/connect/external/databricks.py index 379bbb20..f2c318dc 100644 --- a/src/posit/connect/external/databricks.py +++ b/src/posit/connect/external/databricks.py @@ -56,10 +56,12 @@ def _get_auth_type(local_auth_type: str) -> str: otherwise, we should return the auth_type of the configured local_strategy instead to avoid breaking someone elses attribution. - https://github.com/databricks/databricks-sdk-py/blob/v0.29.0/databricks/sdk/config.py#L261-L269 - NOTE: The databricks-sql client does not use auth_type to set the user-agent. https://github.com/databricks/databricks-sql-python/blob/v3.3.0/src/databricks/sql/client.py#L214-L219 + + See Also + -------- + * https://github.com/databricks/databricks-sdk-py/blob/v0.29.0/databricks/sdk/config.py#L261-L269 Returns ------- From 36634c7643618a1866ad679e594d3da707de361e Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Tue, 3 Dec 2024 17:01:38 -0500 Subject: [PATCH 06/21] responding to PR comments --- src/posit/connect/external/databricks.py | 93 +++++++++++++++++++++++- 1 file changed, 91 insertions(+), 2 deletions(-) diff --git a/src/posit/connect/external/databricks.py b/src/posit/connect/external/databricks.py index f2c318dc..81855a09 100644 --- a/src/posit/connect/external/databricks.py +++ b/src/posit/connect/external/databricks.py @@ -98,7 +98,45 @@ def __call__(self) -> Dict[str, str]: class PositContentCredentialsStrategy(CredentialsStrategy): - """CredentialsStrategy implementation which returns a PositContentCredentialsProvider when called.""" + """`CredentialsStrategy` implementation which supports interacting with Service Account OAuth integrations on Connect. + + This strategy callable class returns a `PositContentCredentialsProvider` when hosted on Connect, and + its `local_strategy` strategy otherwise. + + Example + ------- + from posit.connect.external.databricks import PositContentCredentialsStrategy + + import pandas as pd + import requests + + from databricks import sql + from databricks.sdk.core import ApiClient, Config, databricks_cli + from databricks.sdk.service.iam import CurrentUserAPI + + # env vars + DATABRICKS_HOST = "" + DATABRICKS_HOST_URL = f"https://{DATABRICKS_HOST}" + SQL_HTTP_PATH = "" + + posit_strategy = PositContentCredentialsStrategy(local_strategy=databricks_cli) + + cfg = Config(host=DATABRICKS_HOST_URL, credentials_strategy=posit_strategy) + + databricks_user_info = CurrentUserAPI(ApiClient(cfg)).me() + print(f"Hello, {databricks_user_info.display_name}!") + + query = "SELECT * FROM samples.nyctaxi.trips LIMIT 10;" + with sql.connect( + server_hostname=DATABRICKS_HOST, + http_path=SQL_HTTP_PATH, + credentials_provider=posit_strategy.sql_credentials_provider(cfg), + ) as connection: + with connection.cursor() as cursor: + cursor.execute(query) + rows = cursor.fetchall() + print(pd.DataFrame([row.asDict() for row in rows])) + """ def __init__( self, @@ -135,7 +173,58 @@ def __call__(self, *args, **kwargs) -> CredentialsProvider: class PositCredentialsStrategy(CredentialsStrategy): - """CredentialsStrategy implementation which returns a PositContentCredentialsProvider when called.""" + """`CredentialsStrategy` implementation which supports interacting with Viewer OAuth integrations on Connect. + + This strategy callable class returns a `PositCredentialsProvider` when hosted on Connect, and + its `local_strategy` strategy otherwise. + + Example + ------- + import os + + import pandas as pd + from databricks import sql + from databricks.sdk.core import ApiClient, Config, databricks_cli + from databricks.sdk.service.iam import CurrentUserAPI + from posit.connect.external.databricks import PositCredentialsStrategy + from shiny import App, Inputs, Outputs, Session, render, ui + + # env vars + DATABRICKS_HOST = "" + DATABRICKS_HOST_URL = f"https://{DATABRICKS_HOST}" + SQL_HTTP_PATH = "" + + app_ui = ui.page_fluid(ui.output_text("text"), ui.output_data_frame("result")) + + def server(i: Inputs, o: Outputs, session: Session): + session_token = session.http_conn.headers.get("Posit-Connect-User-Session-Token") + posit_strategy = PositCredentialsStrategy( + local_strategy=databricks_cli, user_session_token=session_token + ) + cfg = Config(host=DATABRICKS_HOST_URL, credentials_strategy=posit_strategy) + + @render.data_frame + def result(): + query = "SELECT * FROM samples.nyctaxi.trips LIMIT 10;" + + with sql.connect( + server_hostname=DATABRICKS_HOST, + http_path=SQL_HTTP_PATH, + credentials_provider=posit_strategy.sql_credentials_provider(cfg), + ) as connection: + with connection.cursor() as cursor: + cursor.execute(query) + rows = cursor.fetchall() + df = pd.DataFrame(rows, columns=[col[0] for col in cursor.description]) + return df + + @render.text + def text(): + databricks_user_info = CurrentUserAPI(ApiClient(cfg)).me() + return f"Hello, {databricks_user_info.display_name}!" + + app = App(app_ui, server) + """ def __init__( self, From d7ba5e6dfc3577205c6d324ff02e8850098eee3e Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Tue, 3 Dec 2024 17:03:21 -0500 Subject: [PATCH 07/21] fix comment typo --- src/posit/connect/external/databricks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/posit/connect/external/databricks.py b/src/posit/connect/external/databricks.py index 81855a09..e220fd1d 100644 --- a/src/posit/connect/external/databricks.py +++ b/src/posit/connect/external/databricks.py @@ -50,7 +50,7 @@ def _new_bearer_authorization_header(credentials: Credentials) -> Dict[str, str] def _get_auth_type(local_auth_type: str) -> str: """Returns the auth type currently in use. - The databricks-sdk client uses the configurated auth_type to create + The databricks-sdk client uses the configured auth_type to create a user-agent string which is used for attribution. We should only overwrite the auth_type if we are using the PositCredentialsStrategy (non-local), otherwise, we should return the auth_type of the configured local_strategy instead From e146dddb5285fc077f16a43f43b9bfb33a8ebac4 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 4 Dec 2024 10:20:37 -0500 Subject: [PATCH 08/21] Wrap examples in markdown blocks --- src/posit/connect/content.py | 4 +-- src/posit/connect/external/databricks.py | 37 +++++++++++++++--------- 2 files changed, 25 insertions(+), 16 deletions(-) diff --git a/src/posit/connect/content.py b/src/posit/connect/content.py index 404867b7..b1b9cd4d 100644 --- a/src/posit/connect/content.py +++ b/src/posit/connect/content.py @@ -755,8 +755,8 @@ def find_by( ------- Optional[ContentItem] - Example - ------- + Examples + -------- >>> find_by(name="example-content-name") """ attr_items = attrs.items() diff --git a/src/posit/connect/external/databricks.py b/src/posit/connect/external/databricks.py index e220fd1d..728c6717 100644 --- a/src/posit/connect/external/databricks.py +++ b/src/posit/connect/external/databricks.py @@ -15,11 +15,14 @@ # The Databricks SDK CredentialsProvider == Databricks SQL HeaderFactory CredentialsProvider = Callable[[], Dict[str, str]] + class CredentialsStrategy(abc.ABC): """Maintain compatibility with the Databricks SQL/SDK client libraries. - https://github.com/databricks/databricks-sql-python/blob/v3.3.0/src/databricks/sql/auth/authenticators.py#L19-L33 - https://github.com/databricks/databricks-sdk-py/blob/v0.29.0/databricks/sdk/credentials_provider.py#L44-L54 + See Also + -------- + * https://github.com/databricks/databricks-sql-python/blob/v3.3.0/src/databricks/sql/auth/authenticators.py#L19-L33 + * https://github.com/databricks/databricks-sdk-py/blob/v0.29.0/databricks/sdk/credentials_provider.py#L44-L54 """ @abc.abstractmethod @@ -47,7 +50,8 @@ def _new_bearer_authorization_header(credentials: Credentials) -> Dict[str, str] raise ValueError("Missing value for field 'access_token' in credentials.") return {"Authorization": f"Bearer {access_token}"} -def _get_auth_type(local_auth_type: str) -> str: + +def _get_auth_type(local_auth_type: str) -> str: """Returns the auth type currently in use. The databricks-sdk client uses the configured auth_type to create @@ -62,7 +66,7 @@ def _get_auth_type(local_auth_type: str) -> str: See Also -------- * https://github.com/databricks/databricks-sdk-py/blob/v0.29.0/databricks/sdk/config.py#L261-L269 - + Returns ------- str @@ -70,8 +74,7 @@ def _get_auth_type(local_auth_type: str) -> str: if is_local(): return local_auth_type - return POSIT_OAUTH_INTEGRATION_AUTH_TYPE - + return POSIT_OAUTH_INTEGRATION_AUTH_TYPE class PositContentCredentialsProvider: @@ -100,16 +103,17 @@ def __call__(self) -> Dict[str, str]: class PositContentCredentialsStrategy(CredentialsStrategy): """`CredentialsStrategy` implementation which supports interacting with Service Account OAuth integrations on Connect. - This strategy callable class returns a `PositContentCredentialsProvider` when hosted on Connect, and + This strategy callable class returns a `PositContentCredentialsProvider` when hosted on Connect, and its `local_strategy` strategy otherwise. - Example - ------- + Examples + -------- + ```python from posit.connect.external.databricks import PositContentCredentialsStrategy import pandas as pd import requests - + from databricks import sql from databricks.sdk.core import ApiClient, Config, databricks_cli from databricks.sdk.service.iam import CurrentUserAPI @@ -136,6 +140,7 @@ class PositContentCredentialsStrategy(CredentialsStrategy): cursor.execute(query) rows = cursor.fetchall() print(pd.DataFrame([row.asDict() for row in rows])) + ``` """ def __init__( @@ -175,11 +180,12 @@ def __call__(self, *args, **kwargs) -> CredentialsProvider: class PositCredentialsStrategy(CredentialsStrategy): """`CredentialsStrategy` implementation which supports interacting with Viewer OAuth integrations on Connect. - This strategy callable class returns a `PositCredentialsProvider` when hosted on Connect, and + This strategy callable class returns a `PositCredentialsProvider` when hosted on Connect, and its `local_strategy` strategy otherwise. - Example - ------- + Examples + -------- + ```python import os import pandas as pd @@ -196,6 +202,7 @@ class PositCredentialsStrategy(CredentialsStrategy): app_ui = ui.page_fluid(ui.output_text("text"), ui.output_data_frame("result")) + def server(i: Inputs, o: Outputs, session: Session): session_token = session.http_conn.headers.get("Posit-Connect-User-Session-Token") posit_strategy = PositCredentialsStrategy( @@ -222,8 +229,10 @@ def result(): def text(): databricks_user_info = CurrentUserAPI(ApiClient(cfg)).me() return f"Hello, {databricks_user_info.display_name}!" - + + app = App(app_ui, server) + ``` """ def __init__( From d58a743e8ccf379c8d2ee77ff8c61ee864e17559 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 4 Dec 2024 10:20:48 -0500 Subject: [PATCH 09/21] Expose `external` on docs --- docs/_quarto.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/_quarto.yml b/docs/_quarto.yml index f77c78ba..2de729b1 100644 --- a/docs/_quarto.yml +++ b/docs/_quarto.yml @@ -107,3 +107,7 @@ quartodoc: contents: - connect.metrics - connect.metrics.usage + - title: External Integrations + contents: + - connect.external.databricks + - connect.external.snowflake From b229b4b288d4caf25c1340e3e674b4813158b395 Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Wed, 4 Dec 2024 12:05:05 -0500 Subject: [PATCH 10/21] updating docstrings in response to PR comments --- src/posit/connect/external/databricks.py | 49 +++++++++++++++++++----- 1 file changed, 39 insertions(+), 10 deletions(-) diff --git a/src/posit/connect/external/databricks.py b/src/posit/connect/external/databricks.py index e220fd1d..daaa5dd1 100644 --- a/src/posit/connect/external/databricks.py +++ b/src/posit/connect/external/databricks.py @@ -75,7 +75,15 @@ def _get_auth_type(local_auth_type: str) -> str: class PositContentCredentialsProvider: - """CredentialsProvider implementation which initiates a credential exchange using a content-session-token.""" + """CredentialsProvider implementation which initiates a credential exchange using a content-session-token. + + The content-session-token is provided by Connect through the environment variable `CONNECT_CONTENT_SESSION_TOKEN`. + + See Also + -------- + * https://github.com/posit-dev/posit-sdk-py/blob/main/src/posit/connect/oauth/oauth.py + + """ def __init__(self, client: Client): self._client = client @@ -86,7 +94,16 @@ def __call__(self) -> Dict[str, str]: class PositCredentialsProvider: - """CredentialsProvider implementation which initiates a credential exchange using a user-session-token.""" + """CredentialsProvider implementation which initiates a credential exchange using a user-session-token. + + The user-session-token is provided by Connect through the HTTP session header + `Posit-Connect-User-Session-Token`. + + See Also + -------- + * https://github.com/posit-dev/posit-sdk-py/blob/main/src/posit/connect/oauth/oauth.py + + """ def __init__(self, client: Client, user_session_token: str): self._client = client @@ -103,22 +120,26 @@ class PositContentCredentialsStrategy(CredentialsStrategy): This strategy callable class returns a `PositContentCredentialsProvider` when hosted on Connect, and its `local_strategy` strategy otherwise. - Example - ------- + Examples + -------- + + NOTE: in the example below, the PositContentCredentialsStrategy can be initialized anywhere that + the Python process can read environment variables. + + ```python from posit.connect.external.databricks import PositContentCredentialsStrategy import pandas as pd - import requests from databricks import sql from databricks.sdk.core import ApiClient, Config, databricks_cli from databricks.sdk.service.iam import CurrentUserAPI - # env vars DATABRICKS_HOST = "" DATABRICKS_HOST_URL = f"https://{DATABRICKS_HOST}" SQL_HTTP_PATH = "" + # reads `CONNECT_CONTENT_SESSION_TOKEN` environment variable if hosted on Connect posit_strategy = PositContentCredentialsStrategy(local_strategy=databricks_cli) cfg = Config(host=DATABRICKS_HOST_URL, credentials_strategy=posit_strategy) @@ -136,6 +157,7 @@ class PositContentCredentialsStrategy(CredentialsStrategy): cursor.execute(query) rows = cursor.fetchall() print(pd.DataFrame([row.asDict() for row in rows])) + ``` """ def __init__( @@ -178,8 +200,13 @@ class PositCredentialsStrategy(CredentialsStrategy): This strategy callable class returns a `PositCredentialsProvider` when hosted on Connect, and its `local_strategy` strategy otherwise. - Example - ------- + Examples + -------- + + NOTE: In the example below, the PositCredentialsProvider *must* be initialized within the context of the + shiny `server` function, which provides access to the HTTP session headers. + + ```python import os import pandas as pd @@ -188,8 +215,7 @@ class PositCredentialsStrategy(CredentialsStrategy): from databricks.sdk.service.iam import CurrentUserAPI from posit.connect.external.databricks import PositCredentialsStrategy from shiny import App, Inputs, Outputs, Session, render, ui - - # env vars + DATABRICKS_HOST = "" DATABRICKS_HOST_URL = f"https://{DATABRICKS_HOST}" SQL_HTTP_PATH = "" @@ -197,6 +223,8 @@ class PositCredentialsStrategy(CredentialsStrategy): app_ui = ui.page_fluid(ui.output_text("text"), ui.output_data_frame("result")) def server(i: Inputs, o: Outputs, session: Session): + + # HTTP session headers are available in this context. session_token = session.http_conn.headers.get("Posit-Connect-User-Session-Token") posit_strategy = PositCredentialsStrategy( local_strategy=databricks_cli, user_session_token=session_token @@ -224,6 +252,7 @@ def text(): return f"Hello, {databricks_user_info.display_name}!" app = App(app_ui, server) + ``` """ def __init__( From 89497bd0466a525926f95e06a5d52d47152cfafb Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Wed, 4 Dec 2024 12:10:53 -0500 Subject: [PATCH 11/21] docstring polish - backticks --- src/posit/connect/external/databricks.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/posit/connect/external/databricks.py b/src/posit/connect/external/databricks.py index 07b4a828..7536746e 100644 --- a/src/posit/connect/external/databricks.py +++ b/src/posit/connect/external/databricks.py @@ -78,7 +78,7 @@ def _get_auth_type(local_auth_type: str) -> str: class PositContentCredentialsProvider: - """CredentialsProvider implementation which initiates a credential exchange using a content-session-token. + """`CredentialsProvider` implementation which initiates a credential exchange using a content-session-token. The content-session-token is provided by Connect through the environment variable `CONNECT_CONTENT_SESSION_TOKEN`. @@ -97,7 +97,7 @@ def __call__(self) -> Dict[str, str]: class PositCredentialsProvider: - """CredentialsProvider implementation which initiates a credential exchange using a user-session-token. + """`CredentialsProvider` implementation which initiates a credential exchange using a user-session-token. The user-session-token is provided by Connect through the HTTP session header `Posit-Connect-User-Session-Token`. From 3f42531112952805478ab00cc2edcd236f786a56 Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Wed, 4 Dec 2024 12:30:19 -0500 Subject: [PATCH 12/21] fix linting issues --- src/posit/connect/external/databricks.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/posit/connect/external/databricks.py b/src/posit/connect/external/databricks.py index 7536746e..f49c0855 100644 --- a/src/posit/connect/external/databricks.py +++ b/src/posit/connect/external/databricks.py @@ -125,7 +125,6 @@ class PositContentCredentialsStrategy(CredentialsStrategy): Examples -------- - NOTE: in the example below, the PositContentCredentialsStrategy can be initialized anywhere that the Python process can read environment variables. @@ -204,7 +203,6 @@ class PositCredentialsStrategy(CredentialsStrategy): Examples -------- - NOTE: In the example below, the PositCredentialsProvider *must* be initialized within the context of the shiny `server` function, which provides access to the HTTP session headers. From fb80f1d306d326a9f4ee2950f28a5c3837d1b8ff Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 4 Dec 2024 14:46:06 -0500 Subject: [PATCH 13/21] Copy in existing example app and add title to each file --- src/posit/connect/external/databricks.py | 12 +++-- src/posit/connect/external/snowflake.py | 59 ++++++++++++++++++++++-- 2 files changed, 62 insertions(+), 9 deletions(-) diff --git a/src/posit/connect/external/databricks.py b/src/posit/connect/external/databricks.py index f49c0855..96cdb743 100644 --- a/src/posit/connect/external/databricks.py +++ b/src/posit/connect/external/databricks.py @@ -1,3 +1,10 @@ +""" +Databricks SDK credentials implementations which support interacting with Posit OAuth integrations on Connect. + +NOTE: These APIs are provided as a convenience and are subject to breaking changes: +https://github.com/databricks/databricks-sdk-py#interface-stability +""" + import abc from typing import Callable, Dict, Optional @@ -5,11 +12,6 @@ from ..oauth import Credentials from .external import is_local -""" -NOTE: These APIs are provided as a convenience and are subject to breaking changes: -https://github.com/databricks/databricks-sdk-py#interface-stability -""" - POSIT_OAUTH_INTEGRATION_AUTH_TYPE = "posit-oauth-integration" # The Databricks SDK CredentialsProvider == Databricks SQL HeaderFactory diff --git a/src/posit/connect/external/snowflake.py b/src/posit/connect/external/snowflake.py index 197efa3a..7f5ec923 100644 --- a/src/posit/connect/external/snowflake.py +++ b/src/posit/connect/external/snowflake.py @@ -1,14 +1,65 @@ +"""Snowflake SDK credentials implementations which support interacting with Posit OAuth integrations on Connect. + +NOTE: The APIs in this module are provided as a convenience and are subject to breaking changes. +""" + from typing import Optional from ..client import Client from .external import is_local -""" -NOTE: The APIs in this module are provided as a convenience and are subject to breaking changes. -""" - class PositAuthenticator: + """ + Authenticator for Snowflake SDK which supports Posit OAuth integrations on Connect. + + Examples + -------- + ```python + import os + + import pandas as pd + import snowflake.connector + import streamlit as st + + from posit.connect.external.snowflake import PositAuthenticator + + ACCOUNT = os.getenv("SNOWFLAKE_ACCOUNT") + WAREHOUSE = os.getenv("SNOWFLAKE_WAREHOUSE") + + # USER is only required when running the example locally with external browser auth + USER = os.getenv("SNOWFLAKE_USER") + + # https://docs.snowflake.com/en/user-guide/sample-data-using + DATABASE = os.getenv("SNOWFLAKE_DATABASE", "snowflake_sample_data") + SCHEMA = os.getenv("SNOWFLAKE_SCHEMA", "tpch_sf1") + TABLE = os.getenv("SNOWFLAKE_TABLE", "lineitem") + + session_token = st.context.headers.get("Posit-Connect-User-Session-Token") + auth = PositAuthenticator( + local_authenticator="EXTERNALBROWSER", user_session_token=session_token + ) + + con = snowflake.connector.connect( + user=USER, + account=ACCOUNT, + warehouse=WAREHOUSE, + database=DATABASE, + schema=SCHEMA, + authenticator=auth.authenticator, + token=auth.token, + ) + + snowflake_user = con.cursor().execute("SELECT CURRENT_USER()").fetchone() + st.write(f"Hello, {snowflake_user[0]}!") + + with st.spinner("Loading data from Snowflake..."): + df = pd.read_sql_query(f"SELECT * FROM {TABLE} LIMIT 10", con) + + st.dataframe(df) + ``` + """ + def __init__( self, local_authenticator: Optional[str] = None, From 73569a3e6bf9517ba10702a9d48468b8d046e877 Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Thu, 5 Dec 2024 09:53:16 -0500 Subject: [PATCH 14/21] add patched-in local strategy for client credential access tokens --- src/posit/connect/external/databricks.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/posit/connect/external/databricks.py b/src/posit/connect/external/databricks.py index 96cdb743..c35e87f4 100644 --- a/src/posit/connect/external/databricks.py +++ b/src/posit/connect/external/databricks.py @@ -12,6 +12,8 @@ from ..oauth import Credentials from .external import is_local +import requests + POSIT_OAUTH_INTEGRATION_AUTH_TYPE = "posit-oauth-integration" # The Databricks SDK CredentialsProvider == Databricks SQL HeaderFactory @@ -78,6 +80,24 @@ def _get_auth_type(local_auth_type: str) -> str: return POSIT_OAUTH_INTEGRATION_AUTH_TYPE +class PositLocalContentCredentialsProvider: + + def __init__(self, token_endpoint_url: str, client_id: str, client_secret: str): + self._token_endpoint_url = token_endpoint_url + self._client_id = client_id + self._client_secret = client_secret + + def __call__(self) -> Dict[str, str]: + response = requests.post( + self._token_endpoint_url, + auth=(self._client_id, self._client_secret), + data={ + "grant_type": "client_credentials", + "scope": "all-apis", + }, + ) + credentials = Credentials(**response.json()) + return _new_bearer_authorization_header(credentials) class PositContentCredentialsProvider: """`CredentialsProvider` implementation which initiates a credential exchange using a content-session-token. From 91877eedd24c49383f5c2b19b5706bcf59b087aa Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Thu, 5 Dec 2024 10:03:35 -0500 Subject: [PATCH 15/21] add implementing strategy that can use local credentials provider --- src/posit/connect/external/databricks.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/posit/connect/external/databricks.py b/src/posit/connect/external/databricks.py index c35e87f4..95ca7f5f 100644 --- a/src/posit/connect/external/databricks.py +++ b/src/posit/connect/external/databricks.py @@ -15,6 +15,7 @@ import requests POSIT_OAUTH_INTEGRATION_AUTH_TYPE = "posit-oauth-integration" +POSIT_LOCAL_CLIENT_CREDENTIALS_AUTH_TYPE = "posit-local-client-credentials" # The Databricks SDK CredentialsProvider == Databricks SQL HeaderFactory CredentialsProvider = Callable[[], Dict[str, str]] @@ -138,6 +139,26 @@ def __call__(self) -> Dict[str, str]: credentials = self._client.oauth.get_credentials(self._user_session_token) return _new_bearer_authorization_header(credentials) +class PositLocalContentCredentialsStrategy(CredentialsStrategy): + def __init__(self, token_endpoint_url: str, client_id: str, client_secret: str): + self._token_endpoint_url = token_endpoint_url + self._client_id = client_id + self._client_secret = client_secret + + def sql_credentials_provider(self, *args, **kwargs): + return lambda: self.__call__(*args, **kwargs) + + def auth_type(self) -> str: + return POSIT_LOCAL_CLIENT_CREDENTIALS_AUTH_TYPE + + def __call__(self) -> CredentialsProvider: + + return PositLocalContentCredentialsProvider( + self._token_endpoint_url, + self._client_id, + self._client_secret, + ) + class PositContentCredentialsStrategy(CredentialsStrategy): """`CredentialsStrategy` implementation which supports interacting with Service Account OAuth integrations on Connect. From c38fecd7e5a989039b0b9eda5765e4b1e5e61117 Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Thu, 5 Dec 2024 16:59:19 -0500 Subject: [PATCH 16/21] missing params --- src/posit/connect/external/databricks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/posit/connect/external/databricks.py b/src/posit/connect/external/databricks.py index 95ca7f5f..f0b21eab 100644 --- a/src/posit/connect/external/databricks.py +++ b/src/posit/connect/external/databricks.py @@ -151,7 +151,7 @@ def sql_credentials_provider(self, *args, **kwargs): def auth_type(self) -> str: return POSIT_LOCAL_CLIENT_CREDENTIALS_AUTH_TYPE - def __call__(self) -> CredentialsProvider: + def __call__(self, *args, **kwargs) -> CredentialsProvider: return PositLocalContentCredentialsProvider( self._token_endpoint_url, From 4a1a512a3175832224a19ee94448961e252b2fa2 Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Thu, 5 Dec 2024 17:07:05 -0500 Subject: [PATCH 17/21] commit to force develop dependency to update --- src/posit/connect/external/databricks.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/posit/connect/external/databricks.py b/src/posit/connect/external/databricks.py index f0b21eab..f75c1494 100644 --- a/src/posit/connect/external/databricks.py +++ b/src/posit/connect/external/databricks.py @@ -152,7 +152,6 @@ def auth_type(self) -> str: return POSIT_LOCAL_CLIENT_CREDENTIALS_AUTH_TYPE def __call__(self, *args, **kwargs) -> CredentialsProvider: - return PositLocalContentCredentialsProvider( self._token_endpoint_url, self._client_id, From 54a47c93250e25b26cbe76ed96414d5228c7fdae Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Thu, 5 Dec 2024 17:15:47 -0500 Subject: [PATCH 18/21] add raise_for_status --- src/posit/connect/external/databricks.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/posit/connect/external/databricks.py b/src/posit/connect/external/databricks.py index f75c1494..9eb64d7f 100644 --- a/src/posit/connect/external/databricks.py +++ b/src/posit/connect/external/databricks.py @@ -97,6 +97,8 @@ def __call__(self) -> Dict[str, str]: "scope": "all-apis", }, ) + response.raise_for_status() + credentials = Credentials(**response.json()) return _new_bearer_authorization_header(credentials) From 144e895091ed56a4f53de62f2dea6cc0b83fb49c Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Thu, 5 Dec 2024 18:02:13 -0500 Subject: [PATCH 19/21] adding docstrings, tests --- src/posit/connect/external/databricks.py | 80 +++++++++++++++++++ .../posit/connect/external/test_databricks.py | 74 +++++++++++++++++ 2 files changed, 154 insertions(+) diff --git a/src/posit/connect/external/databricks.py b/src/posit/connect/external/databricks.py index 9eb64d7f..3cea970d 100644 --- a/src/posit/connect/external/databricks.py +++ b/src/posit/connect/external/databricks.py @@ -82,6 +82,19 @@ def _get_auth_type(local_auth_type: str) -> str: return POSIT_OAUTH_INTEGRATION_AUTH_TYPE class PositLocalContentCredentialsProvider: + """`CredentialsProvider` implementation which provides a fallback for local development using a client credentials flow. + + There is an open issue against the Databricks CLI which prevents it from returning service principal access tokens. + https://github.com/databricks/cli/issues/1939 + + Until the CLI issue is resolved, this CredentialsProvider implements the approach described in the Databricks documentation + for manually generating a workspace-level access token using OAuth M2M authentication. Once it has acquired an access token, + it returns it as a Bearer authorization header like other `CredentialsProvider` implementations. + + See Also + -------- + * https://docs.databricks.com/en/dev-tools/auth/oauth-m2m.html#manually-generate-a-workspace-level-access-token + """ def __init__(self, token_endpoint_url: str, client_id: str, client_secret: str): self._token_endpoint_url = token_endpoint_url @@ -142,6 +155,73 @@ def __call__(self) -> Dict[str, str]: return _new_bearer_authorization_header(credentials) class PositLocalContentCredentialsStrategy(CredentialsStrategy): + """`CredentialsStrategy` implementation which supports local development using OAuth M2M authentication against databricks. + + There is an open issue against the Databricks CLI which prevents it from returning service principal access tokens. + https://github.com/databricks/cli/issues/1939 + + Until the CLI issue is resolved, this CredentialsStrategy provides a drop-in replacement as a local_strategy that can be used + to develop applications which target Service Account OAuth integrations on Connect. + + Examples + -------- + In the example below, the PositContentCredentialsStrategy can be initialized anywhere that + the Python process can read environment variables. + + CLIENT_ID and CLIENT_SECRET credentials associated with the Databricks Service Principal. + + ```python + import os + + from posit.connect.external.databricks import PositContentCredentialsStrategy, PositLocalContentCredentialsStrategy + + import pandas as pd + from databricks import sql + from databricks.sdk.core import ApiClient, Config + from databricks.sdk.service.iam import CurrentUserAPI + + DATABRICKS_HOST = "" + DATABRICKS_HOST_URL = f"https://{DATABRICKS_HOST}" + SQL_HTTP_PATH = "" + TOKEN_ENDPOINT_URL = f"https://{DATABRICKS_HOST}/oidc/v1/token" + + CLIENT_ID = "" + CLIENT_SECRET = "" + + # Rather than relying on the Databricks CLI as a local strategy, we use + # PositLocalContentCredentialsStragtegy as a drop-in replacement. + # Can be replaced with the Databricks CLI implementation when + # https://github.com/databricks/cli/issues/1939 is resolved. + local_strategy = PositLocalContentCredentialsStrategy( + token_endpoint_url=TOKEN_ENDPOINT_URL, + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + ) + + posit_strategy = PositContentCredentialsStrategy(local_strategy=local_strategy) + + cfg = Config(host=DATABRICKS_HOST_URL, credentials_strategy=posit_strategy) + + databricks_user_info = CurrentUserAPI(ApiClient(cfg)).me() + print(f"Hello, {databricks_user_info.display_name}!") + + query = "SELECT * FROM samples.nyctaxi.trips LIMIT 10;" + with sql.connect( + server_hostname=DATABRICKS_HOST, + http_path=SQL_HTTP_PATH, + credentials_provider=posit_strategy.sql_credentials_provider(cfg), + ) as connection: + with connection.cursor() as cursor: + cursor.execute(query) + rows = cursor.fetchall() + print(pd.DataFrame([row.asDict() for row in rows])) + ``` + + See Also + -------- + * https://docs.databricks.com/en/dev-tools/auth/oauth-m2m.html#manually-generate-a-workspace-level-access-token + """ + def __init__(self, token_endpoint_url: str, client_id: str, client_secret: str): self._token_endpoint_url = token_endpoint_url self._client_id = client_id diff --git a/tests/posit/connect/external/test_databricks.py b/tests/posit/connect/external/test_databricks.py index 1a53c3cd..daf5e1ab 100644 --- a/tests/posit/connect/external/test_databricks.py +++ b/tests/posit/connect/external/test_databricks.py @@ -1,3 +1,5 @@ +import base64 + from typing import Dict from unittest.mock import patch @@ -13,6 +15,8 @@ PositContentCredentialsStrategy, PositCredentialsProvider, PositCredentialsStrategy, + PositLocalContentCredentialsProvider, + PositLocalContentCredentialsStrategy, _get_auth_type, _new_bearer_authorization_header, ) @@ -92,6 +96,36 @@ def test_get_auth_type_local(self): def test_get_auth_type_connect(self): assert _get_auth_type("local-auth") == POSIT_OAUTH_INTEGRATION_AUTH_TYPE + @responses.activate + def test_local_content_credentials_provider(self): + + token_url = "https://my-token/url" + client_id = "client_id" + client_secret = "client_secret_123" + basic_auth = f"{client_id}:{client_secret}" + b64_basic_auth = base64.b64encode(basic_auth.encode('utf-8')).decode('utf-8') + + responses.post( + token_url, + match=[ + responses.matchers.urlencoded_params_matcher( + { + "grant_type": "client_credentials", + "scope": "all-apis", + }, + ), + responses.matchers.header_matcher({"Authorization": f"Basic {b64_basic_auth}"}) + ], + json={ + "access_token": "oauth2-m2m-access-token", + "token_type": "Bearer", + "expires_in": 3600, + }, + ) + + cp = PositLocalContentCredentialsProvider(token_url, client_id, client_secret) + assert cp() == {"Authorization": "Bearer oauth2-m2m-access-token"} + @patch.dict("os.environ", {"CONNECT_CONTENT_SESSION_TOKEN": "cit"}) @responses.activate def test_posit_content_credentials_provider(self): @@ -111,6 +145,46 @@ def test_posit_credentials_provider(self): cp = PositCredentialsProvider(client=client, user_session_token="cit") assert cp() == {"Authorization": "Bearer dynamic-viewer-access-token"} + + @responses.activate + def test_local_content_credentials_strategy(self): + + token_url = "https://my-token/url" + client_id = "client_id" + client_secret = "client_secret_123" + basic_auth = f"{client_id}:{client_secret}" + b64_basic_auth = base64.b64encode(basic_auth.encode('utf-8')).decode('utf-8') + + + responses.post( + token_url, + match=[ + responses.matchers.urlencoded_params_matcher( + { + "grant_type": "client_credentials", + "scope": "all-apis", + }, + ), + responses.matchers.header_matcher({"Authorization": f"Basic {b64_basic_auth}"}) + ], + json={ + "access_token": "oauth2-m2m-access-token", + "token_type": "Bearer", + "expires_in": 3600, + }, + ) + + cs = PositLocalContentCredentialsStrategy( + token_url, + client_id, + client_secret, + ) + cp = cs() + assert cs.auth_type() == "posit-local-client-credentials" + assert cp() == {"Authorization": "Bearer oauth2-m2m-access-token"} + + + @patch.dict("os.environ", {"CONNECT_CONTENT_SESSION_TOKEN": "cit"}) @responses.activate @patch.dict("os.environ", {"RSTUDIO_PRODUCT": "CONNECT"}) From 204293852570a5e8ccf7732362efd6c3801f852e Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Fri, 6 Dec 2024 09:22:20 -0500 Subject: [PATCH 20/21] sweep through docstrings in response to PR comments --- src/posit/connect/external/databricks.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/posit/connect/external/databricks.py b/src/posit/connect/external/databricks.py index 3cea970d..59b6add8 100644 --- a/src/posit/connect/external/databricks.py +++ b/src/posit/connect/external/databricks.py @@ -155,7 +155,7 @@ def __call__(self) -> Dict[str, str]: return _new_bearer_authorization_header(credentials) class PositLocalContentCredentialsStrategy(CredentialsStrategy): - """`CredentialsStrategy` implementation which supports local development using OAuth M2M authentication against databricks. + """`CredentialsStrategy` implementation which supports local development using OAuth M2M authentication against Databricks. There is an open issue against the Databricks CLI which prevents it from returning service principal access tokens. https://github.com/databricks/cli/issues/1939 @@ -165,14 +165,12 @@ class PositLocalContentCredentialsStrategy(CredentialsStrategy): Examples -------- - In the example below, the PositContentCredentialsStrategy can be initialized anywhere that + In the example below, the `PositContentCredentialsStrategy` can be initialized anywhere that the Python process can read environment variables. - CLIENT_ID and CLIENT_SECRET credentials associated with the Databricks Service Principal. + CLIENT_ID and CLIENT_SECRET are credentials associated with the Databricks service principal. ```python - import os - from posit.connect.external.databricks import PositContentCredentialsStrategy, PositLocalContentCredentialsStrategy import pandas as pd @@ -189,7 +187,7 @@ class PositLocalContentCredentialsStrategy(CredentialsStrategy): CLIENT_SECRET = "" # Rather than relying on the Databricks CLI as a local strategy, we use - # PositLocalContentCredentialsStragtegy as a drop-in replacement. + # PositLocalContentCredentialsStrategy as a drop-in replacement. # Can be replaced with the Databricks CLI implementation when # https://github.com/databricks/cli/issues/1939 is resolved. local_strategy = PositLocalContentCredentialsStrategy( @@ -249,7 +247,7 @@ class PositContentCredentialsStrategy(CredentialsStrategy): Examples -------- - NOTE: in the example below, the PositContentCredentialsStrategy can be initialized anywhere that + NOTE: in the example below, the `PositContentCredentialsStrategy` can be initialized anywhere that the Python process can read environment variables. ```python @@ -264,7 +262,13 @@ class PositContentCredentialsStrategy(CredentialsStrategy): DATABRICKS_HOST_URL = f"https://{DATABRICKS_HOST}" SQL_HTTP_PATH = "" - # reads `CONNECT_CONTENT_SESSION_TOKEN` environment variable if hosted on Connect + # NOTE: currently the databricks_cli local strategy only supports auth code OAuth flows. + # https://github.com/databricks/cli/issues/1939 + # + # This means that the databricks_cli supports local development using the developer's + # databricks credentials, but not the credentials for a service principal. + # To fallback to service principal credentials in local development, use + # `PositLocalContentCredentialsStrategy` as a drop-in replacement. posit_strategy = PositContentCredentialsStrategy(local_strategy=databricks_cli) cfg = Config(host=DATABRICKS_HOST_URL, credentials_strategy=posit_strategy) From 59862c434cd4f3ceed41c1624148968993ad575e Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Fri, 6 Dec 2024 09:54:33 -0500 Subject: [PATCH 21/21] linting --- src/posit/connect/external/databricks.py | 67 ++++++++++--------- src/posit/connect/oauth/oauth.py | 11 ++- .../posit/connect/external/test_databricks.py | 20 ++---- tests/posit/connect/oauth/test_oauth.py | 3 +- 4 files changed, 49 insertions(+), 52 deletions(-) diff --git a/src/posit/connect/external/databricks.py b/src/posit/connect/external/databricks.py index 59b6add8..e73c5f71 100644 --- a/src/posit/connect/external/databricks.py +++ b/src/posit/connect/external/databricks.py @@ -8,12 +8,12 @@ import abc from typing import Callable, Dict, Optional +import requests + from ..client import Client from ..oauth import Credentials from .external import is_local -import requests - POSIT_OAUTH_INTEGRATION_AUTH_TYPE = "posit-oauth-integration" POSIT_LOCAL_CLIENT_CREDENTIALS_AUTH_TYPE = "posit-local-client-credentials" @@ -81,14 +81,15 @@ def _get_auth_type(local_auth_type: str) -> str: return POSIT_OAUTH_INTEGRATION_AUTH_TYPE + class PositLocalContentCredentialsProvider: """`CredentialsProvider` implementation which provides a fallback for local development using a client credentials flow. - There is an open issue against the Databricks CLI which prevents it from returning service principal access tokens. + There is an open issue against the Databricks CLI which prevents it from returning service principal access tokens. https://github.com/databricks/cli/issues/1939 Until the CLI issue is resolved, this CredentialsProvider implements the approach described in the Databricks documentation - for manually generating a workspace-level access token using OAuth M2M authentication. Once it has acquired an access token, + for manually generating a workspace-level access token using OAuth M2M authentication. Once it has acquired an access token, it returns it as a Bearer authorization header like other `CredentialsProvider` implementations. See Also @@ -103,7 +104,7 @@ def __init__(self, token_endpoint_url: str, client_id: str, client_secret: str): def __call__(self) -> Dict[str, str]: response = requests.post( - self._token_endpoint_url, + self._token_endpoint_url, auth=(self._client_id, self._client_secret), data={ "grant_type": "client_credentials", @@ -115,6 +116,7 @@ def __call__(self) -> Dict[str, str]: credentials = Credentials(**response.json()) return _new_bearer_authorization_header(credentials) + class PositContentCredentialsProvider: """`CredentialsProvider` implementation which initiates a credential exchange using a content-session-token. @@ -136,10 +138,10 @@ def __call__(self) -> Dict[str, str]: class PositCredentialsProvider: """`CredentialsProvider` implementation which initiates a credential exchange using a user-session-token. - - The user-session-token is provided by Connect through the HTTP session header + + The user-session-token is provided by Connect through the HTTP session header `Posit-Connect-User-Session-Token`. - + See Also -------- * https://github.com/posit-dev/posit-sdk-py/blob/main/src/posit/connect/oauth/oauth.py @@ -154,10 +156,11 @@ def __call__(self) -> Dict[str, str]: credentials = self._client.oauth.get_credentials(self._user_session_token) return _new_bearer_authorization_header(credentials) + class PositLocalContentCredentialsStrategy(CredentialsStrategy): """`CredentialsStrategy` implementation which supports local development using OAuth M2M authentication against Databricks. - There is an open issue against the Databricks CLI which prevents it from returning service principal access tokens. + There is an open issue against the Databricks CLI which prevents it from returning service principal access tokens. https://github.com/databricks/cli/issues/1939 Until the CLI issue is resolved, this CredentialsStrategy provides a drop-in replacement as a local_strategy that can be used @@ -165,17 +168,20 @@ class PositLocalContentCredentialsStrategy(CredentialsStrategy): Examples -------- - In the example below, the `PositContentCredentialsStrategy` can be initialized anywhere that + In the example below, the `PositContentCredentialsStrategy` can be initialized anywhere that the Python process can read environment variables. CLIENT_ID and CLIENT_SECRET are credentials associated with the Databricks service principal. ```python - from posit.connect.external.databricks import PositContentCredentialsStrategy, PositLocalContentCredentialsStrategy + from posit.connect.external.databricks import ( + PositContentCredentialsStrategy, + PositLocalContentCredentialsStrategy, + ) import pandas as pd from databricks import sql - from databricks.sdk.core import ApiClient, Config + from databricks.sdk.core import ApiClient, Config from databricks.sdk.service.iam import CurrentUserAPI DATABRICKS_HOST = "" @@ -183,18 +189,18 @@ class PositLocalContentCredentialsStrategy(CredentialsStrategy): SQL_HTTP_PATH = "" TOKEN_ENDPOINT_URL = f"https://{DATABRICKS_HOST}/oidc/v1/token" - CLIENT_ID = "" - CLIENT_SECRET = "" + CLIENT_ID = "" + CLIENT_SECRET = "" - # Rather than relying on the Databricks CLI as a local strategy, we use + # Rather than relying on the Databricks CLI as a local strategy, we use # PositLocalContentCredentialsStrategy as a drop-in replacement. - # Can be replaced with the Databricks CLI implementation when + # Can be replaced with the Databricks CLI implementation when # https://github.com/databricks/cli/issues/1939 is resolved. local_strategy = PositLocalContentCredentialsStrategy( token_endpoint_url=TOKEN_ENDPOINT_URL, client_id=CLIENT_ID, client_secret=CLIENT_SECRET, - ) + ) posit_strategy = PositContentCredentialsStrategy(local_strategy=local_strategy) @@ -214,27 +220,27 @@ class PositLocalContentCredentialsStrategy(CredentialsStrategy): rows = cursor.fetchall() print(pd.DataFrame([row.asDict() for row in rows])) ``` - + See Also -------- * https://docs.databricks.com/en/dev-tools/auth/oauth-m2m.html#manually-generate-a-workspace-level-access-token """ def __init__(self, token_endpoint_url: str, client_id: str, client_secret: str): - self._token_endpoint_url = token_endpoint_url + self._token_endpoint_url = token_endpoint_url self._client_id = client_id self._client_secret = client_secret - def sql_credentials_provider(self, *args, **kwargs): + def sql_credentials_provider(self, *args, **kwargs): return lambda: self.__call__(*args, **kwargs) def auth_type(self) -> str: - return POSIT_LOCAL_CLIENT_CREDENTIALS_AUTH_TYPE + return POSIT_LOCAL_CLIENT_CREDENTIALS_AUTH_TYPE - def __call__(self, *args, **kwargs) -> CredentialsProvider: + def __call__(self, *args, **kwargs) -> CredentialsProvider: # noqa: ARG002 return PositLocalContentCredentialsProvider( - self._token_endpoint_url, - self._client_id, + self._token_endpoint_url, + self._client_id, self._client_secret, ) @@ -247,7 +253,7 @@ class PositContentCredentialsStrategy(CredentialsStrategy): Examples -------- - NOTE: in the example below, the `PositContentCredentialsStrategy` can be initialized anywhere that + NOTE: in the example below, the `PositContentCredentialsStrategy` can be initialized anywhere that the Python process can read environment variables. ```python @@ -265,9 +271,9 @@ class PositContentCredentialsStrategy(CredentialsStrategy): # NOTE: currently the databricks_cli local strategy only supports auth code OAuth flows. # https://github.com/databricks/cli/issues/1939 # - # This means that the databricks_cli supports local development using the developer's - # databricks credentials, but not the credentials for a service principal. - # To fallback to service principal credentials in local development, use + # This means that the databricks_cli supports local development using the developer's + # databricks credentials, but not the credentials for a service principal. + # To fallback to service principal credentials in local development, use # `PositLocalContentCredentialsStrategy` as a drop-in replacement. posit_strategy = PositContentCredentialsStrategy(local_strategy=databricks_cli) @@ -331,7 +337,7 @@ class PositCredentialsStrategy(CredentialsStrategy): Examples -------- - NOTE: In the example below, the PositCredentialsProvider *must* be initialized within the context of the + NOTE: In the example below, the PositCredentialsProvider *must* be initialized within the context of the shiny `server` function, which provides access to the HTTP session headers. ```python @@ -343,7 +349,7 @@ class PositCredentialsStrategy(CredentialsStrategy): from databricks.sdk.service.iam import CurrentUserAPI from posit.connect.external.databricks import PositCredentialsStrategy from shiny import App, Inputs, Outputs, Session, render, ui - + DATABRICKS_HOST = "" DATABRICKS_HOST_URL = f"https://{DATABRICKS_HOST}" SQL_HTTP_PATH = "" @@ -352,7 +358,6 @@ class PositCredentialsStrategy(CredentialsStrategy): def server(i: Inputs, o: Outputs, session: Session): - # HTTP session headers are available in this context. session_token = session.http_conn.headers.get("Posit-Connect-User-Session-Token") posit_strategy = PositCredentialsStrategy( diff --git a/src/posit/connect/oauth/oauth.py b/src/posit/connect/oauth/oauth.py index 82cd1fe3..6d53eeb6 100644 --- a/src/posit/connect/oauth/oauth.py +++ b/src/posit/connect/oauth/oauth.py @@ -13,6 +13,7 @@ USER_SESSION_TOKEN_TYPE = "urn:posit:connect:user-session-token" CONTENT_SESSION_TOKEN_TYPE = "urn:posit:connect:content-session-token" + def _get_content_session_token() -> str: """Return the content session token. @@ -28,15 +29,18 @@ def _get_content_session_token() -> str: """ value = os.environ.get("CONNECT_CONTENT_SESSION_TOKEN") if not value: - raise ValueError("Invalid value for 'CONNECT_CONTENT_SESSION_TOKEN': Must be a non-empty string.") + raise ValueError( + "Invalid value for 'CONNECT_CONTENT_SESSION_TOKEN': Must be a non-empty string." + ) return value + class OAuth(Resources): def __init__(self, params: ResourceParameters, api_key: str) -> None: super().__init__(params) self.api_key = api_key - def _get_credentials_url(self) -> str: + def _get_credentials_url(self) -> str: return self.params.url + "v1/oauth/integrations/credentials" @property @@ -65,11 +69,12 @@ def get_content_credentials(self, content_session_token: Optional[str] = None) - data = {} data["grant_type"] = GRANT_TYPE data["subject_token_type"] = CONTENT_SESSION_TOKEN_TYPE - data["subject_token"] = content_session_token or _get_content_session_token() + data["subject_token"] = content_session_token or _get_content_session_token() response = self.params.session.post(self._get_credentials_url(), data=data) return Credentials(**response.json()) + class Credentials(TypedDict, total=False): access_token: str issued_token_type: str diff --git a/tests/posit/connect/external/test_databricks.py b/tests/posit/connect/external/test_databricks.py index daf5e1ab..9861b907 100644 --- a/tests/posit/connect/external/test_databricks.py +++ b/tests/posit/connect/external/test_databricks.py @@ -1,5 +1,4 @@ import base64 - from typing import Dict from unittest.mock import patch @@ -72,10 +71,7 @@ def register_mocks(): ) - - class TestPositCredentialsHelpers: - def test_new_bearer_authorization_header(self): credential = Credentials() credential["token_type"] = "token_type" @@ -91,19 +87,17 @@ def test_new_bearer_authorization_header(self): def test_get_auth_type_local(self): assert _get_auth_type("local-auth") == "local-auth" - @patch.dict("os.environ", {"RSTUDIO_PRODUCT": "CONNECT"}) def test_get_auth_type_connect(self): assert _get_auth_type("local-auth") == POSIT_OAUTH_INTEGRATION_AUTH_TYPE @responses.activate def test_local_content_credentials_provider(self): - token_url = "https://my-token/url" client_id = "client_id" client_secret = "client_secret_123" basic_auth = f"{client_id}:{client_secret}" - b64_basic_auth = base64.b64encode(basic_auth.encode('utf-8')).decode('utf-8') + b64_basic_auth = base64.b64encode(basic_auth.encode("utf-8")).decode("utf-8") responses.post( token_url, @@ -114,7 +108,7 @@ def test_local_content_credentials_provider(self): "scope": "all-apis", }, ), - responses.matchers.header_matcher({"Authorization": f"Basic {b64_basic_auth}"}) + responses.matchers.header_matcher({"Authorization": f"Basic {b64_basic_auth}"}), ], json={ "access_token": "oauth2-m2m-access-token", @@ -145,16 +139,13 @@ def test_posit_credentials_provider(self): cp = PositCredentialsProvider(client=client, user_session_token="cit") assert cp() == {"Authorization": "Bearer dynamic-viewer-access-token"} - @responses.activate def test_local_content_credentials_strategy(self): - token_url = "https://my-token/url" client_id = "client_id" client_secret = "client_secret_123" basic_auth = f"{client_id}:{client_secret}" - b64_basic_auth = base64.b64encode(basic_auth.encode('utf-8')).decode('utf-8') - + b64_basic_auth = base64.b64encode(basic_auth.encode("utf-8")).decode("utf-8") responses.post( token_url, @@ -165,7 +156,7 @@ def test_local_content_credentials_strategy(self): "scope": "all-apis", }, ), - responses.matchers.header_matcher({"Authorization": f"Basic {b64_basic_auth}"}) + responses.matchers.header_matcher({"Authorization": f"Basic {b64_basic_auth}"}), ], json={ "access_token": "oauth2-m2m-access-token", @@ -183,8 +174,6 @@ def test_local_content_credentials_strategy(self): assert cs.auth_type() == "posit-local-client-credentials" assert cp() == {"Authorization": "Bearer oauth2-m2m-access-token"} - - @patch.dict("os.environ", {"CONNECT_CONTENT_SESSION_TOKEN": "cit"}) @responses.activate @patch.dict("os.environ", {"RSTUDIO_PRODUCT": "CONNECT"}) @@ -201,7 +190,6 @@ def test_posit_content_credentials_strategy(self): assert cs.auth_type() == "posit-oauth-integration" assert cp() == {"Authorization": "Bearer content-access-token"} - @responses.activate @patch.dict("os.environ", {"RSTUDIO_PRODUCT": "CONNECT"}) def test_posit_credentials_strategy(self): diff --git a/tests/posit/connect/oauth/test_oauth.py b/tests/posit/connect/oauth/test_oauth.py index 96966f5b..46cef485 100644 --- a/tests/posit/connect/oauth/test_oauth.py +++ b/tests/posit/connect/oauth/test_oauth.py @@ -8,9 +8,8 @@ class TestOAuthIntegrations: - @patch.dict("os.environ", {"CONNECT_CONTENT_SESSION_TOKEN": "cit"}) - def test_get_content_session_token_success(self): + def test_get_content_session_token_success(self): assert _get_content_session_token() == "cit" def test_get_content_session_token_failure(self):