Adios
BlogNext.js SaaS

Next.js SaaS

Come implementare l'autenticazione e l'autorizzazione basata sul ruolo in Next.js

Realizza accesso con GitHub, sessioni nel database e ruoli negli spazi di lavoro con Better Auth e PostgreSQL. Proteggi pagine, Server Actions e route API.

Team di AdiosAggiornato 26 settembre 202624 min di lettura

Realizzeremo l'autenticazione per una dashboard di progetti: accesso con GitHub, sessione in PostgreSQL e ruoli owner e admin degli spazi di lavoro autorizzati a eliminare progetti, mentre i viewer possono solo leggerli. Gli stessi controlli di permessi proteggeranno pagina, Server Action e API HTTP.

Definisci esattamente cosa può fare un utente autenticato

Supponi che Maya sia owner dello spazio di lavoro Northstar e abbia accesso viewer ad Acme. L'accesso è lo stesso in entrambi, ma i permessi sono diversi. Un solo campo user.role non può rappresentarlo: concedere a Maya il ruolo owner globalmente le darebbe controllo anche su Acme.

Salva il ruolo nella relazione tra utente e spazio di lavoro. Una richiesta è consentita solo se la sessione è valida, la relazione è attiva, il ruolo permette l'operazione e il progetto richiesto appartiene a quello spazio di lavoro. Conoscere l'ID di un progetto non soddisfa nessuna di queste condizioni.

Questa implementazione usa l'App Router di Next.js 16, TypeScript, Better Auth 1.7 e PostgreSQL nell'ambiente di esecuzione Node.js. Parti da un'app TypeScript esistente con alias di importazione @/*, un database PostgreSQL e Node.js 24. L'esempio implementa lettura ed eliminazione dei progetti; inviti agli spazi di lavoro e modifica dei ruoli richiedono operazioni protette proprie.

Permessi all'interno di uno spazio di lavoro
RuoloLeggi i progettiElimina progettiModifica le appartenenze
ResponsabileSìSìSì
AmministratoreSìSìNo
LettoreSìNoNo
Nessuna appartenenza attivaNoNoNo

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

Configura un vero provider di autenticazione

Better Auth gestirà il callback GitHub, i cookie di sessione e le proprie tabelle di autenticazione. La nostra applicazione gestirà le tabelle degli spazi di lavoro e le decisioni di autorizzazione. Separare queste responsabilità permette di cambiare un ruolo nello spazio di lavoro senza modificare l'identità dell'utente.

Installa i pacchetti nell'app e salva nei commit il lockfile risultante. Genera BETTER_AUTH_SECRET con openssl rand -base64 32. Crea un'app OAuth GitHub con callback locale http://localhost:3000/api/auth/callback/github; usa un'app OAuth separata e un callback HTTPS per la produzione.

Install dependencies

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

Imposta la configurazione del server

Aggiungi questi valori a un file .env escluso dal controllo di versione. Sostituisci i segnaposto con le credenziali del database e OAuth. Nessuna di queste variabili richiede il prefisso 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

Riusa un unico pool di connessioni per processo Node

Creare un Pool dentro ogni richiesta genererebbe ogni volta un nuovo budget di connessioni. Questo modulo mantiene un unico pool, anche durante i ricaricamenti in sviluppo. Con quattro repliche applicative e max: 10, l'app può usare fino a 40 connessioni; anche migrazioni e altri servizi richiedono capacità.

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;

Usa sessioni nel database con scadenza fissa

In questo esempio scegliamo una durata assoluta di sette giorni e disabilitiamo il rinnovo della sessione. La cache dei cookie rimane disabilitata, quindi la verifica della sessione consulta il database. Sono politiche consapevoli: un cookie rubato può essere revocato centralmente e un browser attivo deve effettuare di nuovo l'accesso dopo sette giorni.

Un token firmato autosufficiente può ridurre le letture del database, ma un ruolo o una sessione copiati al suo interno rimangono validi fino alla scadenza, salvo l'aggiunta di un controllo della revoca. Le sessioni nel database sono adatte a questa dashboard perché i cambi di ruolo e la disconnessione di un dispositivo smarrito devono avere effetto sui controlli successivi.

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 },
  },
});

Monta gli endpoint di autenticazione

La route catch-all gestisce richieste di accesso, callback, sessione e uscita. Genera lo schema della libreria dopo aver creato la configurazione, esamina il SQL e applicalo al database di sviluppo. Il comando CLI è npx auth@latest generate; conserva la migrazione generata nella cronologia delle migrazioni dell'app. Usa una versione della CLI compatibile con la versione fissata di 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);

Segui il login dal browser alla sessione

Il browser avvia l'accesso tramite la nostra route di autenticazione e viene reindirizzato a GitHub. Il callback restituisce un codice di autorizzazione. Better Auth valida il flusso OAuth, scambia il codice sul server, associa l'identità del provider a un utente locale e crea una sessione nel database. Il browser riceve un cookie di sessione e torna al sito.

Il token di accesso GitHub e la sessione dell'app hanno funzioni diverse. Il token del provider serve a comunicare con GitHub. La dashboard usa la sessione dell'app per identificare chi chiama. Né un indirizzo email fornito dal client né un nome utente GitHub dimostrano l'appartenenza a uno spazio di lavoro.

Usa il client sotto da app/sign-in/page.tsx. Il callback torna alla home page esistente; dopo aver creato lo spazio di lavoro di esempio nella sezione successiva, apri l'URL del suo progetto.

lib/auth-client.ts

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

export const authClient = createAuthClient();

Accedi e gestisci una richiesta fallita

L'accesso con GitHub crea l'utente locale al primo utilizzo. Un nuovo utente non ha accesso agli spazi di lavoro finché l'onboarding non ne crea uno o un invito autorizzato non gli concede l'appartenenza.

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>
  );
}

Salva i ruoli nelle appartenenze agli spazi di lavoro

Applica questa migrazione applicativa dopo quella generata da Better Auth. La libreria di autenticazione gestisce le tabelle user, account, session e verification. Le tabelle sotto fanno riferimento alla sua tabella user PostgreSQL predefinita, senza aggiungerle un ruolo globale.

La chiave composta dell'appartenenza permette un ruolo per utente in ogni spazio di lavoro. workspace_id del progetto è obbligatorio. Questi vincoli impediscono appartenenze duplicate e proprietà orfane; non effettuano automaticamente l'autorizzazione. Ogni query sui progetti richiede comunque il confine dello spazio di lavoro.

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()
);

Crea uno spazio di lavoro per l'esempio guidato locale

Accedi una volta e trova il tuo ID utente locale nel database di autenticazione. In psql, imposta demo_user_id su quell'ID ed esegui le istruzioni sotto. Sono dati iniziali di sviluppo inseriti dall'operatore, non un endpoint che accetta ID utente o ruolo dal browser.

L'onboarding di produzione deve creare lo spazio di lavoro e il primo owner in un'unica transazione, ricavando l'ID dell'owner dalla sessione verificata. Accettare un invito deve collegarlo all'identità destinataria e consumare un token monouso con scadenza. Un utente autenticato non deve mai poter scegliere uno spazio di lavoro esistente e concedersi accesso 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

Definisci i permessi una sola volta e nega l'accesso ai ruoli sconosciuti

Nomina l'operazione nel codice applicativo: project:read o project:delete. Traduci i ruoli in questi permessi in un unico modulo. Pagina e server possono usare la stessa mappatura, ma solo la verifica corrente dell'appartenenza sul server può autorizzare una richiesta.

Questa politica restituisce false per un ruolo non riconosciuto. È importante durante le migrazioni e quando un valore inatteso raggiunge l'applicazione. Non trattare un ruolo mancante o sconosciuto come l'impostazione predefinita più 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);
}

Dove si ferma il controllo RBAC

Il ruolo determina se un membro può eliminare progetti nello spazio di lavoro. workspace_id del progetto determina a quali progetti si applica la regola. Sono condizioni distinte e devono essere entrambe soddisfatte.

Se in futuro il prodotto permette ai membri di eliminare solo i progetti creati da loro, aggiungi un controllo created_by alla query protetta. La sola verifica del ruolo non esprime questa regola di appartenenza. Allo stesso modo, il ruolo admin di uno spazio di lavoro non deve diventare implicitamente un permesso di usare una console interna di assistenza su tutti i clienti.

Inserisci la verifica della sessione nell'accesso ai dati protetti

Un reindirizzamento nel layout può migliorare la navigazione, ma un Route Handler o una Server Action possono essere chiamati direttamente. Inserisci la verifica dell'identità nella funzione che legge i dati protetti. Ogni chiamante riceve così gli stessi controlli.

requireUserId risolve il cookie ricevuto tramite Better Auth. Restituisce solo l'ID utente e genera un errore 401 se non esiste una sessione valida. Gli errori del database o del provider devono poter far fallire la richiesta; non devono ripiegare su un utente anonimo o precedentemente privilegiato.

La query del progetto unisce l'appartenenza del chiamante allo spazio di lavoro richiesto. Un progetto Acme non può essere recuperato inserendone l'ID in un URL Northstar. Qui appartenenza mancante e progetto mancante restituiscono entrambi 404, così l'endpoint non rivela l'esistenza del progetto di un altro spazio di lavoro.

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;
}

Limita la lettura e restituisci un risultato ridotto

Zod valida la struttura degli identificatori; il join sull'appartenenza stabilisce l'accesso. Un UUID sintatticamente valido resta un input non attendibile. I segnaposto SQL mantengono questi valori separati dal testo della query.

Restituisci i campi del progetto da visualizzare e un flag canDelete. La pagina non richiede token di sessione, token del provider, riga dell'utente o record completo dell'appartenenza. Questo riduce anche il danno di una serializzazione accidentale in un 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"),
  };
}

Proteggi la pagina e fai riflettere i permessi all'interfaccia

Chiama getProject dal Server Component. Un visitatore anonimo viene indirizzato all'accesso; un progetto non accessibile mostra la pagina di contenuto non trovato. Il modulo di eliminazione appare solo se il ruolo corrente permette di eliminare.

I campi nascosti trasportano gli ID destinatari e rendono facile collegare il modulo, ma non conferiscono autorità. Un viewer può aggiungere il modulo negli strumenti del browser o inviare da sé la richiesta; l'operazione di modifica nella sezione successiva deve rifiutarla.

canDelete descrive l'accesso al momento del rendering della pagina. Se un owner riduce il ruolo dell'utente in un'altra scheda, la pagina precedente può ancora mostrare Elimina. La correttezza dipende da una nuova verifica dell'appartenenza al momento della scrittura.

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>
  );
}

Autorizza la scrittura nella stessa transazione

Eliminare un progetto richiede una nuova decisione sull'appartenenza. Usa un'unica connessione PostgreSQL prelevata dal pool per tutta la transazione. Carica e blocca l'appartenenza del chiamante, verifica project:delete ed elimina filtrando sia per ID del progetto sia per ID dello spazio di lavoro. Registra l'operazione riuscita prima del commit.

FOR SHARE permette lettori simultanei, ma blocca UPDATE o DELETE della riga di appartenenza fino al termine della transazione. Elimina così la finestra in cui un ruolo potrebbe cambiare dopo la verifica ma prima della scrittura. Una transazione senza un lock appropriato conserverebbe quella finestra.

Inserimento nell'audit ed eliminazione del progetto eseguono insieme il commit oppure il rollback. actor_id dell'audit deriva dalla sessione verificata. Lo conserviamo volutamente come testo storico, così eliminare in seguito un utente di autenticazione non cancella l'identità di chi ha eseguito l'operazione.

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();
  }
}

Chiama l'operazione protetta da una Server Action

L'azione passa i campi non attendibili del modulo a deleteProject, che verifica autonomamente identità e permessi. Non accetta dal browser alcun ruolo, ID del proprietario o campo canDelete.

Tieni i reindirizzamenti fuori dal blocco catch della modifica riuscita, perché i reindirizzamenti Next.js lanciano eccezioni internamente. In caso di invio rifiutato o non valido, questo piccolo modulo passa a una pagina di errore fissa. Un modulo più ricco può restituire errori tipizzati tramite useActionState senza modificare l'operazione del database.

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);
}

Dai una destinazione chiara agli invii del modulo rifiutati

Questa pagina segnala l'operazione non riuscita senza confermare che il progetto indicato esista.

app/access-denied/page.tsx

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

Riusa i controlli in un'API HTTP

L'API importa le stesse funzioni getProject e deleteProject. Un secondo punto d'ingresso non può quindi omettere accidentalmente i controlli di permessi o spazio di lavoro. Usa 401 per una sessione non valida, 403 per un membro attivo senza il permesso dell'operazione e 404 per una risorsa non accessibile.

L'autenticazione tramite cookie richiede anche protezione CSRF. Better Auth protegge i propri endpoint, ma non questo gestore DELETE personalizzato. Questa API del browser, usata dalla stessa origine, richiede un'intestazione Origin che corrisponda esattamente all'origine configurata dell'applicazione. Origini mancanti, null e diverse vengono rifiutate prima della modifica. I client macchina richiedono un percorso di autenticazione progettato separatamente, anziché un'eccezione che indebolisca questo endpoint basato sui cookie.

Next.js aggiunge controlli Origin/Host alle Server Actions. Mantieni attive queste protezioni e limita strettamente le origini dei proxy attendibili. CORS controlla quali script del browser possono leggere una risposta; non verifica il permesso di scrittura di una richiesta autenticata tramite 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);
  }
}

Rendi prevedibili revoca e richieste simultanee

La sessione identifica chi chiama. L'appartenenza determina cosa può fare in quel momento. Poiché ogni operazione protetta legge l'appartenenza, passare un admin a viewer influisce sulla verifica dei permessi successiva anche se la sua sessione di sette giorni rimane valida. Rimuovere l'appartenenza blocca l'accesso a quello spazio di lavoro, conservando l'accesso agli altri.

Una richiesta già autorizzata è diversa. Se l'eliminazione acquisisce per prima il lock sull'appartenenza, può eseguire il commit prima che una riduzione concorrente del ruolo termini. Se il cambio di ruolo acquisisce per primo il lock, l'eliminazione attende e verifica poi il ruolo aggiornato. La garanzia è una decisione ordinata nel database, non la cancellazione di lavoro già autorizzato.

La revoca della sessione ha un confine simile: invalidare una sessione impedisce il successo delle verifiche successive. Non richiama una risposta HTTP già inviata, non cancella dati nel browser e non annulla automaticamente un'operazione che ha già superato requireUserId. Azioni finanziarie o distruttive sull'intero account possono richiedere una nuova verifica di autenticazione e un confine transazionale più rigoroso.

Proteggi anche l'operazione di cambio ruolo

Un futuro endpoint di gestione dei ruoli deve caricare l'appartenenza dell'utente che agisce, richiedere membership:manage e limitare l'appartenenza destinataria allo stesso spazio di lavoro. Non deve mai accettare un ruolo dell'attore dichiarato nel JSON. Applica la stessa regola alla creazione degli inviti, alla rimozione degli utenti e alla sospensione.

Impedisci la rimozione dell'ultimo owner. Due richieste simultanee possono ciascuna vedere un altro owner e rimuoverli entrambi se la verifica non è serializzata. Un approccio pratico è bloccare la riga dello spazio di lavoro con FOR UPDATE, verificare l'attore e il numero di owner, poi modificare le appartenenze in un'unica transazione. Ogni percorso che cambia la proprietà deve acquisire quel lock nello stesso ordine.

Tieni l'autorizzazione fuori dalle cache condivise

Gli esempi non memorizzano sessioni, appartenenze o risposte dei progetti in cache condivise tra richieste. Non inserire queste funzioni in un wrapper condiviso use cache o unstable_cache. Una decisione positiva memorizzata solo per ID del progetto potrebbe essere riusata dall'utente successivo; anche una decisione in cache specifica dell'utente può rimanere valida oltre una revoca.

Se verifiche duplicate diventano costose durante un singolo rendering di un Server Component, React cache può deduplicarle all'interno della richiesta. La durata è diversa da quella di una cache dati persistente di Next.js. Le modifiche devono comunque ottenere una nuova decisione per la propria transazione. Le risposte delle route sopra usano private, no-store; configura una CDN perché lo rispetti ed escluda HTML e risposte RSC autenticate dalla cache pubblica.

Un proxy.ts facoltativo può reindirizzare i visitatori senza cookie di sessione. La presenza di un cookie non dimostra che sia valido. Mantieni i controlli del DAL anche se l'app usa reindirizzamenti Proxy, layout protetti e controlli di route lato client.

Verifica le operazioni negate con due spazi di lavoro

Un accesso riuscito come owner dimostra ben poco sull'autorizzazione. Crea Northstar e Acme in un database temporaneo. Assegna allo stesso utente accesso admin a Northstar e viewer ad Acme, poi crea un progetto in ciascuno. Crea anche un secondo utente senza alcuna appartenenza.

Inizia con un test eseguibile della politica, poi verifica i percorsi del database e HTTP. Per un rifiuto, la verifica essenziale riguarda sia la risposta sia lo stato invariato: il progetto deve ancora esistere e non deve essere stato aggiunto alcun evento di audit di eliminazione riuscita.

Scenari di integrazione e risultati previsti
Richiesta o modificaRisultato previstoChe cosa prova
Nessuna sessione o sessione revocata401 dall'API; la pagina reindirizza all'accessoOgni punto d'ingresso verifica l'identità.
Un admin di Northstar elimina un progetto Northstar204; un progetto rimosso; un evento di auditIl percorso consentito esegue il commit come un'unica unità.
Lo stesso utente elimina in Acme come viewer403; resta il progettoI ruoli sono limitati alle appartenenze.
URL Northstar con l'ID del progetto Acme404; il progetto Acme rimaneLa scrittura usa sia l'ID dello spazio di lavoro sia quello del progetto.
Un non membro richiede un ID di progetto noto404 senza campi del progettoConoscere un ID non concede accesso.
Riduci il ruolo admin dopo il rendering del modulo di eliminazioneInvio successivo rifiutato; il progetto rimaneIl server ignora i permessi obsoleti dell'interfaccia.
DELETE con Origin mancante o diverso403; resta il progettoL'endpoint personalizzato basato sui cookie verifica l'origine per la protezione CSRF.
L'inserimento nell'audit fallisce dopo l'istruzione DELETERollback della transazione; il progetto rimaneUn errore non può produrre un commit parziale dell'operazione.

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);
  }
});

Simula la concorrenza anziché presumere che il lock funzioni

Usa due connessioni al database. Sulla prima, avvia una transazione e aggiorna il ruolo del membro a viewer senza eseguire il commit. Avvia l'eliminazione sulla seconda connessione. Deve attendere su SELECT … FOR SHARE. Esegui il commit del cambio di ruolo; l'eliminazione deve riprendere, rilevare viewer e restituire un rifiuto.

Ripeti facendo acquisire per prima all'eliminazione il lock sull'appartenenza. Il cambio a un ruolo inferiore deve attendere il commit dell'eliminazione. Mantieni il ritardo sotto il timeout del lock di tre secondi dell'esempio. Questo test mostra esattamente quale operazione prevale e verifica il comportamento concorrente che un test unitario sui permessi non può coprire.

Chiama anche direttamente la Server Action usando la richiesta acquisita dal browser dopo il cambio di ruolo. Verificare solo i pulsanti visibili farebbe perdere il punto d'ingresso che un attaccante può ancora invocare.

Gestisci il sistema di autenticazione dopo il lancio

Tutte le repliche richiedono lo stesso segreto di autenticazione, URL dell'applicazione, database e configurazione OAuth. Conserva questi valori nei segreti dell'ambiente di esecuzione, usa HTTPS e registra il callback di produzione esatto. Una sessione creata sulla replica A deve essere verificabile sulla replica B. Distribuisci le modifiche allo schema tramite migrazioni esaminate, così vecchia e nuova versione dell'applicazione possono coesistere durante la distribuzione.

Quando esegui più repliche, limita il traffico di autenticazione in un punto condiviso; contatori indipendenti in memoria moltiplicano il limite effettivo. Configura il nodo edge per sovrascrivere le intestazioni attendibili dell'IP client e impedisci accessi diretti che possano falsificarle. Monitora errori di callback, verifiche di sessione non riuscite, operazioni negate ed esaurimento del pool senza registrare cookie o token dei provider.

Questo esempio guidato usa l'accesso GitHub, quindi verifica della password e recupero dell'account partono da quel provider. Se in seguito abiliti email e password, implementa anche consegna verificata delle email, token di ripristino monouso con scadenza, limiti ai tentativi e revoca delle sessioni dopo modifiche sensibili alle credenziali. Aggiungere il solo campo password non crea un sistema di recupero.

Per esportazioni lunghe e job in background, salva utente iniziale e spazio di lavoro, poi definisci se i permessi vengono verificati di nuovo all'esecuzione del job e al download del risultato. Una richiesta autorizzata oggi non deve creare un link di download senza restrizioni che funzioni ancora dopo che l'utente ha lasciato lo spazio di lavoro.

Il risultato è un percorso di autorizzazione piccolo ed esplicito: sessione verificata, appartenenza corrente, operazione consentita e query limitata al suo ambito. Ogni endpoint aggiuntivo deve chiamare quel percorso e ogni ulteriore permesso deve avere un test di rifiuto.

Tutti gli articoli