From a4f5a679a78e4563ed34603c0cb9a24622d2b568 Mon Sep 17 00:00:00 2001 From: david Date: Thu, 22 Jan 2026 11:37:35 +0100 Subject: [PATCH] First commit --- .dockerignore | 12 ++++ .env.example | 22 +++++++ .gitignore | 66 +++++++++++++++++++ .prettierrc | 9 +++ Dockerfile | 0 LEEME.md | 0 pyproject.toml | 22 +++++++ src/signing_service/__init__.py | 0 .../api/routes/sign_document.py | 55 ++++++++++++++++ .../application/settings/app_settings.py | 54 +++++++++++++++ .../application/settings/container.py | 7 ++ .../application/use_cases/sign_document.py | 37 +++++++++++ .../use_cases/sign_document_dto.py | 12 ++++ .../domain/ports/pdf_signer.py | 13 ++++ .../domain/ports/secret_manager.py | 8 +++ .../infrastructure/factories/__init__.py | 0 .../factories/pdf_signer_factory.py | 21 ++++++ .../factories/secret_manager_factory.py | 39 +++++++++++ .../infrastructure/pdf/fake_pdf_signer.py | 16 +++++ .../infrastructure/pdf/pyhanko_pdf_signer.py | 49 ++++++++++++++ .../secrets/fake_secret_manager.py | 12 ++++ .../secrets/google_secret_manager.py | 23 +++++++ .../secrets/infisical_secret_manager.py | 17 +++++ src/signing_service/main.py | 18 +++++ .../use_cases/test_sign_document.py | 58 ++++++++++++++++ 25 files changed, 570 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .prettierrc create mode 100644 Dockerfile create mode 100644 LEEME.md create mode 100644 pyproject.toml create mode 100644 src/signing_service/__init__.py create mode 100644 src/signing_service/api/routes/sign_document.py create mode 100644 src/signing_service/application/settings/app_settings.py create mode 100644 src/signing_service/application/settings/container.py create mode 100644 src/signing_service/application/use_cases/sign_document.py create mode 100644 src/signing_service/application/use_cases/sign_document_dto.py create mode 100644 src/signing_service/domain/ports/pdf_signer.py create mode 100644 src/signing_service/domain/ports/secret_manager.py create mode 100644 src/signing_service/infrastructure/factories/__init__.py create mode 100644 src/signing_service/infrastructure/factories/pdf_signer_factory.py create mode 100644 src/signing_service/infrastructure/factories/secret_manager_factory.py create mode 100644 src/signing_service/infrastructure/pdf/fake_pdf_signer.py create mode 100644 src/signing_service/infrastructure/pdf/pyhanko_pdf_signer.py create mode 100644 src/signing_service/infrastructure/secrets/fake_secret_manager.py create mode 100644 src/signing_service/infrastructure/secrets/google_secret_manager.py create mode 100644 src/signing_service/infrastructure/secrets/infisical_secret_manager.py create mode 100644 src/signing_service/main.py create mode 100644 tests/application/use_cases/test_sign_document.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8eb47dd --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.venv +__pycache__ +*.pyc +*.pyo +*.pyd +.pytest_cache +.mypy_cache +.ruff_cache +.git +.gitignore +tests +*.log diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4f523e5 --- /dev/null +++ b/.env.example @@ -0,0 +1,22 @@ +# ============================================================ +# Signing Service - Environment Variables +# Copy this file to `.env` and fill the real values +# ============================================================ + +# -------------------- +# Application +# -------------------- +APP_ENV=local # local | prod +LOG_LEVEL=INFO + +# -------------------- +# Secret manager +# -------------------- +SECRET_PROVIDER=fake # fake | google | aws | azure | hcp +GCP_PROJECT_ID= # required if SECRET_PROVIDER=google + +# -------------------- +# PDF signing +# -------------------- +PDF_CERT_SECRET_NAME=pdf-signing-cert +PDF_CERT_PASSWORD_SECRET_NAME=pdf-signing-cert-password \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8f20a --- /dev/null +++ b/.gitignore @@ -0,0 +1,66 @@ +# =========================== +# Python básico +# =========================== +__pycache__/ +*.py[cod] +*.pyo +*.pyd +*.so +*.egg +*.egg-info/ +dist/ +build/ + +# =========================== +# Entornos virtuales +# =========================== +venv/ +.env/ +.venv/ + +# =========================== +# Configuración de IDEs +# =========================== +#.vscode/ +.idea/ +*.code-workspace + +# =========================== +# Herramientas / tests +# =========================== +.mypy_cache/ +.pytest_cache/ +.coverage +htmlcov/ +.cache/ + +# =========================== +# Logs del proyecto +# =========================== +logs/ +*.log + +# =========================== +# Ficheros de entorno +# =========================== +#environment/*.env +#environment/*.env.* +# Si quieres tener uno de ejemplo en git: +# !environment/dev.example.env + +# =========================== +# Docker +# =========================== +*.pid +docker-compose.override.yml + +# =========================== +# Sistemas operativos +# =========================== +.DS_Store +Thumbs.db + +# =========================== +# Otros +# =========================== +.env \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..6dd71b2 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,9 @@ +{ + "bracketSpacing": true, + "useTabs": false, + "printWidth": 100, + "tabWidth": 4, + "semi": true, + "singleQuote": false, + "rcVerbose": true +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e69de29 diff --git a/LEEME.md b/LEEME.md new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..67b1294 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,22 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "signing-service" +version = "0.1.0" +description = "FastAPI service for signing PDF documents using external secret managers" +requires-python = ">=3.11" + +dependencies = [ + "python-dotenv>=1.0", + "fastapi>=0.110", + "uvicorn[standard]>=0.27", + "pydantic-settings>=2.2", + "google-cloud-secret-manager>=2.18", + "pyhanko>=0.25", + "cryptography>=42", +] + +[project.optional-dependencies] +dev = ["pytest>=8.0", "pytest-cov", "ruff", "mypy"] diff --git a/src/signing_service/__init__.py b/src/signing_service/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/signing_service/api/routes/sign_document.py b/src/signing_service/api/routes/sign_document.py new file mode 100644 index 0000000..cc7be22 --- /dev/null +++ b/src/signing_service/api/routes/sign_document.py @@ -0,0 +1,55 @@ +from fastapi import APIRouter, UploadFile, File, Form, HTTPException +from fastapi.responses import Response + +from signing_service.application.settings.container import get_settings + +from signing_service.application.use_cases.sign_document import ( + SignDocumentUseCase, +) +from signing_service.application.use_cases.sign_document_dto import ( + SignDocumentCommand, +) +from signing_service.infrastructure.factories.secret_manager_factory import ( + create_secret_manager, +) +from signing_service.infrastructure.factories.pdf_signer_factory import ( + create_pdf_signer, +) + +router = APIRouter(prefix="/documents", tags=["documents"]) + + +@router.post("/sign") +async def sign_document( + file: UploadFile = File(...), + certificate_secret_name: str = Form(...), +): + try: + settings = get_settings() + + pdf_bytes = await file.read() + + use_case = SignDocumentUseCase( + secret_manager=create_secret_manager(), + pdf_signer=create_pdf_signer(), + cert_secret_name=settings.pdf_cert_secret_name, + cert_password_secret_name=settings.pdf_cert_password_secret_name, + ) + + command = SignDocumentCommand( + pdf_bytes=pdf_bytes, + certificate_secret_name=certificate_secret_name, + ) + + result = use_case.execute(command) + + return Response( + content=result.signed_pdf_bytes, + media_type="application/pdf", + headers={ + "Content-Disposition": f"attachment; filename=signed_{file.filename}" + }, + ) + + except Exception as exc: + raise HTTPException(status_code=400, detail=str(exc)) diff --git a/src/signing_service/application/settings/app_settings.py b/src/signing_service/application/settings/app_settings.py new file mode 100644 index 0000000..f0873a8 --- /dev/null +++ b/src/signing_service/application/settings/app_settings.py @@ -0,0 +1,54 @@ +from pydantic_settings import BaseSettings +from pydantic import Field, model_validator + + +class AppSettings(BaseSettings): + # -------------------- + # Application + # -------------------- + app_env: str = Field(default="local") + log_level: str = Field(default="INFO") + + # -------------------- + # Secret manager + # -------------------- + secret_provider: str = Field(default="fake") + gcp_project_id: str | None = None + + # -------------------- + # PDF signing + # -------------------- + pdf_cert_secret_name: str + pdf_cert_password_secret_name: str + + @model_validator(mode="after") + def validate_required_settings(self) -> "AppSettings": + """ + Fail fast if required configuration is missing. + """ + + if not self.pdf_cert_secret_name: + raise ValueError("PDF_CERT_SECRET_NAME is required") + + if not self.pdf_cert_password_secret_name: + raise ValueError("PDF_CERT_PASSWORD_SECRET_NAME is required") + + # ---- Secret provider validation ---- + if self.secret_provider == "google": + if not self.gcp_project_id: + raise ValueError( + "GCP_PROJECT_ID is required when SECRET_PROVIDER=google" + ) + + # ---- PDF signing validation ---- + if self.app_env == "prod": + if not self.pdf_pfx_password: + raise ValueError( + "PDF_PFX_PASSWORD is required in production" + ) + + return self + + class Config: + env_file = ".env" + case_sensitive = False diff --git a/src/signing_service/application/settings/container.py b/src/signing_service/application/settings/container.py new file mode 100644 index 0000000..fc32608 --- /dev/null +++ b/src/signing_service/application/settings/container.py @@ -0,0 +1,7 @@ +from functools import lru_cache +from signing_service.application.settings.app_settings import AppSettings + + +@lru_cache +def get_settings() -> AppSettings: + return AppSettings() diff --git a/src/signing_service/application/use_cases/sign_document.py b/src/signing_service/application/use_cases/sign_document.py new file mode 100644 index 0000000..60b3754 --- /dev/null +++ b/src/signing_service/application/use_cases/sign_document.py @@ -0,0 +1,37 @@ +from signing_service.application.use_cases.sign_document_dto import ( + SignDocumentCommand, + SignDocumentResult, +) +from signing_service.domain.ports.secret_manager import SecretManagerPort +from signing_service.domain.ports.pdf_signer import PDFSignerPort + + +class SignDocumentUseCase: + def __init__( + self, + secret_manager: SecretManagerPort, + pdf_signer: PDFSignerPort, + cert_secret_name: str, + cert_password_secret_name: str, + ) -> None: + self._secret_manager = secret_manager + self._pdf_signer = pdf_signer + self._cert_secret_name = cert_secret_name + self._cert_password_secret_name = cert_password_secret_name + + def execute(self, command: SignDocumentCommand) -> SignDocumentResult: + certificate = self._secret_manager.get_secret( + self._cert_secret_name + ) + + password = self._secret_manager.get_secret( + self._cert_password_secret_name + ) + + signed_pdf = self._pdf_signer.sign( + pdf_bytes=command.pdf_bytes, + certificate=certificate, + password=password, + ) + + return SignDocumentResult(signed_pdf_bytes=signed_pdf) \ No newline at end of file diff --git a/src/signing_service/application/use_cases/sign_document_dto.py b/src/signing_service/application/use_cases/sign_document_dto.py new file mode 100644 index 0000000..29f554a --- /dev/null +++ b/src/signing_service/application/use_cases/sign_document_dto.py @@ -0,0 +1,12 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class SignDocumentCommand: + pdf_bytes: bytes + certificate_secret_name: str + + +@dataclass(frozen=True) +class SignDocumentResult: + signed_pdf_bytes: bytes diff --git a/src/signing_service/domain/ports/pdf_signer.py b/src/signing_service/domain/ports/pdf_signer.py new file mode 100644 index 0000000..4326d46 --- /dev/null +++ b/src/signing_service/domain/ports/pdf_signer.py @@ -0,0 +1,13 @@ +from abc import ABC, abstractmethod + + +class PDFSignerPort(ABC): + @abstractmethod + def sign( + self, + pdf_bytes: bytes, + certificate: str, + password: str, + ) -> bytes: + """Sign a PDF and return signed PDF bytes.""" + raise NotImplementedError diff --git a/src/signing_service/domain/ports/secret_manager.py b/src/signing_service/domain/ports/secret_manager.py new file mode 100644 index 0000000..ddeb45a --- /dev/null +++ b/src/signing_service/domain/ports/secret_manager.py @@ -0,0 +1,8 @@ +from abc import ABC, abstractmethod + + +class SecretManagerPort(ABC): + @abstractmethod + def get_secret(self, name: str) -> str: + """Retrieve a secret by name.""" + raise NotImplementedError diff --git a/src/signing_service/infrastructure/factories/__init__.py b/src/signing_service/infrastructure/factories/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/signing_service/infrastructure/factories/pdf_signer_factory.py b/src/signing_service/infrastructure/factories/pdf_signer_factory.py new file mode 100644 index 0000000..94b914f --- /dev/null +++ b/src/signing_service/infrastructure/factories/pdf_signer_factory.py @@ -0,0 +1,21 @@ +from signing_service.application.settings.container import get_settings +from signing_service.domain.ports.pdf_signer import PDFSignerPort +from signing_service.infrastructure.pdf.fake_pdf_signer import FakePDFSigner + + +def create_pdf_signer() -> PDFSignerPort: + settings = get_settings() + + if settings.app_env == "local": + return FakePDFSigner() + + from signing_service.infrastructure.pdf.pyhanko_pdf_signer import ( + PyHankoPDFSigner, + ) + + if not settings.pdf_pfx_password: + raise ValueError("PDF_PFX_PASSWORD is required") + + return PyHankoPDFSigner( + pfx_password=settings.pdf_pfx_password + ) diff --git a/src/signing_service/infrastructure/factories/secret_manager_factory.py b/src/signing_service/infrastructure/factories/secret_manager_factory.py new file mode 100644 index 0000000..b8377b5 --- /dev/null +++ b/src/signing_service/infrastructure/factories/secret_manager_factory.py @@ -0,0 +1,39 @@ +from signing_service.application.settings.container import get_settings +from signing_service.domain.ports.secret_manager import SecretManagerPort +from signing_service.infrastructure.secrets.fake_secret_manager import ( + FakeSecretManager, +) +from signing_service.infrastructure.secrets.infisical_secret_manager import InfisicalSecretManager + + +def create_secret_manager() -> SecretManagerPort: + settings = get_settings() + provider = settings.secret_provider.lower() + + if provider == "fake": + return FakeSecretManager( + secrets={ + "pdf-signing-cert": "FAKE_CERTIFICATE_DATA" + } + ) + + if provider == "infisical": + return InfisicalSecretManager( + token=settings.infisical_token, + project_id=settings.infisical_project_id, + environment=settings.infisical_environment, + ) + + if provider == "google": + from signing_service.infrastructure.secrets.google_secret_manager import ( + GoogleSecretManager, + ) + + if not settings.gcp_project_id: + raise ValueError("GCP_PROJECT_ID is required for Google Secret Manager") + + return GoogleSecretManager( + project_id=settings.gcp_project_id + ) + + raise ValueError(f"Unsupported secret provider: {provider}") diff --git a/src/signing_service/infrastructure/pdf/fake_pdf_signer.py b/src/signing_service/infrastructure/pdf/fake_pdf_signer.py new file mode 100644 index 0000000..2fed92d --- /dev/null +++ b/src/signing_service/infrastructure/pdf/fake_pdf_signer.py @@ -0,0 +1,16 @@ +from signing_service.domain.ports.pdf_signer import PDFSignerPort + + +class FakePDFSigner(PDFSignerPort): + """ + Fake implementation of PDFSignerPort. + It does NOT sign a real PDF. + It only simulates the behavior for testing and development. + """ + + def sign(self, pdf_bytes: bytes, certificate: str, password: str) -> bytes: + # Simulación simple: + # añadimos un marcador para indicar "firmado" + signature_marker = f"\n\n-- Signed with certificate: {certificate} and password: {password}".encode() + + return pdf_bytes + signature_marker diff --git a/src/signing_service/infrastructure/pdf/pyhanko_pdf_signer.py b/src/signing_service/infrastructure/pdf/pyhanko_pdf_signer.py new file mode 100644 index 0000000..2c16d0b --- /dev/null +++ b/src/signing_service/infrastructure/pdf/pyhanko_pdf_signer.py @@ -0,0 +1,49 @@ +import base64 +import io + +from pyhanko.sign import signers +from pyhanko.sign.general import load_cert_from_pfx + +from signing_service.domain.ports.pdf_signer import PDFSignerPort + + +class PyHankoPDFSigner(PDFSignerPort): + def __init__(self, pfx_password: str) -> None: + self._pfx_password = pfx_password.encode() + + def sign(self, pdf_bytes: bytes, certificate: str, password: str,) -> bytes: + """ + certificate: base64-encoded PKCS#12 (PFX) + """ + + # 1️⃣ Decodificar certificado + pfx_bytes = base64.b64decode(certificate) + + # 2️⃣ Cargar clave y certificado + private_key, cert, other_certs = load_cert_from_pfx( + pfx_bytes, password.encode() + ) + + signer = signers.SimpleSigner( + signing_cert=cert, + signing_key=private_key, + cert_registry=signers.SimpleCertificateStore.from_certs( + other_certs + ), + ) + + # 3️⃣ Preparar PDF + input_pdf = io.BytesIO(pdf_bytes) + output_pdf = io.BytesIO() + + # 4️⃣ Firmar + signers.sign_pdf( + input_pdf, + signers.PdfSignatureMetadata( + field_name="Signature1" + ), + signer=signer, + output=output_pdf, + ) + + return output_pdf.getvalue() diff --git a/src/signing_service/infrastructure/secrets/fake_secret_manager.py b/src/signing_service/infrastructure/secrets/fake_secret_manager.py new file mode 100644 index 0000000..2272f8c --- /dev/null +++ b/src/signing_service/infrastructure/secrets/fake_secret_manager.py @@ -0,0 +1,12 @@ +from signing_service.domain.ports.secret_manager import SecretManagerPort + + +class FakeSecretManager(SecretManagerPort): + def __init__(self, secrets: dict[str, str]) -> None: + self._secrets = secrets + + def get_secret(self, name: str) -> str: + try: + return self._secrets[name] + except KeyError: + raise ValueError(f"Secret '{name}' not found") diff --git a/src/signing_service/infrastructure/secrets/google_secret_manager.py b/src/signing_service/infrastructure/secrets/google_secret_manager.py new file mode 100644 index 0000000..c3248c8 --- /dev/null +++ b/src/signing_service/infrastructure/secrets/google_secret_manager.py @@ -0,0 +1,23 @@ +from google.cloud import secretmanager + +from signing_service.domain.ports.secret_manager import SecretManagerPort + + +class GoogleSecretManager(SecretManagerPort): + def __init__(self, project_id: str) -> None: + self._project_id = project_id + self._client = secretmanager.SecretManagerServiceClient() + + def get_secret(self, name: str) -> str: + """ + Retrieve the latest version of a secret from Google Cloud Secret Manager. + """ + secret_path = ( + f"projects/{self._project_id}/secrets/{name}/versions/latest" + ) + + response = self._client.access_secret_version( + request={"name": secret_path} + ) + + return response.payload.data.decode("utf-8") diff --git a/src/signing_service/infrastructure/secrets/infisical_secret_manager.py b/src/signing_service/infrastructure/secrets/infisical_secret_manager.py new file mode 100644 index 0000000..75fb132 --- /dev/null +++ b/src/signing_service/infrastructure/secrets/infisical_secret_manager.py @@ -0,0 +1,17 @@ +from infisical_sdk import InfisicalSDKClient +from signing_service.domain.ports.secret_manager import SecretManagerPort + +class InfisicalSecretManager(SecretManagerPort): + def __init__(self, token: str, project_id: str, environment: str): + self._client = InfisicalSDKClient(token=token) + self._project_id = project_id + self._environment = environment + + def get_secret(self, name: str) -> str: + secret = self._client.secrets.get_secret_by_name( + secret_name=name, + project_id=self._project_id, + environment_slug=self._environment, + secret_path="/" + ) + return secret.secret_value \ No newline at end of file diff --git a/src/signing_service/main.py b/src/signing_service/main.py new file mode 100644 index 0000000..5daa6aa --- /dev/null +++ b/src/signing_service/main.py @@ -0,0 +1,18 @@ +from fastapi import FastAPI +from dotenv import load_dotenv + +from signing_service.application.settings.container import get_settings +from signing_service.api.routes.sign_document import router as sign_router + +load_dotenv() + + +# 👇 FAIL FAST: load settings at startup +get_settings() + +app = FastAPI(title="Signing Service") +app.include_router(sign_router) + +@app.get("/health") +def health_check() -> dict[str, str]: + return {"status": "ok"} diff --git a/tests/application/use_cases/test_sign_document.py b/tests/application/use_cases/test_sign_document.py new file mode 100644 index 0000000..a628597 --- /dev/null +++ b/tests/application/use_cases/test_sign_document.py @@ -0,0 +1,58 @@ +import pytest + +from signing_service.application.use_cases.sign_document import ( + SignDocumentUseCase, +) +from signing_service.application.use_cases.sign_document_dto import ( + SignDocumentCommand, +) +from signing_service.infrastructure.pdf.fake_pdf_signer import FakePDFSigner +from signing_service.infrastructure.secrets.fake_secret_manager import ( + FakeSecretManager, +) + + +def test_sign_document_success(): + # GIVEN: dependencias fake + fake_secrets = { + "pdf-signing-cert": "FAKE_CERTIFICATE_DATA" + } + secret_manager = FakeSecretManager(fake_secrets) + pdf_signer = FakePDFSigner() + + use_case = SignDocumentUseCase( + secret_manager=secret_manager, + pdf_signer=pdf_signer, + ) + + original_pdf = b"%PDF-1.4 fake pdf content" + + command = SignDocumentCommand( + pdf_bytes=original_pdf, + certificate_secret_name="pdf-signing-cert", + ) + + # WHEN: ejecutamos el caso de uso + result = use_case.execute(command) + + # THEN: el PDF está "firmado" + assert result.signed_pdf_bytes.startswith(original_pdf) + assert b"FAKE_CERTIFICATE_DATA" in result.signed_pdf_bytes + + +def test_sign_document_secret_not_found(): + secret_manager = FakeSecretManager(secrets={}) + pdf_signer = FakePDFSigner() + + use_case = SignDocumentUseCase( + secret_manager=secret_manager, + pdf_signer=pdf_signer, + ) + + command = SignDocumentCommand( + pdf_bytes=b"fake pdf", + certificate_secret_name="missing-secret", + ) + + with pytest.raises(ValueError): + use_case.execute(command) \ No newline at end of file