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

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.

## Como testar permissões entre empresas com RLS?

Defina as associações de cada usuário às empresas e as ações permitidas por perfil, depois teste acessos permitidos e negados. Este guia demonstra leitura e edição com dados sintéticos e SQL executável. A validação pela API real e pelas demais partes da aplicação ainda é necessária antes de concluir sobre a segurança em produção.

**Neste guia**

1. [Identifique quem opera o backend](https://tornostudio.com/guias/rls-multiempresa/#arquitetura)
2. [Contrato: identidade, associação e ação](https://tornostudio.com/guias/rls-multiempresa/#contrato)
3. [USING, WITH CHECK e privilégios por coluna](https://tornostudio.com/guias/rls-multiempresa/#politicas)
4. [Matriz empresa A/B × ação](https://tornostudio.com/guias/rls-multiempresa/#matriz)
5. [Execute e confira o resultado](https://tornostudio.com/guias/rls-multiempresa/#executar)
6. [O que ainda precisa ser validado para produção](https://tornostudio.com/guias/rls-multiempresa/#limites)

## 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.

| Perfil sintético | Empresa A | Empresa B |
| ---------------- | --------- | --------- |
| Editor A         | CRUD      | Nenhum    |
| Viewer A         | Ler       | Nenhum    |
| Editor B         | Nenhum    | CRUD      |
| Editor A+B       | CRUD      | CRUD      |
| Sem associação   | Nenhum    | Nenhum    |

## 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

* [run-local.sh](https://tornostudio.com/examples/rls-multiempresa/run-local.sh)
* [bootstrap-local.sql](https://tornostudio.com/examples/rls-multiempresa/bootstrap-local.sql)
* [schema.sql](https://tornostudio.com/examples/rls-multiempresa/schema.sql)
* [tests.sql](https://tornostudio.com/examples/rls-multiempresa/tests.sql)

```
bash run-local.sh
```

Alternativa PostgreSQL WASM: [run-wasm.mjs](https://tornostudio.com/examples/rls-multiempresa/run-wasm.mjs)

[Instruções e alternativa WASM (PT/EN)](https://tornostudio.com/examples/rls-multiempresa/README.txt)

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

* [Supabase — Row Level Security](https://supabase.com/docs/guides/database/postgres/row-level-security)
* [PostgreSQL — Row Security Policies](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)
* [Lovable — Security overview](https://docs.lovable.dev/features/security)
* [Supabase — Identifying Lovable Cloud or Supabase](https://supabase.com/docs/guides/troubleshooting/identify-lovable-cloud-or-supabase-backend)
* [PGlite — PostgreSQL WASM](https://pglite.dev/docs/)

## 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](https://calendly.com/henriquepett/45min)

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

* [Recuperação de projeto](https://tornostudio.com/recuperacao-de-projeto/)
* [Lovable em produção](https://tornostudio.com/lovable-em-producao/)
