diff --git a/docs/ref/modules/index.rst b/docs/ref/modules/index.rst index 29603e72..2f6ace70 100644 --- a/docs/ref/modules/index.rst +++ b/docs/ref/modules/index.rst @@ -10,3 +10,4 @@ _________________ :toctree: vault + vault_db diff --git a/docs/ref/modules/saltext.vault.modules.vault_db.rst b/docs/ref/modules/saltext.vault.modules.vault_db.rst new file mode 100644 index 00000000..5a5d8f1e --- /dev/null +++ b/docs/ref/modules/saltext.vault.modules.vault_db.rst @@ -0,0 +1,5 @@ +``vault_db`` +============ + +.. automodule:: saltext.vault.modules.vault_db + :members: diff --git a/docs/ref/states/index.rst b/docs/ref/states/index.rst index 6689ef03..eac45ce6 100644 --- a/docs/ref/states/index.rst +++ b/docs/ref/states/index.rst @@ -10,3 +10,4 @@ _____________ :toctree: vault + vault_db diff --git a/docs/ref/states/saltext.vault.states.vault_db.rst b/docs/ref/states/saltext.vault.states.vault_db.rst new file mode 100644 index 00000000..c2cdd1b5 --- /dev/null +++ b/docs/ref/states/saltext.vault.states.vault_db.rst @@ -0,0 +1,5 @@ +``vault_db`` +============ + +.. automodule:: saltext.vault.states.vault_db + :members: diff --git a/docs/ref/utils/index.rst b/docs/ref/utils/index.rst index a563f8c0..3dfbd9a3 100644 --- a/docs/ref/utils/index.rst +++ b/docs/ref/utils/index.rst @@ -14,6 +14,7 @@ _________ vault.auth vault.cache vault.client + vault.db vault.exceptions vault.factory vault.helpers diff --git a/docs/ref/utils/saltext.vault.utils.vault.db.rst b/docs/ref/utils/saltext.vault.utils.vault.db.rst new file mode 100644 index 00000000..40f018a7 --- /dev/null +++ b/docs/ref/utils/saltext.vault.utils.vault.db.rst @@ -0,0 +1,5 @@ +saltext.vault.utils.vault.db +============================ + +.. automodule:: saltext.vault.utils.vault.db + :members: diff --git a/src/saltext/vault/modules/vault_db.py b/src/saltext/vault/modules/vault_db.py new file mode 100644 index 00000000..415c2555 --- /dev/null +++ b/src/saltext/vault/modules/vault_db.py @@ -0,0 +1,815 @@ +""" +Manage the Vault database secret engine, request and cache +leased database credentials. + +.. important:: + This module requires the general :ref:`Vault setup `. +""" +import logging +from datetime import datetime +from datetime import timezone + +import saltext.vault.utils.vault as vault +import saltext.vault.utils.vault.db as vaultdb +from salt.exceptions import CommandExecutionError +from salt.exceptions import SaltInvocationError + +log = logging.getLogger(__name__) + + +def list_connections(mount="database"): + """ + List configured database connections. + + `API method docs `__. + + CLI Example: + + .. code-block:: bash + + salt '*' vault_db.list_connections + + mount + The mount path the DB backend is mounted to. Defaults to ``database``. + """ + endpoint = f"{mount}/config" + try: + return vault.query("LIST", endpoint, __opts__, __context__)["data"]["keys"] + except vault.VaultNotFoundError: + return [] + except vault.VaultException as err: + raise CommandExecutionError(f"{err.__class__}: {err}") from err + + +def fetch_connection(name, mount="database"): + """ + Read a configured database connection. Returns None if it does not exist. + + `API method docs `__. + + CLI Example: + + .. code-block:: bash + + salt '*' vault_db.fetch_connection mydb + + name + The name of the database connection. + + mount + The mount path the database backend is mounted to. Defaults to ``database``. + """ + endpoint = f"{mount}/config/{name}" + try: + return vault.query("GET", endpoint, __opts__, __context__)["data"] + except vault.VaultNotFoundError: + return None + except vault.VaultException as err: + raise CommandExecutionError(f"{err.__class__}: {err}") from err + + +def write_connection( + name, + plugin, + version="", + verify=True, + allowed_roles=None, + root_rotation_statements=None, + password_policy=None, + rotate=True, + mount="database", + **kwargs, +): + """ + Create/update a configured database connection. + + .. note:: + + This endpoint distinguishes between create and update ACL capabilities. + + .. note:: + + It is highly recommended to use a Vault-specific user rather than the admin user in the + database when configuring the plugin. This user will be used to create/update/delete users + within the database so it will need to have the appropriate permissions to do so. + If the plugin supports rotating the root credentials, it is highly recommended to perform + that action after configuring the plugin. This will change the password of the user + configured in this step. The new password will not be viewable by users. + + `API method docs `__. + + CLI Example: + + .. code-block:: bash + + salt '*' vault_db.write_connection mydb elasticsearch \ + url=http://127.0.0.1:9200 username=vault password=hunter2 + + name + The name of the database connection. + + plugin + The name of the database plugin. Known plugins to this module are: + ``cassandra``, ``couchbase``, ``elasticsearch``, ``influxdb``, ``hanadb``, ``mongodb``, + ``mongodb_atlas``, ``mssql``, ``mysql``, ``oracle``, ``postgresql``, ``redis``, + ``redis_elasticache``, ``redshift``, ``snowflake``. + If you pass an unknown plugin, make sure its Vault-internal name can be formatted + as ``{plugin}-database-plugin`` and to pass all required parameters as kwargs. + + version + Specifies the semantic version of the plugin to use for this connection. + + verify + Verify the connection during initial configuration. Defaults to True. + + allowed_roles + List of the roles allowed to use this connection. ``["*"]`` means any role + can use this connection. Defaults to empty (no role can use it). + + root_rotation_statements + Specifies the database statements to be executed to rotate the root user's credentials. + See the plugin's API page for more information on support and formatting for this parameter. + + password_policy + The name of the password policy to use when generating passwords for this database. + If not specified, this will use a default policy defined as: + 20 characters with at least 1 uppercase, 1 lowercase, 1 number, and 1 dash character. + + rotate + Rotate the root credentials after plugin setup. Defaults to True. + + mount + The mount path the database backend is mounted to. Defaults to ``database``. + + kwargs + Different plugins require different parameters. You need to make sure that you pass them + as supplemental keyword arguments. For known plugins, the required arguments will + be checked. + """ + endpoint = f"{mount}/config/{name}" + plugin_meta = vaultdb.get_plugin_meta(plugin) + plugin_name = plugin_meta["name"] or plugin + payload = {k: v for k, v in kwargs.items() if not k.startswith("_")} + + if fetch_connection(name, mount=mount) is None: + missing_kwargs = set(plugin_meta["required"]) - set(payload) + if missing_kwargs: + raise SaltInvocationError( + f"The plugin {plugin} requires the following additional kwargs: {missing_kwargs}." + ) + + payload["plugin_name"] = f"{plugin_name}-database-plugin" + payload["verify_connection"] = verify + if version is not None: + payload["plugin_version"] = version + if allowed_roles is not None: + payload["allowed_roles"] = allowed_roles + if root_rotation_statements is not None: + payload["root_rotation_statements"] = root_rotation_statements + if password_policy is not None: + payload["password_policy"] = password_policy + + try: + vault.query("POST", endpoint, __opts__, __context__, payload=payload) + except vault.VaultException as err: + raise CommandExecutionError(f"{err.__class__}: {err}") from err + + if not rotate: + return True + return rotate_root(name, mount=mount) + + +def delete_connection(name, mount="database"): + """ + Delete a configured database connection. Returns None if it does not exist. + + `API method docs `__. + + CLI Example: + + .. code-block:: bash + + salt '*' vault_db.delete_connection mydb + + name + The name of the database connection. + + mount + The mount path the database backend is mounted to. Defaults to ``database``. + """ + endpoint = f"{mount}/config/{name}" + try: + return vault.query("DELETE", endpoint, __opts__, __context__) + except vault.VaultException as err: + raise CommandExecutionError(f"{err.__class__}: {err}") from err + + +def reset_connection(name, mount="database"): + """ + Close a connection and restart its plugin with the configuration stored in the barrier. + + `API method docs `__. + + CLI Example: + + .. code-block:: bash + + salt '*' vault_db.reset_connection mydb + + name + The name of the database connection. + + mount + The mount path the database backend is mounted to. Defaults to ``database``. + """ + endpoint = f"{mount}/reset/{name}" + try: + return vault.query("POST", endpoint, __opts__, __context__) + except vault.VaultException as err: + raise CommandExecutionError(f"{err.__class__}: {err}") from err + + +def rotate_root(name, mount="database"): + """ + Rotate the "root" user credentials stored for the database connection. + + .. warning:: + + The rotated password will not be accessible, so it is highly recommended to create + a dedicated user account as Vault's configured "root". + + `API method docs `__. + + CLI Example: + + .. code-block:: bash + + salt '*' vault_db.rotate_root mydb + + name + The name of the database connection. + + mount + The mount path the database backend is mounted to. Defaults to ``database``. + """ + endpoint = f"{mount}/rotate-root/{name}" + try: + return vault.query("POST", endpoint, __opts__, __context__) + except vault.VaultException as err: + raise CommandExecutionError(f"{err.__class__}: {err}") from err + + +def list_roles(static=False, mount="database"): + """ + List configured database roles. + + `API method docs `__. + `API method docs static `__. + + CLI Example: + + .. code-block:: bash + + salt '*' vault_db.list_roles + + static + Whether to list static roles. Defaults to False. + + mount + The mount path the DB backend is mounted to. Defaults to ``database``. + """ + endpoint = f"{mount}/{'static-' if static else ''}roles" + try: + return vault.query("LIST", endpoint, __opts__, __context__)["data"]["keys"] + except vault.VaultNotFoundError: + return [] + except vault.VaultException as err: + raise CommandExecutionError(f"{err.__class__}: {err}") from err + + +def fetch_role(name, static=False, mount="database"): + """ + Read a configured database role. Returns None if it does not exist. + + `API method docs `__. + `API method docs static `__. + + CLI Example: + + .. code-block:: bash + + salt '*' vault_db.fetch_role myrole + + name + The name of the database role. + + static + Whether this role is static. Defaults to False. + + mount + The mount path the database backend is mounted to. Defaults to ``database``. + """ + endpoint = f"{mount}/{'static-' if static else ''}roles/{name}" + try: + return vault.query("GET", endpoint, __opts__, __context__)["data"] + except vault.VaultNotFoundError: + return None + except vault.VaultException as err: + raise CommandExecutionError(f"{err.__class__}: {err}") from err + + +def write_static_role( + name, + connection, + username, + rotation_period, + rotation_statements=None, + credential_type=None, + credential_config=None, + mount="database", +): + """ + Create/update a database Static Role. Mind that not all databases support Static Roles. + + `API method docs `__. + + CLI Example: + + .. code-block:: bash + + salt '*' vault_db.write_static_role myrole mydb myuser 24h + + name + The name of the database role. + + connection + The name of the database connection this role applies to. + + username + The username to manage. + + rotation_period + Specifies the amount of time Vault should wait before rotating the password. + The minimum is ``5s``. + + rotation_statements + Specifies the database statements to be executed to rotate the password for the + configured database user. Not every plugin type will support this functionality. + + credential_type + Specifies the type of credential that will be generated for the role. + Options include: ``password``, ``rsa_private_key``. Defaults to ``password``. + See the plugin's API page for credential types supported by individual databases. + + credential_config + Specifies the configuration for the given ``credential_type`` as a mapping. + For ``password``, only ``password_policy`` can be passed. + For ``rsa_private_key``, ``key_bits`` (defaults to 2048) and ``format`` + (defaults to ``pkcs8``) are available. + + mount + The mount path the database backend is mounted to. Defaults to ``database``. + """ + payload = { + "username": username, + "rotation_period": rotation_period, + } + if rotation_statements is not None: + payload["rotation_statements"] = rotation_statements + return _write_role( + name, + connection, + payload, + credential_type=credential_type, + credential_config=credential_config, + static=True, + mount=mount, + ) + + +def write_role( + name, + connection, + creation_statements, + default_ttl=None, + max_ttl=None, + revocation_statements=None, + rollback_statements=None, + renew_statements=None, + credential_type=None, + credential_config=None, + mount="database", +): + r""" + Create/update a regular database role. + + `API method docs `__. + + CLI Example: + + .. code-block:: bash + + salt '*' vault_db.write_role myrole mydb \ + \["CREATE USER '{{name}}'@'%' IDENTIFIED BY '{{password}}'", "GRANT SELECT ON *.* TO '{{name}}'@'%'"\] + + name + The name of the database role. + + connection + The name of the database connection this role applies to. + + creation_statements + Specifies a list of database statements executed to create and configure a user, + usually templated with {{name}} and {{password}}. Required. + + default_ttl + Specifies the TTL for the leases associated with this role. Accepts time suffixed + strings (1h) or an integer number of seconds. Defaults to system/engine default TTL time. + + max_ttl + Specifies the maximum TTL for the leases associated with this role. Accepts time suffixed + strings (1h) or an integer number of seconds. Defaults to sys/mounts's default TTL time; + this value is allowed to be less than the mount max TTL (or, if not set, + the system max TTL), but it is not allowed to be longer. + + revocation_statements + Specifies a list of database statements to be executed to revoke a user. + + rollback_statements + Specifies a list of database statements to be executed to rollback a create operation + in the event of an error. Availability and formatting depend on the specific plugin. + + renew_statements + Specifies a list of database statements to be executed to renew a user. + Availability and formatting depend on the specific plugin. + + credential_type + Specifies the type of credential that will be generated for the role. + Options include: ``password``, ``rsa_private_key``. Defaults to ``password``. + See the plugin's API page for credential types supported by individual databases. + + credential_config + Specifies the configuration for the given ``credential_type`` as a mapping. + For ``password``, only ``password_policy`` can be passed. + For ``rsa_private_key``, ``key_bits`` (defaults to 2048) and ``format`` + (defaults to ``pkcs8``) are available. + + mount + The mount path the database backend is mounted to. Defaults to ``database``. + """ + payload = { + "creation_statements": creation_statements, + } + if default_ttl is not None: + payload["default_ttl"] = default_ttl + if max_ttl is not None: + payload["max_ttl"] = max_ttl + if revocation_statements is not None: + payload["revocation_statements"] = revocation_statements + if rollback_statements is not None: + payload["rollback_statements"] = rollback_statements + if renew_statements is not None: + payload["renew_statements"] = renew_statements + return _write_role( + name, + connection, + payload, + credential_type=credential_type, + credential_config=credential_config, + static=False, + mount=mount, + ) + + +def _write_role( + name, + connection, + payload, + credential_type=None, + credential_config=None, + static=False, + mount="database", +): + endpoint = f"{mount}/{'static-' if static else ''}roles/{name}" + payload["db_name"] = connection + if credential_type is not None: + payload["credential_type"] = credential_type + if credential_config is not None: + valid_cred_configs = { + "password": ["password_policy"], + "rsa_private_key": ["key_bits", "format"], + } + credential_type = credential_type or "password" + if credential_type in valid_cred_configs: + invalid_configs = set(credential_config) - set(valid_cred_configs[credential_type]) + if invalid_configs: + raise SaltInvocationError( + f"The following options are invalid for credential type {credential_type}: {invalid_configs}" + ) + payload["credential_config"] = credential_config + try: + return vault.query("POST", endpoint, __opts__, __context__, payload=payload) + except vault.VaultException as err: + raise CommandExecutionError(f"{err.__class__}: {err}") from err + + +def delete_role(name, static=False, mount="database"): + """ + Delete a configured database role. + + `API method docs `__. + `API method docs static `__. + + CLI Example: + + .. code-block:: bash + + salt '*' vault_db.delete_role myrole + + name + The name of the database role. + + static + Whether this role is static. Defaults to False. + + mount + The mount path the database backend is mounted to. Defaults to ``database``. + """ + endpoint = f"{mount}/{'static-' if static else ''}roles/{name}" + try: + return vault.query("DELETE", endpoint, __opts__, __context__) + except vault.VaultException as err: + raise CommandExecutionError(f"{err.__class__}: {err}") from err + + +def get_creds( + name, + static=False, + cache=True, + valid_for=None, + check_server=False, + renew_increment=None, + revoke_delay=None, + meta=None, + mount="database", + _warn_about_attr_change=True, +): + """ + Read credentials based on the named role. + + `API method docs `__. + `API method docs static `__. + + CLI Example: + + .. code-block:: bash + + salt '*' vault_db.get_creds myrole + + name + The name of the database role. + + static + Whether this role is static. Defaults to False. + + cache + Whether to use cached credentials local to this minion to avoid + unnecessary reissuance. + When ``static`` is false, set this to a string to be able to use multiple + distinct credentials using the same role on the same minion. + Set this to false to disable caching. + Defaults to true. + + .. note:: + + This uses the same cache backend as the Vault integration, so make + sure you configure a persistent backend like ``disk`` if you expect + the credentials to survive a single run. + + + valid_for + When using cache, ensure the credentials are valid for at least this + amount of time, otherwise request new ones. + This can be an integer, which will be interpreted as seconds, or a time string + using the same format as Vault does: + Suffix ``s`` for seconds, ``m`` for minuts, ``h`` for hours, ``d`` for days. + This will be cached together with the lease and might be used by other + modules later. + + check_server + Check on the Vault server whether the lease is still active and was not + revoked early. Defaults to false. + + renew_increment + When using cache and ``valid_for`` results in a renewal attempt, request this + amount of time extension on the lease. This will be cached together with the + lease and might be used by other modules later. + + revoke_delay + When using cache and ``valid_for`` results in a revocation, set the lease + validity to this value to allow a short amount of delay between the issuance + of the new lease and the revocation of the old one. Defaults to ``60``. + This will be cached together with the lease and might be used by other + modules later. + + meta + When using cache, this value will be cached together with the lease. It will + be emitted by the ``vault_lease`` beacon module whenever a lease is + running out (usually because it cannot be extended further). It is intended + to support the reactor in deciding what needs to be done in order + to to reconfigure dependent, Vault-unaware software with newly issued + credentials. Entirely optional. + + mount + The mount path the database backend is mounted to. Defaults to ``database``. + """ + endpoint = f"{mount}/{'static-' if static else ''}creds/{name}" + + if cache: + ckey = f"db.{mount}.{'static' if static else 'dynamic'}.{name}" + if not static and isinstance(cache, str): + ckey += f".{cache}" + else: + ckey += ".default" + creds_cache = vault.get_lease_store(__opts__, __context__) + cached_creds = creds_cache.get( + ckey, valid_for=valid_for, revoke=revoke_delay, check_server=check_server + ) + if cached_creds: + changed = False + for attr, val in ( + ("min_ttl", valid_for), + ("renew_increment", renew_increment), + ("revoke_delay", revoke_delay), + ("meta", meta), + ): + if val is not None and getattr(cached_creds, attr) != val: + setattr(cached_creds, attr, val) + changed = True + if changed: + # Warn about changes if a lease is managed by the state module + # and this function is called e.g. during YAML rendering, overwriting + # the desired attributes. The state module sets this to false. + if _warn_about_attr_change: + log.warning(f"Cached credential `{ckey}` changed lifecycle attributes") + creds_cache.store(ckey, cached_creds) + return cached_creds.data + + try: + res = vault.query("GET", endpoint, __opts__, __context__) + except vault.VaultException as err: + raise CommandExecutionError(f"{err.__class__}: {err}") from err + + lease = vault.VaultLease( + min_ttl=valid_for, + renew_increment=renew_increment, + revoke_delay=revoke_delay, + meta=meta, + **res, + ) + if cache: + creds_cache.store(ckey, lease) + return lease.data + + +def clear_cached(name=None, mount=None, cache=None, static=None, delta=None, flush_on_failure=True): + """ + Clear and revoke cached database credentials matching specified parameters. + + CLI Example: + + .. code-block:: bash + + salt '*' vault_db.clear_cached name=myrole mount=database + salt '*' vault_db.clear_cached mount=database + salt '*' vault_db.clear_cached + + name + Only clear credentials using this role name. + + mount + Only clear credentials from this mount. + + cache + Only clear credentials using this cache name (refer to get_creds for details). + + static + Only clear static (``True``) or dynamic (``False``) credentials. + + delta + Time after which the leases should be revoked by Vault. + Defaults to what was set on the lease(s) during creation or 60s. + + flush_on_failure + If a revocation fails, remove the lease from cache anyways. + Defaults to true. + """ + creds_cache = vault.get_lease_store(__opts__, __context__) + return creds_cache.revoke_cached( + match=vaultdb.create_cache_pattern(name=name, mount=mount, cache=cache, static=static), + delta=delta, + flush_on_failure=flush_on_failure, + ) + + +def list_cached(name=None, mount=None, cache=None, static=None): + """ + List cached database credentials matching specified parameters. + + CLI Example: + + .. code-block:: bash + + salt '*' vault_db.list_cached name=myrole mount=database + salt '*' vault_db.list_cached mount=database + salt '*' vault_db.list_cached + + name + Only list credentials using this role name. + + mount + Only list credentials from this mount. + + cache + Only list credentials using this cache name (refer to get_creds for details). + + static + Only list static (``True``) or dynamic (``False``) credentials. + """ + creds_cache = vault.get_lease_store(__opts__, __context__) + info = creds_cache.list_info( + match=vaultdb.create_cache_pattern(name=name, mount=mount, cache=cache, static=static) + ) + for lease in info.values(): + for val in ("creation_time", "expire_time"): + if val in lease: + lease[val] = ( + datetime.fromtimestamp(lease[val], tz=timezone.utc) + .astimezone() + .strftime("%Y-%m-%d %H:%M:%S %Z") + ) + return info + + +def renew_cached(name=None, mount=None, cache=None, static=None, increment=None): + """ + Renew cached database credentials matching specified parameters. + + CLI Example: + + .. code-block:: bash + + salt '*' vault_db.renew_cached name=myrole mount=database + salt '*' vault_db.renew_cached mount=database + salt '*' vault_db.renew_cached + + name + Only renew credentials using this role name. + + mount + Only renew credentials from this mount. + + cache + Only renew credentials using this cache name (refer to get_creds for details). + + static + Only renew static (``True``) or dynamic (``False``) credentials. + + increment + Request the leases to be valid for this amount of time from the current + point of time onwards. Can also be used to reduce the validity period. + The server might not honor this increment. + Can be an integer (seconds) or a time string like ``1h``. Optional. + If unset, defaults to what was set on the lease during creation or + the lease's default TTL. + """ + creds_cache = vault.get_lease_store(__opts__, __context__) + return creds_cache.renew_cached( + match=vaultdb.create_cache_pattern(name=name, mount=mount, cache=cache, static=static), + increment=increment, + ) + + +def rotate_static_role(name, mount="database"): + """ + Rotate Static Role credentials stored for a given role name. + + `API method docs static `__. + + CLI Example: + + .. code-block:: bash + + salt '*' vault_db.rotate_static_role mystaticrole + + name + The name of the database role. + + mount + The mount path the database backend is mounted to. Defaults to ``database``. + """ + endpoint = f"{mount}/rotate-role/{name}" + try: + return vault.query("POST", endpoint, __opts__, __context__) + except vault.VaultException as err: + raise CommandExecutionError(f"{err.__class__}: {err}") from err diff --git a/src/saltext/vault/states/vault_db.py b/src/saltext/vault/states/vault_db.py new file mode 100644 index 00000000..d417241f --- /dev/null +++ b/src/saltext/vault/states/vault_db.py @@ -0,0 +1,802 @@ +""" +Manage the Vault database secret engine, request and cache +leased database credentials. + +.. important:: + This module requires the general :ref:`Vault setup `. +""" +import logging + +import saltext.vault.utils.vault.db as vaultdb +from salt.defaults import NOT_SET +from salt.exceptions import CommandExecutionError +from salt.exceptions import SaltInvocationError +from saltext.vault.utils.vault.helpers import timestring_map + +log = logging.getLogger(__name__) + + +def connection_present( + name, + plugin, + version=None, + verify=True, + allowed_roles=None, + root_rotation_statements=None, + password_policy=None, + rotate=True, + force=False, + mount="database", + **kwargs, +): + """ + Ensure a database connection is present as specified. + + name + The name of the database connection. + + plugin + The name of the database plugin. Known plugins to this module are: + ``cassandra``, ``couchbase``, ``elasticsearch``, ``influxdb``, ``hanadb``, ``mongodb``, + ``mongodb_atlas``, ``mssql``, ``mysql``, ``oracle``, ``postgresql``, ``redis``, + ``redis_elasticache``, ``redshift``, ``snowflake``. + If you pass an unknown plugin, make sure its Vault-internal name can be formatted + as ``{plugin}-database-plugin`` and to pass all required parameters as kwargs. + + version + Specifies the semantic version of the plugin to use for this connection. + + verify + Verify the connection during initial configuration. Defaults to True. + + allowed_roles + List of the roles allowed to use this connection. ``["*"]`` means any role + can use this connection. Defaults to empty (no role can use it). + + root_rotation_statements + Specifies the database statements to be executed to rotate the root user's credentials. + See the plugin's API page for more information on support and formatting for this parameter. + + password_policy + The name of the password policy to use when generating passwords for this database. + If not specified, this will use a default policy defined as: + 20 characters with at least 1 uppercase, 1 lowercase, 1 number, and 1 dash character. + + rotate + Rotate the root credentials after plugin setup. Defaults to True. + + force + When the plugin changes, this state fails to protect from accidental errors. + Set force to True to delete existing connections with the same name and a + different plugin type. Defaults to False. + + mount + The mount path the database backend is mounted to. Defaults to ``database``. + + kwargs + Different plugins require different parameters. You need to make sure that you pass them + as supplemental keyword arguments. For known plugins, the required arguments will + be checked. + """ + ret = { + "name": name, + "result": True, + "comment": "The connection is present as specified", + "changes": {}, + } + kwargs = {k: v for k, v in kwargs.items() if not k.startswith("_")} + + def _diff_params(current): + nonlocal version, allowed_roles, root_rotation_statements, password_policy, kwargs + diff_params = ( + ("plugin_version", version), + ("allowed_roles", allowed_roles), + ("root_credentials_rotate_statements", root_rotation_statements), + ("password_policy", password_policy), + # verify_connection is not reported + ) + changed = {} + for param, arg in diff_params: + if arg is None: + continue + # Strip statements to avoid tripping over final newlines + if param.endswith("statements"): + arg = [x.rstrip() for x in arg] + if param in current: + current[param] = [x.rstrip() for x in current[param]] + if param not in current or current[param] != arg: + changed.update({param: {"old": current.get(param), "new": arg}}) + for param, val in kwargs.items(): + if param == "password": + # password is not reported + continue + if ( + param not in current["connection_details"] + or current["connection_details"][param] != val + ): + changed.update( + {param: {"old": current["connection_details"].get(param), "new": val}} + ) + return changed + + try: + current = __salt__["vault_db.fetch_connection"](name, mount=mount) + changes = {} + + if current: + if current["plugin_name"] != vaultdb.get_plugin_name(plugin): + if not force: + raise CommandExecutionError( + "Cannot change plugin type without deleting the existing connection. " + "Set force: true to override." + ) + if not __opts__["test"]: + __salt__["vault_db.delete_connection"](name, mount=mount) + ret["changes"]["deleted_for_plugin_change"] = name + current = None + else: + changes = _diff_params(current) + if not changes: + return ret + + if __opts__["test"]: + ret["result"] = None + ret[ + "comment" + ] = f"Connection `{name}` would have been {'updated' if current else 'created'}" + ret["changes"].update(changes) + if not current: + ret["changes"]["created"] = name + return ret + + if current and "password" in kwargs: + kwargs.pop("password") + + __salt__["vault_db.write_connection"]( + name, + plugin, + version=version, + verify=verify, + allowed_roles=allowed_roles, + root_rotation_statements=root_rotation_statements, + password_policy=password_policy, + rotate=rotate, + mount=mount, + **kwargs, + ) + new = __salt__["vault_db.fetch_connection"](name, mount=mount) + + if new is None: + raise CommandExecutionError( + "There were no errors during role management, but it is still reported as absent." + ) + if not current: + ret["changes"]["created"] = name + + new_diff = _diff_params(new) + if new_diff: + ret["result"] = False + ret["comment"] = ( + "There were no errors during connection management, but " + f"the reported parameters do not match: {new_diff}" + ) + return ret + ret["changes"].update(changes) + ret["comment"] = f"Connection `{name}` has been {'updated' if current else 'created'}" + + except CommandExecutionError as err: + ret["result"] = False + ret["comment"] = str(err) + # do not reset changes + + return ret + + +def connection_absent(name, mount="database"): + """ + Ensure a database connection is absent. + + name + The name of the connection. + + mount + The mount path the database backend is mounted to. Defaults to ``database``. + """ + ret = {"name": name, "result": True, "comment": "", "changes": {}} + + try: + current = __salt__["vault_db.fetch_connection"](name, mount=mount) + + if current is None: + ret["comment"] = f"Connection `{name}` is already absent." + return ret + + ret["changes"]["deleted"] = name + + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f"Connection `{name}` would have been deleted." + return ret + + __salt__["vault_db.delete_connection"](name, mount=mount) + + if __salt__["vault_db.fetch_connection"](name, mount=mount) is not None: + raise CommandExecutionError( + "There were no errors during connection deletion, " + "but it is still reported as present." + ) + ret["comment"] = f"Connection `{name}` has been deleted." + + except CommandExecutionError as err: + ret["result"] = False + ret["comment"] = str(err) + ret["changes"] = {} + + return ret + + +def role_present( + name, + connection, + creation_statements, + default_ttl=None, + max_ttl=None, + revocation_statements=None, + rollback_statements=None, + renew_statements=None, + credential_type=None, + credential_config=None, + mount="database", +): + """ + Ensure a regular database role is present as specified. + + name + The name of the database role. + + connection + The name of the database connection this role applies to. + + creation_statements + Specifies a list of database statements executed to create and configure a user, + usually templated with {{name}} and {{password}}. Required. + + default_ttl + Specifies the TTL for the leases associated with this role. Accepts time suffixed + strings (1h) or an integer number of seconds. Defaults to system/engine default TTL time. + + max_ttl + Specifies the maximum TTL for the leases associated with this role. Accepts time suffixed + strings (1h) or an integer number of seconds. Defaults to sys/mounts's default TTL time; + this value is allowed to be less than the mount max TTL (or, if not set, + the system max TTL), but it is not allowed to be longer. + + revocation_statements + Specifies a list of database statements to be executed to revoke a user. + + rollback_statements + Specifies a list of database statements to be executed to rollback a create operation + in the event of an error. Availability and formatting depend on the specific plugin. + + renew_statements + Specifies a list of database statements to be executed to renew a user. + Availability and formatting depend on the specific plugin. + + credential_type + Specifies the type of credential that will be generated for the role. + Options include: ``password``, ``rsa_private_key``. Defaults to ``password``. + See the plugin's API page for credential types supported by individual databases. + + credential_config + Specifies the configuration for the given ``credential_type`` as a mapping. + For ``password``, only ``password_policy`` can be passed. + For ``rsa_private_key``, ``key_bits`` (defaults to 2048) and ``format`` + (defaults to ``pkcs8``) are available. + + mount + The mount path the database backend is mounted to. Defaults to ``database``. + """ + ret = {"name": name, "result": True, "comment": "", "changes": {}} + + if not isinstance(creation_statements, list): + creation_statements = [creation_statements] + if revocation_statements and not isinstance(revocation_statements, list): + revocation_statements = [revocation_statements] + if rollback_statements and not isinstance(rollback_statements, list): + rollback_statements = [rollback_statements] + if renew_statements and not isinstance(renew_statements, list): + renew_statements = [renew_statements] + + def _diff_params(current): + nonlocal connection, creation_statements, default_ttl, max_ttl, revocation_statements + nonlocal rollback_statements, renew_statements, credential_type, credential_config + + diff_params = ( + ("db_name", connection), + ("creation_statements", creation_statements), + ("default_ttl", timestring_map(default_ttl)), + ("max_ttl", timestring_map(max_ttl)), + ("revocation_statements", revocation_statements), + ("rollback_statements", rollback_statements), + ("renew_statements", renew_statements), + ("credential_type", credential_type), + ("credential_config", credential_config), + ) + changed = {} + for param, arg in diff_params: + if arg is None: + continue + # Strip statements to avoid tripping over final newlines + if param.endswith("statements"): + arg = [x.rstrip() for x in arg] + if param in current: + current[param] = [x.rstrip() for x in current[param]] + if param not in current or current[param] != arg: + changed.update({param: {"old": current.get(param), "new": arg}}) + return changed + + try: + current = __salt__["vault_db.fetch_role"](name, static=False, mount=mount) + + if current: + changed = _diff_params(current) + if not changed: + ret["comment"] = "Role is present as specified" + return ret + ret["changes"].update(changed) + + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f"Role `{name}` would have been {'updated' if current else 'created'}" + if not current: + ret["changes"]["created"] = name + return ret + + __salt__["vault_db.write_role"]( + name, + connection, + creation_statements, + default_ttl=default_ttl, + max_ttl=max_ttl, + revocation_statements=revocation_statements, + rollback_statements=rollback_statements, + renew_statements=renew_statements, + credential_type=credential_type, + credential_config=credential_config, + mount=mount, + ) + new = __salt__["vault_db.fetch_role"](name, static=False, mount=mount) + + if new is None: + raise CommandExecutionError( + "There were no errors during role management, but it is still reported as absent." + ) + + if not current: + ret["changes"]["created"] = name + + new_diff = _diff_params(new) + if new_diff: + ret["result"] = False + ret["comment"] = ( + "There were no errors during role management, but " + f"the reported parameters do not match: {new_diff}" + ) + return ret + + ret["comment"] = f"Role `{name}` has been {'updated' if current else 'created'}" + except (CommandExecutionError, SaltInvocationError) as err: + ret["result"] = False + ret["comment"] = str(err) + ret["changes"] = {} + + return ret + + +def role_absent(name, static=False, mount="database"): + """ + Ensure a database role is absent. + + name + The name of the role. + + static + Whether this role is static. Defaults to False. + + mount + The mount path the database backend is mounted to. Defaults to ``database``. + """ + ret = {"name": name, "result": True, "comment": "", "changes": {}} + + try: + current = __salt__["vault_db.fetch_role"](name, static=static, mount=mount) + + if current is None: + ret["comment"] = f"Role `{name}` is already absent." + return ret + + ret["changes"]["deleted"] = name + + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f"Role `{name}` would have been deleted." + return ret + + __salt__["vault_db.delete_role"](name, static=static, mount=mount) + + if __salt__["vault_db.fetch_role"](name, static=static, mount=mount) is not None: + raise CommandExecutionError( + "There were no errors during role deletion, but it is still reported as present." + ) + ret["comment"] = f"Role `{name}` has been deleted." + + except CommandExecutionError as err: + ret["result"] = False + ret["comment"] = str(err) + ret["changes"] = {} + + return ret + + +def static_role_present( + name, + connection, + username, + rotation_period, + rotation_statements=None, + credential_type=None, + credential_config=None, + mount="database", +): + """ + Ensure a database Static Role is present as specified. + + name + The name of the database role. + + connection + The name of the database connection this role applies to. + + username + The username to manage. + + rotation_period + Specifies the amount of time Vault should wait before rotating the password. + The minimum is ``5s``. + + rotation_statements + Specifies the database statements to be executed to rotate the password for the + configured database user. Not every plugin type will support this functionality. + + credential_type + Specifies the type of credential that will be generated for the role. + Options include: ``password``, ``rsa_private_key``. Defaults to ``password``. + See the plugin's API page for credential types supported by individual databases. + + credential_config + Specifies the configuration for the given ``credential_type`` as a mapping. + For ``password``, only ``password_policy`` can be passed. + For ``rsa_private_key``, ``key_bits`` (defaults to 2048) and ``format`` + (defaults to ``pkcs8``) are available. + + mount + The mount path the database backend is mounted to. Defaults to ``database``. + """ + ret = {"name": name, "result": True, "comment": "", "changes": {}} + + if rotation_statements and not isinstance(rotation_statements, list): + rotation_statements = [rotation_statements] + + def _diff_params(current): + nonlocal connection, username, rotation_period, rotation_statements, credential_type, credential_config + diff_params = ( + ("db_name", connection), + ("username", username), + ("rotation_period", timestring_map(rotation_period)), + ("rotation_statements", rotation_statements), + ("credential_type", credential_type), + ("credential_config", credential_config), + ) + changed = {} + for param, arg in diff_params: + if arg is None: + continue + # Strip statements to avoid tripping over final newlines + if param.endswith("statements"): + arg = [x.rstrip() for x in arg] + if param in current: + current[param] = [x.rstrip() for x in current[param]] + if param not in current or current[param] != arg: + changed.update({param: {"old": current.get(param), "new": arg}}) + return changed + + try: + current = __salt__["vault_db.fetch_role"](name, static=True, mount=mount) + + if current: + changed = _diff_params(current) + if not changed: + ret["comment"] = "Role is present as specified" + return ret + ret["changes"].update(changed) + + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f"Role `{name}` would have been {'updated' if current else 'created'}" + if not current: + ret["changes"]["created"] = name + return ret + + __salt__["vault_db.write_static_role"]( + name, + connection, + username, + rotation_period, + rotation_statements=None, + credential_type=credential_type, + credential_config=credential_config, + mount=mount, + ) + new = __salt__["vault_db.fetch_role"](name, static=True, mount=mount) + + if new is None: + raise CommandExecutionError( + "There were no errors during role management, but it is still reported as absent." + ) + + if not current: + ret["changes"]["created"] = name + + new_diff = _diff_params(new) + if new_diff: + ret["result"] = False + ret["comment"] = ( + "There were no errors during role management, but " + f"the reported parameters do not match: {new_diff}" + ) + return ret + + ret["comment"] = f"Role `{name}` has been {'updated' if current else 'created'}" + + except (CommandExecutionError, SaltInvocationError) as err: + ret["result"] = False + ret["comment"] = str(err) + ret["changes"] = {} + + return ret + + +def creds_cached( + name, + static=False, + cache=None, + valid_for=NOT_SET, + renew_increment=None, + revoke_delay=None, + meta=None, + mount="database", + **kwargs, # pylint: disable=unused-argument +): + """ + Ensure valid credentials are present in the minion's cache based on the named role. + Supports ``mod_beacon``. + + .. note:: + + This function is mosly intended to associate a specific credential with + a beacon that warns about expiry and allows to run an associated state to + reconfigure an application with new credentials. + + name + The name of the database role. + + static + Whether this role is static. Defaults to False. + + cache + A variable cache suffix to be able to use multiple distinct credentials + using the same role on the same minion. + Ignored when ``static`` is true. + + .. note:: + + This uses the same cache backend as the Vault integration, so make + sure you configure a persistent backend like ``disk`` if you expect + the credentials to survive a single run. + + valid_for + Ensure the credentials are valid for at least this amount of time, + otherwise request new ones. + This can be an integer, which will be interpreted as seconds, or a time string + using the same format as Vault does: + Suffix ``s`` for seconds, ``m`` for minuts, ``h`` for hours, ``d`` for days. + Defaults to ``0``. + + renew_increment + When using cache and ``valid_for`` results in a renewal attempt, request this + amount of time extension on the lease. This will be cached together with the + lease and might be used by other modules later. + + revoke_delay + When using cache and ``valid_for`` results in a revocation, set the lease + validity to this value to allow a short amount of delay between the issuance + of the new lease and the revocation of the old one. Defaults to ``60``. + This will be cached together with the lease and might be used by other + modules later. + + meta + When using cache, this value will be cached together with the lease. It will + be emitted by the ``vault_lease`` beacon module whenever a lease is + running out (usually because it cannot be extended further). It is intended + to support the reactor in deciding what needs to be done in order + to to reconfigure dependent, Vault-unaware software with newly issued + credentials. Entirely optional. + + mount + The mount path the database backend is mounted to. Defaults to ``database``. + """ + ret = { + "name": name, + "result": True, + "comment": "The credentials are already cached and valid", + "changes": {}, + } + + cached = __salt__["vault_db.list_cached"]( + name, static=static, cache=cache or True if not static else True, mount=mount + ) + pp = "issued" + if cached: + info = cached[next(iter(cached))] + if valid_for is NOT_SET: + if info["min_ttl"] is not None: + valid_for = info["min_ttl"] + else: + valid_for = None + for attr, val in ( + ("min_ttl", valid_for), + ("renew_increment", renew_increment), + ("revoke_delay", revoke_delay), + ("meta", meta), + ): + if val is not None and info.get(attr) != val: + # Meta-only changes should be reported as well because the + # execution module needs to be called later to update them. + # This is especially valid for a lowering of min_ttl, which + # might result in a reissuance if the current lease has already + # reached its min_ttl (the current logic would not recognize that + # situation otherwise). + ret["changes"][attr] = {"old": info.get(attr), "new": val} + pp = "edited" + + current_effective_valid_for = valid_for or 0 + if info["min_ttl"] is not None: + current_effective_valid_for = max(info["min_ttl"], valid_for or 0) + if info["expires_in"] <= timestring_map(current_effective_valid_for): + ret["changes"]["expiry"] = True + pp = "renewed" + if not ret["changes"]: + return ret + else: + ret["changes"]["new"] = True + if __opts__["test"]: + ret["result"] = None + if pp == "renewed": + pp = "renewed/reissued" + ret["comment"] = f"The credentials would have been {pp}" + return ret + __salt__["vault_db.get_creds"]( + name, + static=static, + cache=cache or True, + valid_for=valid_for, + renew_increment=renew_increment, + revoke_delay=revoke_delay, + meta=meta, + mount=mount, + _warn_about_attr_change=False, + ) + new_cached = __salt__["vault_db.list_cached"](name, static=static, cache=cache, mount=mount) + if not new_cached: + raise CommandExecutionError( + "Could not find cached credentials after issuing, this is likely a bug" + ) + # Ensure the reporting is correct. + if cached and new_cached[next(iter(cached))]["lease_id"] != info["lease_id"]: + pp = "reissued" + ret["changes"][pp] = True + + ret["comment"] = f"The credentials have been {pp}" + return ret + + +def creds_uncached( + name, static=False, cache=None, mount="database", **kwargs +): # pylint: disable=unused-argument + """ + Ensure credentials are absent in the minion's cache based on the named role. + Supports ``mod_beacon``. + + .. note:: + + This function is mosly intended to associate a specific credential with + a beacon that warns about expiry and allows to run an associated state to + reconfigure an application with new credentials. + + name + The name of the database role. + + static + Whether this role is static. Defaults to False. + + cache + A variable cache suffix to be able to use multiple distinct credentials + using the same role on the same minion. + Ignored when ``static`` is true. + + mount + The mount path the database backend is mounted to. Defaults to ``database``. + """ + ret = { + "name": name, + "result": True, + "comment": "No matching credentials present", + "changes": {}, + } + + cached = __salt__["vault_db.list_cached"](name, static=static, cache=cache, mount=mount) + if not cached: + return ret + ret["changes"]["revoked"] = True + if __opts__["test"]: + ret["result"] = None + ret["comment"] = "The credentials would have been revoked" + return ret + __salt__["vault_db.clear_cached"](name, static=static, cache=cache or True, mount=mount) + ret["comment"] = "The credentials have been revoked" + return ret + + +def mod_beacon(name, sfun=None, static=False, cache=None, mount="database", **kwargs): + """ + Associates a Vault lease with a ``vault_lease`` beacon and + possibly a state. + + beacon_interval + The interval to run the beacon in. Defaults to 60. + + min_ttl + If this minimum TTL on the lease is undercut, the beacon will + fire an event. Defaults to 0. + """ + ret = {"name": name, "changes": {}, "result": True, "comment": ""} + supported_funcs = ["creds_cached", "creds_uncached"] + + if sfun not in supported_funcs: + ret["result"] = False + ret["comment"] = f"'vault_db.{sfun}' does not work with mod_beacon" + return ret + if not kwargs.get("beacon"): + ret["comment"] = "Not managing beacon" + return ret + lease = vaultdb.create_cache_pattern(name, mount=mount, static=static, cache=cache) + beacon_module = "vault_lease" + beacon_name = f"{beacon_module}_{lease}" + if sfun == "creds_uncached": + beacon_kwargs = { + "name": beacon_name, + "beacon_module": beacon_module, + } + bfun = "absent" + elif sfun == "creds_cached": + beacon_kwargs = { + "name": beacon_name, + "beacon_module": beacon_module, + "interval": kwargs.get("beacon_interval", 60), + "lease": lease, + "min_ttl": kwargs.get("min_ttl", 0), + "meta": kwargs.get("meta"), + "check_server": kwargs.get("check_server", False), + } + bfun = "present" + return __states__[f"beacon.{bfun}"](**beacon_kwargs) diff --git a/src/saltext/vault/utils/vault/db.py b/src/saltext/vault/utils/vault/db.py new file mode 100644 index 00000000..623f53f8 --- /dev/null +++ b/src/saltext/vault/utils/vault/db.py @@ -0,0 +1,158 @@ +from salt.utils.immutabletypes import freeze + + +PLUGINS = freeze( + { + "cassandra": { + "name": "cassandra", + "required": [ + "hosts", + "username", + "password", + ], + }, + "couchbase": { + "name": "couchbase", + "required": [ + "hosts", + "username", + "password", + ], + }, + "elasticsearch": { + "name": "elasticsearch", + "required": [ + "url", + "username", + "password", + ], + }, + "influxdb": { + "name": "influxdb", + "required": [ + "host", + "username", + "password", + ], + }, + "hanadb": { + "name": "hana", + "required": [ + "connection_url", + ], + }, + "mongodb": { + "name": "mongodb", + "required": [ + "connection_url", + ], + }, + "mongodb_atlas": { + "name": "mongodbatlas", + "required": [ + "public_key", + "private_key", + "project_id", + ], + }, + "mssql": { + "name": "mssql", + "required": [ + "connection_url", + ], + }, + "mysql": { + "name": "mysql", + "required": [ + "connection_url", + ], + }, + "oracle": { + "name": "oracle", + "required": [ + "connection_url", + ], + }, + "postgresql": { + "name": "postgresql", + "required": [ + "connection_url", + ], + }, + "redis": { + "name": "redis", + "required": [ + "host", + "port", + "username", + "password", + ], + }, + "redis_elasticache": { + "name": "redis-elasticache", + "required": [ + "url", + "username", + "password", + ], + }, + "redshift": { + "name": "redshift", + "required": [ + "connection_url", + ], + }, + "snowflake": { + "name": "snowflake", + "required": [ + "connection_url", + ], + }, + "default": { + "name": "", + "required": [], + }, + } +) + + +def get_plugin_meta(name): + """ + Get meta information for a plugin with this name, + excluding the `-database-plugin` suffix. + """ + return PLUGINS.get(name, PLUGINS["default"]) + + +def get_plugin_name(name): + """ + Get the name of a plugin as rendered by this module. This is a utility for the state + module primarily. + """ + plugin_name = PLUGINS.get(name, {"name": name})["name"] + return f"{plugin_name}-database-plugin" + + +def create_cache_pattern(name=None, mount=None, cache=None, static=None): + """ + Render a match pattern for operating on cached leases. + Unset parameters will result in a ``*`` glob. + + name + The name of the database role. + + static + Whether the role is static. + + cache + Filter by cache name (refer to get_creds for details). + + mount + The mount path the associated database backend is mounted to. + """ + ptrn = ["db"] + ptrn.append("*" if mount is None else mount) + ptrn.append("*" if static is None else "static" if static else "dynamic") + ptrn.append("*" if name is None else name) + ptrn.append("*" if cache is None else "default" if cache is True else cache) + return ".".join(ptrn) diff --git a/src/saltext/vault/utils/vault/leases.py b/src/saltext/vault/utils/vault/leases.py index 74f6e1f7..4385f556 100644 --- a/src/saltext/vault/utils/vault/leases.py +++ b/src/saltext/vault/utils/vault/leases.py @@ -569,6 +569,9 @@ def list_info(self, match="*"): ret = {} for ckey, lease in self._list_cached_leases(match=match, flush=False): info = lease.to_dict() + ttl_left = lease.ttl_left + info["expires_in"] = ttl_left + info["expired"] = ttl_left == 0 # do not leak auth data info.pop("data", None) ret[ckey] = info diff --git a/tests/functional/modules/test_vault_db.py b/tests/functional/modules/test_vault_db.py new file mode 100644 index 00000000..bb4b0e61 --- /dev/null +++ b/tests/functional/modules/test_vault_db.py @@ -0,0 +1,499 @@ +import logging +import time +from datetime import datetime + +import pytest +from saltfactories.utils import random_string + +from tests.support.mysql import create_mysql_combo # pylint: disable=unused-import +from tests.support.mysql import mysql_combo # pylint: disable=unused-import +from tests.support.mysql import mysql_container # pylint: disable=unused-import +from tests.support.mysql import MySQLImage +from tests.support.vault import vault_delete +from tests.support.vault import vault_disable_secret_engine +from tests.support.vault import vault_enable_secret_engine +from tests.support.vault import vault_list +from tests.support.vault import vault_revoke +from tests.support.vault import vault_write + +pytest.importorskip("docker") + +pytestmark = [ + pytest.mark.slow_test, + pytest.mark.skip_if_binaries_missing("vault", "getent"), + pytest.mark.usefixtures("vault_container_version"), +] + + +@pytest.fixture(scope="module") +def minion_config_overrides(vault_port): + return { + "vault": { + "auth": { + "method": "token", + "token": "testsecret", + }, + "cache": { + "backend": "disk", # ensure a persistent cache is available for get_creds + }, + "server": { + "url": f"http://127.0.0.1:{vault_port}", + }, + } + } + + +@pytest.fixture(scope="module") +def mysql_image(): + version = "10.3" + return MySQLImage( + name="mariadb", + tag=version, + container_id=random_string(f"mariadb-{version}-"), + ) + + +@pytest.fixture +def role_args_common(): + return { + "db_name": "testdb", + "creation_statements": r"CREATE USER '{{name}}'@'%' IDENTIFIED BY '{{password}}';GRANT SELECT ON *.* TO '{{name}}'@'%';", + } + + +@pytest.fixture +def testrole(): + return { + "default_ttl": 3600, + "max_ttl": 86400, + } + + +@pytest.fixture +def testreissuerole(): + return { + "default_ttl": 180, + "max_ttl": 180, + } + + +@pytest.fixture +def teststaticrole(mysql_container): + return { + "db_name": "testdb", + "rotation_period": 86400, + "username": mysql_container.mysql_user, + } + + +@pytest.fixture +def testdb(mysql_container): + return { + "plugin_name": "mysql-database-plugin", + "connection_url": f"{{{{username}}}}:{{{{password}}}}@tcp(host.docker.internal:{mysql_container.mysql_port})/", + "allowed_roles": "testrole,teststaticrole,testreissuerole", + "username": "root", + "password": mysql_container.mysql_passwd, + } + + +@pytest.fixture(scope="module", autouse=True) +def db_engine(vault_container_version): # pylint: disable=unused-argument + assert vault_enable_secret_engine("database") + yield + assert vault_disable_secret_engine("database") + + +@pytest.fixture +def connection_setup(testdb): + try: + vault_write("database/config/testdb", **testdb) + assert "testdb" in vault_list("database/config") + yield + finally: + # prevent dangling leases, which prevent disabling the secret engine + assert vault_revoke("database/creds", prefix=True) + if "testdb" in vault_list("database/config"): + vault_delete("database/config/testdb") + assert "testdb" not in vault_list("database/config") + + +@pytest.fixture(params=[["testrole"]]) +def roles_setup(connection_setup, request, role_args_common): # pylint: disable=unused-argument + try: + for role_name in request.param: + role_args = request.getfixturevalue(role_name) + role_args.update(role_args_common) + vault_write(f"database/roles/{role_name}", **role_args) + assert role_name in vault_list("database/roles") + yield + finally: + for role_name in request.param: + if role_name in vault_list("database/roles"): + vault_delete(f"database/roles/{role_name}") + assert role_name not in vault_list("database/roles") + + +@pytest.fixture +def role_static_setup(connection_setup, teststaticrole): # pylint: disable=unused-argument + role_name = "teststaticrole" + try: + vault_write(f"database/static-roles/{role_name}", **teststaticrole) + assert role_name in vault_list("database/static-roles") + yield + finally: + if role_name in vault_list("database/static-roles"): + vault_delete(f"database/static-roles/{role_name}") + assert role_name not in vault_list("database/static-roles") + + +@pytest.fixture +def vault_db(modules): + try: + yield modules.vault_db + finally: + # prevent dangling leases, which prevent disabling the secret engine + assert vault_revoke("database/creds", prefix=True) + if "testdb" in vault_list("database/config"): + vault_delete("database/config/testdb") + assert "testdb" not in vault_list("database/config") + if "testrole" in vault_list("database/roles"): + vault_delete("database/roles/testrole") + assert "testrole" not in vault_list("database/roles") + if "teststaticrole" in vault_list("database/static-roles"): + vault_delete("database/static-roles/teststaticrole") + assert "teststaticrole" not in vault_list("database/static-roles") + + +@pytest.mark.usefixtures("connection_setup") +def test_list_connections(vault_db): + ret = vault_db.list_connections() + assert ret == ["testdb"] + + +def test_list_connections_empty(vault_db): + ret = vault_db.list_connections() + assert ret == [] + + +@pytest.mark.usefixtures("connection_setup") +def test_fetch_connection(vault_db, testdb): + ret = vault_db.fetch_connection("testdb") + assert ret + for var, val in testdb.items(): + if var == "password": + continue + if var in ["connection_url", "username"]: + assert var in ret["connection_details"] + assert ret["connection_details"][var] == val + else: + assert var in ret + if var == "allowed_roles": + assert ret[var] == list(val.split(",")) + else: + assert ret[var] == val + + +@pytest.mark.usefixtures("testdb") +def test_fetch_connection_empty(vault_db): + ret = vault_db.fetch_connection("foobar") + assert ret is None + + +@pytest.mark.usefixtures("testdb") +def test_write_connection(vault_db, mysql_container): + args = { + "plugin": "mysql", + "connection_url": f"{{{{username}}}}:{{{{password}}}}@tcp(host.docker.internal:{mysql_container.mysql_port})/", + "allowed_roles": ["testrole", "teststaticrole"], + "username": "root", + "password": mysql_container.mysql_passwd, + # Can't rotate because we wouldn't know the new one for further tests + "rotate": False, + } + ret = vault_db.write_connection("testdb", **args) + assert ret + assert "testdb" in vault_list("database/config") + + +@pytest.mark.usefixtures("connection_setup") +def test_update_connection(vault_db): + """ + Ensure missing kwargs are not enforced on updates. + """ + assert vault_db.write_connection("testdb", "mysql", allowed_roles=["*"], rotate=False) is True + + +@pytest.mark.usefixtures("connection_setup") +def test_delete_connection(vault_db): + ret = vault_db.delete_connection("testdb") + assert ret + assert "testdb" not in vault_list("database/config") + + +@pytest.mark.usefixtures("connection_setup") +def test_reset_connection(vault_db): + ret = vault_db.reset_connection("testdb") + assert ret + + +@pytest.mark.usefixtures("roles_setup") +def test_list_roles(vault_db): + ret = vault_db.list_roles() + assert ret == ["testrole"] + + +def test_list_roles_empty(vault_db): + ret = vault_db.list_roles() + assert ret == [] + + +@pytest.mark.usefixtures("role_static_setup") +def test_list_roles_static(vault_db): + ret = vault_db.list_roles(static=True) + assert ret == ["teststaticrole"] + + +@pytest.mark.usefixtures("roles_setup") +def test_fetch_role(vault_db, testrole): + ret = vault_db.fetch_role("testrole") + assert ret + for var, val in testrole.items(): + assert var in ret + if var == "creation_statements": + assert ret[var] == [val] + else: + assert ret[var] == val + + +@pytest.mark.usefixtures("role_static_setup") +def test_fetch_role_static(vault_db, teststaticrole): + ret = vault_db.fetch_role("teststaticrole", static=True) + assert ret + for var, val in teststaticrole.items(): + assert var in ret + assert ret[var] == val + + +def test_fetch_role_empty(vault_db): + ret = vault_db.fetch_role("foobar") + assert ret is None + + +@pytest.mark.usefixtures("connection_setup") +def test_write_role(vault_db): + args = { + "connection": "testdb", + "creation_statements": r"CREATE USER '{{name}}'@'%' IDENTIFIED BY '{{password}}';GRANT SELECT ON *.* TO '{{name}}'@'%';", + } + ret = vault_db.write_role("testrole", **args) + assert ret + assert "testrole" in vault_list("database/roles") + + +@pytest.mark.usefixtures("connection_setup") +def test_write_static_role(vault_db, mysql_container): + args = { + "connection": "testdb", + "username": mysql_container.mysql_user, + "rotation_period": 86400, + } + ret = vault_db.write_static_role("teststaticrole", **args) + assert ret + assert "teststaticrole" in vault_list("database/static-roles") + + +@pytest.mark.usefixtures("roles_setup") +def test_delete_role(vault_db): + ret = vault_db.delete_role("testrole") + assert ret + assert "testrole" not in vault_list("database/roles") + + +@pytest.mark.usefixtures("role_static_setup") +def test_delete_role_static(vault_db): + ret = vault_db.delete_role("teststaticrole", static=True) + assert ret + assert "teststaticrole" not in vault_list("database/static-roles") + + +@pytest.fixture(params=({},)) +def _cached_creds(vault_db, roles_setup, request): # pylint: disable=unused-argument + data = request.param.copy() + role = data.pop("role", "testrole") + ret = vault_db.get_creds(role, cache=True, **data) + assert ret + assert "username" in ret + assert "password" in ret + return ret + + +@pytest.mark.usefixtures("roles_setup") +def test_get_creds(vault_db): + ret = vault_db.get_creds("testrole", cache=False) + assert ret + assert "username" in ret + assert "password" in ret + + +@pytest.mark.usefixtures("role_static_setup") +def test_get_creds_static(vault_db, teststaticrole): + ret = vault_db.get_creds("teststaticrole", static=True, cache=False) + assert ret + assert "username" in ret + assert "password" in ret + assert ret["username"] == teststaticrole["username"] + + +@pytest.mark.parametrize("vault_container_version", ("latest",), indirect=True) +def test_get_creds_cached(vault_db, _cached_creds): + ret_new = vault_db.get_creds("testrole", cache=True) + assert ret_new + assert "username" in ret_new + assert "password" in ret_new + assert ret_new["username"] == _cached_creds["username"] + assert ret_new["password"] == _cached_creds["password"] + + +@pytest.mark.parametrize("vault_container_version", ("latest",), indirect=True) +@pytest.mark.usefixtures("roles_setup") +def test_get_creds_cached__multiple(vault_db): + ret = vault_db.get_creds("testrole", cache="one") + assert ret + assert "username" in ret + assert "password" in ret + ret_new = vault_db.get_creds("testrole", cache="two") + assert ret_new + assert "username" in ret_new + assert "password" in ret_new + assert ret_new["username"] != ret["username"] + assert ret_new["password"] != ret["password"] + assert vault_db.get_creds("testrole", cache="one") == ret + assert vault_db.get_creds("testrole", cache="two") == ret_new + + +@pytest.mark.parametrize("vault_container_version", ("latest",), indirect=True) +@pytest.mark.usefixtures("roles_setup") +@pytest.mark.parametrize("roles_setup", [["testreissuerole"]], indirect=True) +@pytest.mark.parametrize( + "_cached_creds", ({"role": "testreissuerole", "valid_for": 180},), indirect=True +) +def test_get_creds_cached_valid_for_reissue(vault_db, testreissuerole, _cached_creds): + """ + Test that valid cached credentials that do not fulfill valid_for + and cannot be renewed as requested are reissued + """ + # 3 seconds because of leeway in lease validity check after renewals + time.sleep(3) + ret_new = vault_db.get_creds( + "testreissuerole", cache=True, valid_for=testreissuerole["default_ttl"] + ) + assert ret_new + assert "username" in ret_new + assert "password" in ret_new + assert ret_new["username"] != _cached_creds["username"] + assert ret_new["password"] != _cached_creds["password"] + + +@pytest.mark.parametrize("vault_container_version", ("latest",), indirect=True) +@pytest.mark.usefixtures("roles_setup") +@pytest.mark.parametrize("roles_setup", [["testreissuerole"]], indirect=True) +@pytest.mark.parametrize( + "_cached_creds", ({"role": "testreissuerole", "valid_for": 180},), indirect=True +) +def test_get_creds_cached_with_cached_min_ttl(vault_db, _cached_creds): + """ + Test that a cached ``min_ttl`` (``valid_for``) is respected at a minimum. + """ + # 3 seconds because of leeway in lease validity check after renewals + time.sleep(3) + ret_new = vault_db.get_creds("testreissuerole", cache=True, valid_for=5) + assert ret_new + assert "username" in ret_new + assert "password" in ret_new + assert ret_new["username"] != _cached_creds["username"] + assert ret_new["password"] != _cached_creds["password"] + + +@pytest.mark.parametrize("vault_container_version", ("latest",), indirect=True) +@pytest.mark.parametrize( + "_cached_creds,new", + ( + ({"valid_for": 240}, {"valid_for": 360}), + ({"revoke_delay": 240}, {"revoke_delay": 15}), + ({"renew_increment": 240}, {"renew_increment": 15}), + ({"meta": {"foo": "bar"}}, {"meta": {"bar": "baz"}}), + ), + indirect=("_cached_creds",), +) +@pytest.mark.parametrize("warn", (False, True)) +def test_get_creds_cached_changed_lifecycle(vault_db, _cached_creds, new, warn, caplog): + """ + Test that changed lifecycle attributes are warned about when + _warn_about_attr_change is not set only. + + This is a precaution for the following situation: + * A state manages the cached creds with wanted lifecycle attributes + * During template rendering, the creds are requested with different + lifecycle attributes. + """ + with caplog.at_level(logging.WARNING): + ret_new = vault_db.get_creds("testrole", cache=True, **new, _warn_about_attr_change=warn) + assert ret_new + assert "username" in ret_new + assert "password" in ret_new + assert ret_new["username"] == _cached_creds["username"] + assert ret_new["password"] == _cached_creds["password"] + assert ("changed lifecycle attributes" in caplog.text) is warn + + +@pytest.mark.usefixtures("_cached_creds") +@pytest.mark.parametrize("vault_container_version", ("latest",), indirect=True) +def test_clear_cached(vault_db): + assert vault_db.list_cached() + assert vault_db.clear_cached() is True + assert not vault_db.list_cached() + + +@pytest.mark.usefixtures("_cached_creds") +@pytest.mark.parametrize("vault_container_version", ("latest",), indirect=True) +def test_list_cached(vault_db): + ret = vault_db.list_cached() + ckey = "db.database.dynamic.testrole.default" + assert ret + assert ckey in ret + assert not ret[ckey]["expired"] + assert ret[ckey]["expires_in"] > 3590 + assert "data" not in ret[ckey] + now = datetime.now().astimezone() + # this might fail if this test runs juuust before midnight + assert ret[ckey]["creation_time"].startswith(now.strftime("%Y-%m-%d")) + # I hope you have something better to do during New Year's Eve + assert ret[ckey]["expire_time"].startswith(now.strftime("%Y-")) + for val in ("creation_time", "expire_time"): + assert ret[ckey][val].endswith(now.strftime(" %Z")) + + +@pytest.mark.usefixtures("_cached_creds") +@pytest.mark.parametrize("vault_container_version", ("latest",), indirect=True) +def test_renew_cached(vault_db): + curr = vault_db.list_cached() + ckey = "db.database.dynamic.testrole.default" + assert curr + assert ckey in curr + time.sleep(3) + assert vault_db.renew_cached() is True + new = vault_db.list_cached() + assert new[ckey]["expire_time"] != curr[ckey]["expire_time"] + + +@pytest.mark.usefixtures("role_static_setup") +def test_rotate_static_role(vault_db): + ret = vault_db.get_creds("teststaticrole", static=True, cache=False) + assert ret + old_pw = ret["password"] + ret = vault_db.rotate_static_role("teststaticrole") + assert ret + ret = vault_db.get_creds("teststaticrole", static=True, cache=False) + assert ret + assert ret["password"] != old_pw diff --git a/tests/functional/states/test_vault_db.py b/tests/functional/states/test_vault_db.py new file mode 100644 index 00000000..9baf218d --- /dev/null +++ b/tests/functional/states/test_vault_db.py @@ -0,0 +1,563 @@ +import pytest +from saltfactories.utils import random_string + +from tests.support.mysql import create_mysql_combo # pylint: disable=unused-import +from tests.support.mysql import mysql_combo # pylint: disable=unused-import +from tests.support.mysql import mysql_container # pylint: disable=unused-import +from tests.support.mysql import MySQLImage +from tests.support.vault import vault_delete +from tests.support.vault import vault_disable_secret_engine +from tests.support.vault import vault_enable_secret_engine +from tests.support.vault import vault_list +from tests.support.vault import vault_read +from tests.support.vault import vault_revoke +from tests.support.vault import vault_write + +pytest.importorskip("docker") + +pytestmark = [ + pytest.mark.slow_test, + pytest.mark.skip_if_binaries_missing("vault", "getent"), + pytest.mark.usefixtures("vault_container_version"), +] + + +@pytest.fixture(scope="module") +def minion_config_overrides(vault_port): + return { + "vault": { + "auth": { + "method": "token", + "token": "testsecret", + }, + "cache": { + "backend": "disk", + }, + "server": { + "url": f"http://127.0.0.1:{vault_port}", + }, + } + } + + +@pytest.fixture(scope="module") +def mysql_image(): + version = "10.5" + return MySQLImage( + name="mariadb", + tag=version, + container_id=random_string(f"mariadb-{version}-"), + ) + + +@pytest.fixture +def role_args_common(): + return { + "db_name": "testdb", + "creation_statements": r"CREATE USER '{{name}}'@'%' IDENTIFIED BY '{{password}}';GRANT SELECT ON *.* TO '{{name}}'@'%';", + } + + +@pytest.fixture +def testrole(): + return { + "default_ttl": 3600, + "max_ttl": 86400, + } + + +@pytest.fixture +def teststaticrole(mysql_container): + return { + "db_name": "testdb", + "rotation_period": 86400, + "username": mysql_container.mysql_user, + } + + +@pytest.fixture +def testreissuerole(): + return { + "default_ttl": 180, + "max_ttl": 180, + } + + +@pytest.fixture +def testdb(mysql_container): + return { + "plugin_name": "mysql-database-plugin", + "connection_url": f"{{{{username}}}}:{{{{password}}}}@tcp(host.docker.internal:{mysql_container.mysql_port})/", + "allowed_roles": "testrole,teststaticrole,testreissuerole", + "username": "root", + "password": mysql_container.mysql_passwd, + } + + +@pytest.fixture(scope="module", autouse=True) +def db_engine(vault_container_version): # pylint: disable=unused-argument + assert vault_enable_secret_engine("database") + yield + assert vault_disable_secret_engine("database") + + +@pytest.fixture +def connection_setup(testdb): + try: + vault_write("database/config/testdb", **testdb) + assert "testdb" in vault_list("database/config") + yield + finally: + # prevent dangling leases, which prevent disabling the secret engine + assert vault_revoke("database/creds", prefix=True) + if "testdb" in vault_list("database/config"): + vault_delete("database/config/testdb") + assert "testdb" not in vault_list("database/config") + + +@pytest.fixture(params=[["testrole"]]) +def roles_setup(connection_setup, request, role_args_common): # pylint: disable=unused-argument + try: + for role_name in request.param: + role_args = request.getfixturevalue(role_name) + role_args.update(role_args_common) + vault_write(f"database/roles/{role_name}", **role_args) + assert role_name in vault_list("database/roles") + yield + finally: + for role_name in request.param: + if role_name in vault_list("database/roles"): + vault_delete(f"database/roles/{role_name}") + assert role_name not in vault_list("database/roles") + + +@pytest.fixture +def role_static_setup(connection_setup, teststaticrole): # pylint: disable=unused-argument + role_name = "teststaticrole" + try: + vault_write(f"database/static-roles/{role_name}", **teststaticrole) + assert role_name in vault_list("database/static-roles") + yield + finally: + if role_name in vault_list("database/static-roles"): + vault_delete(f"database/static-roles/{role_name}") + assert role_name not in vault_list("database/static-roles") + + +@pytest.fixture +def vault_db(states): + try: + yield states.vault_db + finally: + # prevent dangling leases, which prevent disabling the secret engine + assert vault_revoke("database/creds", prefix=True) + if "testdb" in vault_list("database/config"): + vault_delete("database/config/testdb") + assert "testdb" not in vault_list("database/config") + if "testrole" in vault_list("database/roles"): + vault_delete("database/roles/testrole") + assert "testrole" not in vault_list("database/roles") + if "teststaticrole" in vault_list("database/static-roles"): + vault_delete("database/static-roles/teststaticrole") + assert "teststaticrole" not in vault_list("database/static-roles") + + +@pytest.fixture +def connargs(mysql_container): + return { + "plugin": "mysql", + "connection_url": f"{{{{username}}}}:{{{{password}}}}@tcp(host.docker.internal:{mysql_container.mysql_port})/", + "allowed_roles": ["testrole", "teststaticrole", "testreissuerole"], + "username": "root", + "password": mysql_container.mysql_passwd, + "rotate": False, + } + + +@pytest.fixture +def roleargs(): + return { + "connection": "testdb", + "creation_statements": r"CREATE USER '{{name}}'@'%' IDENTIFIED BY '{{password}}';GRANT SELECT ON *.* TO '{{name}}'@'%';", + } + + +@pytest.fixture +def roleargs_static(mysql_container): + return { + "connection": "testdb", + "username": mysql_container.mysql_user, + "rotation_period": 86400, + } + + +def test_connection_present(vault_db, connargs): + ret = vault_db.connection_present("testdb", **connargs) + assert ret.result + assert ret.changes + assert "created" in ret.changes + assert ret.changes["created"] == "testdb" + assert "testdb" in vault_list("database/config") + + +@pytest.mark.usefixtures("connection_setup") +def test_connection_present_no_changes(vault_db, connargs): + ret = vault_db.connection_present("testdb", **connargs) + assert ret.result + assert not ret.changes + + +@pytest.mark.usefixtures("connection_setup") +def test_connection_present_allowed_roles_change(vault_db, connargs): + connargs["allowed_roles"] = ["testrole", "teststaticrole", "newrole"] + ret = vault_db.connection_present("testdb", **connargs) + assert ret.result + assert ret.changes + assert "allowed_roles" in ret.changes + assert ( + vault_read("database/config/testdb")["data"]["allowed_roles"] == connargs["allowed_roles"] + ) + + +@pytest.mark.usefixtures("connection_setup") +def test_connection_present_new_param(vault_db, connargs): + connargs["username_template"] = r"{{random 20}}" + ret = vault_db.connection_present("testdb", **connargs) + assert ret.result + assert ret.changes + assert "username_template" in ret.changes + assert ( + vault_read("database/config/testdb")["data"]["connection_details"]["username_template"] + == connargs["username_template"] + ) + + +def test_connection_present_test_mode(vault_db, connargs): + ret = vault_db.connection_present("testdb", test=True, **connargs) + assert ret.result is None + assert ret.changes + assert "created" in ret.changes + assert ret.changes["created"] == "testdb" + assert "testdb" not in vault_list("database/config") + + +@pytest.mark.usefixtures("connection_setup") +def test_connection_absent(vault_db): + ret = vault_db.connection_absent("testdb") + assert ret.result + assert ret.changes + assert "deleted" in ret.changes + assert ret.changes["deleted"] == "testdb" + assert "testdb" not in vault_list("database/config") + + +def test_connection_absent_no_changes(vault_db): + ret = vault_db.connection_absent("testdb") + assert ret.result + assert not ret.changes + + +@pytest.mark.usefixtures("connection_setup") +def test_connection_absent_test_mode(vault_db): + ret = vault_db.connection_absent("testdb", test=True) + assert ret.result is None + assert ret.changes + assert "deleted" in ret.changes + assert ret.changes["deleted"] == "testdb" + assert "testdb" in vault_list("database/config") + + +@pytest.mark.usefixtures("connection_setup") +def test_role_present(vault_db, roleargs): + ret = vault_db.role_present("testrole", **roleargs) + assert ret.result + assert ret.changes + assert "created" in ret.changes + assert ret.changes["created"] == "testrole" + assert "testrole" in vault_list("database/roles") + + +@pytest.mark.usefixtures("roles_setup") +def test_role_present_no_changes(vault_db, roleargs): + ret = vault_db.role_present("testrole", **roleargs) + assert ret.result + assert not ret.changes + + +@pytest.mark.usefixtures("roles_setup") +def test_role_present_no_changes_with_time_string(vault_db, roleargs): + roleargs["default_ttl"] = "1h" + ret = vault_db.role_present("testrole", **roleargs) + assert ret.result + assert not ret.changes + + +@pytest.mark.usefixtures("roles_setup") +def test_role_present_param_change(vault_db, roleargs): + roleargs["default_ttl"] = 1337 + ret = vault_db.role_present("testrole", **roleargs) + assert ret.result + assert ret.changes + assert "default_ttl" in ret.changes + assert vault_read("database/roles/testrole")["data"]["default_ttl"] == 1337 + + +@pytest.mark.usefixtures("connection_setup") +def test_role_present_test_mode(vault_db, roleargs): + ret = vault_db.role_present("testrole", test=True, **roleargs) + assert ret.result is None + assert ret.changes + assert "created" in ret.changes + assert ret.changes["created"] == "testrole" + assert "testrole" not in vault_list("database/roles") + + +@pytest.mark.usefixtures("connection_setup") +def test_static_role_present(vault_db, roleargs_static): + ret = vault_db.static_role_present("teststaticrole", **roleargs_static) + assert ret.result + assert ret.changes + assert "created" in ret.changes + assert ret.changes["created"] == "teststaticrole" + assert "teststaticrole" in vault_list("database/static-roles") + + +@pytest.mark.usefixtures("role_static_setup") +def test_static_role_present_no_changes(vault_db, roleargs_static): + ret = vault_db.static_role_present("teststaticrole", **roleargs_static) + assert ret.result + assert not ret.changes + + +@pytest.mark.usefixtures("role_static_setup") +def test_static_role_present_param_change(vault_db, roleargs_static): + roleargs_static["rotation_period"] = 1337 + ret = vault_db.static_role_present("teststaticrole", **roleargs_static) + assert ret.result + assert ret.changes + assert "rotation_period" in ret.changes + assert vault_read("database/static-roles/teststaticrole")["data"]["rotation_period"] == 1337 + + +@pytest.mark.usefixtures("connection_setup") +def test_static_role_present_test_mode(vault_db, roleargs_static): + ret = vault_db.static_role_present("teststaticrole", test=True, **roleargs_static) + assert ret.result is None + assert ret.changes + assert "created" in ret.changes + assert ret.changes["created"] == "teststaticrole" + assert "teststaticrole" not in vault_list("database/static-roles") + + +@pytest.mark.usefixtures("roles_setup") +def test_role_absent(vault_db): + ret = vault_db.role_absent("testrole") + assert ret.result + assert ret.changes + assert "deleted" in ret.changes + assert ret.changes["deleted"] == "testrole" + assert "testrole" not in vault_list("database/roles") + + +@pytest.mark.usefixtures("role_static_setup") +def test_role_absent_static(vault_db): + ret = vault_db.role_absent("teststaticrole", static=True) + assert ret.result + assert ret.changes + assert "deleted" in ret.changes + assert ret.changes["deleted"] == "teststaticrole" + assert "teststaticrole" not in vault_list("database/static-roles") + + +def test_role_absent_no_changes(vault_db): + ret = vault_db.role_absent("testrole") + assert ret.result + assert not ret.changes + + +@pytest.mark.usefixtures("roles_setup") +def test_role_absent_test_mode(vault_db): + ret = vault_db.role_absent("testrole", test=True) + assert ret.result is None + assert ret.changes + assert "deleted" in ret.changes + assert ret.changes["deleted"] == "testrole" + assert "testrole" in vault_list("database/roles") + + +@pytest.fixture(params=(False, True)) +def testmode(request): + return request.param + + +@pytest.fixture(params=({},)) +def _cached_creds(request, loaders, roles_setup): # pylint: disable=unused-argument + kwargs = {"name": "testrole", "cache": True} + kwargs.update(request.param) + ret = loaders.modules.vault_db.get_creds(**kwargs) + assert ret + assert "username" in ret + assert "password" in ret + assert loaders.modules.vault_db.list_cached() + # We need to clear the context because in the test suite, the state modules + # are running in a different one than the execution modules and the lease + # has already been cached in the context of the execution module. + # This means it does not pick up changes to the cached files, but we need + # it to check changes in the tests. + loaders.context.clear() + return ret + + +@pytest.mark.usefixtures("roles_setup") +def test_creds_cached(testmode, vault_db, modules): + ret = vault_db.creds_cached("testrole", test=testmode) + assert (ret.result is None) is testmode + assert ret.changes + assert "new" in ret.changes + assert "issued" in ret.comment + assert ("would have" in ret.comment) is testmode + assert bool(modules.vault_db.list_cached()) is not testmode + + +@pytest.mark.parametrize("vault_container_version", ("latest",), indirect=True) +def test_creds_cached_already_cached(testmode, vault_db, modules, _cached_creds): + ret = vault_db.creds_cached("testrole", test=testmode) + assert ret.result is True + assert not ret.changes + assert "already cached and valid" in ret.comment + assert modules.vault_db.get_creds("testrole") == _cached_creds + + +@pytest.mark.parametrize("vault_container_version", ("latest",), indirect=True) +def test_creds_cached_already_cached_but_different_params( + testmode, vault_db, modules, _cached_creds +): + """ + Ensure changed parameters are reported without reissuing the + lease, if possible. + """ + ret = vault_db.creds_cached( + "testrole", + valid_for=800, + renew_increment=120, + revoke_delay=240, + meta={"foo": "bar"}, + test=testmode, + ) + assert (ret.result is None) is testmode + assert ret.changes + assert "new" not in ret.changes + assert "expiry" not in ret.changes + assert ret.changes == { + "min_ttl": {"old": None, "new": 800}, + "renew_increment": {"old": None, "new": 120}, + "revoke_delay": {"old": None, "new": 240}, + "meta": {"old": None, "new": {"foo": "bar"}}, + } + assert "edited" in ret.comment + assert ("would have" in ret.comment) is testmode + assert modules.vault_db.get_creds("testrole") == _cached_creds + new = modules.vault_db.list_cached() + new = new[next(iter(new))] + assert (new["min_ttl"] == 800) is not testmode + assert (new["renew_increment"] == 120) is not testmode + assert (new["revoke_delay"] == 240) is not testmode + assert (new["meta"] == {"foo": "bar"}) is not testmode + + +def test_creds_cached_renew(testmode, vault_db, modules, _cached_creds): + """ + Ensure renewed credentials are reported as such. + """ + ret = vault_db.creds_cached("testrole", valid_for="2h", test=testmode) + assert (ret.result is None) is testmode + assert ret.changes + assert "expiry" in ret.changes + assert ret["changes"] == {"expiry": True, "min_ttl": {"old": None, "new": "2h"}} + assert "renewed" in ret.comment + assert ("would have" in ret.comment) is testmode + if not testmode: + assert modules.vault_db.get_creds("testrole") == _cached_creds + # can't check the changes because requesting it would + # apply the changes and listing the cache does not include the data + + +@pytest.mark.usefixtures("roles_setup") +@pytest.mark.parametrize("roles_setup", [["testreissuerole"]], indirect=True) +@pytest.mark.parametrize( + "_cached_creds", ({"name": "testreissuerole", "valid_for": 185},), indirect=True +) +def test_creds_cached_reissue(testmode, vault_db, modules, _cached_creds): + """ + Ensure reissued credentials are reported as such. + """ + ret = vault_db.creds_cached("testreissuerole", valid_for=160, test=testmode) + assert (ret.result is None) is testmode + assert ret.changes + assert "expiry" in ret.changes + if testmode: + # this is hard to detect in test mode TODO? + assert "renewed/reissued" in ret.comment + assert ret["changes"] == {"expiry": True, "min_ttl": {"old": 185, "new": 160}} + else: + assert "reissued" in ret.comment + assert ret["changes"] == { + "expiry": True, + "min_ttl": {"old": 185, "new": 160}, + "reissued": True, + } + assert ("would have" in ret.comment) is testmode + new = modules.vault_db.list_cached() + new = new[next(iter(new))] + assert (new["min_ttl"] == 160) is not testmode + + +@pytest.mark.usefixtures("roles_setup") +@pytest.mark.parametrize("roles_setup", [["testreissuerole"]], indirect=True) +@pytest.mark.parametrize( + "_cached_creds", ({"name": "testreissuerole", "valid_for": 185},), indirect=True +) +def test_creds_cached_reissue_only(testmode, vault_db, loaders, _cached_creds): + """ + Ensure that expired leases are recognized, even if valid_for has not been set. + """ + old = loaders.modules.vault_db.list_cached() + old = old[next(iter(old))] + old.pop("expires_in") + loaders.context.clear() # to get updated information because of differing ctx + ret = vault_db.creds_cached("testreissuerole", test=testmode) + assert (ret.result is None) is testmode + assert ret.changes + assert "expiry" in ret.changes + if testmode: + # this is hard to detect in test mode TODO? + assert "renewed/reissued" in ret.comment + assert ret["changes"] == {"expiry": True} + else: + assert "reissued" in ret.comment + assert ret["changes"] == {"expiry": True, "reissued": True} + assert ("would have" in ret.comment) is testmode + new = loaders.modules.vault_db.list_cached() + new = new[next(iter(new))] + new.pop("expires_in") + assert (new == old) is testmode + + +def test_creds_uncached(testmode, vault_db, modules, _cached_creds): + ret = vault_db.creds_uncached("testrole", test=testmode) + assert ret.result is not False + assert (ret.result is None) is testmode + assert ret.changes + assert "revoked" in ret.changes + assert ("would have" in ret.comment) is testmode + assert "revoked" in ret.comment + after = modules.vault_db.list_cached() + assert bool(after) is testmode + + +@pytest.mark.parametrize("vault_container_version", ("latest",), indirect=True) +def test_creds_uncached_no_changes(testmode, vault_db): + ret = vault_db.creds_uncached("testrole", test=testmode) + assert ret.result is True + assert not ret.changes + assert "No matching credentials" in ret.comment diff --git a/tests/support/mysql.py b/tests/support/mysql.py new file mode 100644 index 00000000..7ed347ab --- /dev/null +++ b/tests/support/mysql.py @@ -0,0 +1,195 @@ +""" +This is copied from Salt's testsuite at tests/support/pytest/mysql.py. +""" +import logging +import time + +import attr +import pytest +from pytestskipmarkers.utils import platform +from saltfactories.utils import random_string + +# This `pytest.importorskip` here actually works because this module +# is imported into test modules, otherwise, the skipping would just fail +pytest.importorskip("docker") +import docker.errors # isort:skip + +log = logging.getLogger(__name__) + + +@attr.s(kw_only=True, slots=True) +class MySQLImage: + name = attr.ib() + tag = attr.ib() + container_id = attr.ib() + + def __str__(self): + return f"{self.name}:{self.tag}" + + +@attr.s(kw_only=True, slots=True) +class MySQLCombo: + mysql_name = attr.ib() + mysql_version = attr.ib() + mysql_port = attr.ib(default=None) + mysql_host = attr.ib(default="%") + mysql_user = attr.ib() + mysql_passwd = attr.ib() + mysql_database = attr.ib(default=None) + mysql_root_user = attr.ib(default="root") + mysql_root_passwd = attr.ib() + container = attr.ib(default=None) + container_id = attr.ib() + + @container_id.default + def _default_container_id(self): + return random_string( + "{}-{}-".format( # pylint: disable=consider-using-f-string + self.mysql_name.replace("/", "-"), + self.mysql_version, + ) + ) + + @mysql_root_passwd.default + def _default_mysql_root_user_passwd(self): + return self.mysql_passwd + + def get_credentials(self, **kwargs): + return { + "connection_user": kwargs.get("connection_user") or self.mysql_root_user, + "connection_pass": kwargs.get("connection_pass") or self.mysql_root_passwd, + "connection_db": kwargs.get("connection_db") or "mysql", + "connection_port": kwargs.get("connection_port") or self.mysql_port, + } + + +def get_test_versions(): + test_versions = [] + name = "mysql-server" + for version in ("5.5", "5.6", "5.7", "8.0"): + test_versions.append( + MySQLImage( + name=name, + tag=version, + container_id=random_string(f"mysql-{version}-"), + ) + ) + name = "mariadb" + for version in ("10.3", "10.4", "10.5"): + test_versions.append( + MySQLImage( + name=name, + tag=version, + container_id=random_string(f"mariadb-{version}-"), + ) + ) + name = "percona" + for version in ("5.6", "5.7", "8.0"): + test_versions.append( + MySQLImage( + name=name, + tag=version, + container_id=random_string(f"percona-{version}-"), + ) + ) + return test_versions + + +def get_test_version_id(value): + return f"container={value}" + + +@pytest.fixture(scope="module", params=get_test_versions(), ids=get_test_version_id) +def mysql_image(request): + return request.param + + +@pytest.fixture(scope="module") +def create_mysql_combo(mysql_image): + if platform.is_fips_enabled(): + if mysql_image.name in ("mysql-server", "percona") and mysql_image.tag == "8.0": + pytest.skip(f"These tests fail on {mysql_image.name}:{mysql_image.tag}") + + return MySQLCombo( + mysql_name=mysql_image.name, + mysql_version=mysql_image.tag, + mysql_user="salt-mysql-user", + mysql_passwd="Pa55w0rd!", + container_id=mysql_image.container_id, + ) + + +@pytest.fixture(scope="module") +def mysql_combo(create_mysql_combo): + return create_mysql_combo + + +def check_container_started(timeout_at, container, combo): + sleeptime = 0.5 + while time.time() <= timeout_at: + try: + if not container.is_running(): + log.warning("%s is no longer running", container) + return False + ret = container.run( + "mysql", + f"--user={combo.mysql_user}", + f"--password={combo.mysql_passwd}", + "-e", + "SELECT 1", + ) + if ret.returncode == 0: + break + except docker.errors.APIError: + log.exception("Failed to run start check") + time.sleep(sleeptime) + sleeptime *= 2 + else: + return False + time.sleep(0.5) + return True + + +def set_container_name_before_start(container): + """ + This is useful if the container has to be restared and the old + container, under the same name was left running, but in a bad shape. + """ + container.name = random_string( + "{}-".format(container.name.rsplit("-", 1)[0]) # pylint: disable=consider-using-f-string + ) + container.display_name = None + return container + + +@pytest.fixture(scope="module") +def mysql_container(salt_factories, mysql_combo): + + container_environment = { + "MYSQL_ROOT_PASSWORD": mysql_combo.mysql_passwd, + "MYSQL_ROOT_HOST": mysql_combo.mysql_host, + "MYSQL_USER": mysql_combo.mysql_user, + "MYSQL_PASSWORD": mysql_combo.mysql_passwd, + } + if mysql_combo.mysql_database: + container_environment["MYSQL_DATABASE"] = mysql_combo.mysql_database + + container = salt_factories.get_container( + mysql_combo.container_id, + "ghcr.io/saltstack/salt-ci-containers/{}:{}".format( # pylint: disable=consider-using-f-string + mysql_combo.mysql_name, mysql_combo.mysql_version + ), + pull_before_start=True, + skip_on_pull_failure=True, + skip_if_docker_client_not_connectable=True, + container_run_kwargs={ + "ports": {"3306/tcp": None}, + "environment": container_environment, + }, + ) + container.before_start(set_container_name_before_start, container) + container.container_start_check(check_container_started, container, mysql_combo) + with container.started(): + mysql_combo.container = container + mysql_combo.mysql_port = container.get_host_port_binding(3306, protocol="tcp", ipv6=False) + yield mysql_combo diff --git a/tests/unit/modules/test_vault_db.py b/tests/unit/modules/test_vault_db.py new file mode 100644 index 00000000..25e68e01 --- /dev/null +++ b/tests/unit/modules/test_vault_db.py @@ -0,0 +1,170 @@ +import contextlib +from unittest.mock import patch + +import pytest +import saltext.vault.utils.vault as vault +from salt.exceptions import CommandExecutionError +from salt.exceptions import SaltInvocationError +from saltext.vault.modules import vault_db + + +@pytest.fixture +def configure_loader_modules(): + return { + vault_db: { + "__grains__": {"id": "test-minion"}, + } + } + + +@pytest.fixture +def _conn_absent(): + with patch( + "saltext.vault.modules.vault_db.fetch_connection", return_value=None, autospec=True + ) as fetch: + yield fetch + + +@pytest.fixture +def query(): + with patch("saltext.vault.utils.vault.query", return_value=True, autospec=True) as _query: + yield _query + + +@pytest.mark.parametrize( + "func,kwargs", + ( + ("list_connections", {}), + ("fetch_connection", {"name": "foo"}), + ("write_connection", {"name": "foo", "plugin": "custom"}), + ("delete_connection", {"name": "foo"}), + ("reset_connection", {"name": "foo"}), + ("rotate_root", {"name": "foo"}), + ("list_roles", {}), + ("fetch_role", {"name": "foo"}), + ( + "write_static_role", + {"name": "foo", "connection": "bar", "username": "baz", "rotation_period": 42}, + ), + ( + "write_role", + {"name": "foo", "connection": "bar", "creation_statements": "thou shall exist"}, + ), + ("delete_role", {"name": "foo"}), + ("get_creds", {"name": "foo", "cache": False}), + ("rotate_static_role", {"name": "foo"}), + ), +) +def test_func_converts_errors(func, kwargs, query, request): + query.side_effect = vault.VaultException("booh") + if func == "write_connection": + # otherwise we would test fetch_connection again + request.getfixturevalue("_conn_absent") + with pytest.raises(CommandExecutionError, match="booh"): + getattr(vault_db, func)(**kwargs) + + +@pytest.mark.usefixtures("_conn_absent") +@pytest.mark.parametrize("plugin", ("mysql", "custom")) +def test_write_connection_missing_kwargs(plugin): + if plugin == "custom": + ctx = patch("saltext.vault.utils.vault.query", autospec=True) + else: + ctx = pytest.raises(SaltInvocationError, match="requires.*additional.*connection_url") + with ctx: + vault_db.write_connection("foo", plugin) + + +@pytest.mark.usefixtures("_conn_absent") +def test_write_connection_payload(query): + kwargs = { + "version": "1.2.3", + "verify": True, + "allowed_roles": ["*"], + "root_rotation_statements": ["rotate!"], + "password_policy": "yolo", + "custom_arg": True, + } + assert vault_db.write_connection("foo", "custom", **kwargs, rotate=False, mount="bar") is True + endpoint = query.call_args[0][1] + payload = query.call_args[1]["payload"] + assert endpoint == "bar/config/foo" + expected_payload = kwargs.copy() + expected_payload["plugin_name"] = "custom-database-plugin" + expected_payload["plugin_version"] = expected_payload.pop("version") + expected_payload["verify_connection"] = expected_payload.pop("verify") + assert payload == expected_payload + + +@pytest.mark.usefixtures("_conn_absent") +@pytest.mark.parametrize("rotate", (False, True)) +def test_write_connection_rotate(query, rotate): + vault_db.write_connection("foo", "custom", rotate=rotate) + endpoint = query.call_args[0][1] + assert (endpoint == "database/config/foo") is not rotate + assert (endpoint == "database/rotate-root/foo") is rotate + + +def test_write_static_role_payload(query): + kwargs = { + "rotation_period": 42, + "rotation_statements": ["rotate!"], + "credential_type": "password", + "credential_config": {"password_policy": "yolo"}, + } + assert vault_db.write_static_role("role", "conn", "user", **kwargs, mount="mount") is True + endpoint = query.call_args[0][1] + payload = query.call_args[1]["payload"] + assert endpoint == "mount/static-roles/role" + expected_payload = kwargs.copy() + expected_payload["username"] = "user" + expected_payload["db_name"] = "conn" + assert payload == expected_payload + + +def test_write_role_payload(query): + kwargs = { + "creation_statements": ["cogito ergo sum"], + "default_ttl": 42, + "max_ttl": 1337, + "revocation_statements": ["it's not you, it's me"], + "rollback_statements": ["this should be fine"], + "renew_statements": ["kekkon shitemo kudasai"], + "credential_type": "rsa_private_key", + "credential_config": {"key_bits": 1}, + } + assert vault_db.write_role("role", "conn", **kwargs, mount="mount") is True + endpoint = query.call_args[0][1] + payload = query.call_args[1]["payload"] + assert endpoint == "mount/roles/role" + expected_payload = kwargs.copy() + expected_payload["db_name"] = "conn" + assert payload == expected_payload + + +@pytest.mark.parametrize( + "typ,vals,expected", + ( + (None, None, True), + (None, {"password_policy": "yolo"}, True), + (None, {"password_police": "??"}, False), + (None, {"key_bits": 1}, False), + ("password", {"password_policy": "yolo"}, True), + ("password", {"password_alice": "257"}, False), + ("password", {"key_bits": 1}, False), + ("rsa_private_key", {"key_bits": 1, "format": "red"}, True), + ("rsa_private_key", {"key_fits": 0}, False), + ("rsa_private_key", {"password_policy": "yolo"}, False), + ("unknown", {"something": "else"}, True), + ), +) +@pytest.mark.usefixtures("query") +def test_write_role_credential_type_param_verification(typ, vals, expected): + if expected: + ctx = contextlib.nullcontext() + else: + ctx = pytest.raises(SaltInvocationError, match="invalid for credential type") + with ctx: + vault_db.write_static_role( + "role", "conn", "user", 42, credential_type=typ, credential_config=vals + ) diff --git a/tests/unit/states/test_vault_db.py b/tests/unit/states/test_vault_db.py new file mode 100644 index 00000000..136ba7a8 --- /dev/null +++ b/tests/unit/states/test_vault_db.py @@ -0,0 +1,501 @@ +from unittest.mock import Mock + +import pytest +from saltext.vault.modules import vault_db as vault_db_exe +from saltext.vault.states import vault_db + + +@pytest.fixture +def _conns(): + return {} + + +@pytest.fixture +def _roles(): + return {} + + +@pytest.fixture +def delete_connection_mock(_conns): + def _del(name, **kwargs): # pylint: disable=unused-argument + _conns.pop(name, None) + return True + + return Mock(spec=vault_db_exe.delete_connection, side_effect=_del) + + +@pytest.fixture +def delete_role_mock(_roles): + def _del(name, **kwargs): # pylint: disable=unused-argument + _roles.pop(name, None) + return True + + return Mock(spec=vault_db_exe.delete_role, side_effect=_del) + + +@pytest.fixture +def fetch_connection_mock(_conns): + return Mock(spec=vault_db_exe.fetch_connection, side_effect=lambda x, **kwargs: _conns.get(x)) + + +@pytest.fixture +def fetch_role_mock(_roles): + return Mock(spec=vault_db_exe.fetch_role, side_effect=lambda x, **kwargs: _roles.get(x)) + + +@pytest.fixture +def rotate_root_mock(): + return Mock(spec=vault_db_exe.rotate_root, return_value=True) + + +@pytest.fixture +def write_connection_mock(_conns): + def _write( + name, + plugin, + version="", + verify=True, + rotate=True, + allowed_roles=None, + root_rotation_statements=None, + password_policy=None, + mount="database", + **kwargs, + ): # pylint: disable=unused-argument + kwargs.pop("password", None) # password is obviously never returned + data = { + "plugin_name": f"{plugin}-database-plugin", + "plugin_version": version, + "verify_connection": verify, + "allowed_roles": allowed_roles or [], + "root_credentials_rotate_statements": root_rotation_statements or [], + "password_policy": password_policy or "", + "connection_details": kwargs, + } + _conns[name] = data + + return Mock(spec=vault_db_exe.write_connection, side_effect=_write) + + +@pytest.fixture +def write_role_mock(_roles): + def _write( + name, connection, creation_statements, mount="database", **kwargs + ): # pylint: disable=unused-argument + data = { + "db_name": connection, + "creation_statements": creation_statements, + "default_ttl": kwargs.get("default_ttl") or 0, + "max_ttl": kwargs.get("max_ttl") or 0, + "renew_statements": kwargs.get("renew_statements") or [], + "revocation_statements": kwargs.get("revocation_statements") or [], + "rollback_statements": kwargs.get("rollback_statements") or [], + # credential_type/_config are only exposed if the DB plugin supports it + } + _roles[name] = data + + return Mock(spec=vault_db_exe.write_role, side_effect=_write) + + +@pytest.fixture +def write_static_role_mock(_roles): + def _write( + name, + connection, + username, + rotation_period, + rotation_statements=None, + mount="database", + **kwargs, + ): # pylint: disable=unused-argument + data = { + "db_name": connection, + "username": username, + "rotation_period": rotation_period, + "rotation_statements": rotation_statements or [], + # credential_type/_config are only exposed if the DB plugin supports it + } + _roles[name] = data + + return Mock(spec=vault_db_exe.write_static_role, side_effect=_write) + + +@pytest.fixture +def configure_loader_modules( + delete_connection_mock, + delete_role_mock, + fetch_connection_mock, + fetch_role_mock, + rotate_root_mock, + write_connection_mock, + write_role_mock, + write_static_role_mock, + testmode, +): + return { + vault_db: { + "__opts__": {"test": testmode}, + "__grains__": {"id": "test-minion"}, + "__salt__": { + "vault_db.delete_connection": delete_connection_mock, + "vault_db.delete_role": delete_role_mock, + "vault_db.fetch_connection": fetch_connection_mock, + "vault_db.fetch_role": fetch_role_mock, + "vault_db.write_connection": write_connection_mock, + "vault_db.write_role": write_role_mock, + "vault_db.write_static_role": write_static_role_mock, + "vault_db.rotate_root": rotate_root_mock, + }, + } + } + + +@pytest.fixture(params=(False, True), autouse=True) +def testmode(request): + return request.param + + +def test_conn_present(testmode, write_connection_mock): + ret = vault_db.connection_present("conn", "custom") + assert ret["result"] is not False + assert (ret["result"] is None) is testmode + assert ret["changes"] + assert "created" in ret["changes"] + assert (write_connection_mock.call_args is None) is testmode + assert "created" in ret["comment"] + assert ("would have been" in ret["comment"]) is testmode + + +def test_conn_already_present(write_connection_mock): + write_connection_mock("conn", "custom") + ret = vault_db.connection_present("conn", "custom") + assert ret["result"] is True + assert not ret["changes"] + assert "as specified" in ret["comment"] + + +@pytest.mark.parametrize( + "kwargs,param", + ( + ({"version": "1.2.3"}, "plugin_version"), + ({"allowed_roles": ["*"]}, None), + ({"root_rotation_statements": ["rotate"]}, "root_credentials_rotate_statements"), + ({"password_policy": "foo"}, None), + ), +) +def test_conn_changes(testmode, write_connection_mock, kwargs, param, _conns): + write_connection_mock("conn", "custom") + param = param or next(iter(kwargs)) + expected_changes = {"old": _conns["conn"][param], "new": kwargs[next(iter(kwargs))]} + ret = vault_db.connection_present("conn", "custom", **kwargs) + assert ret["result"] is not False + assert (ret["result"] is None) is testmode + assert ret["changes"] + assert param in ret["changes"] + assert ret["changes"][param] == expected_changes + assert (write_connection_mock.call_count == 1) is testmode + assert "updated" in ret["comment"] + assert ("would have been" in ret["comment"]) is testmode + + +def test_conn_no_password_changes(write_connection_mock, _conns): + write_connection_mock("conn", "custom", username="foo", password="bar") + ret = vault_db.connection_present("conn", "custom", username="foo", password="baz") + assert ret["result"] is True + assert not ret["changes"] + + +def test_conn_detail_changes(testmode, write_connection_mock, _conns): + write_connection_mock("conn", "custom", username="foo", password="bar") + ret = vault_db.connection_present("conn", "custom", username="bar", password="baz") + assert ret["result"] is not False + assert (ret["result"] is None) is testmode + assert ret["changes"] + assert "username" in ret["changes"] + assert ret["changes"]["username"] == {"old": "foo", "new": "bar"} + assert (write_connection_mock.call_count == 1) is testmode + assert "updated" in ret["comment"] + assert ("would have been" in ret["comment"]) is testmode + if not testmode: + assert "username" in write_connection_mock.call_args[1] + # for an existing connection, the password should never be updated + assert "password" not in write_connection_mock.call_args[1] + + +def test_conn_statements_strip(write_connection_mock): + write_connection_mock("conn", "custom", root_rotation_statements=["foo"]) + ret = vault_db.connection_present("conn", "custom", root_rotation_statements=["foo\n"]) + assert ret["result"] is True + assert not ret["changes"] + + +def test_conn_plugin_change_err(write_connection_mock): + write_connection_mock("conn", "custom") + ret = vault_db.connection_present("conn", "custom2") + assert ret["result"] is False + assert not ret["changes"] + assert "Cannot change plugin type without deleting" in ret["comment"] + + +def test_conn_plugin_change_force(testmode, write_connection_mock, delete_connection_mock): + write_connection_mock("conn", "custom") + ret = vault_db.connection_present("conn", "custom2", force=True) + assert ret["result"] is not False + assert (ret["result"] is None) is testmode + assert ret["changes"] + assert "deleted_for_plugin_change" in ret["changes"] + assert (write_connection_mock.call_count == 1) is testmode + assert "created" in ret["comment"] + assert ("would have been" in ret["comment"]) is testmode + assert (delete_connection_mock.call_count == 0) is testmode + + +@pytest.mark.parametrize("testmode", (False,), indirect=True) +@pytest.mark.parametrize("present", (False, True)) +def test_conn_verification(present, write_connection_mock): + if present: + write_connection_mock("conn", "custom") + write_connection_mock.side_effect = None + ret = vault_db.connection_present("conn", "custom", allowed_roles=["*"]) + assert ret["result"] is False + if present: + assert "reported parameters do not match" in ret["comment"] + else: + assert "but it is still reported as absent" in ret["comment"] + + +def test_conn_absent(testmode, write_connection_mock, delete_connection_mock): + write_connection_mock("conn", "custom") + ret = vault_db.connection_absent("conn") + assert ret["result"] is not False + assert (ret["result"] is None) is testmode + assert ret["changes"] + assert "deleted" in ret["changes"] + assert "deleted" in ret["comment"] + assert ("would have been" in ret["comment"]) is testmode + assert (delete_connection_mock.call_count == 0) is testmode + + +def test_conn_already_absent(delete_connection_mock): + ret = vault_db.connection_absent("conn", "custom") + assert ret["result"] is True + assert not ret["changes"] + assert "already absent" in ret["comment"] + delete_connection_mock.assert_not_called() + + +@pytest.mark.parametrize("testmode", (False,), indirect=True) +def test_conn_absent_verification(write_connection_mock, delete_connection_mock): + write_connection_mock("conn", "custom") + delete_connection_mock.side_effect = None + ret = vault_db.connection_absent("conn", "custom") + assert ret["result"] is False + assert "but it is still reported as present" in ret["comment"] + + +def test_role_present(testmode, write_role_mock): + ret = vault_db.role_present("role", "conn", []) + assert ret["result"] is not False + assert (ret["result"] is None) is testmode + assert ret["changes"] + assert "created" in ret["changes"] + assert (write_role_mock.call_args is None) is testmode + assert "created" in ret["comment"] + assert ("would have been" in ret["comment"]) is testmode + + +def test_role_already_present(write_role_mock): + write_role_mock("role", "conn", []) + ret = vault_db.role_present("role", "conn", []) + assert ret["result"] is True + assert not ret["changes"] + assert "as specified" in ret["comment"] + + +@pytest.mark.parametrize( + "kwargs,param", + ( + ({"connection": "conn2"}, "db_name"), + ({"creation_statements": ["foo"]}, None), + ({"revocation_statements": ["revoke"]}, None), + ({"rollback_statements": ["back"]}, None), + ({"renew_statements": ["bling"]}, None), + ({"default_ttl": 42}, None), + ({"max_ttl": 1337}, None), + ), +) +def test_role_changes(testmode, write_role_mock, kwargs, param, _roles): + write_role_mock("role", "conn", []) + kwargs = kwargs.copy() + param = param or next(iter(kwargs)) + expected_changes = {"old": _roles["role"][param], "new": kwargs[next(iter(kwargs))]} + conn = kwargs.pop("connection", "conn") + creation_statements = kwargs.pop("creation_statements", []) + ret = vault_db.role_present("role", conn, creation_statements, **kwargs) + assert ret["result"] is not False + assert (ret["result"] is None) is testmode + assert ret["changes"] + assert param in ret["changes"] + assert ret["changes"][param] == expected_changes + assert (write_role_mock.call_count == 1) is testmode + assert "updated" in ret["comment"] + assert ("would have been" in ret["comment"]) is testmode + + +def test_role_changes_strip(write_role_mock): + write_role_mock( + "role", + "conn", + creation_statements=["foo"], + revocation_statements=["foo"], + rollback_statements=["foo"], + renew_statements=["foo"], + ) + ret = vault_db.role_present( + "role", + "conn", + creation_statements=["foo\n"], + revocation_statements=["foo\n"], + rollback_statements=["foo\n"], + renew_statements=["foo\n"], + ) + assert ret["result"] is True + assert not ret["changes"] + + +def test_role_statements_as_strings(write_role_mock): + write_role_mock( + "role", + "conn", + creation_statements=["foo"], + revocation_statements=["foo"], + rollback_statements=["foo"], + renew_statements=["foo"], + ) + ret = vault_db.role_present( + "role", + "conn", + creation_statements="foo", + revocation_statements="foo", + rollback_statements="foo", + renew_statements="foo", + ) + assert ret["result"] is True + assert not ret["changes"] + + +@pytest.mark.parametrize("testmode", (False,), indirect=True) +@pytest.mark.parametrize("present", (False, True)) +def test_role_verification(present, write_role_mock): + if present: + write_role_mock("role", "conn", []) + write_role_mock.side_effect = None + ret = vault_db.role_present("role", "conn", ["foo"]) + assert ret["result"] is False + if present: + assert "reported parameters do not match" in ret["comment"] + else: + assert "but it is still reported as absent" in ret["comment"] + + +def test_role_absent(testmode, write_role_mock, delete_role_mock): + write_role_mock("role", "conn", []) + ret = vault_db.role_absent("role") + assert ret["result"] is not False + assert (ret["result"] is None) is testmode + assert ret["changes"] + assert "deleted" in ret["changes"] + assert "deleted" in ret["comment"] + assert ("would have been" in ret["comment"]) is testmode + assert (delete_role_mock.call_count == 0) is testmode + + +def test_role_already_absent(delete_role_mock): + ret = vault_db.role_absent("role") + assert ret["result"] is True + assert not ret["changes"] + assert "already absent" in ret["comment"] + delete_role_mock.assert_not_called() + + +@pytest.mark.parametrize("testmode", (False,), indirect=True) +def test_role_absent_verification(write_role_mock, delete_role_mock): + write_role_mock("role", "conn", []) + delete_role_mock.side_effect = None + ret = vault_db.role_absent("role") + assert ret["result"] is False + assert "but it is still reported as present" in ret["comment"] + + +def test_static_role_present(testmode, write_static_role_mock): + ret = vault_db.static_role_present("role", "conn", "user", 42) + assert ret["result"] is not False + assert (ret["result"] is None) is testmode + assert ret["changes"] + assert "created" in ret["changes"] + assert (write_static_role_mock.call_args is None) is testmode + assert "created" in ret["comment"] + assert ("would have been" in ret["comment"]) is testmode + + +def test_static_role_already_present(write_static_role_mock): + write_static_role_mock("role", "conn", "user", 42) + ret = vault_db.static_role_present("role", "conn", "user", 42) + assert ret["result"] is True + assert not ret["changes"] + assert "as specified" in ret["comment"] + + +@pytest.mark.parametrize( + "kwargs,param", + ( + ({"connection": "conn2"}, "db_name"), + ({"username": "bar"}, None), + ({"rotation_period": 43}, None), + ), +) +def test_static_role_changes(testmode, write_static_role_mock, kwargs, param, _roles): + write_static_role_mock("role", "conn", "user", 42) + kwargs = kwargs.copy() + param = param or next(iter(kwargs)) + expected_changes = {"old": _roles["role"][param], "new": kwargs[next(iter(kwargs))]} + conn = kwargs.pop("connection", "conn") + username = kwargs.pop("username", "user") + rotation_period = kwargs.pop("rotation_period", 42) + ret = vault_db.static_role_present("role", conn, username, rotation_period, **kwargs) + assert ret["result"] is not False + assert (ret["result"] is None) is testmode + assert ret["changes"] + assert param in ret["changes"] + assert ret["changes"][param] == expected_changes + assert (write_static_role_mock.call_count == 1) is testmode + assert "updated" in ret["comment"] + assert ("would have been" in ret["comment"]) is testmode + + +def test_static_role_changes_strip(write_static_role_mock): + write_static_role_mock("role", "conn", "user", 42, rotation_statements=["foo"]) + ret = vault_db.static_role_present("role", "conn", "user", 42, rotation_statements=["foo\n"]) + assert ret["result"] is True + assert not ret["changes"] + + +def test_static_role_statements_as_strings(write_static_role_mock): + write_static_role_mock("role", "conn", "user", 42, rotation_statements=["foo"]) + ret = vault_db.static_role_present("role", "conn", "user", 42, rotation_statements="foo") + assert ret["result"] is True + assert not ret["changes"] + + +@pytest.mark.parametrize("testmode", (False,), indirect=True) +@pytest.mark.parametrize("present", (False, True)) +def test_static_role_verification(present, write_static_role_mock): + if present: + write_static_role_mock("role", "conn", "user", 42) + write_static_role_mock.side_effect = None + ret = vault_db.static_role_present("role", "conn", "user", 43) + assert ret["result"] is False + if present: + assert "reported parameters do not match" in ret["comment"] + else: + assert "but it is still reported as absent" in ret["comment"] diff --git a/tests/unit/utils/vault/test_leases.py b/tests/unit/utils/vault/test_leases.py index c988eac6..b822f7df 100644 --- a/tests/unit/utils/vault/test_leases.py +++ b/tests/unit/utils/vault/test_leases.py @@ -599,6 +599,8 @@ def test_list_info(self, store_multi, lease): ret = store_multi.list_info() assert set(ret) == {"test_1", "test_12", "test_3"} lease.pop("data") + lease["expires_in"] = 1337 + lease["expired"] = False assert ret["test_1"] == lease assert ret["test_12"]["lease_id"] == "foobar" assert ret["test_3"]["lease_id"] == "barbaz" @@ -610,6 +612,8 @@ def test_list_info_match(self, store_multi, lease): ret = store_multi.list_info(match="test_1*") assert set(ret) == {"test_1", "test_12"} lease.pop("data") + lease["expires_in"] = 1337 + lease["expired"] = False assert ret["test_1"] == lease assert ret["test_12"]["lease_id"] == "foobar" @@ -623,6 +627,8 @@ def test_list_info_expired(self, store_multi, lease): ret = store_multi.list_info(match="test_1*") assert set(ret) == {"test_1"} lease.pop("data") + lease["expires_in"] = 1337 + lease["expired"] = False assert ret["test_1"] == lease store_multi.cache.get.assert_called_with("test_12", flush=False)