Turn it multi-tenant with teams and roles
Continues from the last build: Every row is owned by a user and cross-user access is blocked and tested.
The freelancer who built InvoiceDesk just hired two contractors to help chase invoices.
What you'll build
Every row now belongs to an org, not a user. A freelancer's personal org is created automatically on signup, contractors join through hashed, expiring invite tokens, and a single can(role, action) helper decides every write on the server, never trusting the UI. An attacker with a valid session in one org gets a 404 reading another org's data and a 403 trying to exceed their own role in their own org, and an integration test suite proves it.
See how we teach, before you sign up
You don't just get code dumped on you. Every starter file and every solution is explained line-by-line, in plain English. Here's one real file from this project:
import { pgEnum, pgTable, text, timestamp, uuid, uniqueIndex } from 'drizzle-orm/pg-core';
import { users } from './schema-users';
export const membershipRole = pgEnum('membership_role', ['owner', 'admin', 'member']);
export const orgs = pgTable('orgs', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
slug: text('slug').notNull().unique(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
});
export const memberships = pgTable('memberships', {
id: uuid('id').primaryKey().defaultRandom(),
orgId: uuid('org_id').notNull().references(() => orgs.id, { onDelete: 'cascade' }),
userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
role: membershipRole('role').notNull().default('member'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => ({
orgUserUnique: uniqueIndex('memberships_org_user_idx').on(table.orgId, table.userId),
}));
export const invites = pgTable('invites', {
id: uuid('id').primaryKey().defaultRandom(),
orgId: uuid('org_id').notNull().references(() => orgs.id, { onDelete: 'cascade' }),
email: text('email').notNull(),
role: membershipRole('role').notNull().default('member'),
tokenHash: text('token_hash').notNull(),
invitedByUserId: uuid('invited_by_user_id').notNull().references(() => users.id),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
acceptedAt: timestamp('accepted_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
});Reading this file
export const membershipRole = pgEnum('membership_role', ['owner', 'admin', 'member']);The enum is the source of truth for valid roles, Postgres itself rejects any other string.orgUserUnique: uniqueIndex('memberships_org_user_idx').on(table.orgId, table.userId),Guarantees one role per user per org at the database level, not just in application code.tokenHash: text('token_hash').notNull(),Invites only ever store the hash of the invite token, never the raw value.acceptedAt: timestamp('accepted_at', { withTimezone: true }),Nullable until accepted, this is what acceptInvite checks to reject a second use of the same token.
Starter file for this rung.
That's 1 of 8 explained code blocks in this single project.
The build, milestone by milestone
- 1
Model orgs and memberships
5 guided stepsWithout an org layer there is no shared ownership boundary to hang roles, invites, or the org switcher on.
- 2
Move ownership from user to org
5 guided stepsRBAC and invites are meaningless until every query is scoped by org instead of by whichever single user happened to create the row.
- 3
Enforce RBAC on the server
5 guided stepsAn IDOR check that only compares orgId is not enough once three roles exist inside the same org with different write privileges.
- 4
Invites that do not leak
5 guided stepsAn invite system that returns different messages for already exists versus already invited lets an attacker enumerate every email on the platform.
- 5
Test tenant isolation like an attacker
5 guided stepsThe org-scoping code from the earlier milestones is only as good as the tests that try to break it, untested authorization code regresses silently.
What's inside when you start
You'll walk away with
This is portfolio-grade. Build it free.
Sign up to unlock every milestone step-by-step, the code skeletons, full reference solutions, and checkable tasks, with your progress saved as you build.
Start building