diff --git a/CHANGELOG.md b/CHANGELOG.md index 1060137..d2bad0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Support authentication with API tokens +- Added `Credentials` class for parsing credentials from environment similarly than in our Go based tooling. + ## [2.7.0] - 2025-07-23 ### Changed diff --git a/README.md b/README.md index 9684a06..c4eb912 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,16 @@ python setup.py install More usage examples are available under [docs/]. If there's a specific thing you're interested in, but are not able to get working, please [contact UpCloud support](https://upcloud.com/contact/). +### Load credentials from environment + +```python +from upcloud_api import CloudManager, Credentials + +credentials = Credentials.parse() +c = CloudManager(**credentials.dict) +c.get_account() +``` + ### Defining and creating servers ```python diff --git a/requirements-dev.in b/requirements-dev.in index e7ad970..f40e94e 100644 --- a/requirements-dev.in +++ b/requirements-dev.in @@ -1,5 +1,6 @@ +keyring mock pip-tools pytest pytest-cov -responses +responses \ No newline at end of file diff --git a/requirements-dev.txt b/requirements-dev.txt index 9d1d932..c6f533b 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -18,8 +18,20 @@ idna==3.10 # via requests iniconfig==2.1.0 # via pytest +jaraco-classes==3.4.0 + # via keyring +jaraco-context==6.0.1 + # via keyring +jaraco-functools==4.2.1 + # via keyring +keyring==25.6.0 + # via -r requirements-dev.in mock==5.2.0 # via -r requirements-dev.in +more-itertools==10.7.0 + # via + # jaraco-classes + # jaraco-functools packaging==25.0 # via # build diff --git a/setup.cfg b/setup.cfg index 234c035..9b54482 100644 --- a/setup.cfg +++ b/setup.cfg @@ -20,3 +20,7 @@ install_requires = packages = upcloud_api upcloud_api.cloud_manager + +[options.extras_require] +keyring = + keyring>=23.0 diff --git a/test/test_cloud_manager.py b/test/test_cloud_manager.py index 1d99f02..54aac38 100644 --- a/test/test_cloud_manager.py +++ b/test/test_cloud_manager.py @@ -1,10 +1,18 @@ import json +import pytest import responses from conftest import Mock +from upcloud_api import CloudManager +from upcloud_api.errors import UpCloudClientError + class TestCloudManagerBasic: + def test_no_credentials(self): + with pytest.raises(UpCloudClientError): + CloudManager() + @responses.activate def test_get_account(self, manager): data = Mock.mock_get("account") diff --git a/test/test_credentials.py b/test/test_credentials.py new file mode 100644 index 0000000..e9a1b71 --- /dev/null +++ b/test/test_credentials.py @@ -0,0 +1,66 @@ +import keyring + +from upcloud_api import Credentials + + +class DictBackend(keyring.backend.KeyringBackend): + priority = 1 + + def __init__(self, secrets=None): + super().__init__() + self._secrets = secrets or {} + + def set_password(self, servicename, username, password): + pass + + def get_password(self, servicename, username): + return self._secrets.get(servicename, {}).get(username) + + def delete_password(self, servicename, username): + pass + + +class TestCredentials: + def test_precedence(self, monkeypatch): + param_basic = 'Basic cGFyYW1fdXNlcjpwYXJhbV9wYXNz' + param_bearer = 'Bearer param_token' + env_basic = 'Basic ZW52X3VzZXI6ZW52X3Bhc3M=' + env_bearer = 'Bearer env_token' + keyring_basic = 'Basic ZW52X3VzZXI6a2V5cmluZ19wYXNz' + keyring_bearer = 'Bearer keyring_token' + + backend = DictBackend( + { + "UpCloud": { + "env_user": "keyring_pass", + "": "keyring_token", + } + } + ) + keyring.set_keyring(backend) + + credentials = Credentials.parse() + assert credentials.authorization == keyring_bearer + + monkeypatch.setenv("UPCLOUD_USERNAME", 'env_user') + + credentials = Credentials.parse() + assert credentials.authorization == keyring_basic + + monkeypatch.setenv("UPCLOUD_PASSWORD", 'env_pass') + + credentials = Credentials.parse(username='param_user', password='param_pass') + assert credentials.authorization == param_basic + + credentials = Credentials.parse() + assert credentials.authorization == env_basic + + monkeypatch.setenv("UPCLOUD_TOKEN", 'env_token') + credentials = Credentials.parse(username='param_user', password='param_pass') + assert credentials.authorization == param_basic + + credentials = Credentials.parse() + assert credentials.authorization == env_bearer + + credentials = Credentials.parse(token='param_token') + assert credentials.authorization == param_bearer diff --git a/upcloud_api/__init__.py b/upcloud_api/__init__.py index 3df1c6a..7543488 100644 --- a/upcloud_api/__init__.py +++ b/upcloud_api/__init__.py @@ -11,6 +11,7 @@ __copyright__ = 'Copyright (c) 2015 UpCloud Oy' from upcloud_api.cloud_manager import CloudManager +from upcloud_api.credentials import Credentials from upcloud_api.errors import UpCloudAPIError, UpCloudClientError from upcloud_api.firewall import FirewallRule from upcloud_api.host import Host diff --git a/upcloud_api/cloud_manager/__init__.py b/upcloud_api/cloud_manager/__init__.py index 8860031..dfb1cca 100644 --- a/upcloud_api/cloud_manager/__init__.py +++ b/upcloud_api/cloud_manager/__init__.py @@ -1,5 +1,3 @@ -import base64 - from upcloud_api.api import API from upcloud_api.cloud_manager.firewall_mixin import FirewallManager from upcloud_api.cloud_manager.host_mixin import HostManager @@ -10,6 +8,8 @@ from upcloud_api.cloud_manager.server_mixin import ServerManager from upcloud_api.cloud_manager.storage_mixin import StorageManager from upcloud_api.cloud_manager.tag_mixin import TagManager +from upcloud_api.credentials import Credentials +from upcloud_api.errors import UpCloudClientError class CloudManager( @@ -31,21 +31,23 @@ class CloudManager( api: API - def __init__(self, username: str, password: str, timeout: int = 60) -> None: + def __init__( + self, username: str = None, password: str = None, timeout: int = 60, token: str = None + ) -> None: """ Initiates CloudManager that handles all HTTP connections with UpCloud's API. Optionally determine a timeout for API connections (in seconds). A timeout with the value `None` means that there is no timeout. """ - if not username or not password: - raise Exception('Invalid credentials, please provide a username and password') - - credentials = f'{username}:{password}'.encode() - encoded_credentials = base64.b64encode(credentials).decode() + credentials = Credentials(username, password, token) + if not credentials.is_defined: + raise UpCloudClientError( + "Credentials are not defined. Please provide username and password or an API token." + ) self.api = API( - token=f'Basic {encoded_credentials}', + token=credentials.authorization, timeout=timeout, ) diff --git a/upcloud_api/credentials.py b/upcloud_api/credentials.py new file mode 100644 index 0000000..a3f26e3 --- /dev/null +++ b/upcloud_api/credentials.py @@ -0,0 +1,120 @@ +import base64 +import os + +try: + import keyring +except ImportError: + keyring = None + +from upcloud_api.errors import UpCloudClientError + +ENV_KEY_USERNAME = "UPCLOUD_USERNAME" +ENV_KEY_PASSWORD = "UPCLOUD_PASSWORD" # noqa: S105 +ENV_KEY_TOKEN = "UPCLOUD_TOKEN" # noqa: S105 + +KEYRING_SERVICE_NAME = "UpCloud" +KEYRING_TOKEN_USER = "" +KEYRING_GO_PREFIX = "go-keyring-base64:" + + +def _parse_keyring_value(value: str) -> str: + if value.startswith(KEYRING_GO_PREFIX): + value = value[len(KEYRING_GO_PREFIX) :] + return base64.b64decode(value).decode() + + return value + + +def _read_keyring_value(username: str) -> str: + if keyring is None: + return None + + value = keyring.get_password(KEYRING_SERVICE_NAME, username) + try: + return _parse_keyring_value(value) if value else None + except Exception: + raise UpCloudClientError( + f"Failed to read keyring value for {username}. Ensure that the value saved to the system keyring is correct." + ) from None + + +class Credentials: + """ + Class for handling UpCloud API credentials. + """ + + def __init__(self, username: str = None, password: str = None, token: str = None): + """ + Initializes the Credentials object with username, password and/or token. Use `parse` method to read credentials from environment variables or keyring. + """ + self._username = username + self._password = password + self._token = token + + @property + def authorization(self) -> str: + """ + Returns the authorization header value based on the provided credentials. + """ + if self._token: + return f"Bearer {self._token}" + + credentials = f"{self._username}:{self._password}".encode() + encoded_credentials = base64.b64encode(credentials).decode() + return f"Basic {encoded_credentials}" + + @property + def dict(self) -> dict: + """ + Returns the credentials as a dictionary. + """ + return { + "username": self._username, + "password": self._password, + "token": self._token, + } + + @property + def is_defined(self) -> bool: + """ + Checks if the credentials are defined. + """ + return bool(self._username and self._password or self._token) + + def _read_from_env(self): + if not self._username: + self._username = os.getenv(ENV_KEY_USERNAME) + if not self._password: + self._password = os.getenv(ENV_KEY_PASSWORD) + if not self._token: + self._token = os.getenv(ENV_KEY_TOKEN) + + def _read_from_keyring(self): + if self._username and not self._password: + self._password = _read_keyring_value(self._username) + + if self.is_defined: + return + + self._token = _read_keyring_value(KEYRING_TOKEN_USER) + + @classmethod + def parse(cls, username: str = None, password: str = None, token: str = None): + """ + Parses credentials from the provided parameters, environment variables or the system keyring. + """ + credentials = cls(username, password, token) + if credentials.is_defined: + return credentials + + credentials._read_from_env() + if credentials.is_defined: + return credentials + + credentials._read_from_keyring() + if credentials.is_defined: + return credentials + + raise UpCloudClientError( + f"Credentials not found. These must be set in configuration, via environment variables or in the system keyring ({KEYRING_SERVICE_NAME})" + )