diff --git a/.env.example b/.env.example index 8deb02a..ab5e117 100644 --- a/.env.example +++ b/.env.example @@ -13,10 +13,20 @@ LOG_LEVEL=INFO # Secret manager # -------------------- SECRET_PROVIDER=infisical # fake | google | infisical -GCP_PROJECT_ID= # required if SECRET_PROVIDER=google + +# Infisical +INFISICAL_HOST=https://eu.infisical.com +INFISICAL_CLIENT_ID=35f83820-a9d3-4622-a0ab-ae6170f662fa +INFISICAL_CLIENT_SECRET=2c158d6f77fe4fc684bdb78d0c1ed21ed7a762885f508facc575aa05c42397c5 +INFISICAL_PROJECT_ID=0bd3c2e0-39f5-4f92-8c6e-49bcc7eda896 +INFISICAL_ENV_SLUG=dev +INFISICAL_TOKEN_AUTH=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZGVudGl0eUlkIjoiMmNjYmRkZmMtMGQyOC00ZmIwLTlhMGEtNWQyYTZiZWU3YzliIiwiaWRlbnRpdHlBY2Nlc3NUb2tlbklkIjoiZWMzNjg1OGUtNTkzYi00ZTE3LWI2ZmItN2NhODdlZDBhMmZhIiwiYXV0aFRva2VuVHlwZSI6ImlkZW50aXR5QWNjZXNzVG9rZW4iLCJpYXQiOjE3Njk2MTQ3NzQsImV4cCI6MTc3MjIwNjc3NH0.KZvzabbsgevhukPfc8LSlkOwos5eBaGvaqC2XPJEGHI + +# Google +GCP_PROJECT_ID= # -------------------- # PDF signing # -------------------- -PDF_CERT_SECRET_NAME=pdf-signing-cert -PDF_CERT_PASSWORD_SECRET_NAME=pdf-signing-cert-password \ No newline at end of file +PDF_CERT_SECRET_NAME=pdf_cert_pfx_b64 +PDF_CERT_PASSWORD_SECRET_NAME=pdf_cert_password \ No newline at end of file diff --git a/.infisical.json b/.infisical.json new file mode 100644 index 0000000..4d5caf4 --- /dev/null +++ b/.infisical.json @@ -0,0 +1,5 @@ +{ + "workspaceId": "0bd3c2e0-39f5-4f92-8c6e-49bcc7eda896", + "defaultEnvironment": "dev", + "gitBranchToEnvironmentMapping": null +} \ No newline at end of file diff --git a/docs/development-certificate.md b/docs/development-certificate.md new file mode 100644 index 0000000..b58095e --- /dev/null +++ b/docs/development-certificate.md @@ -0,0 +1,42 @@ +## 1. Generar un certificado de prueba (local) + +### 1.1 Generar clave privada y certificado + +```bash +openssl req -x509 -newkey rsa:2048 \ + -keyout dev.key \ + -out dev.crt \ + -days 90 \ + -nodes \ + -subj "/C=ES/O=ACME DEV/CN=ACME DEV TEST CERT" +``` + +Esto genera: +- `dev.key` → clave privada +- `dev.crt` → certificado autofirmado +- expiración: 90 días + +### 1.2 Crear el archivo PFX (PKCS#12) + +```bash +openssl pkcs12 -export \ + -out dev.pfx \ + -inkey dev.key \ + -in dev.crt \ + -password pass:devpassword +``` + +Resultado: + +- archivo: `dev.pfx` +- password: `devpassword` + +## 2. Convertir el certificado a base64 + +```bash +base64 dev.pfx > dev.pfx.b64 +``` + +Comprueba que: +- el archivo no esté vacío +- contiene texto base64 válido \ No newline at end of file diff --git a/docs/development-secrets.md b/docs/development-secrets.md new file mode 100644 index 0000000..ff39fc8 --- /dev/null +++ b/docs/development-secrets.md @@ -0,0 +1,185 @@ +# Development Secrets & Test Certificates + +Este documento describe **cómo generar y configurar secretos de prueba** para el +entorno `development` del Factuges Document Signing Service. + +⚠️ **IMPORTANTE** +Este procedimiento es **solo para desarrollo y pruebas internas**. +NO usar certificados reales ni secretos de producción. + +--- + +## Objetivo + +En `development` queremos: + +- Probar el flujo completo de firmado de PDFs +- Usar certificados de prueba (autofirmados) +- Almacenar secretos en Infisical +- Mantener el mismo flujo que en producción + +La validez legal **NO es un objetivo en development**. + +--- + +## Qué secretos necesita el Signing Service + +Para firmar documentos PDF se necesitan exactamente **dos secretos**: + +| Secreto | Descripción | +|------|------------| +| `PDF_CERT_PFX_B64` | Certificado PKCS#12 (`.pfx`) codificado en base64 | +| `PDF_CERT_PASSWORD` | Password del certificado | + +Estos secretos **solo existen en Infisical**, nunca en el repositorio. + +--- + +## 1. Generar un certificado de prueba (local) + +Usamos un certificado **autofirmado**, válido técnicamente pero **no legalmente**. + +### 1.1 Generar clave privada y certificado + +```bash +openssl req -x509 -newkey rsa:2048 \ + -keyout dev.key \ + -out dev.crt \ + -days 3600 \ + -nodes \ + -subj "/C=ES/O=ACME DEV/CN=ACME DEV TEST CERT" +``` + +Esto genera: + +- dev.key → clave privada +- dev.crt → certificado autofirmado +- expiración: 3600 días + +### 1.2 Crear el archivo PFX (PKCS#12) + +```bash +openssl pkcs12 -export \ + -out dev.pfx \ + -inkey dev.key \ + -in dev.crt \ + -password pass:devpassword +``` + + +Resultado: + +- archivo: dev.pfx +- password: devpassword +- ⚠️ Password solo para development + +## 2. Convertir el certificado a base64 + +```bash +base64 dev.pfx > dev.pfx.b64 +``` + +Comprueba que: + +- el archivo no esté vacío +- contiene texto base64 válido + +## 3. Guardar los secretos en Infisical (environment = development) + +### 3.1 Desde la UI de Infisical + +- Accede a Infisical Cloud +- Selecciona el Project +- Selecciona el Environment: development +- Ve a Secrets +- Crea los siguientes secretos: + +### Secreto 1 + +- Key: PDF_CERT_PFX_B64 +- Value: contenido de dev.pfx.b64 +- Type: secret + +### Secreto 2 + +- Key: PDF_CERT_PASSWORD +- Value: devpassword +- Type: secret + +Guarda los cambios. + +### 3.2 (Opcional) Usando Infisical CLI + +```bash +infisical secrets set PDF_CERT_PFX_B64="$(cat dev.pfx.b64)" +infisical secrets set PDF_CERT_PASSWORD="devpassword" +``` + + +##4. Configuración local del Signing Service + +Ejemplo de .env local: + +```bash +APP_ENV=development +SECRET_PROVIDER=infisical + +INFISICAL_PROJECT_ID=your_project_id +INFISICAL_ENV_SLUG=development +INFISICAL_TOKEN_AUTH=your_dev_token_auth + +PDF_CERT_PFX_SECRET_NAME=PDF_CERT_PFX_B64 +PDF_CERT_PASSWORD_SECRET_NAME=PDF_CERT_PASSWORD +``` + +***📌 El archivo .env NO debe commitearse.*** + +## 5. Comprobación manual + +- 1. Arranca el Signing Service +- 2. Llama al endpoint de firmado con un PDF de prueba +- 3. Abre el PDF firmado con: + - Adobe Reader + - Okular + - Foxit + +Resultado esperado: + +- La firma aparece como válida técnicamente +- El visor muestra advertencia: +``` +“El certificado no es de confianza” +``` + +***✔️ Esto es correcto en development.*** + +## 6. Casos de prueba recomendados + +En development se recomienda probar: + +### Certificado caducado + +- Generar un certificado con -days -1 +- Verificar que el servicio bloquea la firma + +### Password incorrecto + +- Cambiar PDF_CERT_PASSWORD +- Verificar error controlado + +### Certificado eliminado + +- Borrar los secretos en Infisical +- Verificar error CERT_NOT_CONFIGURED + +### Rotación manual +- Reemplazar PDF_CERT_PFX_B64 por otro certificado +- Verificar que el servicio sigue firmando + +## 7. Qué NO hacer nunca + +❌ No usar certificados reales +❌ No reutilizar secretos de producción +❌ No commitear .pfx, .key, .crt +❌ No loguear secretos +❌ No compartir tokens de Infisical \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 99f5cfc..5486a90 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = ["setuptools>=68", "wheel"] build-backend = "setuptools.build_meta" [project] -name = "signing-service" +name = "factuges-document-signing-service" version = "0.1.1" description = "FastAPI service for signing PDF documents using external secret managers" requires-python = ">=3.11" diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..b3ba5ae --- /dev/null +++ b/setup.cfg @@ -0,0 +1,15 @@ + +[metadata] +name = "factuges-document-signing-service" +version = "0.1.1" +description = "FastAPI service for signing PDF documents using external secret managers" +author = Rodax Software +author_email = info@rodax-software.com +long_description = file: README.md +long_description_content_type = text/markdown +url = https://factuges.app +license = "Propietaria" +classifiers = + Programming Language :: Python :: 3.11 + License :: OSI Approved :: MIT License + Operating System :: OS Independent \ No newline at end of file diff --git a/src/signing_service/api/routes/sign_document.py b/src/signing_service/api/routes/sign_document.py index cc7be22..37a3a9a 100644 --- a/src/signing_service/api/routes/sign_document.py +++ b/src/signing_service/api/routes/sign_document.py @@ -23,6 +23,8 @@ router = APIRouter(prefix="/documents", tags=["documents"]) async def sign_document( file: UploadFile = File(...), certificate_secret_name: str = Form(...), + certificate_password_secret_name: str = Form(...), + company_slug: str = Form(...), ): try: settings = get_settings() @@ -39,9 +41,11 @@ async def sign_document( command = SignDocumentCommand( pdf_bytes=pdf_bytes, certificate_secret_name=certificate_secret_name, + certificate_password_secret_name=certificate_password_secret_name, + company_slug=company_slug, ) - result = use_case.execute(command) + result = await use_case.execute(command) return Response( content=result.signed_pdf_bytes, diff --git a/src/signing_service/application/settings/app_settings.py b/src/signing_service/application/settings/app_settings.py index 06aa5e0..a21f29b 100644 --- a/src/signing_service/application/settings/app_settings.py +++ b/src/signing_service/application/settings/app_settings.py @@ -8,11 +8,19 @@ class AppSettings: # -------------------- app_env: str log_level: str + state_path: str + local_tz: str # -------------------- # Secret manager # -------------------- secret_provider: str + infisical_host: str + infisical_client_id: str | None + infisical_client_secret: str | None + infisical_project_id: str | None + infisical_env_slug: str | None + infisical_token_auth: str | None gcp_project_id: str | None # -------------------- diff --git a/src/signing_service/application/settings/app_settings_loader.py b/src/signing_service/application/settings/app_settings_loader.py index 71d59f5..c23439e 100644 --- a/src/signing_service/application/settings/app_settings_loader.py +++ b/src/signing_service/application/settings/app_settings_loader.py @@ -46,6 +46,16 @@ def load_app_settings() -> AppSettings: normalize=str.upper, ) + state_path = get_env( + "STATE_PATH", + default=".", + ) + + local_tz = get_env( + "LOCAL_TZ", + default="UTC", + ) + # -------------------- # Secret manager # -------------------- @@ -61,6 +71,36 @@ def load_app_settings() -> AppSettings: "Allowed values: fake | google | infisical" ) + infisical_host = get_env( + "INFISICAL_HOST", + default="https://eu.infisical.com", + ) + + infisical_client_id = get_env( + "INFISICAL_CLIENT_ID", + required=secret_provider == "infisical", + ) + + infisical_client_secret = get_env( + "INFISICAL_CLIENT_SECRET", + required=secret_provider == "infisical", + ) + + infisical_project_id = get_env( + "INFISICAL_PROJECT_ID", + required=secret_provider == "infisical", + ) + + infisical_env_slug = get_env( + "INFISICAL_ENV_SLUG", + required=secret_provider == "infisical", + ) + + infisical_token_auth = get_env( + "INFISICAL_TOKEN_AUTH", + required=secret_provider == "infisical", + ) + gcp_project_id = get_env( "GCP_PROJECT_ID", required=secret_provider == "google", @@ -85,7 +125,15 @@ def load_app_settings() -> AppSettings: return AppSettings( app_env=app_env, log_level=log_level, + state_path=state_path, + local_tz=local_tz, secret_provider=secret_provider, + infisical_host=infisical_host, + infisical_client_id=infisical_client_id, + infisical_client_secret=infisical_client_secret, + infisical_project_id=infisical_project_id, + infisical_env_slug=infisical_env_slug, + infisical_token_auth=infisical_token_auth, gcp_project_id=gcp_project_id, pdf_cert_secret_name=pdf_cert_secret_name, pdf_cert_password_secret_name=pdf_cert_password_secret_name, diff --git a/src/signing_service/application/settings/container.py b/src/signing_service/application/settings/container.py index 58246a2..fad8ebe 100644 --- a/src/signing_service/application/settings/container.py +++ b/src/signing_service/application/settings/container.py @@ -7,3 +7,4 @@ from signing_service.application.settings.app_settings_loader import load_app_se @lru_cache def get_settings() -> AppSettings: return load_app_settings() + diff --git a/src/signing_service/application/settings/setup_logger.py b/src/signing_service/application/settings/setup_logger.py new file mode 100644 index 0000000..1ad15b0 --- /dev/null +++ b/src/signing_service/application/settings/setup_logger.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +import logging +import os +import sys +from logging.handlers import RotatingFileHandler +from pathlib import Path +from typing import Optional, Union + + +def create_logger( + name: str = "factuges-document-signing-service", + *, + level: int = logging.INFO, + log_path: Optional[Union[str, Path]] = None, + max_bytes: int = 5_000_000, # rotación opcional + backup_count: int = 3, +) -> logging.Logger: + """ + Crea un logger consistente para FactuGES Document Signing Service. + + Reglas: + - SIEMPRE envia logs a stdout (Docker-friendly). + - SOLO en producción escribe también a fichero si `log_path` está definido. + - `log_path` puede ser `str` o `Path`. + - Evita duplicar handlers. + """ + + logger = logging.getLogger(name) + logger.setLevel(level) + + # Si ya está configurado, no duplicamos handlers + if logger.handlers: + return logger + + formatter = logging.Formatter( + "%(asctime)s - %(levelname)s - %(message)s" + ) + + # ------------------------------ + # 1) Handler de consola (siempre) + # ------------------------------ + h_console = logging.StreamHandler(sys.stdout) + h_console.setFormatter(formatter) + logger.addHandler(h_console) + + # ------------------------------ + # 2) Handler de fichero (solo prod) + # ------------------------------ + is_production = os.getenv("ENV") == "production" + + if log_path and is_production: + p = Path(log_path) + + # Aseguramos directorios + p.parent.mkdir(parents=True, exist_ok=True) + + # Puedes usar FileHandler simple, pero Rotating es más seguro. + h_file = RotatingFileHandler( + filename=str(p), + maxBytes=max_bytes, + backupCount=backup_count, + encoding="utf-8", + ) + h_file.setFormatter(formatter) + logger.addHandler(h_file) + + # Verificación explícita + try: + test_msg = f"Log file active at: {p}" + h_file.acquire() + h_file.stream.write(f"{test_msg}\n") + h_file.flush() + h_file.release() + + logger.info(test_msg) + except Exception as e: + logger.error(f"ERROR: cannot write to log file {p}: {e}") + raise + + return logger + + +# logger "global" ya creado +logger = create_logger() diff --git a/src/signing_service/application/settings/version.py b/src/signing_service/application/settings/version.py new file mode 100644 index 0000000..cf93359 --- /dev/null +++ b/src/signing_service/application/settings/version.py @@ -0,0 +1,8 @@ +from importlib.metadata import version, PackageNotFoundError + + +def get_package_version() -> str: + try: + return version("factuges-document-signing-service") # nombre del paquete en [metadata].name + except PackageNotFoundError: + return "unknown" diff --git a/src/signing_service/application/use_cases/sign_document.py b/src/signing_service/application/use_cases/sign_document.py index 60b3754..2ed597c 100644 --- a/src/signing_service/application/use_cases/sign_document.py +++ b/src/signing_service/application/use_cases/sign_document.py @@ -19,7 +19,7 @@ class SignDocumentUseCase: self._cert_secret_name = cert_secret_name self._cert_password_secret_name = cert_password_secret_name - def execute(self, command: SignDocumentCommand) -> SignDocumentResult: + async def execute(self, command: SignDocumentCommand) -> SignDocumentResult: certificate = self._secret_manager.get_secret( self._cert_secret_name ) @@ -28,7 +28,7 @@ class SignDocumentUseCase: self._cert_password_secret_name ) - signed_pdf = self._pdf_signer.sign( + signed_pdf = await self._pdf_signer.sign( pdf_bytes=command.pdf_bytes, certificate=certificate, password=password, diff --git a/src/signing_service/application/use_cases/sign_document_dto.py b/src/signing_service/application/use_cases/sign_document_dto.py index 29f554a..2ab8a8d 100644 --- a/src/signing_service/application/use_cases/sign_document_dto.py +++ b/src/signing_service/application/use_cases/sign_document_dto.py @@ -5,6 +5,8 @@ from dataclasses import dataclass class SignDocumentCommand: pdf_bytes: bytes certificate_secret_name: str + certificate_password_secret_name: str + company_slug: str @dataclass(frozen=True) diff --git a/src/signing_service/domain/ports/pdf_signer.py b/src/signing_service/domain/ports/pdf_signer.py index 4326d46..7fd85af 100644 --- a/src/signing_service/domain/ports/pdf_signer.py +++ b/src/signing_service/domain/ports/pdf_signer.py @@ -3,11 +3,17 @@ from abc import ABC, abstractmethod class PDFSignerPort(ABC): @abstractmethod - def sign( + async def sign( self, pdf_bytes: bytes, certificate: str, password: str, ) -> bytes: - """Sign a PDF and return signed PDF bytes.""" - raise NotImplementedError + """ + Signs a PDF and returns signed PDF bytes. + + - pdf_bytes: original PDF as bytes + - certificate: base64-encoded PKCS#12 (PFX) + - password: password for the PFX + """ + raise NotImplementedError \ No newline at end of file diff --git a/src/signing_service/infrastructure/factories/pdf_signer_factory.py b/src/signing_service/infrastructure/factories/pdf_signer_factory.py index 94b914f..0653ff7 100644 --- a/src/signing_service/infrastructure/factories/pdf_signer_factory.py +++ b/src/signing_service/infrastructure/factories/pdf_signer_factory.py @@ -13,9 +13,7 @@ def create_pdf_signer() -> PDFSignerPort: PyHankoPDFSigner, ) - if not settings.pdf_pfx_password: - raise ValueError("PDF_PFX_PASSWORD is required") + if not settings.pdf_cert_password_secret_name: + raise ValueError("PDF_CERT_PASSWORD_SECRET_NAME is required") - return PyHankoPDFSigner( - pfx_password=settings.pdf_pfx_password - ) + return PyHankoPDFSigner() diff --git a/src/signing_service/infrastructure/factories/secret_manager_factory.py b/src/signing_service/infrastructure/factories/secret_manager_factory.py index b8377b5..20551e5 100644 --- a/src/signing_service/infrastructure/factories/secret_manager_factory.py +++ b/src/signing_service/infrastructure/factories/secret_manager_factory.py @@ -1,9 +1,6 @@ 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 +from signing_service.infrastructure.secrets.fake_secret_manager import FakeSecretManager def create_secret_manager() -> SecretManagerPort: @@ -13,15 +10,30 @@ def create_secret_manager() -> SecretManagerPort: if provider == "fake": return FakeSecretManager( secrets={ - "pdf-signing-cert": "FAKE_CERTIFICATE_DATA" + "pdf-signing-cert": "FAKE_CERTIFICATE_DATA", + "pdf-signing-cert-password": "FAKE_CERTIFICATE_PASSWORD", + } ) - if provider == "infisical": + if provider == "infisical": + from signing_service.infrastructure.secrets.infisical_secret_manager import InfisicalSecretManager + + if not settings.infisical_project_id: + raise ValueError("INFISICAL_PROJECT_ID is required for Infisical Secret Manager") + + if not settings.infisical_token_auth: + raise ValueError("INFISICAL_TOKEN_AUTH is required for Infisical Secret Manager") + + if not settings.infisical_env_slug: + raise ValueError("INFISICAL_ENV_SLUG is required for Infisical Secret Manager") + return InfisicalSecretManager( - token=settings.infisical_token, + host=settings.infisical_host, + client_id=settings.infisical_client_id, + client_secret=settings.infisical_client_secret, project_id=settings.infisical_project_id, - environment=settings.infisical_environment, + environment=settings.infisical_env_slug, ) if provider == "google": diff --git a/src/signing_service/infrastructure/pdf/fake_pdf_signer.py b/src/signing_service/infrastructure/pdf/fake_pdf_signer.py index 2fed92d..f24ce9e 100644 --- a/src/signing_service/infrastructure/pdf/fake_pdf_signer.py +++ b/src/signing_service/infrastructure/pdf/fake_pdf_signer.py @@ -8,7 +8,7 @@ class FakePDFSigner(PDFSignerPort): It only simulates the behavior for testing and development. """ - def sign(self, pdf_bytes: bytes, certificate: str, password: str) -> bytes: + async 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() diff --git a/src/signing_service/infrastructure/pdf/pyhanko_pdf_signer.py b/src/signing_service/infrastructure/pdf/pyhanko_pdf_signer.py index 2c16d0b..a30b07b 100644 --- a/src/signing_service/infrastructure/pdf/pyhanko_pdf_signer.py +++ b/src/signing_service/infrastructure/pdf/pyhanko_pdf_signer.py @@ -1,18 +1,18 @@ import base64 +from csv import writer import io +from pydoc import doc from pyhanko.sign import signers -from pyhanko.sign.general import load_cert_from_pfx +from pyhanko.pdf_utils.incremental_writer import IncrementalPdfFileWriter 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: + async def sign(self, pdf_bytes: bytes, certificate: str, password: str,) -> bytes: """ + pdf_bytes: original PDF byte certificate: base64-encoded PKCS#12 (PFX) """ @@ -20,30 +20,23 @@ class PyHankoPDFSigner(PDFSignerPort): 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 - ), + signer = signers.SimpleSigner.load_pkcs12_data( + pkcs12_bytes=pfx_bytes, + passphrase=password.encode(), + other_certs=[], ) # 3️⃣ Preparar PDF - input_pdf = io.BytesIO(pdf_bytes) - output_pdf = io.BytesIO() + input_pdf = IncrementalPdfFileWriter(io.BytesIO(pdf_bytes)) # 4️⃣ Firmar - signers.sign_pdf( + signed_pdf: io.BytesIO = await signers.async_sign_pdf( input_pdf, signers.PdfSignatureMetadata( field_name="Signature1" ), signer=signer, - output=output_pdf, ) - return output_pdf.getvalue() + # 5️⃣ Obtener PDF firmado + return signed_pdf.getbuffer().tobytes() diff --git a/src/signing_service/infrastructure/secrets/infisical_secret_manager.py b/src/signing_service/infrastructure/secrets/infisical_secret_manager.py index 75fb132..4baeb45 100644 --- a/src/signing_service/infrastructure/secrets/infisical_secret_manager.py +++ b/src/signing_service/infrastructure/secrets/infisical_secret_manager.py @@ -1,17 +1,31 @@ +from http import client from infisical_sdk import InfisicalSDKClient from signing_service.domain.ports.secret_manager import SecretManagerPort +from signing_service.application.settings.setup_logger import logger class InfisicalSecretManager(SecretManagerPort): - def __init__(self, token: str, project_id: str, environment: str): - self._client = InfisicalSDKClient(token=token) + def __init__(self, host: str, client_id: str, client_secret: str, project_id: str, environment: str): + self._client = InfisicalSDKClient( + host=host, + token=None) + self._client.auth.universal_auth.login( + client_id=client_id, + client_secret=client_secret + ) self._project_id = project_id self._environment = environment - def get_secret(self, name: str) -> str: + + 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="/" + secret_path="/rodax/", + expand_secret_references=True, # Optional + view_secret_value=True, # Optional + include_imports=True, # Optional + version=None # Optional ) - return secret.secret_value \ No newline at end of file + + return secret.secretValue \ No newline at end of file diff --git a/src/signing_service/main.py b/src/signing_service/main.py index cde7e56..baec930 100644 --- a/src/signing_service/main.py +++ b/src/signing_service/main.py @@ -1,18 +1,56 @@ from fastapi import FastAPI from dotenv import load_dotenv +from pathlib import Path +from datetime import datetime +from dateutil import tz from signing_service.application.settings.container import get_settings -from signing_service.api.routes.sign_document import router as sign_router +from signing_service.application.settings.setup_logger import create_logger +from signing_service.api.routes.sign_document import router as sign_router +from signing_service.application.settings.version import get_package_version load_dotenv() - # 👇 FAIL FAST: load settings at startup -get_settings() +settings = get_settings() +version = get_package_version() +local_tz = tz.gettz(settings.local_tz) + +state_path = Path(settings.state_path) + +# Logging +log_dir = state_path / "logs" +log_dir.mkdir(parents=True, exist_ok=True) +logger = create_logger( + name="factuges-document-signing-service", + log_path=log_dir / "factuges-document-signing-service.log", # Solo lo genera en producción + +) + +logger.info("") +logger.info("============================================================") +logger.info("FactuGES Document Signing Service - START ") +logger.info("Version: %s", version) +logger.info("UTC Now: %s", datetime.utcnow().isoformat()) +logger.info("Environment: %s", settings.app_env) +logger.info("") +logger.info("Log Level: %s", settings.log_level) +logger.info("Log: %s", log_dir / "factuges-document-signing-service.log") +logger.info("") +logger.info("Secret Provider: %s", settings.secret_provider) +logger.info("Infisical project ID: %s", settings.infisical_project_id) +logger.info("Infisical environment: %s", settings.infisical_env_slug) +logger.info("") app = FastAPI(title="FactuGES Document Signing Service") -app.include_router(sign_router) +app.add_event_handler("startup", lambda: logger.info("Application startup complete")) +app.add_event_handler("shutdown", lambda: logger.info("Application shutdown complete")) +app.add_exception_handler(Exception, lambda request, exc: logger.error(f"Unhandled exception: {exc}")) -@app.get("/health") -def health_check() -> dict[str, str]: - return {"status": "ok"} + +# Register routers +app.include_router(sign_router) +logger.info("API routes registered from sign_document router") + +app.add_api_route("/health", lambda: {"status": "ok"}, methods=["GET"]) +logger.info("Health check endpoint registered at /health") diff --git a/src/signing_service/test_infisical.py b/src/signing_service/test_infisical.py new file mode 100644 index 0000000..13dac80 --- /dev/null +++ b/src/signing_service/test_infisical.py @@ -0,0 +1,79 @@ +import os +import sys + +from dotenv import load_dotenv + +from infisical_sdk import InfisicalSDKClient +from signing_service.application.settings.container import get_settings + +load_dotenv() + +def main() -> int: + settings = get_settings() + secret_name = os.environ.get("INFISICAL_SECRET_NAME", "pdf_cert_password") + secret_path = os.environ.get("INFISICAL_SECRET_PATH", "/rodax") + + missing = [k for k, v in { + "INFISICAL_SERVICE_TOKEN": settings.infisical_token_auth, + "INFISICAL_PROJECT_ID": settings.infisical_project_id, + }.items() if not v] + + if missing: + print(f"Missing env vars: {', '.join(missing)}", file=sys.stderr) + return 2 + + print("== Infisical smoke test ==") + + print(f"host=https://eu.infisical.com") + + print(f"client_id={settings.infisical_client_id}") + print(f"client_secret={settings.infisical_client_secret}") + print(f"project_id={settings.infisical_project_id}") + print(f"environment={settings.infisical_env_slug}") + print(f"secret_path={secret_path}") + print(f"secret_name={secret_name}") + + client = InfisicalSDKClient(host="https://eu.infisical.com", token=None) + + try: + # 1) Login + #client.auth.token_auth.login( + # token=settings.infisical_token_auth + #) + + client.auth.universal_auth.login( + client_id=settings.infisical_client_id, + client_secret=settings.infisical_client_secret + ) + + print("OK: token_auth.login") + + # 2) Read one secret (the real test) + secret = client.secrets.get_secret_by_name( + secret_name=secret_name, + project_id=settings.infisical_project_id, + environment_slug=settings.infisical_env_slug, + secret_path=secret_path, + view_secret_value=True, + ) + + print(secret) + + value = getattr(secret, "secretValue", None) + if not value: + print("ERROR: Secret read returned empty value", file=sys.stderr) + return 3 + + print("OK: get_secret_by_name") + print(f"OK: secret_value_length={len(value)}") + return 0 + + except Exception as e: + # Print the most useful info without dumping secrets + print("ERROR: Infisical smoke test failed", file=sys.stderr) + print(f"{type(e).__name__}: {e}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/application/use_cases/test_sign_document.py b/tests/application/use_cases/test_sign_document.py index a628597..9a6b410 100644 --- a/tests/application/use_cases/test_sign_document.py +++ b/tests/application/use_cases/test_sign_document.py @@ -30,6 +30,7 @@ def test_sign_document_success(): command = SignDocumentCommand( pdf_bytes=original_pdf, certificate_secret_name="pdf-signing-cert", + certificate_password_secret_name="pdf-signing-cert-password", ) # WHEN: ejecutamos el caso de uso