Documentation: - docs/architecture/user-context-pattern.md: Comprehensive guide on UserContext vs User model, JWT token mapping, common mistakes Architecture Rules (auth.yaml): - 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 Architecture Rules (module.yaml): - MOD-024: Module static file mount order - specific paths first These rules prevent issues like: - Accessing SQLAlchemy relationships on Pydantic schemas - Missing token fields causing fallback warnings - Response model validation errors from missing timestamps - 404 errors for module locale files due to mount order Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
6.7 KiB
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:
# CORRECT: Use UserContext from dependencies
from models.schema.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
id: int # User ID
email: str # Email address
username: str # Username
role: str # "admin" or "vendor"
is_active: bool # Account status
Admin-Specific Fields
is_super_admin: bool # True for super admins
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
Vendor-Specific Fields
token_vendor_id: int | None # Vendor ID from JWT
token_vendor_code: str | None # Vendor code from JWT
token_vendor_role: str | None # Role in vendor (owner, manager, etc.)
Profile Fields
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 |
vendors |
SQLAlchemy relationship | Use token_vendor_id |
owned_companies |
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, is_super_admin, accessible_platforms
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
JWT Token → UserContext Mapping
When a JWT token is decoded, these fields are mapped:
| JWT Claim | UserContext Field |
|---|---|
sub |
id |
username |
username |
email |
email |
role |
role |
is_super_admin |
is_super_admin |
accessible_platforms |
accessible_platform_ids |
platform_id |
token_platform_id |
platform_code |
token_platform_code |
vendor_id |
token_vendor_id |
vendor_code |
token_vendor_code |
vendor_role |
token_vendor_role |
Helper Methods
UserContext provides helper methods:
# 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
if current_user.is_admin:
...
if current_user.is_vendor:
...
# Full name
name = current_user.full_name # First + Last, or username
Common Mistakes to Avoid
1. Accessing SQLAlchemy Relationships
# 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
# 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
# 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
# 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:
-
Add field to UserContext (
models/schema/auth.py):token_new_field: str | None = None -
Add to JWT payload (
middleware/auth.py-create_access_token):if new_field is not None: payload["new_field"] = new_field -
Extract from JWT (
middleware/auth.py-verify_token):if "new_field" in payload: user_data["new_field"] = payload["new_field"] -
Attach to User object (
middleware/auth.py-get_current_user):if "new_field" in user_data: user.token_new_field = user_data["new_field"] -
Copy in from_user() (
models/schema/auth.py):data["token_new_field"] = getattr(user, "token_new_field", None)
Related Documentation
- Authentication & RBAC - Complete auth guide
- Middleware Reference - 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