First commit
This commit is contained in:
commit
a4f5a679a7
12
.dockerignore
Normal file
12
.dockerignore
Normal file
@ -0,0 +1,12 @@
|
||||
.venv
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.pytest_cache
|
||||
.mypy_cache
|
||||
.ruff_cache
|
||||
.git
|
||||
.gitignore
|
||||
tests
|
||||
*.log
|
||||
22
.env.example
Normal file
22
.env.example
Normal file
@ -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
|
||||
66
.gitignore
vendored
Normal file
66
.gitignore
vendored
Normal file
@ -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
|
||||
9
.prettierrc
Normal file
9
.prettierrc
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"bracketSpacing": true,
|
||||
"useTabs": false,
|
||||
"printWidth": 100,
|
||||
"tabWidth": 4,
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"rcVerbose": true
|
||||
}
|
||||
0
Dockerfile
Normal file
0
Dockerfile
Normal file
22
pyproject.toml
Normal file
22
pyproject.toml
Normal file
@ -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"]
|
||||
0
src/signing_service/__init__.py
Normal file
0
src/signing_service/__init__.py
Normal file
55
src/signing_service/api/routes/sign_document.py
Normal file
55
src/signing_service/api/routes/sign_document.py
Normal file
@ -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))
|
||||
54
src/signing_service/application/settings/app_settings.py
Normal file
54
src/signing_service/application/settings/app_settings.py
Normal file
@ -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
|
||||
7
src/signing_service/application/settings/container.py
Normal file
7
src/signing_service/application/settings/container.py
Normal file
@ -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()
|
||||
37
src/signing_service/application/use_cases/sign_document.py
Normal file
37
src/signing_service/application/use_cases/sign_document.py
Normal file
@ -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)
|
||||
@ -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
|
||||
13
src/signing_service/domain/ports/pdf_signer.py
Normal file
13
src/signing_service/domain/ports/pdf_signer.py
Normal file
@ -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
|
||||
8
src/signing_service/domain/ports/secret_manager.py
Normal file
8
src/signing_service/domain/ports/secret_manager.py
Normal file
@ -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
|
||||
@ -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
|
||||
)
|
||||
@ -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}")
|
||||
16
src/signing_service/infrastructure/pdf/fake_pdf_signer.py
Normal file
16
src/signing_service/infrastructure/pdf/fake_pdf_signer.py
Normal file
@ -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
|
||||
49
src/signing_service/infrastructure/pdf/pyhanko_pdf_signer.py
Normal file
49
src/signing_service/infrastructure/pdf/pyhanko_pdf_signer.py
Normal file
@ -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()
|
||||
@ -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")
|
||||
@ -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")
|
||||
@ -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
|
||||
18
src/signing_service/main.py
Normal file
18
src/signing_service/main.py
Normal file
@ -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"}
|
||||
58
tests/application/use_cases/test_sign_document.py
Normal file
58
tests/application/use_cases/test_sign_document.py
Normal file
@ -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)
|
||||
Loading…
Reference in New Issue
Block a user