# Sprint 16: Identity Foundation

> **Size**: L | **Owner**: Backend + Client | **Prerequisites**: Sprint 15
> **Phase**: 5 - Platform Evolution

---

## Implementation Status (2025-12-30)

### ✅ CORE IMPLEMENTATION COMPLETE

| Component | Status | Notes |
|-----------|--------|-------|
| Domain identity module | ✅ Complete | `roles.py`, `context.py`, `entities.py` |
| Identity repository protocols | ✅ Complete | `UserRepository`, `WorkspaceRepository` |
| SQLAlchemy identity repos | ✅ Complete | Full CRUD implementations |
| Identity service | ✅ Complete | Default user/workspace creation |
| gRPC identity interceptor | ✅ Complete | Context propagation via metadata |
| Unit of Work integration | ✅ Complete | `users`, `workspaces` properties |
| Structured logging context | ✅ Complete | Context variables for tracing |
| Workspace-scoped repos | 🔲 Deferred | Not blocking for local-first |
| Proto messages | 🔲 Deferred | Not blocking for local-first |
| Client workspace switcher | 🔲 Deferred | Not blocking for local-first |

---

### ✅ PREREQUISITES VERIFIED

| Asset | Status | Location |
|-------|--------|----------|
| UserModel | ✅ Verified | `persistence/models/identity/identity.py:87-125` |
| WorkspaceModel | ✅ Verified | `persistence/models/identity/identity.py:27-85` |
| WorkspaceMembershipModel | ✅ Verified | `persistence/models/identity/identity.py:127-159` |
| MeetingModel.workspace_id | ✅ Verified | FK to workspaces with RESTRICT delete |
| MeetingModel.created_by_id | ✅ Verified | FK to users with SET NULL delete |
| TaskModel.workspace_id | ✅ Verified | FK to workspaces with CASCADE delete |
| TagModel.workspace_id | ✅ Verified | FK to workspaces with CASCADE delete |
| IntegrationModel.workspace_id | ✅ Verified | FK to workspaces with CASCADE delete |
| Keystore | ✅ Verified | `infrastructure/security/keystore.py` with 3 implementations |

**Ready to implement**: All models exist. Identity service, interceptor, and scope enforcement can proceed.

---

## Objective

Every operation has a "user POV" and tenant boundaries are enforced via a **scope lattice** (workspace → project → resource). This sprint establishes auth context and workspace scoping that all subsequent sprints depend on.

---

## Decision: Local-First

> **Decided**: Local-first — single user per install, implicit workspace, auth optional. Design for multi-user but defer full implementation.

| Aspect | Approach |
|--------|----------|
| **User creation** | Auto-create default "Local User" on first run |
| **Workspace** | Auto-create default "Personal" workspace |
| **Auth** | Optional, not required for local operation |
| **Multi-user prep** | Models exist, enforcement deferred to later sprint |

---

## What Already Exists

| Asset | Location | Status | Implication |
|-------|----------|--------|-------------|
| `UserModel` | `persistence/models/identity/identity.py` | ✅ | User entity exists |
| `WorkspaceModel` | `persistence/models/identity/identity.py` | ✅ | Workspace entity exists |
| `WorkspaceMembershipModel` | `persistence/models/identity/identity.py` | ✅ | Role-based membership exists |
| `MeetingModel.workspace_id` | ORM models | ✅ | Scoping column exists |
| `MeetingModel.created_by_id` | ORM models | ✅ | Creator tracking exists |
| `TaskModel.workspace_id` | ORM models | ✅ | Scoping column exists |
| `TagModel.workspace_id` | ORM models | ✅ | Scoping column exists |
| `IntegrationModel.workspace_id` | ORM models | ✅ | Scoping column exists |
| `Keystore` | `infrastructure/security/keystore.py` | ✅ | Secure storage for tokens |

---

## Scope

| Task | Effort | Builds On | Risk |
|------|--------|-----------|------|
| Auth mechanism (local-first) | M | Keystore | Low |
| "Current user" context in RPC calls | M | New interceptor | Medium |
| Scope lattice (`workspace` → `project`) context | M | New context model | Medium |
| Workspace enforcement in repositories | L | Existing columns | Medium |
| Default workspace creation | S | Existing models | Low |
| Workspace switcher UI | S | New | Low |

---

## Implementation Plan

### Task 1: Local-First Auth Context

**Objective**: Establish user identity without requiring external auth.

**File**: `src/noteflow/domain/identity/context.py`

```python
"""User and workspace context for operations."""

from __future__ import annotations

from dataclasses import dataclass
from uuid import UUID

from noteflow.domain.identity.roles import WorkspaceRole


@dataclass(frozen=True)
class UserContext:
    """Current user context for an operation."""

    user_id: UUID
    display_name: str
    email: str | None = None


@dataclass(frozen=True)
class WorkspaceContext:
    """Current workspace context for an operation."""

    workspace_id: UUID
    workspace_name: str
    role: WorkspaceRole


@dataclass(frozen=True)
class ProjectContext:
    """Optional project context for an operation."""

    project_id: UUID
    project_name: str


@dataclass(frozen=True)
class OperationContext:
    """Full context for an operation."""

    user: UserContext
    workspace: WorkspaceContext
    project: ProjectContext | None = None
    request_id: str | None = None

    @property
    def user_id(self) -> UUID:
        return self.user.user_id

    @property
    def workspace_id(self) -> UUID:
        return self.workspace.workspace_id

    @property
    def project_id(self) -> UUID | None:
        return self.project.project_id if self.project else None

    def can_write(self) -> bool:
        """Check if user can write in current workspace."""
        return self.workspace.role in (WorkspaceRole.OWNER, WorkspaceRole.ADMIN, WorkspaceRole.MEMBER)

    def is_admin(self) -> bool:
        """Check if user is admin/owner of current workspace."""
        return self.workspace.role in (WorkspaceRole.OWNER, WorkspaceRole.ADMIN)
```

**File**: `src/noteflow/domain/identity/roles.py`

```python
"""Workspace roles and permissions."""

from enum import Enum


class WorkspaceRole(Enum):
    """Roles within a workspace."""

    OWNER = "owner"      # Full control, can delete workspace
    ADMIN = "admin"      # Can manage members, settings
    MEMBER = "member"    # Can create/edit meetings
    VIEWER = "viewer"    # Read-only access
```

### Task 2: Context Provider Service

**Objective**: Provide operation context to services.

**File**: `src/noteflow/application/services/identity_service.py`

```python
"""Identity and context management service."""

from __future__ import annotations

from typing import TYPE_CHECKING
from uuid import UUID, uuid4

from noteflow.domain.identity.context import OperationContext, UserContext, WorkspaceContext
from noteflow.domain.identity.roles import WorkspaceRole

if TYPE_CHECKING:
    from noteflow.domain.ports.unit_of_work import UnitOfWork


class IdentityService:
    """Manages user identity and workspace context."""

    async def get_or_create_default_user(
        self,
        uow: UnitOfWork,
    ) -> UserContext:
        """Get or create the default local user.

        For local-first mode, we create a default user on first run.
        """
        # Check for existing default user
        user = await uow.users.get_default()
        if user:
            return UserContext(
                user_id=user.id,
                display_name=user.display_name,
                email=user.email,
            )

        # Create default user
        user_id = uuid4()
        await uow.users.create_default(
            user_id=user_id,
            display_name="Local User",
        )
        await uow.commit()

        return UserContext(
            user_id=user_id,
            display_name="Local User",
        )

    async def get_or_create_default_workspace(
        self,
        uow: UnitOfWork,
        user_id: UUID,
    ) -> WorkspaceContext:
        """Get or create the default workspace for a user.

        For local-first mode, each user has a default "Personal" workspace.
        """
        # Check for existing default workspace
        workspace = await uow.workspaces.get_default_for_user(user_id)
        if workspace:
            membership = await uow.workspaces.get_membership(workspace.id, user_id)
            return WorkspaceContext(
                workspace_id=workspace.id,
                workspace_name=workspace.name,
                role=membership.role if membership else WorkspaceRole.OWNER,
            )

        # Create default workspace
        workspace_id = uuid4()
        await uow.workspaces.create(
            workspace_id=workspace_id,
            name="Personal",
            owner_id=user_id,
        )
        await uow.commit()

        return WorkspaceContext(
            workspace_id=workspace_id,
            workspace_name="Personal",
            role=WorkspaceRole.OWNER,
        )

    async def get_context(
        self,
        uow: UnitOfWork,
        workspace_id: UUID | None = None,
    ) -> OperationContext:
        """Get the full operation context.

        Args:
            uow: Unit of work for database access
            workspace_id: Optional specific workspace, or default

        Returns:
            Full operation context with user and workspace
        """
        user = await self.get_or_create_default_user(uow)

        if workspace_id:
            workspace = await uow.workspaces.get(workspace_id)
            if not workspace:
                raise ValueError(f"Workspace {workspace_id} not found")
            membership = await uow.workspaces.get_membership(workspace_id, user.user_id)
            if not membership:
                raise PermissionError(f"User not a member of workspace {workspace_id}")
            ws_context = WorkspaceContext(
                workspace_id=workspace.id,
                workspace_name=workspace.name,
                role=membership.role,
            )
        else:
            ws_context = await self.get_or_create_default_workspace(uow, user.user_id)

        return OperationContext(user=user, workspace=ws_context)
```

### Task 3: Workspace-Scoped Repository Queries

**Objective**: All repository queries respect workspace boundaries.

**File**: `src/noteflow/infrastructure/persistence/repositories/_scoped.py`

```python
"""Base class for workspace-scoped repositories."""

from __future__ import annotations

from typing import TYPE_CHECKING, TypeVar
from uuid import UUID

from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

if TYPE_CHECKING:
    from sqlalchemy.orm import DeclarativeBase

T = TypeVar("T", bound="DeclarativeBase")


class WorkspaceScopedRepository:
    """Mixin for workspace-scoped repository queries."""

    def __init__(self, session: AsyncSession, workspace_id: UUID | None = None) -> None:
        self._session = session
        self._workspace_id = workspace_id

    def _apply_workspace_filter(self, query, model_class: type[T]):
        """Apply workspace filter to a query if workspace is set."""
        if self._workspace_id is not None and hasattr(model_class, "workspace_id"):
            return query.where(model_class.workspace_id == self._workspace_id)
        return query

    def _set_workspace_on_create(self, model: T) -> T:
        """Set workspace_id on model if applicable."""
        if self._workspace_id is not None and hasattr(model, "workspace_id"):
            model.workspace_id = self._workspace_id
        return model
```

**Update**: `src/noteflow/infrastructure/persistence/repositories/meeting_repo.py`

```python
# Add workspace filtering to list queries
class SqlAlchemyMeetingRepository(WorkspaceScopedRepository, MeetingRepository):

    async def list(
        self,
        limit: int = 50,
        offset: int = 0,
    ) -> list[Meeting]:
        query = select(MeetingModel).order_by(MeetingModel.created_at.desc())
        query = self._apply_workspace_filter(query, MeetingModel)
        query = query.limit(limit).offset(offset)
        result = await self._session.execute(query)
        return [self._to_domain(m) for m in result.scalars()]

    async def create(self, meeting: Meeting) -> Meeting:
        model = self._to_model(meeting)
        model = self._set_workspace_on_create(model)
        # ... rest of create logic
```

### Task 4: gRPC Interceptor for Context

**Objective**: Populate operation context for all RPC calls.

**File**: `src/noteflow/grpc/interceptors/identity.py`

```python
"""Identity interceptor for gRPC calls."""

from __future__ import annotations

from typing import TYPE_CHECKING, Any, Callable

import grpc

from noteflow.infrastructure.logging.structured import (
    request_id_var,
    workspace_id_var,
    user_id_var,
    generate_request_id,
)

if TYPE_CHECKING:
    pass


class IdentityInterceptor(grpc.aio.ServerInterceptor):
    """Interceptor that populates identity context for RPC calls."""

    async def intercept_service(
        self,
        continuation: Callable,
        handler_call_details: grpc.HandlerCallDetails,
    ) -> Any:
        # Generate request ID
        request_id = generate_request_id()
        request_id_var.set(request_id)

        # Extract workspace_id from metadata if present
        metadata = dict(handler_call_details.invocation_metadata or [])
        if workspace_id := metadata.get("x-workspace-id"):
            workspace_id_var.set(workspace_id)
        if user_id := metadata.get("x-user-id"):
            user_id_var.set(user_id)

        return await continuation(handler_call_details)
```

### Task 5: Client Workspace Context

**File**: `client/src/contexts/workspace-context.tsx`

```typescript
import React, { createContext, useContext, useState, useCallback } from 'react';

interface Workspace {
  id: string;
  name: string;
  role: 'owner' | 'admin' | 'member' | 'viewer';
}

interface WorkspaceContextValue {
  currentWorkspace: Workspace | null;
  workspaces: Workspace[];
  switchWorkspace: (workspaceId: string) => Promise<void>;
  isLoading: boolean;
}

const WorkspaceContext = createContext<WorkspaceContextValue | null>(null);

export function WorkspaceProvider({ children }: { children: React.ReactNode }) {
  const [currentWorkspace, setCurrentWorkspace] = useState<Workspace | null>(null);
  const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
  const [isLoading, setIsLoading] = useState(false);

  const switchWorkspace = useCallback(async (workspaceId: string) => {
    setIsLoading(true);
    try {
      const workspace = workspaces.find(w => w.id === workspaceId);
      if (workspace) {
        setCurrentWorkspace(workspace);
        // Store in preferences for persistence
        localStorage.setItem('currentWorkspaceId', workspaceId);
      }
    } finally {
      setIsLoading(false);
    }
  }, [workspaces]);

  return (
    <WorkspaceContext.Provider value={{
      currentWorkspace,
      workspaces,
      switchWorkspace,
      isLoading,
    }}>
      {children}
    </WorkspaceContext.Provider>
  );
}

export function useWorkspace(): WorkspaceContextValue {
  const context = useContext(WorkspaceContext);
  if (!context) {
    throw new Error('useWorkspace must be used within WorkspaceProvider');
  }
  return context;
}
```

**File**: `client/src/components/workspace-switcher.tsx`

```typescript
import { useWorkspace } from '../contexts/workspace-context';
import { ChevronDown, Building2 } from 'lucide-react';
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from './ui/dropdown-menu';
import { Button } from './ui/button';

export function WorkspaceSwitcher() {
  const { currentWorkspace, workspaces, switchWorkspace } = useWorkspace();

  if (workspaces.length <= 1) {
    // Single workspace, no need to show switcher
    return null;
  }

  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button variant="ghost" className="gap-2">
          <Building2 className="w-4 h-4" />
          <span>{currentWorkspace?.name || 'Select Workspace'}</span>
          <ChevronDown className="w-4 h-4" />
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent align="start">
        {workspaces.map((workspace) => (
          <DropdownMenuItem
            key={workspace.id}
            onClick={() => switchWorkspace(workspace.id)}
            className={workspace.id === currentWorkspace?.id ? 'bg-accent' : ''}
          >
            <Building2 className="w-4 h-4 mr-2" />
            {workspace.name}
            <span className="ml-auto text-xs text-muted-foreground">
              {workspace.role}
            </span>
          </DropdownMenuItem>
        ))}
      </DropdownMenuContent>
    </DropdownMenu>
  );
}
```

---

## Proto Additions

```protobuf
// Identity messages (Sprint 16)
message GetCurrentUserRequest {}
message GetCurrentUserResponse {
  string user_id = 1;
  string display_name = 2;
  string email = 3;
}

message ListWorkspacesRequest {}
message ListWorkspacesResponse {
  repeated Workspace workspaces = 1;
}

message Workspace {
  string id = 1;
  string name = 2;
  string role = 3;  // owner, admin, member, viewer
  bool is_default = 4;
}

message SwitchWorkspaceRequest {
  string workspace_id = 1;
}
message SwitchWorkspaceResponse {
  bool success = 1;
  Workspace workspace = 2;
}
```

---

## Acceptance Criteria

### Functional

- [x] Default user created on first run (via `IdentityService.get_or_create_default_user`)
- [x] Default "Personal" workspace created for user (via `IdentityService.get_or_create_default_workspace`)
- [ ] All meeting queries filtered by workspace (deferred - workspace-scoped repos)
- [ ] All task queries filtered by workspace (deferred - workspace-scoped repos)
- [ ] Project-scoped queries respect `OperationContext.project_id` when set (deferred)
- [ ] Workspace switcher visible when multiple workspaces exist (deferred - client work)

### Technical

- [x] Operation context available in all RPC handlers (via `IdentityInterceptor`)
- [x] Correlation IDs propagate through requests (via `request_id_var`, `user_id_var`, `workspace_id_var`)
- [ ] Workspace enforcement cannot be bypassed (deferred - workspace-scoped repos)
- [ ] Scope lattice (`workspace` → `project`) enforced consistently (deferred)

### Quality Gates

- [x] All lint checks pass (ruff)
- [x] All type checks pass (basedpyright)
- [x] All 457 existing tests pass
- [x] Meets `docs/sprints/QUALITY_STANDARDS.md` (lint + test quality thresholds)

---

## Files Created/Modified

### Created (Sprint 16 Implementation)

| File | Status | Purpose |
|------|--------|---------|
| `src/noteflow/domain/identity/__init__.py` | ✅ Created | Module exports |
| `src/noteflow/domain/identity/context.py` | ✅ Created | `UserContext`, `WorkspaceContext`, `OperationContext` |
| `src/noteflow/domain/identity/roles.py` | ✅ Created | `WorkspaceRole` enum with permission checks |
| `src/noteflow/domain/identity/entities.py` | ✅ Created | `User`, `Workspace`, `WorkspaceMembership` entities |
| `src/noteflow/domain/ports/repositories/identity.py` | ✅ Created | `UserRepository`, `WorkspaceRepository` protocols |
| `src/noteflow/infrastructure/persistence/repositories/identity_repo.py` | ✅ Created | SQLAlchemy implementations |
| `src/noteflow/infrastructure/logging/structured.py` | ✅ Created | Context variables for tracing |
| `src/noteflow/application/services/identity_service.py` | ✅ Created | Identity management service |
| `src/noteflow/grpc/interceptors/__init__.py` | ✅ Created | Interceptors module |
| `src/noteflow/grpc/interceptors/identity.py` | ✅ Created | gRPC identity interceptor |

### Modified (Sprint 16 Implementation)

| File | Status | Change |
|------|--------|--------|
| `src/noteflow/domain/ports/repositories/__init__.py` | ✅ Modified | Added identity repository exports |
| `src/noteflow/domain/ports/__init__.py` | ✅ Modified | Added `UserRepository`, `WorkspaceRepository` |
| `src/noteflow/domain/ports/unit_of_work.py` | ✅ Modified | Added `users`, `workspaces` properties |
| `src/noteflow/infrastructure/persistence/repositories/__init__.py` | ✅ Modified | Added SQLAlchemy identity repos |
| `src/noteflow/infrastructure/persistence/unit_of_work.py` | ✅ Modified | Integrated identity repositories |
| `src/noteflow/infrastructure/persistence/memory/repositories.py` | ✅ Modified | Added unsupported identity repos |
| `src/noteflow/infrastructure/persistence/memory/unit_of_work.py` | ✅ Modified | Added identity repo properties |
| `src/noteflow/infrastructure/logging/__init__.py` | ✅ Modified | Exported structured logging |
| `src/noteflow/application/services/__init__.py` | ✅ Modified | Exported `IdentityService` |

### Deferred (Local-First Optimization)

| File | Status | Reason |
|------|--------|--------|
| `src/noteflow/infrastructure/persistence/repositories/_scoped.py` | 🔲 Deferred | Workspace enforcement not needed for single-user |
| `src/noteflow/grpc/interceptors/scope.py` | 🔲 Deferred | Scope lattice enforcement deferred |
| `client/src/contexts/workspace-context.tsx` | 🔲 Deferred | Client-side workspace management deferred |
| `client/src/components/workspace-switcher.tsx` | 🔲 Deferred | UI for multi-workspace deferred |

### Bug Fix (Unrelated)

| File | Status | Change |
|------|--------|--------|
| `src/noteflow/domain/webhooks/events.py` | ✅ Fixed | Fixed `slots=True` inheritance issue with `super().to_dict()` |

---

## Rollback Plan

1. **Identity issues**: Remove interceptor, use implicit single-user
2. **Scoping issues**: Remove workspace filters from queries
3. **UI issues**: Hide workspace switcher

---

## Post-Sprint

- Add workspace creation UI
- Add member invitation flow
- Add workspace settings page
- Prepare for multi-user auth (Sprint 17)
