diff --git a/cmd/cloud-run/signifysecretrotator/Dockerfile b/cmd/cloud-run/signifysecretrotator/Dockerfile index 03a3ff6079af..8477656cbe0c 100644 --- a/cmd/cloud-run/signifysecretrotator/Dockerfile +++ b/cmd/cloud-run/signifysecretrotator/Dockerfile @@ -8,6 +8,7 @@ WORKDIR /app COPY ./cmd/cloud-run/signifysecretrotator/**/*.py . COPY ./cmd/cloud-run/signifysecretrotator/requirements.txt . +RUN apk add g++ RUN pip install --no-cache-dir --upgrade -r requirements.txt && \ apk add --no-cache ca-certificates diff --git a/cmd/cloud-run/signifysecretrotator/pylogger/__pycache__/logger.cpython-312.pyc b/cmd/cloud-run/signifysecretrotator/pylogger/__pycache__/logger.cpython-312.pyc deleted file mode 100644 index 73a5eaca2c16..000000000000 Binary files a/cmd/cloud-run/signifysecretrotator/pylogger/__pycache__/logger.cpython-312.pyc and /dev/null differ diff --git a/cmd/cloud-run/signifysecretrotator/requirements.txt b/cmd/cloud-run/signifysecretrotator/requirements.txt index 1821d3243438..b3d01df69b6b 100644 --- a/cmd/cloud-run/signifysecretrotator/requirements.txt +++ b/cmd/cloud-run/signifysecretrotator/requirements.txt @@ -1,5 +1,4 @@ flask==2.3.2 cloudevents==1.9.0 gunicorn==22.0.0 -google-cloud-secret-manager==2.20.2 -cryptography==43.0.1 \ No newline at end of file +google-cloud-secret-manager==2.20.2 \ No newline at end of file diff --git a/cmd/cloud-run/signifysecretrotator/signify/__init__.py b/cmd/cloud-run/signifysecretrotator/signify/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/cmd/cloud-run/signifysecretrotator/signify/client.py b/cmd/cloud-run/signifysecretrotator/signify/client.py new file mode 100644 index 000000000000..63b023ac18ec --- /dev/null +++ b/cmd/cloud-run/signifysecretrotator/signify/client.py @@ -0,0 +1,123 @@ +"""Custom client for signify API""" + +import json +import tempfile +import requests +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography import x509 +from cryptography.hazmat.primitives import serialization, hashes +from cryptography.hazmat.primitives.serialization import pkcs7 + + +class SignifyClient: + """Wraps signify API""" + + def __init__( + self, token_url: str, certificate_service_url: str, client_id: str + ) -> None: + self.token_url = token_url + self.certificate_service_url = certificate_service_url + self.client_id = client_id + + def fetch_access_token(self, certificate: bytes, private_key: bytes) -> str: + """fetches access token from given token_url using certificate and private key""" + # Use temporary file for old cert and key because requests library needs file paths, + # the code is running in known environment controlled by us + with ( + tempfile.NamedTemporaryFile() as old_cert_file, + tempfile.NamedTemporaryFile() as old_key_file, + ): + + old_cert_file.write(certificate) + old_cert_file.flush() + + old_key_file.write(private_key) + old_key_file.flush() + + access_token_response = requests.post( + self.token_url, + cert=(old_cert_file.name, old_key_file.name), + data={ + "grant_type": "client_credentials", + "client_id": self.client_id, + }, + timeout=30, + ) + + if access_token_response.status_code != 200: + raise requests.HTTPError( + f"Got not-success status code {access_token_response.status_code}", + response=access_token_response, + ) + + decoded_response = access_token_response.json() + + if "access_token" not in decoded_response: + raise ValueError( + f"Got unexpected response structure: {decoded_response}" + ) + + return decoded_response["access_token"] + + def fetch_new_certificate( + self, cert_data: bytes, private_key: rsa.RSAPrivateKey, access_token: str + ): + """Fetch new certificates from given certificate service""" + + csr = self._prepare_csr(cert_data, private_key) + + crt_create_payload = self._prepare_cert_request_paylaod(csr) + + cert_create_response = requests.post( + self.certificate_service_url, + headers={ + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + "Accept": "application/json", + }, + data=crt_create_payload, + timeout=10, + ) + + if cert_create_response.status_code != 200: + raise requests.HTTPError( + f"Got un-success statsu code {cert_create_response.status_code}" + ) + + decoded_response = cert_create_response.json() + + if ( + "certificateChain" not in decoded_response + or "value" not in decoded_response["certificateChain"] + ): + raise ValueError( + f"Cannot issue new certifacte, invalid response format: {decoded_response}" + ) + + pkcs7_certs = decoded_response["certificateChain"]["value"].encode() + + return pkcs7.load_pem_pkcs7_certificates(pkcs7_certs) + + def _prepare_cert_request_paylaod(self, csr: x509.CertificateSigningRequest): + return json.dumps( + { + "csr": { + "value": csr.public_bytes(serialization.Encoding.PEM).decode( + "utf-8" + ) + }, + "validity": {"value": 7, "type": "DAYS"}, + "policy": "sap-cloud-platform-clients", + } + ) + + def _prepare_csr(self, cert_data: bytes, private_key: rsa.RSAPrivateKey): + old_cert = x509.load_pem_x509_certificate(cert_data) + + csr = ( + x509.CertificateSigningRequestBuilder() + .subject_name(old_cert.subject) + .sign(private_key, hashes.SHA256()) + ) + + return csr diff --git a/cmd/cloud-run/signifysecretrotator/signify/test_client.py b/cmd/cloud-run/signifysecretrotator/signify/test_client.py new file mode 100644 index 000000000000..79eecb8e4577 --- /dev/null +++ b/cmd/cloud-run/signifysecretrotator/signify/test_client.py @@ -0,0 +1,141 @@ +"""Tests for signify client module""" + +import base64 +import unittest +from unittest.mock import patch, MagicMock + +import requests + +# pylint: disable=import-error +# False positive see: https://github.com/pylint-dev/pylint/issues/3984 +from client import SignifyClient +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.serialization import Encoding, PrivateFormat + +# pylint: disable=import-error +# False positive see: https://github.com/pylint-dev/pylint/issues/3984 +import test_fixtures + + +class TestSignifyClient(unittest.TestCase): + """ + Unit tests for the SignifyClient class. + """ + + def setUp(self): + """ + Set up method to initialize the SignifyClient object and necessary data for tests. + """ + self.token_url = "https://example.com/token" + self.certificate_service_url = "https://example.com/certificate" + self.client = SignifyClient( + token_url=self.token_url, + certificate_service_url=self.certificate_service_url, + client_id="fake_client_id", + ) + self.certificate = base64.b64decode( + test_fixtures.mocked_secret_data["certData"] + ) + self.private_key = rsa.generate_private_key( + public_exponent=65537, key_size=2048 + ) + self.access_token = "fake_access_token" + + @patch("requests.post") + def test_fetch_access_token_success(self, mock_post): + """ + Test successful fetch of access token. + + Mock the requests.post to return a successful response with the expected access token. + """ + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"access_token": self.access_token} + mock_post.return_value = mock_response + private_key_bytes = self.private_key.private_bytes( + Encoding.PEM, PrivateFormat.PKCS8, serialization.NoEncryption() + ) + + token = self.client.fetch_access_token( + self.certificate, + private_key_bytes, + ) + + self.assertEqual(token, self.access_token) + mock_post.assert_called_once() + + @patch("requests.post") + def test_fetch_access_token_failed_status_code(self, mock_post): + """ + Test fetch of access token when the response status code is not 200. + """ + mock_response = MagicMock() + mock_response.status_code = 400 + mock_response.json.return_value = {} + mock_post.return_value = mock_response + private_key_bytes = self.private_key.private_bytes( + Encoding.PEM, PrivateFormat.PKCS8, serialization.NoEncryption() + ) + + with self.assertRaises(requests.HTTPError): + self.client.fetch_access_token( + certificate=self.certificate, private_key=private_key_bytes + ) + mock_post.assert_called_once() + + @patch("requests.post") + def test_fetch_access_token_unexpected_response(self, mock_post): + """ + Test fetch of access token when the response does not contain the expected structure. + """ + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {} + mock_post.return_value = mock_response + private_key_bytes = self.private_key.private_bytes( + Encoding.PEM, PrivateFormat.PKCS8, serialization.NoEncryption() + ) + + with self.assertRaises(ValueError): + self.client.fetch_access_token( + certificate=self.certificate, private_key=private_key_bytes + ) + mock_post.assert_called_once() + + @patch("requests.post") + def test_fetch_new_certificate_success(self, mock_post): + """ + Test successful fetch of a new certificate. + """ + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = test_fixtures.mocked_cert_create_response + mock_post.return_value = mock_response + + certs = self.client.fetch_new_certificate( + self.certificate, self.private_key, self.access_token + ) + + self.assertEqual(len(certs), 1) + mock_post.assert_called_once() + + @patch("requests.post") + def test_fetch_new_certificate_failed_status_code(self, mock_post): + """ + Test fetch of a new certificate when the response status code is not 200.s + """ + mock_response = MagicMock() + mock_response.status_code = 400 + mock_response.json.return_value = {} + mock_post.return_value = mock_response + + with self.assertRaises(KeyError): + self.client.fetch_new_certificate( + self.certificate, self.private_key, self.access_token + ) + mock_post.assert_called_once() + + +if __name__ == "__main__": + unittest.main() diff --git a/cmd/cloud-run/signifysecretrotator/test_fixtures.py b/cmd/cloud-run/signifysecretrotator/signify/test_fixtures.py similarity index 100% rename from cmd/cloud-run/signifysecretrotator/test_fixtures.py rename to cmd/cloud-run/signifysecretrotator/signify/test_fixtures.py diff --git a/cmd/cloud-run/signifysecretrotator/signifysecretrotator.py b/cmd/cloud-run/signifysecretrotator/signifysecretrotator.py index d6d9ebda92be..aaf30e409ee2 100644 --- a/cmd/cloud-run/signifysecretrotator/signifysecretrotator.py +++ b/cmd/cloud-run/signifysecretrotator/signifysecretrotator.py @@ -5,36 +5,33 @@ import base64 import json import sys -import tempfile import traceback from typing import Any, Dict, List -import requests from flask import Flask, Response, request, make_response from cryptography import x509 -from cryptography.hazmat.primitives import serialization, hashes -from cryptography.hazmat.primitives.serialization import pkcs7, Encoding, PrivateFormat from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.serialization import Encoding, PrivateFormat +from requests import HTTPError from secretmanager import client +from pylogger.logger import Logger +from signify.client import SignifyClient app = Flask(__name__) project_id: str = os.getenv("PROJECT_ID", "sap-kyma-prow") component_name: str = os.getenv("COMPONENT_NAME", "signify-certificate-rotator") application_name: str = os.getenv("APPLICATION_NAME", "secret-rotator") - - -# TODO(kacpermalachowski): Move it to common package -class LogEntry(dict): - """Simplifies logging by returning a JSON string.""" - - def __str__(self): - return json.dumps(self) +secret_rotate_message_type = os.getenv("SECRET_ROTATE_MESSAGE_TYPE", "signify") @app.route("/", methods=["POST"]) def rotate_signify_secret() -> Response: """HTTP webhook handler for rotating Signify secrets.""" - log_fields: Dict[str, Any] = prepare_log_fields() - log_fields["labels"]["io.kyma.app"] = "signify-certificate-rotate" + logger = Logger( + component_name=component_name, + application_name=application_name, + request=request, + ) try: sm_client = client.SecretManagerClient() @@ -46,11 +43,19 @@ def rotate_signify_secret() -> Response: secret_rotate_msg = extract_message_data(pubsub_message) - if secret_rotate_msg["labels"]["type"] != "signify": - return prepare_error_response("Unsupported resource type", log_fields) + # Pub/Sub topic handle multiple secret rotator components + # verify if we should handle that message + if secret_rotate_msg["labels"]["type"] != secret_rotate_message_type: + return prepare_error_response("Unsupported event type", logger) secret_data = sm_client.get_secret(secret_rotate_msg["name"]) + signify_client = SignifyClient( + token_url=secret_data["tokenURL"], + certificate_service_url=secret_data["certServiceURL"], + client_id=secret_data["clientID"], + ) + old_cert_data = base64.b64decode(secret_data["certData"]) old_pk_data = base64.b64decode(secret_data["privateKeyData"]) @@ -61,14 +66,17 @@ def rotate_signify_secret() -> Response: new_private_key = rsa.generate_private_key(public_exponent=65537, key_size=4096) - access_token = fetch_access_token( - old_cert_data, old_pk_data, secret_data["tokenURL"], secret_data["clientID"] + access_token = signify_client.fetch_access_token( + certificate=old_cert_data, + private_key=old_pk_data, ) created_at = datetime.datetime.now().strftime("%d-%m-%Y %H:%M:%S") - new_certs: List[x509.Certificate] = fetch_new_certificate( - old_cert_data, new_private_key, access_token, secret_data["certServiceURL"] + new_certs: List[x509.Certificate] = signify_client.fetch_new_certificate( + cert_data=old_cert_data, + private_key=new_private_key, + access_token=access_token, ) new_secret_data = prepare_new_secret( @@ -77,58 +85,13 @@ def rotate_signify_secret() -> Response: sm_client.set_secret(secret_rotate_msg["name"], json.dumps(new_secret_data)) - print( - LogEntry( - severity="INFO", - message="Certificate rotated successfully", - **log_fields, - ) - ) + logger.log_info(f"Certificate rotated successfully at {created_at}") return "Certificate rotated successfully" + except HTTPError as exc: + return prepare_error_response(exc, logger) except ValueError as exc: - return prepare_error_response(exc, log_fields) - - -def fetch_new_certificate( - cert_data: bytes, - private_key: rsa.RSAPrivateKey, - access_token: str, - certificate_service_url: str, -): - """Fetch new certificates from given certificate service""" - old_cert = x509.load_pem_x509_certificate(cert_data) - - csr = ( - x509.CertificateSigningRequestBuilder() - .subject_name(old_cert.subject) - .sign(private_key, hashes.SHA256()) - ) - - crt_create_payload = json.dumps( - { - "csr": { - "value": csr.public_bytes(serialization.Encoding.PEM).decode("utf-8") - }, - "validity": {"value": 7, "type": "DAYS"}, - "policy": "sap-cloud-platform-clients", - } - ) - - cert_create_response = requests.post( - certificate_service_url, - headers={ - "Authorization": f"Bearer {access_token}", - "Content-Type": "application/json", - "Accept": "application/json", - }, - data=crt_create_payload, - timeout=10, - ).json() - - pkcs7_certs = cert_create_response["certificateChain"]["value"].encode() - - return pkcs7.load_pem_pkcs7_certificates(pkcs7_certs) + return prepare_error_response(exc, logger) def prepare_new_secret( @@ -173,97 +136,33 @@ def extract_message_data(pubsub_message: Any) -> Any: def decrypt_private_key(private_key_data: bytes, password: bytes) -> bytes: """Decrypts an encrypted private key.""" - # pylint: disable=line-too-long private_key = serialization.load_pem_private_key(private_key_data, password) - # pylint: disable=line-too-long return private_key.private_bytes( Encoding.PEM, PrivateFormat.PKCS8, serialization.NoEncryption() ) -def fetch_access_token( - certificate: bytes, private_key: bytes, token_url: str, client_id: str -) -> str: - """fetches access token from given token_url using certificate and private key""" - # Use temporary file for old cert and key because requests library needs file paths, - # it's not a security concern because the code is running in known environment controlled by us - # pylint: disable=line-too-long - with ( - tempfile.NamedTemporaryFile() as old_cert_file, - tempfile.NamedTemporaryFile() as old_key_file, - ): - - old_cert_file.write(certificate) - old_cert_file.flush() - - old_key_file.write(private_key) - old_key_file.flush() - - # pylint: disable=line-too-long - access_token_response = requests.post( - token_url, - cert=(old_cert_file.name, old_key_file.name), - data={ - "grant_type": "client_credentials", - "client_id": client_id, - }, - timeout=30, - ).json() - - return access_token_response["access_token"] - - -# TODO(kacpermalachowski): Move it to common package -def prepare_log_fields() -> Dict[str, Any]: - """prepare_log_fields prapares basic log fields""" - log_fields: Dict[str, Any] = {} - request_is_defined = "request" in globals() or "request" in locals() - if request_is_defined and request: - trace_header = request.headers.get("X-Cloud-Trace-Context") - if trace_header and project_id: - trace = trace_header.split("/") - log_fields["logging.googleapis.com/trace"] = ( - f"projects/{project_id}/traces/{trace[0]}" - ) - log_fields["Component"] = "signify-certificate-rotator" - log_fields["labels"] = {"io.kyma.component": "signify-certificate-rotator"} - return log_fields - - # TODO(kacpermalachowski): Move it to common package def get_pubsub_message(): """Parses the Pub/Sub message from the request.""" envelope = request.get_json() if not envelope: - # pylint: disable=broad-exception-raised raise ValueError("No Pub/Sub message received") if not isinstance(envelope, dict) or "message" not in envelope: - # pylint: disable=broad-exception-raised raise ValueError("Invalid Pub/Sub message format") return envelope["message"] # TODO(kacpermalachowski): Move it to common package -def prepare_error_response(err: str, log_fields: Dict[str, Any]) -> Response: +def prepare_error_response(err: str, logger: Logger) -> Response: """Prepares an error response with logging.""" _, exc_value, _ = sys.exc_info() stacktrace = repr(traceback.format_exception(exc_value)) - print( - LogEntry( - severity="ERROR", - message=f"Error: {err}\nStack:\n {stacktrace}", - **log_fields, - ) - ) + logger.log_error(f"Error: {err}\nStack:\n {stacktrace}") resp = make_response() resp.content_type = "application/json" resp.status_code = 500 return resp - - -def setup_app(): - print("test") - pass