Controle de acesso por papéis
Contextos, papéis, escopos e grants — como uma decisão de permissão é realmente tomada.
A maior parte dos bugs de controle de acesso vem de um modelo que responde "o que este usuário é?" quando a pergunta real é "o que este usuário é aqui?". A nulvless só responde a segunda.
Não existe uma flag global de "admin". Permissão sempre existe no cruzamento entre um usuário e um recurso.
As quatro peças
contextstringUm tipo de recurso — "org", "project". Papéis são definidos dentro de um.
roleRoleUm conjunto nomeado de escopos, pertencente a exatamente um contexto.
scopestringUma capacidade — "members:write". É o que o seu código verifica.
grantGrantUm usuário com um papel sobre um recurso. É o que existe em tempo de execução.
Leia como uma frase:
Grace tem o papel owner — um papel definido no contexto org, que carrega os escopos
members:writeebilling:read— sobre o recurso acme.
Troque o recurso e é outro grant. Grace pode ser owner em acme e não ter
nada em globex, sem nenhum caso especial no seu código.
Nada aqui se chama organização
A maioria dos produtos de identidade já vem com uma opinião: usuários pertencem a organizações, uma organização tem membros, e se o seu produto tem outro formato você briga com o modelo. A nulvless não tem essa entidade.
context e resource são strings que você escolhe. A plataforma não guarda
esquema nenhum para elas e nunca valida se acme existe — ela só registra que
um grant aponta para lá. É isso que faz um único mecanismo cobrir qualquer
formato:
| Seu conceito | context | resource |
|---|---|---|
| Organizações | org | o slug da org — acme |
| Projetos | project | o id do projeto — proj_16tbwq34 |
| Workspaces | workspace | o uuid do workspace |
| Lojas de um marketplace | store | o id da loja |
| Um documento específico | document | o id do documento |
| Seu próprio back office | platform | uma constante, ex. internal |
Nada na plataforma muda entre essas linhas. Você não está modelando organizações — está dizendo este papel se aplica a coisas deste tipo, e este grant se aplica àquela coisa específica.
Como as peças se conectam
context "project" ← um tipo de coisa, nomeado por você
│
└── role "editor" ← definido dentro de exatamente um contexto
│ key: 'editor'
│ scopes: ['project:write', 'project:read']
│
└── grant ← o papel, um usuário e um recurso concreto
user: usr_8f21
resource: 'proj_16tbwq34'- O contexto liga o papel a um tipo de coisa. Um papel
editoremprojectnão tem como ser concedido de forma significativa sobre uma org — os papéis que você lista para um contexto são os únicos que cabem ali. - O papel carrega as permissões. Os escopos ficam no papel, não no usuário, então mudar um papel move todo mundo que o tem de uma vez.
- O grant fornece a coisa específica. É o único lugar onde um identificador concreto de recurso aparece.
O papel é, por construção, reutilizável: editor é definido uma vez e concedido
em mil projetos. O papel não sabe quais são.
Como recursos são opacos, a hierarquia é sua
A plataforma vê acme e proj_16tbwq34 como strings sem relação — ela não sabe
que uma contém a outra. Grants não cascateiam.
Isso é uma restrição e um recurso ao mesmo tempo. Significa que você pode codificar qualquer hierarquia, inclusive nenhuma, mas precisa verificar os níveis que quer honrar. Se donos da organização devem alcançar todos os projetos dentro dela, diga isso explicitamente — veja recursos aninhados.
Escolhendo nomes de escopo
Os escopos também são seus. A única regra que importa é que nomeiem uma capacidade, não uma tela nem um papel:
// Bom — sobrevive a um redesign.
['members:read', 'members:write', 'billing:read', 'projects:delete']
// Ruim — um detalhe de UI e um papel fantasiado de escopo.
['settings-page', 'is_admin']A convenção recurso:ação lê bem e agrupa naturalmente, mas nada obriga.
O contexto é propriedade do papel, não do grant
Aqui muita gente tropeça. Quando você convida alguém, você nomeia um recurso
e um role_id. O contexto veio junto com o papel quando o papel foi definido —
você nunca passa contexto para o invite.
// Papéis são definidos uma vez, por contexto.
await nulvless.auth.admin.roles.create({
name: 'Administrator',
key: 'admin',
context: 'org',
scopes: ['members:read', 'members:write', 'projects:read'],
});
// Grants apontam para um recurso e reaproveitam um papel.
await nulvless.auth.admin.grants.set({
resource: 'acme',
granted_to_user_id: userId,
role_id: adminRoleId,
});Como uma decisão é tomada
Uma chamada responde as duas perguntas — o token continua válido, e quem o porta tem o que você nomeou:
const { data, error } = await nulvless.auth.verify(
{ resource: 'acme', roles: ['owner', 'admin'] },
{ headers: { Authorization: `Bearer ${token}` } },
);resourcestringOptionalSobre qual recurso é a pergunta. Omita para verificar só o token.
rolesstring[]OptionalCasado contra a key do papel. Qualquer um deles basta. Ignorado se resource
não for passado.
scopesstring[]OptionalEscopos exigidos. Quem porta precisa ter todos.
Pergunte, não busque e decida
Buscar os grants do usuário e comparar no seu próprio código reimplementa a
decisão — e desalinha no instante em que um papel muda. Nomeie o requisito e
deixe o verify responder.
As quatro formas de perguntar
// 1. Este token ainda é válido? Nada sobre permissões.
await nulvless.auth.verify(undefined, { headers });
// 2. Quem porta tem uma capacidade aqui?
await nulvless.auth.verify({ resource: 'acme', scopes: ['members:write'] }, { headers });
// 3. Quem porta tem um destes papéis aqui?
await nulvless.auth.verify({ resource: 'acme', roles: ['owner', 'admin'] }, { headers });
// 4. A própria sessão carrega estes escopos, independente de recurso?
await nulvless.auth.verify({ scopes: ['platform:support'] }, { headers });As duas dimensões não combinam do mesmo jeito:
| Semântica | |
|---|---|
scopes | Exige todos eles. |
roles | Qualquer um deles basta. |
Então scopes: ['members:read', 'members:write'] exige os dois, enquanto
roles: ['owner', 'admin'] aceita qualquer um.
Duas coisas que surpreendem
roles casa com a key do papel, não com o nome. Um papel criado com
key: 'admin' e name: 'Administrator' é verificado como 'admin'.
roles é ignorado quando você omite resource. Sem recurso não há grant de
onde ler um papel, então só os scopes são avaliados — contra os escopos da
própria sessão. Uma verificação só com papéis e sem recurso passa silenciosamente.
Distinguir "não autenticado" de "não autorizado"
O verify separa os dois, e a sua API deve preservar a diferença:
const { data, error } = await nulvless.auth.verify(
{ resource, scopes: ['members:write'] },
{ headers: { Authorization: `Bearer ${token}` } },
);
if (error?.status_code === 401) {
// Token ausente, expirado ou revogado → mande para a tela de entrada.
}
if (error?.status_code === 403) {
// Autenticado, mas não tem a permissão → mostre um estado de acesso negado.
}Juntar os dois em 401 é um bug comum: joga um usuário já autenticado de volta para a tela de login, onde entrar de novo não muda nada.
Um 403 também traz um code, e um deles vale tratar à parte:
forbidden_resource_access403Nenhum grant neste recurso, ou ele está bloqueado, ou falta um escopo ou papel exigido.
forbidden_access_waiting_accept403Existe um grant, mas ainda está como invited ou requested — o convite nunca
foi aceito.
Esse segundo código é a diferença entre "você não tem acesso" e "existe um convite parado na sua caixa de entrada", e vale dizer isso na interface.
O que volta
{
user: { id },
session: {
id,
scopes, // os escopos efetivos neste recurso
amr, // como autenticou: password, otp, totp…
sudo, // se o modo sudo está ativo agora
sudo_expires_at,
},
}Só um grant com status granted passa. Um convite nunca aceito é inerte, e um
grant bloqueado nega.
session.scopes é o conjunto resolvido para aquele recurso — útil para uma UI
que esconde o que o usuário não pode fazer, depois que o servidor já decidiu.
Papéis versus escopos na verificação
Verifique escopos, não papéis, sempre que puder. Papel é um rótulo que seus clientes renomeiam; escopo é a capacidade da qual o seu código depende.
// Frágil — quebra quando a key de um papel muda ou outro papel também deveria passar.
await nulvless.auth.verify({ resource, roles: ['admin'] });
// Durável — continua funcionando conforme os papéis se reorganizam.
await nulvless.auth.verify({ resource, scopes: ['members:write'] });Papéis são a verificação certa quando a regra é mesmo sobre senioridade — "só um dono pode apagar a organização".
Permissões avulsas
Um grant não precisa de papel. Omita role_id e ele carrega escopos soltos —
útil para acesso que nenhum papel deveria descrever, como uma automação ou uma
exceção pontual:
await nulvless.auth.admin.grants.set({
resource: 'acme',
granted_to_user_id: botUserId,
scopes: ['projects:read'],
});Você também pode somar escopos sobre um papel para uma pessoa só:
await nulvless.auth.grants.update(grantId, {
scopes: ['members:read', 'billing:read'],
});Os dois são exceções, e exceções são invisíveis numa tela de papéis. Se você não consegue responder "quem vê o faturamento?" lendo só os papéis, o modelo começou a desalinhar — prefira criar um papel quando a mesma exceção aparecer duas vezes.
A lista de grants é a sua lista de inquilinos
Um padrão que vale nomear, porque elimina uma tabela que a maioria dos times constrói: para listar as organizações de um usuário, liste os grants dele naquele contexto.
const { data: grants } = await nulvless.auth.grants.list('org');
const slugs = grants.map((grant) => grant.resource);
const organizations = await db.organizations.findBySlugs(slugs);Não há tabela de membros para manter sincronizada, porque o vínculo é o grant — uma coisa a menos no seu esquema que pode discordar das permissões.
Semeando o primeiro grant
Quem cria um recurso não deveria precisar aceitar um convite para ele. Crie o grant como plataforma, na mesma transação que cria o recurso:
await db.transaction(async (tx) => {
const org = await tx.organizations.create({ slug, name });
await nulvless.auth.admin.grants.set({
resource: org.slug,
granted_to_user_id: creatorId,
role_id: OWNER_ROLE_ID,
});
return org;
});admin.grants.set pula o convite inteiro — o grant existe imediatamente. Manter
isso na transação garante que uma falha aqui não deixe um recurso órfão que
ninguém consegue administrar.
Limpeza
Quando um recurso é apagado, os grants dele precisam ir junto, senão sobrevivem ao que apontavam:
await nulvless.auth.admin.grants.deleteByResource(org.slug);Agindo como o usuário, ou como a plataforma
Dois namespaces fazem coisas parecidas, e escolher o errado é um erro real:
| Age como | Use para | |
|---|---|---|
auth.grants | O usuário autenticado | O que uma pessoa faz — convidar, aceitar, sair |
auth.admin.grants | A plataforma | Semear donos, limpeza, ferramentas internas |
auth.grants aplica as permissões de quem chamou e registra isso na trilha de
auditoria, então repasse os headers:
import { forwardUserHeaders } from '@nulvless/nulvless-js';
await nulvless.auth.grants.invite(params, {
headers: forwardUserHeaders(req),
});auth.admin.grants ignora isso por design. É ferramenta de back office, não
atalho para ações de usuário — um grant criado por ele não guarda registro de
quem pediu.