DEV Community

Michel
Michel

Posted on • Originally published at garraia.org

Multi-tenant files em Postgres + S3 com RLS FORCE: como o GarraIA fechou 9 slices REST sem deixar brecha de tenant isolation

No GarraIA — framework de agentes IA em Rust, 100% local, MIT — a Fase 3 (Group Workspace) ganhou esta semana sua superfície de arquivos completa: 9 slices da Files API, do GET /v1/groups/{group_id}/files até o POST /v1/groups/{group_id}/files de upload direto, passando por download streaming, version upload, version list, folder CRUD e soft-delete.

Tudo isso sobre o pool garraia_app, que é BYPASSRLS = false. O Postgres é quem filtra cross-tenant, não o handler HTTP.

Este post é sobre as decisões de design que fizeram esses 9 slices serem 9 PRs cirúrgicos em vez de uma refatoração gigante.

A regra de ouro: tenant isolation não é responsabilidade do handler

Em arquitetura multi-tenant tradicional, o handler HTTP é quem decide o que cada tenant pode ver:

// ❌ O handler "sabe" o que filtrar — uma linha errada e a brecha aparece
let files = sqlx::query!("SELECT * FROM files WHERE group_id = $1", group_id)
    .fetch_all(&pool).await?;
Enter fullscreen mode Exit fullscreen mode

O problema: a query e o filtro de tenant estão acoplados na lógica do handler. Em 200 handlers, um esquecer o WHERE group_id é vazamento cross-tenant. CodeQL pega alguns. Code review pega outros. Mas a régua é "o humano não erra", o que é uma régua péssima.

No GarraIA, a régua é diferente: o Postgres é quem filtra. O handler nem precisa saber o group_id na query — basta setar o GUC app.current_group_id no início da transação:

sqlx::query("SELECT set_config('app.current_group_id', $1, true)")
    .bind(principal.group_id.to_string())
    .execute(&mut *tx).await?;

// A query agora pode ser "burra" — RLS força o filtro
let files = sqlx::query_as!(FileRow, "SELECT * FROM files WHERE deleted_at IS NULL")
    .fetch_all(&mut *tx).await?;
Enter fullscreen mode Exit fullscreen mode

A policy no Postgres faz o resto:

ALTER TABLE files ENABLE ROW LEVEL SECURITY;
ALTER TABLE files FORCE ROW LEVEL SECURITY;

CREATE POLICY files_isolation ON files
  USING (group_id = NULLIF(current_setting('app.current_group_id', true), '')::uuid);
Enter fullscreen mode Exit fullscreen mode

Repare em duas coisas que aprendemos a duras penas:

  1. FORCE ROW LEVEL SECURITY — sem isso, o owner da tabela faz bypass automático. Nosso ADR 0003 prova com benchmark que sem FORCE, qualquer migração rodando como owner vê tudo. Não dá.
  2. NULLIF(current_setting('app.current_group_id', true), '')::uuid — fail-closed. Se o GUC não estiver setado (porque o handler esqueceu), o NULLIF retorna NULL, o cast falha, a query retorna zero linhas. Não cheira a vazamento — cheira a bug óbvio, que é o que você quer.

O set_config() parameterized (e por que format! é arma carregada)

Na semana 19 contamos o caso do format!("SET LOCAL app.current_group_id = '{}'", group_id) que o CodeQL flaggou como SQL injection em 19 ocorrências. A solução foi set_config('app.current_group_id', $1, true) parameterized, que é o que está no exemplo acima. Sem isso, qualquer pattern de tenant isolation com RLS é teatro — o CodeQL alerta, o auditor LGPD alerta, e mais cedo ou mais tarde alguém prova exploração.

Esse é o pré-requisito da Files API. Sem ele, não dá nem para começar.

O design dos 9 slices

A surface de files do GarraIA é um produto que precisa funcionar em rede mobile flaky (Brasil, 4G), com versionamento por arquivo, soft-delete, folders aninhados, e upload retomável (tus 1.0). Tentamos resistir à tentação de fazer um único PR gigante e quebramos em 9 slices:

Slice Endpoint Issue
1 GET /v1/groups/{group_id}/files + folders + DELETE GAR-555
2 GET /v1/files/{file_id} (metadata) (já existia)
3 PATCH /v1/folders/{folder_id} (rename, soft-delete) GAR-561
4 (consolidado em outros slices)
5 POST /v1/groups/{group_id}/folders + DELETE GAR-562
6 GET /v1/files/{file_id}/download (stream) GAR-564
7 POST /v1/groups/{group_id}/files/{file_id}/versions GAR-567
8 GET /v1/groups/{group_id}/files/{file_id}/versions GAR-569
9 POST /v1/groups/{group_id}/files (direct upload) GAR-577

Cada slice tem o mesmo esqueleto:

  1. Extractor Principal (JWT bearer) + header X-Group-Id (com validação de que o usuário pertence ao grupo).
  2. RequirePermission(Action::FilesWrite) ou FilesRead — o RBAC central tem uma matriz role × action que decide.
  3. Begin transaction no garraia_app pool.
  4. set_config('app.current_group_id', $1, true) — RLS armado.
  5. Query — sem cláusulas de tenant.
  6. Audit eventaudit_events tabela sem FK para sobreviver erasure cascade.
  7. Commit.
  8. Response — RFC 9457 Problem Details em caso de erro, JSON normal em caso de sucesso.

Cursor pagination keyset-safe

Em rede mobile, paginação por OFFSET é tóxica — não escala e tem race conditions com escrita concorrente. Optamos por cursor keyset:

GET /v1/groups/{group_id}/files?folder_id=<uuid>&cursor=<file_uuid>&limit=50
Enter fullscreen mode Exit fullscreen mode

O cursor é o file_id do último item da página anterior. O servidor faz:

SELECT * FROM files
WHERE folder_id = $1
  AND deleted_at IS NULL
  AND (created_at, id) < ($cursor_created_at, $cursor_id)
ORDER BY created_at DESC, id DESC
LIMIT $limit + 1;
Enter fullscreen mode Exit fullscreen mode

O +1 é o truque clássico para descobrir se tem próxima página sem fazer COUNT. Funciona em rede flaky porque o cursor é estável mesmo que entrem arquivos novos no meio.

Storage: trait ObjectStore desacoplado do Postgres

O Postgres guarda metadata (files, file_versions, folders). O conteúdo binário vai para um ObjectStore, que é uma trait com 3 impls:

pub trait ObjectStore {
    async fn put(&self, key: &str, bytes: Bytes) -> Result<PutResult>;
    async fn get(&self, key: &str) -> Result<Bytes>;
    async fn delete(&self, key: &str) -> Result<()>;
    async fn presigned_url(&self, key: &str, ttl: Duration) -> Result<Url>;
}
Enter fullscreen mode Exit fullscreen mode
  • LocalFs — para self-host single-node.
  • S3Compatible — para AWS S3 / R2 / B2 / etc.
  • Minio — para deploy on-prem com bucket compatible.

A presigned URL tem TTL ≤ 15 min por design. Não queremos que um link vazado em um log seja válido por horas.

task_attachments — quando dois RLS se cruzam

A grande sutileza da semana foi GAR-572: task_attachments é uma tabela de junção entre tasks e files. Ambas têm RLS FORCE. Como fazer a policy?

A primeira tentativa foi duas policies (uma sobre task.group_id, outra sobre file.group_id). Não funcionou bem — RLS aplica AND entre policies de mesma tabela, mas a policy precisava expressar "o task e o file estão no mesmo group, que é o current_setting". Acabamos com uma policy JOIN-based:

CREATE POLICY task_attachments_isolation ON task_attachments
  USING (
    EXISTS (
      SELECT 1 FROM tasks t
      WHERE t.id = task_attachments.task_id
        AND t.group_id = NULLIF(current_setting('app.current_group_id', true), '')::uuid
    )
  );
Enter fullscreen mode Exit fullscreen mode

E adicionamos um attached_by_label denormalizado (cache do nome de quem anexou) para sobreviver created_by ON DELETE SET NULL em caso de LGPD erasure. O audit event registra TaskFileAttached e TaskFileDetached, ambos PII-safe (só labels, nunca PII bruta).

O resultado

  • Files API: 9 slices, 9 PRs, cada um com integration tests (cross-group injection guards), audit metadata sem PII, RLS FORCE em cima de tudo.
  • Task attachments: 3 endpoints (POST/GET/DELETE) com migration 017 e dois audit variants novos.
  • Groups slice 2: GET members + GET invites com cursor pagination.
  • CLI fix: garra chat --provider openrouter agora respeita o config.llm["openrouter"].model em vez de hardcodar openrouter/auto (GAR-576).

A surface REST /v1 da Fase 3 está virtualmente completa. O que falta é GAR-391d (cross-group authz matrix via HTTP, ≥100 cenários) — uma suite de testes end-to-end que prova que o app-layer respeita RLS sem regressão. Depois disso, a Fase 3 vai a beta.

Quer ver o código?

GarraIA é open source MIT: github.com/michelbr84/GarraRUST

Roadmap completo e issues no Linear: linear.app/chatgpt25/team/GAR/projects

Wiki técnica: github.com/michelbr84/GarraRUST/wiki

Top comments (0)