diff --git a/.architecture-rules/auth.yaml b/.architecture-rules/auth.yaml index f9ac6358..3fa76ab6 100644 --- a/.architecture-rules/auth.yaml +++ b/.architecture-rules/auth.yaml @@ -61,6 +61,82 @@ auth_rules: required_patterns: - "require_vendor_context\\(\\)|# public" + - id: "AUTH-005" + name: "Routes must use UserContext, not User model attributes" + severity: "error" + description: | + When using current_user from dependency injection, it is a UserContext + (Pydantic schema), NOT a User (SQLAlchemy model). Do not access: + + FORBIDDEN (SQLAlchemy relationships/columns not in UserContext): + - current_user.admin_platforms → Use accessible_platform_ids + - current_user.vendors → Use token_vendor_id + - current_user.owned_companies → Query via service + - current_user.hashed_password → Never needed in routes + - current_user.created_at → Query User if needed + - current_user.updated_at → Query User if needed + + CORRECT ALTERNATIVES: + - current_user.accessible_platform_ids # list[int] | None + - current_user.token_platform_id # Selected platform from JWT + - current_user.token_vendor_id # Vendor from JWT + - current_user.is_super_admin # Boolean + - current_user.can_access_platform(id) # Helper method + + See: docs/architecture/user-context-pattern.md + pattern: + file_pattern: "app/modules/*/routes/**/*.py" + anti_patterns: + - "current_user\\.admin_platforms" + - "current_user\\.vendors" + - "current_user\\.owned_companies" + - "current_user\\.hashed_password" + + - id: "AUTH-006" + name: "JWT token context fields must be defined in UserContext" + severity: "error" + description: | + When adding new context to JWT tokens, ensure the field is: + + 1. Added to UserContext schema (models/schema/auth.py) + 2. Extracted in verify_token() (middleware/auth.py) + 3. Attached to User in get_current_user() (middleware/auth.py) + 4. Copied in UserContext.from_user() method + + Pattern: token_* prefix for JWT-derived fields + - token_platform_id, token_platform_code (admin platform context) + - token_vendor_id, token_vendor_code, token_vendor_role (vendor context) + + If getattr(current_user, "token_X", None) is needed, the field is missing + from UserContext and should be added. + + See: docs/architecture/user-context-pattern.md + pattern: + file_pattern: "app/modules/*/routes/**/*.py" + anti_patterns: + - "getattr\\(current_user,\\s*['\"]token_" + + - id: "AUTH-007" + name: "Response models must match available UserContext data" + severity: "error" + description: | + When returning user data from endpoints that use UserContext: + + 1. Do NOT return LoginResponse(user=current_user) if LoginResponse.user + expects UserResponse with created_at/updated_at + + 2. Create dedicated response models for different contexts: + - LoginResponse: Full user data (from login, has timestamps) + - PlatformSelectResponse: Token + platform info (no user data) + - TokenRefreshResponse: Just new token data + + 3. If user timestamps are needed, query the User model explicitly + + See: docs/architecture/user-context-pattern.md + pattern: + file_pattern: "app/modules/*/routes/**/*.py" + enforcement: "review" + # ============================================================================ # MULTI-TENANCY RULES # ============================================================================ diff --git a/.architecture-rules/module.yaml b/.architecture-rules/module.yaml index e617330b..5bbb6340 100644 --- a/.architecture-rules/module.yaml +++ b/.architecture-rules/module.yaml @@ -723,3 +723,41 @@ module_rules: file_pattern: "app/modules/*/definition.py" validates: - "router imports -> get_*_with_routers function" + + # ========================================================================= + # Static File Mounting Rules + # ========================================================================= + + - id: "MOD-024" + name: "Module static file mount order - specific paths first" + severity: "error" + description: | + When mounting module static files in main.py, more specific paths must + be mounted BEFORE less specific paths. FastAPI processes mounts in + registration order. + + WRONG ORDER (locales 404): + # Less specific first - intercepts /static/modules/X/locales/* + app.mount("/static/modules/X", StaticFiles(...)) + # More specific second - never reached! + app.mount("/static/modules/X/locales", StaticFiles(...)) + + RIGHT ORDER (locales work): + # More specific first + app.mount("/static/modules/X/locales", StaticFiles(...)) + # Less specific second - catches everything else + app.mount("/static/modules/X", StaticFiles(...)) + + This applies to all nested static file mounts: + - locales/ must be mounted before static/ + - img/ or css/ subdirectories must be mounted before parent + + SYMPTOMS OF WRONG ORDER: + - 404 errors for nested paths like /static/modules/tenancy/locales/en.json + - Requests to subdirectories served as 404 instead of finding files + + See: docs/architecture/user-context-pattern.md (Static File Mount Order section) + pattern: + file_pattern: "main.py" + validates: + - "module_locales mount BEFORE module_static mount" diff --git a/docs/architecture/user-context-pattern.md b/docs/architecture/user-context-pattern.md new file mode 100644 index 00000000..259201f0 --- /dev/null +++ b/docs/architecture/user-context-pattern.md @@ -0,0 +1,231 @@ +# 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 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 +```python +id: int # User ID +email: str # Email address +username: str # Username +role: str # "admin" or "vendor" +is_active: bool # Account status +``` + +### Admin-Specific Fields +```python +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 +```python +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 +```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` | +| `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: + +```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 +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 + +```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 diff --git a/mkdocs.yml b/mkdocs.yml index 3cd7daab..2a2546ff 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -44,6 +44,7 @@ nav: - Observability: architecture/observability.md - Request Flow: architecture/request-flow.md - Authentication & RBAC: architecture/auth-rbac.md + - UserContext Pattern: architecture/user-context-pattern.md - Frontend Structure: architecture/frontend-structure.md - Models Structure: architecture/models-structure.md - Background Tasks: architecture/background-tasks.md