diff --git a/docs/ref/states/index.rst b/docs/ref/states/index.rst index 9d637f9..7061847 100644 --- a/docs/ref/states/index.rst +++ b/docs/ref/states/index.rst @@ -12,3 +12,4 @@ _____________ vault vault_db vault_pki + vault_secret diff --git a/docs/ref/states/saltext.vault.states.vault_secret.rst b/docs/ref/states/saltext.vault.states.vault_secret.rst new file mode 100644 index 0000000..9ad3e34 --- /dev/null +++ b/docs/ref/states/saltext.vault.states.vault_secret.rst @@ -0,0 +1,5 @@ +``vault_secret`` +================ + +.. automodule:: saltext.vault.states.vault_secret + :members: diff --git a/src/saltext/vault/modules/vault.py b/src/saltext/vault/modules/vault.py index 2c031de..d5d7531 100644 --- a/src/saltext/vault/modules/vault.py +++ b/src/saltext/vault/modules/vault.py @@ -74,6 +74,38 @@ def read_secret(path, key=None, metadata=False, default=NOT_SET): return default +def read_secret_meta(path): + """ + .. versionadded:: 1.2.0 + + Return secret metadata and versions for . + Requires KV v2. + + CLI Example: + + .. code-block:: bash + + salt '*' vault.read_secret_meta salt/kv/secret + + Required policy: + + .. code-block:: vaultpolicy + + path "/metadata/" { + capabilities = ["read"] + } + + path + The path to the secret, including mount. + """ + log.debug("Reading Vault secret metadata for %s at %s", __grains__.get("id"), path) + try: + return vault.read_kv_meta(path, __opts__, __context__) + except Exception as err: # pylint: disable=broad-except + log.error("Failed to read secret metadata! %s: %s", type(err).__name__, err) + return False + + def write_secret(path, **kwargs): """ Set secret dataset at . @@ -203,7 +235,7 @@ def patch_secret(path, **kwargs): return False -def delete_secret(path, *args): +def delete_secret(path, *args, **kwargs): """ Delete secret at . If is on KV v2, the secret will be soft-deleted. @@ -213,6 +245,7 @@ def delete_secret(path, *args): salt '*' vault.delete_secret "secret/my/secret" salt '*' vault.delete_secret "secret/my/secret" 1 2 3 + salt '*' vault.delete_secret "secret/my/secret" all_versions=true Required policy: @@ -228,6 +261,7 @@ def delete_secret(path, *args): } # KV v2 versions + # all_versions=True additionally requires the policy for vault.read_secret_meta path "/delete/" { capabilities = ["update"] } @@ -235,22 +269,35 @@ def delete_secret(path, *args): path The path to the secret, including mount. + all_versions + .. versionadded:: 1.2.0 + + Delete all versions of the secret for KV v2. + Can only be passed as a keyword argument. + Defaults to false. + .. versionadded:: 1.0.0 For KV v2, you can specify versions to soft-delete as supplemental positional arguments. """ + all_versions = kwargs.pop("all_versions", False) + unknown_kwargs = tuple(x for x in kwargs if not x.startswith("_")) + if unknown_kwargs: + raise SaltInvocationError(f"Passed unknown keyword arguments: {' '.join(unknown_kwargs)}") log.debug("Deleting vault secrets for %s in %s", __grains__.get("id"), path) if args: log.debug(f"Affected versions: {' '.join(str(x) for x in args)}") try: - return vault.delete_kv(path, __opts__, __context__, versions=list(args) or None) + return vault.delete_kv( + path, __opts__, __context__, versions=list(args) or None, all_versions=all_versions + ) except Exception as err: # pylint: disable=broad-except log.error("Failed to delete secret! %s: %s", type(err).__name__, err) return False -def destroy_secret(path, *args): +def destroy_secret(path, *args, **kwargs): """ Destroy specified secret versions at . Only supported on Vault KV v2. @@ -258,12 +305,16 @@ def destroy_secret(path, *args): .. code-block:: bash + salt '*' vault.destroy_secret "secret/my/secret" salt '*' vault.destroy_secret "secret/my/secret" 1 2 + salt '*' vault.destroy_secret "secret/my/secret" all_versions=true Required policy: .. code-block:: vaultpolicy + # all_versions=True or defaulting to the most recent version additionally + # requires the policy for vault.read_secret_meta path "/destroy/" { capabilities = ["update"] } @@ -271,21 +322,64 @@ def destroy_secret(path, *args): path The path to the secret, including mount. + all_versions + .. versionadded:: 1.2.0 + + Delete all versions of the secret for KV v2. + Can only be passed as a keyword argument. + Defaults to false. + You can specify versions to destroy as supplemental positional arguments. - At least one is required. + + .. versionchanged:: 1.2.0 + + If no version was specified, defaults to the most recent one. """ - if not args: - raise SaltInvocationError("Need at least one version to destroy.") + all_versions = kwargs.pop("all_versions", False) + unknown_kwargs = tuple(x for x in kwargs if not x.startswith("_")) + if unknown_kwargs: + raise SaltInvocationError(f"Passed unknown keyword arguments: {' '.join(unknown_kwargs)}") log.debug("Destroying vault secrets for %s in %s", __grains__.get("id"), path) if args: log.debug(f"Affected versions: {' '.join(str(x) for x in args)}") try: - return vault.destroy_kv(path, list(args), __opts__, __context__) + return vault.destroy_kv( + path, list(args) or None, __opts__, __context__, all_versions=all_versions + ) except Exception as err: # pylint: disable=broad-except log.error("Failed to destroy secret! %s: %s", type(err).__name__, err) return False +def wipe_secret(path): + """ + .. versionadded:: 1.2.0 + + Remove all version history and data for the secret at . + Requires KV v2. + + CLI Example: + + .. code-block:: bash + + salt '*' vault.wipe_secret "secret/my/secret" + + Required policy: + + .. code-block:: vaultpolicy + + path "/metadata/" { + capabilities = ["delete"] + } + """ + log.debug("Wiping vault secrets for %s in %s", __grains__.get("id"), path) + try: + return vault.wipe_kv(path, __opts__, __context__) + except Exception as err: # pylint: disable=broad-except + log.error("Failed to wipe secret! %s: %s", type(err).__name__, err) + return False + + def list_secrets(path, default=NOT_SET, keys_only=None): """ List secret keys at . The path should end with a trailing slash. diff --git a/src/saltext/vault/states/vault_secret.py b/src/saltext/vault/states/vault_secret.py new file mode 100644 index 0000000..717bfea --- /dev/null +++ b/src/saltext/vault/states/vault_secret.py @@ -0,0 +1,141 @@ +""" +Manage Vault KV v1/v2 secrets statefully. + +.. versionadded:: 1.2.0 + +.. important:: + This module requires the general :ref:`Vault setup `. +""" + +import copy +import logging + +from salt.exceptions import CommandExecutionError +from salt.exceptions import SaltException +from salt.exceptions import SaltInvocationError + +log = logging.getLogger(__name__) + + +def present(name, values, sync=False): + """ + Ensure a secret is present as specified. + Does not report a diff. + + name + The path of the secret. + + values + A mapping of values the secret should expose. + + sync + Ensure the secret only exposes ``values`` and delete unspecified ones. + Defaults to false, which results in patching (merging over) existing data + and deleting keys that are set to ``None``/``null``. For details, see + https://datatracker.ietf.org/doc/html/draft-ietf-appsawg-json-merge-patch-07 + """ + # TODO: manage KV v2 metadata? + ret = { + "name": name, + "result": True, + "comment": "The secret is already present as specified", + "changes": {}, + } + try: + try: + current = __salt__["vault.read_secret"](name) + except CommandExecutionError as err: + # VaultNotFoundError should be subclassed to + # CommandExecutionError and not re-raised by the + # execution module @FIXME? + if "VaultNotFoundError" not in str(err): + raise + current = None + else: + if sync: + if current == values: + return ret + else: + + def apply_json_merge_patch(data, patch): + if not patch: + return data + if not isinstance(data, dict) or not isinstance(patch, dict): + raise ValueError("Data and patch must be dictionaries.") + + for key, value in patch.items(): + if value is None: + data.pop(key, None) + elif isinstance(value, dict): + data[key] = apply_json_merge_patch(data.get(key, {}), value) + else: + data[key] = value + return data + + new = apply_json_merge_patch(copy.deepcopy(current), values) + if new == current: + return ret + verb = "patch" if current is not None and not sync else "write" + pp = "patched" if verb == "patch" else "written" + ret["changes"][pp] = name + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f"Would have {pp} the secret" + return ret + if not __salt__[f"vault.{verb}_secret"](name, **values): + # Only read_secret raises exceptions sadly FIXME? + raise CommandExecutionError(f"Failed to {verb} secret, see logs for details") + ret["comment"] = f"The secret was {pp}" + except SaltException as err: + ret["result"] = False + ret["comment"] = str(err) + ret["changes"] = {} + return ret + + +def absent(name, operation="delete"): + """ + Ensure a secret is absent. This operates only on the most recent version + for delete/destroy. Currently does not destroy/wipe a secret that has + been made unreadable in some other way. + + name + The path of the secret. + + operation + The operation to perform to remove the secret. Only relevant for KV v2. + Options are: ``delete`` (meaning: soft-delete), ``destroy`` (meaning delete unrecoverably) + and ``wipe`` (forget about the secret completely). Defaults to ``delete``. + KV v1 secrets are always wiped since the backend does not support versioning. + """ + valid_ops = ("delete", "destroy", "wipe") + if operation not in valid_ops: + raise SaltInvocationError(f"Invalid operation '{operation}'. Valid: {', '.join(valid_ops)}") + ret = { + "name": name, + "result": True, + "comment": "The secret is already absent", + "changes": {}, + } + pp = "deleted" if operation == "delete" else operation + "ed" + try: + try: + __salt__["vault.read_secret"](name) + except CommandExecutionError as err: + if "VaultNotFoundError" not in str(err): + raise + return ret + ret["changes"][pp] = name + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f"Would have {pp} the secret" + return ret + if not __salt__[f"vault.{operation}_secret"](name): + # Only read_secret raises exceptions sadly FIXME? + raise CommandExecutionError(f"Failed to {operation} secret, see logs for details") + ret["comment"] = f"The secret has been {pp}" + except SaltException as err: + ret["result"] = False + ret["comment"] = str(err) + ret["changes"] = {} + return ret diff --git a/src/saltext/vault/utils/vault/__init__.py b/src/saltext/vault/utils/vault/__init__.py index b5f779b..cea660b 100644 --- a/src/saltext/vault/utils/vault/__init__.py +++ b/src/saltext/vault/utils/vault/__init__.py @@ -201,6 +201,26 @@ def read_kv(path, opts, context, include_metadata=False): return kv.read(path, include_metadata=include_metadata) +def read_kv_meta(path, opts, context): + """ + Read secret metadata and version info at . + Requires KV v2. + + .. versionadded:: 1.2.0 + """ + kv, config = get_kv(opts, context, get_config=True) + try: + return kv.read_meta(path) + except VaultPermissionDeniedError: + if not _check_clear(config, kv.client): + raise + + # in case policies have changed + clear_cache(opts, context) + kv = get_kv(opts, context) + return kv.read_meta(path) + + def write_kv(path, data, opts, context): """ Write secret to . @@ -240,14 +260,14 @@ def patch_kv(path, data, opts, context): return kv.patch(path, data) -def delete_kv(path, opts, context, versions=None): +def delete_kv(path, opts, context, versions=None, all_versions=False): """ Delete secret at . For KV v2, versions can be specified, which will be soft-deleted. """ kv, config = get_kv(opts, context, get_config=True) try: - return kv.delete(path, versions=versions) + return kv.delete(path, versions=versions, all_versions=all_versions) except VaultPermissionDeniedError: if not _check_clear(config, kv.client): raise @@ -255,16 +275,36 @@ def delete_kv(path, opts, context, versions=None): # in case policies have changed clear_cache(opts, context) kv = get_kv(opts, context) - return kv.delete(path, versions=versions) + return kv.delete(path, versions=versions, all_versions=all_versions) -def destroy_kv(path, versions, opts, context): +def destroy_kv(path, versions, opts, context, all_versions=False): """ Destroy secret at . Requires KV v2. """ kv, config = get_kv(opts, context, get_config=True) try: - return kv.destroy(path, versions) + return kv.destroy(path, versions, all_versions=all_versions) + except VaultPermissionDeniedError: + if not _check_clear(config, kv.client): + raise + + # in case policies have changed + clear_cache(opts, context) + kv = get_kv(opts, context) + return kv.destroy(path, versions, all_versions=all_versions) + + +def wipe_kv(path, opts, context): + """ + Completely remove all version history and data at . + Requires KV v2. + + .. versionadded:: 1.2.0 + """ + kv, config = get_kv(opts, context, get_config=True) + try: + return kv.nuke(path) except VaultPermissionDeniedError: if not _check_clear(config, kv.client): raise @@ -272,7 +312,7 @@ def destroy_kv(path, versions, opts, context): # in case policies have changed clear_cache(opts, context) kv = get_kv(opts, context) - return kv.destroy(path, versions) + return kv.wipe(path) def list_kv(path, opts, context): diff --git a/src/saltext/vault/utils/vault/kv.py b/src/saltext/vault/utils/vault/kv.py index 77fde87..ded1476 100644 --- a/src/saltext/vault/utils/vault/kv.py +++ b/src/saltext/vault/utils/vault/kv.py @@ -6,6 +6,7 @@ from saltext.vault.utils.vault.exceptions import VaultException from saltext.vault.utils.vault.exceptions import VaultInvocationError +from saltext.vault.utils.vault.exceptions import VaultNotFoundError from saltext.vault.utils.vault.exceptions import VaultPermissionDeniedError from saltext.vault.utils.vault.exceptions import VaultUnsupportedOperationError @@ -38,6 +39,19 @@ def read(self, path, include_metadata=False): return ret["data"] return ret + def read_meta(self, path): + """ + Read secret metadata for all versions at path. This is different from + the metadata returned by read, which pertains only to the most recent + version. Requires KV v2. + + .. versionadded:: 1.2.0 + """ + v2_info = self.is_v2(path) + if not v2_info["v2"]: + raise VaultInvocationError("The backend is not KV v2") + return self.client.get(v2_info["metadata"])["data"] + def write(self, path, data): """ Write secret data to path. @@ -92,7 +106,7 @@ def patch_in_memory(path, data): pass return patch_in_memory(path, data) - def delete(self, path, versions=None): + def delete(self, path, versions=None, all_versions=False): """ Delete secret path data. For KV v1, this is permanent. For KV v2, this only soft-deletes the data. @@ -100,12 +114,33 @@ def delete(self, path, versions=None): versions For KV v2, specifies versions to soft-delete. Needs to be castable to a list of integers. + + all_versions + For KV v2, delete all known versions. Defaults to false. + + .. versionadded:: 1.2.0 + """ method = "DELETE" payload = None - versions = self._parse_versions(versions) v2_info = self.is_v2(path) + if all_versions and v2_info["v2"]: + versions = [] + try: + curr = self.read_meta(path) + except VaultNotFoundError: + # The delete API behaves the same + return True + else: + for version, meta in curr["versions"].items(): + if not meta["destroyed"] and not meta["deletion_time"]: + versions.append(version) + if not versions: + # No version left to delete + return True + versions = self._parse_versions(versions) + if v2_info["v2"]: if versions is not None: method = "POST" @@ -119,18 +154,46 @@ def delete(self, path, versions=None): return self.client.request(method, path, payload=payload) - def destroy(self, path, versions): + def destroy(self, path, versions=None, all_versions=False): """ Permanently remove version data. Requires KV v2. versions Specifies versions to destroy. Needs to be castable to a list of integers. + + .. versionchanged:: 1.2.0 + If unspecified, destroys the most recent version. + + all_versions + Destroy all versions of the secret. Defaults to false. + + .. versionadded:: 1.2.0 """ - versions = self._parse_versions(versions) v2_info = self.is_v2(path) if not v2_info["v2"]: raise VaultInvocationError("Destroy operation requires KV v2.") + if all_versions or not versions: + versions = [] + try: + curr = self.read_meta(path)["versions"] + except VaultNotFoundError: + # The destroy API behaves the same + return True + else: + if all_versions: + for version, meta in curr.items(): + if not meta["destroyed"]: + versions.append(version) + else: + most_recent = str(max(int(x) for x in curr)) + if not curr[most_recent]["destroyed"]: + versions = [most_recent] + if not versions: + # No version left to destroy + return True + + versions = self._parse_versions(versions) path = v2_info["destroy"] payload = {"versions": versions} return self.client.post(path, payload=payload) @@ -153,7 +216,7 @@ def nuke(self, path): """ v2_info = self.is_v2(path) if not v2_info["v2"]: - raise VaultInvocationError("Nuke operation requires KV v2.") + raise VaultInvocationError("Wipe operation requires KV v2.") path = v2_info["metadata"] return self.client.delete(path) diff --git a/tests/unit/modules/test_vault.py b/tests/unit/modules/test_vault.py index 4c0317a..865be0e 100644 --- a/tests/unit/modules/test_vault.py +++ b/tests/unit/modules/test_vault.py @@ -247,7 +247,9 @@ def test_delete_secret(delete_kv, args): path = "secret/some/path" res = vault.delete_secret(path, *args) assert res - delete_kv.assert_called_once_with(path, opts=ANY, context=ANY, versions=args or None) + delete_kv.assert_called_once_with( + path, opts=ANY, context=ANY, versions=args or None, all_versions=False + ) @pytest.mark.usefixtures("delete_kv_err") @@ -262,7 +264,7 @@ def test_delete_secret_err(args, caplog): assert "Failed to delete secret! VaultPermissionDeniedError: damn" in caplog.messages -@pytest.mark.parametrize("args", [[1], [1, 2]]) +@pytest.mark.parametrize("args", [[], [1], [1, 2]]) def test_destroy_secret(destroy_kv, args): """ Ensure destroy_secret works as expected @@ -270,16 +272,9 @@ def test_destroy_secret(destroy_kv, args): path = "secret/some/path" res = vault.destroy_secret(path, *args) assert res - destroy_kv.assert_called_once_with(path, args, opts=ANY, context=ANY) - - -@pytest.mark.usefixtures("destroy_kv") -def test_destroy_secret_requires_version(): - """ - Ensure destroy_secret requires at least one version - """ - with pytest.raises(salt.exceptions.SaltInvocationError, match=".*at least one version.*"): - vault.destroy_secret("secret/some/path") + destroy_kv.assert_called_once_with( + path, args or None, opts=ANY, context=ANY, all_versions=False + ) @pytest.mark.usefixtures("destroy_kv_err")