Repository navigation
feat(backend): Add multi-tenancy with tenant_id scoping and default organization #20
Description
Problem Statement
Current State: Backend API (Taskflow API) has no organization/tenant scoping. All users see all projects/tasks regardless of which organization they're in via the org switcher.
Security Risk: 🔴 CRITICAL - Without tenant isolation, users can access each other's data.
UX Issue: Users switch organizations in the UI but see the same data (confusing and broken).
Proposed Solution
Add tenant_id to core models and filter all queries by the authenticated user's active tenant from JWT.
1. Database Schema Changes
Add tenant_id column to:
- ✅
projecttable - ✅
tasktable - ✅
workertable - ✅
audit_logtable
# Example: Project model
class Project(SQLModel, table=True):
# ... existing fields ...
tenant_id: str = Field(
index=True,
description="Organization ID from SSO (tenant isolation)"
)2. Default Organization Strategy
Problem: Users shouldn't need to understand organizations to use Taskflow.
Solution: Auto-create "Personal Workspace" as default organization.
When to Create Default Org:
-
First API request - If user has no
tenant_idin JWT:- Call SSO API to create "Personal Workspace" org
- SSO assigns user as owner
- SSO updates session with new tenant_id
- Redirect user to retry request (new JWT with tenant_id)
-
Alternative: During signup - SSO creates default org immediately
Benefits:
- ✅ Seamless single-user experience
- ✅ No need to explain organizations upfront
- ✅ Users can add orgs later as they grow
- ✅ Zero setup friction
3. Query Filtering
Add tenant scoping to ALL queries:
# Before (INSECURE)
projects = session.exec(select(Project)).all()
# After (SECURE)
tenant_id = get_tenant_from_jwt(request)
projects = session.exec(
select(Project).where(Project.tenant_id == tenant_id)
).all()Where to apply:
- All
GET /projects,GET /tasks,GET /workersendpoints - All
POST,PUT,DELETEoperations (verify tenant ownership) - Audit log queries
4. Middleware for tenant_id Extraction
async def inject_tenant_id(request: Request, call_next):
"""Extract tenant_id from JWT and attach to request state."""
jwt_claims = decode_jwt(request.cookies.get("taskflow_id_token"))
tenant_id = jwt_claims.get("tenant_id")
if not tenant_id:
# User has no org - trigger default org creation
return await create_default_org_flow(request)
request.state.tenant_id = tenant_id
return await call_next(request)5. Migration Strategy
For existing data:
-- Option 1: Assign to user's first organization
UPDATE project p
SET tenant_id = (
SELECT organization_id
FROM member
WHERE user_id = p.owner_id
LIMIT 1
);
-- Option 2: Create default org for each user, assign all their data
-- (Safer - preserves all data under user's control)Implementation Checklist
Phase 1: Schema & Migration
- Add
tenant_idcolumn to Project, Task, Worker, AuditLog models - Create migration script (Alembic)
- Backfill existing data with default org per user
- Add indexes on
tenant_idcolumns
Phase 2: Default Organization
- SSO: Create default "Personal Workspace" org on user signup
- OR: Backend middleware creates default org on first API request
- Handle edge case: user deletes all orgs (recreate default)
Phase 3: Query Filtering
- Add tenant_id extraction middleware
- Update ALL query endpoints to filter by tenant_id
- Update ALL mutations to verify tenant ownership
- Add tenant_id to audit logs
Phase 4: Security Validation
- Test: User A cannot see User B's projects
- Test: User A cannot modify User B's tasks
- Test: Switching orgs shows different data
- Test: Default org created automatically
Phase 5: Documentation
- Update API docs with tenant scoping behavior
- Document default org creation flow
- Add migration guide for existing deployments
Impact Analysis
Security
- Before: 🔴 All users see all data (tenant_id ignored)
- After: ✅ Complete data isolation per organization
UX
- Before: Org switcher doesn't work (data unchanged)
- After: ✅ Switching orgs shows that org's data
Default Org Behavior
- Single user: ✅ Never sees organizations, just works
- Team user: ✅ Can switch between orgs seamlessly
- Growth path: ✅ Start alone, invite team later
Performance
- Query impact: Minimal (indexed tenant_id filter)
- Migration: One-time cost to backfill existing data
Naming: tenant_id vs organization_id?
Recommendation: tenant_id
Why:
- ✅ Matches JWT claim name (
tenant_id) - ✅ Standard multi-tenancy terminology
- ✅ Consistent with SSO (session has
activeOrganizationId→ JWT hastenant_id)
Note: ChatKit store already uses organization_id - consider aligning both to tenant_id for consistency.
Related
- Closes organization data isolation requirement
- Depends on: SSO organization switching (feat(organizations): Complete Organization Management UI #19)
- Spec: TBD (needs
/sp.specifyfor multi-tenancy backend)
Questions for Discussion
- Default org creation timing: Signup vs first API request?
- Default org name: "Personal Workspace" or user's name?
- Rename ChatKit
organization_idtotenant_idfor consistency? - Migration strategy: Assign to first org or create default per user?
🤖 Generated with Claude Code
Activity
mjunaidca commentedon Dec 9, 2025
✅ Decisions Made
After discussion, here are the confirmed implementation decisions:
1. Default Organization Creation: During Signup at SSO ✅
When: Automatically during user registration at SSO platform
What: Create "Personal Workspace" organization
Why:
tenant_idImplementation Location: SSO platform signup flow
2. Default Organization Name: "Personal Workspace" ✅
Rationale:
Alternative considered: "{User's Name}'s Workspace" - rejected due to PII concerns and unnecessary complexity
3. ChatKit Alignment: Rename to
tenant_id✅Change: ChatKit's
organization_id→tenant_idWhy:
Updated Implementation Plan
Phase 0: SSO Default Organization (DO THIS FIRST) 🔴
Critical Path: Must be completed before Phase 2
activeOrganizationIdtenant_idon first loginFiles to modify:
sso-platform/src/lib/auth.ts(signup hooks)sso-platform/src/app/api/auth/[...all]/route.ts(registration flow)Success Criteria:
tenant_idappears in JWT claims immediatelyPhase 1: Backend Schema Migration
Only start after Phase 0 is complete (ensures all users have tenant_id)
organization_idtotenant_idfor consistencytenant_idcolumn to Project, Task, Worker, AuditLog modelstenant_idcolumns (critical for query performance)Migration Strategy:
Phase 2: Middleware & Query Filtering
tenant_idfrom JWTtenant_idto request state (request.state.tenant_id)tenant_idtenant_idto audit log entriesKey Middleware:
Phase 3: Security Validation & Testing
tenant_idtenant_idfilteringPhase 4: UX Polish
User Experience Flow
Single User (Default Path)
Growing Team
Enterprise User
Success Metrics
✅ Security: Zero cross-tenant data leaks
✅ UX: Single users never see "organization"
✅ Performance:
tenant_idqueries remain fast (<50ms p95)✅ Migration: All existing users get default org, zero data loss
✅ Growth: Seamless path from solo → team → enterprise
Next Steps
🤖 Updated with Claude Code
mjunaidca commentedon Dec 10, 2025
Simplified Implementation Analysis
After review, multi-tenancy is much simpler than originally scoped:
Changes Required (API only - 4 files)
models/project.pytenant_id: str = Field(index=True)schemas/project.pytenant_idto create/read schemasauth.pytenant_idfrom JWT OR use default"taskflow"routers/projects.py.where(Project.tenant_id == tenant_id)to all queriesWhy Other Components Don't Need Changes
project_id→ inherit tenant boundarySimplified Strategy
Use a default tenant (
"taskflow") for all users initially. This avoids SSO changes and org-switching complexity.Estimated Time: 30-45 minutes
🤖 Analysis by Gemini
Merge add multi tenancy support in taskflow api and fix org switcher …