Fase 2 — Auth, Multi-Tenant & Admin
Design técnico para transformar Carbon Exchange de single-tenant interno em produto multi-cliente com isolamento total de dados, login JWT, provisionamento via UI admin e roteamento WhatsApp por tenant + cópia auditada para grupo admin.
1. Objetivos
- Isolamento de dados: cliente A nunca vê dados do cliente B (queries automaticamente filtram por
tenant_id). - Login JWT: usuário se autentica via email + senha; recebe access token de curta duração + refresh token revogável.
- Provisionamento centralizado: somente admin global cria tenants e usuários; sem self-register.
- WhatsApp por tenant: cada cliente tem seu grupo; admin recebe cópia de tudo.
- Reaproveitamento: webhook de ingestão (
POST /carbon) continua anônimo — sensores não carregam JWT. - Auditoria: toda mensagem WhatsApp/áudio enviada é registrada em
carbon.alert_events.
2. Não-objetivos (desta fase)
- SSO/OAuth (Google, Azure AD, etc.) — postergado.
- 2FA — postergado.
- API keys de leitura por tenant (M2M) — postergado.
- Pagamento/billing por tenant — fora de escopo.
- Quotas/rate-limit por tenant — usar nginx até evidência de necessidade.
3. Estado atual (Fase 1)
Já implementado e em produção:
- Webhook
POST /carboningere leituras → JSONL audit + PostgreSQLcarbon.sensor_readings. - Mensagem WhatsApp formatada em PT-BR, enviada para grupo único Carbon Software.
- Dashboard com filtros, mapa Leaflet, previsão Open-Meteo.
- Banco dedicado
carbon; tabelas Fase 2 já criadas (tenants,users,tenant_members,auth_refresh_tokens,alert_events) e vazias. - Backfill: 4 tenants já existem, mapeados 1-para-1 com
clients.client_idexistente.
clients.tenant_id está backfilled. Próximo passo: ligar a aplicação ao modelo.4. Modelo de dados
4.1 Tabelas (já existem em carbon.*)
| Tabela | Função | Chave |
|---|---|---|
tenants | Identidade canônica do cliente (slug, nome, JID WhatsApp) | id BIGSERIAL, slug UNIQUE |
users | Identidade de login (email, hash, is_admin global) | id BIGSERIAL, email UNIQUE |
tenant_members | N:N entre users e tenants com role | (user_id, tenant_id) UNIQUE |
auth_refresh_tokens | Refresh tokens revogáveis | token_hash UNIQUE |
clients | Source-of-truth de client_id de sensor | client_id UNIQUE; FK para tenants |
alert_events | Audit de toda mensagem disparada | id BIGSERIAL |
4.2 Decisão: tenant_id em devices e sensor_readings?
Não denormalizar agora. Cada query filtra por tenant_id via JOIN devices ↔ clients. Custo do JOIN é desprezível (centenas de devices por tenant) e mantemos uma única source-of-truth para a relação cliente↔tenant.
Se métricas mostrarem latência > 50 ms em queries de histórico, denormalizar tenant_id em sensor_readings via gatilho.
5. Papéis & permissões
Combinação de flag global (users.is_admin) + papel por tenant (tenant_members.role):
| Papel | Definição | Pode | Não pode |
|---|---|---|---|
| admin | users.is_admin = TRUE (independente de membership) |
Tudo: ver/editar todos os tenants, criar usuários, ver auditoria global, configurar JID admin | — |
| client_admin | membership com role='client_admin' |
Ver dashboard do tenant; convidar usuários como viewer; editar nome de devices; configurar JID do tenant |
Criar tenants; ver outros tenants; promover a client_admin |
| viewer | membership com role='viewer' |
Ler dashboard, histórico e mapa do tenant | Qualquer escrita |
Matriz de autorização
| Ação | admin | client_admin | viewer |
|---|---|---|---|
| POST /carbon (ingestão) | público (sem JWT) | ||
| GET /carbon/dashboard/data (do meu tenant) | ✓ | ✓ | ✓ |
| GET dashboard de outro tenant | ✓ | ✗ | ✗ |
| POST /admin/tenants (criar tenant) | ✓ | ✗ | ✗ |
| POST /admin/users | ✓ | ✗ | ✗ |
| POST /tenants/:id/members (convidar viewer) | ✓ | ✓ | ✗ |
| PUT /tenants/:id (editar nome, JID) | ✓ | ✓ (só o seu) | ✗ |
| GET /admin/audit/alert_events | ✓ | só do seu tenant | ✗ |
6. Resolução de tenant na ingestão
O webhook POST /carbon permanece anônimo (sensores LoRa não fazem auth). Como atribuir a leitura ao tenant correto?
Estratégia (em ordem de precedência)
- Se o
device_idjá existe emcarbon.devices→ tenant viadevices.client_id → clients.tenant_id. - Se não existe e payload trouxe
cliente→ upsert emclients(slug = slugify(cliente)) + auto-create detenantcom mesmo slug se ainda não existir. - Senão → fallback
default-client/ tenant de "órfãos".
CARBON_AUTO_PROVISION_TENANT (default false em produção). Se desligado, payloads de devices desconhecidos vão para tenant orphan e admin recebe alerta para promover/movê-los.7. JWT — claims & ciclo
Algoritmo & segredo
HS256 com segredo em env CARBON_JWT_SECRET (mínimo 32 bytes aleatórios). Rotação documentada: gerar novo secret, deploy com ambos suportados por 2× TTL do refresh, depois remover o antigo.
Access token (curto)
{
"iss": "carbon-exchange",
"sub": "<user_id>",
"email": "[email protected]",
"is_admin": false,
"memberships": [
{"tenant_id": 4, "role": "client_admin"},
{"tenant_id": 7, "role": "viewer"}
],
"iat": 1731000000,
"exp": 1731003600,
"jti": "<uuid>"
}
TTL: 60 minutos. Inclui memberships para evitar query a cada request — mas isso significa que mudanças de papel só refletem após o refresh. Aceitável.
Refresh token (longo)
String aleatória de 64 bytes (não-JWT, opaco). Hash SHA-256 do token guardado em auth_refresh_tokens.token_hash; o cliente recebe o token original. TTL: 30 dias. Revogável via UPDATE … SET revoked_at = NOW().
Fluxo de refresh
- Frontend detecta access expirado (401) e chama
POST /auth/refreshcom refresh token. - Backend valida hash, expiração, não-revogação.
- Emite novo access (e opcionalmente rotaciona refresh, marcando o anterior como revogado).
Storage no frontend
localStorage para ambos os tokens (simples, suficiente para protótipo). Refator para HttpOnly cookie depois quando houver requisitos PCI/LGPD mais rígidos.
8. Endpoints de auth
| Método | Path | Body / Resposta |
|---|---|---|
| POST | /auth/login |
req: {email, password} · res: {access_token, refresh_token, user, memberships} ou 401 |
| POST | /auth/refresh |
req: {refresh_token} · res: {access_token, refresh_token?} ou 401 |
| POST | /auth/logout |
req: {refresh_token} · revoga; res: 204 |
| GET | /auth/me |
res: user atual + memberships (lê do JWT, não bate no DB) |
| POST | /auth/change-password |
req: {old_password, new_password} |
Hash de senha
bcrypt com cost 12 (via passlib[bcrypt] ou bcrypt direto). Validar mínimo de 10 caracteres no register/change.
Login: rate-limit & lockout
Limite de 10 tentativas falhas em 10 min por (email, IP) → 429 com retry-after de 15 min. Implementação: tabela carbon.auth_login_attempts ou Redis (TBD; usar Postgres por simplicidade enquanto não houver Redis).
9. Fluxos de sequência
9.1 Login
9.2 Request autenticado para o dashboard de um tenant
9.3 Ingestão de sensor (anônima) + roteamento WA
10. Endpoints admin / tenant
Todos requerem Authorization: Bearer <JWT>. admin = global; client_admin = só seu tenant.
| Método | Path | Acesso | Função |
|---|---|---|---|
| GET | /admin/tenants | admin | Lista todos os tenants |
| POST | /admin/tenants | admin | req: {slug, name, whatsapp_jid?} |
| PUT | /admin/tenants/:id | admin ou client_admin (próprio) | Editar nome, JID, is_active |
| DEL | /admin/tenants/:id | admin | Soft-delete (is_active=false); cascata em devices é proibida |
| GET | /admin/users | admin | Lista global de users |
| POST | /admin/users | admin | req: {email, password, name, is_admin?} |
| POST | /tenants/:id/members | admin ou client_admin (próprio) | req: {user_id|email, role}. Promoção a client_admin só admin. |
| DEL | /tenants/:id/members/:user_id | ↑ | Remove membership |
| GET | /tenants/:id/devices | membros do tenant | Lista devices |
| PUT | /tenants/:id/devices/:device_id | membros do tenant (com role >= client_admin) | Edita label, location |
| POST | /admin/devices/:device_id/move | admin | req: {tenant_id} — move device entre tenants |
| GET | /admin/audit/alerts | admin (todos) / client_admin (do seu) | Lista alert_events paginado |
| GET | /admin/wa-groups | admin | Proxy para :8790/wa/chats filtrando @g.us — popula dropdown ao criar tenant |
11. Filtragem por tenant nas queries
Implementação: decorator @require_auth(roles=...) que injeta g.user e g.tenant_filter.
def require_auth(roles=("admin","client_admin","viewer")):
def deco(fn):
@wraps(fn)
def wrapper(*args, **kw):
token = (request.headers.get("Authorization") or "").removeprefix("Bearer ").strip()
try:
claims = jwt.decode(token, CARBON_JWT_SECRET, algorithms=["HS256"])
except jwt.PyJWTError:
return jsonify({"ok": False, "error": "unauthorized"}), 401
g.user_id = int(claims["sub"])
g.is_admin = bool(claims.get("is_admin"))
g.memberships = {m["tenant_id"]: m["role"] for m in claims.get("memberships", [])}
if not g.is_admin and not g.memberships:
return jsonify({"ok": False, "error": "no_tenant_access"}), 403
# Optional: per-tenant constraint via path or query param
tenant_id = kw.get("tenant_id") or request.args.get("tenant_id", type=int)
if tenant_id is not None and not g.is_admin:
role = g.memberships.get(tenant_id)
if not role or role not in roles:
return jsonify({"ok": False, "error": "forbidden"}), 403
g.current_tenant = tenant_id
return fn(*args, **kw)
return wrapper
return deco
Mudanças nas queries existentes
Cada _carbon_db_* ganha um filtro implícito por tenant_id:
# antes
SELECT ... FROM carbon.sensor_readings WHERE client_id = %s
# depois (não-admin)
SELECT r.* FROM carbon.sensor_readings r
JOIN carbon.clients c ON c.client_id = r.client_id
WHERE c.tenant_id = ANY(%s) -- ARRAY de memberships do user
AND (%s IS NULL OR r.client_id = %s)
Para admin sem tenant_id explícito, o filtro é omitido (vê tudo).
12. Roteamento WhatsApp por tenant
O POST /carbon resolve o tenant da leitura, busca tenants.whatsapp_jid, e dispara duas mensagens:
- Para o grupo do tenant (se
whatsapp_jidpresente). - Para o grupo admin (env
CARBON_ADMIN_WHATSAPP_JID).
Cada envio gera 1 linha em carbon.alert_events com channel ∈ {whatsapp_tenant, whatsapp_admin}, target=<jid>, status ∈ {sent, failed}, reading_id, message.
Falha não-bloqueante
Se o envio para tenant ou admin falhar, o webhook ainda retorna 200 (a leitura está persistida). A falha aparece em alert_events.status='failed' e pode ser replayed pela reading_id (Phase 2.1, fora deste design).
Mudança no payload de resposta
{
"ok": true, "saved": true,
"forwarded": {
"tenant": {"sent": true, "target": "[email protected]"},
"admin": {"sent": true, "target": "[email protected]"}
},
"reading": {...}, "risk": {...}
}
13. Novas telas
Login
/carbon/loginEmail + senha. Em sucesso: salva tokens em localStorage e redireciona — admin → /carbon/admin; demais → /carbon/dashboard.
Dashboard (autenticado)
/carbon/dashboardFiltros por tenant (se user tem mais de 1 membership). Header com email do user + botão logout. Em todas as fetches: Authorization: Bearer ….
Admin Hub
/carbon/admin (só admin)Tabs: Tenants, Users, Audit. Cards de quick-stats: total tenants ativos, devices, leituras nas 24 h, alertas críticos no dia.
Editor de tenant
/carbon/admin/tenants/:idForm: nome, slug (read-only após criação), JID WhatsApp (dropdown com lista de grupos vindo de /admin/wa-groups), is_active. Sub-tabs: Devices, Membros.
Convidar usuário
/carbon/admin/tenants/:idEmail + role (viewer ou client_admin). Se email não existe em users, admin escolhe entre criar com senha temporária ou enviar link de convite (Phase 2.2 — começamos só com senha temporária).
Auditoria de alertas
/carbon/admin/auditTabela paginada de alert_events: timestamp, tenant, device, channel, target, status. Filtros por tenant, channel, status, janela.
14. Fluxo de provisioning de cliente
- Admin cria tenant:
POST /admin/tenantscomslug,name. Semwhatsapp_jidainda. - Cliente envia QR a Marcelo ou similar — eventualmente o número do cliente entra no Baileys (mesma instância) ou em uma instância dedicada (Phase 3).
- Cliente cria grupo do projeto e adiciona o número Baileys. Grupo aparece em
/wa/chats. - Admin abre editor do tenant, escolhe o JID do grupo no dropdown (alimentado por
/admin/wa-groups) e salva. - Admin convida primeiro
client_admin: define email + senha temporária; user troca no primeiro login. - Sensores começam a postar: webhook resolve tenant pelo
clienteoudevice_idjá cadastrado pelo admin. - Mensagens fluem: cada leitura → grupo do tenant + grupo admin.
15. Migração
15.1 Estado pré-existente
4 tenants já criados (auto-backfilled de clients): vin-cola, client-acme-florestal, client-serra-verde, default-client. users e tenant_members vazios.
15.2 Passos da migração
- Bootstrap admin user: script
scripts/bootstrap_admin.pycria 1º admin a partir de envCARBON_BOOTSTRAP_ADMIN_EMAIL/PASSWORD. Idempotente; refuse se já existir admin. - Deploy do código com auth opcional: feature flag
CARBON_AUTH_ENFORCE=false(default). Endpoints aceitam mas não exigem JWT; queries não filtram por tenant. Permite testar login/admin UI sem quebrar dashboard atual. - Configurar JIDs por tenant existente: admin loga, edita cada tenant, anexa JID. Verificar que
POST /carbonmanda para 2 grupos. - Convidar primeiros usuários: criar 1
client_adminpor tenant existente. Logam, validam que veem só seus dados. - Flip da flag:
CARBON_AUTH_ENFORCE=true. A partir daqui, dashboard e endpoints retornam 401 sem JWT. - Cleanup: drop da DB
goku_games.carbon.*(já feito a Fase 1; só confirmar).
16. Rollout (sprint plan estimado)
| Sprint | Entregas | Estimativa |
|---|---|---|
| 1 | POST /auth/login, refresh, logout, me; tabela login_attempts; bcrypt; bootstrap admin script; tela de login | 3-4 dias |
| 2 | Decorator @require_auth; injeção de tenant_filter nas queries existentes; flag CARBON_AUTH_ENFORCE | 2-3 dias |
| 3 | Endpoints admin (tenants CRUD, users, members); UI /carbon/admin | 4-5 dias |
| 4 | Roteamento WhatsApp por tenant + escrita em alert_events; tela de auditoria | 2-3 dias |
| 5 | Migração + flip de flag em produção; smoke tests | 1-2 dias |
Total: ~12-17 dias de dev efetivo. Pode paralelizar 1-2 e 3 se houver dois devs.
17. Riscos & mitigações
| Risco | Probabilidade | Impacto | Mitigação |
|---|---|---|---|
| Vazamento entre tenants em query nova | Média | Alto | Testes unitários com 2 tenants + assert de zero leakage; revisão de PR com olho em cada novo SELECT |
| JWT secret comprometido | Baixa | Alto | Secret em systemd Environment (não no repo); plano de rotação documentado; refresh tokens revogáveis permitem invalidação imediata |
| Membership em JWT fica stale após mudança | Média | Médio | TTL access = 60 min; logout-all-sessions para forçar refresh |
| Webhook anônimo aceita payload spoofado de outro tenant | Alta | Médio | Phase 3: assinatura HMAC por device. Mitigação interim: device_id já cadastrado é fonte da verdade; cliente só desambigua quando device é novo |
| Baileys cai durante migração e nada manda WhatsApp | Média | Baixo | POST /carbon ainda persiste tudo; replay manual via alert_events |
| Deploy quebra dashboard atual no flip de flag | Média | Médio | Período de soft-enforcement: durante 7d, queries logam violations sem bloquear, depois passam a bloquear |
18. Questões abertas
- Email de convite: começamos só com senha temporária no Sprint 3 ou já investimos em SendGrid template no Sprint 1? decidir antes do Sprint 3
- Múltiplas instâncias Baileys: cada tenant com sua própria sessão WhatsApp (número dedicado) ou um número compartilhado mandando para vários grupos? depende do número de tenants — single-instance OK até ~50
- Histórico antigo: leituras existentes em
sensor_readingsnão foram associadas a tenant viareading.tenant_iddireto. JOIN funciona, mas se houver renomeação de cliente e reassociação de devices, registros antigos podem aparecer no tenant errado. Aceitar e documentar? - Auto-provision de tenants pelo webhook (env
CARBON_AUTO_PROVISION_TENANT): liga em produção ou exige sempre admin manual? recomendo desligar em prod - Frontend: continuar HTML estático ou migrar para React/Next.js conforme cresce? manter HTML/Vanilla até Sprint 3 terminar
Documento gerado em 2026-05-10. Carbon Exchange Phase 2 design — versão 0.1 (draft).