ADR-004: Multi-tenant via UUID em DB¶
Status: ✅ Accepted (2026-05)
Contexto¶
Até v1.x o sistema era single-tenant:
- Uma única
API_KEYem 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:
- Identidade única por assinante (provisionamento via webhook)
- Permissões granulares (plano basic vs pro vs owner)
- Rate limiting por assinante (não global)
- Auditoria (last_seen_at, IPs distintos)
- 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 imediata —
UPDATE api_keys SET active=FALSE WHERE email=$1revoga tudo do email em uma única transação - Permissões em runtime — mudou de basic para pro? altera
plan_tierno DB, próxima request já tem acesso novo - Auditoria nativa —
last_seen_at,created_at,notespermitem rastrear histórico - Rate limit por plano — fácil (basta ler
plan_tierna auth) - Anti-key-sharing — Redis track de IPs únicos por chave em 1h
- Endpoint admin —
/admin/keyslista, 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_keysafeta 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_keyprevine colisão UUID (probabilisticamente impossível, mas defesa em profundidade)
Auth¶
app/api/main.py:251-334—require_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-Secretheader
Webhook¶
app/api/routers/webhooks.py:294-457— auto-provisiona via Eduzz- HMAC-SHA256 validation (fail-closed se
EDUZZ_SECRETausente) - Email transacional via SMTP (HTML profissional com instruções)
Self-service¶
app/api/routers/account.py— assinante consulta sua chave viaX-API-Keyprópria- Inclui:
/me,/signals,/executions,/regenerate-key
Validação contínua¶
- Quality gate de identidade: queries no
/admin/monitoring-dataagrupam porkey_ide 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