diff --git a/docs/usage/secrets_engines/index.rst b/docs/usage/secrets_engines/index.rst index 4728a7d0..a3041854 100644 --- a/docs/usage/secrets_engines/index.rst +++ b/docs/usage/secrets_engines/index.rst @@ -10,6 +10,7 @@ Secrets Engines database gcp identity + ldap pki kv kv_v1 diff --git a/docs/usage/secrets_engines/ldap.rst b/docs/usage/secrets_engines/ldap.rst new file mode 100644 index 00000000..f3e830f0 --- /dev/null +++ b/docs/usage/secrets_engines/ldap.rst @@ -0,0 +1,146 @@ +LDAP +================ + +.. contents:: + +Configure LDAP Secrets Secrets Engine +------------------------------------- + +Configure the LDAP secrets engine to either manage service accounts or service account libraries. + +Source reference: :py:meth:`hvac.api.secrets_engines.ldap.configure` + +.. code:: python + + import hvac + client = hvac.Client() + + # Authenticate to Vault using client.auth.x + + # Not all these settings may apply to your setup, refer to Vault + # documentation for context of what to use here + + config_response = client.secrets.ldap.configure( + binddn='username@domain.fqdn', # A upn or DN can be used for this value, Vault resolves the user to a dn silently + bindpass='***********', + url='ldaps://domain.fqdn', + userdn='cn=Users,dn=domain,dn=fqdn', + upndomain='domain.fqdn', + userattr="cn", + schema="openldap" + ) + print(config_response) + + +Read Config +----------- + +Return the LDAP Secret Engine configuration. + +Source reference: :py:meth:`hvac.api.secrets_engines.ldap.read_config` + +.. code:: python + + import hvac + client = hvac.Client() + + # Authenticate to Vault using client.auth.x + + config_response = client.secrets.ldap.read_config() + + +Create or Update Static Role +---------------------------- + +Create or Update a role which allows the retrieval and rotation of an LDAP account. Retrieve and rotate the actual credential via generate_static_credentials(). + +Source reference: :py:meth:`hvac.api.secrets_engines.ldap.create_or_update_static_role` + +.. code:: python + + import hvac + client = hvac.Client() + + # Authenticate to Vault using client.auth.x + + role_response = client.secrets.ldap.create_or_update_static_role( + name='hvac-role', + username='sql-service-account', + dn='cn=sql-service-account,dc=petshop,dc=com', + rotation_period="60s") + + +Read Static Role +---------------- + +Retrieve the role configuration which allows the retrieval and rotation of an LDAP account. Retrieve and rotate the actual credential via generate_static_credentials(). + +Source reference: :py:meth:`hvac.api.secrets_engines.ldap.read_static_role` + +.. code:: python + + import hvac + client = hvac.Client() + + # Authenticate to Vault using client.auth.x + + role_response = client.secrets.ldap.read_static_role(name='sql-service-account') + + +List Static Roles +----------------- + +List all configured roles which allows the retrieval and rotation of an LDAP account. Retrieve and rotate the actual credential via generate_static_credentials(). + +Source reference: :py:meth:`hvac.api.secrets_engines.ldap.list_static_roles` + +.. code:: python + + import hvac + client = hvac.Client() + + # Authenticate to Vault using client.auth.x + + all_static_roles = client.secrets.ldap.list_static_roles() + + +Delete Static Role +------------------ + +Remove the role configuration which allows the retrieval and rotation of an LDAP account. + +Passwords are not rotated upon deletion of a static role. The password should be manually rotated prior to deleting the role or revoking access to the static role. + +Source reference: :py:meth:`hvac.api.secrets_engines.ldap.delete_static_role` + +.. code:: python + + import hvac + client = hvac.Client() + + # Authenticate to Vault using client.auth.x + + deletion_response = client.secrets.ldap.delete_static_role(name='sql-service-account') + +Generate Static Credentials +--------------------------- + +Retrieve a service account password from LDAP. Return the previous password (if known). Vault shall rotate +the password before returning it, if it has breached its configured ttl. + +Source reference: :py:meth:`hvac.api.secrets_engines.ldap.generate_static_credentials` + +.. code:: python + + import hvac + client = hvac.Client() + + # Authenticate to Vault using client.auth.x + + gen_creds_response = client.secrets.ldap.generate_static_credentials( + name='hvac-role', + ) + print('Retrieved Service Account Password: {access} (Current) / {secret} (Old)'.format( + access=gen_creds_response['data']['current_password'], + secret=gen_creds_response['data']['old_password'], + )) diff --git a/hvac/api/secrets_engines/__init__.py b/hvac/api/secrets_engines/__init__.py index 9f5430fe..03cf0b53 100644 --- a/hvac/api/secrets_engines/__init__.py +++ b/hvac/api/secrets_engines/__init__.py @@ -9,6 +9,7 @@ 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 +from hvac.api.secrets_engines.ldap import Ldap from hvac.api.secrets_engines.pki import Pki from hvac.api.secrets_engines.rabbitmq import RabbitMQ from hvac.api.secrets_engines.ssh import Ssh @@ -25,6 +26,7 @@ "Kv", "KvV1", "KvV2", + "Ldap", "Pki", "Transform", "Transit", @@ -45,6 +47,7 @@ class SecretsEngines(VaultApiCategory): ActiveDirectory, Identity, Kv, + Ldap, Pki, Transform, Transit, diff --git a/hvac/api/secrets_engines/ldap.py b/hvac/api/secrets_engines/ldap.py new file mode 100644 index 00000000..369a8db5 --- /dev/null +++ b/hvac/api/secrets_engines/ldap.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python +"""LDAP methods module.""" + +from hvac import utils +from hvac.api.vault_api_base import VaultApiBase + +DEFAULT_MOUNT_POINT = "ldap" + + +class Ldap(VaultApiBase): + """LDAP Secrets Engine (API). + Reference: https://www.vaultproject.io/api/secret/ldap/index.html + """ + + def configure( + self, + binddn=None, + bindpass=None, + url=None, + password_policy=None, + schema=None, + userdn=None, + userattr=None, + upndomain=None, + mount_point=DEFAULT_MOUNT_POINT, + ): + """Configure shared information for the ldap secrets engine. + + Supported methods: + POST: /{mount_point}/config. Produces: 204 (empty body) + + :param binddn: Distinguished name of object to bind when performing user and group search. + :type binddn: str | unicode + :param bindpass: Password to use along with binddn when performing user search. + :type bindpass: str | unicode + :param url: Base DN under which to perform user search. + :type url: str | unicode + :param userdn: Base DN under which to perform user search. + :type userdn: str | unicode + :param upndomain: userPrincipalDomain used to construct the UPN string for the authenticating user. + :type upndomain: str | unicode + :param password_policy: The name of the password policy to use to generate passwords. + :type password_policy: str | unicode + :param schema: The LDAP schema to use when storing entry passwords. Valid schemas include ``openldap``, ``ad``, and ``racf``. + :type schema: 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 = utils.remove_nones( + { + "binddn": binddn, + "bindpass": bindpass, + "url": url, + "userdn": userdn, + "userattr": userattr, + "upndomain": upndomain, + "password_policy": password_policy, + "schema": schema, + } + ) + + api_path = utils.format_url("/v1/{mount_point}/config", mount_point=mount_point) + return self._adapter.post( + url=api_path, + json=params, + ) + + def read_config(self, mount_point=DEFAULT_MOUNT_POINT): + """Read the configured shared information for the ldap secrets engine. + + Credentials will be omitted from returned data. + + 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 JSON response of the request. + :rtype: dict + """ + api_path = utils.format_url("/v1/{mount_point}/config", mount_point=mount_point) + return self._adapter.get( + url=api_path, + ) + + def rotate_root(self, mount_point=DEFAULT_MOUNT_POINT): + """Rotate the root password for the binddn entry used to manage the ldap secrets engine. + + Supported methods: + POST: /{mount_point}/rotate root. Produces: 200 application/json + + :param mount_point: The "path" the method/backend was mounted on. + :type mount_point: str | unicode + :return: The JSON response of the request. + :rtype: dict + """ + api_path = utils.format_url( + "/v1/{mount_point}/rotate-root", mount_point=mount_point + ) + return self._adapter.post(url=api_path) + + def create_or_update_static_role( + self, + name, + username=None, + dn=None, + rotation_period=None, + mount_point=DEFAULT_MOUNT_POINT, + ): + """This endpoint creates or updates the ldap static role definition. + + :param name: Specifies the name of an existing static role against which to create this ldap credential. + :type name: str | unicode + :param username: The name of a pre-existing service account in LDAP that maps to this static role. + This value is required on create and cannot be updated. + :type username: str | unicode + :param dn: Distinguished name of the existing LDAP entry to manage password rotation for (takes precedence over username). + Optional but cannot be modified after creation. + :type dn: str | unicode + :param rotation_period: How often Vault should rotate the password. + This is provided as a string duration with a time suffix like "30s" or "1h" or as seconds. + If not provided, the default Vault rotation_period is used. + :type rotation_period: str | unicode + :param mount_point: Specifies the place where the secrets engine will be accessible (default: ad). + :type mount_point: str | unicode + :return: The response of the request. + :rtype: requests.Response + """ + api_path = utils.format_url("/v1/{}/static-role/{}", mount_point, name) + params = {"username": username, "rotation_period": rotation_period} + params.update(utils.remove_nones({"dn": dn})) + return self._adapter.post( + url=api_path, + json=params, + ) + + def read_static_role(self, name, mount_point=DEFAULT_MOUNT_POINT): + """This endpoint queries for information about an ldap static role with the given name. + If no role exists with that name, a 404 is returned. + :param name: Specifies the name of the static role to query. + :type name: str | unicode + :param mount_point: Specifies the place where the secrets engine will be accessible (default: ad). + :type mount_point: str | unicode + :return: The response of the request. + :rtype: requests.Response + """ + api_path = utils.format_url("/v1/{}/static-role/{}", mount_point, name) + return self._adapter.get( + url=api_path, + ) + + def list_static_roles(self, mount_point=DEFAULT_MOUNT_POINT): + """This endpoint lists all existing static roles in the secrets engine. + :return: The response of the request. + :rtype: requests.Response + """ + api_path = utils.format_url("/v1/{}/static-role", mount_point) + return self._adapter.list( + url=api_path, + ) + + def delete_static_role(self, name, mount_point=DEFAULT_MOUNT_POINT): + """This endpoint deletes an ldap static role with the given name. + Even if the role does not exist, this endpoint will still return a successful response. + :param name: Specifies the name of the role to delete. + :type name: str | unicode + :param mount_point: Specifies the place where the secrets engine will be accessible (default: ad). + :type mount_point: str | unicode + :return: The response of the request. + :rtype: requests.Response + """ + api_path = utils.format_url("/v1/{}/static-role/{}", mount_point, name) + return self._adapter.delete( + url=api_path, + ) + + def generate_static_credentials(self, name, mount_point=DEFAULT_MOUNT_POINT): + """This endpoint retrieves the previous and current LDAP password for + the associated account (or rotate if required) + + :param name: Specifies the name of the static role to request credentials from. + :type name: str | unicode + :param mount_point: Specifies the place where the secrets engine will be accessible (default: ad). + :type mount_point: str | unicode + :return: The response of the request. + :rtype: requests.Response + """ + api_path = utils.format_url("/v1/{}/static-cred/{}", mount_point, name) + return self._adapter.get( + url=api_path, + ) diff --git a/tests/unit_tests/api/secrets_engines/test_ldap.py b/tests/unit_tests/api/secrets_engines/test_ldap.py new file mode 100644 index 00000000..512ea9cb --- /dev/null +++ b/tests/unit_tests/api/secrets_engines/test_ldap.py @@ -0,0 +1,319 @@ +from unittest import TestCase + +import requests_mock +from parameterized import parameterized + +from hvac.adapters import JSONAdapter +from hvac.api.secrets_engines import Ldap +from hvac.api.secrets_engines.ldap import DEFAULT_MOUNT_POINT + + +class TestLdap(TestCase): + @parameterized.expand( + [ + ("default mount point", DEFAULT_MOUNT_POINT), + ("custom mount point", "other-ldap-tree"), + ] + ) + @requests_mock.Mocker() + def test_configure(self, test_label, mount_point, requests_mocker): + expected_status_code = 204 + mock_url = "http://localhost:8200/v1/{mount_point}/config".format( + mount_point=mount_point, + ) + requests_mocker.register_uri( + method="POST", + url=mock_url, + status_code=expected_status_code, + ) + ldap = Ldap(adapter=JSONAdapter()) + response = ldap.configure( + binddn="cn=admin,dc=example,dc=com", + bindpass="password", + url="ldaps://ldap.example.com", + mount_point=mount_point, + upndomain="example.com", + password_policy=None, + userattr=None, + schema=None, + userdn="ou=users,dc=example,dc=com", + ) + + self.assertEqual( + first=expected_status_code, + second=response.status_code, + ) + + @parameterized.expand( + [ + ("default mount point", DEFAULT_MOUNT_POINT), + ("custom mount point", "other-ldap-tree"), + ] + ) + @requests_mock.Mocker() + def test_read_configuration(self, test_label, mount_point, requests_mocker): + expected_status_code = 200 + mock_response = { + "lease_id": "", + "warnings": None, + "wrap_info": None, + "auth": None, + "lease_duration": 0, + "request_id": "dd7c3635-8e1c-d454-7381-bf11970fe8de", + "data": { + "anonymous_group_search": False, + "binddn": "cn=admin,dc=example,dc=com", + "case_sensitive_names": False, + "certificate": "", + "connection_timeout": "", + "deny_null_bind": True, + "dereference_aliases": "never", + "discoverdn": False, + "groupattr": "", + "groupdn": "", + "groupfilter": "", + "insecure_tls": False, + "max_page_size": "0", + "request_timeout": "90", + "starttls": False, + "tls_max_version": "tls12", + "tls_min_version": "tls12", + "upndomain": "", + "url": "ldaps://ldap.example.com", + "userattr": "", + "userdn": "", + }, + "renewable": False, + } + mock_url = "http://localhost:8200/v1/{mount_point}/config".format( + mount_point=mount_point, + ) + requests_mocker.register_uri( + method="GET", + url=mock_url, + status_code=expected_status_code, + json=mock_response, + ) + ldap = Ldap(adapter=JSONAdapter()) + response = ldap.read_config( + mount_point=mount_point, + ) + self.assertEqual( + first=mock_response, + second=response, + ) + + @parameterized.expand( + [ + ("default mount point", DEFAULT_MOUNT_POINT), + ("custom mount point", "other-ldap-tree"), + ] + ) + @requests_mock.Mocker() + def test_rotate_root(self, test_label, mount_point, requests_mocker): + expected_status_code = 204 + mock_url = "http://localhost:8200/v1/{mount_point}/rotate-root".format( + mount_point=mount_point, + ) + requests_mocker.register_uri( + method="POST", + url=mock_url, + status_code=expected_status_code, + ) + ldap = Ldap(adapter=JSONAdapter()) + response = ldap.rotate_root( + mount_point=mount_point, + ) + self.assertEqual( + first=expected_status_code, + second=response.status_code, + ) + + @parameterized.expand( + [ + ("default mount point", DEFAULT_MOUNT_POINT, "role1"), + ("custom mount point", "other-ldap-tree", "role2"), + ] + ) + @requests_mock.Mocker() + def test_create_or_update_static_role( + self, test_label, mount_point, name, requests_mocker + ): + expected_status_code = 204 + mock_url = "http://localhost:8200/v1/{mount_point}/static-role/{name}".format( + mount_point=mount_point, + name=name, + ) + requests_mocker.register_uri( + method="POST", + url=mock_url, + status_code=expected_status_code, + ) + ldap = Ldap(adapter=JSONAdapter()) + response = ldap.create_or_update_static_role( + name=name, + mount_point=mount_point, + ) + self.assertEqual( + first=expected_status_code, + second=response.status_code, + ) + + @parameterized.expand( + [ + ("default mount point", DEFAULT_MOUNT_POINT), + ("custom mount point", "other-ldap-tree"), + ] + ) + @requests_mock.Mocker() + def test_read_static_role(self, test_label, mount_point, requests_mocker): + expected_status_code = 200 + role_name = "hvac" + mock_response = { + "lease_id": "", + "warnings": None, + "wrap_info": None, + "auth": None, + "lease_duration": 0, + "request_id": "448bc87c-e948-ac5f-907c-9b01fb9d26c6", + "data": { + "username": "myuser", + "dn": "cn=myuser,ou=users,dc=example,dc=com", + "rotation_period": 600, + }, + "renewable": False, + } + mock_url = "http://localhost:8200/v1/{mount_point}/static-role/{name}".format( + mount_point=mount_point, + name=role_name, + ) + requests_mocker.register_uri( + method="GET", + url=mock_url, + status_code=expected_status_code, + json=mock_response, + ) + ldap = Ldap(adapter=JSONAdapter()) + response = ldap.read_static_role( + name=role_name, + mount_point=mount_point, + ) + self.assertEqual( + first=mock_response, + second=response, + ) + + @parameterized.expand( + [ + ("default mount point", DEFAULT_MOUNT_POINT), + ("custom mount point", "other-ldap-tree"), + ] + ) + @requests_mock.Mocker() + def test_generate_static_credentials( + self, test_label, mount_point, requests_mocker + ): + expected_status_code = 200 + role_name = "hvac" + mock_response = { + "dn": "uid=hashicorp,ou=Users,dc=example,dc=com", + "last_vault_rotation": "2020-02-19T11:31:53.7812-05:00", + "password": "LTNfyn7pS7XEZIxEYQ2sEAWic02PEP7zSvIs0xMqIjaU0ORzLhKOKVmYLxL1Xkyv", + "last_password": "?@09AZSen9TzUwK7ZhafS7B0GuWGraQjfWEna5SwnmF/tVaKFqjXhhGV/Z0v/pBJ", + "rotation_period": 86400, + "ttl": 86072, + "username": "hashicorp", + } + mock_url = "http://localhost:8200/v1/{mount_point}/static-cred/{name}".format( + mount_point=mount_point, + name=role_name, + ) + requests_mocker.register_uri( + method="GET", + url=mock_url, + status_code=expected_status_code, + json=mock_response, + ) + ldap = Ldap(adapter=JSONAdapter()) + response = ldap.generate_static_credentials( + name=role_name, + mount_point=mount_point, + ) + self.assertEqual( + first=mock_response, + second=response, + ) + + @parameterized.expand( + [ + ("default mount point", DEFAULT_MOUNT_POINT), + ("custom mount point", "other-ldap-tree"), + ] + ) + @requests_mock.Mocker() + def test_delete_static_role(self, test_label, mount_point, requests_mocker): + expected_status_code = 204 + role_name = "hvac" + mock_url = "http://localhost:8200/v1/{mount_point}/static-role/{name}".format( + mount_point=mount_point, + name=role_name, + ) + requests_mocker.register_uri( + method="DELETE", + url=mock_url, + status_code=expected_status_code, + ) + ldap = Ldap(adapter=JSONAdapter()) + response = ldap.delete_static_role( + name=role_name, + mount_point=mount_point, + ) + self.assertEqual( + first=expected_status_code, + second=response.status_code, + ) + + @parameterized.expand( + [ + ("default mount point", DEFAULT_MOUNT_POINT), + ("custom mount point", "other-ldap-tree"), + ] + ) + @requests_mock.Mocker() + def test_list_static_roles(self, test_label, mount_point, requests_mocker): + expected_status_code = 200 + mock_response = { + "lease_id": "", + "warnings": None, + "wrap_info": None, + "auth": None, + "lease_duration": 0, + "request_id": "0c34cc02-2f75-7deb-a531-33cf7434a729", + "data": { + "roles": [ + { + "username": "myuser", + "dn": "cn=myuser,ou=users,dc=example,dc=com", + "rotation_period": 600, + } + ] + }, + "renewable": False, + } + mock_url = "http://localhost:8200/v1/{mount_point}/static-role".format( + mount_point=mount_point, + ) + requests_mocker.register_uri( + method="LIST", + url=mock_url, + status_code=expected_status_code, + json=mock_response, + ) + ldap = Ldap(adapter=JSONAdapter()) + response = ldap.list_static_roles( + mount_point=mount_point, + ) + self.assertEqual( + first=mock_response, + second=response, + )