Controle de acesso
Motor de RBAC multi-tenant do template, em lib/access-control.
O controle de acesso fica concentrado em lib/access-control. Esse diretório reúne as peças que decidem se o usuário atual pode executar uma ação ou acessar um recurso — services de domínio continuam responsáveis pelas regras de negócio, mas validações de acesso reutilizáveis não devem ficar espalhadas em lib/services.
O motor é multi-tenant: um usuário pode estar vinculado a várias empresas (Empresa/MembroEmpresa, ver Empresas e multi-tenant), cada uma com seus próprios perfis (Role) e um plano de assinatura (Plano) que funciona como teto de features. Não existe mais perfil global do usuário — permissão sempre vem do vínculo com a empresa ativa da sessão.
Estrutura
context.ts: monta oAccessContextdo usuário atual (sessão + vínculo com a empresa ativa + permissões do perfil + teto do plano).current-user.ts: helpers para obter o usuário/contexto atual, incluindoUsuarioAtualContexto(usado porBaseServicepara escopar queries porempresaId).policy.ts: decisões puras — papel ativo (canActAs), vínculo com empresa (temEmpresaAtiva) e permissão granular (canUseFeature).action-guards.ts: guards de papel/tenant (assertAdminAction,assertEmpresaAction,assertProprietarioEmpresa), usados por server actions.permission-guard.ts: guard de permissão granular (assertCurrentUserCan), usado por server actions.permission-registry.ts: catálogo único de recursos e operações (resourceRegistry/permissionKeys) — fonte de verdade das chavesrecurso:acao, consumida pela tela de perfis, pela tela de planos e pelo componentePermissionMatrixField.errors.ts: erros padronizados de acesso.
AccessContext
AccessContext é o objeto base para qualquer decisão de acesso. Contém o usuário atual, se ele é admin da plataforma e o vínculo dele com a empresa ativa da sessão (ou null, se nenhuma).
export type AccessContext = {
usuarioId: string;
isAdmin: boolean;
membroEmpresa: {
membroId: string;
empresaId: string;
roleId: string;
roleNome: string;
permissoes: string[];
permissoesPlano: Record<string, boolean>;
} | null;
};membroEmpresavem doMembroEmpresaativo do usuário para a empresa ativa da sessão (cookieEMPRESA_ATIVA_COOKIE, sempre revalidado contra o banco — o cookie nunca é confiado sozinho). Um usuário sem nenhuma empresa (ou sem cookie de empresa ativa válido) temmembroEmpresa: null.permissoesvem derole.permissoes(Role.permissoes, uma colunaString[]no Prisma) — o perfil que oMembroEmpresaaponta, escopado à empresa.permissoesPlanovem deplanoService.buscarMatriz(empresa.planoId)— a matriz de features liberadas pelo plano de assinatura da empresa (empresaIda partir do plano em/admin/planos). Empresa semplanoId(null) equivale a "tudo liberado", para não travar empresas criadas antes de o teto de plano existir.
Exemplo real: app/(admin)/admin/layout.tsx usa getAccessContext() e canActAs(ctx, "ADMIN") para decidir se o usuário pode entrar na área administrativa (global, não por empresa), redirecionando para /dashboard caso contrário. Já app/dashboard/empresa/layout.tsx usa temEmpresaAtiva(ctx) para o gate da área de gerenciamento de empresa.
Três mecanismos de decisão
O motor tem três camadas, para três tipos de regra diferentes:
- Papel administrativo da plataforma (
isAdmin, binário): decide acesso a áreas/funcionalidades globais, fora do escopo de qualquer empresa (ex.:/admin,/admin/planos). Verificado comcanActAs(ctx, "ADMIN")/assertAdminAction. - Vínculo com empresa (
membroEmpresa !== null, binário): decide acesso à área de gerenciamento de uma empresa como um todo, independente do perfil específico. Verificado comtemEmpresaAtiva(ctx)/assertEmpresaAction. Há tambémcanActAs(ctx, "PROPRIETARIO_EMPRESA")/assertProprietarioEmpresa, reservado às ações exclusivas do Proprietário (inativar a empresa, transferir titularidade). - Permissão granular (
recurso:acao, lista): decide acesso a uma ação específica dentro de um recurso. Verificado comcanUseFeature(ctx, permissao)/assertCurrentUserCan(permissao).
canUseFeature exige duas condições independentes para liberar uma permissão granular:
- A chave estar em
ctx.membroEmpresa.permissoes(o perfil do usuário na empresa libera) — exceto para o perfil"Proprietário", que sempre passa nesta camada, mesmo que a chave não esteja gravada emRole.permissoes(ver Empresas e multi-tenant — o Proprietário sempre tem todas as permissões e não é editável em/dashboard/empresa/papeis). - A chave estar em
ctx.membroEmpresa.permissoesPlano(o plano da empresa libera) — sem exceção, nem para o Proprietário: o teto de plano vale para qualquer perfil.
Um usuário admin da plataforma (ctx.isAdmin === true) sempre passa em canUseFeature, ignorando as duas camadas — acesso irrestrito, sem depender de vínculo com nenhuma empresa.
Fluxo padrão de uma action protegida
- Validar o input com schema (zod).
- Montar
AccessContextcomgetAccessContext()(ou obtê-lo já validado viaassertCurrentUserCan). - Validar papel/tenant com os guards.
- Chamar o service, sempre passando o
empresaIddo contexto — nunca aceitar umempresaIdvindo do client sem confirmar o vínculo.
"use server";
import { assertAdminAction, getAccessContext } from "@/lib/access-control";
import { DataBaseResponse } from "@/lib/services/config/database-response";
import { z } from "zod";
const schema = z.object({
id: z.string().min(1),
});
export async function exemploAction(input: unknown) {
const parsed = schema.safeParse(input);
if (!parsed.success) {
return DataBaseResponse.error({
code: "VALIDATION_ERROR",
message: "Dados inválidos.",
}).serialize();
}
const ctx = await getAccessContext();
await assertAdminAction(ctx);
// ...lógica protegida
}Exemplo real: permissão granular escopada por empresa (agenda:*)
app/agenda/_components/engine/agendamento.actions.ts protege criação, atualização e exclusão de eventos com assertCurrentUserCan, uma permissão por ação, e sempre escopa ao empresaId do vínculo ativo:
"use server";
import { assertCurrentUserCan } from "@/lib/access-control";
import { EmpresaRequiredError } from "@/lib/access-control/errors";
export async function criarEventoAction(input: unknown) {
const ctx = await assertCurrentUserCan("agenda:create");
// ctx.usuarioId já validado; se o usuário não tiver a permissão
// "agenda:create" (nem em Role.permissoes, nem no plano da empresa) e
// não for admin, a linha acima já lançou AccessDeniedError.
if (!ctx.membroEmpresa) {
throw new EmpresaRequiredError();
}
// ...validação do input e chamada ao agendamentoService, passando
// ctx.membroEmpresa.empresaId
}Guard de permissão e escopo de tenant são checagens independentes: mesmo o admin da plataforma (que sempre passa em assertCurrentUserCan) precisa de uma empresa ativa pra criar um evento, porque Evento pertence a uma Empresa, não a um usuário. atualizarEventoAction e excluirEventoAction seguem o mesmo padrão, com "agenda:update" e "agenda:delete".
Como incluir uma nova permissão
- Escolha o nome no formato
recurso:acao(ex.:relatorios:export,financeiro:delete) e adicione-o emlib/access-control/permission-registry.ts(resourceRegistry), classificando a operação comocreate/read/update/delete(fica atrás do checkbox mestre do recurso na UI de perfis/planos) ouextra(só aparece em "+ opções"). Se o recurso tiver uma operaçãoread,aplicarDependenciasDeLeituratrava automaticamentecreate/update/deletedesse recurso atrás da leitura — não dá pra liberar escrita sem leitura, nem na UI (PermissionMatrixField) nem gravando direto viarole.service/plano.service. - Libere no plano: em
/admin/planos(área global, só admin), marque a chave como permitida para o(s) plano(s) que devem ter acesso à feature — sem isso,canUseFeaturenunca libera a permissão, mesmo que o perfil do usuário a tenha. - Atribua a um perfil da empresa: em
/dashboard/empresa/papeis, marque a chave no perfil desejado (ou crie um perfil customizado). Não exige migration —Role.permissoeséString[]. - Proteja a action com
await assertCurrentUserCan("recurso:acao")logo no topo, antes de validar o input. - Esconda a UI correspondente, se fizer sentido, checando
canUseFeature(ctx, "recurso:acao")num Server Component (sem precisar lançar erro — só decide o que renderizar).
A página /dashboard/exemplos mostra, para o usuário logado, o resultado de cada camada (perfil, plano, canUseFeature) para todas as chaves do catálogo — útil pra depurar por que uma permissão está ou não liberada.
Policies
policy.ts contém decisões sem consultar o banco (o AccessContext já foi carregado por getAccessContext()):
canActAs(ctx, "ADMIN" | "PROPRIETARIO_EMPRESA")— papel administrativo da plataforma ou papel de Proprietário na empresa ativa.temEmpresaAtiva(ctx)— há vínculo ativo com alguma empresa (ou o usuário é admin da plataforma).canUseFeature(ctx, permissao)— permissão granular (recurso:acao); admin sempre retornatrue; ver as duas camadas (perfil + plano) acima.
Use policy quando a decisão depender apenas do AccessContext já carregado — por exemplo, pra decidir o que renderizar num Server Component sem lançar erro.
Erros
Erros disponíveis em errors.ts: AuthenticationRequiredError, AccessDeniedError, ProfileRequiredError, EmpresaRequiredError.
assertAdminAction/assertProprietarioEmpresa lançam ProfileRequiredError (subclasse de AccessDeniedError); assertEmpresaAction lança EmpresaRequiredError (também subclasse de AccessDeniedError); assertCurrentUserCan lança AccessDeniedError diretamente, com a permissão faltante na mensagem.
Estendendo o RBAC
Para adicionar um novo perfil, recurso ou regra de ownership:
- Se for decisão sem banco, adicione em
policy.ts. - Se for uma permissão granular nova (
recurso:acao), siga "Como incluir uma nova permissão" acima — cadastro no registry, teto de plano, atribuição a um perfil, guard na action. - Se precisar carregar um recurso com verificação de ownership além do escopo de empresa, crie um guard próprio (siga o padrão de
assertProprietarioEmpresa). - Se for composição comum de server action, adicione em
action-guards.tsoupermission-guard.ts, conforme o mecanismo (papel/tenant ou permissão).