Carbon Exchange Carbon Exchange Fase 2 — Design carbonexchange.com.br ↗

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

2. Não-objetivos (desta fase)

3. Estado atual (Fase 1)

Já implementado e em produção:

A FK clients.tenant_id está backfilled. Próximo passo: ligar a aplicação ao modelo.

4. Modelo de dados

+----------------+ +-------------------+ +-------------------+ | carbon.tenants |<-------| carbon.tenant_ |-------->| carbon.users | | id PK | 1:N | members | N:1 | id PK | | slug UNQ | | (user_id, tenant) | | email UNQ | | name | | role | | password_hash | | whatsapp_jid | +-------------------+ | is_admin (global) | | is_active | | is_active | +--------+-------+ +---------+---------+ | | | 1:N | 1:N v v +----------------+ +------------------------+ | carbon.clients | | auth_refresh_tokens | | client_id UNQ | | token_hash UNQ | | tenant_id FK | | expires_at, revoked_at | +--------+-------+ +------------------------+ | | 1:N v +--------+--------+ +---------------------+ | carbon.devices |-------->| sensor_readings | | device_id UNQ | 1:N | risk_level | | label, lat/lon | | raw_payload | +-----------------+ +---------------------+ | | 1:N v +-------------------+ | alert_events | | tenant_id FK | | reading_id FK | | channel, target | | status, error | +-------------------+

4.1 Tabelas (já existem em carbon.*)

TabelaFunçãoChave
tenantsIdentidade canônica do cliente (slug, nome, JID WhatsApp)id BIGSERIAL, slug UNIQUE
usersIdentidade de login (email, hash, is_admin global)id BIGSERIAL, email UNIQUE
tenant_membersN:N entre users e tenants com role(user_id, tenant_id) UNIQUE
auth_refresh_tokensRefresh tokens revogáveistoken_hash UNIQUE
clientsSource-of-truth de client_id de sensorclient_id UNIQUE; FK para tenants
alert_eventsAudit de toda mensagem disparadaid BIGSERIAL

4.2 Decisão: tenant_id em devices e sensor_readings?

Não denormalizar agora. Cada query filtra por tenant_id via JOIN devicesclients. 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):

PapelDefiniçãoPodeNã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çãoadminclient_adminviewer
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_eventssó 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)

  1. Se o device_id já existe em carbon.devices → tenant via devices.client_id → clients.tenant_id.
  2. Se não existe e payload trouxe cliente → upsert em clients (slug = slugify(cliente)) + auto-create de tenant com mesmo slug se ainda não existir.
  3. Senão → fallback default-client / tenant de "órfãos".
A auto-criação de tenants pelo webhook é controlada por env 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

  1. Frontend detecta access expirado (401) e chama POST /auth/refresh com refresh token.
  2. Backend valida hash, expiração, não-revogação.
  3. 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étodoPathBody / 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

Browser funnel_backend PostgreSQL Baileys | | | | |--POST /auth/login-->| | | | {email,pw} | | | | |--SELECT user--------->| | | |<----row + hash--------| | | |--bcrypt.verify()------| | | |--SELECT memberships-->| | | |<----rows--------------| | | |--issue JWT + RT-------| | | |--INSERT refresh_token->| | |<--200 {access,RT}---| | |

9.2 Request autenticado para o dashboard de um tenant

Browser funnel_backend PostgreSQL | | | |--GET /carbon/ | | | dashboard/data? | | | tenant_id=4-------->| | | Authorization: Bearer <JWT> | | |--decode JWT-----------| | |--check membership 4---| | | (in JWT claims) | | |--SELECT readings------>| | | WHERE client_id IN ( | | | SELECT client_id | | | FROM clients | | | WHERE tenant_id=4) | | |<----rows--------------| |<--200 {reading,risk}| |

9.3 Ingestão de sensor (anônima) + roteamento WA

Sensor funnel_backend PostgreSQL Baileys | | | | |--POST /carbon->| | | | {cliente=Vinícola, device_id=X, ...} | | | |--resolve tenant---------->| | | |<-- tenant 4, jid=<G2>-----| | | |--save reading------------->| | | |--format msg | | | |--send to tenant JID <G2>----------------------->| | |--send to admin JID <G_admin>------------------->| | |--INSERT alert_events x2-->| | |<--200 {forwarded:[admin, tenant]}---| |

10. Endpoints admin / tenant

Todos requerem Authorization: Bearer <JWT>. admin = global; client_admin = só seu tenant.

MétodoPathAcessoFunção
GET/admin/tenantsadminLista todos os tenants
POST/admin/tenantsadminreq: {slug, name, whatsapp_jid?}
PUT/admin/tenants/:idadmin ou client_admin (próprio)Editar nome, JID, is_active
DEL/admin/tenants/:idadminSoft-delete (is_active=false); cascata em devices é proibida
GET/admin/usersadminLista global de users
POST/admin/usersadminreq: {email, password, name, is_admin?}
POST/tenants/:id/membersadmin ou client_admin (próprio)req: {user_id|email, role}. Promoção a client_admin só admin.
DEL/tenants/:id/members/:user_idRemove membership
GET/tenants/:id/devicesmembros do tenantLista devices
PUT/tenants/:id/devices/:device_idmembros do tenant (com role >= client_admin)Edita label, location
POST/admin/devices/:device_id/moveadminreq: {tenant_id} — move device entre tenants
GET/admin/audit/alertsadmin (todos) / client_admin (do seu)Lista alert_events paginado
GET/admin/wa-groupsadminProxy 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:

  1. Para o grupo do tenant (se whatsapp_jid presente).
  2. 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/login

Email + senha. Em sucesso: salva tokens em localStorage e redireciona — admin → /carbon/admin; demais → /carbon/dashboard.

Dashboard (autenticado)

/carbon/dashboard

Filtros 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/:id

Form: 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

Modal sobre /carbon/admin/tenants/:id

Email + 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/audit

Tabela paginada de alert_events: timestamp, tenant, device, channel, target, status. Filtros por tenant, channel, status, janela.

14. Fluxo de provisioning de cliente

  1. Admin cria tenant: POST /admin/tenants com slug, name. Sem whatsapp_jid ainda.
  2. 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).
  3. Cliente cria grupo do projeto e adiciona o número Baileys. Grupo aparece em /wa/chats.
  4. Admin abre editor do tenant, escolhe o JID do grupo no dropdown (alimentado por /admin/wa-groups) e salva.
  5. Admin convida primeiro client_admin: define email + senha temporária; user troca no primeiro login.
  6. Sensores começam a postar: webhook resolve tenant pelo cliente ou device_id já cadastrado pelo admin.
  7. 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

  1. Bootstrap admin user: script scripts/bootstrap_admin.py cria 1º admin a partir de env CARBON_BOOTSTRAP_ADMIN_EMAIL/PASSWORD. Idempotente; refuse se já existir admin.
  2. 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.
  3. Configurar JIDs por tenant existente: admin loga, edita cada tenant, anexa JID. Verificar que POST /carbon manda para 2 grupos.
  4. Convidar primeiros usuários: criar 1 client_admin por tenant existente. Logam, validam que veem só seus dados.
  5. Flip da flag: CARBON_AUTH_ENFORCE=true. A partir daqui, dashboard e endpoints retornam 401 sem JWT.
  6. Cleanup: drop da DB goku_games.carbon.* (já feito a Fase 1; só confirmar).
Cuidado: entre passo 2 e 5 o dashboard é tecnicamente acessível sem auth. Mitigação: durante a janela, expor apenas em rede interna ou atrás de Basic Auth nginx temporário.

16. Rollout (sprint plan estimado)

SprintEntregasEstimativa
1POST /auth/login, refresh, logout, me; tabela login_attempts; bcrypt; bootstrap admin script; tela de login3-4 dias
2Decorator @require_auth; injeção de tenant_filter nas queries existentes; flag CARBON_AUTH_ENFORCE2-3 dias
3Endpoints admin (tenants CRUD, users, members); UI /carbon/admin4-5 dias
4Roteamento WhatsApp por tenant + escrita em alert_events; tela de auditoria2-3 dias
5Migração + flip de flag em produção; smoke tests1-2 dias

Total: ~12-17 dias de dev efetivo. Pode paralelizar 1-2 e 3 se houver dois devs.

17. Riscos & mitigações

RiscoProbabilidadeImpactoMitigação
Vazamento entre tenants em query novaMédiaAltoTestes unitários com 2 tenants + assert de zero leakage; revisão de PR com olho em cada novo SELECT
JWT secret comprometidoBaixaAltoSecret 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çaMédiaMédioTTL access = 60 min; logout-all-sessions para forçar refresh
Webhook anônimo aceita payload spoofado de outro tenantAltaMédioPhase 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 WhatsAppMédiaBaixoPOST /carbon ainda persiste tudo; replay manual via alert_events
Deploy quebra dashboard atual no flip de flagMédiaMédioPeríodo de soft-enforcement: durante 7d, queries logam violations sem bloquear, depois passam a bloquear

18. Questões abertas

  1. 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
  2. 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
  3. Histórico antigo: leituras existentes em sensor_readings não foram associadas a tenant via reading.tenant_id direto. JOIN funciona, mas se houver renomeação de cliente e reassociação de devices, registros antigos podem aparecer no tenant errado. Aceitar e documentar?
  4. Auto-provision de tenants pelo webhook (env CARBON_AUTO_PROVISION_TENANT): liga em produção ou exige sempre admin manual? recomendo desligar em prod
  5. 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).