Files
orion/docs/architecture/user-context-pattern.md
Samir Boulahtit 1dcb0e6c33
Some checks failed
CI / ruff (push) Successful in 11s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
feat: RBAC Phase 1 — consolidate user roles into 4-value enum
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>
2026-02-19 22:44:29 +01:00

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:

  1. Add field to UserContext (models/schema/auth.py):

    token_new_field: str | None = None
    
  2. Add to JWT payload (middleware/auth.py - create_access_token):

    if new_field is not None:
        payload["new_field"] = new_field
    
  3. Extract from JWT (middleware/auth.py - verify_token):

    if "new_field" in payload:
        user_data["new_field"] = payload["new_field"]
    
  4. Attach to User object (middleware/auth.py - get_current_user):

    if "new_field" in user_data:
        user.token_new_field = user_data["new_field"]
    
  5. Copy in from_user() (models/schema/auth.py):

    data["token_new_field"] = getattr(user, "token_new_field", None)
    

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