- 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>
577 lines
23 KiB
Markdown
577 lines
23 KiB
Markdown
# 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)
|
|
```python
|
|
# ❌ 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)
|
|
```python
|
|
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)
|
|
```python
|
|
@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)
|
|
```python
|
|
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)
|
|
```python
|
|
@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:
|
|
```bash
|
|
grep -r "require_vendor_context" app/api/v1/vendor/
|
|
```
|
|
|
|
### Step 2: Update Endpoint Signature
|
|
|
|
**Before:**
|
|
```python
|
|
@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:**
|
|
```python
|
|
@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:**
|
|
```python
|
|
product = product_service.get_product(db, vendor.id, product_id)
|
|
```
|
|
|
|
**After:**
|
|
```python
|
|
# 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:**
|
|
```python
|
|
logger.info(f"Product updated for vendor {vendor.vendor_code}")
|
|
```
|
|
|
|
**After:**
|
|
```python
|
|
logger.info(f"Product updated for vendor {current_user.token_vendor_code}")
|
|
```
|
|
|
|
### Complete Migration Example
|
|
|
|
**Before (URL-based vendor detection):**
|
|
```python
|
|
@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):**
|
|
```python
|
|
@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
|
|
```python
|
|
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
|
|
```python
|
|
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:
|
|
|
|
```yaml
|
|
# .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:
|
|
```bash
|
|
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.
|
|
|
|
## Related Documentation
|
|
|
|
- [Vendor RBAC System](./vendor-rbac.md) - Role-based access control for vendors
|
|
- [Authentication & RBAC](../architecture/auth-rbac.md) - Complete authentication guide
|
|
- [Architecture Patterns](../architecture/architecture-patterns.md) - All architecture patterns
|
|
- [Middleware Reference](./middleware-reference.md) - Middleware patterns
|
|
|
|
## 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
|