Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[master] New pem managed state #66322

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
5 changes: 5 additions & 0 deletions doc/ref/states/all/salt.states.pem.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
salt.states.pem
================

.. automodule:: salt.states.pem
:members:
175 changes: 175 additions & 0 deletions salt/states/pem.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
"""
Provide easier operations on certificate files
=====================================================================

Provides human-readable information regarding changes of files containing certificates.

Requires `cryptography` python package.

.. code-block:: yaml

/etc/ssl/postfix/mydomain/mydomain.pem:
pem.managed:
- source: salt://files/etc/ssl/mydomain.pem
- user: root
- group: postfix
- dir_mode: 711
- makedirs: True
- template: jinja
"""

import logging

import salt.utils.files

try:
from cryptography import x509

HAS_CRYPTOGRAPHY = True
except ImportError:
HAS_CRYPTOGRAPHY = False

__virtualname__ = "pem"


def __virtual__():
if not HAS_CRYPTOGRAPHY:
return (False, "Could not load cryptography")
return __virtualname__


log = logging.getLogger(__name__)


def managed(
name,
source=None,
source_hash=None,
source_hash_name=None,
user=None,
group=None,
mode=None,
skip_verify=None,
defaults=None,
attrs=None,
context=None,
saltenv="base",
skip_conditions=False,
**kwargs,
):
"""
Manage certificates as files and provide certificate expiration and common name in the comment.

It can handle one certificate or full privkey->cert->chain files.

Conditions provides an easy way to match against the certificate's Common Name
or to make sure that only newer certificates are copied down.

State can handle everything that file.managed can handle,
because it is used underneath to process changes to files.

For all parameters refer to file.managed documentation:
https://docs.saltproject.io/en/master/ref/states/all/salt.states.file.html#salt.states.file.managed

Args:

skip_conditions (bool): Do not check expiration or Common name match (default: False)
Also pillar can be used: pillar="{skip_conditions: True}"
"""

skip_conditions = __pillar__.get("skip_conditions", skip_conditions)
twangboy marked this conversation as resolved.
Show resolved Hide resolved
ret = {"name": name, "changes": {}, "result": False, "comment": ""}
existing_cert_info = ""
new_cert_info = ""

# Load existing certificate
try:
with salt.utils.files.fopen(name, "rb") as existing_cert_file:
existing_cert = x509.load_pem_x509_certificate(existing_cert_file.read())
existing_cert_info = f"- Subject: {existing_cert.subject.rfc4514_string()}\n- Not valid after: {existing_cert.not_valid_after}"
except FileNotFoundError:
# Old certificate initialy does not need to exist if it is a first time state is running
skip_conditions = True

try:
tmp_local_file, source_sum, comment_ = __salt__["file.get_managed"](
name,
source=source,
source_hash=source_hash,
source_hash_name=source_hash_name,
user=user,
group=group,
mode=mode,
attrs=attrs,
saltenv=saltenv,
defaults=defaults,
skip_verify=skip_verify,
context=context,
**kwargs,
)
except Exception as exc: # pylint: disable=broad-except
ret["result"] = False
ret["comment"] = f"Unable to manage file: {exc}"
return ret

if not tmp_local_file:
return _error(ret, f"Source file {source} not found")

try:
with salt.utils.files.fopen(tmp_local_file, "rb") as new_cert_file:
new_cert = x509.load_pem_x509_certificate(new_cert_file.read())
new_cert_info = f"+ Subject: {new_cert.subject.rfc4514_string()}\n+ Not valid after: {new_cert.not_valid_after}"
except FileNotFoundError:
return _error(ret, f"New cached file {tmp_local_file} not found")

ret["comment"] = (
f"Existing cert info:\n{existing_cert_info}\nNew cert info:\n{new_cert_info}\n"
)

# Conditions when certificates are salted
if skip_conditions:
log.debug("pem: Certificate conditions are skipped")
else:
failed_conditions = False

if new_cert.not_valid_after < existing_cert.not_valid_after:
ret[
"comment"
] += "New certificate expires sooner than existing one (skip with pillar='{skip_conditions: True}')\n"
failed_conditions = True
if new_cert.subject.rfc4514_string() != existing_cert.subject.rfc4514_string():
ret[
"comment"
] += "Certificates CN does not match (skip with pillar='{skip_conditions: True}')\n"
failed_conditions = True

if failed_conditions:
ret["result"] = False
return ret

result = __states__["file.managed"](
name=name,
source=source,
source_hash=source_hash,
source_hash_name=source_hash_name,
user=user,
group=group,
mode=mode,
skip_verify=skip_verify,
defaults=defaults,
attrs=attrs,
context=context,
**kwargs,
)

ret["changes"] = result["changes"]
ret["result"] = result["result"]
ret["comment"] += result["comment"]

return ret


def _error(ret, err_msg):
ret["result"] = False
ret["comment"] = err_msg
return ret
160 changes: 160 additions & 0 deletions tests/pytests/unit/states/test_pem.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
# tests/pytests/unit/states/test_pem.py

from datetime import datetime

import pytest

import salt.states.pem as pem
from tests.support.mock import MagicMock, mock_open, patch


@pytest.fixture
def configure_loader_modules():
return {
pem: {
"__opts__": {"test": False},
"__salt__": {},
"__states__": {
"file.managed": MagicMock(
return_value={"result": True, "changes": {}, "comment": ""}
)
},
}
}


def test_managed():
"""
Test pem.managed state
"""
name = "/tmp/example.crt"
source = "salt://example.crt"
user = "root"
group = "root"
mode = "0600"

good_cert = """
-----BEGIN CERTIFICATE-----
MIIG6zCCBdOgAwIBAgIQD5enH9rVhbYSQrtpPBDjsDANBgkqhkiG9w0BAQsFADBZ
MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMTMwMQYDVQQDEypE
aWdpQ2VydCBHbG9iYWwgRzIgVExTIFJTQSBTSEEyNTYgMjAyMCBDQTEwHhcNMjMx
MDA2MDAwMDAwWhcNMjQxMTA1MjM1OTU5WjBsMQswCQYDVQQGEwJVUzEVMBMGA1UE
CBMMUGVubnN5bHZhbmlhMQ4wDAYDVQQHEwVQYW9saTEbMBkGA1UEChMSRHVjayBE
dWNrIEdvLCBJbmMuMRkwFwYDVQQDDBAqLmR1Y2tkdWNrZ28uY29tMIIBIjANBgkq
hkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtqZKa5gE0IO5367vb9MGypqNzDdicz3y
RAF938TbQMUA0ZCyEbAFnngUkJYLR+YmSazWHKt0kW9c2SOsu230E/YQtsdzayYu
dlHnwOuXVBeFzUCFSICMNhrRW5gw4kIh9Lw3PS8RBxafzvO4gRcTtk+odRxElH+I
BcXF4DA22mKdkA9p8Nh7HYBpsuRKniyjiUVTDC2u1uNJ3YRcWsug3Dkq/M7tw57o
dp59JNpIWOybtTfwppTrUyQGOqMzW+zt/uJYRk2ZB1Hd98hIBAqy8Q7EkEGw/V1f
CRpM+byhYgq/otBMd9XAGP9zpHD3F4qiYCQOyK+RKd1iMNXs1+CtowIDAQABo4ID
mjCCA5YwHwYDVR0jBBgwFoAUdIWAwGbH3zfez70pN6oDHb7tzRcwHQYDVR0OBBYE
FIaIVeEXZBu4TesZG6lpf+zmOty9MCsGA1UdEQQkMCKCECouZHVja2R1Y2tnby5j
b22CDmR1Y2tkdWNrZ28uY29tMD4GA1UdIAQ3MDUwMwYGZ4EMAQICMCkwJwYIKwYB
BQUHAgEWG2h0dHA6Ly93d3cuZGlnaWNlcnQuY29tL0NQUzAOBgNVHQ8BAf8EBAMC
BaAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMIGfBgNVHR8EgZcwgZQw
SKBGoESGQmh0dHA6Ly9jcmwzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydEdsb2JhbEcy
VExTUlNBU0hBMjU2MjAyMENBMS0xLmNybDBIoEagRIZCaHR0cDovL2NybDQuZGln
aWNlcnQuY29tL0RpZ2lDZXJ0R2xvYmFsRzJUTFNSU0FTSEEyNTYyMDIwQ0ExLTEu
Y3JsMIGHBggrBgEFBQcBAQR7MHkwJAYIKwYBBQUHMAGGGGh0dHA6Ly9vY3NwLmRp
Z2ljZXJ0LmNvbTBRBggrBgEFBQcwAoZFaHR0cDovL2NhY2VydHMuZGlnaWNlcnQu
Y29tL0RpZ2lDZXJ0R2xvYmFsRzJUTFNSU0FTSEEyNTYyMDIwQ0ExLTEuY3J0MAwG
A1UdEwEB/wQCMAAwggF8BgorBgEEAdZ5AgQCBIIBbASCAWgBZgB1AO7N0GTV2xrO
xVy3nbTNE6Iyh0Z8vOzew1FIWUZxH7WbAAABiwRZRLwAAAQDAEYwRAIgQSl6sqy2
uIt1vG+7EHKLkToASFvYY5NV9Np8runSdAYCIBQ0qDazjm9FwrGunk8C9rEaw3QV
+hb8juCsd90A+9DXAHUASLDja9qmRzQP5WoC+p0w6xxSActW3SyB2bu/qznYhHMA
AAGLBFlEbwAABAMARjBEAiANubHLJUmCBphlEmTf4PR5TYBHnNLDTDGTEKsXDF+N
LQIgNwPp1iM7kwUT8g+nxkrPyNhNh/kQVmrfuMrBaLxwr3UAdgDatr9rP7W2Ip+b
wrtca+hwkXFsu1GEhTS9pD0wSNf7qwAAAYsEWURZAAAEAwBHMEUCIGE1BSUI8i/1
apqkN6hdrlvo0le3RYCu36BLbb9qqzn8AiEAkib0gR04diUH4Rta1EY2nyrXoTxZ
XuaT9SL5tW5aw+YwDQYJKoZIhvcNAQELBQADggEBADLm6XLJ1/uPtSDFB0rtHYVK
tKjSYqmjP2m/7xUFsc05qxmd7xBuD17wArDRZSMfnfSb4ZL1kyMmGHtUmaPLUEh1
r9jioPvdHI09afqhLSzbGCaP9bN9hCz++m0vKVT1jyo91NuDfubYjF5IYwFpCanw
ccNUo9RHaJ78Umd697/4z5lIgNTy/EUoyOMLM77JNoYnRsgZwYuy/OmsZDLagyEy
YX4VHgyZ0mbjZ3wLhxLaR7bpXm3xaXhkT+aYhxAz41VLnTbrrd8tWndpUBZxZIOo
QzrWHN1s5ktSh2ThhyA4d3hanaxrohNFFWPqpk0WX1PZwJeNPAL8P8d8B6VPzMs=
-----END CERTIFICATE-----
"""

# Mock file.get_managed to return local tmp file
with patch.dict(
pem.__salt__,
{"file.get_managed": MagicMock(return_value=("/tmp/example.crt", None, None))},
):

fopen_mock = mock_open(read_data=good_cert.encode())

# Cache file not found test
with patch(
"salt.utils.files.fopen", MagicMock(side_effect=FileNotFoundError())
):
ret = pem.managed(
name=name, source=source, user=user, group=group, mode=mode
)
assert ret["result"] is False
assert "New cached file /tmp/example.crt not found" == ret["comment"]

# Test mode
with patch.dict(pem.__opts__, {"test": True}), patch(
"salt.utils.files.fopen", fopen_mock
):
ret = pem.managed(
name=name, source=source, user=user, group=group, mode=mode
)
assert ret["result"] is True
assert (
ret["comment"]
== "Existing cert info:\n- Subject: CN=*.duckduckgo.com,O=Duck Duck Go\\, Inc.,L=Paoli,ST=Pennsylvania,C=US\n- Not valid after: 2024-11-05 23:59:59\nNew cert info:\n+ Subject: CN=*.duckduckgo.com,O=Duck Duck Go\\, Inc.,L=Paoli,ST=Pennsylvania,C=US\n+ Not valid after: 2024-11-05 23:59:59\n"
)
assert ret["changes"] == {}


def test_managed_failed_conditions():
"""
Test failure conditions in pem.managed state
"""
name = "/tmp/example.crt"
source = "salt://example.crt"
user = "root"
group = "root"
mode = "0600"

existing_cert = MagicMock()
existing_cert.subject.rfc4514_string.return_value = "CN=existing.com"
# existing_cert.not_valid_after.isoformat.return_value = "2023-01-01T00:00:00"
existing_cert.not_valid_after = datetime(2023, 1, 1)
new_cert = MagicMock()
new_cert.subject.rfc4514_string.return_value = "CN=new.com"
# new_cert.not_valid_after.isoformat.return_value = "2022-01-01T00:00:00"
new_cert.not_valid_after = datetime(2022, 1, 1)

with patch.dict(
pem.__salt__,
{"file.get_managed": MagicMock(return_value=("/tmp/example.crt", None, None))},
), patch("salt.utils.files.fopen", MagicMock()), patch(
"cryptography.x509.load_pem_x509_certificate",
MagicMock(side_effect=[existing_cert, new_cert]),
):
ret = pem.managed(name=name, source=source, user=user, group=group, mode=mode)
assert ret["result"] is False
assert "New certificate expires sooner than existing one" in ret["comment"]
assert "Certificates CN does not match" in ret["comment"]
assert ret["changes"] == {}

# Skip coditions
with patch.dict(
pem.__salt__,
{"file.get_managed": MagicMock(return_value=("/tmp/example.crt", None, None))},
), patch("salt.utils.files.fopen", MagicMock()), patch(
"cryptography.x509.load_pem_x509_certificate",
MagicMock(side_effect=[existing_cert, new_cert]),
):
ret = pem.managed(
name=name,
source=source,
user=user,
group=group,
mode=mode,
skip_conditions=True,
)
assert ret["result"] is True
assert ret["changes"] == {}