agentsclimarketplace

Multi tenancy

Skill param087/saas-starter-skills/skills/multi-tenancy

Production-grade full-stack SaaS skills for AI coding agents — Next.js, Postgres/Drizzle, Auth, Stripe & Vercel patterns for Codex, Claude Code, Cursor & OpenCode.

Install
npx -y skills add param087/saas-starter-skills --skill multi-tenancy

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

One thing to look at

  • 1 stars1 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.

What its author says it does

Copied from the file, not written here

Use when an app has organizations/teams/workspaces — resolve the active tenant from the session or URL, scope every query to it, and guarantee one tenant can never read or write another's data.

SKILL.md

2.9 KB, as published. Nobody here has run it

Multi-Tenancy

Overview

B2B SaaS is multi-tenant: users belong to organizations, and all data is owned by an org. The cardinal rule is isolation — no request may ever touch another tenant's rows. Use the shared-database, shared-schema model (an org_id column on every tenant table) and enforce scoping in one place: resolve the active org from the session, verify membership, and pass orgId into the data-access layer.

When to use

  • The app has teams, workspaces, or organizations.
  • A user can belong to more than one org and switch between them.
  • You're writing any query against tenant-owned data.

Resolving the active org

// src/server/auth/active-org.ts
import "server-only";
import { and, eq } from "drizzle-orm";
import { db } from "@/server/db/client";
import { memberships, organizations } from "@/server/db/schema";
import { requireSession } from "@/server/auth/require";

export async function requireOrg(slug: string) {
  const session = await requireSession();
  const [row] = await db
    .select({ org: organizations, role: memberships.role })
    .from(memberships)
    .innerJoin(organizations, eq(memberships.orgId, organizations.id))
    .where(and(eq(memberships.userId, session.user.id), eq(organizations.slug, slug)))
    .limit(1);

  if (!row) throw new Error("Not a member of this organization"); // 404/redirect
  return { org: row.org, role: row.role, user: session.user };
}

Route by org in the URL — /(app)/[org]/... — and call requireOrg(params.org) in the layout so membership is checked before anything renders.

Isolation strategies (defense in depth)

  • App layer (required): every DAL query filters by org_id (see data-access-layer).
  • Database layer (strong): Postgres Row-Level Security with a current_setting('app.org_id') policy, so even a buggy query can't cross tenants.
  • Never trust a client-supplied orgId. Derive it from a verified membership, not a form field or header.

Pitfalls

  • Resolving org from a request body/header — a user can forge it. Resolve from session + membership.
  • A single missing org_id filter — one un-scoped query is a full cross-tenant breach. Centralize in the DAL.
  • Checking membership in the UI only — re-check on the server for every action and route.
  • Global unique constraints that should be per-tenant — e.g. project name unique per org, not globally; use composite unique indexes.
  • Leaking tenant existence — return 404, not 403, for orgs the user can't access.

Hand-off

A verified { org, role, user } for the request. Feed role into authorization-rbac and org.id into every data-access-layer call.

Keep looking

Skills are one crate of 328,083. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.