Pular para conteúdo

ADR-004: Multi-tenant via UUID em DB

Status: ✅ Accepted (2026-05)

Contexto

Até v1.x o sistema era single-tenant:

  • Uma única API_KEY em variável de ambiente (.env)
  • Todos os EAs usavam a mesma chave
  • Sem como restringir símbolos/timeframes por assinante
  • Sem como rastrear quem está usando quanto (uso, IPs, heartbeats)
  • Revogar acesso de 1 assinante = trocar chave de TODOS

Com a abertura para vendas (Eduzz), precisamos de:

  1. Identidade única por assinante (provisionamento via webhook)
  2. Permissões granulares (plano basic vs pro vs owner)
  3. Rate limiting por assinante (não global)
  4. Auditoria (last_seen_at, IPs distintos)
  5. Revogação isolada (cancelar 1 não afeta os outros)

Decisão

Implementar multi-tenancy DB-backed com api_keys table e UUID por assinante.

Schema

-- docker/init.sql
CREATE TABLE IF NOT EXISTS api_keys (
    key_id          BIGSERIAL PRIMARY KEY,
    user_name       TEXT        NOT NULL,
    email           TEXT        NOT NULL,
    api_key         UUID        NOT NULL DEFAULT gen_random_uuid(),
    plan_tier       TEXT        NOT NULL DEFAULT 'basic',
    symbols_allowed JSONB,      -- NULL = todos
    tfs_allowed     JSONB,      -- NULL = todos
    active          BOOLEAN     NOT NULL DEFAULT TRUE,
    expires_at      TIMESTAMPTZ,
    last_seen_at    TIMESTAMPTZ,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    notes           TEXT,
    CONSTRAINT api_keys_key_unique UNIQUE (api_key)
);

Fast path + Slow path

# app/api/main.py
async def require_api_key(request):
    key = request.headers.get("X-API-Key", "")

    # Fast path: owner key (env var) — nunca toca no DB
    if not API_KEY or key == API_KEY:
        request.state.key_info = KeyInfo("owner", None, None)
        return

    # Slow path: verifica DB
    info = await get_api_key_info(pool, key)
    if not info:
        raise HTTPException(401, "Invalid or expired API key")
    request.state.key_info = KeyInfo(
        plan_tier=info["plan_tier"],
        symbols_allowed=_parse(info["symbols_allowed"]),
        tfs_allowed=_parse(info["tfs_allowed"]),
        expires_at=info["expires_at"],
    )

Tiers de acesso

# app/api/routers/admin.py
_PLAN_DEFAULTS = {
    "owner": {"symbols": None, "tfs": None},                       # tudo
    "pro":   {"symbols": None, "tfs": None},                       # tudo
    "basic": {"symbols": ["EURUSD","GBPUSD","XAUUSD"],
              "tfs": ["H1","H4"]},                                  # restrito
}

Provisionamento automático

Webhook Eduzz (POST /webhooks/eduzz) → valida HMAC → identifica plano → chama create_api_key() → email com a chave.

Alternativas consideradas

1. Stripe API Keys (managed externamente)

Vantagens: - Off-the-shelf (Stripe gera, audita, revoga) - Dashboard pronto para o assinante ver suas chaves

Desvantagens: - Brasil: Stripe não suporta PIX nativo (ver ADR-005) - Lock-in com Stripe (mudança de gateway = re-emitir todas as chaves) - Custos por chave (tier alto) - Latência adicional (chamar Stripe API a cada request) — inaceitável para EA com 60 req/min

Decisão: rejeitado. Acoplar auth ao gateway de pagamento é antipattern.

2. JWT (stateless tokens)

Vantagens: - Sem chamada ao DB para validar (claims dentro do token) - Padrão de mercado

Desvantagens: - Revogação difícil — JWT é válido até expirar, mesmo após cancel/refund (a menos que mantenha blocklist no DB → volta ao DB lookup) - Tokens longos (centenas de chars) — chato no MT5 InpApiKey - Rotação de chave de assinatura derruba todos os tokens emitidos - Não há "renovação por re-leitura do DB" (mudou plano? espera o JWT expirar)

Decisão: rejeitado. Para o caso de assinatura recorrente com cancelamento em tempo real, JWT é antipattern.

3. OAuth2 (Authorization Code Flow)

Vantagens: - Padrão da indústria - Suporta scopes (semelhante aos symbols_allowed)

Desvantagens: - Overkill — EA não é uma 3rd-party app, é o cliente final - Fluxo de redirecionamento incompatível com MT5 - Adiciona Authorization Server (mais 1 serviço pra manter) - Refresh tokens não fazem sentido para EA que roda 24/7

Decisão: rejeitado. Complexidade desproporcional ao caso de uso.

4. API Key com escopo via JSON nas claims (hash)

Vantagens: - Permissões "codificadas" na própria chave (sem DB lookup) - Auto-contained

Desvantagens: - Permissões imutáveis após emissão (mudou plano? nova chave) - Tamanho da chave cresce com permissões - Mesmo problema do JWT: revogação ainda precisa DB

Decisão: rejeitado. UUID + lookup no DB é mais simples e flexível.

Consequências

✅ Positivas

  • Revogação imediataUPDATE api_keys SET active=FALSE WHERE email=$1 revoga tudo do email em uma única transação
  • Permissões em runtime — mudou de basic para pro? altera plan_tier no DB, próxima request já tem acesso novo
  • Auditoria nativalast_seen_at, created_at, notes permitem rastrear histórico
  • Rate limit por plano — fácil (basta ler plan_tier na auth)
  • Anti-key-sharing — Redis track de IPs únicos por chave em 1h
  • Endpoint admin/admin/keys lista, cria e revoga via curl

❌ Negativas

  • +1 query no DB por request (SELECT * FROM api_keys WHERE api_key=$1)
  • Mitigação: api_key é PK indexada → lookup em <1ms
  • Mitigação: owner key bypassa via fast path (env var) → sem DB para owner
  • Schema migration ao mudar permissions — adicionar coluna em api_keys afeta produção
  • Mitigação: usar JSONB para campos extensíveis (symbols_allowed, tfs_allowed)
  • DB single point of failure — DB down = ninguém autentica
  • Mitigação: backup + retention 30d em B2 + restore RTO 30 min
  • Mitigação: Postgres pooling com health check

🎯 Trade-offs aceitos

Custo Benefício
+1ms latência por request Revogação em segundos, não minutos
Schema rígido (não-cripto-encoded) Auditoria completa + audit log futuro
Coupling auth ↔ DB Visibilidade total do uso por assinante

Implementação completa

Tabela

  • docker/init.sql:174-189 — schema
  • Constraint UNIQUE em api_key previne colisão UUID (probabilisticamente impossível, mas defesa em profundidade)

Auth

  • app/api/main.py:251-334require_api_key() com fast path + slow path
  • Rate limit por plano: 60/300/600 req/min
  • Anti-key-sharing: max 3 IPs por chave em 1h → alerta Telegram

Admin

  • app/api/routers/admin.py — CRUD endpoints (POST/GET/DELETE /admin/keys)
  • Protegido por X-Admin-Secret header

Webhook

  • app/api/routers/webhooks.py:294-457 — auto-provisiona via Eduzz
  • HMAC-SHA256 validation (fail-closed se EDUZZ_SECRET ausente)
  • Email transacional via SMTP (HTML profissional com instruções)

Self-service

  • app/api/routers/account.py — assinante consulta sua chave via X-API-Key própria
  • Inclui: /me, /signals, /executions, /regenerate-key

Validação contínua

  • Quality gate de identidade: queries no /admin/monitoring-data agrupam por key_id e mostram cobertura por plano
  • Alerta automático: chave compartilhada (>3 IPs em 1h) → Telegram com mascaramento de PII
  • Revogação automática: webhook de cancel/refund/chargeback da Eduzz → UPDATE active=FALSE + email

Referências

  • IETF RFC 6750 — The OAuth 2.0 Authorization Framework: Bearer Token Usage
  • IETF RFC 7519 — JSON Web Token (JWT)
  • OWASP — API Security Top 10 (focado em API2: Broken Authentication)
  • PostgreSQL — gen_random_uuid() via pgcrypto