Skip to content

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.

DefaultO que previneOnde
O boot recusa rotas cuja meta de segurança não tem guard a aplicá-la (UnguardedRouteMetaError)rotas "protegidas" a servir abertas em silêncionesta página, abaixo
As rotas de billing/faturas exigem utilizador autenticado (meta.auth)gestão anónima de cartões/planos via tenant forjadoBilling
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" partilhadoCaching
meta.can rejeita formas não aplicáveis (PERMISSION_META_INVALID)uma declaração malformada a saltar a autorização em silêncioAutorização
URLs assinados de storage servem Content-Disposition: attachmentstored XSS via uploads de utilizadores numa origem CDNStorage
Corpos de mail construídos com html`…` com interpolações auto-escapadas; o log driver redige corpos em produçãoinjeção de markup no mail da app · links de reset em agregadores de logsNotificações
Páginas admin/UI transportam uma CSP route-scoped com hashinjeção de script inline — sem enfraquecer a CSP da appPáginas admin
As chaves de rate-limit ignoram X-Forwarded-For; o CORS nunca reflete origens arbitrárias com credenciais; HSTS/nosniff/frame-deny ligadoslimites forjados por header · leituras cross-origin com credenciais · clickjackingnesta página, abaixo
Os segredos falham fechados em produção (secret(), AUTH_WEAK_SECRET)arrancar com uma chave de assinatura adivinhávelnesta 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).

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

ts
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:

ts
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:

keyBaldePara quê
'ip' (predefinição)endereço do cliente (request.ip)endpoints anónimos: login, registo, reposição de palavra-passe
'user'ctx().user.idaçõ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 tenantum utilizador que pertence a vários tenants
(ctx) => stringo id que devolveres (ex.: o id de uma API key)qualquer outro caso
ts
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:

ts
// 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_000

Streams 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:

ts
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 min

Os 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):

ts
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:

ts
import { defineEnv, secret } from '@basaltkit/env'

export const env = defineEnv({
  APP_SECRET: secret({ devDefault: 'dev-only-insecure-secret-value' }),
})
  • Desenvolvimento (NODE_ENV=development ou test, definido explicitamente): usa devDefault quando não definido — a app simplesmente corre.
  • Em qualquer outro caso — NODE_ENV=production, staging ou 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 o NODE_ENV num 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.

ts
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":

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

ts
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 chama reply.send().
  • Uma repetição enquanto a primeira ainda está em curso → 409 IDEMPOTENCY_CONFLICT.
  • Respostas 5xx nã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ão authorization, 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 a credentialHeaders.
  • 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: true apenas em endpoints públicos sem nada privado.
  • Chaves com mais de 255 caracteres → 400 IDEMPOTENCY_KEY_INVALID; o MemoryIdempotencyStore remove entradas expiradas e tem um limite maxEntries (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:

ts
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:

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

ts
// ❌ 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.feature

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

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

ts
// ❌ 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 }.

ts
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:

ts
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) e findRaw / aggregateRaw do 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 predicado tenant_id = $1 e define onRawInTenant: 'allow'.
  • Escalares de chave estrangeira — connect / create aninhados são limitados, mas um valor de FK bruto (data: { projectId: body.projectId }) não é verificado, e um include segue 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.
ts
// ❌ 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:

ts
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 BYPASSRLS ignoram todas as políticas; os donos das tabelas também, a menos que a tabela tenha FORCE ROW LEVEL SECURITY (o rlsPolicySql adiciona-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ão NULL), e uma comparação simples apanharia linhas cuja coluna de tenant é ''. Políticas geradas antes do @basaltkit/prisma 3.0 não têm o NULLIF — volta a correr o rlsPolicySql (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 com 42501) — usa antes tenantTransaction(db, fn). Um db.$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 guarda PRISMA_RAW_IN_TENANT deixa 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):

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

ts
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' })
text
-- 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 ms

A 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:

ts
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 (ou Strict), HttpOnly e Secure. Só o SameSite=Lax já trava o POST cross-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 POST de formulário dispara à mesma.
ts
// ✅ 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 nenhum tenantMembershipPlugin (com ou sem o @basaltkit/teams instalado — 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) — sem securityPlugin(), as respostas saem sem cabeçalhos seguros.
bash
basalt ai:doctor      # corre-o em CI para falhar o build numa regressão de segurança

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

Publicado sob a licença MIT.