Segurança
O Basalt é seguro por omissão na borda HTTP e fail-closed nos segredos. Tudo aqui é zero-dependências e ligado através do ciclo de vida dos plugins.
Os defaults seguros, num relance
Cada um destes vem LIGADO por omissão — tens de optar por sair, nunca por entrar. Esta tabela é o mapa; o guia de cada linha tem os detalhes e o opt-out.
| Default | O que previne | Onde |
|---|---|---|
O boot recusa rotas cuja meta de segurança não tem guard a aplicá-la (UnguardedRouteMetaError) | rotas "protegidas" a servir abertas em silêncio | nesta página, abaixo |
As rotas de billing/faturas exigem utilizador autenticado (meta.auth) | gestão anónima de cartões/planos via tenant forjado | Billing |
Guard de membership em cada pedido autenticado com tenant (tenantMembershipPlugin) | um utilizador válido do tenant A a conduzir o tenant B (TEAM_NOT_A_MEMBER) | nesta página, abaixo |
A cache falha fechada fora do contexto de tenant em apps multi-tenant (MissingCacheScopeError) | leituras cross-tenant através de um namespace "global" partilhado | Caching |
meta.can rejeita formas não aplicáveis (PERMISSION_META_INVALID) | uma declaração malformada a saltar a autorização em silêncio | Autorização |
URLs assinados de storage servem Content-Disposition: attachment | stored XSS via uploads de utilizadores numa origem CDN | Storage |
Corpos de mail construídos com html`…` com interpolações auto-escapadas; o log driver redige corpos em produção | injeção de markup no mail da app · links de reset em agregadores de logs | Notificações |
| Páginas admin/UI transportam uma CSP route-scoped com hash | injeção de script inline — sem enfraquecer a CSP da app | Páginas admin |
As chaves de rate-limit ignoram X-Forwarded-For; o CORS nunca reflete origens arbitrárias com credenciais; HSTS/nosniff/frame-deny ligados | limites forjados por header · leituras cross-origin com credenciais · clickjacking | nesta página, abaixo |
Os segredos falham fechados em produção (secret(), AUTH_WEAK_SECRET) | arrancar com uma chave de assinatura adivinhável | nesta página, abaixo |
Proteção de borda — securityPlugin
Um só plugin cobre rate limiting, CORS e cabeçalhos de resposta seguros. Os cabeçalhos seguros estão ligados por omissão; o rate limiting e o CORS são opt-in — ativa-os explicitamente para produção. As apps novas já trazem securityPlugin() no scaffold com um rate limit global por IP ligado (rateLimit: { limit: 120, windowMs: 60_000 }), por isso os cabeçalhos e os limites de pedidos ficam protegidos desde o primeiro deploy. Com tenancy e auth, o scaffold regista também teamsPlugin() + tenantMembershipPlugin() (vê Nunca confies num tenant vindo do cliente abaixo).
import { securityPlugin } from '@basaltkit/fastify'
securityPlugin({
rateLimit: { limit: 100, windowMs: 60_000 }, // 100 req / minuto / IP
cors: { origin: ['https://app.example.com'], credentials: true },
headers: true, // defaults seguros
})Rate limiting
Um limitador de janela fixa chaveado pelo IP do cliente (substitui com key). Pedidos bloqueados recebem 429 RATE_LIMITED com Retry-After, e cada resposta carrega X-RateLimit-Limit / -Remaining / -Reset.
securityPlugin({
rateLimit: {
limit: 20,
windowMs: 10_000,
key: (req) => req.headers['x-api-key'] as string ?? req.ip,
skip: (req) => req.url.startsWith('/livez'),
},
})O store por omissão é em memória (MemoryRateLimitStore). A sua memória é limitada: os baldes expirados são varridos à medida que chega tráfego, e são mantidos no máximo maxEntries (por omissão 100 000) baldes — acima disso as janelas mais antigas são despejadas primeiro, para que uma avalanche de endereços de cliente distintos não faça crescer o processo sem limite (new MemoryRateLimitStore({ maxEntries }) para o dimensionar). Um balde que esgotou o seu limite nunca é despejado: uma avalanche de endereços novos não liberta mais cedo um cliente limitado — fica retido até a sua janela terminar. Para múltiplas instâncias, implementa a interface RateLimitStore sobre Redis — o mesmo padrão de driver usado por @basaltkit/cache.
Limites por rota. Uma rota pode apertar o orçamento de um endpoint sensível via meta.rateLimit — recebe o seu próprio balde (por IP + padrão de rota) nesse limite. É imposto como guard de rota, por isso vale de forma idêntica em Fastify, Express e Hono, e também quando a rota é invocada como tool MCP. Em Express e Hono (onde o hook de borda corre antes do routing) o pedido conta também para o balde global; em Fastify é contado uma só vez, no seu próprio balde:
route({
method: 'POST',
url: '/auth/login',
meta: { rateLimit: { limit: 5, windowMs: 60_000 } }, // 5/min além do limite global
// …
})Orçamentos por utilizador e por tenant. Por omissão o balde é o IP do cliente, por isso toda a gente atrás de um mesmo NAT ou proxy empresarial o partilha. meta.rateLimit.key escolhe a quem pertence o balde:
key | Balde | Para quê |
|---|---|---|
'ip' (predefinição) | endereço do cliente (request.ip) | endpoints anónimos: login, registo, reposição de palavra-passe |
'user' | ctx().user.id | ações caras por utilizador: exportações, chamadas de IA, uploads |
'tenant' | ctx().tenant.id (partilhado pelos utilizadores do tenant) | uma quota por organização |
'user+tenant' | um balde por utilizador por tenant | um utilizador que pertence a vários tenants |
(ctx) => string | o id que devolveres (ex.: o id de uma API key) | qualquer outro caso |
route({
method: 'POST',
url: '/reports/export',
meta: { auth: true, rateLimit: { limit: 10, windowMs: 60_000, key: 'user' } },
// …
})A chave é resolvida no guard de rota, depois de os enrichers correrem, por isso a autenticação e a tenancy já definiram ctx().user / ctx().tenant. Quando o id falta (um chamador anónimo, nenhum tenant resolvido, ou a função não devolve nada), o balde recua para o IP do cliente, e os baldes anónimos nunca se misturam com os de utilizadores autenticados. Quando o adaptador também não conseguiu resolver um IP (request.ip undefined — Hono num runtime sem getClientIp), todos esses pedidos partilham um só balde: falha fechada, em vez de um balde por header falsificável. Os baldes com chave usam o mesmo store (MemoryRateLimitStore, ou Redis entre instâncias). Uma rota com chave continua a contar para o limite global por IP em todos os adaptadores, porque o hook anterior ao routing ainda não conhece o utilizador.
CORS
origin aceita true (refletir), uma string, um array de allow-list, ou um predicado. Os pedidos de preflight OPTIONS são respondidos automaticamente (204). Contam para o rate limit global como qualquer outro pedido, e os cabeçalhos Access-Control-Allow-Methods / -Allow-Headers / -Max-Age só são enviados a uma origem permitida — uma origem não permitida recebe um 204 simples, que não revela nada.
Credenciais exigem uma allow-list explícita
Refletir uma Origin arbitrária com credentials: true entregaria respostas autenticadas (com cookies) a qualquer site. Quando credentials está ligado, o Basalt recusa refletir — tens de passar uma origin explícita (string, array ou predicado). Um wildcard * só é emitido para pedidos sem credenciais.
Cabeçalhos seguros
headers: true define HSTS, X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: no-referrer, Cross-Origin-Opener-Policy: same-origin e uma Content-Security-Policy restritiva por omissão — default-src 'none'; frame-ancestors 'none' (adequada a uma API JSON), mais Cache-Control: no-store, para que nenhuma cache do browser ou intermediária guarde uma resposta com um token de sessão, API key ou segredo MFA. Passa um objeto para personalizar (p. ex. a tua própria contentSecurityPolicy para uma superfície HTML/docs, ou cacheControl: 'private, no-cache'), contentSecurityPolicy: false / cacheControl: false para omitir só esse cabeçalho, ou headers: false para desativar tudo. Uma rota que pode ser guardada em cache define o seu próprio Cache-Control, que substitui o valor por omissão — fá-lo nas rotas meta.etag (p. ex. private, no-cache), já que no-store impede o browser de revalidar.
Limites de recursos & resistência a DoS
Para além de headers e rate limits, conexões longas e lentas podem esgotar um servidor. O Basalt traz defaults sensatos e knobs para as arestas.
Timeouts de request (anti-slowloris)
Um cliente lento que envia um pedido byte a byte prende uma conexão indefinidamente. O adapter Fastify usa por omissão um requestTimeout de 30 s (o default do Fastify é desligado); sobrepõe via fastifyPlugin({ fastify: { requestTimeout } }). O Express e o Hono correm num servidor Node teu — aplica a mesma proteção nele:
// Express / Hono (servidor node:http)
server.requestTimeout = 30_000 // o pedido inteiro tem de chegar em 30s
server.headersTimeout = 20_000 // headers em 20s (slowloris de headers)
server.keepAliveTimeout = 5_000Streams SSE
Os Server-Sent Events são longos por natureza, por isso dá-lhes um heartbeat e um limite de vida. O send() devolve também um booleano de backpressure — para de produzir quando for false:
return sse(async (stream) => {
for await (const update of source) {
if (!stream.send({ data: update })) break // o cliente não acompanha → abranda
}
}, { heartbeatMs: 15_000, maxDurationMs: 30 * 60_000 }) // ping a cada 15s, limite de 30 minOs pings de heartbeat impedem os proxies de largar um stream inativo e revelam um socket morto; o maxDurationMs é um backstop contra conexões que nunca desligam. Limita o número de streams concorrentes por utilizador/tenant no teu handler para um teto rígido.
Endpoints de cerimónia (WebAuthn, MFA)
Endpoints que emitem um challenge ou verificam um código são pré-auth e baratos de martelar — faz-lhes throttle. Reutiliza o bloqueio por força bruta e limites por-rota, e em produção suporta o PasskeyStore / WebAuthnChallengeStore do WebAuthn (e os stores de MFA) com uma implementação durável, não o default em memória.
Re-verificação de domínios custom
Um domínio custom verificado que depois expira ou repointa o DNS é um risco de takeover. Re-verifica num agendamento com @basaltkit/scheduler — o reverifyAll() re-verifica o registo TXT de cada domínio verificado e revoga os que já não o têm de forma definitiva (um timeout de DNS deixa-os verificados):
schedule.call('reverify-domains', async () => {
const { revoked } = await customDomains.reverifyAll()
if (revoked.length) log.warn({ revoked }, 'custom domains un-verified')
}).daily().at('04:00')O novo dono de um domínio expirado também pode tomar um claim verificado obsoleto publicando o seu registo de challenge() — vê Tenancy.
Segredos fail-closed — secret()
O incidente de produção mais comum é enviar uma chave de assinatura placeholder. secret() torna isso impossível:
import { defineEnv, secret } from '@basaltkit/env'
export const env = defineEnv({
APP_SECRET: secret({ devDefault: 'dev-only-insecure-secret-value' }),
})- Desenvolvimento (
NODE_ENV=developmentoutest, definido explicitamente): usadevDefaultquando não definido — a app simplesmente corre. - Em qualquer outro caso —
NODE_ENV=production,stagingou não definido: a variável é obrigatória, tem de cumprir um comprimento mínimo, e é rejeitada se parecer um placeholder (change-me,secret,password, …). Caso contrário a app recusa arrancar, por isso esquecer oNODE_ENVnum deploy nunca recai no valor de dev público.
Bloqueio por força bruta
@basaltkit/auth limita logins falhados por email logo à partida — sem qualquer ligação necessária. Após demasiadas falhas dentro de uma janela deslizante, login() lança AccountLockedError (HTTP 429) mesmo com a password correta; um sucesso limpa o contador.
import { authPlugin, LoginThrottle } from '@basaltkit/auth'
authPlugin({
users,
secret: env.APP_SECRET,
// por omissão 5 tentativas / 15 min; personaliza ou desativa:
loginThrottle: new LoginThrottle({ maxAttempts: 10, windowMs: 5 * 60_000 }),
// loginThrottle: false, // para desligar
})Um throttle por IP corre em paralelo (ligado por omissão), para apanhar também um spray de uma tentativa por muitas contas — passa o IP do cliente ao login({ ip }) (a rota incorporada já o faz).
A enumeração de contas está fechada por omissão
O endpoint público de registo é enumeration-safe: registar um email que já existe devolve o mesmo 202 (e faz trabalho equivalente) que um registo novo, por isso não pode ser usado para sondar que emails têm conta. A colisão é sinalizada out-of-band pelo hook auth:register_existing_email — envia ao endereço "já tens conta; entra ou repõe a password":
app.hooks.on('auth:register_existing_email', ({ email }) => sendAlreadyRegisteredEmail(email))
// opt-out (409 clássico no duplicado) se mesmo precisares:
authPlugin({ users, secret: env.APP_SECRET, enumerationSafeRegister: false })As respostas de login, reposição de password e verificação de email são também uniformes exista ou não a conta (timing equalizado, { ok: true } genérico), por isso nenhum endpoint de auth revela que emails estão registados.
Mutações idempotentes — idempotencyPlugin
Retries seguros para POST: um cliente que envia uma Idempotency-Key recebe a mesma resposta replicada num retry, por isso uma ligação caída nunca cobra um cartão duas vezes.
import { idempotencyPlugin } from '@basaltkit/fastify'
idempotencyPlugin() // protege POST por omissão- Repetir com a mesma chave → a resposta em cache, com
Idempotent-Replayed: true. Vale para qualquer forma de handler — um que devolve o payload é repetido exactamente como um que chamareply.send(). - Uma repetição enquanto a primeira ainda está em curso →
409 IDEMPOTENCY_CONFLICT. - Respostas
5xxnão são colocadas em cache, por isso falhas genuínas continuam repetíveis. - As chaves têm escopo por credenciais do caller + tenant + método + rota, com hash SHA-256 antes de chegarem ao store. As credenciais são todos os headers em
credentialHeaders(por omissãoauthorization,x-session-id,cookie,x-api-key) e o tenant éx-tenant-id+host, por isso a resposta em cache de um utilizador nunca pode ser replicada a outro (sem fuga entre utilizadores/tenants), e a mesma chave em dois endpoints não pode colidir. O replay corre antes dos guards da rota: se autenticas com outro header, adiciona-o acredentialHeaders. - Pedidos sem nenhum header de credencial não são colocados em cache nem replicados por omissão (senão um estranho que adivinhasse a chave receberia a resposta). Ativa com
allowAnonymous: trueapenas em endpoints públicos sem nada privado. - Chaves com mais de 255 caracteres →
400 IDEMPOTENCY_KEY_INVALID; oMemoryIdempotencyStoreremove entradas expiradas e tem um limitemaxEntries(por omissão 10 000).
Revogar access tokens
Os access tokens (JWTs) são stateless, por isso ficam válidos até expirarem. Liga um TokenVersionStore para os revogar mais cedo — uma reposição de password (e o explícito revokeAllTokens(userId)) invalida então todos os tokens emitidos antes do incremento:
import { authPlugin, MemoryTokenVersionStore } from '@basaltkit/auth'
import { PrismaTokenVersionStore } from '@basaltkit/auth-prisma' // ou SqliteTokenVersionStore
authPlugin({ users, secret: env.APP_SECRET, tokenVersions: new PrismaTokenVersionStore(prisma) })Desligado por omissão (a verificação passa a custar uma leitura ao store por pedido). O próprio segredo de assinatura é protegido: o Auth recusa arrancar com segredo vazio e, em produção (tudo excepto um NODE_ENV=development/test explícito, incluindo não definido), rejeita um com menos de 32 chars (uma chave HS256 curta é forjável offline) — usa secret({ minLength: 32 }).
Cifrar segredos TOTP em repouso
O TOTP tem proteção anti-replay de origem (o time-step de um código é registado, por isso um código intercetado é de uso único). Para sobreviver também a uma fuga da base de dados, cifra os segredos guardados com uma chave da app (pelo menos 32 bytes) — ficam como envelopes AES-256-GCM (chave derivada por HKDF com id de chave, ligados ao utilizador) e só são decifrados ao verificar um código:
authPlugin({ users, secret: env.APP_SECRET, mfaEncryptionKey: env.MFA_KEY })Um valor guardado que não seja um destes envelopes é recusado, por isso uma escrita na tabela não consegue rebaixar um segredo para um em texto simples que quem escreve conhece. A rotação de chaves e a migração de linhas em texto simples ou v1: (uma adesão legacy explícita mais auth.reencryptMfaSecret(userId)) estão em Cifrar os segredos TOTP em repouso.
Responsabilidade partilhada — reforçar a tua integração
O Basalt fecha as vulnerabilidades que consegue fechar sozinho. Três coisas, porém, dependem de como a tua app liga as peças — a framework não as pode decidir por ti. Acerta nestas em cada deployment.
1. Autorização é explícita — declara um guard, não só a intenção
O meta.auth / meta.can / meta.teamRole / meta.scopes / meta.subscribed / meta.feature de uma rota documenta o que a rota precisa, mas o guard que o aplica tem de estar de facto registado. Uma rota declarada-mas-sem-guard estaria aberta — por isso os adapters recusam arrancá-la: no arranque verificam que cada chave de meta de segurança declarada tem um guard registado a reclamá-la (via o bucket http:guarded-meta) e falham alto, listando todas as rotas em falta.
// ❌ o meta diz "admin", mas nada o aplica → UnguardedRouteMetaError no BOOT
route({ method: 'POST', url: '/admin/purge', meta: { can: 'admin' }, handler })
// ✅ regista o plugin cujo guard aplica a chave
authPlugin(…) // aplica meta.auth
permissionsPlugin(…) // aplica meta.can
teamsPlugin(…) // aplica meta.teamRole
apiKeysPlugin(…) // aplica meta.scopes
subscriptionsPlugin(…) // aplica meta.subscribed e meta.featureO conjunto guardado completo é auth, can, teamRole, scopes, subscribed e feature (GUARDED_META_KEYS). As chaves que relaxam em vez de proteger ficam deliberadamente de fora — meta.central (salta a verificação de pertença ao tenant), meta.mcp (opta por expor a rota via MCP) e meta.rateLimit (travagem de abuso, não uma fronteira de autorização).
Se a proteção acontecer genuinamente numa edge/gateway exterior, opta por sair explicitamente com a opção do adapter allowUnguardedMeta: true (ou ['auth', …] para chaves específicas). Um plugin de guard próprio que aplique uma destas chaves deve reclamá-la: ensureMetadata(container).add('http:guarded-meta', 'auth').
Reclamar uma chave prova que alguém a aplica; não diz nada sobre o valor. Para isso, um plugin regista um validador de meta de rota em http:meta-validators (META_VALIDATORS_BUCKET): todos os adapters correm-nos sobre a lista completa de rotas no arranque, logo a seguir à verificação de meta guardada, e recusam arrancar com InvalidRouteMetaError (HTTP_INVALID_ROUTE_META) listando cada rota: problema. O teamsPlugin usa-o para que meta.teamRole: 'Admin' (um erro de escrita) faça falhar o arranque em vez de responder 500 no primeiro pedido. O allowUnguardedMeta nunca dispensa os validadores.
const validator: RouteMetaValidator = ({ route, container }) =>
typeof route.meta?.['shape'] === 'string' || route.meta?.['shape'] === undefined
? undefined
: `meta.shape must be a string` // ou string[] para vários problemas; lançar também conta
ensureMetadata(container).add(META_VALIDATORS_BUCKET, validator)Corres o runRoute() sem adapter? assertRoutesGuarded(routes, app.container) corre as duas verificações; assertRouteMetaValid(routes, app.container) corre só os validadores.
Um guard pode também publicar uma verificação de visibilidade pura em http:route-visibility (RouteVisibilityCheck, avaliada por isRouteVisible) — "este chamador poderia passar?" sem efeitos secundários (sem rate limit, sem auditoria, sem hooks), usada por superfícies de listagem como o tools/list do MCP. Visibilidade nunca é autorização: o guard corre sempre em cada chamada.
2. Nunca confies num tenant vindo do cliente — verifica a membership
Resolver o tenant ativo a partir de um header do pedido (ou subdomínio, ou path) é conveniente, mas o header é controlado pelo atacante. Ler X-Tenant-Id: acme e limitar a acme sem verificar que o utilizador autenticado pertence de facto a acme deixa qualquer utilizador com sessão ler os dados de outro tenant.
// ❌ tenant vindo diretamente de um header do cliente — acesso entre tenants
const tenantId = req.headers['x-tenant-id']
// ✅ resolve, depois confirma que o utilizador é membro antes de confiar
const tenantId = req.headers['x-tenant-id']
if (!(await teams.can(tenantId, ctx().user.id, 'member'))) {
throw new ForbiddenError()
}Liga a seleção de tenant a uma membership user↔tenant verificada (via @basaltkit/teams, o tenantId de uma API-key, ou uma claim de sessão) — nunca apenas ao pedido em bruto.
Faz isto para todas as rotas de uma vez com tenantMembershipPlugin. Em vez de repetir a verificação, regista o guard do @basaltkit/teams: em cada pedido autenticado e com tenant resolvido, garante que o utilizador é membro do tenant e devolve 403 caso contrário. As rotas centrais que legitimamente atuam fora de um só tenant (login, criação de tenant, admin da plataforma, aceitar convite) optam por sair com meta: { central: true }.
import { teamsPlugin, tenantMembershipPlugin } from '@basaltkit/teams'
createApp({
plugins: [
authPlugin(/* … */),
tenancyPlugin(/* … */),
teamsPlugin(/* … */),
tenantMembershipPlugin(), // seguro por omissão: membership imposta em todo o lado
],
})
// uma rota de que o utilizador não é membro → 403; rota central sai:
route({ method: 'POST', url: '/tenants', meta: { central: true }, /* … */ })Por omissão o guard verifica a existência de membership — qualquer registo de membership passa, por isso roles personalizados ausentes do roleRank não são rejeitados; passa role: 'member' (ou superior) para impor semântica de rank. Mais duas opções:
tenantMembershipPlugin({
// Escape baseado em QUEM chama: admins de plataforma / suporte cruzam
// tenants legitimamente. Prefere isto a meta.central quando a exceção é
// sobre o chamador — central desativa o guard para toda a gente nessa rota.
exempt: ({ user }) => (user as { platformAdmin?: boolean })?.platformAdmin === true,
// Cache de decisão opt-in: sem ela, cada pedido autenticado com tenant custa
// um lookup indexado de membership (normalmente ok). Decisões em cache são
// descartadas de imediato pelos hooks team:joined/role_changed/member_removed
// no mesmo processo; ttlMs apenas limita a staleness de alterações feitas
// NOUTRA réplica — um membro removido noutro lado pode manter acesso até ttlMs.
cache: { ttlMs: 30_000 },
})3. O scoping automático de tenant cobre o ORM — não SQL bruto nem escalares de chave estrangeira
A extensão de tenancy do Prisma limita as operações de modelo (incluindo writes de relação aninhados) e falha fechado: sem contexto de tenant, e para qualquer operação que não consegue limitar (PRISMA_UNSCOPED_OPERATION). Os dados de um update não podem mover uma linha para outro tenant (PRISMA_CROSS_TENANT_WRITE). Dois caminhos ficam fora dessa rede:
- Queries brutas —
$queryRaw,$executeRaw,$queryRawTyped,$runCommandRaw(todas as operações ao nível do client) efindRaw/aggregateRawdo MongoDB contornam o scoping de modelo. O Basalt recusa-as por omissão quando há um tenant em contexto (PRISMA_RAW_IN_TENANT), para uma query bruta não poder ler entre tenants em silêncio. Corre-as em código central (sem tenant em contexto), ou adiciona tu o predicadotenant_id = $1e defineonRawInTenant: 'allow'. - Escalares de chave estrangeira —
connect/createaninhados são limitados, mas um valor de FK bruto (data: { projectId: body.projectId }) não é verificado, e umincludesegue a FK que estiver guardada. Usa chaves estrangeiras compostas(tenantId, id)(a base de dados recusa então uma ligação entre tenants) e RLS, ou verifica primeiro que o registo relacionado pertence ao tenant atual.
// ❌ query bruta dentro de um contexto de tenant agora lança PRISMA_RAW_IN_TENANT
await db.$queryRaw`SELECT * FROM invoices WHERE status = ${status}`
// ✅ limita explicitamente, parametrizado, e opta por permitir
const tenantId = ctx().tenant.id
await db.$queryRaw`
SELECT * FROM invoices WHERE status = ${status} AND tenant_id = ${tenantId}`
// com tenancyExtension({ onRawInTenant: 'allow' })Defesa em profundidade — ativa RLS no Postgres. O scoping aplicacional é uma camada; junta uma imposta pela base de dados, para que nem um predicado esquecido vaze — incluindo o include por chave estrangeira acima, que a extensão não vê. O rlsPolicySql gera a migração, e o tenancyExtension({ rls: true }) indica o tenant ativo ao Postgres em cada operação limitada ao tenant:
import { rlsPolicySql, tenancyExtension, tenantTransaction } from '@basaltkit/prisma'
// migração (uma vez): põe o SQL gerado numa migração Prisma — ativa e força
// (FORCE) o RLS e cria uma política de isolamento por tenant em cada tabela
console.log(rlsPolicySql({ tables: ['invoices', 'projects'], tenantColumn: 'tenantId' }))
// cada operação de modelo com tenant em contexto corre agora como
// $transaction([ set_config('app.tenant_id', <tenant>, true), <operação> ])
const db = new PrismaClient().$extends(tenancyExtension({ rls: true }))
// transações interativas: abre-as com tenantTransaction, que define o tenant
// primeiro na própria ligação da transação — o tx continua limitado ao tenant
await tenantTransaction(db, async (tx) => {
const invoice = await tx.invoice.create({ data })
await tx.invoiceLine.createMany({ data: lines(invoice.id) })
})- Liga-te com um role a que o RLS se aplique. Superusers e roles com
BYPASSRLSignoram todas as políticas; os donos das tabelas também, a menos que a tabela tenhaFORCE ROW LEVEL SECURITY(orlsPolicySqladiciona-o por omissão). Corre a app com um role de login simples e deixa migrações/admin noutro. - Sem tenant não há linhas — mesmo numa ligação reutilizada. A política compara com
NULLIF(current_setting('app.tenant_id', true), ''): depois de uma sessão do pool ter definido o tenant numa transação qualquer, o Postgres passa a devolver''(nãoNULL), e uma comparação simples apanharia linhas cuja coluna de tenant é''. Políticas geradas antes do@basaltkit/prisma3.0 não têm oNULLIF— volta a correr orlsPolicySql(idempotente) numa nova migração. - Custos. Cada operação limitada ao tenant passa a ser uma transação batch curta (
BEGIN,set_config, a query,COMMIT) — algumas instruções extra na mesma ligação (≈ +2 ms p50 nas medições da equipa da app). A definição é local à transação (set_config(…, true)), por isso nunca vaza para uma ligação do pool. - Transações abertas por ti não são envolvidas (não podem ser aninhadas): um
db.$transaction(async (tx) => …)interativo simples corre sem a definição do tenant e falha fechado (leituras não veem linhas, escritas falham com42501) — usa antestenantTransaction(db, fn). Umdb.$transaction([...])em batch tem de começar pela definição:db.$executeRawUnsafe(setTenantConfigSql(), ...tenantConfigParams(tenantId)). Essa instrução exata, para o tenant já em contexto, é a única query bruta que a guardaPRISMA_RAW_IN_TENANTdeixa passar — qualquer outra continua recusada. - Um cliente com
onMissingTenant: 'bypass'não envia a definição, por isso com RLS não vê nada: dá ao código central/admin o seu próprio role de base de dados (BYPASSRLS, ou um que as políticas não cubram).
Pesquisa full-text com RLS — o índice GIN desaparece em silêncio. Ativar RLS numa tabela que também tem uma coluna tsvector com índice GIN (o que o @basaltkit/search-postgres cria) é uma armadilha que nada assinala: o PostgreSQL só pode avaliar um qualificador antes de uma política de segurança de linha se esse qualificador for LEAKPROOF, e o operador de pesquisa de texto @@ não é. Por isso, precisamente para o role que a política protege, tsv @@ plainto_tsquery(…) nunca pode ser uma condição de índice — passa a ser um filtro aplicado depois da política, e o plano degrada-se para uma varredura sequencial sobre as linhas de todos os tenants. Medido em 30 200 documentos (PostgreSQL 16):
-- como owner -- como role da aplicação, o mesmo SQL
-> Bitmap Heap Scan -> Seq Scan on basalt_search
Recheck Cond: (tsv @@ …) Filter: (… AND (tsv @@ …))
-> Bitmap Index Scan on Rows Removed by Filter: 30197
basalt_search_tsv_idx Execution Time: 14.702 ms
Execution Time: 4.920 ms (~24x mais lento, quente)O rlsSearchFunctionSql gera a correção: uma função de pesquisa SECURITY DEFINER que volta a aplicar ela própria o predicado do tenant, de modo que @@ volta a ser uma condição de índice enquanto as linhas que ela pode devolver continuam a ser exatamente as de um tenant.
import { rlsSearchFunctionSql } from '@basaltkit/prisma'
// migração (uma vez), executada por um role que ignora o RLS da tabela
rlsSearchFunctionSql({
name: 'basalt_search_scoped', table: 'basalt_search', vectorColumn: 'tsv',
partitionColumn: 'idx', filterColumn: 'document',
columns: [{ name: 'document', type: 'jsonb' }],
role: 'app', owner: 'app_owner', maxRows: 100,
})
// runtime — o driver encaminha as queries de texto pela função
new PostgresSearchDriver({ client: pool, searchFunction: 'basalt_search_scoped' })-- como role da aplicação, através da função
-> Bitmap Heap Scan on basalt_search t
Filter: ((idx = 'notes') AND (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')))
-> Bitmap Index Scan on basalt_search_tsv_idx
Execution Time: 1.850 msA função não recebe nenhum parâmetro de tenant: lê o tenant do mesmo current_setting(…) que a política lê (o que o tenancyExtension({ rls: true }) já define), por isso não há nada que um chamador possa apontar para outro lado, e uma definição por preencher lê-se como NULL — nenhuma linha, nunca todas. É endurecida como o cross-tenant scan (search_path fixado, identificadores validados, EXECUTE revogado de PUBLIC, p_limit limitado), e o driver ainda recusa qualquer linha devolvida cujo tenant não seja o que pediu.
Nunca faças ALTER FUNCTION ts_match_vq(tsvector, tsquery) LEAKPROOF. É o atalho que todos os resultados de pesquisa sugerem, e de facto traz o índice de volta — enfraquecendo a regra leakproof em toda a base de dados, para todas as tabelas e todas as políticas, de modo que um @@ bem construído passa a ser um canal lateral para sondar linhas que uma política esconde. Uma aceleração local paga com uma perda global de isolamento.
Varrer todos os tenants — o único buraco, mantido estreito. Um reconciler tem de encontrar linhas encalhadas em todos os tenants, o que com RLS o role da aplicação nunca consegue ver. O crossTenantScanSql gera a única forma segura dessa query: uma função SECURITY DEFINER que devolve apenas identificadores (id do tenant + id da linha), detida por um role que as políticas não alcançam, com o search_path fixado dentro dela e o EXECUTE revogado ao PUBLIC. O crossTenantSweep processa depois cada identificador de volta no âmbito do seu próprio tenant, onde as políticas voltam a aplicar-se:
import { crossTenantScanSql, crossTenantSweep } from '@basaltkit/prisma'
// migração (uma vez): apenas identificadores — nunca uma coluna com dados do tenant
crossTenantScanSql({
name: 'stuck_jobs', table: 'jobs', tenantColumn: 'tenantId', columns: ['id'],
where: `t."status" = 'PROCESSING' AND t."updatedAt" < now() - interval '15 minutes'`,
role: 'app', owner: 'app_owner', maxRows: 500,
})
// reconciler (código central, sem tenant em contexto)
await crossTenantSweep({
client: db,
scanFunction: 'stuck_jobs',
run: (tenantId, fn) => tenancy.run(tenantId, fn),
handle: (item) => RetryJob.dispatch({ jobId: item.id }),
})A função é um bypass deliberado ao RLS, por isso trata a sua definição como crítica para a segurança: basta alargá-la a uma coluna de dados para que qualquer chamador com EXECUTE leia todos os tenants. O Basalt falha fechado à volta dela — o scan recusa correr dentro de um contexto de tenant (PRISMA_CROSS_TENANT_IN_TENANT; ao contrário do set_config interno, não tem isenção da guarda de queries brutas), recusa uma função instalada que devolva colunas que não declaraste (PRISMA_CROSS_TENANT_SCAN_SHAPE) e pagina com um cursor ordenado e limitado, para que um varrimento não puxe a tabela inteira. Sem RLS a função nem é precisa: passa uma query central normal como scan. Vê o guia do scheduler.
Na dúvida, prefere operações de modelo (limitadas automaticamente) a SQL bruto, e revê cada connect contra o tenant atual.
4. CSRF — seguro por omissão com auth por header; cookies são responsabilidade tua
O Basalt autentica cada pedido a partir de um header — Authorization: Bearer <jwt> ou x-session-id: <id> (ver o enricher de auth). Isto é CSRF-safe por design: um cross-site request forgery só funciona com credenciais que o browser anexa automaticamente (cookies, HTTP Basic). Um header custom nunca é enviado cross-origin num pedido forjado, por isso a página do atacante não pode aproveitar a sessão da vítima. Mantém a auth num header e não há nada a fazer.
O cookie de sessão que o authRoutes() define no login (basalt_session) está coberto pela verificação incorporada do authPlugin: um pedido só com cookie, com um método não seguro, que o browser marca cross-site/same-site, ou cujo Origin não é o teu próprio host nem uma entrada de csrf.trustedOrigins, não é autenticado (403 AUTH_CSRF_REJECTED em rotas meta.auth) — ver Sessões por cookie e CSRF.
Assumes tu o risco de CSRF no momento em que moves uma credencial para um cookie teu — p. ex. guardar o JWT num cookie para o browser o enviar automaticamente. Se o fizeres, protege-o tu:
- Define o cookie
SameSite=Lax(ouStrict),HttpOnlyeSecure. Só oSameSite=Laxjá trava oPOSTcross-site comum. - Acrescenta uma segunda verificação para pedidos que alteram estado: um token CSRF double-submit (um valor aleatório espelhado num cookie e num header/campo do body, comparados no servidor), ou uma allow-list de Origin/Referer.
- Nunca contes com o CORS para isto — o CORS governa a leitura de uma resposta, não se um pedido forjado é enviado. Um
POSTde formulário dispara à mesma.
// ✅ preferido — sem cookie, sem superfície de CSRF
fetch('/api/pay', { method: 'POST', headers: { authorization: `Bearer ${jwt}` } })
// ⚠️ se tiveres mesmo de usar sessão em cookie, junta SameSite + verificação de token CSRF
setCookie('sid', session.id, { httpOnly: true, secure: true, sameSite: 'lax' })Apanha regressões automaticamente — ai:doctor
O basalt ai:doctor verifica estaticamente o teu projeto contra os invariantes de segurança do framework — offline, sem chave de API. Duas verificações codificam as garantias mais importantes deste guia:
missing-tenant-membership(erro) — tens tenancy + auth mas nenhumtenantMembershipPlugin(com ou sem o@basaltkit/teamsinstalado — se não estiver, a correção é instalá-lo), por isso um tenant resolvido nunca é ligado a um membro verificado. É a classe de acesso cross-tenant da secção 2.missing-security-plugin(aviso) — semsecurityPlugin(), as respostas saem sem cabeçalhos seguros.
basalt ai:doctor # corre-o em CI para falhar o build numa regressão de segurançaLiga-o ao teu pipeline para que uma mudança que remova o guard de membership ou o security plugin ponha o build vermelho em vez de ir para produção.
Cadeia de fornecimento
O CI corre pnpm audit (severidade alta), CodeQL SAST, e o Dependabot mantém dependências e Actions atualizadas. Os releases publicam no npm com provenance (NPM_CONFIG_PROVENANCE) via changesets — sem tokens manuais ou OTP no pipeline.