Appearance
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
subembute{ 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êuserRolee oGroupdo 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), validandoactiveeexpiresAt.
A autorização (RBAC) nunca é stateless: requirePermission sempre lê userRole + Group (ou o ApiClient) do banco.
Expiração padrão:
| Token | Expiração padrão | Variável de ambiente |
|---|---|---|
| Access token | 1 dia | JWT_ACCESS_EXPIRES_IN |
| Refresh token | 7 dias | JWT_REFRESH_EXPIRES_IN |
| Secret mínimo | 16 caracteres | JWT_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:
- Se
request.userausente →AppError(401). - Se
request.user.kind === "api_client"→ lê oApiClientno banco e validapermissions/routerIdsdiretamente do registro (mesmo formato deGroup). - Caso contrário, relê o
Userdo banco (userRole,groupId,group.permissions,group.routerIds). - Admin bypass: se
user.userRole === "admin", libera sem checar mais nada. - Sem grupo →
AppError(403). - Procura a entrada
{ resource }emgroup.permissions; se não existir ouactionsnão incluir aaction→AppError("Sem permissão", 403). - Escopo por router: se
group.routerIdsnão for vazio, o grupo é scoped — não pode acessar recursos globais (users,settings) e, se houverrouterIdnos params da rota, ele precisa estar entre osrouterIdspermitidos.
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.
authenticatecostuma ser registrado globalmente no plugin de rotas viaapp.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 peloflow-ai-desk: mesmo fluxo de autenticação, mas bloqueia a emissão de token quando o agente está fora dologinSchedule(403schedule_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) euserRole = "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
| Arquivo | Papel |
|---|---|
packages/flow-ai-database/prisma/schema.prisma | Modelos User, Group, ApiClient, UserDevice, PasswordRecovery |
services/flow-ai-core/src/http/hooks/authenticate.ts | Verificação JWT / lookup fai_ + blacklist de logout + população de request.user |
services/flow-ai-core/src/http/hooks/require-permission.ts | requirePermission(resource, action) — RBAC via Groups/ApiClient |
services/flow-ai-core/src/http/hooks/require-admin.ts | requireAdminUserRole — portão para userRole === "admin" |
services/flow-ai-core/src/services/api-client.service.ts | Geração/rotação de tokens fai_ (SHA-256), findByToken |
services/flow-ai-core/src/http/routes/auth.routes.ts | Endpoints /auth/login, /auth/refresh, /auth/logout, /auth/password-recovery, /auth/desk-login |
services/flow-ai-core/src/services/user.service.ts | authenticate(), renewToken(), passwordReset() |
services/flow-ai-core/src/config/auth.ts | Configuração JWT (secrets, expirations) |
services/flow-ai-core/src/config/env.ts | Variáveis de ambiente com validação Zod |
services/flow-ai-core/src/utils/ensureAdminExists.ts | Bootstrap do usuário administrador padrão |