Files
orion/docs/backend/vendor-in-token-architecture.md
Samir Boulahtit 81bfc49f77 refactor: enforce strict architecture rules and add Pydantic response models
- Update architecture rules to be stricter (API-003 now blocks ALL exception
  raising in endpoints, not just HTTPException)
- Update get_current_vendor_api dependency to guarantee token_vendor_id presence
- Remove redundant _get_vendor_from_token helpers from all vendor API files
- Move vendor access validation to service layer methods
- Add Pydantic response models for media, notification, and payment endpoints
- Add get_active_vendor_by_code service method for public vendor lookup
- Add get_import_job_for_vendor service method with vendor validation
- Update validation script to detect exception raising patterns in endpoints

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 23:26:03 +01:00

23 KiB

Vendor-in-Token Architecture

Overview

This document describes the vendor-in-token authentication architecture used for vendor API endpoints. This architecture embeds vendor context directly into JWT tokens, eliminating the need for URL-based vendor detection and enabling clean, RESTful API endpoints.

The Problem: URL-Based Vendor Detection

Old Pattern (Deprecated)

# ❌ DEPRECATED: URL-based vendor detection
@router.get("/{product_id}")
def get_product(
    product_id: int,
    vendor: Vendor = Depends(require_vendor_context()),  # ❌ Don't use
    current_user: User = Depends(get_current_vendor_api),
    db: Session = Depends(get_db),
):
    product = product_service.get_product(db, vendor.id, product_id)
    return product

Issues with URL-Based Detection

  1. Inconsistent API Routes

    • Page routes: /vendor/{vendor_code}/dashboard (has vendor in URL)
    • API routes: /api/v1/vendor/products (no vendor in URL)
    • require_vendor_context() only works when vendor is in the URL path
  2. 404 Errors on API Endpoints

    • API calls to /api/v1/vendor/products would return 404
    • The dependency expected vendor code in URL but API routes don't have it
    • Breaking RESTful API design principles
  3. Architecture Violation

    • Mixed concerns: URL structure determining business logic
    • Tight coupling between routing and vendor context
    • Harder to test and maintain

The Solution: Vendor-in-Token

Architecture Overview

┌─────────────────────────────────────────────────────────────────┐
│                     Vendor Login Flow                           │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ↓
┌─────────────────────────────────────────────────────────────────┐
│  1. Authenticate user credentials                               │
│  2. Validate vendor membership                                  │
│  3. Create JWT with vendor context:                             │
│     {                                                            │
│       "sub": "user_id",                                         │
│       "username": "john.doe",                                   │
│       "vendor_id": 123,          ← Vendor context in token     │
│       "vendor_code": "WIZAMART",  ← Vendor code in token       │
│       "vendor_role": "Owner"      ← Vendor role in token       │
│     }                                                            │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ↓
┌─────────────────────────────────────────────────────────────────┐
│  4. Set dual token storage:                                     │
│     - HTTP-only cookie (path=/vendor) for page navigation      │
│     - Response body for localStorage (API calls)               │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ↓
┌─────────────────────────────────────────────────────────────────┐
│  5. Subsequent API requests include vendor context             │
│     Authorization: Bearer <token-with-vendor-context>          │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ↓
┌─────────────────────────────────────────────────────────────────┐
│  6. get_current_vendor_api() extracts vendor from token:       │
│     - current_user.token_vendor_id                              │
│     - current_user.token_vendor_code                            │
│     - current_user.token_vendor_role                            │
│  7. Validates user still has access to vendor                  │
└─────────────────────────────────────────────────────────────────┘

Implementation Components

1. Token Creation (middleware/auth.py)

def create_access_token(
    self,
    user: User,
    vendor_id: int | None = None,
    vendor_code: str | None = None,
    vendor_role: str | None = None,
) -> dict[str, Any]:
    """Create JWT with optional vendor context."""
    payload = {
        "sub": str(user.id),
        "username": user.username,
        "email": user.email,
        "role": user.role,
        "exp": expire,
        "iat": datetime.now(UTC),
    }

    # Include vendor information in token if provided
    if vendor_id is not None:
        payload["vendor_id"] = vendor_id
    if vendor_code is not None:
        payload["vendor_code"] = vendor_code
    if vendor_role is not None:
        payload["vendor_role"] = vendor_role

    return {
        "access_token": jwt.encode(payload, self.secret_key, algorithm=self.algorithm),
        "token_type": "bearer",
        "expires_in": self.access_token_expire_minutes * 60,
    }

2. Vendor Login (app/api/v1/vendor/auth.py)

@router.post("/login", response_model=VendorLoginResponse)
def vendor_login(
    user_credentials: UserLogin,
    response: Response,
    db: Session = Depends(get_db),
):
    """
    Vendor team member login.

    Creates vendor-scoped JWT token with vendor context embedded.
    """
    # Authenticate user and determine vendor
    login_result = auth_service.login_user(db=db, user_credentials=user_credentials)
    user = login_result["user"]

    # Determine vendor and role
    vendor = determine_vendor(db, user)  # Your vendor detection logic
    vendor_role = determine_role(db, user, vendor)  # Your role detection logic

    # Create vendor-scoped access token
    token_data = auth_service.auth_manager.create_access_token(
        user=user,
        vendor_id=vendor.id,
        vendor_code=vendor.vendor_code,
        vendor_role=vendor_role,
    )

    # Set cookie and return token
    response.set_cookie(
        key="vendor_token",
        value=token_data["access_token"],
        httponly=True,
        path="/vendor",  # Restricted to vendor routes
    )

    return VendorLoginResponse(**token_data, user=user, vendor=vendor)

3. Token Verification (app/api/deps.py)

def get_current_vendor_api(
    authorization: str | None = Header(None, alias="Authorization"),
    db: Session = Depends(get_db),
) -> User:
    """
    Get current vendor API user from Authorization header.

    Extracts vendor context from JWT token and validates access.
    """
    if not authorization or not authorization.startswith("Bearer "):
        raise AuthenticationException("Authorization header required for API calls")

    token = authorization.replace("Bearer ", "")
    user = auth_service.auth_manager.get_current_user(token, db)

    # Validate vendor access if token is vendor-scoped
    if hasattr(user, "token_vendor_id"):
        vendor_id = user.token_vendor_id

        # Verify user still has access to this vendor
        if not user.is_member_of(vendor_id):
            raise InsufficientPermissionsException(
                "Access to vendor has been revoked. Please login again."
            )

    return user

4. Endpoint Usage (app/api/v1/vendor/products.py)

@router.get("", response_model=ProductListResponse)
def get_vendor_products(
    skip: int = Query(0, ge=0),
    limit: int = Query(100, ge=1, le=1000),
    current_user: User = Depends(get_current_vendor_api),  # ✅ Guarantees token_vendor_id
    db: Session = Depends(get_db),
):
    """
    Get all products in vendor catalog.

    Vendor is determined from JWT token (vendor_id claim).
    The get_current_vendor_api dependency GUARANTEES token_vendor_id is present.
    """
    # Use vendor_id from token for business logic
    # NO validation needed - dependency guarantees token_vendor_id exists
    products, total = product_service.get_vendor_products(
        db=db,
        vendor_id=current_user.token_vendor_id,  # Safe to use directly
        skip=skip,
        limit=limit,
    )

    return ProductListResponse(products=products, total=total)

Important

: The get_current_vendor_api() dependency now guarantees that token_vendor_id is present. Endpoints should NOT check for its existence - this would be redundant validation that belongs in the dependency layer.

Migration Guide

Step 1: Identify Endpoints Using require_vendor_context()

Search for all occurrences:

grep -r "require_vendor_context" app/api/v1/vendor/

Step 2: Update Endpoint Signature

Before:

@router.get("/{product_id}")
def get_product(
    product_id: int,
    vendor: Vendor = Depends(require_vendor_context()),  # ❌ Remove this
    current_user: User = Depends(get_current_vendor_api),
    db: Session = Depends(get_db),
):

After:

@router.get("/{product_id}")
def get_product(
    product_id: int,
    current_user: User = Depends(get_current_vendor_api),  # ✅ Only need this
    db: Session = Depends(get_db),
):

Step 3: Extract Vendor from Token

Before:

product = product_service.get_product(db, vendor.id, product_id)

After:

# Use vendor_id from token directly - dependency guarantees it exists
product = product_service.get_product(db, current_user.token_vendor_id, product_id)

Note

: Do NOT add validation like if not hasattr(current_user, "token_vendor_id"). The get_current_vendor_api dependency guarantees this attribute is present. Adding such checks violates the architecture rule API-003 (endpoints should not raise exceptions).

Step 4: Update Logging References

Before:

logger.info(f"Product updated for vendor {vendor.vendor_code}")

After:

logger.info(f"Product updated for vendor {current_user.token_vendor_code}")

Complete Migration Example

Before (URL-based vendor detection):

@router.put("/{product_id}", response_model=ProductResponse)
def update_product(
    product_id: int,
    product_data: ProductUpdate,
    vendor: Vendor = Depends(require_vendor_context()),  # ❌
    current_user: User = Depends(get_current_vendor_api),
    db: Session = Depends(get_db),
):
    """Update product in vendor catalog."""
    product = product_service.update_product(
        db=db,
        vendor_id=vendor.id,  # ❌ From URL
        product_id=product_id,
        product_update=product_data
    )

    logger.info(
        f"Product {product_id} updated by {current_user.username} "
        f"for vendor {vendor.vendor_code}"  # ❌ From URL
    )

    return ProductResponse.model_validate(product)

After (Token-based vendor context):

@router.put("/{product_id}", response_model=ProductResponse)
def update_product(
    product_id: int,
    product_data: ProductUpdate,
    current_user: User = Depends(get_current_vendor_api),  # ✅ Guarantees token_vendor_id
    db: Session = Depends(get_db),
):
    """Update product in vendor catalog."""
    # NO validation needed - dependency guarantees token_vendor_id exists
    product = product_service.update_product(
        db=db,
        vendor_id=current_user.token_vendor_id,  # ✅ From token - safe to use directly
        product_id=product_id,
        product_update=product_data
    )

    logger.info(
        f"Product {product_id} updated by {current_user.username} "
        f"for vendor {current_user.token_vendor_code}"  # ✅ From token
    )

    return ProductResponse.model_validate(product)

Architecture Rule API-003: Endpoints should NOT raise exceptions. The get_current_vendor_api dependency handles all validation and raises InvalidTokenException if token_vendor_id is missing.

Migration Status

COMPLETED - All vendor API endpoints have been migrated to use the token-based vendor context pattern.

Migrated Files

All vendor API files now use current_user.token_vendor_id:

  • app/api/v1/vendor/customers.py
  • app/api/v1/vendor/notifications.py
  • app/api/v1/vendor/media.py
  • app/api/v1/vendor/marketplace.py
  • app/api/v1/vendor/inventory.py
  • app/api/v1/vendor/settings.py
  • app/api/v1/vendor/analytics.py
  • app/api/v1/vendor/payments.py
  • app/api/v1/vendor/profile.py
  • app/api/v1/vendor/dashboard.py
  • app/api/v1/vendor/products.py
  • app/api/v1/vendor/orders.py
  • app/api/v1/vendor/team.py (uses permission dependencies)

Permission Dependencies Updated

The following permission dependencies now use token-based vendor context:

  • require_vendor_permission() - Gets vendor from token, sets request.state.vendor
  • require_vendor_owner - Gets vendor from token, sets request.state.vendor
  • require_any_vendor_permission() - Gets vendor from token, sets request.state.vendor
  • require_all_vendor_permissions() - Gets vendor from token, sets request.state.vendor
  • get_user_permissions - Gets vendor from token, sets request.state.vendor

Shop Endpoints

Shop endpoints (public, no authentication) still use require_vendor_context():

  • app/api/v1/shop/products.py - Uses URL/subdomain/domain detection
  • app/api/v1/shop/cart.py - Uses URL/subdomain/domain detection

This is correct behavior - shop endpoints need to detect vendor from the request URL, not from JWT token.

Benefits of Vendor-in-Token

1. Clean RESTful APIs

✅ /api/v1/vendor/products
✅ /api/v1/vendor/orders
✅ /api/v1/vendor/customers

❌ /api/v1/vendor/{vendor_code}/products  (unnecessary vendor in URL)

2. Security

  • Vendor context cryptographically signed in JWT
  • Cannot be tampered with by client
  • Automatic validation on every request
  • Token revocation possible via database checks

3. Consistency

  • Same authentication mechanism for all vendor API endpoints
  • No confusion between page routes and API routes
  • Single source of truth (the token)

4. Performance

  • No database lookup for vendor context on every request
  • Vendor information already in token payload
  • Optional validation for revoked access

5. Maintainability

  • Simpler endpoint signatures
  • Less boilerplate code
  • Easier to test
  • Follows architecture rule API-002 (no DB queries in endpoints)

Security Considerations

Token Validation

The token vendor context is validated on every request:

  1. JWT signature verification (ensures token not tampered with)
  2. Token expiration check (typically 30 minutes)
  3. Optional: Verify user still member of vendor (database check)

Access Revocation

If a user's vendor access is revoked:

  1. Existing tokens remain valid until expiration
  2. get_current_vendor_api() performs optional database check
  3. User forced to re-login after token expires
  4. New login will fail if access revoked

Token Refresh

Tokens should be refreshed periodically:

  • Default: 30 minutes expiration
  • Refresh before expiration for seamless UX
  • New login creates new token with current vendor membership

Testing

Unit Tests

def test_vendor_in_token():
    """Test vendor context in JWT token."""
    # Create token with vendor context
    token_data = auth_manager.create_access_token(
        user=user,
        vendor_id=123,
        vendor_code="WIZAMART",
        vendor_role="Owner",
    )

    # Verify token contains vendor data
    payload = jwt.decode(token_data["access_token"], secret_key)
    assert payload["vendor_id"] == 123
    assert payload["vendor_code"] == "WIZAMART"
    assert payload["vendor_role"] == "Owner"

def test_api_endpoint_uses_token_vendor():
    """Test API endpoint extracts vendor from token."""
    response = client.get(
        "/api/v1/vendor/products",
        headers={"Authorization": f"Bearer {token}"}
    )
    assert response.status_code == 200
    # Verify products are filtered by token vendor_id

Integration Tests

def test_vendor_login_and_api_access():
    """Test full vendor login and API access flow."""
    # Login as vendor user
    response = client.post("/api/v1/vendor/auth/login", json={
        "username": "john.doe",
        "password": "password123"
    })
    assert response.status_code == 200
    token = response.json()["access_token"]

    # Access vendor API with token
    response = client.get(
        "/api/v1/vendor/products",
        headers={"Authorization": f"Bearer {token}"}
    )
    assert response.status_code == 200

    # Verify vendor context from token
    products = response.json()["products"]
    # All products should belong to token vendor

Architecture Rules and Design Pattern Enforcement

The Layered Exception Pattern

The architecture enforces a strict layered pattern for where exceptions should be raised:

┌────────────────────────────────────────────────────────────────────────────┐
│  ENDPOINTS (Thin Layer) - app/api/v1/**/*.py                               │
│                                                                            │
│  ❌ MUST NOT raise exceptions                                              │
│  ❌ MUST NOT check hasattr(current_user, 'token_vendor_id')                │
│  ✅ MUST trust dependencies to handle validation                           │
│  ✅ MUST directly use current_user.token_vendor_id                         │
└────────────────────────────────────────────────────────────────────────────┘
                                    │
                                    ▼
┌────────────────────────────────────────────────────────────────────────────┐
│  DEPENDENCIES (Validation Layer) - app/api/deps.py                         │
│                                                                            │
│  ✅ MUST raise InvalidTokenException if token_vendor_id missing            │
│  ✅ MUST validate user still has vendor access                             │
│  ✅ GUARANTEES token_vendor_id, token_vendor_code, token_vendor_role       │
└────────────────────────────────────────────────────────────────────────────┘
                                    │
                                    ▼
┌────────────────────────────────────────────────────────────────────────────┐
│  SERVICES (Business Logic) - app/services/**/*.py                          │
│                                                                            │
│  ✅ MUST raise domain exceptions for business rule violations              │
│  ✅ Examples: VendorNotFoundException, ProductNotFoundException            │
└────────────────────────────────────────────────────────────────────────────┘
                                    │
                                    ▼
┌────────────────────────────────────────────────────────────────────────────┐
│  GLOBAL EXCEPTION HANDLER - app/exceptions/handler.py                      │
│                                                                            │
│  ✅ Catches all WizamartException subclasses                               │
│  ✅ Converts to appropriate HTTP responses                                 │
│  ✅ Provides consistent error formatting                                   │
└────────────────────────────────────────────────────────────────────────────┘

Enforced by Architecture Validation

The validation script (scripts/validate_architecture.py) enforces these rules:

Rule API-003: Endpoints must NOT raise exceptions directly

  • Detects raise HTTPException, raise InvalidTokenException, etc. in endpoint files
  • Detects redundant validation like if not hasattr(current_user, 'token_vendor_id')
  • Blocks commits via pre-commit hook if violations found

Pre-commit Hook

Architecture validation runs on every commit:

# .pre-commit-config.yaml
- repo: local
  hooks:
    - id: validate-architecture
      name: Validate Architecture Patterns
      entry: python scripts/validate_architecture.py
      language: python
      pass_filenames: false
      always_run: true

To run manually:

python scripts/validate_architecture.py  # Full validation
python scripts/validate_architecture.py -d app/api/v1/vendor/  # Specific directory

See .architecture-rules.yaml for the complete rule definitions.

Summary

The vendor-in-token architecture:

  • Embeds vendor context in JWT tokens
  • Eliminates URL-based vendor detection
  • Enables clean RESTful API endpoints
  • Improves security and performance
  • Simplifies endpoint implementation
  • Follows architecture best practices

Migration Status: COMPLETED - All vendor API endpoints migrated and architecture rules enforced