"""
Manage the Vault PKI secret engine and Vault-issued X.509 certificates.
.. versionadded:: 1.1.0
.. important::
This module requires the general :ref:`Vault setup <vault-setup>`.
"""
import base64
import logging
import os
import salt.utils.files
from salt.exceptions import CommandExecutionError
from salt.exceptions import SaltInvocationError
from saltext.vault.utils.vault.helpers import filter_state_internal_kwargs
from saltext.vault.utils.vault.helpers import timestring_map
from saltext.vault.utils.vault.pki import check_cert_for_changes
try:
import salt.utils.x509 as x509util
HAS_CRYPTOGRAPHY = True
except ImportError:
HAS_CRYPTOGRAPHY = False
log = logging.getLogger(__name__)
__virtualname__ = "vault_pki"
def __virtual__():
if "x509.encode_certificate" not in __salt__:
return (
False,
"x509_v2 needs to be explicitly enabled by setting `x509_v2: true` "
"in the minion configuration value `features` until Salt 3008 (Argon).",
)
if not HAS_CRYPTOGRAPHY:
return (False, "Could not load cryptography")
return __virtualname__
VALID_FILE_ARGS = (
"user",
"group",
"mode",
"attrs",
"makedirs",
"dir_mode",
"backup",
"create",
"follow_symlinks",
"check_cmd",
"tmp_dir",
"tmp_ext",
"selinux",
"file_encoding",
"encoding_errors",
"win_owner",
"win_perms",
"win_deny_perms",
"win_inheritance",
"win_perms_reset",
)
[docs]
def certificate_managed(
name,
common_name,
role_name,
private_key,
mount="pki",
ttl="720h",
ttl_remaining="168h",
issuer_ref=None,
encoding="pem",
append_ca_chain=False,
sign_verbatim=False,
private_key_passphrase=None,
reissue=False,
**kwargs,
):
"""
Ensure an X.509 certificate is present as specified.
.. note::
The state can use ``sign-verbatim`` endpoint of Vault in which case CSR subject is fully
translated. If not used, anything from CSR subject, except CN is ignored.
Check `this issue <https://github.com/hashicorp/vault/issues/20719>`__ for more information.
Required policy:
.. code-block:: vaultpolicy
# Need to read the role configuration in case of missing issuer_ref
path "{mount}/roles/*" {
capabilities = ["read"]
}
path "{mount}/issuer/{issuer_ref}/sign/{role_name}" {
capabilities = ["update"]
}
# in case of sign_verbatim
path "{mount}/issuer/{issuer_ref}/sign-verbatim/{role_name}" {
capabilities = ["update"]
}
name
Path to the certificate file.
common_name
Common name to be set for the certificate.
role_name
PKI role to be used for issuing the certificate from Vault.
private_key
Path or PEM formatted text of the private key used to sign CSR for the certificate.
mount
The mount path the PKI backend is mounted to. Defaults to ``pki``.
ttl
Specifies the Time To Live value to be used for the validity period of the requested certificate,
provided as a string duration with time suffix. Hour is the largest suffix. Defaults to ``720h`` or 30 days.
ttl_remaining
Specifies the Time To Live value to be used for checking remaining period before expiration
after which certificate should be renewed.
Provided as a string duration with time suffix. Hour is the largest suffix. Defaults to ``168h`` or 7 days.
issuer_ref
Override role's issuer for the certificate. Defaults to the one specified in the role.
encoding
Encoding to be used for the certificate file. Valid options are ``pem``, ``pkcs7_pem``, ``der``, ``pkcs7_der``. Defaults to ``pem``.
append_ca_chain
If set to true will append CA chain to the certificate. Defaults to ``false``.
.. note::
This will append all CA certificates except self-signed (as they shouldn't be in the chain anyway)!
sign_verbatim
If set to true, the resulting certificate follows the CSR exactly.
Otherwise, only ``CN`` can be set for the subject, any other subject parameters (like ``O``) are ignored.
.. warning::
This option is using a potentially dangerous endpoint. Be careful when using that option, as roles
are not restricting what can be issued anymore.
private_key_passphrase
Password for the private key if encrypted.
reissue
Always reissue the certificate. Defaults to ``false``.
kwargs
Any other parameter accepted by ``file_managed`` execution module or Vault PKI
:obj:`sign_certificate <saltext.vault.modules.vault_pki.sign_certificate>` execution module.
"""
ret = {
"name": name,
"changes": {},
"result": True,
"comment": "The certificate is in the correct state",
}
changes = {}
ca_chain = []
verb = "create"
file_args, cert_args = _split_file_kwargs(filter_state_internal_kwargs(kwargs))
try:
if encoding not in ["der", "pem", "pkcs7_der", "pkcs7_pem"]:
raise SaltInvocationError(
f"Invalid value '{encoding}' for encoding. Valid: der, pem, pkcs7_der, pkcs7_pem"
)
if timestring_map(ttl_remaining, cast=int) >= timestring_map(ttl, cast=int):
raise SaltInvocationError("The ttl_remaning cannot be larger or equal to ttl.")
# check file.managed changes early to avoid using unnecessary resources
file_managed_test = _file_managed(name, test=True, replace=False, **file_args)
if file_managed_test["result"] is False:
ret["result"] = False
ret["comment"] = "Problem while testing file.managed changes, see its output"
_add_sub_state_run(ret, file_managed_test)
return ret
if "is not present and is not set for creation" in file_managed_test["comment"]:
_add_sub_state_run(ret, file_managed_test)
return ret
# handle follow_symlinks
if __salt__["file.is_link"](name):
if file_args.get("follow_symlinks", True):
name = os.path.realpath(name)
else:
# workaround https://github.com/saltstack/salt/issues/31802
__salt__["file.remove"](name)
changes["replaced"] = True
replace = False
file_exists = __salt__["file.file_exists"](name)
if issuer_ref is None:
issuer_ref = __salt__["vault_pki.read_role"](role_name, mount=mount)["issuer_ref"]
if issuer_ref is None:
raise CommandExecutionError(f"role {role_name} does not exists.")
issuer_info = __salt__["vault_pki.read_issuer"](issuer_ref, mount=mount)
if append_ca_chain:
ca_chain = [x509util.load_cert(x) for x in issuer_info["ca_chain"]]
if file_exists:
if reissue:
# No need to make any checks, just replace the cert
changes["replaced"] = True
else:
changes = check_cert_for_changes(
current=name,
append_chain=ca_chain,
common_name=common_name,
encoding=encoding,
issuer=issuer_info["certificate"],
private_key=private_key,
private_key_passphrase=private_key_passphrase,
common_name_only=not sign_verbatim,
expire_tolerance=ttl_remaining,
**cert_args,
)
else:
changes["created"] = True
if not changes and file_managed_test["result"] and not file_managed_test["changes"]:
_add_sub_state_run(ret, file_managed_test)
return ret
ret["changes"] = changes
if changes and file_exists:
verb = "reissue"
if __opts__["test"]:
ret["result"] = None if changes else True
ret["comment"] = (
f"The certificate would have been {verb}d" if changes else ret["comment"]
)
_add_sub_state_run(ret, file_managed_test)
return ret
if changes:
if not set(changes) - {
"ca_chain",
"encoding",
}:
verb = "recreate"
cert = __salt__["x509.encode_certificate"](
name,
append_certs=ca_chain,
encoding=encoding,
)
else:
issued_cert = __salt__["vault_pki.sign_certificate"](
common_name=common_name,
role_name=role_name,
private_key=private_key,
private_key_passphrase=private_key_passphrase,
ttl=ttl,
issuer_ref=issuer_ref,
mount=mount,
sign_verbatim=sign_verbatim,
remove_roots_from_chain=False,
**cert_args,
)
cert = __salt__["x509.encode_certificate"](
issued_cert["certificate"],
append_certs=ca_chain if encoding in ["pem", "pkcs7_pem"] else [],
encoding=encoding,
)
ret["comment"] = f"The certificate has been {verb}d"
if encoding not in ["pem", "pkcs7_pem"]:
# file.managed does not support binary contents, so create
# an empty file first (makedirs). This will not work with check_cmd!
file_managed_ret = _file_managed(name, replace=False, **file_args)
_add_sub_state_run(ret, file_managed_ret)
if not _check_file_ret(file_managed_ret, ret, name):
return ret
_safe_atomic_write(name, base64.b64decode(cert), file_args.get("backup", ""))
if not changes or encoding in ["pem", "pkcs7_pem"]:
replace = bool(encoding in ["pem", "pkcs7_pem"] and changes)
contents = cert if replace else None
file_managed_ret = _file_managed(name, contents=contents, replace=replace, **file_args)
_add_sub_state_run(ret, file_managed_ret)
if not _check_file_ret(file_managed_ret, ret, file_exists):
return ret
except (CommandExecutionError, SaltInvocationError) as err:
ret["result"] = False
ret["comment"] = str(err)
ret["changes"] = {}
return ret
[docs]
def role_managed(name, mount="pki", issuer_ref=None, ttl=None, max_ttl=None, **kwargs):
"""
Ensures PKI role is present and configured as required.
name
The name of the role.
mount
The mount path the PKI backend is mounted to. Defaults to ``pki``.
issuer_ref
Issuer reference for the role. Can be name, id or literal ``default``.
ttl
Specifies the Time To Live value to be used for the validity period of the requested certificate,
provided as a string duration with time suffix. Hour is the largest suffix.
The value specified is strictly used for future validity.
If not set, uses the system default value or the value of ``max_ttl``, whichever is shorter.
max_ttl
Specifies the maximum Time To Live provided as a string duration with time suffix.
Hour is the largest suffix. If not set, defaults to the system maximum lease TTL.
kwargs
Any other parameter accepted by Vault
:obj:`write_role <saltext.vault.modules.vault_pki.write_role>` execution module or Vault update role API method.
"""
ret = {
"name": name,
"result": True,
"comment": "The role is present as specified",
"changes": {},
}
kwargs = {k: v for k, v in kwargs.items() if not k.startswith("_")}
def _diff_params(current):
nonlocal issuer_ref, ttl, max_ttl, kwargs
diff_params = (
("issuer_ref", issuer_ref),
("ttl", timestring_map(ttl, cast=int)),
("max_ttl", timestring_map(max_ttl, cast=int)),
)
changed = {}
for param, arg in diff_params:
if arg is None:
continue
if current[param] != arg:
changed.update(
{
param: {
"old": current.get(param),
"new": arg,
}
}
)
for param, arg in kwargs.items():
if param in current and current[param] != arg:
changed.update(
{
param: {
"old": current.get(param),
"new": arg,
}
}
)
return changed
changes = {}
try:
current = __salt__["vault_pki.read_role"](name, mount=mount)
if current:
changes = _diff_params(current)
if not changes:
return ret
ret["changes"].update(changes)
if not current:
ret["changes"]["created"] = name
if __opts__["test"]:
ret["result"] = None
ret["comment"] = (
f"PKI role `{name}` would have been {'updated' if current else 'created'}"
)
return ret
__salt__["vault_pki.write_role"](
name=name, mount=mount, issuer_ref=issuer_ref, ttl=ttl, max_ttl=max_ttl, **kwargs
)
ret["comment"] = f"PKI 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
[docs]
def role_absent(name, mount="pki"):
"""
Ensure PKI role is absent.
name
The name of the role.
mount
The mount path the PKI backend is mounted to. Defaults to ``pki``.
"""
ret = {
"name": name,
"result": True,
"comment": "",
"changes": {},
}
try:
current = __salt__["vault_pki.read_role"](name, 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"PKI role `{name}` would have been deleted"
return ret
__salt__["vault_pki.delete_role"](name, mount=mount)
ret["comment"] = f"Connection `{name}` has been deleted."
except CommandExecutionError as err:
ret["result"] = False
ret["comment"] = str(err)
ret["changes"] = {}
return ret
def _split_file_kwargs(kwargs):
file_args = {"show_changes": False}
extra_args = {}
for k, v in kwargs.items():
if k in VALID_FILE_ARGS:
file_args[k] = v
else:
extra_args[k] = v
if "file_encoding" in file_args:
file_args["encoding"] = file_args.pop("file_encoding")
return file_args, extra_args
def _add_sub_state_run(ret, sub):
sub["low"] = {
"name": ret["name"],
"state": "file",
"__id__": __low__["__id__"],
"fun": "managed",
}
if "sub_state_run" not in ret:
ret["sub_state_run"] = []
ret["sub_state_run"].append(sub)
def _file_managed(name, test=None, **kwargs):
if test not in [None, True]:
raise SaltInvocationError("test param can only be None or True")
# work around https://github.com/saltstack/salt/issues/62590
test = test or __opts__["test"]
res = __salt__["state.single"]("file.managed", name, test=test, **kwargs)
return res[next(iter(res))]
def _safe_atomic_write(dst, data, backup):
"""
Create a temporary file with only user r/w perms and atomically
copy it to the destination, honoring ``backup``.
"""
tmp = salt.utils.files.mkstemp(prefix=salt.utils.files.TEMPFILE_PREFIX)
with salt.utils.files.fopen(tmp, "wb") as tmp_:
tmp_.write(data)
salt.utils.files.copyfile(
tmp, dst, __salt__["config.backup_mode"](backup), __opts__["cachedir"]
)
salt.utils.files.safe_rm(tmp)
def _check_file_ret(fret, ret, current):
if fret["result"] is False:
ret["result"] = False
ret["comment"] = (
f"Could not {'create' if not current else 'update'} file, see file.managed output"
)
ret["changes"] = {}
return False
return True