Capstone: Multi-Tenant Notes API
What we built
Starting from a simple user-owned notes app, we built a complete multi-tenant authorization system:
| Layer | What it does | Where it lives |
|---|---|---|
| Roles | Owner, editor, viewer per org | memberships table |
| Role hierarchy | Owner > editor > viewer | authorize.ts |
| Permissions | Fine-grained resource:action strings | permissions.ts |
| Custom roles | Org-specific roles with custom permission sets | custom_roles table |
| Org scoping | Every query includes org_id | Every db.prepare call |
| Org switching | Active org in session | sessions.ts |
| Invites | Email-based, role-limited, single-use | invites table |
| API keys | Hashed, org-bound, scoped | api_keys table |
| Policy | Centralized can(user, action, resource) | policy.ts |
| Audit | Every decision logged | logger.ts |
Project structure
src/
app.ts # setup(), routes
server.ts # starts the server
db.ts # SQLite schema, seed data
auth.ts # authenticate (session + API key)
sessions.ts # session store with activeOrgId
cookies.ts # cookie helpers
membership.ts # getMembership, getUserOrgs
permissions.ts # ROLE_PERMISSIONS, getPermissions, roleHasPermission
authorize.ts # requireRole, requirePermission
policy.ts # can(), authorize()
api-keys.ts # createApiKey, validateApiKey
logger.ts # structured logging
routes/
auth.ts # POST /login
orgs.ts # /me/orgs, /orgs/:orgId/activate, invites, roles, api-keys
notes.ts # CRUD scoped to org The authorization flow
For any request, the authorization flow is:
Request arrives
│
├─ authenticate(request)
│ ├─ API key? → validate hash, get userId + orgId + scopes
│ └─ Session cookie? → get userId + activeOrgId
│
├─ authorize({ user, orgId, resourceOwnerId }, action)
│ ├─ getMembership(userId, orgId) → role
│ ├─ getPermissions(role, orgId) → permission set
│ ├─ permission set includes action? → check
│ ├─ API key scopes include action? → check
│ ├─ Special rules (creator edit, owner bypass)? → check
│ └─ Log the decision (allowed or denied)
│
└─ Handler executes (or returns 403/404) Every step is a plain function. No framework, no middleware, no magic. The functions compose naturally: authenticate returns a user, the policy checks the user’s access, the handler does the work.
Test the full system
# === Setup ===
npm run dev
# Log in as Alice (owner of Acme)
curl -c cookies.txt -X POST http://localhost:3000/login \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","password":"password123"}'
# Activate Acme
curl -b cookies.txt -X POST http://localhost:3000/me/orgs/org-acme/activate
# === RBAC ===
# Alice can list, create, and delete notes (owner)
curl -b cookies.txt http://localhost:3000/orgs/org-acme/notes
curl -b cookies.txt -X POST http://localhost:3000/orgs/org-acme/notes \
-H "Content-Type: application/json" \
-d '{"title":"Owner Note","body":"Created by Alice"}'
# Log in as Carol (viewer) — can list but not create
curl -c cookies.txt -X POST http://localhost:3000/login \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","password":"password123"}'
curl -b cookies.txt http://localhost:3000/orgs/org-acme/notes # 200
curl -b cookies.txt -X POST http://localhost:3000/orgs/org-acme/notes \
-H "Content-Type: application/json" \
-d '{"title":"Should Fail","body":"Carol cannot create"}' # 403
# === Org scoping ===
# Bob cannot see Globex notes (not a member)
curl -c cookies.txt -X POST http://localhost:3000/login \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","password":"password123"}'
curl -b cookies.txt http://localhost:3000/orgs/org-globex/notes # 404
# === API keys ===
# Alice creates a read-only key for Acme
curl -c cookies.txt -X POST http://localhost:3000/login \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","password":"password123"}'
curl -b cookies.txt -X POST http://localhost:3000/orgs/org-acme/api-keys \
-H "Content-Type: application/json" \
-d '{"name":"CI Read","scopes":["notes:read"]}'
# Copy the key from the response
# Use the key — read works, create fails
curl -H "X-API-Key: sk_..." http://localhost:3000/orgs/org-acme/notes # 200
curl -H "X-API-Key: sk_..." -X POST http://localhost:3000/orgs/org-acme/notes \
-H "Content-Type: application/json" \
-d '{"title":"Key Create","body":"Should fail"}' # 403 What you understand now
You can now build authorization systems for real apps. The concepts transfer directly:
- SaaS apps: Organizations are workspaces or teams. Roles are admin, member, guest. Permissions control feature access.
- Content platforms: Organizations are channels or publications. Roles are editor, author, subscriber. Permissions control publish, draft, comment.
- Developer tools: Organizations are projects. API keys have scopes. Audit logs track deployments and configuration changes.
The specific roles, permissions, and resources change. The patterns are the same: authenticate, resolve the context, check the policy, log the decision.
Challenges
Challenge 1: Add resource-level permissions. Instead of “editors can edit all notes,” implement “editors can only edit notes they created.” This is partly done in the policy function’s special case. Extend it to be configurable per-organization.
Challenge 2: Add a role management UI. Build routes for: listing members of an org, changing a member’s role, and removing a member. Apply proper authorization (only owners can change roles, owners cannot remove themselves if they are the last owner).
Challenge 3: Add time-limited API keys. Add an expires_at column to the api_keys table. Reject expired keys during validation. Add a GET /orgs/:orgId/api-keys route that lists active keys (showing prefix and scopes, not the key itself).
Challenge 4: Implement ABAC. Add a rule: “Notes tagged with ‘confidential’ can only be viewed by owners.” This requires checking a resource attribute (the tag) in the policy function, not just the user’s role.
What pattern does every authorization check in this course follow?
Which authorization layer provides the finest-grained control?