diff --git a/docs/usage.rst b/docs/usage.rst index 69b20eb24..9338dbfdf 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -7,4 +7,11 @@ Usage usage/secrets_engines/index usage/auth_methods/index usage/system_backend + +Wrappers +-------- + +.. toctree:: + :maxdepth: 3 + usage/wrappers/index diff --git a/docs/usage/auth_methods/azure.rst b/docs/usage/auth_methods/azure.rst index e1ce5eed2..d01fe87a8 100644 --- a/docs/usage/auth_methods/azure.rst +++ b/docs/usage/auth_methods/azure.rst @@ -1,7 +1,7 @@ .. _azure-auth-method: -Azure Auth Method -================== +Azure +===== .. note:: Every method under the :py:attr:`Client class's azure attribute` includes a `mount_point` parameter that can be used to address the Azure auth method under a custom mount path. E.g., If enabling the Azure auth method using Vault's CLI commands via `vault auth enable -path=my-azure azure`", the `mount_point` parameter in :py:meth:`hvac.api.auth.Azure` methods would be set to "my-azure". diff --git a/docs/usage/secrets_engines/azure.rst b/docs/usage/secrets_engines/azure.rst index 7a6130aec..15d152399 100644 --- a/docs/usage/secrets_engines/azure.rst +++ b/docs/usage/secrets_engines/azure.rst @@ -1,4 +1,108 @@ .. _azure-secret-engine: -Azure Secret Engine -=================== +Azure +===== + +.. note:: + Every method under the :py:attr:`Azure class` includes a `mount_point` parameter that can be used to address the Azure secret engine under a custom mount path. E.g., If enabling the Azure secret engine using Vault's CLI commands via `vault secrets enable -path=my-azure azure`", the `mount_point` parameter in :py:meth:`hvac.api.secrets_engines.Azure` methods would need to be set to "my-azure". + + +Configure +--------- + +:py:meth:`hvac.api.secrets_engines.Azure.configure` + +.. code:: python + + import hvac + client = hvac.Client() + + client.azure.secret.configure( + subscription_id='my-subscription-id', + tenant_id='my-tenant-id', + ) + +Read Config +----------- + +:py:meth:`hvac.api.secrets_engines.Azure.read_config` + +.. code:: python + + import hvac + client = hvac.Client() + + azure_secret_config = client.azure.secret.read_config() + print('The Azure secret engine is configured with a subscription ID of {id}'.format( + id=azure_secret_config['subscription_id'], + )) + +Delete Config +------------- + +:py:meth:`hvac.api.secrets_engines.Azure.delete_config` + +.. code:: python + + import hvac + client = hvac.Client() + + client.azure.secret.delete_config() + +Create Or Update A Role +----------------------- + +:py:meth:`hvac.api.secrets_engines.Azure.create_or_update_role` + +.. code:: python + + import hvac + client = hvac.Client() + + + azure_roles = [ + { + 'role_name': "Contributor", + 'scope': "/subscriptions/95e675fa-307a-455e-8cdf-0a66aeaa35ae", + }, + ] + client.azure.secret.create_or_update_role( + name='my-azure-secret-role', + azure_roles=azure_roles, + ) + +List Roles +---------- + +:py:meth:`hvac.api.secrets_engines.Azure.list_roles` + +.. code:: python + + import hvac + client = hvac.Client() + + azure_secret_engine_roles = client.azure.secret.list_roles() + print('The following Azure secret roles are configured: {roles}'.format( + roles=','.join(roles['keys']), + )) + + +Generate Credentials +-------------------- + +:py:meth:`hvac.api.secrets_engines.Azure.generate_credentials` + +.. code:: python + + import hvac + from azure.common.credentials import ServicePrincipalCredentials + + client = hvac.Client() + azure_creds = client.azure.secret.secret.generate_credentials( + name='some-azure-role-name', + ) + azure_spc = ServicePrincipalCredentials( + client_id=azure_creds['client_id'], + secret=azure_creds['client_secret'], + tenant=TENANT_ID, + ) diff --git a/docs/usage/wrappers/azure.rst b/docs/usage/wrappers/azure.rst index 80e129685..d2d040deb 100644 --- a/docs/usage/wrappers/azure.rst +++ b/docs/usage/wrappers/azure.rst @@ -1,12 +1,14 @@ Azure ===== -The :py:class:`hvac.api.azure.Azure` instance under the :py:attr:`Client class's azure attribute` is a wrapper to expose either the :py:class:`Azure auth method class` or the :py:class:`Azure secret engine class`. The instances of these classes are under the :py:meth:`auth` and :py:meth:`secret` attributes respectively. +The :py:class:`hvac.api.azure.Azure` instance under the :py:attr:`Client class's azure attribute` is a wrapper to expose either the :py:class:`Azure auth method class` or the :py:class:`Azure secret engine class`. The instances of these classes are under the :py:meth:`auth` and :py:meth:`secret` attributes respectively. Auth Method ----------- -:ref:`azure-auth-method`. +.. note:: + + Additional examples available at: :ref:`Azure Auth Method Usage`. Calling a Azure auth method: @@ -30,7 +32,9 @@ Calling a Azure auth method: Secret Engine ------------- -:ref:`azure-secret-engine`. +.. note:: + + Additional examples available at: :ref:`Azure Secret Engine Usage`. Calling a Azure secret engine method: @@ -43,10 +47,10 @@ Calling a Azure secret engine method: client.azure.secret.configure( # [...] ) - client.azure.auth.create_or_update_role( + client.azure.secret.create_or_update_role( name='some-azure-role-name', ) - azure_creds = client.azure.auth.generate_credentials( + azure_creds = client.azure.secret.generate_credentials( name='some-azure-role-name', ) azure_spc = ServicePrincipalCredentials( diff --git a/docs/usage/wrappers/index.rst b/docs/usage/wrappers/index.rst index 95c1e5605..fe025f621 100644 --- a/docs/usage/wrappers/index.rst +++ b/docs/usage/wrappers/index.rst @@ -4,5 +4,5 @@ Wrappers .. toctree:: :maxdepth: 2 - kv azure + kv diff --git a/hvac/api/azure.py b/hvac/api/azure.py index 3e2443ea0..77169552c 100644 --- a/hvac/api/azure.py +++ b/hvac/api/azure.py @@ -3,7 +3,7 @@ import logging from hvac.api.auth import azure as azure_auth_method -# from hvac.api.secrets_engines import azure as azure_secret_engine +from hvac.api.secrets_engines import azure as azure_secret_engine from hvac.api.vault_api_base import VaultApiBase logger = logging.getLogger(__name__) @@ -25,7 +25,7 @@ def __init__(self, adapter): super(Azure, self).__init__(adapter=adapter) self._azure_auth = azure_auth_method.Azure(adapter=self._adapter) - # self._azure_secret = azure_secret_engine.Azure(adapter=self._adapter) + self._azure_secret = azure_secret_engine.Azure(adapter=self._adapter) @property def auth(self): @@ -39,14 +39,11 @@ def auth(self): @property def secret(self): """Accessor for Azure secret engine instance. Provided via the :py:class:`hvac.api.secrets_engines.Azure` class. - .. warning:: - - Note: Not currently implemented. :return: This Azure instance's associated secrets_engines.Azure instance. :rtype: hvac.api.secrets_engines.Azure """ - raise NotImplementedError + return self._azure_secret def __getattr__(self, item): """Overridden magic method used to direct method calls to the appropriate auth or secret Azure instance. @@ -58,7 +55,7 @@ def __getattr__(self, item): """ if hasattr(self._azure_auth, item): return getattr(self._azure_auth, item) - # elif hasattr(self._azure_secret, item): - # return getattr(self._azure_secret, item) + elif hasattr(self._azure_secret, item): + return getattr(self._azure_secret, item) raise AttributeError diff --git a/hvac/api/secrets_engines/__init__.py b/hvac/api/secrets_engines/__init__.py index c579db7a2..3b221ff3b 100644 --- a/hvac/api/secrets_engines/__init__.py +++ b/hvac/api/secrets_engines/__init__.py @@ -2,11 +2,13 @@ Vault secrets engines endpoints """ +from hvac.api.secrets_engines.azure import Azure from hvac.api.secrets_engines.kv import Kv from hvac.api.secrets_engines.kv_v1 import KvV1 from hvac.api.secrets_engines.kv_v2 import KvV2 __all__ = ( + 'Azure', 'Kv', 'KvV1', 'KvV2', diff --git a/hvac/api/secrets_engines/azure.py b/hvac/api/secrets_engines/azure.py new file mode 100644 index 000000000..b6319172e --- /dev/null +++ b/hvac/api/secrets_engines/azure.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""Azure secret engine methods module.""" +import json + +from hvac import exceptions +from hvac.api.constants import VALID_AZURE_ENVIRONMENTS +from hvac.api.vault_api_base import VaultApiBase + +DEFAULT_MOUNT_POINT = 'azure' + + +class Azure(VaultApiBase): + """Azure Secrets Engine (API). + + Reference: https://www.vaultproject.io/api/secret/azure/index.html + """ + + def configure(self, subscription_id, tenant_id, client_id="", client_secret="", environment='AzurePublicCloud', + mount_point=DEFAULT_MOUNT_POINT): + """Configure the credentials required for the plugin to perform API calls to Azure. + + These credentials will be used to query roles and create/delete service principals. Environment variables will + override any parameters set in the config. + + Supported methods: + POST: /{mount_point}/config. Produces: 204 (empty body) + + + :param subscription_id: The subscription id for the Azure Active Directory + :type subscription_id: str | unicode + :param tenant_id: The tenant id for the Azure Active Directory. + :type tenant_id: str | unicode + :param client_id: The OAuth2 client id to connect to Azure. + :type client_id: str | unicode + :param client_secret: The OAuth2 client secret to connect to Azure. + :type client_secret: str | unicode + :param environment: The Azure environment. If not specified, Vault will use Azure Public Cloud. + :type environment: str | unicode + :param mount_point: The OAuth2 client secret to connect to Azure. + :type mount_point: str | unicode + :return: The response of the request. + :rtype: requests.Response + """ + if environment not in VALID_AZURE_ENVIRONMENTS: + error_msg = 'invalid environment argument provided "{arg}", supported environments: "{environments}"' + raise exceptions.ParamValidationError(error_msg.format( + arg=environment, + environments=','.join(VALID_AZURE_ENVIRONMENTS), + )) + params = { + 'subscription_id': subscription_id, + 'tenant_id': tenant_id, + 'client_id': client_id, + 'client_secret': client_secret, + 'environment': environment, + } + api_path = '/v1/{mount_point}/config'.format(mount_point=mount_point) + return self._adapter.post( + url=api_path, + json=params, + ) + + def read_config(self, mount_point=DEFAULT_MOUNT_POINT): + """Read the stored configuration, omitting client_secret. + + Supported methods: + GET: /{mount_point}/config. Produces: 200 application/json + + + :param mount_point: The "path" the method/backend was mounted on. + :type mount_point: str | unicode + :return: The data key from the JSON response of the request. + :rtype: dict + """ + api_path = '/v1/{mount_point}/config'.format(mount_point=mount_point) + response = self._adapter.get( + url=api_path, + ) + return response.json().get('data') + + def delete_config(self, mount_point=DEFAULT_MOUNT_POINT): + """Delete the stored Azure configuration and credentials. + + Supported methods: + DELETE: /auth/{mount_point}/config. Produces: 204 (empty body) + + + :param mount_point: The "path" the method/backend was mounted on. + :type mount_point: str | unicode + :return: The response of the request. + :rtype: requests.Response + """ + api_path = '/v1/{mount_point}/config'.format(mount_point=mount_point) + return self._adapter.delete( + url=api_path, + ) + + def create_or_update_role(self, name, azure_roles, ttl="", max_ttl="", mount_point=DEFAULT_MOUNT_POINT): + """Create or update a Vault role. + + The provided Azure roles must exist for this call to succeed. See the Azure secrets roles docs for more + information about roles. + + Supported methods: + POST: /{mount_point}/roles/{name}. Produces: 204 (empty body) + + + :param name: Name of the role. + :type name: str | unicode + :param azure_roles: List of Azure roles to be assigned to the generated service principal. + :type azure_roles: list(dict) + :param ttl: Specifies the default TTL for service principals generated using this role. Accepts time suffixed + strings ("1h") or an integer number of seconds. Defaults to the system/engine default TTL time. + :type ttl: str | unicode + :param max_ttl: Specifies the maximum TTL for service principals generated using this role. Accepts time + suffixed strings ("1h") or an integer number of seconds. Defaults to the system/engine max TTL time. + :type max_ttl: str | unicode + :param mount_point: The "path" the method/backend was mounted on. + :type mount_point: str | unicode + :return: The response of the request. + :rtype: requests.Response + """ + params = { + 'azure_roles': json.dumps(azure_roles), + 'ttl': ttl, + 'max_ttl': max_ttl, + } + api_path = '/v1/{mount_point}/roles/{name}'.format( + mount_point=mount_point, + name=name + ) + return self._adapter.post( + url=api_path, + json=params, + ) + + def list_roles(self, mount_point=DEFAULT_MOUNT_POINT): + """List all of the roles that are registered with the plugin. + + Supported methods: + LIST: /{mount_point}/roles. Produces: 200 application/json + + + :param mount_point: The "path" the method/backend was mounted on. + :type mount_point: str | unicode + :return: The data key from the JSON response of the request. + :rtype: dict + """ + api_path = '/v1/{mount_point}/roles'.format(mount_point=mount_point) + response = self._adapter.list( + url=api_path, + ) + return response.json().get('data') + + def generate_credentials(self, name, mount_point=DEFAULT_MOUNT_POINT): + """Generate a new service principal based on the named role. + + Supported methods: + GET: /{mount_point}/creds/{name}. Produces: 200 application/json + + + :param name: Specifies the name of the role to create credentials against. + :type name: str | unicode + :param mount_point: The "path" the method/backend was mounted on. + :type mount_point: str | unicode + :return: The data key from the JSON response of the request. + :rtype: dict + """ + api_path = '/v1/{mount_point}/creds/{name}'.format( + mount_point=mount_point, + name=name, + ) + response = self._adapter.get( + url=api_path, + ) + return response.json().get('data') diff --git a/hvac/tests/integration_tests/api/secrets_engines/test_azure.py b/hvac/tests/integration_tests/api/secrets_engines/test_azure.py new file mode 100644 index 000000000..19700db63 --- /dev/null +++ b/hvac/tests/integration_tests/api/secrets_engines/test_azure.py @@ -0,0 +1,91 @@ +import logging +from unittest import TestCase +from unittest import skipIf + +from parameterized import parameterized + +from hvac import exceptions +from hvac.tests import utils + + +@skipIf(utils.skip_if_vault_version_lt('0.11.0'), "Azure secret engine not available before Vault version 0.11.0") +class TestAzure(utils.HvacIntegrationTestCase, TestCase): + TENANT_ID = '00000000-0000-0000-0000-000000000000' + SUBSCRIPTION_ID = '00000000-0000-0000-0000-000000000000' + DEFAULT_MOUNT_POINT = 'azure-integration-test' + + def setUp(self): + super(TestAzure, self).setUp() + self.client.enable_secret_backend( + backend_type='azure', + mount_point=self.DEFAULT_MOUNT_POINT, + ) + + def tearDown(self): + self.client.disable_secret_backend(mount_point=self.DEFAULT_MOUNT_POINT) + super(TestAzure, self).tearDown() + + @parameterized.expand([ + ('no parameters',), + ('valid environment argument', 'AzureUSGovernmentCloud'), + ('invalid environment argument', 'AzureCityKity', exceptions.ParamValidationError, 'invalid environment argument provided'), + ]) + def test_configure_and_read_configuration(self, test_label, environment=None, raises=False, exception_message=''): + configure_arguments = { + 'subscription_id': self.SUBSCRIPTION_ID, + 'tenant_id': self.TENANT_ID, + 'mount_point': self.DEFAULT_MOUNT_POINT, + } + if environment is not None: + configure_arguments['environment'] = environment + if raises: + with self.assertRaises(raises) as cm: + self.client.azure.secret.configure(**configure_arguments) + self.assertIn( + member=exception_message, + container=str(cm.exception), + ) + else: + configure_response = self.client.azure.secret.configure(**configure_arguments) + logging.debug('configure_response: %s' % configure_response) + read_configuration_response = self.client.azure.secret.read_config( + mount_point=self.DEFAULT_MOUNT_POINT, + ) + logging.debug('read_configuration_response: %s' % read_configuration_response) + # raise Exception() + self.assertEqual( + first=self.SUBSCRIPTION_ID, + second=read_configuration_response['subscription_id'], + ) + self.assertEqual( + first=self.TENANT_ID, + second=read_configuration_response['tenant_id'], + ) + if environment is not None: + self.assertEqual( + first=environment, + second=read_configuration_response['environment'], + ) + + @parameterized.expand([ + ('create and then delete config',), + ]) + def test_delete_config(self, test_label): + configure_response = self.client.azure.secret.configure( + subscription_id=self.SUBSCRIPTION_ID, + tenant_id=self.TENANT_ID, + mount_point=self.DEFAULT_MOUNT_POINT + ) + logging.debug('configure_response: %s' % configure_response) + self.client.azure.secret.delete_config( + mount_point=self.DEFAULT_MOUNT_POINT, + ) + read_configuration_response = self.client.azure.secret.read_config( + mount_point=self.DEFAULT_MOUNT_POINT, + ) + logging.debug('read_configuration_response: %s' % read_configuration_response) + for key in read_configuration_response.keys(): + self.assertEqual( + first='', + second=read_configuration_response[key], + ) diff --git a/hvac/tests/unit_tests/api/secrets_engines/test_azure.py b/hvac/tests/unit_tests/api/secrets_engines/test_azure.py new file mode 100644 index 000000000..52d6edc1f --- /dev/null +++ b/hvac/tests/unit_tests/api/secrets_engines/test_azure.py @@ -0,0 +1,120 @@ +import logging +from unittest import TestCase +from unittest import skipIf + +import requests_mock +from parameterized import parameterized + +from hvac.adapters import Request +from hvac.api.secrets_engines.azure import Azure, DEFAULT_MOUNT_POINT +from hvac.tests import utils + + +@skipIf(utils.skip_if_vault_version_lt('0.11.0'), "Azure secret engine not available before Vault version 0.11.0") +class TestAzure(TestCase): + @parameterized.expand([ + ('create role', None), + ]) + @requests_mock.Mocker() + def test_create_or_update_role(self, test_label, azure_roles, requests_mocker): + expected_status_code = 204 + role_name = 'hvac' + if azure_roles is None: + azure_roles = [ + { + 'role_name': "Contributor", + 'scope': "/subscriptions/95e675fa-307a-455e-8cdf-0a66aeaa35ae", + }, + ] + + mock_url = 'http://localhost:8200/v1/{mount_point}/roles/{name}'.format( + mount_point=DEFAULT_MOUNT_POINT, + name=role_name, + ) + requests_mocker.register_uri( + method='POST', + url=mock_url, + status_code=expected_status_code, + # json=mock_response, + ) + azure = Azure(adapter=Request()) + create_or_update_role_response = azure.create_or_update_role( + name=role_name, + azure_roles=azure_roles, + mount_point=DEFAULT_MOUNT_POINT + ) + logging.debug('create_or_update_role_response: %s' % create_or_update_role_response) + + self.assertEqual( + first=expected_status_code, + second=create_or_update_role_response.status_code, + ) + + @parameterized.expand([ + ('some_test',), + ]) + @requests_mock.Mocker() + def test_list_roles(self, test_label, requests_mocker): + expected_status_code = 200 + role_names = ['hvac'] + mock_response = { + 'data': { + 'roles': role_names, + }, + } + + mock_url = 'http://localhost:8200/v1/{mount_point}/roles'.format( + mount_point=DEFAULT_MOUNT_POINT, + ) + requests_mocker.register_uri( + method='LIST', + url=mock_url, + status_code=expected_status_code, + json=mock_response, + ) + azure = Azure(adapter=Request()) + list_roles_response = azure.list_roles( + mount_point=DEFAULT_MOUNT_POINT + ) + logging.debug('list_roles_response: %s' % list_roles_response) + + self.assertEqual( + first=mock_response['data'], + second=list_roles_response, + ) + + @parameterized.expand([ + ('some_test',), + ]) + @requests_mock.Mocker() + def test_generate_credentials(self, test_label, requests_mocker): + expected_status_code = 200 + role_name = 'hvac' + mock_response = { + 'data': { + 'client_id': 'some_client_id', + 'client_secret': 'some_client_secret', + }, + } + + mock_url = 'http://localhost:8200/v1/{mount_point}/creds/{name}'.format( + mount_point=DEFAULT_MOUNT_POINT, + name=role_name, + ) + requests_mocker.register_uri( + method='GET', + url=mock_url, + status_code=expected_status_code, + json=mock_response, + ) + azure = Azure(adapter=Request()) + generate_credentials_response = azure.generate_credentials( + name=role_name, + mount_point=DEFAULT_MOUNT_POINT + ) + logging.debug('generate_credentials_response: %s' % generate_credentials_response) + + self.assertEqual( + first=mock_response['data'], + second=generate_credentials_response, + ) diff --git a/hvac/tests/unit_tests/api/test_azure.py b/hvac/tests/unit_tests/api/test_azure.py index 56b508e1e..f37eb3b4f 100644 --- a/hvac/tests/unit_tests/api/test_azure.py +++ b/hvac/tests/unit_tests/api/test_azure.py @@ -5,7 +5,7 @@ from hvac.api.azure import Azure from hvac.api.auth import azure as azure_auth_method -# from hvac.api.secrets_engines import azure as azure_secret_engine +from hvac.api.secrets_engines import azure as azure_secret_engine from hvac.tests import utils @@ -22,8 +22,10 @@ def test_auth_property(self): def test_secret_property(self): mock_adapter = MagicMock() azure = Azure(adapter=mock_adapter) - with self.assertRaises(NotImplementedError): - assert azure.secret + self.assertIsInstance( + obj=azure.secret, + cls=azure_secret_engine.Azure, + ) @parameterized.expand([ param( @@ -35,7 +37,6 @@ def test_secret_property(self): 'secret engine method', method='generate_credentials', expected_property='secret', - raises=AttributeError ), ]) def test_getattr(self, label, method, expected_property, raises=None):