commit f902ccbecb3a27a40ed8094b6aa7e5ebc3cf4a63 Author: Ricardo Date: Tue Aug 18 06:05:03 2026 -0300 Conector Scardua — recebe da API da FLS e entrega no Oracle do cliente Servico que roda dentro da rede da Comercial Scardua e faz a ponte entre a API publica da FLS (na VPS) e o Oracle privado (10.16.x), inalcancavel pela internet. Fluxo: Holmes -> API na VPS (trata e cifra) -> ESTE conector (abre e valida) -> Oracle. Recebimento: - app/v1/compras.py: POST /v1/compras/dados - app/services/cripto.py: abre o envelope AES-256-GCM + RSA-OAEP-SHA256 com a chave privada. O GCM autentica: corpo adulterado levanta InvalidTag em vez de devolver lixo - app/schemas.py: modulo folha (so pydantic) com a config e o contrato PayloadCompras, que espelha o da API. Fora de sincronia devolve 422 de proposito, pra falhar explicito em vez de gravar dado torto Seguranca: - app/seguranca.py: header X-Token com compare_digest (nao vaza por tempo de resposta) + allowlist de IP da VPS - .gitignore barra configs.json.*, *.bak-*, *.pem e *.key Config: - app/config.py e so o carregamento do configs.json - o bloco "vps" guarda chave privada, token e ips permitidos Estado: ainda NAO persiste. Recebe, valida e descarta (persistido: false). O INSERT no Oracle e a proxima fase, e tem que ser MERGE por id_processo porque o Holmes reentrega webhook. Docs em docs/ — arquitetura, a decisao do conector (opcao A, com as alternativas descartadas), deploy e problemas conhecidos. Co-Authored-By: Claude Opus 5 (1M context) diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c3d2526 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,19 @@ +# Ambiente virtual / caches (nunca vao pra imagem) +.venv/ +__pycache__/ +*.pyc +.ruff_cache/ + +# Git +.git/ +.gitignore + +# SEGREDOS — nunca bakear na imagem (montar por volume em runtime) +configs.json +.env +certs/ + +# Ruido +logs/ +tests/ +*.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5381105 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +.venv/ +.ruff_cache +__pycache__/ +*.pyc +.env +logs/ +configs.json +certs/ + +# Backups de config e chaves — contem segredo, nunca versionar +configs.json.* +*.bak-* +*.pem +*.key diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8e98535 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,52 @@ +# Conector Scardua + +Serviço que roda **dentro da rede da Comercial Scardua**, recebe da API da FLS +(na VPS) o payload tratado e cifrado, abre e — na fase 4 — grava no Oracle privado. + +Visão completa e o porquê das decisões: **[docs/arquitetura.md](docs/arquitetura.md)**. + +> ⚠️ Este repo **mudou de papel**: era a API que roda na VPS. Essa parte saiu +> daqui. Se você achar código do Holmes (`app/services/holmes.py`, `app/v1/holmes/`), +> é resíduo — o conector **não fala com o Holmes**. + +## Stack +- Python ≥3.10 · FastAPI + Uvicorn (ASGI) +- Pydantic v2 (validação e config) +- **cryptography** — abre o envelope (RSA-OAEP-SHA256 + AES-256-GCM) +- oracledb — driver Oracle em **thin mode** (fase 4) +- `requirements.txt` = só runtime (usado no Dockerfile) + +## Estrutura +- `main.py` — app FastAPI, middleware, rotas raiz (`/health`, `/docs` protegido), logging +- `app/schemas.py` — **módulo folha** (só pydantic): config + contrato (`Envelope`, `PayloadCompras`) +- `app/config.py` — carrega o `configs.json` **no import** (precisa existir ou a app não sobe) +- `app/seguranca.py` — `X-Token` + allowlist de IP; dependência das rotas +- `app/services/cripto.py` — abre o envelope; espelho do lado que cifra, na API +- `app/v1/compras.py` — `POST /v1/compras/dados` +- `app/middleware.py` — `block_scanners`: denylist + heurística (NÃO é allowlist estrita) + +## O contrato com a API +`PayloadCompras` em `app/schemas.py` **espelha** o da API da VPS. Se um lado +mudar e o outro não, o conector devolve **422** — de propósito, pra falhar +explícito em vez de gravar dado torto. + +## Rodar local (Windows) +Precisa do `configs.json` na raiz (NÃO versionado — ver `configs.example.json`). +``` +uvicorn main:app --reload --port 8000 +``` + +## Config e segredos +- Tudo vem de `configs.json`, lido em `app/config.py`. Está no `.gitignore` e no + `.dockerignore` — **nunca commitar**. +- Ele guarda a **chave privada** do conector. É o segredo mais sensível do projeto: + quem tem ela lê todo payload que a VPS manda. +- Template versionável: `configs.example.json`. + +## Convenções +- Código, nomes e comentários em **português**. +- Lint/format: **ruff** (config no `pyproject.toml` — linha 120, aspas duplas). +- Logging: use `logging.info/error`; o nível vem de `settings.log_level`. + +## Problemas conhecidos +**[docs/known-issues.md](docs/known-issues.md)**. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..400f6f2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +# --------------------------------------------------------------------------- +# API - Scardua +# Imagem enxuta: thin mode do oracledb (sem Instant Client / sem libs nativas) +# --------------------------------------------------------------------------- +FROM python:3.12-slim + +# Nao gera .pyc e nao bufferiza o log (aparece na hora no docker logs) +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +# Dependencias primeiro para aproveitar o cache de camada do Docker +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Codigo da aplicacao +COPY main.py . +COPY app/ ./app/ + +EXPOSE 8000 + +# Healthcheck em exec-form (JSON, sem shell -> sem problema de aspas). +# O podman auto-update usa isto pra decidir rollback. +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD ["python3", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health').read()"] + +# ATENCAO: config/segredos NAO entram na imagem (.dockerignore). +# No deploy sao injetados via Quadlet (Volume do configs.json) -> ver deploy/api-scardua.container. +# Roda com 1 worker (VPS tem 2 vCPU; evitar paralelismo pesado). +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..f018495 --- /dev/null +++ b/README.md @@ -0,0 +1,40 @@ +# Conector Scardua + +Serviço que roda **dentro da rede da Comercial Scardua** e faz a ponte entre a +API pública da FLS (na VPS) e o **Oracle privado** do cliente (`10.16.x`). + +Recebe o payload **cifrado**, abre com a chave privada, valida e — quando a fase 2 +entrar — grava no Oracle. + +> ⚠️ **Este repositório mudou de papel.** Ele era a API que roda na VPS; essa +> parte migrou pro repositório da API (ver [docs/arquitetura.md](docs/arquitetura.md)). +> Aqui ficou o conector. + +## Onde ele fica no fluxo + +``` +Holmes --webhook--> [API na VPS da FLS] --cifra e envia--> [ESTE conector] --> [Oracle do cliente] + público, TLS rede privada do cliente +``` + +## Documentação + +| Doc | Assunto | +|-----|---------| +| [docs/arquitetura.md](docs/arquitetura.md) | Visão, atores e as duas pontas do sistema | +| [docs/conector.md](docs/conector.md) | A decisão do conector e por quê (fechada) | +| [docs/deploy.md](docs/deploy.md) | Como subir no servidor do cliente | +| [docs/known-issues.md](docs/known-issues.md) | Problemas conhecidos e TODOs | +| [CLAUDE.md](CLAUDE.md) | Guia rápido do código | + +## Rodar local (Windows) + +Precisa do `configs.json` na raiz — veja `configs.example.json`. + +``` +uvicorn main:app --reload --port 8000 +``` + +## Stack + +Python ≥3.10 · FastAPI + Uvicorn · Pydantic v2 · cryptography (RSA-OAEP + AES-GCM) · oracledb (thin mode) diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..f78f8a7 --- /dev/null +++ b/app/config.py @@ -0,0 +1,6 @@ +from pathlib import Path + +from app.schemas import Settings + +_PATH = Path(__file__).resolve().parent.parent / "configs.json" +settings = Settings.model_validate_json(_PATH.read_text(encoding="utf-8")) diff --git a/app/middleware.py b/app/middleware.py new file mode 100644 index 0000000..f41c9be --- /dev/null +++ b/app/middleware.py @@ -0,0 +1,45 @@ +import re + +from fastapi import Request, status +from fastapi.responses import Response + +BLOCKED_PATHS = { + "/", + "/metrics", + "/security.txt", + "/.env", + "/wp-admin", + "/wp-login.php", + "/admin", + "/config", + "/actuator", + "/nice%20ports%2C/Trinity.txt.bak", +} + +BLOCKED_UA_PATTERNS = re.compile( + r"(nmap|nikto|masscan|zgrab|censys|shodan|nuclei|httpx|gobuster|dirbuster)", + re.IGNORECASE, +) + + +async def block_scanners(request: Request, call_next): + path = request.url.path + ua = request.headers.get("user-agent", "") + + if ( + path.startswith("/v1/") + or path.startswith("/v2/") + or path in ("/health", "/dados_retorno", "/token") + ): + return await call_next(request) + + if path in BLOCKED_PATHS or path.endswith((".bak", ".env", ".git", ".php")): + return Response(status_code=status.HTTP_403_FORBIDDEN) + + if BLOCKED_UA_PATTERNS.search(ua): + return Response(status_code=status.HTTP_403_FORBIDDEN) + + if not ua: + return Response(status_code=status.HTTP_403_FORBIDDEN) + + return await call_next(request) diff --git a/app/schemas.py b/app/schemas.py new file mode 100644 index 0000000..e27940d --- /dev/null +++ b/app/schemas.py @@ -0,0 +1,98 @@ +""" +Modelos do conector. + +Modulo folha: nao importa nada do projeto, so pydantic. Config e contrato +moram aqui; quem le o configs.json e o app/config.py. +""" + +from datetime import datetime + +from pydantic import BaseModel + +# --------------------------------------------------------------------------- +# Config +# --------------------------------------------------------------------------- + + +class ApiConfig(BaseModel): + port: int + ambiente: str + workers: int + + +class DocsConfig(BaseModel): + user: str + password: str + + +class BancoConfig(BaseModel): + """Oracle do cliente. Fase 2 — o conector ainda nao insere.""" + + user: str + password: str + dns: str + + +class VpsConfig(BaseModel): + """Como a VPS da FLS se identifica e como abrimos o que ela manda.""" + + # Chave PRIVADA (PEM). Abre o envelope cifrado com a nossa publica. + # Nunca sai daqui — e o que garante que so o conector le o payload. + chave_privada: str + + # Segredo compartilhado, esperado no header X-Token. Sem isso a rota + # fica aberta pra quem souber a URL. + token: str + + # Allowlist de origem. Vazio = desligado (util em teste local). + ips_permitidos: list[str] = [] + + +class Settings(BaseModel): + api: ApiConfig + docs: DocsConfig + vps: VpsConfig + banco: BancoConfig | None = None + log_level: str = "INFO" + + +# --------------------------------------------------------------------------- +# Contrato com a API da VPS +# --------------------------------------------------------------------------- + + +class Envelope(BaseModel): + """O que chega no corpo do POST, ainda cifrado.""" + + alg: str + chave: str + nonce: str + dados: str + + +class Parcela(BaseModel): + valor: float + vencimento: datetime | None = None + + +class PayloadCompras(BaseModel): + """ + O que sai de dentro do envelope depois de decifrado. + + Espelha o PayloadCompras da API da VPS. Se um lado mudar, o outro tem + que mudar junto — e o jeito de descobrir e o teste, nao a producao. + """ + + # Chave de deduplicacao: id do processo no Holmes. O MERGE no Oracle + # vai por aqui, senao reentrega de webhook vira linha duplicada. + id_processo: str + + protocolo: str + cnpj: str + pedido_linx: str + fornecedor: str + tipo: str + nf_entrada: str | None = None + aprovador: str + valor_total: float + parcelas: dict[int, Parcela] diff --git a/app/security.py b/app/security.py new file mode 100644 index 0000000..a8c323d --- /dev/null +++ b/app/security.py @@ -0,0 +1,57 @@ +from datetime import datetime, timedelta, timezone + +import jwt +from app.config import settings +from app.infra.database import db_instance +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer + +ALGORITHM = "HS256" +TOKEN_EXPIRE_MINUTES = 5 + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/token") + + +def criar_token(data: dict) -> str: + payload = data.copy() + payload["exp"] = datetime.now(timezone.utc) + timedelta( + minutes=TOKEN_EXPIRE_MINUTES + ) + return jwt.encode(payload, settings.api.jwt_secret, algorithm=ALGORITHM) + + +def verificar_credenciais(client_id: str, client_secret: str) -> bool: + db_instance.create_pool() + assert db_instance.pool is not None + conn = db_instance.pool.acquire() + try: + cursor = conn.cursor() + cursor.execute( + """ + SELECT 1 + FROM orvel_ti.CAD_USUARIO + WHERE PERFIL_TI = 'operador_ti' + AND ativo = 'S' + AND API = 'S' + AND login = :login + AND senha = :senha + """, + {"login": client_id, "senha": client_secret}, + ) + return cursor.fetchone() is not None + finally: + db_instance.pool.release(conn) + + +async def token_valido(token: str = Depends(oauth2_scheme)) -> dict: + try: + payload = jwt.decode(token, settings.api.jwt_secret, algorithms=[ALGORITHM]) + return payload + except jwt.ExpiredSignatureError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expirado" + ) + except jwt.InvalidTokenError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Token inválido" + ) diff --git a/app/seguranca.py b/app/seguranca.py new file mode 100644 index 0000000..c97c4c9 --- /dev/null +++ b/app/seguranca.py @@ -0,0 +1,42 @@ +""" +Quem pode falar com o conector. + +Este servico escreve no banco de producao do cliente — e alvo de alto valor. +As duas travas aqui sao o minimo: segredo compartilhado e origem conhecida. +Nao e seguranca por obscuridade (URL secreta nao conta). +""" + +import logging +import secrets + +from app.config import settings +from fastapi import Header, HTTPException, Request, status + + +def _origem(request: Request) -> str: + """ + IP de origem. + + Se um dia entrar proxy na frente, o IP do socket vira o do proxy — ai o + uvicorn precisa de --proxy-headers e isto passa a ler X-Forwarded-For. + """ + return request.client.host if request.client else "" + + +async def autorizar(request: Request, x_token: str = Header(default="")) -> None: + """Dependencia de rota: barra quem nao for a VPS.""" + permitidos = settings.vps.ips_permitidos + if permitidos: + ip = _origem(request) + if ip not in permitidos: + logging.warning(f"Origem recusada: {ip}") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="origem nao autorizada" + ) + + # compare_digest pra nao vazar o segredo por tempo de resposta. + if not secrets.compare_digest(x_token, settings.vps.token): + logging.warning(f"Token invalido vindo de {_origem(request)}") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="token invalido" + ) diff --git a/app/services/controle_api.py b/app/services/controle_api.py new file mode 100644 index 0000000..62c4649 --- /dev/null +++ b/app/services/controle_api.py @@ -0,0 +1,33 @@ +# app/services/api_tracker.py +import logging + + +def registrar_contador( + conn, api, endpoint, metodo, status_code, sucesso, duracao_ms, erro=None +): + """Grava uma linha na tabela contadores_api.""" + cursor = conn.cursor() + try: + cursor.execute( + """ + INSERT INTO orvel_ti.contadores_api + (api, endpoint, metodo_http, status_code, sucesso, duracao_ms, mensagem_erro) + VALUES + (:api, :endpoint, :metodo, :status, :sucesso, :duracao, :erro) + """, + { + "api": api, + "endpoint": endpoint, + "metodo": metodo, + "status": status_code, + "sucesso": "S" if sucesso else "N", + "duracao": duracao_ms, + "erro": erro[:500] if erro else None, + }, + ) + conn.commit() + except Exception as e: + conn.rollback() + logging.error(f"Falha ao gravar contador: {e}") + finally: + cursor.close() diff --git a/app/services/cripto.py b/app/services/cripto.py new file mode 100644 index 0000000..cda032b --- /dev/null +++ b/app/services/cripto.py @@ -0,0 +1,47 @@ +""" +Abertura do envelope que a API da VPS manda. + +Espelho do app/services/cripto.py de la, so que do lado que DECIFRA. +Envelope hibrido: AES-256-GCM nos dados, RSA-OAEP na chave AES. + +O GCM autentica: se o corpo for adulterado no caminho, o decrypt levanta +excecao em vez de devolver lixo. Ou seja, decifrou = veio integro. +""" + +import base64 +import json + +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import padding +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + +ALGORITMO = "RSA-OAEP-256+A256GCM" + +_OAEP = padding.OAEP( + mgf=padding.MGF1(algorithm=hashes.SHA256()), + algorithm=hashes.SHA256(), + label=None, +) + + +def carregar_chave_privada(pem: str, senha: bytes | None = None): + return serialization.load_pem_private_key(pem.encode(), password=senha) + + +def descriptografar(envelope: dict, chave_privada) -> dict: + """ + Abre o envelope e devolve o dict original. + + Levanta se o algoritmo nao for o esperado, se a chave nao for a par da + publica usada la, ou se o conteudo tiver sido mexido. + """ + if envelope.get("alg") != ALGORITMO: + raise ValueError(f"algoritmo inesperado: {envelope.get('alg')!r}") + + chave_aes = chave_privada.decrypt(base64.b64decode(envelope["chave"]), _OAEP) + corpo = AESGCM(chave_aes).decrypt( + base64.b64decode(envelope["nonce"]), + base64.b64decode(envelope["dados"]), + None, + ) + return json.loads(corpo) diff --git a/app/services/holmes.py b/app/services/holmes.py new file mode 100644 index 0000000..b4020ea --- /dev/null +++ b/app/services/holmes.py @@ -0,0 +1,704 @@ +import asyncio +import json +import logging +import os +import random +import re +import tempfile +import time + +import httpx +from app.config import settings +from app.services.controle_api import registrar_contador +from oracledb import Connection + +_TOKEN_FILE = os.path.join(tempfile.gettempdir(), "holmes_token_cache.json") + + +def _ler_token_arquivo() -> dict | None: + try: + with open(_TOKEN_FILE) as f: + return json.load(f) + except Exception: + return None + + +def _salvar_token_arquivo(token: dict): + try: + with open(_TOKEN_FILE, "w") as f: + json.dump(token, f) + except Exception: + pass + + +def _invalidar_token_usuario(): + try: + os.remove(_TOKEN_FILE) + except Exception: + pass + + +async def token_usuario(): + cached = _ler_token_arquivo() + if cached: + return cached + await asyncio.sleep(random.uniform(0, 0.5)) + cached = _ler_token_arquivo() + if cached: + return cached + url = "https://app-api.holmesdoc.io/v1/session" + body = {"email": settings.holmes.usuario , "password": settings.holmes.senha} + headers = {"Content-Type": "application/json", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36"} + async with httpx.AsyncClient() as client: + response = await client.post(url, json=body, headers=headers) + response.raise_for_status() + token = {"authorization": response.json()["token"]} + _salvar_token_arquivo(token) + return token + + +async def get_header() -> tuple[dict, str]: + if random.randint(1, 1) >= 2: + return {"api_token": settings.holmes.token_api}, "holmes" + header = await token_usuario() + return header, "holmes_user" + + +async def _holmes_request( + method: str, url: str, **kwargs +) -> tuple[httpx.Response, str]: + api_nome: str = "holmes" + for tentativa in range(2): + header, api_nome = await get_header() + async with httpx.AsyncClient() as client: + response = await getattr(client, method)(url, headers=header, **kwargs) + if ( + response.status_code == 401 + and api_nome == "holmes_user" + and tentativa == 0 + ): + _invalidar_token_usuario() + continue + return response, api_nome + raise RuntimeError("Holmes: falha de autenticação após retry") + + +def extrair_origem(origem: str): + """Extrair origem da string do Holmes + + Args: + origem (str): 1548 Entrada de Freio + + Returns: + str: 1548 + """ + return str(origem[:4]) + + +def extrair_empresa(unidade: str): + """Extrai a empresa da string inteira do holmes + + Args: + unidade (str): Ex 10.1 Hyundai Teix. Freitas + + Returns: + empresa: 10 | None + revenda: 1 | None + """ + match = re.search(r"^(\d+)\.(\d+)", unidade) + if match: + return match.group(1), match.group(2) + return None, None + + +def extrair_transacao(transacao: str) -> str: + """Extrai apenas a parte transação do texto do Holmes + + Args: + transacao (str): D15 Entrada de Nota + + Returns: + str: D15 + """ + return transacao[:3] + + +async def get_holmes_process(id_processo: str, conn: Connection): + """ + Busca um processo no Holmes. Centralizado para Peças e Despesas. + """ + url = f"https://app-api.holmesdoc.io/v1/processes/{id_processo}" + inicio = time.perf_counter() + status = None + sucesso = False + erro = None + api_nome = "holmes" + + try: + response, api_nome = await _holmes_request("get", url) + response.raise_for_status() + status = response.status_code + sucesso = True + return response.json() + except httpx.HTTPStatusError as e: + status = e.response.status_code + erro = str(e) + logging.error(f"Erro na API Holmes (ID {id_processo}): {e}") + return None + except Exception as e: + erro = str(e) + logging.error(f"Erro inesperado ao consultar Holmes: {e}") + return None + finally: + duracao = (time.perf_counter() - inicio) * 1000 + registrar_contador( + conn=conn, + api=api_nome, + endpoint="/v1/processes/{id}", + metodo="GET", + status_code=status, + sucesso=sucesso, + duracao_ms=duracao, + erro=erro, + ) + + +async def get_holmes_process_details(id_processo: str, conn: Connection): + """ + Busca os detalhes (properties) de um processo no Holmes. + """ + url = f"https://app-api.holmesdoc.io/v1/processes/{id_processo}/details" + inicio = time.perf_counter() + status = None + sucesso = False + erro = None + api_nome = "holmes" + + try: + response, api_nome = await _holmes_request("get", url) + response.raise_for_status() + status = response.status_code + sucesso = True + return response.json() + except httpx.HTTPStatusError as e: + status = e.response.status_code + erro = str(e) + logging.error(f"Erro na API Holmes details (ID {id_processo}): {e}") + return None + except Exception as e: + erro = str(e) + logging.error(f"Erro inesperado ao consultar Holmes details: {e}") + return None + finally: + duracao = (time.perf_counter() - inicio) * 1000 + registrar_contador( + conn=conn, + api=api_nome, + endpoint="/v1/processes/{id}/details", + metodo="GET", + status_code=status, + sucesso=sucesso, + duracao_ms=duracao, + erro=erro, + ) + + +async def get_holmes_history(id_processo: str, conn: Connection): + """ + Busca o historico no holmes (importante para pegar o id da ultima task, para avançar futuramente) + Args: + id_processo (str): id do processo no holmes + """ + url = f"https://app-api.holmesdoc.io/v1/processes/{id_processo}/history" + payload = { + "filters": [], + "page": 1, + "per_page": 100, + "sortBy": ["created_at", "desc"], + } + inicio = time.perf_counter() + status = None + sucesso = False + erro = None + api_nome = "holmes" + + try: + response, api_nome = await _holmes_request("post", url, json=payload) + response.raise_for_status() + status = response.status_code + sucesso = True + return response.json() + except httpx.HTTPStatusError as e: + status = e.response.status_code + erro = str(e) + logging.error(f"Erro na API Holmes (ID {id_processo}): {e}") + return None + except Exception as e: + erro = str(e) + logging.error(f"Erro inesperado ao consultar Holmes: {e}") + return None + finally: + duracao = (time.perf_counter() - inicio) * 1000 + registrar_contador( + conn=conn, + api=api_nome, + endpoint="/v1/processes/{id}/history", + metodo="POST", + status_code=status, + sucesso=sucesso, + duracao_ms=duracao, + erro=erro, + ) + + +async def get_holmes_rateio(id_processo: str, conn: Connection): + url = f"https://app-api.holmesdoc.io/v1/processes/{id_processo}/tables/e124b2d0-ee14-11ef-95b4-25dee32fe73f/table_items?page=1&per_page=800" + inicio = time.perf_counter() + status = None + sucesso = False + erro = None + api_nome = "holmes" + + try: + response, api_nome = await _holmes_request("get", url) + response.raise_for_status() + status = response.status_code + sucesso = True + return response.json() + except httpx.HTTPStatusError as e: + status = e.response.status_code + erro = str(e) + logging.error(f"Erro na API Holmes (ID {id_processo}): {e}") + return None + except Exception as e: + erro = str(e) + logging.error(f"Erro inesperado ao consultar Holmes: {e}") + return None + finally: + duracao = (time.perf_counter() - inicio) * 1000 + registrar_contador( + conn=conn, + api=api_nome, + endpoint="/v1/processes/{id}/table/(rateio)", + metodo="GET", + status_code=status, + sucesso=sucesso, + duracao_ms=duracao, + erro=erro, + ) + + +async def task_id_recente(id_processo: str, conn: Connection): + dados_tasks = await get_holmes_history(id_processo, conn) + + if ( + not dados_tasks + or "histories" not in dados_tasks + or not dados_tasks["histories"] + ): + return None + + mais_recente = max(dados_tasks["histories"], key=lambda x: x["created_at"]) + + return mais_recente["properties"]["task_id"] + + +async def task_mais_recente(id_processo: str, conn: Connection): + dados_tasks = await get_holmes_history(id_processo, conn) + + if not dados_tasks or "histories" not in dados_tasks: + return None # Tratamento se a API falhar + + # print(dados_tasks) + + mais_recente = max(dados_tasks["histories"], key=lambda x: x["created_at"]) + + return mais_recente + + +async def historicos_task(id_processo: str, conn: Connection) -> dict | None: + """_summary_ + + Args: + id_processo (str): Id do processo no holmes + + Returns: + dict | None : dicionario do historico | None + """ + dados_tasks = await get_holmes_history(id_processo, conn) + if not dados_tasks or "histories" not in dados_tasks: + return None # Tratamento se a API falhar + + return dados_tasks + + +async def buscar_processo( + conn: Connection, + chave: str | None = None, + fluxos: list[str] | bool = False, + ativos: bool = True, + payload: dict | bool = False, +) -> dict: + """Obtem os processos que existem com a sua chave + + Args: + chave (str): Chave principal a ser procurada, preferencialmente unica pfvr, ajuda ae po. + ativos (bool) Defaults to True + fluxos (list[str] | bool, optional): _description_. Defaults to False. se quer pegar de um fluxo específico ou geral. Padrão: Geral + + Returns: + dict: _description_ + """ + if not payload and chave is None: + raise ValueError( + "Chave é obrigatória caso o payload não seja enviado chefia, fica alerta ae rapa" + ) + + inicio = time.perf_counter() + status = None + sucesso = False + erro = None + api_nome = "holmes" + + url = "https://app-api.holmesdoc.io/v2/search" + + if not payload: + payload = { + "query": { + "from": 0, + "size": 200, + "context": "process", + "sort": "updated_at", + "order": "desc", + "groups": [ + { + "match_all": True, + "terms": [ + { + "value": f"{chave}", + "type": "match_phrase", + "field": "_content", + } + ], + } + ], + }, + "trash": False, + "deleted_by_me": False, + } + try: + response, api_nome = await _holmes_request("post", url, json=payload) + response.raise_for_status() + status = response.status_code + sucesso = True + dados = response.json() + docs = dados.get("docs", []) + if ativos: + docs = [d for d in docs if d.get("status") != "canceled"] + if fluxos: + docs = [d for d in docs if d.get("name") in fluxos] + return {"status": True, "dados": {**dados, "docs": docs, "total": len(docs)}} + except httpx.HTTPStatusError as e: + status = e.response.status_code + erro = str(e) + logging.error(f"Erro na API Holmes (ID {chave}): {e}") + return {"status": False, "error": e} + except Exception as e: + erro = str(e) + logging.error(f"Erro inesperado ao consultar Holmes: {e}") + return {"status": False, "error": e} + finally: + duracao = (time.perf_counter() - inicio) * 1000 + registrar_contador( + conn=conn, + api=api_nome, + endpoint="/v1/processes/{id}/search/por-chave", + metodo="POST", + status_code=status, + sucesso=sucesso, + duracao_ms=duracao, + erro=erro, + ) + + +async def buscar_processo_por_chaves( + conn: Connection, + combinacoes: list[list[str]], + fluxos: list[str] | bool = False, + ativos: bool = True, +) -> dict: + """Busca processos no Holmes usando combinações de termos. + + Cada item de combinacoes é uma lista de valores que juntos identificam + um processo único (ex: [cnpj, numero_nf]). Cada combinação vira um group + separado na query. + + Args: + combinacoes: Ex: [["03657256000164", "19"], ["698cd1c570fd0f8f5f8436a4"]] + ativos: Ignora processos cancelados. Padrão: True. + fluxos: Filtra por nome de fluxo. Padrão: False (todos). + """ + url = "https://app-api.holmesdoc.io/v2/search" + inicio = time.perf_counter() + status = None + sucesso = False + erro = None + api_nome = "holmes" + + payload = { + "query": { + "from": 0, + "size": 200, + "context": "process", + "sort": "updated_at", + "order": "desc", + "groups": [ + { + "match_all": True, + "terms": [ + {"value": termo, "type": "match_phrase", "field": "_content"} + for termo in combinacao + ], + } + for combinacao in combinacoes + ], + }, + "trash": False, + "deleted_by_me": False, + } + + try: + response, api_nome = await _holmes_request("post", url, json=payload) + response.raise_for_status() + status = response.status_code + sucesso = True + dados = response.json() + docs = dados.get("docs", []) + if ativos: + docs = [d for d in docs if d.get("status") != "canceled"] + if fluxos: + docs = [d for d in docs if d.get("name") in fluxos] + return {"status": True, "dados": {**dados, "docs": docs, "total": len(docs)}} + except httpx.HTTPStatusError as e: + status = e.response.status_code + erro = str(e) + logging.error(f"Erro na API Holmes (combinacoes {combinacoes}): {e}") + return {"status": False, "error": e} + except Exception as e: + erro = str(e) + logging.error(f"Erro inesperado ao consultar Holmes: {e}") + return {"status": False, "error": e} + finally: + duracao = (time.perf_counter() - inicio) * 1000 + registrar_contador( + conn=conn, + api=api_nome, + endpoint="/v1/processes/{id}/search/por-chaves", + metodo="POST", + status_code=status, + sucesso=sucesso, + duracao_ms=duracao, + erro=erro, + ) + + +async def action(payload: dict, id_task: str, id_processo: str, conn: Connection): + url = f"https://app-api.holmesdoc.io/v1/tasks/{id_task}/action" + inicio = time.perf_counter() + status = None + sucesso = False + erro = None + api_nome = "holmes" + + try: + async with httpx.AsyncClient() as client: + response = await client.post( + url, headers={"api_token": settings.holmes.token_api}, json=payload + ) + response.raise_for_status() + status = response.status_code + sucesso = True + return True, response.json() + except httpx.HTTPStatusError as e: + status = e.response.status_code + erro = str(e) + logging.error(f"Erro na API Holmes (ID {id_processo}): {e}") + return False, e + except Exception as e: + erro = str(e) + logging.error(f"Erro inesperado ao consultar Holmes: {e}") + return False, e + finally: + duracao = (time.perf_counter() - inicio) * 1000 + registrar_contador( + conn=conn, + api=api_nome, + endpoint="/v1/processes/{id}/action", + metodo="POST", + status_code=status, + sucesso=sucesso, + duracao_ms=duracao, + erro=erro, + ) + + +async def cria_processo( + id_start: str, + payload: dict, + conn: Connection +) -> tuple[bool, dict | str]: + url = f"https://app-api.holmesdoc.io/v1/workflows/{id_start}/start" + inicio = time.perf_counter() + status = None + sucesso = False + erro = None + api_nome = "holmes" + + try: + response, api_nome = await _holmes_request("post", url, json=payload) + response.raise_for_status() + status = response.status_code + sucesso = True + return True, response.json() + except httpx.HTTPStatusError as e: + status = e.response.status_code + erro = str(e) + logging.error(f"Erro na API Holmes - Criar Processo ({payload}): {e}") + return False, str(e) + except Exception as e: + erro = str(e) + logging.error(f"Erro inesperado ao criar processo no Holmes: {e}") + return False, str(e) + finally: + duracao = (time.perf_counter() - inicio) * 1000 + registrar_contador( + conn=conn, + api=api_nome, + endpoint="/v1/workflows/{id}/start", + metodo="POST", + status_code=status, + sucesso=sucesso, + duracao_ms=duracao, + erro=erro, + ) + + +async def enviar_documento( + id_processo: str, + arquivo: bytes, + nome_arquivo: str, + id_documento: str, + conn: Connection, +) -> tuple[bool, str | dict]: + task_id = await task_id_recente(id_processo, conn) + + if not task_id: + return False, "Não foi possível obter a task mais recente do Holmes" + + url = f"https://app-api.holmesdoc.io/v1/tasks/{task_id}/documents/{id_documento}" + inicio = time.perf_counter() + status = None + sucesso = False + erro = None + api_nome = "holmes" + + try: + files = {"file": (nome_arquivo, arquivo, "application/pdf")} + async with httpx.AsyncClient() as client: + response = await client.post( + url, headers={"api_token": settings.holmes.token_api}, files=files + ) + response.raise_for_status() + status = response.status_code + sucesso = True + return True, {"task_id": task_id} + except httpx.HTTPStatusError as e: + status = e.response.status_code + erro = str(e) + logging.error(f"Erro ao enviar documento Holmes (ID {id_processo}): {e}") + return False, str(e) + except Exception as e: + erro = str(e) + logging.error(f"Erro inesperado ao enviar documento Holmes: {e}") + return False, str(e) + finally: + duracao = (time.perf_counter() - inicio) * 1000 + registrar_contador( + conn=conn, + api=api_nome, + endpoint="/v1/tasks/{id}/documents/{id_documento}", + metodo="POST", + status_code=status, + sucesso=sucesso, + duracao_ms=duracao, + erro=erro, + ) + + +# Para testar as funcoes + +# async def main(): +# from app.infra.database import db_instance +# db_instance.create_pool() +# conn = db_instance.pool.acquire() +# try: +# print(await get_holmes_history('69e65ea83fad950fad5715ed', conn)) +# finally: +# db_instance.pool.release(conn) + +# if __name__ == '__main__': +# import asyncio +# asyncio.run(main()) + + +async def cancela_processo( + id_processo: str, conn: Connection +) -> tuple[bool, str | dict]: + url = f"https://app-api.holmesdoc.io/v1/processes/{id_processo}/cancel" + payload = {"reason": "Erro na emissão, data de vencimento. Problema na Disal."} + inicio = time.perf_counter() + status = None + sucesso = False + erro = None + api_nome = "holmes" + + try: + response, api_nome = await _holmes_request("put", url, json=payload) + response.raise_for_status() + status = response.status_code + sucesso = True + return True, response.json() if response.content else { + "mensagem": "processo cancelado" + } + except httpx.HTTPStatusError as e: + status = e.response.status_code + erro = str(e) + logging.error(f"Erro ao cancelar processo Holmes (ID {id_processo}): {e}") + return False, str(e) + except Exception as e: + erro = str(e) + logging.error(f"Erro inesperado ao cancelar processo Holmes: {e}") + return False, str(e) + finally: + duracao = (time.perf_counter() - inicio) * 1000 + registrar_contador( + conn=conn, + api=api_nome, + endpoint="/v1/processes/{id}/cancel", + metodo="PUT", + status_code=status, + sucesso=sucesso, + duracao_ms=duracao, + erro=erro, + ) + + +async def main(): + # print(aaaa()) + print(await token_usuario()) + + +if __name__ == "__main__": + import asyncio + + asyncio.run(main()) diff --git a/app/v1/api.py b/app/v1/api.py new file mode 100644 index 0000000..38fae12 --- /dev/null +++ b/app/v1/api.py @@ -0,0 +1,7 @@ +from app.v1.compras import router as compras +from fastapi import APIRouter + +api_router = APIRouter() + +# Rota final: POST /v1/compras/dados +api_router.include_router(compras) diff --git a/app/v1/compras.py b/app/v1/compras.py new file mode 100644 index 0000000..719ba8d --- /dev/null +++ b/app/v1/compras.py @@ -0,0 +1,56 @@ +import logging + +from app.config import settings +from app.schemas import Envelope, PayloadCompras +from app.seguranca import autorizar +from app.services import cripto +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import ValidationError + +router = APIRouter(prefix="/compras", tags=["compras"]) + +# Carregada uma vez no import: parsear PEM a cada request e desperdicio. +_CHAVE = cripto.carregar_chave_privada(settings.vps.chave_privada) + + +@router.post("/dados", dependencies=[Depends(autorizar)]) +async def receber_compras(envelope: Envelope) -> dict: + """ + Recebe o envelope cifrado da API da VPS, abre e valida. + + Fase atual: so registra o que chegou. O INSERT no Oracle entra depois — + quando entrar, tem que ser MERGE por id_processo, senao reentrega de + webhook duplica linha. + """ + try: + bruto = cripto.descriptografar(envelope.model_dump(), _CHAVE) + except Exception as e: + # Chave errada, envelope adulterado ou algoritmo diferente. + logging.error(f"Falha ao abrir envelope: {type(e).__name__}: {e}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="envelope invalido" + ) + + try: + dados = PayloadCompras.model_validate(bruto) + except ValidationError as e: + # Decifrou mas o formato mudou: os dois lados sairam de sincronia. + logging.error(f"Payload fora do contrato: {e}") + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="payload nao bate com o contrato esperado", + ) + + logging.info( + f"Compra recebida | processo={dados.id_processo} " + f"protocolo={dados.protocolo} cnpj={dados.cnpj} " + f"pedido={dados.pedido_linx} total={dados.valor_total} " + f"parcelas={len(dados.parcelas)}" + ) + + return { + "status": "recebido", + "id_processo": dados.id_processo, + "parcelas": len(dados.parcelas), + "persistido": False, # vira True quando o Oracle entrar + } diff --git a/configs.example.json b/configs.example.json new file mode 100644 index 0000000..bf5f762 --- /dev/null +++ b/configs.example.json @@ -0,0 +1,21 @@ +{ + "api": { + "port": 7168, + "ambiente": "homolog", + "workers": 4 + }, + "docs": { + "user": "usuario_docs", + "password": "senha_docs" + }, + "vps": { + "chave_privada": "-----BEGIN PRIVATE KEY-----\nCOLE_AQUI_A_CHAVE_PRIVADA_EM_PEM\n-----END PRIVATE KEY-----\n", + "token": "SEGREDO_COMPARTILHADO_COM_A_VPS", + "ips_permitidos": ["179.197.230.154"] + }, + "banco": { + "user": "SEU_USUARIO_ORACLE", + "password": "SUA_SENHA_ORACLE", + "dns": "10.16.152.30:1521/service_name" + } +} diff --git a/deploy/Caddyfile.scardua b/deploy/Caddyfile.scardua new file mode 100644 index 0000000..cde5961 --- /dev/null +++ b/deploy/Caddyfile.scardua @@ -0,0 +1,9 @@ +# Adicione este bloco ao Caddyfile da VPS e recarregue o Caddy. +# O Caddy termina o TLS (Let's Encrypt) e faz proxy pro container na rede fls. +# +# PRE-REQUISITO: registro DNS A do subdominio -> IP da VPS, +# senao o Caddy nao consegue emitir o certificado. + +api-scardua.flstecnologia.tech { + reverse_proxy api-scardua:8000 +} diff --git a/deploy/api-scardua.container b/deploy/api-scardua.container new file mode 100644 index 0000000..e5d6e46 --- /dev/null +++ b/deploy/api-scardua.container @@ -0,0 +1,29 @@ +# Quadlet do Podman -> o systemd (usuario) converte isto em api-scardua.service +# Instalar em: ~/.config/containers/systemd/api-scardua.container +# Aplicar com: systemctl --user daemon-reload && systemctl --user start api-scardua + +[Unit] +Description=API Scardua + +[Container] +# AJUSTE para o teu usuario/org no Forgejo +Image=git.flstecnologia.tech//api-scardua:latest +AutoUpdate=registry +ContainerName=api-scardua + +# Somente a rede do proxy (Caddy alcanca por "api-scardua:8000"). +# NAO entra na db-net: esta API usa Oracle EXTERNO, nao o Postgres da VPS. +Network=fls.network + +# configs.json montado read-only. :Z reetiqueta o arquivo pro rootless/SELinux. +# Coloque o arquivo real em ~/.config/api-scardua/configs.json no servidor. +Volume=%h/.config/api-scardua/configs.json:/app/configs.json:ro,Z + +# Healthcheck vem embutido na imagem (HEALTHCHECK no Dockerfile) -> o +# podman auto-update usa pra decidir rollback. + +[Service] +Restart=always + +[Install] +WantedBy=default.target diff --git a/docs/arquitetura.md b/docs/arquitetura.md new file mode 100644 index 0000000..08586e0 --- /dev/null +++ b/docs/arquitetura.md @@ -0,0 +1,81 @@ +# Arquitetura e Visão — Conector Scardua + +> Documento da **ideia**: o quê, o porquê e o roadmap. Salvo pra não reexplicar +> toda sessão. Atualizar quando a visão evoluir. + +## Em uma frase + +O conector roda **dentro da rede da Scardua**, recebe da API da FLS o dado já +tratado e cifrado, abre e grava no **Oracle privado** do cliente. + +## Os atores + +- **FLS Tecnologia (dev / nós)** — hospeda a **API** na própria VPS + (`api-scardua.flstecnologia.tech`). Ela recebe o webhook do Holmes, trata e cifra. +- **Comercial Scardua (cliente)** — tem a conta no **Holmes** e o **Oracle** + (IP privado `10.16.x`, inalcançável pela internet). **Este conector roda no + servidor dele.** +- **Holmes** — SaaS de workflow de documentos; dispara o webhook pra API da FLS. + +## O fluxo completo + +``` +Holmes + │ webhook (público, TLS) + ▼ +API na VPS da FLS ← repositório da API + │ · valida o payload do Holmes + │ · traduz pro contrato do cliente + │ · CIFRA com a chave pública do conector + │ POST + X-Token (público, TLS) + ▼ +CONECTOR no servidor da Scardua ← ESTE repositório + │ · confere token e IP de origem + │ · ABRE com a chave privada + │ · valida o contrato + │ · MERGE por id_processo + ▼ +Oracle 10.16.x (rede privada) +``` + +## As decisões e o PORQUÊ + +**Por que a API fica na VPS da FLS, e não no servidor do cliente:** + +1. **O Holmes precisa de endpoint público e estável** pros webhooks. Damos isso + nós, com domínio e TLS próprios — sem depender de o cliente ter. +2. **O código Python fica sob nosso controle**, na nossa infra. + +**Consequência técnica:** o Oracle está em rede privada, então a VPS **não +alcança o banco direto**. Daí este conector — a ponte entre o público e o privado. + +**Por que o payload vai cifrado ponta a ponta,** mesmo já tendo TLS: o conteúdo é +financeiro (CNPJ, notas, parcelas, vencimentos). Cifrando com a chave pública do +conector, só ele abre — nem proxy no caminho, nem log de corpo de requisição, +nem quem tiver acesso à VPS consegue ler. Detalhes em [conector.md](conector.md). + +**Por que `id_processo` viaja no payload:** o Holmes reentrega webhook. Sem chave +de deduplicação, cada reentrega vira linha duplicada no Oracle. + +## Estado atual + +| # | Fase | Estado | +|---|---|---| +| 1 | **API na VPS** — recebe do Holmes, trata, cifra | ✅ no ar | +| 2 | **Conector recebe** — token, decifra, valida, loga | ✅ feito | +| 3 | **API envia** — `POST` da VPS pro conector (`app/services/api_cliente.py` lá) | ⬜ a fazer | +| 4 | **Conector grava** — pool Oracle + `MERGE` por `id_processo` | ⬜ a fazer | +| 5 | **CI/CD** — Forgejo Actions + registry + auto-update | ⬜ desenhado | + +Hoje o dado chega, é validado e **descartado** (`persistido: false` na resposta). +Serve pra homologar o circuito; não persiste nada ainda. + +## Pontos em aberto + +- **Onde o conector é publicado** — precisa ser alcançável pela VPS. Porta + liberada no firewall da Scardua? Domínio? IP fixo? +- **Política de retry do Holmes** — quantas vezes reentrega e em quais status. + A estratégia atual (erro sobe → Holmes reenvia) depende disso; sem retry dele, + vai ser preciso persistência intermediária. +- **Usuário Oracle de privilégio mínimo** — só `INSERT`/`MERGE` nas tabelas do + fluxo, nada de `SELECT` geral nem DDL. diff --git a/docs/conector.md b/docs/conector.md new file mode 100644 index 0000000..9a16c46 --- /dev/null +++ b/docs/conector.md @@ -0,0 +1,81 @@ +# Conector com o cliente — decisão FECHADA + +> Este doc guardava as opções em aberto. A decisão foi tomada: **opção A**. +> Ficam registradas as alternativas e o porquê da escolha. + +## O problema + +O Oracle da Scardua está em **rede privada** (`10.16.x`), inalcançável da +internet. A API roda na VPS da FLS (pública). Algo precisa rodar as queries no +Oracle **de dentro** da rede do cliente. Esse algo é este projeto. + +## A decisão: opção A — conector que RECEBE + +Direção **VPS → conector**. A API trata o dado, cifra e faz `POST` aqui; o +conector abre e grava no Oracle. + +- ✅ A VPS **não armazena nada** — é passagem +- ✅ Simples: recebe e insere +- ⚠️ O conector fica **exposto** — mitigado pelas travas abaixo + +### As alternativas descartadas + +**B) Conector outbound (pull)** — o conector perguntaria pra VPS "tem trabalho?". +Sem rota pública no cliente, mais firewall-friendly. Descartada porque exigiria a +VPS manter fila de pendências (storage que ela não tem) e mais lógica de polling. + +**C) Túnel / VPN** — a VPS alcançaria o Oracle direto. Descartada porque poria a +senha do Oracle na VPS e daria acesso **amplo** à rede do cliente. + +## Travas de segurança (implementadas) + +Um endpoint público que escreve no banco de produção é alvo de alto valor. O que +está no código hoje: + +| Trava | Onde | Estado | +|---|---|---| +| **TLS** | proxy na frente do conector | ⬜ depende da publicação | +| **Token compartilhado** — header `X-Token`, comparado com `secrets.compare_digest` (não vaza por tempo) | `app/seguranca.py` | ✅ | +| **Allowlist de IP** — só a VPS (`179.197.230.154`) | `app/seguranca.py` + `configs.json` | ✅ (vazio = desligado, ligar em produção) | +| **Payload cifrado ponta a ponta** | `app/services/cripto.py` | ✅ | +| **Validação de schema** | `PayloadCompras` em `app/schemas.py` | ✅ | +| **Usuário Oracle de privilégio mínimo** | — | ⬜ fase 4 | +| **Rate limiting** | — | ⬜ | + +## A criptografia + +Envelope híbrido — **AES-256-GCM** nos dados, **RSA-OAEP-SHA256** na chave AES: + +```json +{ + "alg": "RSA-OAEP-256+A256GCM", + "chave": "", + "nonce": "", + "dados": "" +} +``` + +**Por que não RSA puro:** RSA-2048 com OAEP-SHA256 cifra no máximo ~190 bytes, e +o payload de compras passa disso. Envelope é o padrão (mesmo do TLS, PGP e JWE). + +**O GCM autentica:** se o corpo for adulterado no caminho, o decrypt levanta +`InvalidTag` em vez de devolver lixo. Decifrou = veio íntegro. + +**As chaves:** a **privada** fica só aqui, no `configs.json` (gitignored). A +**pública** correspondente vai no `configs.json` da VPS. Hoje é um par de +**homologação** — trocar por um definitivo antes de produção. + +## Idempotência ⭐ + +O Holmes reentrega webhook. O `id_processo` (id do processo no Holmes) viaja no +payload justamente pra ser a chave de deduplicação. Quando o `INSERT` entrar, +**tem que ser `MERGE`** — senão cada reentrega vira linha duplicada. + +## Durabilidade + +Escolhido: **erro sobe, Holmes reentrega.** O conector fora do ar → a API devolve +erro → o Holmes reenvia depois. O Holmes é a fila; por isso a API **nunca** pode +responder 200 pra algo que não chegou aqui. + +⚠️ Depende da política de retry do Holmes, que **ainda não foi confirmada**. Se +ele não reentregar, vai ser preciso persistência intermediária na VPS. diff --git a/docs/deploy.md b/docs/deploy.md new file mode 100644 index 0000000..2a32c37 --- /dev/null +++ b/docs/deploy.md @@ -0,0 +1,91 @@ +# Deploy — Conector Scardua + +> ⚠️ O runbook da **VPS da FLS** (Podman/Quadlet/Caddy, `api-scardua.flstecnologia.tech`) +> **saiu daqui** — ele pertence ao repositório da API. Este doc é do conector, +> que roda no **servidor da Scardua**. + +## Onde ele roda + +No servidor do cliente, com acesso à rede privada `10.16.x` onde está o Oracle. +Precisa ser **alcançável pela VPS** (`179.197.230.154`) — é a VPS que faz o `POST`. + +## Pré-requisitos + +| Item | Detalhe | +|---|---| +| Python | ≥3.10, ou Podman/Docker se for containerizado | +| Rede | porta de entrada liberada **só** pro IP da VPS | +| TLS | proxy na frente (Caddy/nginx) — a VPS manda payload financeiro | +| Oracle | usuário de **privilégio mínimo**: só `INSERT`/`MERGE` nas tabelas do fluxo | + +## configs.json + +Nunca versionado. Ver `configs.example.json`. Os campos críticos: + +```json +"vps": { + "chave_privada": "-----BEGIN PRIVATE KEY-----\n...", + "token": "", + "ips_permitidos": ["179.197.230.154"] +} +``` + +- **`chave_privada`** — o segredo mais sensível do projeto. Quem tem ela lê todo + payload. Permissão `600`, nunca em repo, nunca em imagem. +- **`token`** — tem que ser **o mesmo** configurado na VPS (header `X-Token`). +- **`ips_permitidos`** — deixar vazio só em teste local. **Em produção, preencher.** + +## As chaves + +Par RSA-2048. A **privada** fica aqui; a **pública** vai no `configs.json` da VPS. +Para gerar um par novo: + +```bash +openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out privada.pem +openssl rsa -in privada.pem -pubout -out publica.pem +``` + +O par em uso hoje é de **homologação** — trocar antes de produção, e trocar os +dois lados juntos (senão a VPS cifra com uma pública que este conector não abre). + +## Subir + +```bash +uvicorn main:app --host 0.0.0.0 --port 8000 --workers 2 +``` + +Se for container, o `Dockerfile` da raiz serve — mas o `configs.json` tem que ser +montado por **volume**, nunca copiado pra imagem (o `.dockerignore` já barra). + +```bash +podman build --format docker -t conector-scardua:latest . +``` + +> ⚠️ **`--format docker` é obrigatório**: no formato OCI (padrão do Podman) o +> `HEALTHCHECK` do Dockerfile é **silenciosamente ignorado**. + +## Verificar que está de pé + +```bash +curl -s http://localhost:8000/health # -> {"status":"ok"} +``` + +E o caminho real, com o token: + +```bash +curl -X POST http://localhost:8000/v1/compras/dados \ + -H "X-Token: " \ + -H "Content-Type: application/json" \ + -d '' +``` + +Respostas esperadas: **200** recebido · **401** token errado · **403** IP fora da +allowlist · **400** envelope não abriu (chave errada ou adulterado) · **422** +payload fora do contrato. + +## Comandos úteis + +```bash +journalctl -u conector-scardua -f # se rodar via systemd +podman logs -f conector-scardua # se rodar em container +``` diff --git a/docs/known-issues.md b/docs/known-issues.md new file mode 100644 index 0000000..af3b66d --- /dev/null +++ b/docs/known-issues.md @@ -0,0 +1,55 @@ +# Problemas conhecidos / TODO — Conector Scardua + +## Bloqueadores da fase 4 (gravar no Oracle) + +- **Não existe camada de conexão.** `app/infra/database.py` é referenciado por + `app/security.py` mas **nunca existiu**. Nada abre pool no caminho que roda. +- **O conector não persiste nada.** Hoje ele recebe, decifra, valida e **descarta** + (responde `persistido: false`). O circuito está homologado; o `INSERT` não. +- Quando o `INSERT` entrar, **tem que ser `MERGE` por `id_processo`** — o Holmes + reentrega webhook e sem isso cada reentrega duplica linha. + +## Resíduo do papel antigo + +Este repo era a API que roda na VPS. Sobrou código que o conector **não usa**: + +- `app/services/holmes.py` — o conector não fala com o Holmes +- `app/v1/holmes/` — **pasta vazia**; os `.py` já foram apagados, o git ainda + registra as remoções como pendentes +- `app/services/controle_api.py` — telemetria no Oracle; só volta a fazer sentido + na fase 4 +- `app/security.py` — JWT + auth via Oracle. **Não é usado**; a autenticação do + conector é o `app/seguranca.py` (token + IP). Continua com os defeitos antigos: + importa `db_instance` (inexistente) e usa `settings.api.jwt_secret` (que não + existe no `Settings` novo). + +Nada disso é importado no boot, então não quebra — mas é peso morto a limpar. + +## Segurança + +- **`ips_permitidos` está vazio** no `configs.json` atual (desligado, pra teste + local). **Preencher com `179.197.230.154` antes de produção.** +- **O par de chaves é de homologação.** Gerar um definitivo e trocar nos dois + lados juntos. +- **Sem TLS ainda** — depende de como o conector for publicado. A VPS manda CNPJ, + notas e vencimentos; o envelope protege o conteúdo, mas o token viaja no header. +- **Sem rate limiting.** +- `/docs`, `/redoc` e `/openapi.json` ficam expostos quando `ambiente != prod` + (atrás de basic auth). Fechar com `ambiente = prod` se não precisar. +- Se entrar proxy na frente, o IP de origem vira o do proxy — a allowlist para de + funcionar. Aí o uvicorn precisa de `--proxy-headers` e o `app/seguranca.py` + passa a ler `X-Forwarded-For`. + +## Middleware + +- `block_scanners` é **denylist + heurística**, não allowlist estrita: qualquer + path fora da blocklist com User-Agent normal **passa** e vira 404 na app. + Não vaza nada, mas é mais permissivo do que parece. + +## Empacotamento + +- `pyproject.toml` aponta `readme = "README.md"` — o arquivo existe, mas confira + se `pip install .` funciona; o Docker usa `requirements.txt` justamente pra + não depender disso. +- `PyJWT` está nas dependências mas só era usado pelo `app/security.py`, que + saiu de circulação. diff --git a/main.py b/main.py new file mode 100644 index 0000000..d890922 --- /dev/null +++ b/main.py @@ -0,0 +1,108 @@ +import json +import logging +import secrets + +from app.config import settings +from app.middleware import block_scanners +from app.v1.api import api_router +from fastapi import Depends, FastAPI, HTTPException, status +from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html +from fastapi.security import HTTPBasic, HTTPBasicCredentials + +# --- Logging da aplicacao --- +# Sem isto, os logging.info(...) das rotas sao engolidos: o root logger vem em +# WARNING por padrao, entao INFO nao aparece no `podman logs`. +logging.basicConfig( + level=settings.log_level, + format="%(asctime)s %(levelname)s %(message)s", +) + + +# Filtra o acesso do healthcheck (/health a cada 30s) pra nao poluir o log. +class _FiltraHealthAccess(logging.Filter): + def filter(self, record: logging.LogRecord) -> bool: + return "/health" not in record.getMessage() + + +logging.getLogger("uvicorn.access").addFilter(_FiltraHealthAccess()) + +is_prod = settings.api.ambiente.lower() == "prod" + +app = FastAPI( + title='API - Scardua', + version='0.0.1', + docs_url=None, + redoc_url=None, + openapi_url=None, +) + +app.middleware("http")(block_scanners) +app.include_router(api_router, prefix="/v1") + +# --- Documentação protegida por login/senha (somente fora de produção) --- +if not is_prod: + _docs_security = HTTPBasic() + + def _verificar_docs( + credenciais: HTTPBasicCredentials = Depends(_docs_security), + ) -> str: + usuario_ok = secrets.compare_digest( + credenciais.username.encode("utf-8"), + settings.docs.user.encode("utf-8"), + ) + senha_ok = secrets.compare_digest( + credenciais.password.encode("utf-8"), + settings.docs.password.encode("utf-8"), + ) + if not (usuario_ok and senha_ok): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Credenciais inválidas", + headers={"WWW-Authenticate": "Basic"}, + ) + return credenciais.username + + @app.get("/openapi.json", include_in_schema=False) + def _openapi_protegido(_: str = Depends(_verificar_docs)): + return app.openapi() + + @app.get("/docs", include_in_schema=False) + def _swagger_protegido(_: str = Depends(_verificar_docs)): + return get_swagger_ui_html(openapi_url="/openapi.json", title=app.title) + + @app.get("/redoc", include_in_schema=False) + def _redoc_protegido(_: str = Depends(_verificar_docs)): + return get_redoc_html(openapi_url="/openapi.json", title=app.title) + +@app.get("/health", include_in_schema=False) +def health(): + """Healthcheck usado pelo Podman (auto-update / rollback).""" + return {"status": "ok"} + + +@app.post('/dados_retorno') +def dados_retorno(dados_retorno: dict): + """ + Rota para vizualizar qualquer retorno do holmes + """ + logging.info("Dados retornados do teste") + logging.info(json.dumps(dados_retorno, ensure_ascii=False)) + return dados_retorno + +if __name__ == "__main__": + import uvicorn + + port = settings.api.port + ambiente = settings.api.ambiente + workers = settings.api.workers + is_reload = ambiente.lower() != "prod" + + logging.info("API ligada.") + uvicorn.run( + "main:app", + host="0.0.0.0", + port=port, + log_level="info", + workers=workers, + reload=is_reload, + ) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4f71c96 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,75 @@ +[project] +name = "api-scardua" +version = "0.1.0" +description = "API de integracao com servicos externos (Holmes, Apollo) e banco Oracle" +requires-python = ">=3.10" +readme = "README.md" + +# --------------------------------------------------------------------------- +# Dependencias principais — necessarias para a aplicacao rodar +# (todas sao importadas no carregamento dos modulos, portanto obrigatorias) +# --------------------------------------------------------------------------- +dependencies = [ + "fastapi>=0.115", # Framework da API + "uvicorn[standard]>=0.34", # Servidor ASGI + "pydantic>=2.0", # Validacao / configs (app/config.py) + "httpx>=0.27", # Chamadas HTTP as APIs externas (Holmes etc.) + "oracledb>=2.0", # Driver Oracle moderno (substitui cx_Oracle) +] + +# --------------------------------------------------------------------------- +# Dependencias opcionais +# pip install .[dev] → ferramentas de desenvolvimento +# --------------------------------------------------------------------------- +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "pytest-cov>=6.0", + "pytest-asyncio>=0.24", + "ruff>=0.8", + "httpx>=0.27", # Necessario para o TestClient do FastAPI +] + +# --------------------------------------------------------------------------- +# Configuracao do pytest +# --------------------------------------------------------------------------- +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] # Permite "from app...." a partir da raiz +addopts = "-v --tb=short" +asyncio_mode = "auto" + +# --------------------------------------------------------------------------- +# Configuracao do Ruff (linter + formatter) +# --------------------------------------------------------------------------- +[tool.ruff] +target-version = "py310" +line-length = 120 +src = ["app", "tests"] + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "N", # pep8-naming + "UP", # pyupgrade +] +ignore = [ + "E501", # line too long (controlado pelo formatter) +] + +[tool.ruff.format] +quote-style = "double" + +# --------------------------------------------------------------------------- +# Build system +# --------------------------------------------------------------------------- +[build-system] +requires = ["setuptools>=75.0", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +where = ["."] +include = ["app*"] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e91e3d8 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,23 @@ +# gerado em 2026-08-16 11:39 | origem: pip freeze | python 3.13.13 +annotated-doc==0.0.4 +annotated-types==0.7.0 +anyio==4.14.2 +certifi==2026.6.17 +cffi==2.1.0 +click==8.4.2 +colorama==0.4.6 +cryptography==49.0.0 +fastapi==0.139.0 +h11==0.16.0 +httpcore==1.0.9 +httpx==0.28.1 +idna==3.18 +oracledb==4.0.1 +pycparser==3.0 +pydantic==2.13.4 +pydantic_core==2.46.4 +PyJWT==2.13.0 +starlette==1.3.1 +typing-inspection==0.4.2 +typing_extensions==4.16.0 +uvicorn==0.51.0 diff --git a/tests/test_holmes.py b/tests/test_holmes.py new file mode 100644 index 0000000..e69de29