# UserContext Pattern This document describes the `UserContext` pattern used for dependency injection in API routes, and the important distinction between `UserContext` (Pydantic schema) and `User` (SQLAlchemy model). ## Overview The platform uses two different representations of users: | Type | Purpose | Location | Contains | |------|---------|----------|----------| | `User` | Database model | `app/modules/tenancy/models/user.py` | All DB columns + relationships | | `UserContext` | API dependency injection | `models/schema/auth.py` | Subset of fields + JWT token context | ## Why UserContext? Routes should not import database models directly (architecture rule API-007). Instead, they receive user context through dependency injection: ```python # CORRECT: Use UserContext from dependencies from app.modules.tenancy.schemas.auth import UserContext @router.get("/endpoint") def my_endpoint(current_user: UserContext = Depends(get_current_admin_api)): # current_user is a Pydantic schema, not a SQLAlchemy model pass # WRONG: Don't import User model in routes from app.modules.tenancy.models import User # noqa: API-007 violation ``` ## UserContext Fields ### Core User Fields ```python id: int # User ID email: str # Email address username: str # Username role: str # "super_admin", "platform_admin", "merchant_owner", or "store_member" is_active: bool # Account status ``` ### Admin-Specific Fields ```python is_super_admin: bool # Computed: role == "super_admin" (not stored in DB or JWT) accessible_platform_ids: list[int] | None # Platform IDs (None = all for super admin) token_platform_id: int | None # Selected platform from JWT token_platform_code: str | None # Selected platform code from JWT ``` **Note**: `is_super_admin` is no longer a database column or JWT claim. It is derived from `role == "super_admin"`. On the `User` model it is a computed property; on `UserContext` it is populated from the role field during `from_user()` construction. ### Store-Specific Fields ```python token_store_id: int | None # Store ID from JWT token_store_code: str | None # Store code from JWT token_store_role: str | None # Role in store (owner, manager, etc.) ``` ### Profile Fields ```python first_name: str | None last_name: str | None preferred_language: str | None ``` ## What UserContext Does NOT Have `UserContext` is intentionally limited. It does NOT have: | Missing | Why | Alternative | |---------|-----|-------------| | `admin_platforms` | SQLAlchemy relationship | Use `accessible_platform_ids` | | `stores` | SQLAlchemy relationship | Use `token_store_id` | | `owned_merchants` | SQLAlchemy relationship | Query via service | | `hashed_password` | Security - never expose | N/A | | `created_at` / `updated_at` | Not needed in most routes | Query User if needed | ## Authentication Flow ### Platform Admin Login Flow ``` 1. POST /api/v1/admin/auth/login - Returns LoginResponse with user data and token - Token includes: user_id, role (e.g. "super_admin" or "platform_admin"), accessible_platforms - Note: is_super_admin is NOT in the JWT; derive from role == "super_admin" 2. GET /api/v1/admin/auth/accessible-platforms - Returns list of platforms admin can access 3. POST /api/v1/admin/auth/select-platform - Platform admin selects a platform - Returns PlatformSelectResponse with NEW token - New token includes: platform_id, platform_code 4. Subsequent API calls - Token decoded → UserContext populated - current_user.token_platform_id available - current_user.is_super_admin derived from role ``` ### JWT Token → UserContext Mapping When a JWT token is decoded, these fields are mapped: | JWT Claim | UserContext Field | Notes | |-----------|-------------------|-------| | `sub` | `id` | | | `username` | `username` | | | `email` | `email` | | | `role` | `role` | 4-value enum: `super_admin`, `platform_admin`, `merchant_owner`, `store_member` | | *(derived from role)* | `is_super_admin` | Computed: `role == "super_admin"` (no longer a JWT claim) | | `accessible_platforms` | `accessible_platform_ids` | | | `platform_id` | `token_platform_id` | | | `platform_code` | `token_platform_code` | | | `store_id` | `token_store_id` | | | `store_code` | `token_store_code` | | | `store_role` | `token_store_role` | | ## Helper Methods `UserContext` provides helper methods and computed properties: ```python # Check platform access if current_user.can_access_platform(platform_id): ... # Get accessible platforms platform_ids = current_user.get_accessible_platform_ids() # Returns None for super admins (all platforms) # Returns list[int] for platform admins # Check role categories (computed from role field) if current_user.is_admin: # role in ("super_admin", "platform_admin") ... if current_user.is_super_admin: # role == "super_admin" ... if current_user.is_platform_admin: # role == "platform_admin" ... if current_user.is_merchant_owner: # role == "merchant_owner" ... if current_user.is_store_user: # role in ("merchant_owner", "store_member") ... # Full name name = current_user.full_name # First + Last, or username ``` ## Common Mistakes to Avoid ### 1. Accessing SQLAlchemy Relationships ```python # WRONG - admin_platforms is a SQLAlchemy relationship if current_user.admin_platforms: platform_id = current_user.admin_platforms[0].id # CORRECT - use accessible_platform_ids if current_user.accessible_platform_ids: platform_id = current_user.accessible_platform_ids[0] ``` ### 2. Using User Model Attributes ```python # WRONG - created_at exists on User, not UserContext return {"created": current_user.created_at} # CORRECT - if you need timestamps, query the User user = db.query(User).filter(User.id == current_user.id).first() return {"created": user.created_at} ``` ### 3. Using getattr for Token Fields ```python # WRONG - use getattr only for optional fallback platform_id = getattr(current_user, "token_platform_id", None) # CORRECT - use the field directly (it's defined on UserContext) platform_id = current_user.token_platform_id ``` ### 4. Response Models Requiring Unavailable Fields ```python # WRONG - LoginResponse.user expects UserResponse with created_at return LoginResponse(user=current_user) # ValidationError! # CORRECT - use a response model that matches available data return PlatformSelectResponse( access_token=token, platform_id=platform.id, platform_code=platform.code, ) ``` ## Adding New Fields to UserContext When adding JWT token context that should be available in routes: 1. **Add field to UserContext** (`models/schema/auth.py`): ```python token_new_field: str | None = None ``` 2. **Add to JWT payload** (`middleware/auth.py` - `create_access_token`): ```python if new_field is not None: payload["new_field"] = new_field ``` 3. **Extract from JWT** (`middleware/auth.py` - `verify_token`): ```python if "new_field" in payload: user_data["new_field"] = payload["new_field"] ``` 4. **Attach to User object** (`middleware/auth.py` - `get_current_user`): ```python if "new_field" in user_data: user.token_new_field = user_data["new_field"] ``` 5. **Copy in from_user()** (`models/schema/auth.py`): ```python data["token_new_field"] = getattr(user, "token_new_field", None) ``` ## Related Documentation - [Authentication & RBAC](auth-rbac.md) - Complete auth guide - [Middleware Reference](middleware.md) - Request processing ## Related Architecture Rules See `.architecture-rules/auth.yaml` for: - AUTH-005: Routes must use UserContext, not User model attributes - AUTH-006: JWT token context fields must be defined in UserContext - AUTH-007: Response models must match available UserContext data