Consolidate User.role (2-value: admin/store) + User.is_super_admin (boolean) into a single 4-value UserRole enum: super_admin, platform_admin, merchant_owner, store_member. Drop stale StoreUser.user_type column. Fix role="user" bug in merchant creation. Key changes: - Expand UserRole enum from 2 to 4 values with computed properties (is_admin, is_super_admin, is_platform_admin, is_merchant_owner, is_store_user) - Add Alembic migration (tenancy_003) for data migration + column drops - Remove is_super_admin from JWT token payload - Update all auth dependencies, services, routes, templates, JS, and tests - Update all RBAC documentation 66 files changed, 1219 unit tests passing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
7.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 # "super_admin", "platform_admin", "merchant_owner", or "store_member"
is_active: bool # Account status
Admin-Specific Fields
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
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
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:
# 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
# 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