simpla.fydocs
Hub APIProduto

Equipe

Modelo de equipe do Hub: organizações como tenant, papéis OWNER/ADMIN/MEMBER/CLIENT, fluxo de convites, bypass interno via MCP_INTERNAL_SECRET e troca de organização.

Equipe

O Hub é multi-tenant por organização. Todo recurso operacional (agentes, credenciais, leads, conversas, integrações) é escopado a uma Organization, e cada usuário acessa esses recursos por meio de uma Membership que carrega o papel (Role) dentro daquela organização.

Esta página descreve o modelo de tenancy, os papéis disponíveis, o fluxo de convites, o bypass de admin interno usado por chamadas server-to-server e a mecânica de troca de organização exposta pela API.

Modelo de organização

O tenant primário é o modelo Organization. Cada organização tem id, slug único e name, e funciona como raiz de isolamento para todos os recursos do Hub.

Um usuário pode pertencer a múltiplas organizações por meio de Membership. A constraint @@unique([userId, organizationId]) garante que cada usuário tenha no máximo um vínculo (e um papel) por organização.

model Organization {
  id            String         @id @default(cuid())
  name          String
  slug          String         @unique
  avatarUrl     String?
  memberships   Membership[]
  invitations   Invitation[]
  credentials   Credential[]
  // ... demais relacionamentos escopados por org
}

model Membership {
  id                String             @id @default(cuid())
  userId            String
  organizationId    String
  role              Role               @default(MEMBER)
  clientPermissions ClientPermission[]
  permissions       Permission[]       @relation("MembershipPermissions")

  @@unique([userId, organizationId])
}

Toda chamada à API que toca recursos de uma organização passa por requireOrgMembership(orgId) antes de executar a operação. Se o usuário autenticado não tem Membership ativo na organização do recurso, a chamada falha com Not a member of this organization.

Papéis e permissões

O enum Role define quatro papéis fixos:

PapelDescrição
OWNERDono da organização. Único papel autorizado por requireOrgOwner. Pode transferir titularidade e excluir org.
ADMINAdministrador operacional. Passa por requireOrgAdmin junto com OWNER.
MEMBEROperador padrão da organização. Acessa módulos sem o gating granular aplicado a CLIENT.
CLIENTAcesso restrito por módulo via ClientPermission. Usado pelo portal do cliente.

A hierarquia é aplicada por helpers em apps/web/src/lib/auth/org-auth.ts:

// Qualquer membro
await requireOrgMembership(orgId);

// OWNER ou ADMIN
await requireOrgAdmin(orgId);

// Apenas OWNER
await requireOrgOwner(orgId);

Permissões granulares para CLIENT

Usuários com papel CLIENT têm acesso adicional controlado por ClientPermission, com um registro por módulo e nível (none | view | edit).

Os módulos suportados estão em apps/web/src/lib/auth/client-permissions.ts:

export const CLIENT_MODULES = [
  "dashboard",
  "inbox",
  "funnel",
  "contacts",
  "agents",
  "rag",
  "metrics",
  "channels",
] as const;

requireClientPermission(orgId, module, minLevel) aplica o gating:

  • Se o papel da membership for OWNER, ADMIN ou MEMBER, o gating é ignorado (operadores têm acesso pleno).
  • Se for CLIENT, o helper consulta ClientPermission do módulo e compara o nível armazenado contra minLevel (view < edit). Sem registro ou nível insuficiente, lança Insufficient permissions.

Não conceda papel MEMBER para usuários externos. MEMBER ignora ClientPermission e tem visão operacional completa. Para acesso restrito ao portal, sempre use CLIENT com ClientPermission ajustado por módulo.

Convidar membros

Convites são modelados como registros Invitation com token único, papel pré-atribuído, expiração e status. O ciclo de vida está exposto sob /api/organizations/[orgId]/invitations (criação/listagem), /api/organizations/[orgId]/invitations/[invitationId] (revogação) e /api/organizations/invitations/accept (aceite).

model Invitation {
  id                String           @id @default(cuid())
  email             String
  organizationId    String
  role              Role             @default(MEMBER)
  status            InvitationStatus @default(PENDING)
  invitedById       String
  token             String           @unique @default(cuid())
  expiresAt         DateTime
  permissionsConfig Json?
}

enum InvitationStatus {
  PENDING
  ACCEPTED
  EXPIRED
  REVOKED
}

Criar o convite. A chamada precisa de papel mínimo ADMIN na organização. O payload define email, role e, para convites CLIENT, permissionsConfig com os níveis por módulo.

curl -X POST https://hub.simplafy.com.br/api/v1/organizations/$ORG_ID/invitations \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "[email protected]",
    "role": "CLIENT",
    "permissionsConfig": {
      "dashboard": "view",
      "inbox": "edit"
    }
  }'

Enviar o link. O Hub gera um token único. O link de aceite tem o formato:

https://hub.simplafy.com.br/invitations/accept?token=<token>

O destinatário recebe o convite por e-mail (quando o envio está habilitado) ou via repasse manual do link.

Aceitar o convite. Se o e-mail já tem conta no Hub, o aceite acontece via POST /api/organizations/invitations/accept com o token. Se ainda não tem conta, o fluxo accept-with-signup cria o usuário e a Membership na mesma transação.

O aceite cria a Membership com o role do convite. Para convites CLIENT, os registros de ClientPermission são materializados a partir de permissionsConfig (ou dos defaults via createDefaultClientPermissions quando o config é omitido).

Revogar. Convites pendentes podem ser revogados por ADMIN ou OWNER via DELETE /api/organizations/$ORG_ID/invitations/$INVITATION_ID. O status passa a REVOKED e o token deixa de ser aceitável. Convites expirados (expiresAt < now) são marcados como EXPIRED automaticamente no momento do aceite.

Internal admin bypass

Chamadas server-to-server (admin proxies, MCP server, jobs internos) precisam atravessar as mesmas verificações de organização, mas não têm sessão NextAuth. O Hub suporta um bypass dedicado controlado pela variável MCP_INTERNAL_SECRET.

A lógica está em apps/web/src/lib/auth/internal-auth.ts e é invocada pelos helpers de org-auth.ts:

async function isInternalAdmin(): Promise<boolean> {
  if (!process.env.MCP_INTERNAL_SECRET) return false;
  try {
    await validateInternalAuth();
    return true;
  } catch {
    return false;
  }
}

validateInternalAuth extrai o Bearer token do header Authorization e compara contra MCP_INTERNAL_SECRET usando comparação timing-safe (timingSafeCompare).

Quando o bypass é aceito:

  • requireAuth retorna o sentinel "__internal_admin__" como userId.
  • requireOrgMembership retorna { userId: "__internal_admin__", membership: { role: "OWNER" } } sem consultar o banco.
  • Por consequência, requireOrgAdmin e requireOrgOwner também passam.

Exemplo de chamada interna:

curl https://hub.simplafy.com.br/api/v1/organizations/$ORG_ID/agents \
  -H "Authorization: Bearer $MCP_INTERNAL_SECRET"

O header legado x-internal-admin: true foi removido. Qualquer caller interno precisa enviar Authorization: Bearer ${MCP_INTERNAL_SECRET}. O segredo é distribuído via Infisical para as workloads que precisam dele e nunca deve ser exposto a clientes ou ao browser.

Se MCP_INTERNAL_SECRET não está configurado no pod, isInternalAdmin retorna false antes de qualquer tentativa de validação — o bypass simplesmente não existe naquele ambiente, e as chamadas precisam usar sessão real ou API key.

Trocar de organização

Como Membership é n:n entre User e Organization, o usuário pode estar vinculado a várias orgs. O Hub mantém uma organização ativa por sessão, exposta no contexto da aplicação web e no token JWT consumido pela API.

O OrgProvider (parte da chain Sidebar → Org → MCP → Agents → Rag em apps/web) carrega as memberships do usuário, persiste a organização selecionada e atualiza o contexto downstream (MCP, agentes, RAG).

Pontos importantes ao integrar:

  • A escolha de organização é feita pelo usuário; não há herança implícita de "última organização usada" entre dispositivos.
  • O reset do estado downstream (agentes, deployments, channels) e o fetch dos novos dados ocorrem em um único useEffect com prevOrgId ref. Dividir em dois efeitos separados causa race entre reset e fetch.
  • Toda chamada server-side revalida a organização via requireOrgMembership(orgId) — não basta confiar no orgId armazenado em cache do cliente.
  • Para chamadas server-to-server que operam sobre várias organizações em sequência, use o bypass MCP_INTERNAL_SECRET em vez de simular troca de sessão.

On this page