From c31a1ac646a875f1e3d9de8e1faf8d8ea79a251b Mon Sep 17 00:00:00 2001 From: Jonathan Fuerth Date: Tue, 19 Nov 2024 18:31:25 -0500 Subject: [PATCH] [CU-86b2qymcx] Enable k8s-injected credentials This initial implementation requires low-level configuration of the client: ``` dnastack config contexts add data-connect-k8s dnastack config contexts use data-connect-k8s dnastack config endpoints add --type data_connect data-connect dnastack config endpoints set data-connect url https://collection-service.staging.dnastack.com/data-connect/ dnastack config endpoints set data-connect authentication.client_id loader-cronjob-client dnastack config endpoints set data-connect authentication.resource_url https://publisher-data.staging.dnastack.com/ dnastack config endpoints set data-connect authentication.token_endpoint https://wallet.staging.dnastack.com/oauth/token dnastack config endpoints set data-connect authentication.grant_type client_credentials dnastack config endpoints set data-connect authentication.client_assertion_file /var/run/secrets/kubernetes.io/serviceaccount/token ``` The plan for the future is to have `dnastack use ${service-registry-url}` detect if: * the file /var/run/secrets/kubernetes.io/serviceaccount/token exists * the terminal is non-interactive and if so, edit all the service registry endpoint authentication objects on the fly, making the following edits: * add `client_assertion_file` with the path to the k8s token * replace `client_id` with the one found in the `client_assertion_file` * replace `grant_type` with `client_credentials` * null out `client_secret` If this happens, the CLI will print a message that says so, and it'll offer some switch like `--no-k8s-auth` to inhibit this behaviour. --- .../client_credentials_client_assertion.py | 48 +++++++++++++++++++ ...py => client_credentials_client_secret.py} | 6 +-- .../authenticators/oauth2_adapter/factory.py | 7 ++- .../authenticators/oauth2_adapter/models.py | 1 + dnastack/http/session.py | 2 +- 5 files changed, 58 insertions(+), 6 deletions(-) create mode 100644 dnastack/http/authenticators/oauth2_adapter/client_credentials_client_assertion.py rename dnastack/http/authenticators/oauth2_adapter/{client_credential.py => client_credentials_client_secret.py} (89%) diff --git a/dnastack/http/authenticators/oauth2_adapter/client_credentials_client_assertion.py b/dnastack/http/authenticators/oauth2_adapter/client_credentials_client_assertion.py new file mode 100644 index 00000000..49b237aa --- /dev/null +++ b/dnastack/http/authenticators/oauth2_adapter/client_credentials_client_assertion.py @@ -0,0 +1,48 @@ +from typing import Dict, Any, List + +import requests + +from dnastack.common.tracing import Span +from dnastack.http.authenticators.oauth2_adapter.abstract import OAuth2Adapter, AuthException + + +class ClientCredentialsClientAssertionAdapter(OAuth2Adapter): + __grant_type = 'client_credentials' + + @staticmethod + def get_expected_auth_info_fields() -> List[str]: + return [ + 'client_id', + 'client_assertion_file', # normally at /var/run/secrets/kubernetes.io/serviceaccount/token + 'grant_type', + 'resource_url', + 'token_endpoint', + ] + + def exchange_tokens(self, trace_context: Span) -> Dict[str, Any]: + auth_info = self._auth_info + resource_urls = self._prepare_resource_urls_for_request(auth_info.resource_url) + with open(auth_info.client_assertion_file, 'r') as f: + client_assertion = f.read() + auth_params = dict( + client_id=auth_info.client_id, + grant_type=self.__grant_type, + resource=resource_urls, + client_assertion_type='urn:ietf:params:oauth:client-assertion-type:jwt-bearer', + client_assertion=client_assertion, + ) + + if auth_info.scope: + auth_params['scope'] = auth_info.scope + + with trace_context.new_span(metadata={'oauth': 'client-credentials', 'init_url': auth_info.token_endpoint}) \ + as sub_span: + span_headers = sub_span.create_http_headers() + response = requests.post(auth_info.token_endpoint, data=auth_params, headers=span_headers) + + if not response.ok: + raise AuthException(f'Client assertion authentication for {auth_info.client_id} failed with ' + f'HTTP {response.status_code}:\n\n{response.text}\n', + resource_urls) + + return response.json() diff --git a/dnastack/http/authenticators/oauth2_adapter/client_credential.py b/dnastack/http/authenticators/oauth2_adapter/client_credentials_client_secret.py similarity index 89% rename from dnastack/http/authenticators/oauth2_adapter/client_credential.py rename to dnastack/http/authenticators/oauth2_adapter/client_credentials_client_secret.py index fbd12b95..e586cc76 100644 --- a/dnastack/http/authenticators/oauth2_adapter/client_credential.py +++ b/dnastack/http/authenticators/oauth2_adapter/client_credentials_client_secret.py @@ -5,7 +5,7 @@ from dnastack.http.client_factory import HttpClientFactory -class ClientCredentialAdapter(OAuth2Adapter): +class ClientCredentialsClientSecretAdapter(OAuth2Adapter): __grant_type = 'client_credentials' @staticmethod @@ -55,8 +55,8 @@ def exchange_tokens(self, trace_context: Span) -> Dict[str, Any]: if not response.ok: sub_logger.debug(f'exchange_token: Token exchange fails.') - raise AuthException(f'Failed to perform client-credential authentication for ' - f'{auth_info.client_id} as the server responds with HTTP {response.status_code}:' + raise AuthException(f'Client secret authentication for {auth_info.client_id} failed with ' + f'HTTP {response.status_code}:' f'\n\n{response.text}\n', resource_urls) diff --git a/dnastack/http/authenticators/oauth2_adapter/factory.py b/dnastack/http/authenticators/oauth2_adapter/factory.py index f780e318..bbd4cfa7 100644 --- a/dnastack/http/authenticators/oauth2_adapter/factory.py +++ b/dnastack/http/authenticators/oauth2_adapter/factory.py @@ -3,7 +3,9 @@ from imagination.decorator import service from dnastack.http.authenticators.oauth2_adapter.abstract import OAuth2Adapter -from dnastack.http.authenticators.oauth2_adapter.client_credential import ClientCredentialAdapter +from dnastack.http.authenticators.oauth2_adapter.client_credentials_client_assertion import \ + ClientCredentialsClientAssertionAdapter +from dnastack.http.authenticators.oauth2_adapter.client_credentials_client_secret import ClientCredentialsClientSecretAdapter from dnastack.http.authenticators.oauth2_adapter.device_code_flow import DeviceCodeFlowAdapter from dnastack.http.authenticators.oauth2_adapter.models import OAuth2Authentication @@ -12,8 +14,9 @@ class OAuth2AdapterFactory: # NOTE: It was ordered this way to accommodate the general intended authentication flow. __supported_auth_adapter_classes = [ + ClientCredentialsClientAssertionAdapter, DeviceCodeFlowAdapter, - ClientCredentialAdapter, + ClientCredentialsClientSecretAdapter, ] def get_from(self, auth_info: OAuth2Authentication) -> Optional[OAuth2Adapter]: diff --git a/dnastack/http/authenticators/oauth2_adapter/models.py b/dnastack/http/authenticators/oauth2_adapter/models.py index f81ec9f4..8bd97fe1 100644 --- a/dnastack/http/authenticators/oauth2_adapter/models.py +++ b/dnastack/http/authenticators/oauth2_adapter/models.py @@ -10,6 +10,7 @@ class OAuth2Authentication(BaseModel, HashableModel): authorization_endpoint: Optional[str] client_id: Optional[str] client_secret: Optional[str] + client_assertion_file: Optional[str] device_code_endpoint: Optional[str] grant_type: str personal_access_endpoint: Optional[str] diff --git a/dnastack/http/session.py b/dnastack/http/session.py index e5b89fa7..ba8eb498 100644 --- a/dnastack/http/session.py +++ b/dnastack/http/session.py @@ -121,7 +121,7 @@ def __init__(self, self.__events.set_passthrough(authenticator.events) if not self.__enable_auth: - self.__logger.info('Authentication has been disable for this session.') + self.__logger.info('Authentication has been disabled for this session.') @property def events(self) -> EventSource: