"""
Manage the Vault (or OpenBao) SSH secret engine.
.. versionadded:: 1.2.0
.. important::
This module requires the general :ref:`Vault setup <vault-setup>`.
"""
import logging
import salt.utils.dictdiffer
from salt.exceptions import CommandExecutionError
from salt.exceptions import SaltInvocationError
from saltext.vault.utils.vault.helpers import deserialize_csl
from saltext.vault.utils.vault.helpers import timestring_map
log = logging.getLogger(__name__)
LIST_ROLE_PARAMS = (
"allowed_users",
"allowed_critical_options",
"allowed_domains",
"allowed_extensions",
"cidr_list",
"exclude_cidr_list",
)
MAP_ROLE_PARAMS = ("default_critical_options", "default_extensions", "allowed_user_key_lengths")
TIME_ROLE_PARAMS = ("ttl", "max_ttl", "not_before_duration")
[docs]
def ca_present(
name,
private_key=None,
public_key=None,
key_type="ssh-rsa",
key_bits=0,
mount="ssh",
):
"""
Ensure a CA is present on the mount. Note that only one is possible
per mount. This state does not inspect the properties once a CA
has been initialized.
name
Irrelevant.
private_key
Private key part of the SSH CA key pair. Can be a file
local to the minion or a PEM-encoded string.
If this or ``public_key`` is unspecified, generates a pair
on the Vault server.
public_key
Public key part of the SSH CA key pair. Can be a file
local to the minion or a PEM-encoded string.
If this or ``public_key`` is unspecified, generates a pair
on the Vault server.
key_type
Desired key type for the generated SSH CA key when generating
on the Vault server. Valid: ``ssh-rsa`` (default), ``sha2-nistp256``,
``ecdsa-sha2-nistp384``, ``ecdsa-sha2-nistp521``, or ``ssh-ed25519``.
Can also specify an algorithm: ``rsa``, ``ec``, or ``ed25519``.
key_bits
Desired key bits for the generated SSH CA key when generating
on the Vault server. Only used for variable length keys (e.g. ``ssh-rsa``)
or when ``ec`` was specified as ``key_type``, in which case this
selects the NIST P-curve: ``256``, ``384``, ``521``.
0 (default) selects 4096 bits for RSA or NIST P-256 for EC.
mount
Name of the mount point the SSH secret backend is mounted at.
Defaults to ``ssh``.
"""
ret = {
"name": name,
"result": True,
"comment": "The CA has already been initialized",
"changes": {},
}
try:
try:
current = __salt__["vault_ssh.read_ca"](mount=mount)
except CommandExecutionError as err:
if (
"keys haven't been configured yet" not in err.message # Vault
and "no default issuer currently configured" not in err.message # OpenBao
):
raise
current = None
if current:
return ret
if __opts__["test"]:
ret["result"] = None
ret["comment"] = "The CA would have been initialized"
ret["changes"]["created"] = f"SSH CA on mount {mount}"
return ret
ca = __salt__["vault_ssh.create_ca"](
private_key=private_key,
public_key=public_key,
key_type=key_type,
key_bits=key_bits,
mount=mount,
)
ret["comment"] = "The CA has been initialized"
ret["changes"]["created"] = ca
except (CommandExecutionError, SaltInvocationError) as err:
ret["result"] = False
ret["comment"] = str(err)
ret["changes"] = {}
return ret
[docs]
def ca_absent(
name,
mount="ssh",
):
"""
Ensure a CA is absent from the mount. Note that you are
unable to easily recover a destroy private key.
name
Irrelevant.
mount
Name of the mount point the SSH secret backend is mounted at.
Defaults to ``ssh``.
"""
ret = {
"name": name,
"result": True,
"comment": "There is no CA on the mount",
"changes": {},
}
try:
try:
current = __salt__["vault_ssh.read_ca"](mount=mount)
except CommandExecutionError as err:
if (
"keys haven't been configured yet" not in err.message # Vault
and "no default issuer currently configured" not in err.message # OpenBao
):
raise
current = None
if not current:
return ret
if __opts__["test"]:
ret["result"] = None
ret["comment"] = "The CA would have been destroyed"
ret["changes"]["destroyed"] = f"SSH CA on mount {mount}"
return ret
ca = __salt__["vault_ssh.destroy_ca"](mount=mount)
ret["comment"] = "The CA has been destroyed"
ret["changes"]["destroyed"] = ca
except (CommandExecutionError, SaltInvocationError) as err:
ret["result"] = False
ret["comment"] = str(err)
ret["changes"] = {}
return ret
[docs]
def role_present_otp(
name,
default_user,
cidr_list=None,
allowed_users=None,
exclude_cidr_list=None,
port=None,
mount="ssh",
):
"""
Ensure an SSH role (OTP type) is present as specified.
name
Name of the SSH role.
default_user
Default username for which a credential is generated.
Required.
cidr_list
List of CIDR blocks to which the role is applicable.
Required, unless the role is registered as a zero-address role.
allowed_users
List of usernames the client can request under this role.
By default, **any usernames are allowed** (``*``).
The ``default_user`` is always allowed.
exclude_cidr_list
List of CIDR blocks not accepted by the role.
port
Specifies the port number for SSH connections, which is returned to
OTP clients as an informative value. Defaults to ``22``.
mount
Name of the mount point the SSH secret backend is mounted at.
Defaults to ``ssh``.
"""
ret = {
"name": name,
"result": True,
"comment": "The role is present as specified",
"changes": {},
}
try:
port = int(port) if port else None
except TypeError:
ret["result"] = False
ret["comment"] = "'port' must be castable to an integer"
return ret
payload = {
"default_user": default_user,
"cidr_list": cidr_list,
"allowed_users": allowed_users,
"exclude_cidr_list": exclude_cidr_list,
"port": port,
}
return _role_present(name, "otp", ret, mount=mount, **payload)
def _diff_role_params(curr, wanted):
diff = {}
for param, val in wanted.items():
if param in LIST_ROLE_PARAMS:
curr_param = set(deserialize_csl(curr.get(param, [])))
wanted_param = set(deserialize_csl(val or []))
added = wanted_param - curr_param
removed = curr_param - wanted_param
if added or removed:
diff[param] = {"added": list(sorted(added)), "removed": list(sorted(removed))}
continue
if param in MAP_ROLE_PARAMS:
val = val or {}
if param == "allowed_user_key_lengths":
for algo, allowed in val.items():
if isinstance(allowed, int):
val[algo] = [allowed]
else:
val[algo] = deserialize_csl(allowed)
map_diff = salt.utils.dictdiffer.recursive_diff(
curr.get(param, {}), val, ignore_missing_keys=False
)
if map_diff.added() or map_diff.changed() or map_diff.removed():
diff[param] = {
"added": map_diff.added(),
"changed": map_diff.changed(),
"removed": map_diff.removed(),
}
continue
curr_val = curr.get(param)
if param in TIME_ROLE_PARAMS:
curr_val = timestring_map(curr_val)
val = timestring_map(val)
if curr_val != val:
diff[param] = {"old": curr_val, "new": val}
return diff
def _role_present(name, key_type, ret, mount="ssh", **kwargs):
try:
try:
current = __salt__["vault_ssh.read_role"](name, mount=mount)
except CommandExecutionError as err:
if "VaultNotFoundError" not in str(err):
raise
current = None
ret["changes"]["created"] = name
if current:
if current["key_type"] != key_type:
ret["changes"]["key_type"] = {"old": current["key_type"], "new": key_type}
else:
ret["changes"] = _diff_role_params(current, kwargs)
if not ret["changes"]:
return ret
if __opts__["test"]:
ret["result"] = None
ret["comment"] = f"Role `{name}` would have been {'updated' if current else 'created'}"
return ret
__salt__[f"vault_ssh.write_role_{key_type}"](
name,
**kwargs,
mount=mount,
)
try:
new = __salt__["vault_ssh.read_role"](name, mount=mount)
except CommandExecutionError as err:
if "VaultNotFoundError" not in str(err):
raise
raise CommandExecutionError(
"There were no errors during role management, but it is reported as absent."
) from err
new_diff = _diff_role_params(new, kwargs)
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}"
)
else:
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
[docs]
def role_present_ca(
name,
default_user="",
default_user_template=False,
allowed_users=None,
allowed_users_template=False,
allowed_domains=None,
allowed_domains_template=False,
ttl=0,
max_ttl=0,
allowed_critical_options=None,
allowed_extensions=None,
default_critical_options=None,
default_extensions=None,
default_extensions_template=False,
allow_user_certificates=False,
allow_host_certificates=False,
allow_bare_domains=False,
allow_subdomains=False,
allow_user_key_ids=False,
key_id_format="",
allowed_user_key_lengths=None,
algorithm_signer="default",
not_before_duration=30,
mount="ssh",
):
"""
Ensure an SSH role (CA type) is present as specified.
name
Name of the SSH role.
default_user
Default username for which a credential is generated.
When ``default_user_template`` is true, this can contain an identity
template with any prefix or suffix, like ``ssh-{{identity.entity.id}}-user``.
If you wish this to be a valid principal, it must also be in ``allowed_users``.
default_user_template
Allow ``default_users`` to be specified using identity template values.
A non-templated user is also permitted. Defaults to false.
allowed_users
List of usernames the client can request under this role.
By default, none are allowed. Set this to ``*`` to allow any usernames.
If ``allowed_users_template`` is true, this list can contain an
identity template with any prefix or suffix. The ``default_user``
is always allowed.
allowed_users_template
Allow ``allowed_users`` to be specified using identity template values.
Non-templated users are also permitted. Defaults to false.
allowed_domains
List of domains for which a client can request a host certificate.
``*`` allows any domain. See also ``allow_bare_domains`` and ``allow_subdomains``.
allowed_domains_template
Allow ``allowed_domains`` to be specified using identity template values.
Non-templated domains are also permitted. Defaults to false.
ttl
Specifies the Time To Live value provided as a string duration with
time suffix. Hour is the largest suffix. If unset, 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 unset, defaults to the
system maximum lease TTL.
allowed_critical_options
List of critical options that certificates can carry when signed.
If unset (default), allows any option.
allowed_extensions
List of extensions that certificates can carry when signed.
If unset (default), always takes the extensions
from ``default_extensions`` only. If set to ``*``, allows
any extension to be set.
For the list of extensions, take a look at the sshd manual's
AUTHORIZED_KEYS FILE FORMAT section. You should add a ``permit-``
before the name of extension to allow it.
default_critical_options
Map of critical options to their values certificates should carry
if none are provided when signing.
default_extensions
Map of extensions to their values certificates should carry
if none are provided when signing or allowed_extensions is unset.
default_extensions_template
Allow ``default_extensions`` to be specified using identity template values.
Non-templated values are also permitted. Defaults to false.
allow_user_certificates
Allow certificates to be signed for ``user`` use. Defaults to false.
allow_host_certificates
Allow certificates to be signed for ``host`` use. Defaults to false.
allow_bare_domains
Allow host certificates to be signed for the base domains listed in
``allowed_domains``. This is a separate option as in some cases this
can be considered a security threat. Defaults to false.
allow_subdomains
Allow host certificates to be signed for subdomains of the base domains
listed in ``allowed_domains``. Defaults to false.
allow_user_key_ids
Allow users to override the key ID for a certificate. When false (default),
the key ID always equals the token display name.
The key ID is logged by the SSH server and can be useful for auditing.
key_id_format
Specifies a custom format for the key ID of a signed certificate.
See `key_id_format <https://developer.hashicorp.com/vault/api-docs/secret/ssh#key_id_format>`_
for available template values.
allowed_user_key_lengths
Map of ssh key types to allowed sizes when signing with the CA type.
Values can be a list of multiple sizes.
Keys can both be OpenSSH-style key identifiers and short names
(``rsa``, ``ecdsa``, ``dsa``, or ``ed25519``). If an algorithm has
a fixed key size, values are ignored.
algorithm_signer
**RSA** algorithm to sign keys with. Valid: ``ssh-rsa``, ``rsa-sha2-256``,
``rsa-sha2-512``, or ``default`` (which is the default). Ignored
when not signing with an RSA key.
not_before_duration
Specifies the duration by which to backdate the ``ValidAfter`` property.
Defaults to ``30s``.
mount
Name of the mount point the SSH secret backend is mounted at.
Defaults to ``ssh``.
"""
ret = {
"name": name,
"result": True,
"comment": "The role is present as specified",
"changes": {},
}
if not (allow_user_certificates or allow_host_certificates):
ret["result"] = False
ret["comment"] = "Either allow_user_certificates or allow_host_certificates must be true"
return ret
payload = {
"default_user": default_user,
"default_user_template": default_user_template,
"allowed_users": allowed_users,
"allowed_users_template": allowed_users_template,
"allowed_domains": allowed_domains,
"allowed_domains_template": allowed_domains_template,
"ttl": ttl,
"max_ttl": max_ttl,
"allowed_critical_options": allowed_critical_options,
"allowed_extensions": allowed_extensions,
"default_critical_options": default_critical_options,
"default_extensions": default_extensions,
"default_extensions_template": default_extensions_template,
"allow_user_certificates": allow_user_certificates,
"allow_host_certificates": allow_host_certificates,
"allow_bare_domains": allow_bare_domains,
"allow_subdomains": allow_subdomains,
"allow_user_key_ids": allow_user_key_ids,
"key_id_format": key_id_format,
"allowed_user_key_lengths": allowed_user_key_lengths,
"algorithm_signer": algorithm_signer,
"not_before_duration": not_before_duration,
}
return _role_present(name, "ca", ret, mount=mount, **payload)
[docs]
def role_absent(name, mount="ssh"):
"""
Ensure an SSH role is absent.
name
Name of the role.
mount
Name of the mount point the SSH secret backend is mounted at.
Defaults to ``ssh``.
"""
ret = {
"name": name,
"result": True,
"comment": "The role is already absent",
"changes": {},
}
try:
try:
__salt__["vault_ssh.read_role"](name, mount=mount)
except CommandExecutionError as err:
if "VaultNotFoundError" not in str(err):
raise
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_ssh.delete_role"](name, mount=mount)
try:
__salt__["vault_ssh.read_role"](name, mount=mount)
except CommandExecutionError as err:
if "VaultNotFoundError" not in str(err):
raise
ret["comment"] = f"Role `{name}` has been deleted."
return ret
raise CommandExecutionError(
"There were no errors during role deletion, but it is still reported as present."
)
except CommandExecutionError as err:
ret["result"] = False
ret["comment"] = str(err)
ret["changes"] = {}
return ret