feat: read-only internal endpoint for GTI Fiscal to mirror clients
GET /internal/fiscal/clients returns every CLIENTE (active and inactive)
as {external_client_id, code, display_name, active} — CLIENTE.ID is the
identity (CODIGO is editable, same reasoning as FUNCIONARIO_PAINEL.ID in
SSO). Authenticated by a dedicated bearer token (FISCAL_INTERNAL_API_TOKEN,
never reused from SESSION_SECRET/SSO key/admin password), rate-limited per
IP, returns 503 rather than opening anonymously when the token isn't
configured. GTI Gerenciador stays the single source of truth: the Fiscal
Hub never receives MySQL credentials.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
161da90e02
commit
b1f93c2d68
@@ -87,6 +87,28 @@ def listar_clientes(
|
||||
conn.close()
|
||||
|
||||
|
||||
def listar_clientes_para_fiscal() -> list[dict]:
|
||||
"""Para o endpoint interno GET /internal/fiscal/clients (app/internal_fiscal.py):
|
||||
o Fiscal espelha TODOS os clientes (ativos e inativos — inativo continua
|
||||
aparecendo em histórico do lado de lá), nunca só uma página. Payload
|
||||
mínimo: ID é a PK técnica estável (CODIGO é editável, não serve de
|
||||
identidade — mesma razão de FUNCIONARIO_PAINEL.ID no SSO). Sem CNPJ/
|
||||
endereço/contato: o Fiscal não precisa desses campos hoje."""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT ID, CODIGO, COALESCE(NULLIF(FANTASIA, ''), RAZAO) AS NOME, ATIVO "
|
||||
"FROM CLIENTE ORDER BY ID"
|
||||
)
|
||||
return [
|
||||
{"external_client_id": row["ID"], "code": row["CODIGO"], "display_name": row["NOME"], "active": bool(row["ATIVO"])}
|
||||
for row in cur.fetchall()
|
||||
]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def proximo_codigo_sugerido() -> str:
|
||||
"""
|
||||
Sugere o próximo CODIGO sequencial (maior CODIGO numérico + 1, com zeros
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""
|
||||
Endpoint interno server-to-server, read-only, para o GTI Fiscal espelhar o
|
||||
cadastro de clientes. GTI Gerenciador continua sendo a fonte única da
|
||||
verdade: o Fiscal NUNCA recebe credencial MySQL direta (nem lê a tabela
|
||||
CLIENTE por fora) — só chama esta rota, autenticada por um bearer token
|
||||
dedicado (FISCAL_INTERNAL_API_TOKEN), nunca reaproveitado de SESSION_SECRET,
|
||||
da chave privada do SSO, de senha de admin ou de token de Agent.
|
||||
|
||||
Rate limit em memória por IP de origem — mesmo espírito de app/rate_limit.py
|
||||
(container único, sem load balancer; ver ali o aviso sobre proxy reverso).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import os
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException, Request
|
||||
|
||||
from .db.clientes import listar_clientes_para_fiscal
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
MAX_TENTATIVAS = 10
|
||||
JANELA_SEGUNDOS = 5 * 60
|
||||
_falhas: dict[str, list[float]] = {}
|
||||
|
||||
|
||||
def _token_configurado() -> bool:
|
||||
return bool(os.environ.get("FISCAL_INTERNAL_API_TOKEN"))
|
||||
|
||||
|
||||
def _token_valido(fornecido: str | None) -> bool:
|
||||
esperado = os.environ.get("FISCAL_INTERNAL_API_TOKEN", "")
|
||||
if not esperado or not fornecido:
|
||||
return False
|
||||
return hmac.compare_digest(fornecido, esperado)
|
||||
|
||||
|
||||
def _bloqueado(origem: str, agora: float | None = None) -> bool:
|
||||
momento = agora if agora is not None else time.time()
|
||||
limite = momento - JANELA_SEGUNDOS
|
||||
return len([t for t in _falhas.get(origem, []) if t > limite]) >= MAX_TENTATIVAS
|
||||
|
||||
|
||||
def _registrar_falha(origem: str, agora: float | None = None) -> None:
|
||||
momento = agora if agora is not None else time.time()
|
||||
limite = momento - JANELA_SEGUNDOS
|
||||
recentes = [t for t in _falhas.get(origem, []) if t > limite]
|
||||
recentes.append(momento)
|
||||
_falhas[origem] = recentes
|
||||
|
||||
|
||||
@router.get("/internal/fiscal/clients")
|
||||
def listar_clientes_fiscal(request: Request, authorization: str | None = Header(default=None)):
|
||||
origem = request.client.host if request.client else "unknown"
|
||||
if _bloqueado(origem):
|
||||
raise HTTPException(status_code=429, detail="muitas tentativas")
|
||||
if not _token_configurado():
|
||||
# Sem token configurado = integração desligada, nunca "aberto por padrão".
|
||||
raise HTTPException(status_code=503, detail="FISCAL_INTERNAL_API_TOKEN não configurado")
|
||||
fornecido = authorization.removeprefix("Bearer ").strip() if authorization and authorization.startswith("Bearer ") else None
|
||||
if not _token_valido(fornecido):
|
||||
_registrar_falha(origem)
|
||||
raise HTTPException(status_code=401, detail="token inválido ou ausente")
|
||||
return {"clients": listar_clientes_para_fiscal()}
|
||||
@@ -12,6 +12,7 @@ from . import session
|
||||
from .asset_versao import ASSET_VERSAO
|
||||
from .auth import NaoAutenticado, UsuarioLogado, exigir_login
|
||||
from .csrf import CSRF_COOKIE_NAME, gerar_token
|
||||
from . import internal_fiscal
|
||||
from .routers import auth, chave_manual, clientes, contatos, funcionarios, log, minha_conta, sso
|
||||
|
||||
app = FastAPI(title="Painel de Clientes", docs_url=None, redoc_url=None, openapi_url=None)
|
||||
@@ -23,6 +24,7 @@ app.include_router(chave_manual.router)
|
||||
app.include_router(funcionarios.router)
|
||||
app.include_router(minha_conta.router)
|
||||
app.include_router(log.router)
|
||||
app.include_router(internal_fiscal.router)
|
||||
app.include_router(sso.router)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user