Multi-Tenant & RBAC
Organizations, memberships, invitations, and role-based access control
Coldstart generates a complete multi-tenant system with organizations, memberships, invitations, and optional RBAC. Enable it during coldstart init or add it later with coldstart add tenant.
What's generated
Three database tables and two middleware:
| Component | File | Purpose |
|---|---|---|
organization table | packages/db/src/tenant-schema.ts | Org name, slug, logo, metadata |
member table | packages/db/src/tenant-schema.ts | User-org relationship with role |
invitation table | packages/db/src/tenant-schema.ts | Email invitations with token + expiry |
| Organization middleware | apps/api/src/middleware/organization.ts | Extract org context, verify membership |
| RBAC middleware | apps/api/src/middleware/rbac.ts | Role hierarchy enforcement |
Database schema
Organization
export const organization = pgTable("organization", {
id: text("id").primaryKey(),
name: text("name").notNull(),
slug: text("slug").notNull().unique(),
logo: text("logo"),
metadata: text("metadata"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});Member
export const member = pgTable("member", {
id: text("id").primaryKey(),
userId: text("user_id").notNull().references(() => user.id, { onDelete: "cascade" }),
organizationId: text("organization_id").notNull().references(() => organization.id, { onDelete: "cascade" }),
role: text("role", { enum: ["owner", "admin", "member", "viewer"] }).notNull().default("member"),
createdAt: timestamp("created_at").defaultNow().notNull(),
}, (table) => [
index("member_userId_idx").on(table.userId),
index("member_organizationId_idx").on(table.organizationId),
]);Invitation
export const invitation = pgTable("invitation", {
id: text("id").primaryKey(),
email: text("email").notNull(),
token: text("token").notNull().unique(),
organizationId: text("organization_id").notNull().references(() => organization.id, { onDelete: "cascade" }),
role: text("role", { enum: ["owner", "admin", "member", "viewer"] }).notNull().default("member"),
inviterId: text("inviter_id").notNull().references(() => user.id),
status: text("status", { enum: ["pending", "accepted", "rejected", "expired"] }).notNull().default("pending"),
createdAt: timestamp("created_at").defaultNow().notNull(),
expiresAt: timestamp("expires_at").notNull(),
}, (table) => [
index("invitation_token_idx").on(table.token),
index("invitation_email_idx").on(table.email),
index("invitation_organizationId_idx").on(table.organizationId),
]);Organization middleware
The organization middleware extracts the current organization from either the x-organization-slug header or the :orgSlug path parameter, then verifies the authenticated user is a member:
export const organizationMiddleware = async (c: Context, next: Next) => {
const slug = c.req.header("x-organization-slug") || c.req.param("orgSlug");
if (!slug) {
return c.json({ error: "Organization slug is required" }, 400);
}
const org = await db.query.organization.findFirst({
where: eq(organization.slug, slug),
});
if (!org) {
return c.json({ error: "Organization not found" }, 404);
}
const user = c.get("user");
if (!user) {
return c.json({ error: "Unauthorized" }, 401);
}
const membership = await db.query.member.findFirst({
where: and(
eq(member.userId, user.id),
eq(member.organizationId, org.id),
),
});
if (!membership) {
return c.json({ error: "Not a member of this organization" }, 403);
}
c.set("organization", org);
c.set("membership", membership);
await next();
};The middleware sets c.get("organization") and c.get("membership") on the context, available to all downstream handlers.
RBAC middleware
When RBAC is enabled, Coldstart generates a role hierarchy with four levels:
type Role = "owner" | "admin" | "member" | "viewer";
const ROLE_HIERARCHY: Record<Role, number> = {
owner: 4,
admin: 3,
member: 2,
viewer: 1,
};
export const requireRole = (minRole: Role) => {
return async (c: Context, next: Next) => {
const membership = c.get("membership");
if (!membership || ROLE_HIERARCHY[membership.role as Role] < ROLE_HIERARCHY[minRole]) {
return c.json({ error: `Requires ${minRole} role or higher` }, 403);
}
await next();
};
};Role capabilities
Full access to everything:
- Delete the organization
- Manage billing and subscription
- Manage all members (promote, demote, remove)
- All admin, member, and viewer permissions
app.delete("/org/:orgSlug", requireRole("owner"), deleteOrg);
app.put("/org/:orgSlug/billing", requireRole("owner"), updateBilling);Organization management:
- Invite and remove members
- Update organization settings
- All member and viewer permissions
app.use("/org/:orgSlug/settings/*", requireRole("admin"));
app.post("/org/:orgSlug/invitations", requireRole("admin"), createInvitation);
app.delete("/org/:orgSlug/members/:memberId", requireRole("admin"), removeMember);Standard CRUD access:
- Create, read, update, delete resources
- View other members
- All viewer permissions
app.use("/org/:orgSlug/resources/*", requireRole("member"));
app.get("/org/:orgSlug/members", requireRole("member"), listMembers);Read-only access:
- View resources
- View organization info
app.get("/org/:orgSlug/resources", requireRole("viewer"), listResources);
app.get("/org/:orgSlug/resources/:id", requireRole("viewer"), getResource);Protecting routes
Apply session middleware first
Organization middleware requires an authenticated user:
import { sessionMiddleware, requireAuth } from "../middleware/session";
import { organizationMiddleware } from "../middleware/organization";
import { requireRole } from "../middleware/rbac";Chain middleware on org routes
// All org routes require auth + org membership
app.use("/org/:orgSlug/*", sessionMiddleware, requireAuth, organizationMiddleware);
// Settings require admin
app.use("/org/:orgSlug/settings/*", requireRole("admin"));
// Destructive actions require owner
app.delete("/org/:orgSlug", requireRole("owner"), deleteOrg);Access org context in handlers
app.get("/org/:orgSlug/dashboard", (c) => {
const org = c.get("organization");
const membership = c.get("membership");
return c.json({
orgName: org.name,
userRole: membership.role,
});
});Invitation flow
Create invitation (admin+)
Generate a unique token and send an email:
const token = crypto.randomUUID();
await db.insert(invitation).values({
id: crypto.randomUUID(),
email: "new@member.com",
token,
organizationId: org.id,
role: "member",
inviterId: user.id,
status: "pending",
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
});Accept invitation
Verify the token, create a membership, update the invitation status:
const inv = await db.query.invitation.findFirst({
where: and(
eq(invitation.token, token),
eq(invitation.status, "pending"),
),
});
if (!inv || inv.expiresAt < new Date()) {
return c.json({ error: "Invalid or expired invitation" }, 400);
}
await db.insert(member).values({
id: crypto.randomUUID(),
userId: user.id,
organizationId: inv.organizationId,
role: inv.role,
});
await db.update(invitation)
.set({ status: "accepted" })
.where(eq(invitation.id, inv.id));Decline invitation
await db.update(invitation)
.set({ status: "rejected" })
.where(eq(invitation.id, inv.id));Per-seat billing integration
Selecting per-seat billing during scaffold automatically enables multi-tenant and RBAC. The billing system enforces seat limits when inviting new members.
With per-seat billing, the userBilling table includes a maxSeats field. Before creating an invitation or adding a member, check the seat count:
const currentMembers = await db.select({ count: count() })
.from(member)
.where(eq(member.organizationId, org.id));
const billing = await db.query.userBilling.findFirst({
where: eq(userBilling.userId, org.ownerId),
});
if (currentMembers[0].count >= (billing?.maxSeats ?? 1)) {
return c.json({ error: "Seat limit reached. Upgrade your plan." }, 403);
}Adding after scaffold
coldstart add tenantThis generates the tenant schema, organization middleware, and RBAC middleware. Run migrations afterward:
pnpm -F @scope/db db:generate && pnpm -F @scope/db db:migrate