Files
bakery-ia/gateway/app/middleware/read_only_mode.py
2026-01-15 20:45:49 +01:00

149 lines
5.9 KiB
Python

"""
Gateway middleware to enforce read-only mode for subscriptions with status:
- pending_cancellation (until cancellation_effective_date)
- inactive (after cancellation or no active subscription)
Allowed operations in read-only mode:
- GET requests (all read operations)
- POST /api/v1/users/me/delete/request (account deletion)
- POST /api/v1/subscriptions/reactivate (subscription reactivation)
- POST /api/v1/subscriptions/* (subscription management)
"""
import httpx
import logging
from fastapi import Request, HTTPException, status
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware
from typing import Optional
import re
logger = logging.getLogger(__name__)
# Whitelist of POST/PUT/DELETE endpoints allowed in read-only mode
READ_ONLY_WHITELIST_PATTERNS = [
r'^/api/v1/users/me/delete/request$',
r'^/api/v1/users/me/export.*$',
r'^/api/v1/subscriptions/.*',
r'^/api/v1/auth/.*', # Allow auth operations
r'^/api/v1/tenants/register$', # Allow new tenant registration (no existing tenant context)
r'^/api/v1/tenants/.*/orchestrator/run-daily-workflow$', # Allow workflow testing
r'^/api/v1/tenants/.*/inventory/ml/insights/.*', # Allow ML insights (safety stock optimization)
r'^/api/v1/tenants/.*/production/ml/insights/.*', # Allow ML insights (yield prediction)
r'^/api/v1/tenants/.*/procurement/ml/insights/.*', # Allow ML insights (supplier analysis, price forecasting)
r'^/api/v1/tenants/.*/forecasting/ml/insights/.*', # Allow ML insights (rules generation)
r'^/api/v1/tenants/.*/forecasting/operations/.*', # Allow forecasting operations
r'^/api/v1/webhooks/.*', # Webhook endpoints - no tenant context
]
class ReadOnlyModeMiddleware(BaseHTTPMiddleware):
"""
Middleware to enforce read-only mode based on subscription status
"""
def __init__(self, app, tenant_service_url: str = "http://tenant-service:8000"):
super().__init__(app)
self.tenant_service_url = tenant_service_url
self.cache = {}
self.cache_ttl = 60
async def check_subscription_status(self, tenant_id: str, authorization: str) -> dict:
"""
Check subscription status from tenant service
Returns subscription data including status and read_only flag
"""
try:
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get(
f"{self.tenant_service_url}/api/v1/tenants/{tenant_id}/subscriptions/status",
headers={"Authorization": authorization}
)
if response.status_code == 200:
return response.json()
elif response.status_code == 404:
return {"status": "inactive", "is_read_only": True}
else:
logger.warning(
f"Failed to check subscription status: {response.status_code}",
extra={"tenant_id": tenant_id}
)
return {"status": "unknown", "is_read_only": False}
except Exception as e:
logger.error(
f"Error checking subscription status: {e}",
extra={"tenant_id": tenant_id}
)
return {"status": "unknown", "is_read_only": False}
def is_whitelisted_endpoint(self, path: str) -> bool:
"""
Check if endpoint is whitelisted for read-only mode
"""
for pattern in READ_ONLY_WHITELIST_PATTERNS:
if re.match(pattern, path):
return True
return False
def is_write_operation(self, method: str) -> bool:
"""
Determine if HTTP method is a write operation
"""
return method.upper() in ['POST', 'PUT', 'DELETE', 'PATCH']
async def dispatch(self, request: Request, call_next):
"""
Process each request through read-only mode check
"""
tenant_id = request.headers.get("X-Tenant-ID")
authorization = request.headers.get("Authorization")
path = request.url.path
method = request.method
if not tenant_id or not authorization:
return await call_next(request)
if method.upper() == 'GET':
return await call_next(request)
if self.is_whitelisted_endpoint(path):
return await call_next(request)
if self.is_write_operation(method):
subscription_data = await self.check_subscription_status(tenant_id, authorization)
if subscription_data.get("is_read_only", False):
status_detail = subscription_data.get("status", "inactive")
effective_date = subscription_data.get("cancellation_effective_date")
error_message = {
"detail": "Account is in read-only mode",
"reason": f"Subscription status: {status_detail}",
"message": "Your subscription has been cancelled. You can view data but cannot make changes.",
"action_required": "Reactivate your subscription to regain full access",
"reactivation_url": "/app/settings/subscription"
}
if effective_date:
error_message["read_only_until"] = effective_date
error_message["message"] = f"Your subscription is pending cancellation. Read-only mode starts on {effective_date}."
logger.info(
"read_only_mode_enforced",
extra={
"tenant_id": tenant_id,
"path": path,
"method": method,
"subscription_status": status_detail
}
)
return JSONResponse(
status_code=status.HTTP_403_FORBIDDEN,
content=error_message
)
return await call_next(request)