Guia técnico · Henrique Pett · 13 de outubro de 2026

RLS multiempresa no Supabase: permissões e teste executável

Um login válido não autoriza acesso a todas as empresas. Neste guia, eu modelo um usuário que participa de uma ou mais empresas, separo leitura de edição e testo o que acontece quando ele tenta acessar outra empresa. O exemplo usa apenas dados sintéticos.

1. Identifique quem opera o backend

No Lovable Cloud, o backend é gerenciado pelo Lovable. Não presuma que o projeto aparecerá na sua conta Supabase ou que você terá o mesmo acesso administrativo de um projeto próprio. Use os recursos de banco, autenticação e segurança disponíveis no Cloud para revisar a configuração.

Com Supabase próprio conectado ao Lovable, você administra esse projeto Supabase, suas migrations, grants e políticas. Com backend próprio, a API precisa validar a identidade e aplicar a autorização em toda rota. O proprietário da tabela normalmente ignora RLS, mas FORCE ROW LEVEL SECURITY o sujeita às políticas. Superusuários e papéis BYPASSRLS continuam ignorando RLS mesmo com FORCE.

Eu separo três perguntas: quem controla a conta e a infraestrutura; quais grants, políticas e segredos estão configurados; e qual regra de negócio cada perfil deve cumprir. Problemas de plataforma pertencem ao suporte da plataforma; uma regra de acesso da sua operação precisa de critérios e testes próprios.

2. Contrato: identidade, associação e ação

auth.uid() representa o usuário autenticado, não a empresa ativa. O navegador pode enviar company_id para escolher uma empresa, mas esse valor não concede acesso. A decisão consulta memberships(company_id, user_id, permission) no banco. A chave composta impede associações duplicadas e permission aceita somente viewer ou editor.

Neste exemplo, viewer lê documentos da empresa; editor lê, cria, altera o título e exclui qualquer documento dela. created_by registra o autor, não é a regra de autorização. Na criação ele deve coincidir com auth.uid(); company_id e created_by não podem ser atualizados pelo cliente, nem quando ele é editor das duas empresas.

O cliente só lê suas próprias associações. Não há grant nem policy de escrita em memberships para authenticated: ele não pode se promover a editor, se associar a B ou editar a associação de outra pessoa. Convites e administração de membros exigem um fluxo separado no servidor, com autorização administrativa e auditoria. Esse fluxo não está implementado na demo.

Um usuário multiempresa recebe direitos por associação, sem um tenant global em metadata editável pelo usuário. Ao remover a associação com A, as consultas seguintes em READ COMMITTED deixam de ver A e preservam B. Transações com snapshot antigo, resultados já baixados e caches exigem tratamento adicional; revogar uma associação não apaga dados que já chegaram ao cliente.

3. USING, WITH CHECK e privilégios por coluna

USING restringe quais linhas existentes entram em SELECT, UPDATE e DELETE. WITH CHECK valida os valores novos em INSERT e UPDATE. O exemplo combina as duas condições no UPDATE, além da política SELECT necessária para localizar a linha. Uma leitura negada retorna zero linhas; UPDATE ou DELETE filtrados também podem afetar zero linhas sem lançar erro.

WITH CHECK sozinho não compara OLD com NEW: um editor de A e B poderia passar nas duas verificações e transferir um documento. Por isso o grant de UPDATE permite apenas title. O teste de transferência verifica uma recusa 42501. Esse é um bloqueio por privilégio de coluna, não uma prova de que WITH CHECK detectou a transferência.

O esquema também protege companies e memberships com RLS. As policies de documentos consultam apenas as associações visíveis do usuário, sem função SECURITY DEFINER. Índices em memberships(user_id, company_id) e documents(company_id) apoiam as consultas. Outras policies permissivas existentes podem ampliar acesso: revise o conjunto, não cole este exemplo sobre um banco existente.

4. Matriz empresa A/B × ação

Cada célula abaixo vira uma asserção no SQL. “CRUD” significa ler, criar, alterar title e excluir. Um editor pode modificar documentos de outros autores da mesma empresa; não pode trocar autor ou empresa. O acesso de um editor A+B é intencional e verificado separadamente do vazamento entre empresas.

Permissões esperadas antes da revogação
Perfil sintéticoEmpresa AEmpresa B
Editor ACRUDNenhum
Viewer ALerNenhum
Editor BNenhumCRUD
Editor A+BCRUDCRUD
Sem associaçãoNenhumNenhum

5. Execute e confira o resultado

Baixe os quatro arquivos abaixo na mesma pasta e execute bash run-local.sh. Pré-requisitos: Bash e PostgreSQL com initdb, pg_ctl e psql no PATH, rodando como usuário comum do sistema. O runner cria um cluster descartável, sem listener TCP, em um diretório temporário privado; ele não aceita URL de banco e remove o cluster ao terminar.

bootstrap-local.sql simula auth.users, auth.uid() e os papéis da API. O harness injeta um UUID sintético com set_config; isso não valida JWT. Nunca copie esse bootstrap para Supabase. schema.sql é o contrato da demo; tests.sql prepara os dados como administrador e executa as asserções com SET ROLE authenticated, sem ser dono das tabelas, superusuário ou BYPASSRLS.

O resultado esperado termina com RLS_DEMO_ALL_ASSERTIONS_PASSED. Falhas encerram o processo com status diferente de zero. Os casos verificam contagem de linhas e SQLSTATE 42501, incluindo CRUD permitido e negado, criação com autor falso, troca de empresa/autor, alteração de associações, usuário sem associação, identidade nula, anon, downgrade e revogação. Um caso separado demonstra que BYPASSRLS enxerga ambas as empresas.

Executado em 13/10/2026: PostgreSQL 18.6 WASM via PGlite 0.5.9, com 94 asserções aprovadas e Supabase simulado. Não houve teste integrado de Supabase, JWT, API HTTP ou produção. O runner público usa o mesmo schema e as mesmas asserções; build e testes de navegador não substituem essa execução SQL.

Arquivos executáveis

bash run-local.sh

Alternativa PostgreSQL WASM: run-wasm.mjs

Instruções e alternativa WASM (PT/EN)

Ver o schema completo
-- Contract: synthetic demo, new empty database only. Requires auth.uid(),
-- auth.users(id), authenticated and anon from Supabase or local bootstrap.
-- Ordinary clients can never manage memberships, tenant_id or created_by.
create table public.companies (
  id uuid primary key,
  name text not null
);
create table public.memberships (
  company_id uuid not null references public.companies(id),
  user_id uuid not null references auth.users(id),
  permission text not null check (permission in ('viewer', 'editor')),
  primary key (company_id, user_id)
);
create index memberships_user_company on public.memberships(user_id, company_id);
create table public.documents (
  id uuid primary key,
  company_id uuid not null references public.companies(id),
  created_by uuid not null references auth.users(id) default auth.uid(),
  title text not null
);
create index documents_company on public.documents(company_id);
alter table public.companies enable row level security;
alter table public.memberships enable row level security;
alter table public.documents enable row level security;
revoke all on public.companies, public.memberships, public.documents from public, anon, authenticated;
grant select on public.companies, public.memberships, public.documents to authenticated;
grant insert (id, company_id, created_by, title), update (title), delete on public.documents to authenticated;
-- No INSERT/UPDATE/DELETE grants or policies on memberships for clients.
create policy membership_read on public.memberships for select to authenticated
  using (user_id = (select auth.uid()));
create policy company_read on public.companies for select to authenticated
  using (exists (select 1 from public.memberships m
    where m.company_id = companies.id and m.user_id = (select auth.uid())));
create policy document_read on public.documents for select to authenticated
  using (exists (select 1 from public.memberships m
    where m.company_id = documents.company_id and m.user_id = (select auth.uid())));
create policy document_insert on public.documents for insert to authenticated
  with check (created_by = (select auth.uid()) and exists (
    select 1 from public.memberships m where m.company_id = documents.company_id
      and m.user_id = (select auth.uid()) and m.permission = 'editor'));
create policy document_update on public.documents for update to authenticated
  using (exists (select 1 from public.memberships m
    where m.company_id = documents.company_id and m.user_id = (select auth.uid()) and m.permission = 'editor'))
  with check (exists (select 1 from public.memberships m
    where m.company_id = documents.company_id and m.user_id = (select auth.uid()) and m.permission = 'editor'));
create policy document_delete on public.documents for delete to authenticated
  using (exists (select 1 from public.memberships m
    where m.company_id = documents.company_id and m.user_id = (select auth.uid()) and m.permission = 'editor'));
-- WITH CHECK checks destination authorization, not equality to the OLD row.
-- Column grants above prevent transfers even for an editor of BOTH companies.

6. O que ainda precisa ser validado para produção

service_role e papéis BYPASSRLS ignoram RLS. Secret keys devem permanecer no servidor; quando a requisição roda como serviço, não espere proteção por policies. Em clientes com chaves legadas, confira o Authorization efetivo: um token de usuário pode fazer a consulta executar com as permissões desse usuário. Não conclua o contexto de autorização apenas pela chave usada para inicializar o SDK.

Depois do teste SQL, eu repetiria a matriz pela API real com sessões distintas, incluindo endpoints privilegiados, funções, views, Storage, Realtime, jobs e caches aplicáveis. Também revisaria grants herdados, exposição de schemas, gestão de segredos, migrations, backups, logs sem dados sensíveis e concorrência da revogação. Este SQL isolado não garante segurança nem prontidão de produção.

O Lovable já oferece Quick scan, Deep scan e correções automáticas elegíveis. Quick verifica configuração e acesso ao banco, dependências e exposição MCP; Deep inclui análise da lógica, permissões, dados, autenticação e pagamentos. Eu uso esses recursos e reviso os achados. A matriz executada aqui acrescenta evidência específica da regra A/B; não pressupõe que o Lovable seja incapaz de encontrá-la.

Fontes oficiais consultadas em 13/10/2026

Sua operação precisa dessas regras verificadas?

Eu avalio o que pode ser aproveitado e quais riscos precisam ser resolvidos em um app empresarial em operação ou com lançamento financiado. Traga a arquitetura e a matriz de acesso esperada; não envie credenciais ou dados de clientes.

Conversa gratuita de 45 minutos para entender o contexto e avaliar o próximo passo. Não inclui auditoria técnica.

Agendar triagem gratuita de 45 min

Ao agendar, mencione “guia RLS” na conversa para eu reconhecer a origem do contato.