Replace all ~1,086 occurrences of Wizamart/wizamart/WIZAMART/WizaMart with Orion/orion/ORION across 184 files. This includes database identifiers, email addresses, domain references, R2 bucket names, DNS prefixes, encryption salt, Celery app name, config defaults, Docker configs, CI configs, documentation, seed data, and templates. Renames homepage-wizamart.html template to homepage-orion.html. Fixes duplicate file_pattern key in api.yaml architecture rule. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
577 lines
23 KiB
Markdown
577 lines
23 KiB
Markdown
# Store-in-Token Architecture
|
|
|
|
## Overview
|
|
|
|
This document describes the store-in-token authentication architecture used for store API endpoints. This architecture embeds store context directly into JWT tokens, eliminating the need for URL-based store detection and enabling clean, RESTful API endpoints.
|
|
|
|
## The Problem: URL-Based Store Detection
|
|
|
|
### Old Pattern (Deprecated)
|
|
```python
|
|
# ❌ DEPRECATED: URL-based store detection
|
|
@router.get("/{product_id}")
|
|
def get_product(
|
|
product_id: int,
|
|
store: Store = Depends(require_store_context()), # ❌ Don't use
|
|
current_user: User = Depends(get_current_store_api),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
product = product_service.get_product(db, store.id, product_id)
|
|
return product
|
|
```
|
|
|
|
### Issues with URL-Based Detection
|
|
|
|
1. **Inconsistent API Routes**
|
|
- Page routes: `/store/{store_code}/dashboard` (has store in URL)
|
|
- API routes: `/api/v1/store/products` (no store in URL)
|
|
- `require_store_context()` only works when store is in the URL path
|
|
|
|
2. **404 Errors on API Endpoints**
|
|
- API calls to `/api/v1/store/products` would return 404
|
|
- The dependency expected store 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 store context
|
|
- Harder to test and maintain
|
|
|
|
## The Solution: Store-in-Token
|
|
|
|
### Architecture Overview
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────────────┐
|
|
│ Store Login Flow │
|
|
└─────────────────────────────────────────────────────────────────┘
|
|
│
|
|
↓
|
|
┌─────────────────────────────────────────────────────────────────┐
|
|
│ 1. Authenticate user credentials │
|
|
│ 2. Validate store membership │
|
|
│ 3. Create JWT with store context: │
|
|
│ { │
|
|
│ "sub": "user_id", │
|
|
│ "username": "john.doe", │
|
|
│ "store_id": 123, ← Store context in token │
|
|
│ "store_code": "ORION", ← Store code in token │
|
|
│ "store_role": "Owner" ← Store role in token │
|
|
│ } │
|
|
└─────────────────────────────────────────────────────────────────┘
|
|
│
|
|
↓
|
|
┌─────────────────────────────────────────────────────────────────┐
|
|
│ 4. Set dual token storage: │
|
|
│ - HTTP-only cookie (path=/store) for page navigation │
|
|
│ - Response body for localStorage (API calls) │
|
|
└─────────────────────────────────────────────────────────────────┘
|
|
│
|
|
↓
|
|
┌─────────────────────────────────────────────────────────────────┐
|
|
│ 5. Subsequent API requests include store context │
|
|
│ Authorization: Bearer <token-with-store-context> │
|
|
└─────────────────────────────────────────────────────────────────┘
|
|
│
|
|
↓
|
|
┌─────────────────────────────────────────────────────────────────┐
|
|
│ 6. get_current_store_api() extracts store from token: │
|
|
│ - current_user.token_store_id │
|
|
│ - current_user.token_store_code │
|
|
│ - current_user.token_store_role │
|
|
│ 7. Validates user still has access to store │
|
|
└─────────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
### Implementation Components
|
|
|
|
#### 1. Token Creation (middleware/auth.py)
|
|
```python
|
|
def create_access_token(
|
|
self,
|
|
user: User,
|
|
store_id: int | None = None,
|
|
store_code: str | None = None,
|
|
store_role: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Create JWT with optional store context."""
|
|
payload = {
|
|
"sub": str(user.id),
|
|
"username": user.username,
|
|
"email": user.email,
|
|
"role": user.role,
|
|
"exp": expire,
|
|
"iat": datetime.now(UTC),
|
|
}
|
|
|
|
# Include store information in token if provided
|
|
if store_id is not None:
|
|
payload["store_id"] = store_id
|
|
if store_code is not None:
|
|
payload["store_code"] = store_code
|
|
if store_role is not None:
|
|
payload["store_role"] = store_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. Store Login (app/api/v1/store/auth.py)
|
|
```python
|
|
@router.post("/login", response_model=StoreLoginResponse)
|
|
def store_login(
|
|
user_credentials: UserLogin,
|
|
response: Response,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Store team member login.
|
|
|
|
Creates store-scoped JWT token with store context embedded.
|
|
"""
|
|
# Authenticate user and determine store
|
|
login_result = auth_service.login_user(db=db, user_credentials=user_credentials)
|
|
user = login_result["user"]
|
|
|
|
# Determine store and role
|
|
store = determine_store(db, user) # Your store detection logic
|
|
store_role = determine_role(db, user, store) # Your role detection logic
|
|
|
|
# Create store-scoped access token
|
|
token_data = auth_service.auth_manager.create_access_token(
|
|
user=user,
|
|
store_id=store.id,
|
|
store_code=store.store_code,
|
|
store_role=store_role,
|
|
)
|
|
|
|
# Set cookie and return token
|
|
response.set_cookie(
|
|
key="store_token",
|
|
value=token_data["access_token"],
|
|
httponly=True,
|
|
path="/store", # Restricted to store routes
|
|
)
|
|
|
|
return StoreLoginResponse(**token_data, user=user, store=store)
|
|
```
|
|
|
|
#### 3. Token Verification (app/api/deps.py)
|
|
```python
|
|
def get_current_store_api(
|
|
authorization: str | None = Header(None, alias="Authorization"),
|
|
db: Session = Depends(get_db),
|
|
) -> User:
|
|
"""
|
|
Get current store API user from Authorization header.
|
|
|
|
Extracts store 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 store access if token is store-scoped
|
|
if hasattr(user, "token_store_id"):
|
|
store_id = user.token_store_id
|
|
|
|
# Verify user still has access to this store
|
|
if not user.is_member_of(store_id):
|
|
raise InsufficientPermissionsException(
|
|
"Access to store has been revoked. Please login again."
|
|
)
|
|
|
|
return user
|
|
```
|
|
|
|
#### 4. Endpoint Usage (app/api/v1/store/products.py)
|
|
```python
|
|
@router.get("", response_model=ProductListResponse)
|
|
def get_store_products(
|
|
skip: int = Query(0, ge=0),
|
|
limit: int = Query(100, ge=1, le=1000),
|
|
current_user: User = Depends(get_current_store_api), # ✅ Guarantees token_store_id
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Get all products in store catalog.
|
|
|
|
Store is determined from JWT token (store_id claim).
|
|
The get_current_store_api dependency GUARANTEES token_store_id is present.
|
|
"""
|
|
# Use store_id from token for business logic
|
|
# NO validation needed - dependency guarantees token_store_id exists
|
|
products, total = product_service.get_store_products(
|
|
db=db,
|
|
store_id=current_user.token_store_id, # Safe to use directly
|
|
skip=skip,
|
|
limit=limit,
|
|
)
|
|
|
|
return ProductListResponse(products=products, total=total)
|
|
```
|
|
|
|
> **IMPORTANT**: The `get_current_store_api()` dependency now **guarantees** that `token_store_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_store_context()
|
|
|
|
Search for all occurrences:
|
|
```bash
|
|
grep -r "require_store_context" app/api/v1/store/
|
|
```
|
|
|
|
### Step 2: Update Endpoint Signature
|
|
|
|
**Before:**
|
|
```python
|
|
@router.get("/{product_id}")
|
|
def get_product(
|
|
product_id: int,
|
|
store: Store = Depends(require_store_context()), # ❌ Remove this
|
|
current_user: User = Depends(get_current_store_api),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
```
|
|
|
|
**After:**
|
|
```python
|
|
@router.get("/{product_id}")
|
|
def get_product(
|
|
product_id: int,
|
|
current_user: User = Depends(get_current_store_api), # ✅ Only need this
|
|
db: Session = Depends(get_db),
|
|
):
|
|
```
|
|
|
|
### Step 3: Extract Store from Token
|
|
|
|
**Before:**
|
|
```python
|
|
product = product_service.get_product(db, store.id, product_id)
|
|
```
|
|
|
|
**After:**
|
|
```python
|
|
# Use store_id from token directly - dependency guarantees it exists
|
|
product = product_service.get_product(db, current_user.token_store_id, product_id)
|
|
```
|
|
|
|
> **NOTE**: Do NOT add validation like `if not hasattr(current_user, "token_store_id")`.
|
|
> The `get_current_store_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 store {store.store_code}")
|
|
```
|
|
|
|
**After:**
|
|
```python
|
|
logger.info(f"Product updated for store {current_user.token_store_code}")
|
|
```
|
|
|
|
### Complete Migration Example
|
|
|
|
**Before (URL-based store detection):**
|
|
```python
|
|
@router.put("/{product_id}", response_model=ProductResponse)
|
|
def update_product(
|
|
product_id: int,
|
|
product_data: ProductUpdate,
|
|
store: Store = Depends(require_store_context()), # ❌
|
|
current_user: User = Depends(get_current_store_api),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Update product in store catalog."""
|
|
product = product_service.update_product(
|
|
db=db,
|
|
store_id=store.id, # ❌ From URL
|
|
product_id=product_id,
|
|
product_update=product_data
|
|
)
|
|
|
|
logger.info(
|
|
f"Product {product_id} updated by {current_user.username} "
|
|
f"for store {store.store_code}" # ❌ From URL
|
|
)
|
|
|
|
return ProductResponse.model_validate(product)
|
|
```
|
|
|
|
**After (Token-based store context):**
|
|
```python
|
|
@router.put("/{product_id}", response_model=ProductResponse)
|
|
def update_product(
|
|
product_id: int,
|
|
product_data: ProductUpdate,
|
|
current_user: User = Depends(get_current_store_api), # ✅ Guarantees token_store_id
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Update product in store catalog."""
|
|
# NO validation needed - dependency guarantees token_store_id exists
|
|
product = product_service.update_product(
|
|
db=db,
|
|
store_id=current_user.token_store_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 store {current_user.token_store_code}" # ✅ From token
|
|
)
|
|
|
|
return ProductResponse.model_validate(product)
|
|
```
|
|
|
|
> **Architecture Rule API-003**: Endpoints should NOT raise exceptions. The `get_current_store_api` dependency
|
|
> handles all validation and raises `InvalidTokenException` if `token_store_id` is missing.
|
|
|
|
## Migration Status
|
|
|
|
**COMPLETED** - All store API endpoints have been migrated to use the token-based store context pattern.
|
|
|
|
### Migrated Files
|
|
All store API files now use `current_user.token_store_id`:
|
|
- `app/api/v1/store/customers.py` ✅
|
|
- `app/api/v1/store/notifications.py` ✅
|
|
- `app/api/v1/store/media.py` ✅
|
|
- `app/api/v1/store/marketplace.py` ✅
|
|
- `app/api/v1/store/inventory.py` ✅
|
|
- `app/api/v1/store/settings.py` ✅
|
|
- `app/api/v1/store/analytics.py` ✅
|
|
- `app/api/v1/store/payments.py` ✅
|
|
- `app/api/v1/store/profile.py` ✅
|
|
- `app/api/v1/store/dashboard.py` ✅
|
|
- `app/api/v1/store/products.py` ✅
|
|
- `app/api/v1/store/orders.py` ✅
|
|
- `app/api/v1/store/team.py` ✅ (uses permission dependencies)
|
|
|
|
### Permission Dependencies Updated
|
|
The following permission dependencies now use token-based store context:
|
|
- `require_store_permission()` - Gets store from token, sets `request.state.store`
|
|
- `require_store_owner` - Gets store from token, sets `request.state.store`
|
|
- `require_any_store_permission()` - Gets store from token, sets `request.state.store`
|
|
- `require_all_store_permissions()` - Gets store from token, sets `request.state.store`
|
|
- `get_user_permissions` - Gets store from token, sets `request.state.store`
|
|
|
|
### Shop Endpoints
|
|
Shop endpoints (public, no authentication) still use `require_store_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 store from the request URL, not from JWT token.
|
|
|
|
## Benefits of Store-in-Token
|
|
|
|
### 1. Clean RESTful APIs
|
|
```
|
|
✅ /api/v1/store/products
|
|
✅ /api/v1/store/orders
|
|
✅ /api/v1/store/customers
|
|
|
|
❌ /api/v1/store/{store_code}/products (unnecessary store in URL)
|
|
```
|
|
|
|
### 2. Security
|
|
- Store 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 store API endpoints
|
|
- No confusion between page routes and API routes
|
|
- Single source of truth (the token)
|
|
|
|
### 4. Performance
|
|
- No database lookup for store context on every request
|
|
- Store 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 store 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 store (database check)
|
|
|
|
### Access Revocation
|
|
If a user's store access is revoked:
|
|
1. Existing tokens remain valid until expiration
|
|
2. `get_current_store_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 store membership
|
|
|
|
## Testing
|
|
|
|
### Unit Tests
|
|
```python
|
|
def test_store_in_token():
|
|
"""Test store context in JWT token."""
|
|
# Create token with store context
|
|
token_data = auth_manager.create_access_token(
|
|
user=user,
|
|
store_id=123,
|
|
store_code="ORION",
|
|
store_role="Owner",
|
|
)
|
|
|
|
# Verify token contains store data
|
|
payload = jwt.decode(token_data["access_token"], secret_key)
|
|
assert payload["store_id"] == 123
|
|
assert payload["store_code"] == "ORION"
|
|
assert payload["store_role"] == "Owner"
|
|
|
|
def test_api_endpoint_uses_token_store():
|
|
"""Test API endpoint extracts store from token."""
|
|
response = client.get(
|
|
"/api/v1/store/products",
|
|
headers={"Authorization": f"Bearer {token}"}
|
|
)
|
|
assert response.status_code == 200
|
|
# Verify products are filtered by token store_id
|
|
```
|
|
|
|
### Integration Tests
|
|
```python
|
|
def test_store_login_and_api_access():
|
|
"""Test full store login and API access flow."""
|
|
# Login as store user
|
|
response = client.post("/api/v1/store/auth/login", json={
|
|
"username": "john.doe",
|
|
"password": "password123"
|
|
})
|
|
assert response.status_code == 200
|
|
token = response.json()["access_token"]
|
|
|
|
# Access store API with token
|
|
response = client.get(
|
|
"/api/v1/store/products",
|
|
headers={"Authorization": f"Bearer {token}"}
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
# Verify store context from token
|
|
products = response.json()["products"]
|
|
# All products should belong to token store
|
|
```
|
|
|
|
## 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_store_id') │
|
|
│ ✅ MUST trust dependencies to handle validation │
|
|
│ ✅ MUST directly use current_user.token_store_id │
|
|
└────────────────────────────────────────────────────────────────────────────┘
|
|
│
|
|
▼
|
|
┌────────────────────────────────────────────────────────────────────────────┐
|
|
│ DEPENDENCIES (Validation Layer) - app/api/deps.py │
|
|
│ │
|
|
│ ✅ MUST raise InvalidTokenException if token_store_id missing │
|
|
│ ✅ MUST validate user still has store access │
|
|
│ ✅ GUARANTEES token_store_id, token_store_code, token_store_role │
|
|
└────────────────────────────────────────────────────────────────────────────┘
|
|
│
|
|
▼
|
|
┌────────────────────────────────────────────────────────────────────────────┐
|
|
│ SERVICES (Business Logic) - app/services/**/*.py │
|
|
│ │
|
|
│ ✅ MUST raise domain exceptions for business rule violations │
|
|
│ ✅ Examples: StoreNotFoundException, ProductNotFoundException │
|
|
└────────────────────────────────────────────────────────────────────────────┘
|
|
│
|
|
▼
|
|
┌────────────────────────────────────────────────────────────────────────────┐
|
|
│ GLOBAL EXCEPTION HANDLER - app/exceptions/handler.py │
|
|
│ │
|
|
│ ✅ Catches all OrionException subclasses │
|
|
│ ✅ Converts to appropriate HTTP responses │
|
|
│ ✅ Provides consistent error formatting │
|
|
└────────────────────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
### Enforced by Architecture Validation
|
|
|
|
The validation script (`scripts/validate/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_store_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/validate_architecture.py
|
|
language: python
|
|
pass_filenames: false
|
|
always_run: true
|
|
```
|
|
|
|
To run manually:
|
|
```bash
|
|
python scripts/validate/validate_architecture.py # Full validation
|
|
python scripts/validate/validate_architecture.py -d app/api/v1/store/ # Specific directory
|
|
```
|
|
|
|
See `.architecture-rules.yaml` for the complete rule definitions.
|
|
|
|
## Related Documentation
|
|
|
|
- [Store RBAC System](./store-rbac.md) - Role-based access control for stores
|
|
- [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 store-in-token architecture:
|
|
- ✅ Embeds store context in JWT tokens
|
|
- ✅ Eliminates URL-based store detection
|
|
- ✅ Enables clean RESTful API endpoints
|
|
- ✅ Improves security and performance
|
|
- ✅ Simplifies endpoint implementation
|
|
- ✅ Follows architecture best practices
|
|
|
|
**Migration Status:** ✅ COMPLETED - All store API endpoints migrated and architecture rules enforced
|