Adios
BlogNext.js SaaS

Next.js SaaS

Como implementar autenticação e autorização por papéis no Next.js

Implemente login pelo GitHub, sessões no banco e papéis no espaço de trabalho com Better Auth e PostgreSQL. Proteja páginas, Server Actions e rotas de API.

Equipe AdiosAtualizado 26 de setembro de 202624 min de leitura

Vamos implementar autenticação para um painel de projetos: login GitHub, sessão no PostgreSQL e permissão para owners e admins excluírem projetos, enquanto viewers só podem lê-los. As mesmas verificações protegem a página, sua Server Action e sua API HTTP.

Defina exatamente o que um usuário conectado pode fazer

Suponha que Maya seja owner da Northstar e tenha acesso viewer à Acme. O login é o mesmo nos dois espaços, mas as permissões são diferentes. Um único campo user.role não representa isso: conceder owner globalmente à Maya também lhe daria controle sobre a Acme.

Armazene o papel na relação entre usuário e espaço de trabalho. Uma requisição só é permitida se a sessão for válida, o vínculo estiver ativo, o papel permitir a operação e o projeto solicitado pertencer ao espaço de trabalho. Conhecer o ID de um projeto não satisfaz nenhuma dessas condições.

Esta implementação usa Next.js 16 App Router, TypeScript, Better Auth 1.7 e PostgreSQL no ambiente Node.js. Comece com uma aplicação TypeScript existente com o alias de importação @/*, um banco PostgreSQL e Node.js 24. O exemplo implementa leitura e exclusão de projetos; convites e edição de papéis exigem operações protegidas próprias.

Permissões em um espaço de trabalho
FunçãoLer projetosExcluir projetosAlterar vínculos ao espaço de trabalho
ResponsávelSimSimSim
AdministradorSimSimNão
LeitorSimNãoNão
Sem vínculo ativoNãoNãoNão

One protected request

Browser cookie
  → Better Auth verifies the session
  → application gets the verified user ID
  → PostgreSQL loads membership for the requested workspace
  → permission check + workspace-scoped project query
  → return only the fields the page needs

Configurar um provedor de autenticação real

Better Auth cuidará do callback GitHub, dos cookies de sessão e das tabelas de autenticação. Nossa aplicação cuidará das tabelas de espaços de trabalho e das decisões de autorização. Essa separação permite mudar um papel no espaço de trabalho sem alterar a identidade do usuário.

Instale os pacotes na aplicação e registre o arquivo de lock resultante. Gere BETTER_AUTH_SECRET com openssl rand -base64 32. Crie uma aplicação OAuth GitHub com o callback local http://localhost:3000/api/auth/callback/github; use outra aplicação OAuth e um callback HTTPS para produção.

Install dependencies

npm install better-auth@1.7.6 pg zod server-only
npm install -D @types/pg

Definir a configuração do servidor

Adicione estes valores a um arquivo .env ignorado pelo controle de versão. Substitua os marcadores pelas credenciais do banco de dados e do OAuth. Nenhuma dessas variáveis precisa do prefixo NEXT_PUBLIC_.

.env

DATABASE_URL=postgresql://USER:PASSWORD@localhost:5432/auth_demo
BETTER_AUTH_URL=http://localhost:3000
BETTER_AUTH_SECRET=REPLACE_WITH_A_GENERATED_SECRET
GITHUB_CLIENT_ID=REPLACE_WITH_YOUR_CLIENT_ID
GITHUB_CLIENT_SECRET=REPLACE_WITH_YOUR_CLIENT_SECRET

Reutilizar um pool de conexões por processo Node

Criar um Pool a cada requisição criaria um novo limite de conexões a cada vez. Este módulo mantém um único pool, inclusive durante recargas de desenvolvimento. Com quatro réplicas da aplicação e max: 10, ela pode consumir até 40 conexões; migrações e outros serviços também precisam de capacidade.

lib/db.ts

import { Pool } from "pg";

const globalDb = globalThis as unknown as { authDemoPool?: Pool };

export const db = globalDb.authDemoPool ?? new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 10,
  connectionTimeoutMillis: 5_000,
  idleTimeoutMillis: 30_000,
});

if (process.env.NODE_ENV !== "production") globalDb.authDemoPool = db;

Usar sessões de banco de dados com uma expiração fixa

Neste exemplo, escolhemos uma duração absoluta de sete dias e desabilitamos a renovação da sessão. O cache de cookies também fica desabilitado, então a consulta de sessão verifica o banco. São políticas deliberadas: um cookie roubado pode ser revogado centralmente, e um navegador ativo precisa entrar novamente após sete dias.

Um token assinado autossuficiente pode reduzir consultas ao banco, mas um papel ou uma sessão copiados para ele continuam válidos até expirar, a menos que você consulte a revogação. Sessões no banco são adequadas a este painel porque mudanças de papel e logout de dispositivos perdidos devem valer nas verificações seguintes.

lib/auth.ts

import { betterAuth } from "better-auth";
import { db } from "@/lib/db";

export const auth = betterAuth({
  database: db,
  baseURL: process.env.BETTER_AUTH_URL!,
  secret: process.env.BETTER_AUTH_SECRET!,
  trustedOrigins: [process.env.BETTER_AUTH_URL!],
  socialProviders: {
    github: {
      clientId: process.env.GITHUB_CLIENT_ID!,
      clientSecret: process.env.GITHUB_CLIENT_SECRET!,
    },
  },
  session: {
    expiresIn: 60 * 60 * 24 * 7,
    disableSessionRefresh: true,
    cookieCache: { enabled: false },
  },
});

Montar os endpoints de autenticação

A rota catch-all trata requisições de login, callback, sessão e logout. Após criar a configuração, gere o esquema da biblioteca, revise o SQL e aplique-o ao banco de desenvolvimento. O comando da CLI é npx auth@latest generate; mantenha a migração gerada no histórico da aplicação. Use uma versão da CLI compatível com a versão fixa do Better Auth.

app/api/auth/[...all]/route.ts

import { toNextJsHandler } from "better-auth/next-js";
import { auth } from "@/lib/auth";

export const runtime = "nodejs";
export const { GET, POST } = toNextJsHandler(auth);

Siga o login do navegador para a sessão

O navegador inicia o login pela rota de autenticação e é redirecionado ao GitHub. O callback retorna um código de autorização. Better Auth valida o fluxo OAuth, troca o código no servidor, associa a identidade do provedor a um usuário local e cria uma sessão no banco. O navegador recebe um cookie de sessão e volta ao site.

O token de acesso GitHub e a sessão da aplicação têm funções diferentes. O token do provedor serve para comunicar com o GitHub; o painel usa a sessão da aplicação para identificar o chamador. Nem um e-mail enviado pelo cliente nem um nome de usuário GitHub comprova vínculo ao espaço de trabalho.

Use o cliente abaixo em app/sign-in/page.tsx. O callback volta à página inicial existente; após criar o espaço de trabalho de exemplo na próxima seção, abra a URL do projeto.

lib/auth-client.ts

import { createAuthClient } from "better-auth/react";

export const authClient = createAuthClient();

Entrar e tratar uma requisição com falha

O login pelo GitHub cria o usuário local no primeiro acesso. Um novo usuário não tem acesso a espaços de trabalho até que o onboarding crie um ou um convite autorizado conceda o vínculo.

app/sign-in/page.tsx

"use client";

import { useState } from "react";
import { authClient } from "@/lib/auth-client";

export default function SignInPage() {
  const [pending, setPending] = useState(false);
  const [error, setError] = useState("");

  async function signIn() {
    setPending(true);
    setError("");
    try {
      const result = await authClient.signIn.social({
        provider: "github",
        callbackURL: "/",
      });
      if (result.error) {
        setError("Sign-in failed. Please try again.");
        setPending(false);
      }
    } catch {
      setError("Could not reach the sign-in service.");
      setPending(false);
    }
  }

  return (
    <main>
      <button onClick={signIn} disabled={pending}>
        {pending ? "Redirecting…" : "Continue with GitHub"}
      </button>
      {error && <p role="alert">{error}</p>}
    </main>
  );
}

Armazenar os papéis nos vínculos aos espaços de trabalho

Aplique esta migração da aplicação após a migração gerada pelo Better Auth. A biblioteca gerencia as tabelas de usuário, conta, sessão e verificação. As tabelas abaixo referenciam sua tabela padrão de usuários PostgreSQL sem acrescentar um papel global a ela.

A chave composta do vínculo permite um papel por usuário em cada espaço de trabalho. workspace_id é obrigatório no projeto. Essas restrições impedem vínculos duplicados e propriedade órfã, mas não autorizam operações automaticamente. Toda consulta de projeto ainda precisa do limite do espaço de trabalho.

migrations/002_workspaces.sql

CREATE TABLE app_workspace (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  name text NOT NULL
);

CREATE TABLE app_membership (
  workspace_id uuid NOT NULL REFERENCES app_workspace(id) ON DELETE CASCADE,
  user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
  role text NOT NULL CHECK (role IN ('owner', 'admin', 'viewer')),
  status text NOT NULL DEFAULT 'active'
    CHECK (status IN ('active', 'suspended')),
  PRIMARY KEY (workspace_id, user_id)
);

CREATE TABLE app_project (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  workspace_id uuid NOT NULL REFERENCES app_workspace(id) ON DELETE CASCADE,
  name text NOT NULL
);

CREATE INDEX app_project_workspace_idx ON app_project(workspace_id);

CREATE TABLE app_audit_event (
  id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  workspace_id uuid NOT NULL REFERENCES app_workspace(id),
  actor_id text NOT NULL,
  operation text NOT NULL,
  resource_id uuid NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now()
);

Criar um espaço de trabalho para o exemplo local

Entre uma vez e encontre seu ID de usuário local no banco de autenticação. No psql, defina demo_user_id com esse ID e execute as instruções abaixo. Estes são dados iniciais de desenvolvimento criados por um operador, não um endpoint que aceita ID ou papel de usuário do navegador.

Em produção, o onboarding deve criar o espaço de trabalho e seu owner inicial na mesma transação, derivando o ID da sessão verificada. A aceitação de um convite deve vinculá-lo à identidade prevista e consumir um token de uso único com expiração. Um usuário conectado nunca deve poder escolher um espaço de trabalho existente e conceder a si mesmo acesso owner.

Development seed — run in psql after setting demo_user_id

-- First: SELECT id, email FROM "user";
-- Then:  \set demo_user_id 'PASTE_YOUR_LOCAL_USER_ID'

BEGIN;
INSERT INTO app_workspace (id, name)
VALUES ('11111111-1111-4111-8111-111111111111', 'Northstar');

INSERT INTO app_membership (workspace_id, user_id, role)
VALUES ('11111111-1111-4111-8111-111111111111', :'demo_user_id', 'owner');

INSERT INTO app_project (id, workspace_id, name)
VALUES (
  '22222222-2222-4222-8222-222222222222',
  '11111111-1111-4111-8111-111111111111',
  'Website'
);
COMMIT;

-- Open /workspaces/11111111-1111-4111-8111-111111111111/projects/22222222-2222-4222-8222-222222222222

Defina permissões uma vez e negue papéis desconhecidos

Nomeie a operação no código da aplicação: project:read ou project:delete. Mapeie os papéis para essas permissões em um único módulo. A página e o servidor podem usar o mesmo mapeamento, mas somente a consulta atual do vínculo no servidor pode autorizar uma requisição.

Esta política retorna false para um papel não reconhecido. Isso importa durante migrações e quando um valor inesperado chega à aplicação. Não trate um papel ausente ou desconhecido como a opção mais permissiva.

lib/permissions.ts

export type Permission =
  | "project:read"
  | "project:delete"
  | "membership:manage";

const permissions: Record<string, readonly Permission[]> = {
  owner: ["project:read", "project:delete", "membership:manage"],
  admin: ["project:read", "project:delete"],
  viewer: ["project:read"],
};

export function can(role: string, permission: Permission): boolean {
  return Object.hasOwn(permissions, role)
    && permissions[role].includes(permission);
}

Onde o RBAC termina

O papel determina se o membro pode excluir projetos no espaço de trabalho. workspace_id determina a quais projetos a regra se aplica. São condições distintas, e ambas precisam ser atendidas.

Se o produto passar a permitir que membros excluam apenas projetos criados por eles, acrescente uma verificação created_by à consulta protegida. Verificar o papel não expressa sozinho essa regra de propriedade. Da mesma forma, ser admin de um espaço de trabalho não deve conceder silenciosamente acesso a um console interno de suporte de todos os clientes.

Colocar a verificação da sessão dentro do acesso protegido aos dados

Um redirecionamento no layout pode melhorar a navegação, mas Route Handlers e Server Actions podem ser chamados diretamente. Coloque a consulta de identidade dentro da função que lê os dados protegidos. Assim, todos os chamadores passam pelas mesmas verificações.

requireUserId resolve o cookie recebido por Better Auth. Retorna apenas o ID do usuário e lança 401 quando não há sessão válida. Erros do banco ou do provedor devem fazer a requisição falhar, sem recorrer a um usuário anônimo ou anteriormente privilegiado.

A consulta do projeto associa o vínculo do chamador ao espaço de trabalho solicitado. Um projeto da Acme não pode ser obtido substituindo seu ID em uma URL da Northstar. Tanto um vínculo ausente quanto um projeto ausente retornam 404, para não revelar a existência de projetos de outro espaço de trabalho.

lib/access.ts

import "server-only";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";

export class AccessError extends Error {
  readonly status: 401 | 403 | 404;
  constructor(status: 401 | 403 | 404) {
    super(status === 401 ? "Sign in required"
      : status === 403 ? "Forbidden" : "Not found");
    this.status = status;
  }
}

export async function requireUserId() {
  const session = await auth.api.getSession({ headers: await headers() });
  if (!session) throw new AccessError(401);
  return session.user.id;
}

Restringir a leitura e retornar um resultado reduzido

Zod valida o formato dos identificadores; a junção com os vínculos estabelece o acesso. Um UUID sintaticamente válido ainda é não confiável. Os parâmetros SQL mantêm esses valores separados do texto da consulta.

Retorne os campos de exibição do projeto e um indicador canDelete. A página não precisa de token de sessão, token de provedor, registro do usuário nem registro completo do vínculo. Isso também reduz o dano de uma serialização acidental em um Client Component.

lib/projects.ts — read operation

import "server-only";
import { z } from "zod";
import { db } from "@/lib/db";
import { AccessError, requireUserId } from "@/lib/access";
import { can } from "@/lib/permissions";

export const projectInput = z.object({
  workspaceId: z.string().uuid(),
  projectId: z.string().uuid(),
});

export async function getProject(input: unknown) {
  const userId = await requireUserId();
  const { workspaceId, projectId } = projectInput.parse(input);
  const result = await db.query<{
    id: string; name: string; role: string;
  }>(
    `SELECT p.id, p.name, m.role
       FROM app_project p
       JOIN app_membership m ON m.workspace_id = p.workspace_id
      WHERE p.id = $1 AND p.workspace_id = $2
        AND m.user_id = $3 AND m.status = 'active'`,
    [projectId, workspaceId, userId],
  );

  const row = result.rows[0];
  if (!row || !can(row.role, "project:read")) throw new AccessError(404);
  return {
    id: row.id,
    name: row.name,
    canDelete: can(row.role, "project:delete"),
  };
}

Proteja a página e faça com que a interface reflita permissões

Chame getProject a partir do Server Component. Um visitante anônimo é enviado ao login; um projeto inacessível exibe a página de recurso não encontrado. O formulário de exclusão só aparece quando o papel atual permite excluir.

Os campos ocultos carregam os IDs alvo e facilitam a integração do formulário. Não concedem autorização. Um viewer pode adicionar o formulário nas ferramentas do navegador ou enviar a requisição diretamente; a alteração da próxima seção deve rejeitá-la.

canDelete descreve o acesso no momento da renderização da página. Se um owner rebaixar esse usuário em outra aba, a página antiga ainda pode mostrar Excluir. A correção depende de verificar novamente o vínculo quando a gravação for executada.

app/workspaces/[workspaceId]/projects/[projectId]/page.tsx

import { notFound, redirect } from "next/navigation";
import { ZodError } from "zod";
import { AccessError } from "@/lib/access";
import { getProject } from "@/lib/projects";
import { deleteProjectAction } from "./actions";

export const runtime = "nodejs";

export default async function ProjectPage({
  params,
}: {
  params: Promise<{ workspaceId: string; projectId: string }>;
}) {
  const ids = await params;
  const project = await getProject(ids).catch((error: unknown) => {
    if (error instanceof AccessError && error.status === 401) {
      redirect("/sign-in");
    }
    if (error instanceof AccessError || error instanceof ZodError) notFound();
    throw error;
  });

  return (
    <main>
      <h1>{project.name}</h1>
      {project.canDelete && (
        <form action={deleteProjectAction}>
          <input type="hidden" name="workspaceId" value={ids.workspaceId} />
          <input type="hidden" name="projectId" value={ids.projectId} />
          <button type="submit">Delete project</button>
        </form>
      )}
    </main>
  );
}

Autorizar a escrita na mesma transação

Excluir um projeto exige uma nova decisão sobre o vínculo ao espaço de trabalho. Use uma única conexão PostgreSQL obtida do pool durante toda a transação. Carregue e bloqueie o vínculo do chamador, verifique project:delete e exclua pelo ID do projeto e pelo ID do espaço de trabalho. Registre a operação bem-sucedida antes de confirmar a transação.

FOR SHARE permite leituras concorrentes, mas bloqueia UPDATE ou DELETE da linha do vínculo até a transação terminar. Isso elimina a janela em que um papel poderia mudar entre a verificação e a gravação. Uma transação sem o bloqueio apropriado ainda teria essa janela.

A inserção de auditoria e a exclusão do projeto são confirmadas juntas ou revertidas juntas. actor_id da auditoria vem da sessão verificada. Ele é mantido como texto histórico para que a exclusão posterior de um usuário de autenticação não apague quem executou a operação.

Append to lib/projects.ts

export async function deleteProject(input: unknown) {
  const userId = await requireUserId();
  const { workspaceId, projectId } = projectInput.parse(input);
  const client = await db.connect();

  try {
    await client.query("BEGIN");
    await client.query("SET LOCAL lock_timeout = '3s'");
    await client.query("SET LOCAL statement_timeout = '5s'");

    const membership = await client.query<{ role: string }>(
      `SELECT role FROM app_membership
        WHERE workspace_id = $1 AND user_id = $2 AND status = 'active'
        FOR SHARE`,
      [workspaceId, userId],
    );
    const role = membership.rows[0]?.role;
    if (!role) throw new AccessError(404);
    if (!can(role, "project:delete")) throw new AccessError(403);

    const deleted = await client.query<{ id: string }>(
      `DELETE FROM app_project
        WHERE id = $1 AND workspace_id = $2
        RETURNING id`,
      [projectId, workspaceId],
    );
    if (deleted.rowCount !== 1) throw new AccessError(404);

    await client.query(
      `INSERT INTO app_audit_event
        (workspace_id, actor_id, operation, resource_id)
        VALUES ($1, $2, 'project.delete', $3)`,
      [workspaceId, userId, projectId],
    );
    await client.query("COMMIT");
  } catch (error) {
    await client.query("ROLLBACK");
    throw error;
  } finally {
    client.release();
  }
}

Chamar a operação protegida a partir de uma Server Action

A ação passa os campos não confiáveis do formulário para deleteProject, que obtém identidade e permissões por conta própria. Nenhum papel, ID de proprietário ou campo canDelete do navegador é aceito.

Mantenha os redirecionamentos fora do bloco catch da alteração bem-sucedida porque redirecionamentos Next.js lançam exceções internamente. Em um envio negado ou inválido, este formulário simples navega para uma página fixa de erro. Um formulário mais completo pode retornar erros tipados por useActionState sem mudar a operação no banco.

app/workspaces/[workspaceId]/projects/[projectId]/actions.ts

"use server";

import { redirect } from "next/navigation";
import { ZodError } from "zod";
import { AccessError } from "@/lib/access";
import { deleteProject } from "@/lib/projects";

export async function deleteProjectAction(formData: FormData) {
  let destination = "/";
  try {
    await deleteProject({
      workspaceId: formData.get("workspaceId"),
      projectId: formData.get("projectId"),
    });
  } catch (error) {
    if (error instanceof AccessError && error.status === 401) {
      destination = "/sign-in";
    } else if (error instanceof AccessError || error instanceof ZodError) {
      destination = "/access-denied";
    } else {
      throw error;
    }
  }
  redirect(destination);
}

Dar um destino claro aos envios de formulário negados

Esta página informa a falha da operação sem confirmar a existência do projeto enviado.

app/access-denied/page.tsx

export default function AccessDeniedPage() {
  return <p>The project could not be changed. Check your access and try again.</p>;
}

Reutilizar as verificações em uma API HTTP

A API importa as mesmas funções getProject e deleteProject. Assim, outro ponto de entrada não omite acidentalmente as verificações de permissão ou espaço de trabalho. Use 401 para sessão inválida, 403 para membro ativo sem a permissão da operação e 404 para recurso inacessível.

A autenticação por cookies também exige proteção CSRF. Better Auth protege seus próprios endpoints, mas não envolve este handler DELETE personalizado. Esta API de navegador da mesma origem exige um cabeçalho Origin exatamente igual à origem configurada da aplicação. Origens ausentes, null ou externas são rejeitadas antes da alteração. Clientes de máquina precisam de um caminho de autenticação próprio, em vez de uma exceção que enfraqueça este endpoint baseado em cookies.

Next.js acrescenta verificações de Origin/Host às Server Actions. Mantenha essas proteções habilitadas e restrinja as origens de proxies confiáveis. CORS controla quais scripts do navegador podem ler uma resposta; não é a verificação de permissão de uma gravação autenticada por cookie.

app/api/workspaces/[workspaceId]/projects/[projectId]/route.ts

import { ZodError } from "zod";
import { AccessError } from "@/lib/access";
import { getProject, deleteProject } from "@/lib/projects";

export const runtime = "nodejs";
const privateHeaders = { "Cache-Control": "private, no-store" };
type Context = {
  params: Promise<{ workspaceId: string; projectId: string }>;
};

function errorResponse(error: unknown) {
  if (error instanceof AccessError) {
    return Response.json({ error: error.message }, {
      status: error.status, headers: privateHeaders,
    });
  }
  if (error instanceof ZodError) {
    return Response.json({ error: "Invalid project identifiers" }, {
      status: 400, headers: privateHeaders,
    });
  }
  // Send unexpected failures to server monitoring; do not expose SQL/errors.
  return Response.json({ error: "Request failed" }, {
    status: 500, headers: privateHeaders,
  });
}

export async function GET(_request: Request, { params }: Context) {
  try {
    return Response.json(await getProject(await params), {
      headers: privateHeaders,
    });
  } catch (error) {
    return errorResponse(error);
  }
}

export async function DELETE(request: Request, { params }: Context) {
  const expectedOrigin = new URL(process.env.BETTER_AUTH_URL!).origin;
  if (request.headers.get("origin") !== expectedOrigin) {
    return Response.json({ error: "Forbidden origin" }, {
      status: 403, headers: privateHeaders,
    });
  }
  try {
    await deleteProject(await params);
    return new Response(null, { status: 204, headers: privateHeaders });
  } catch (error) {
    return errorResponse(error);
  }
}

Tornar previsíveis a revogação e as requisições concorrentes

A sessão identifica quem faz a chamada. O vínculo ao espaço de trabalho determina o que essa pessoa pode fazer agora. Como cada operação protegida consulta o vínculo, rebaixar admin para viewer afeta a próxima verificação de permissão, mesmo que a sessão de sete dias continue válida. Remover o vínculo bloqueia o acesso àquele espaço de trabalho e preserva o acesso aos demais.

Uma requisição já autorizada é outro caso. Se a exclusão obtiver primeiro o bloqueio do vínculo, poderá confirmar a transação antes de um rebaixamento concorrente terminar. Se o rebaixamento obtiver o bloqueio primeiro, a exclusão espera e verifica o papel atualizado. A garantia é uma decisão ordenada no banco, não o cancelamento de trabalho já autorizado.

A revogação de sessão tem um limite semelhante: invalidar a sessão impede verificações posteriores de serem aprovadas. Isso não recupera uma resposta HTTP já enviada, não apaga dados do navegador nem cancela automaticamente uma operação aprovada antes por requireUserId. Ações financeiras ou destrutivas que afetam toda a conta podem exigir nova autenticação e um limite transacional mais forte.

Proteger também a operação que altera os papéis

Um futuro endpoint de gerenciamento de papéis deve carregar o vínculo do usuário que executa a ação, exigir membership:manage e limitar o vínculo alvo ao mesmo espaço de trabalho. Nunca deve aceitar como verdadeiro um papel informado para o autor no JSON. Aplique a mesma regra à criação de convites, remoção de usuários e suspensão.

Impeça a remoção do último owner. Duas requisições concorrentes podem identificar outro owner e remover ambos se a verificação não for serializada. Uma abordagem prática é bloquear a linha do espaço de trabalho com FOR UPDATE, verificar o autor e a quantidade de owners e alterar os vínculos em uma única transação. Todos os caminhos que mudam a propriedade devem obter esse bloqueio na mesma ordem.

Manter a autorização fora de caches compartilhados

Os exemplos não compartilham cache de sessões, vínculos ou respostas de projetos entre requisições. Não envolva essas funções em use cache compartilhado ou unstable_cache. Uma decisão aprovada armazenada apenas pelo ID do projeto pode ser reutilizada pelo próximo usuário; mesmo um cache específico do usuário pode continuar válido após uma revogação.

Se consultas repetidas ficarem caras durante uma renderização de Server Component, React cache pode deduplicá-las naquela requisição. Sua duração é diferente da de um cache persistente de dados Next.js. As alterações ainda devem obter uma decisão nova para sua transação. As respostas de rota acima usam private, no-store; configure a CDN para respeitá-los e excluir HTML e respostas RSC autenticados do cache público.

Um arquivo proxy.ts opcional pode redirecionar visitantes sem cookie de sessão. A presença do cookie não comprova sua validade. Mantenha as verificações da DAL mesmo com redirecionamentos Proxy, layouts protegidos e controles de rota no cliente.

Testar operações negadas com dois espaços de trabalho

Um login bem-sucedido como owner diz pouco sobre a autorização. Crie Northstar e Acme em um banco descartável. Dê ao mesmo usuário acesso admin à Northstar e viewer à Acme e crie um projeto em cada. Crie também outro usuário sem vínculo a nenhum deles.

Comece por um teste executável da política e teste depois o banco e os caminhos HTTP. Ao negar uma operação, verifique a resposta e o estado inalterado: o projeto ainda deve existir e nenhum evento de auditoria de exclusão bem-sucedida deve ter sido adicionado.

Cenários de integração e resultados esperados
Requisição ou alteraçãoResultado esperadoO que prova
Nenhuma sessão ou sessão revogada401 da API; a página redireciona para o loginCada ponto de entrada verifica a identidade.
Admin da Northstar exclui um projeto da Northstar204; um projeto removido; um evento de auditoriaA operação permitida é confirmada como uma unidade.
O mesmo usuário exclui na Acme como viewer403; o projeto permaneceOs papéis são definidos por vínculo ao espaço de trabalho.
URL da Northstar com o ID de projeto da Acme404; o projeto da Acme permaneceA gravação usa os IDs do espaço de trabalho e do projeto.
Usuário sem vínculo solicita um ID conhecido de projeto404 sem campos de projetoConhecer um ID não dá acesso.
Rebaixar admin após renderizar o formulário de exclusãoPróximo envio negado; o projeto permaneceO servidor ignora permissões desatualizadas da interface.
DELETE com Origin ausente ou externo403; o projeto permaneceO endpoint personalizado baseado em cookies verifica a origem para proteger contra CSRF.
A inserção de auditoria falha após a instrução DELETEA transação é revertida; o projeto permaneceUma falha não pode confirmar apenas metade da operação.

tests/permissions.test.mjs — run with node --test tests/permissions.test.mjs on Node.js 24

import test from "node:test";
import assert from "node:assert/strict";
import { can } from "../lib/permissions.ts";

test("workspace roles grant only their declared permissions", () => {
  const cases = [
    ["owner", "project:delete", true],
    ["owner", "membership:manage", true],
    ["admin", "project:delete", true],
    ["admin", "membership:manage", false],
    ["viewer", "project:read", true],
    ["viewer", "project:delete", false],
    ["unknown", "project:read", false],
    ["constructor", "project:read", false],
  ];
  for (const [role, permission, expected] of cases) {
    assert.equal(can(role, permission), expected, role + ": " + permission);
  }
});

Testar a condição de corrida em vez de presumir que o bloqueio funciona

Use duas conexões ao banco. Na primeira, inicie uma transação e altere o papel do membro para viewer sem confirmar. Inicie a exclusão na segunda conexão. Ela deve esperar em SELECT … FOR SHARE. Confirme o rebaixamento; a exclusão deve continuar, encontrar viewer e negar a operação.

Repita com a exclusão obtendo primeiro o bloqueio do vínculo. O rebaixamento deve esperar até a exclusão confirmar a transação. Mantenha o atraso abaixo do timeout de bloqueio de três segundos do exemplo. O teste mostra exatamente qual operação vence e verifica a concorrência que um teste unitário de permissões não cobre.

Após mudar o papel, chame também a Server Action diretamente usando a requisição capturada no navegador. Testar apenas os botões visíveis não cobre o ponto de entrada que um invasor ainda pode chamar.

Operar o sistema de autenticação após o lançamento

Todas as réplicas precisam do mesmo segredo de autenticação, URL da aplicação, banco de dados e configuração OAuth. Mantenha esses valores nos segredos de execução, use HTTPS e cadastre o callback exato de produção. Uma sessão criada na réplica A deve ser validada na B. Implante mudanças de esquema por migrações revisadas para permitir a coexistência da versão antiga e da nova durante o rollout.

Ao executar várias réplicas, limite o tráfego de autenticação em um ponto compartilhado; contadores independentes em memória multiplicam o limite efetivo. Configure a borda para sobrescrever cabeçalhos confiáveis de IP do cliente e impedir acesso direto que permita falsificá-los. Monitore falhas de callback, erros de consulta de sessão, operações negadas e esgotamento do pool sem registrar cookies ou tokens de provedores.

Este exemplo usa login GitHub, então verificação de senha e recuperação de conta começam no provedor. Se habilitar e-mail e senha depois, implemente também entrega verificada de e-mail, tokens de redefinição de uso único com expiração, limites de tentativas e revogação de sessão após mudanças sensíveis de credenciais. Adicionar apenas um campo de senha não cria um sistema de recuperação.

Para exportações demoradas e tarefas em segundo plano, armazene o usuário que as iniciou e o espaço de trabalho. Defina se a permissão será verificada novamente na execução e no download do resultado. Uma requisição autorizada hoje não deve gerar um link irrestrito que ainda funcione depois que o usuário sair do espaço de trabalho.

O resultado é um caminho pequeno e explícito de autorização: sessão verificada, vínculo atual, operação permitida e consulta com escopo. Cada novo endpoint deve usar esse caminho, e cada nova permissão deve ter um teste de negação.

Todos os artigos