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?;
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?;
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);
Repare em duas coisas que aprendemos a duras penas:
-
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á. -
NULLIF(current_setting('app.current_group_id', true), '')::uuid— fail-closed. Se o GUC não estiver setado (porque o handler esqueceu), oNULLIFretornaNULL, 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:
-
Extractor
Principal(JWT bearer) + headerX-Group-Id(com validação de que o usuário pertence ao grupo). -
RequirePermission(Action::FilesWrite)ouFilesRead— o RBAC central tem uma matriz role × action que decide. -
Begin transaction no
garraia_apppool. -
set_config('app.current_group_id', $1, true)— RLS armado. - Query — sem cláusulas de tenant.
-
Audit event —
audit_eventstabela sem FK para sobreviver erasure cascade. - Commit.
- 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
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;
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>;
}
-
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
)
);
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 openrouteragora respeita oconfig.llm["openrouter"].modelem vez de hardcodaropenrouter/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)