Skip to content

Commit

Permalink
Secret (version) handling improvements (draft)
Browse files Browse the repository at this point in the history
  • Loading branch information
lkubb committed Jul 23, 2024
1 parent 9d22bc7 commit 91cea66
Show file tree
Hide file tree
Showing 7 changed files with 369 additions and 30 deletions.
1 change: 1 addition & 0 deletions docs/ref/states/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ _____________
vault
vault_db
vault_pki
vault_secret
5 changes: 5 additions & 0 deletions docs/ref/states/saltext.vault.states.vault_secret.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
``vault_secret``
================

.. automodule:: saltext.vault.states.vault_secret
:members:
108 changes: 101 additions & 7 deletions src/saltext/vault/modules/vault.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path>.
Requires KV v2.
CLI Example:
.. code-block:: bash
salt '*' vault.read_secret_meta salt/kv/secret
Required policy:
.. code-block:: vaultpolicy
path "<mount>/metadata/<secret>" {
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 <path>.
Expand Down Expand Up @@ -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 <path>. If <path> is on KV v2, the secret will be soft-deleted.
Expand All @@ -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:
Expand All @@ -228,64 +261,125 @@ def delete_secret(path, *args):
}
# KV v2 versions
# all_versions=True additionally requires the policy for vault.read_secret_meta
path "<mount>/delete/<secret>" {
capabilities = ["update"]
}
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 <path>. Only supported on Vault KV v2.
CLI Example:
.. 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 "<mount>/destroy/<secret>" {
capabilities = ["update"]
}
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 <path>.
Requires KV v2.
CLI Example:
.. code-block:: bash
salt '*' vault.wipe_secret "secret/my/secret"
Required policy:
.. code-block:: vaultpolicy
path "<mount>/metadata/<secret>" {
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 <path>. The path should end with a trailing slash.
Expand Down
141 changes: 141 additions & 0 deletions src/saltext/vault/states/vault_secret.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
"""
Manage Vault KV v1/v2 secrets statefully.
.. versionadded:: 1.2.0
.. important::
This module requires the general :ref:`Vault setup <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
Loading

0 comments on commit 91cea66

Please sign in to comment.