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) <noreply@anthropic.com>
This commit is contained in:
Ricardo 2026-08-18 06:05:03 -03:00
commit f902ccbecb
26 changed files with 1824 additions and 0 deletions

19
.dockerignore Normal file
View file

@ -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

14
.gitignore vendored Normal file
View file

@ -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

52
CLAUDE.md Normal file
View file

@ -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)**.

31
Dockerfile Normal file
View file

@ -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"]

40
README.md Normal file
View file

@ -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)

6
app/config.py Normal file
View file

@ -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"))

45
app/middleware.py Normal file
View file

@ -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)

98
app/schemas.py Normal file
View file

@ -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]

57
app/security.py Normal file
View file

@ -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"
)

42
app/seguranca.py Normal file
View file

@ -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"
)

View file

@ -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()

47
app/services/cripto.py Normal file
View file

@ -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)

704
app/services/holmes.py Normal file
View file

@ -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())

7
app/v1/api.py Normal file
View file

@ -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)

56
app/v1/compras.py Normal file
View file

@ -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
}

21
configs.example.json Normal file
View file

@ -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"
}
}

9
deploy/Caddyfile.scardua Normal file
View file

@ -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
}

View file

@ -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 <owner> para o teu usuario/org no Forgejo
Image=git.flstecnologia.tech/<owner>/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

81
docs/arquitetura.md Normal file
View file

@ -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.

81
docs/conector.md Normal file
View file

@ -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": "<chave AES cifrada com a pública do conector, base64>",
"nonce": "<nonce do GCM, base64>",
"dados": "<JSON cifrado + tag de autenticação, base64>"
}
```
**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.

91
docs/deploy.md Normal file
View file

@ -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": "<segredo compartilhado com a VPS>",
"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: <o token do configs.json>" \
-H "Content-Type: application/json" \
-d '<envelope gerado pela API da VPS>'
```
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
```

55
docs/known-issues.md Normal file
View file

@ -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.

108
main.py Normal file
View file

@ -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,
)

75
pyproject.toml Normal file
View file

@ -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*"]

23
requirements.txt Normal file
View file

@ -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

0
tests/test_holmes.py Normal file
View file