Skip to content

feat(backend): Add multi-tenancy with tenant_id scoping and default organization #20

@mjunaidca

Description

@mjunaidca

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:

  • project table
  • task table
  • worker table
  • audit_log table
# 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:

  1. First API request - If user has no tenant_id in 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)
  2. 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 /workers endpoints
  • All POST, PUT, DELETE operations (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_id column to Project, Task, Worker, AuditLog models
  • Create migration script (Alembic)
  • Backfill existing data with default org per user
  • Add indexes on tenant_id columns

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 has tenant_id)

Note: ChatKit store already uses organization_id - consider aligning both to tenant_id for consistency.

Related

Questions for Discussion

  1. Default org creation timing: Signup vs first API request?
  2. Default org name: "Personal Workspace" or user's name?
  3. Rename ChatKit organization_id to tenant_id for consistency?
  4. Migration strategy: Assign to first org or create default per user?

🤖 Generated with Claude Code

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions