Skip to content

Autenticação e RBAC

O flow-ai-core expõe a API administrativa via HTTP. Toda requisição autenticada carrega um Bearer token no header Authorization. Há dois tipos de portador:

  • Usuário humano — JWT assinado, cujo sub embute { id, name, kind? }.
  • API Client (M2M) — token estático com prefixo fai_, resolvido no banco por hash SHA-256.

A verificação da assinatura do JWT é stateless, mas o flow-ai-core mantém dois pontos de estado no fim do fluxo: uma blacklist de logout no Redis (auth:user_logout:{id}) e o registro do API Client no Postgres. As permissões (RBAC) são resolvidas via modelo Group, sempre consultando o banco no hook de autorização.


Tokens JWT

O sistema emite dois tokens a cada login: access token e refresh token. Ambos são assinados com secrets distintos e carregam o mesmo payload de usuário.

Estrutura do payload

typescript
// Payload bruto do JWT (claims padrão)
interface TokenPayload {
  iat: number   // issued at (Unix timestamp)
  exp: number   // expiration (Unix timestamp)
  sub: string   // JSON.stringify(SubToken) ← o dado real está aqui
}

// Dado do usuário embutido no sub
interface SubToken {
  id:    string                    // CUID do User (ou id do ApiClient)
  name:  string                    // nome de exibição
  kind?: "user" | "api_client"     // ausente = "user"
}

O campo sub é um JSON serializado dentro do JWT. Isso é intencional: jsonwebtoken espera sub como string, e a serialização evita criar claims não-padrão no nível raiz do token.

O token não carrega mais o papel do usuário. O RBAC deixou de viver no token: o hook de autorização (requirePermission) sempre relê userRole e o Group do usuário no banco. Isso permite revogar/alterar permissões sem esperar o token expirar.

O que é stateless e o que não é

A verificação da assinatura do JWT é stateless: jsonwebtoken.verify() valida o token com o secret, sem tocar o banco. Mas o hook authenticate ainda consulta estado externo em dois casos:

  • Token de usuário — após o verify, checa a blacklist de logout no Redis (auth:user_logout:{id}); um token emitido antes do último logout é rejeitado.
  • Token fai_ (API Client) — não é um JWT; é resolvido por lookup SHA-256 no Postgres (findByToken), validando active e expiresAt.

A autorização (RBAC) nunca é stateless: requirePermission sempre lê userRole + Group (ou o ApiClient) do banco.

Expiração padrão:

TokenExpiração padrãoVariável de ambiente
Access token1 diaJWT_ACCESS_EXPIRES_IN
Refresh token7 diasJWT_REFRESH_EXPIRES_IN
Secret mínimo16 caracteresJWT_ACCESS_SECRET, JWT_REFRESH_SECRET

Hook authenticate

Implementado em src/http/hooks/authenticate.ts. É adicionado como preHandler nas rotas que exigem identidade (a maioria dos módulos o registra globalmente via app.addHook("preHandler", authenticate)). O hook tem dois caminhos, decididos pelo prefixo do token:

Requisição chega

authenticate(request, reply)
  ├─ Lê header Authorization
  ├─ Se ausente / sem token após o split " " → AppError("Token não informado", 401)

  ├─ [token começa com "fai_"]  → caminho API Client
  │   ├─ apiClientService.findByToken(token)   (lookup SHA-256 no Postgres)
  │   ├─ Se não existe / inativo → AppError("Token inválido", 401)
  │   ├─ Se expiresAt < agora    → AppError("Token expirado", 401)
  │   └─ request.user = { id, name, kind: "api_client" }

  └─ [demais tokens]            → caminho JWT de usuário
      ├─ verify(token, JWT_ACCESS_SECRET) → TokenPayload
      ├─ Se verificação falha (expirado, assinatura inválida) → AppError("Token JWT inválido", 401)
      ├─ JSON.parse(decoded.sub) → request.user: SubToken
      └─ Checa blacklist de logout: se existe auth:user_logout:{id} no Redis
         e decoded.iat < logoutTs → AppError("Sessão encerrada", 401)

Após o hook, request.user fica disponível para o handler e para quaisquer hooks de autorização subsequentes:

typescript
declare module "fastify" {
  interface FastifyRequest {
    user?: SubToken
  }
}

O hook verifica a assinatura criptográfica do JWT (ou resolve o API Client no banco) e, para tokens de usuário, ainda consulta o Redis para respeitar a blacklist de logout.


Autorização (RBAC via Groups)

O RBAC é baseado em Groups: cada usuário pertence a no máximo um Group, e o Group carrega um array JSON de permissões { resource, actions } e, opcionalmente, uma lista de routerIds que restringe o escopo. Existem dois hooks de autorização.

requirePermission(resource, action)

Fábrica de hook preHandler. Verifica se o usuário (via seu Group) tem a action sobre o resource. Implementado em src/http/hooks/require-permission.ts.

typescript
// Exemplo de uso na definição de rota
app.get("/flows",
  { preHandler: [requirePermission("flows", "read")] },
  listFlowsHandler
)

Fluxo de verificação:

  1. Se request.user ausente → AppError(401).
  2. Se request.user.kind === "api_client" → lê o ApiClient no banco e valida permissions/routerIds diretamente do registro (mesmo formato de Group).
  3. Caso contrário, relê o User do banco (userRole, groupId, group.permissions, group.routerIds).
  4. Admin bypass: se user.userRole === "admin", libera sem checar mais nada.
  5. Sem grupo → AppError(403).
  6. Procura a entrada { resource } em group.permissions; se não existir ou actions não incluir a actionAppError("Sem permissão", 403).
  7. Escopo por router: se group.routerIds não for vazio, o grupo é scoped — não pode acessar recursos globais (users, settings) e, se houver routerId nos params da rota, ele precisa estar entre os routerIds permitidos.

Recursos (Resource) disponíveis:

flows, routers, contacts, whatsapp_numbers, whatsapp_business_profile, queues, users, reports, settings, helpdesk, campaigns, sessions, chats, audit_logs.

Ações (Action): "read", "write", "admin".

Recursos globais (GLOBAL_RESOURCES): users e settings. Grupos com routerIds definidos ficam bloqueados nesses recursos.

requireAdminUserRole

Hook preHandler que exige User.userRole === "admin", relendo o usuário no banco. Implementado em src/http/hooks/require-admin.ts. Usado para operações estritamente administrativas — por exemplo, criar/editar/remover usuários:

typescript
app.post("/users",
  { preHandler: [requireAdminUserRole] },
  createUserHandler
)

Retorna AppError(403, "Acesso restrito a administradores") se o usuário não for admin (ou 401 se não autenticado).

Quando usar cada hook:

  • requirePermission(resource, action) → controle de acesso granular por recurso/ação, com escopo opcional por router. É o hook padrão da maioria das rotas.
  • requireAdminUserRole → portão binário para operações reservadas a administradores.

authenticate costuma ser registrado globalmente no plugin de rotas via app.addHook("preHandler", authenticate); os hooks de autorização entram por rota. Assim toda rota do módulo exige identidade, mas cada endpoint declara sua própria permissão.


Modelos de dados

prisma
model User {
  id       String  @id @default(cuid())
  name     String
  email    String  @unique
  phone    String
  password String                       // hash bcryptjs (cost=8)
  userRole String  @default("member")   // "member" | "admin" — "admin" bypassa o RBAC
  groupId  String?
  group    Group?  @relation(fields: [groupId], references: [id], onDelete: SetNull)
  @@map("users")
}

model Group {
  id          String @id @default(cuid())
  name        String
  permissions Json   @default("[]")  // [{ resource: string, actions: string[] }]
  routerIds   Json   @default("[]")  // string[] — vazio = sem restrição de router
  users       User[]
  @@map("groups")
}

model ApiClient {
  id          String    @id @default(cuid())
  name        String
  tokenHash   String    @unique       // SHA-256(token) em hex; lookup O(1) sem expor o token
  permissions Json      @default("[]") // mesmo formato de Group.permissions
  routerIds   Json      @default("[]") // mesmo formato de Group.routerIds
  active      Boolean   @default(true)
  expiresAt   DateTime?
  lastUsedAt  DateTime?
  @@map("api_clients")
}

O modelo é único: Group concentra tanto a semântica de domínio (permissions) quanto o escopo (routerIds). Não há mais tabela de rotas por papel (RoleRoute foi removido) nem um modelo Role — o antigo "papel" virou o campo escalar User.userRole (member por padrão, admin com bypass total). O ApiClient replica o formato de permissões do Group para autenticação máquina-a-máquina: o token gerado tem prefixo fai_, e só o hash SHA-256 é persistido (rotate gera um novo token e regrava o hash).

UserDevice (user_devices) guarda tokens de push por usuário (token @unique, platform). Não participa da autenticação HTTP — é usado só para notificações.


Fluxo de login e renovação de token

Login

POST /auth/login  { email, password }
└─ UserService.authenticate()
   ├─ Busca usuário por email
   ├─ Se não existe → AppError(401) com mensagem genérica (evita enumeration)
   ├─ compare(password, user.password)  → bcryptjs
   ├─ Se senha errada → AppError(401) com mesma mensagem genérica
   ├─ Monta SubToken { id, name }   (sem papel — o RBAC é resolvido depois, no banco)
   ├─ sign({ sub: JSON.stringify(subPayload) }, ACCESS_SECRET, { expiresIn })
   ├─ sign({ sub: JSON.stringify(subPayload) }, REFRESH_SECRET, { expiresIn })
   └─ Retorna { user, accessToken, refreshToken, permittedRoutes: [] }

O user retornado inclui o group (mas nunca o hash de senha). O campo permittedRoutes é vestígio da API anterior baseada em rotas por papel: hoje é sempre um array vazio ([]); a autorização real vem das group.permissions.

Existe também POST /auth/desk-login, usado pelo flow-ai-desk: mesmo fluxo de autenticação, mas bloqueia a emissão de token quando o agente está fora do loginSchedule (403 schedule_blocked).

Renovação (refresh)

GET /auth/refresh
  Authorization: Bearer {refreshToken}
└─ UserService.renewToken()
   ├─ verify(token, REFRESH_SECRET) → TokenPayload
   ├─ Se inválido/expirado → AppError(401)
   ├─ JSON.parse(decoded.sub) → SubToken
   ├─ Checa blacklist: se auth:user_logout:{id} > decoded.iat → AppError(401)
   ├─ Emite novo accessToken + novo refreshToken
   └─ Retorna { accessToken, refreshToken }

Logout (revogação)

O logout não é mais apenas client-side. Existe um mecanismo de revogação por usuário:

POST /auth/logout   (preHandler: authenticate)
└─ SET auth:user_logout:{userId} = Date.now()  (Redis, TTL 7 dias)

Tanto o hook authenticate quanto o renewToken comparam decoded.iat com esse timestamp: qualquer access ou refresh token emitido antes do último logout é rejeitado com 401. O TTL de 7 dias cobre a validade máxima do refresh token — depois disso, todos os tokens daquela leva já expiraram naturalmente e a chave pode sumir. É uma blacklist por instante de logout (não uma lista de tokens individuais).


Recuperação de senha

O fluxo usa código de verificação enviado por SMS:

POST /auth/password-recovery  { email }
└─ randomBytes(3).toString("hex")  → código de 6 caracteres hex
├─ Salva PasswordRecovery { code, expiresAt: now + 30min, completed: false }
└─ smsClient.send(code, user.phone)

POST /auth/password-recovery/validate  { email, code, password }
└─ Busca último PasswordRecovery não expirado e não completado
   ├─ Se código errado → AppError(400)
   ├─ hash(newPassword, 8) → bcryptjs
   └─ $transaction([
        user.update({ password: hashedPassword }),
        passwordRecovery.update({ completed: true })
      ])

O código é de 6 caracteres hexadecimais (3 bytes aleatórios) — 16.777.216 combinações possíveis. A janela de 30 minutos limita ataques de força bruta por tempo. O completed: true evita reutilização do mesmo código.


Bootstrap do administrador

Na inicialização do flow-ai-core, ensureAdminExists() é chamado de forma idempotente antes do fastify.listen():

  • Verifica se existe um usuário admin@admin.com
  • Se não existir: cria o usuário com senha padrão (adminadmin, hash bcryptjs) e userRole = "admin"
  • Não cria grupo nem permissões: como userRole === "admin" bypassa todo o RBAC, o admin já tem acesso total

Isso garante que um ambiente recém-provisionado sempre tenha acesso inicial. A senha padrão deve ser trocada imediatamente em produção.


Boundary de segurança do core

O flow-ai-core é a única porta de entrada HTTP protegida por JWT/RBAC dos frontends. Os serviços internos de processamento (flow-ai-orchestrator, flow-ai-engine, flow-ai-agent) não expõem HTTP — comunicam-se apenas via Redis Streams. Os gateways de canal (flow-ai-meta-api, flow-ai-ig-api, flow-ai-webchat-gateway) expõem HTTP, mas apenas para receber tráfego do canal, com validação própria (não JWT).

Internet
   ├─ flow-ai-meta-api        ← webhook WhatsApp (verify token + HMAC-SHA256)
   ├─ flow-ai-ig-api          ← webhook Instagram (verify token)
   └─ flow-ai-webchat-gateway ← Socket.io (sessionKey via bcrypt + channelId)
        ↓ stream:incoming
   flow-ai-orchestrator / flow-ai-engine / flow-ai-agent (internos, sem HTTP público)

Frontends (core-ui, desk)
   ↓ JWT Bearer  /  fai_ (API Client)
flow-ai-core ← única API HTTP administrativa (JWT + RBAC via Groups)

Isso significa que o JWT/fai_ do flow-ai-core é o único vetor HTTP administrativo. Os gateways de canal só aceitam tráfego autenticado pelo próprio canal (verify token / HMAC / sessionKey), e os serviços de processamento só são atacáveis via Redis (interno).


Diagrama de uma requisição autenticada

Cliente → POST /api/tickets/:id/close
          Authorization: Bearer eyJ...

  [preHandler global: authenticate]
  ├─ verify(token, ACCESS_SECRET) ← ok
  ├─ Blacklist de logout (Redis) ← sem revogação
  └─ request.user = { id: "usr_abc", name: "Ana" }   (sem papel no token)

  [preHandler: requirePermission("helpdesk", "write")]
  ├─ Relê User no banco → { userRole, groupId, group }
  ├─ Se userRole === "admin" → bypass
  ├─ Procura { resource: "helpdesk" } em group.permissions e checa "write"
  └─ Se group.routerIds ≠ [] e há routerId nos params → valida escopo

  [handler]
  └─ ticketService.close(ticketId, request.user.id)

Arquivos relevantes

ArquivoPapel
packages/flow-ai-database/prisma/schema.prismaModelos User, Group, ApiClient, UserDevice, PasswordRecovery
services/flow-ai-core/src/http/hooks/authenticate.tsVerificação JWT / lookup fai_ + blacklist de logout + população de request.user
services/flow-ai-core/src/http/hooks/require-permission.tsrequirePermission(resource, action) — RBAC via Groups/ApiClient
services/flow-ai-core/src/http/hooks/require-admin.tsrequireAdminUserRole — portão para userRole === "admin"
services/flow-ai-core/src/services/api-client.service.tsGeração/rotação de tokens fai_ (SHA-256), findByToken
services/flow-ai-core/src/http/routes/auth.routes.tsEndpoints /auth/login, /auth/refresh, /auth/logout, /auth/password-recovery, /auth/desk-login
services/flow-ai-core/src/services/user.service.tsauthenticate(), renewToken(), passwordReset()
services/flow-ai-core/src/config/auth.tsConfiguração JWT (secrets, expirations)
services/flow-ai-core/src/config/env.tsVariáveis de ambiente com validação Zod
services/flow-ai-core/src/utils/ensureAdminExists.tsBootstrap do usuário administrador padrão

flow-ai — plataforma proprietária de atendimento via WhatsApp