Voltar para os textos

Autorização em quatro perguntas

Capacidade, partição, relação e tenant. Cada pergunta com o seu mecanismo, e nenhuma role tentando responder todas.

Quase todo sistema de autorização começa com uma pergunta só: qual é o papel do usuário? E funciona, enquanto o domínio também é simples. O problema aparece quando a mesma decisão passa a depender de coisas de natureza diferente e alguém tenta resolver tudo esticando a mesma hierarquia de papéis.

Este texto é um recorte de uma arquitetura que desenhei para autorização humana em sistemas multi-tenant. Não é Zanzibar inteiro, não é um motor de políticas genérico e não joga a regra toda dentro do banco. É uma escolha deliberada: pegar de cada tradição de controle de acesso a abstração que resolve bem um problema específico, e só essa.

Quando uma role deixa de ser suficiente

O começo costuma ser assim:

viewer < editor < admin < superadmin

if user.role.rank >= required_rank:
    allow()

Parece econômico, mas mistura conceitos. Um administrador pode precisar cadastrar pessoas sem ler conteúdo sensível. Um analista pode executar uma operação, mas só sobre os dados da própria unidade. Alguém de outra área pode receber um relatório específico sem passar a fazer parte daquela área. E um operador de plataforma pode precisar atravessar tenants numa emergência sem que o nome da role dele vire senha mestra em todo handler.

Não faltam níveis na hierarquia. A hierarquia é que não é a abstração certa para todas essas perguntas. Quando se insiste, aparecem os sintomas de sempre: rank mágico, exceção ad hoc, permission por departamento, if admin espalhado pelo código, compartilhamento que dá mais poder do que deveria e isolamento entre clientes que depende da disciplina de cada endpoint.

As quatro perguntas

Uma decisão de autorização madura se decompõe em quatro dimensões independentes:

PerguntaMecanismoPapel
O que o sujeito pode fazer?RBAC com permissionsCapacidade
De quais unidades ele faz parte?Membership organizacionalPartição
Este objeto foi compartilhado com ele?Relação tipada, à la ReBACExceção pontual
Esta linha pertence ao tenant?Row Level SecurityIsolamento

As quatro podem dar a mesma resposta final, permitir ou negar, mas não têm a mesma semântica. Espremer todas numa coluna role ou numa tabela genérica de grants é o caminho mais curto para uma política que ninguém consegue explicar nem testar.

Uma pergunta de autorização, um mecanismo principal.

Tem um jeito simples de perceber que os eixos precisam ficar separados: olhar como cada um muda. Capacidade muda quando o cargo da pessoa muda. Partição muda quando o organograma muda. Compartilhamento muda por uma ação pontual de colaboração. Tenant quase nunca muda durante a vida de uma identidade. São eventos com frequência, dono, trilha e revogação diferentes, e colocar tudo no mesmo registro obriga o sistema a tratar como iguais coisas que não são.

Capacidade: RBAC sem ranks mágicos

O núcleo clássico do RBAC continua: usuários recebem roles, roles agregam permissions e a autorização consulta o conjunto efetivo. O que eu rejeito é role como número. Role é um conjunto declarado. Se uma contém a outra, é porque foi declarada assim, e não porque uma ganhou o 3 e a outra o 2.

VIEWER = { profile.manage, content.view }

ANALYST = VIEWER ∪ {
    analysis.run, data.query,
    content.manage, content.share
}

TENANT_ADMIN = ANALYST ∪ {
    users.read, users.write,
    authz.roles.read, authz.roles.assign_tenant
}

Assim o poder fica inspecionável. Dá para mudar a composição de uma role sem reconstruir a semântica da hierarquia inteira, e o conjunto efetivo vira uma união simples:

effective(u) =
      permissions(base_role)
    ∪ permissions(roles de u)
    ∪ grants diretos válidos(u)

A política é monotônica: só existe concessão, não existe deny explícito. Isso é escolha, não esquecimento. Deny muda a precedência e a análise de tudo, e merece ser tratado como evolução de arquitetura, não como mais uma coluna.

Permission é capacidade atômica, no formato domínio.recurso.ação. Antes de uma entrar no catálogo, ela passa por três perguntas:

  • Existe um ponto real de enforcement para ela hoje?
  • Existe uma role que a tem e outra que não tem?
  • Backend e frontend vão usar exatamente o mesmo código?

Se a primeira resposta for não, a permission é documentação sem efeito. Se a segunda for não, a granularidade é teatral. Se a terceira for não, a interface e a API vão divergir. O sintoma mais comum de catálogo desonesto é uma permission por etapa de tela, workflow.upload, workflow.preview, workflow.map, que nenhuma role diferencia. Isso não expressa poder, só copia a estrutura da UI.

E nem toda permission pode ser concedida dentro de um tenant. Coisas como platform.tenant.bypass e authz.roles.assign_platform vivem do outro lado de uma fronteira, e o CI verifica que nenhuma role de tenant contém uma delas. Essa propriedade importa mais do que o nome de qualquer role.

Se uma empresa tem dezenas de unidades, codificar isso em permissions dá em content.view.finance, content.view.hr, content.view.branch_001. O organograma vira código, e cada reorganização vira deploy. A permission deixa de representar capacidade e passa a representar instância de dado.

Partição é membership, e membership é dado:

org_unit(id, tenant_id, name)
user_org_unit(user_id, org_unit_id)
resource.org_unit_id

Uma decisão que parece pequena e não é: o recurso pertence à unidade, não à pessoa que o criou. Se o autor muda de área, ele pode perder acesso ao que produziu. Soa contraintuitivo, mas preserva o princípio de que o ativo é da organização.

E cuidado com o legado. Sistema em migração sempre tem dado antigo sem unidade. Tratar org_unit_id IS NULL como “legado” para sempre é perigoso, porque todo objeto novo que nascer sem unidade herda a mesma exceção histórica. Legado tem que ser marcado como legado, numa coluna própria, e objeto novo não pode nascer com a marca.

Relação: compartilhar é exceção, não membership

Receber um relatório de outra área não faz de ninguém membro daquela área. Esse é o caso perfeito para uma relação tipada, e é a parte do Zanzibar que eu realmente uso. Não preciso de namespace global, zookies nem grafo distribuído. A ideia útil é menor:

Uma exceção de acesso sobre um objeto concreto deve ser uma relação explícita, revogável e auditável.

resource_share(
    resource_id,
    grantee_user_id,
    granted_by_user_id,
    granted_at
)

Três regras acompanham a relação. Share é intra-tenant por padrão: se compartilhar entre clientes for requisito, merece um modelo separado, e não pode surgir como efeito colateral de uma tabela sem constraint. Quem recebe não ganha poder de re-compartilhar, porque receber viewer não é autoridade de concessão. E compartilhar exige capacidade e autoridade sobre o objeto, expressas em permission e predicado, nunca em if user.role == "admin".

can_share(u, r, grantee) :=
      has(u, content.share)
  AND same_tenant(u, r, grantee)
  AND can_view_resource(u, r)
  AND ( created_by(r) = u
     OR has(u, content.view_all_partitions) )

Agregado não é microdado

Essa é a distinção mais fácil de perder. Um relatório e o dataset que o originou são objetos de autorização diferentes, e cada um tem o seu predicado:

can_view_resource(u, r) :=
      same_tenant(u, r)
  AND ( in_same_partition(u, r)
     OR shared_with(u, r)
     OR has_all_partitions_view(u)
     OR is_legacy_unpartitioned(r) )

can_access_data_context(u, c) :=
      same_tenant(u, c)
  AND ( in_same_partition(u, c)
     OR is_legacy_unpartitioned(c) )

A ausência de shared_with no segundo predicado é deliberada. Compartilhar a apresentação não pode virar acesso ao dado bruto. Pelo mesmo motivo, content.view_all_partitions significa ver agregados de todas as unidades, e não consultar os microdados de todas elas. Se o negócio precisa das duas coisas, são duas capacidades. O nome de uma permission tem que refletir o raio real dela.

E particionar só o relatório é barreira cosmética. Se a fonte não tiver autorização própria, basta alguém descobrir o identificador do contexto e chamar o endpoint direto.

Isolamento: o banco garante o piso

Row Level Security tem uma responsabilidade estreita aqui: impedir acesso cross-tenant. A policy é simples o bastante para caber em duas linhas e ser auditada:

tenant_id = current_tenant()
OR current_platform_bypass() = true

O banco não sabe quem é viewer, analyst ou dono de um share. Capacidade, partição e relação ficam na aplicação. Duplicar tudo no RLS e no código cria manutenção dupla, e uma correção feita de um lado não chega ao outro. Se um dia a partição for para o banco, que seja gerada a partir da mesma especificação.

O banco garante o piso. A aplicação garante a política.

Só que RLS de tenant impede o tenant A de ver o tenant B; não impede o departamento A de ver o departamento B. Depois que existem partições, um repositório que faz get_by_id(id) e confia no RLS deixa de ser seguro. A resposta aqui é exigir um AccessContext em toda leitura de conteúdo, deixar os repositórios sensíveis privados ao módulo de autorização e fazer o CI falhar quando um router importa um deles direto. Review de PR ajuda, mas não pode ser a única barreira.

Quem pode dar poder a quem

A regra “só concede quem possui” parece segura e não é. Se um admin tem todas as permissions de admin, o subset check deixa ele criar outro admin. Ao mesmo tempo, obriga quem só precisa conceder uma capacidade a possuí-la. Autoridade de concessão precisa ser modelada diretamente, numa matriz explícita:

role_grantable(grantor_role, target_role)

tenant_admin   -> viewer, analyst
platform_admin -> tenant_admin, viewer, analyst

Usar uma capacidade e atribuí-la a outra pessoa são operações diferentes. Cadastrar uma identidade e conceder autoridade também, então são endpoints diferentes. Escrevi mais sobre isso em Quem pode dar poder a quem.

Sessão com prazo de validade

Quase todo sistema guarda o conjunto efetivo na sessão para não recalcular tudo a cada request. Tudo bem, desde que o cache saiba quando envelheceu. E ele envelhece de três jeitos: quando o usuário muda, quando o catálogo muda e quando o relógio passa da hora de um grant temporário. Cada um precisa de um mecanismo próprio, e o último não pode depender de cron. O detalhe está em Permissões em cache envelhecem de três jeitos.

O frontend espelha, o backend decide

O navegador está fora da fronteira de confiança. Esconder um botão não é autorização, e claim de role vindo do cliente não é fonte de verdade. A permission tem o mesmo código nas duas pontas, require_permission("analysis.run") no servidor e hasPermission("analysis.run") na interface, e só o servidor decide.

FrontendBackendResultado
escondebloqueiacorreto
escondesó observarollout, não enforcement
mostrabloqueiaUX ruim
mostrapermite sem gatevulnerabilidade

Numa migração, vale rodar a autorização em shadow mode: o predicado é avaliado e a decisão registrada, mas a negação ainda não bloqueia. Isso mede o impacto real antes do cutover. Só que shadow não é segurança, e ficar nele indefinidamente é dívida mensurável. Toda rota autenticada declara uma permission ou é classificada explicitamente como sem gate, e o CI compara o inventário de rotas com o catálogo.

O que ficou de fora de propósito

  • Tudo em ReBAC. Dá para modelar membership e herança como relação, mas, para partições estáveis, o custo de grafo, consistência e tooling não se paga. A rejeição é pragmática, não teórica.
  • Um motor ABAC genérico. Uso atributos como tenant_id, org_unit_id e expires_at, mas em predicados fechados e testáveis. Linguagem arbitrária de política é difícil de revisar e de explicar.
  • Tudo no RLS. Enforcement perto do dado é ótimo para o piso; para regra de produto vira versionamento difícil e duplicação.
  • Share como role nova. Transforma uma exceção sobre um objeto em poder global.

Fechando

Autorização madura não é uma sequência de if role == ..., mas também não precisa ser um grafo global distribuído. Na maior parte dos sistemas de aplicação, o problema cabe em quatro perguntas: o que a pessoa pode fazer, a qual unidade o dado pertence, se existe uma exceção sobre aquele objeto e se a linha é do tenant certo. RBAC, membership, relação tipada e RLS, cada um no seu lugar.

O melhor efeito colateral é a disciplina de evolução. Uma unidade nova não exige permission nova. Um share novo não muda a role de ninguém. Uma capacidade nova não mexe no tenant. Cada eixo evolui no seu ritmo.

Capacidade não é partição, partição não é compartilhamento, compartilhamento não é tenant, agregado não é microdado e interface não é enforcement.