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:
commit
f902ccbecb
26 changed files with 1824 additions and 0 deletions
19
.dockerignore
Normal file
19
.dockerignore
Normal 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
14
.gitignore
vendored
Normal 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
52
CLAUDE.md
Normal 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
31
Dockerfile
Normal 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
40
README.md
Normal 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
6
app/config.py
Normal 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
45
app/middleware.py
Normal 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
98
app/schemas.py
Normal 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
57
app/security.py
Normal 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
42
app/seguranca.py
Normal 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"
|
||||||
|
)
|
||||||
33
app/services/controle_api.py
Normal file
33
app/services/controle_api.py
Normal 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
47
app/services/cripto.py
Normal 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
704
app/services/holmes.py
Normal 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
7
app/v1/api.py
Normal 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
56
app/v1/compras.py
Normal 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
21
configs.example.json
Normal 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
9
deploy/Caddyfile.scardua
Normal 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
|
||||||
|
}
|
||||||
29
deploy/api-scardua.container
Normal file
29
deploy/api-scardua.container
Normal 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
81
docs/arquitetura.md
Normal 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
81
docs/conector.md
Normal 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
91
docs/deploy.md
Normal 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
55
docs/known-issues.md
Normal 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
108
main.py
Normal 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
75
pyproject.toml
Normal 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
23
requirements.txt
Normal 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
0
tests/test_holmes.py
Normal file
Loading…
Add table
Add a link
Reference in a new issue