feat: Multi-tenant SaaS Support with TradingView Webhook Integration#3
feat: Multi-tenant SaaS Support with TradingView Webhook Integration#3gn00295120 wants to merge 7 commits intoluisleo526:mainfrom
Conversation
- Add BACKEND_API_TOKEN env var for SaaS frontend connection - Add ALLOWED_ORIGINS env var for configurable CORS - Add verify_auth() supporting both Bearer token and X-Auth-Key - Add /api/v1/ping endpoint for connection testing - Add /api/v1/status endpoint for system health check - Add /api/v1/info endpoint for backend information - Add /api/v1/orders and /api/v1/positions with new auth - Maintain backward compatibility with existing auth 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Update health check endpoint response format - Improve trading worker stability 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Security fixes: - Use secrets.compare_digest() for token comparison (timing attack prevention) - Add startup warning when running with default credentials - Fix DB session crash when SessionLocal() fails Code quality fixes: - Use datetime with UTC timezone for API responses - Use specific exception types (ConnectionError, TimeoutError, SQLAlchemyError) - Strip whitespace from ALLOWED_ORIGINS and SUPPORTED_FUTURES parsing Addresses feedback from Gemini, Copilot, and Codex reviews. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Multi-tenant infrastructure: - Admin API for tenant CRUD with owner_id isolation - Secure slug generation with random prefix - Worker orchestration via Docker containers - Per-tenant Redis DB isolation (0-15) TradingView webhook support: - Enable/disable webhook per tenant - Auto-generated webhook secret - Request validation and logging - Support for TradingView alert actions Credential management: - Shioaji API key upload/storage - CA certificate support - ready_for_trading status flags - Frontend-compatible credential status API 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- docker-compose.yml optimized for Synology - Cloudflare Tunnel integration (免開 Port) - Auto-setup script with security key generation - Environment template with all required variables - Comprehensive README with deployment guide 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Replace hardcoded 'postgres' password with environment variable
${POSTGRES_PASSWORD:-postgres} to resolve GitGuardian security alert.
- docker-compose.yaml: use env var for db-migrate and db services
- docker-compose.multitenant.yaml: use env var for all database URLs
- example.env: add POSTGRES_PASSWORD variable with default value
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR introduces a comprehensive multi-tenant SaaS architecture for the Shioaji trading backend, enabling multiple isolated tenants with TradingView webhook integration. The implementation includes gateway-based routing, admin API for tenant management, per-tenant Docker workers, encrypted credential storage, and Synology NAS deployment support with Cloudflare Tunnel.
Key Changes:
- Multi-tenant architecture with gateway routing to isolated tenant workers
- TradingView webhook endpoint with action parsing and secret validation
- Encrypted credential management with Docker secrets support
Reviewed changes
Copilot reviewed 27 out of 29 changed files in this pull request and generated 31 comments.
Show a summary per file
| File | Description |
|---|---|
gateway/main.py |
API gateway routing requests to tenant-specific workers with webhook support |
admin/main.py |
Admin API for tenant, credential, and worker lifecycle management |
admin/tenant_service.py |
Tenant CRUD service with secure slug generation and credential handling |
orchestrator/worker_manager.py |
Docker-based worker manager for per-tenant container lifecycle |
models/tenant.py |
Multi-tenant data models with audit logging |
trading_worker.py |
Enhanced worker with multi-tenant support and Docker secrets |
trading_queue.py |
Queue client with tenant-based queue prefixing |
utils/log_sanitizer.py |
Log sanitization to prevent credential leakage |
docker-compose.multitenant.yaml |
Multi-tenant deployment configuration |
deploy/synology/* |
Synology NAS deployment scripts and configuration |
db/migrations/002-004*.sql |
Database migrations for tenant management and webhooks |
example.env |
Updated environment configuration with SaaS settings |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| # 如果兩種都沒設定,允許通過(向下相容舊版無認證設定) | ||
| # 注意:這是不安全的,啟動時會有警告 | ||
| if not BACKEND_API_TOKEN and AUTH_KEY == "changeme": | ||
| return True |
There was a problem hiding this comment.
The AUTH_KEY default value "changeme" combined with the security logic creates an insecure default configuration. When both AUTH_KEY is "changeme" and BACKEND_API_TOKEN is empty, the verify_auth function allows all requests without authentication (lines 64-67). While there's a warning on startup, this is a dangerous default that could lead to production deployments with no authentication. Consider requiring at least one authentication method to be set, or failing to start the service if neither is configured properly.
| -- Add webhook_secret to tenants table (may already exist) | ||
| ALTER TABLE tenants ADD COLUMN IF NOT EXISTS webhook_secret VARCHAR(64); | ||
| ALTER TABLE tenants ADD COLUMN IF NOT EXISTS webhook_enabled BOOLEAN DEFAULT false; | ||
|
|
||
| -- Create webhook_logs table | ||
| CREATE TABLE IF NOT EXISTS webhook_logs ( | ||
| id SERIAL PRIMARY KEY, | ||
| tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, | ||
|
|
||
| -- Request info | ||
| source_ip VARCHAR(45), | ||
| request_body JSONB, | ||
| headers JSONB, | ||
|
|
||
| -- Processing result | ||
| status VARCHAR(20) NOT NULL DEFAULT 'received', -- received, validated, processed, failed | ||
| error_message TEXT, | ||
|
|
||
| -- Parsed TradingView data | ||
| tv_alert_name VARCHAR(255), | ||
| tv_ticker VARCHAR(50), | ||
| tv_action VARCHAR(20), -- buy, sell, long, short, exit, etc. | ||
| tv_quantity INTEGER, | ||
| tv_price DECIMAL(18, 4), | ||
|
|
||
| -- Order result | ||
| order_id INTEGER REFERENCES order_history(id), | ||
|
|
||
| -- Timestamps | ||
| created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, | ||
| processed_at TIMESTAMP WITH TIME ZONE | ||
| ); | ||
|
|
||
| -- Index for fast lookups | ||
| CREATE INDEX IF NOT EXISTS idx_webhook_logs_tenant_id ON webhook_logs(tenant_id); | ||
| CREATE INDEX IF NOT EXISTS idx_webhook_logs_created_at ON webhook_logs(created_at DESC); | ||
| CREATE INDEX IF NOT EXISTS idx_webhook_logs_status ON webhook_logs(status); | ||
|
|
||
| -- Comment | ||
| COMMENT ON TABLE webhook_logs IS 'TradingView webhook request logs for audit and debugging'; | ||
| COMMENT ON COLUMN tenants.webhook_secret IS 'Secret token for validating TradingView webhook requests'; | ||
| COMMENT ON COLUMN tenants.webhook_enabled IS 'Whether webhook is enabled for this tenant'; |
There was a problem hiding this comment.
The migration does not check if the webhook_logs table or its indexes already exist before attempting to create them. While using IF NOT EXISTS for the table is good, if the migration is run multiple times or if the table schema changes, there's no version tracking or migration history. Consider using a proper migration framework (like Alembic) or at least adding a migration tracking table to prevent issues with repeated executions.
| -- Add webhook_secret to tenants table (may already exist) | |
| ALTER TABLE tenants ADD COLUMN IF NOT EXISTS webhook_secret VARCHAR(64); | |
| ALTER TABLE tenants ADD COLUMN IF NOT EXISTS webhook_enabled BOOLEAN DEFAULT false; | |
| -- Create webhook_logs table | |
| CREATE TABLE IF NOT EXISTS webhook_logs ( | |
| id SERIAL PRIMARY KEY, | |
| tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, | |
| -- Request info | |
| source_ip VARCHAR(45), | |
| request_body JSONB, | |
| headers JSONB, | |
| -- Processing result | |
| status VARCHAR(20) NOT NULL DEFAULT 'received', -- received, validated, processed, failed | |
| error_message TEXT, | |
| -- Parsed TradingView data | |
| tv_alert_name VARCHAR(255), | |
| tv_ticker VARCHAR(50), | |
| tv_action VARCHAR(20), -- buy, sell, long, short, exit, etc. | |
| tv_quantity INTEGER, | |
| tv_price DECIMAL(18, 4), | |
| -- Order result | |
| order_id INTEGER REFERENCES order_history(id), | |
| -- Timestamps | |
| created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, | |
| processed_at TIMESTAMP WITH TIME ZONE | |
| ); | |
| -- Index for fast lookups | |
| CREATE INDEX IF NOT EXISTS idx_webhook_logs_tenant_id ON webhook_logs(tenant_id); | |
| CREATE INDEX IF NOT EXISTS idx_webhook_logs_created_at ON webhook_logs(created_at DESC); | |
| CREATE INDEX IF NOT EXISTS idx_webhook_logs_status ON webhook_logs(status); | |
| -- Comment | |
| COMMENT ON TABLE webhook_logs IS 'TradingView webhook request logs for audit and debugging'; | |
| COMMENT ON COLUMN tenants.webhook_secret IS 'Secret token for validating TradingView webhook requests'; | |
| COMMENT ON COLUMN tenants.webhook_enabled IS 'Whether webhook is enabled for this tenant'; | |
| -- Migration tracking table to ensure this migration runs only once | |
| CREATE TABLE IF NOT EXISTS schema_migrations ( | |
| id TEXT PRIMARY KEY, | |
| applied_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP | |
| ); | |
| DO $$ | |
| BEGIN | |
| -- Only apply this migration if it has not been recorded yet | |
| IF NOT EXISTS ( | |
| SELECT 1 FROM schema_migrations WHERE id = '004_webhook_support' | |
| ) THEN | |
| -- Add webhook_secret to tenants table (may already exist) | |
| ALTER TABLE tenants ADD COLUMN IF NOT EXISTS webhook_secret VARCHAR(64); | |
| ALTER TABLE tenants ADD COLUMN IF NOT EXISTS webhook_enabled BOOLEAN DEFAULT false; | |
| -- Create webhook_logs table | |
| CREATE TABLE IF NOT EXISTS webhook_logs ( | |
| id SERIAL PRIMARY KEY, | |
| tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, | |
| -- Request info | |
| source_ip VARCHAR(45), | |
| request_body JSONB, | |
| headers JSONB, | |
| -- Processing result | |
| status VARCHAR(20) NOT NULL DEFAULT 'received', -- received, validated, processed, failed | |
| error_message TEXT, | |
| -- Parsed TradingView data | |
| tv_alert_name VARCHAR(255), | |
| tv_ticker VARCHAR(50), | |
| tv_action VARCHAR(20), -- buy, sell, long, short, exit, etc. | |
| tv_quantity INTEGER, | |
| tv_price DECIMAL(18, 4), | |
| -- Order result | |
| order_id INTEGER REFERENCES order_history(id), | |
| -- Timestamps | |
| created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, | |
| processed_at TIMESTAMP WITH TIME ZONE | |
| ); | |
| -- Index for fast lookups | |
| CREATE INDEX IF NOT EXISTS idx_webhook_logs_tenant_id ON webhook_logs(tenant_id); | |
| CREATE INDEX IF NOT EXISTS idx_webhook_logs_created_at ON webhook_logs(created_at DESC); | |
| CREATE INDEX IF NOT EXISTS idx_webhook_logs_status ON webhook_logs(status); | |
| -- Comment | |
| COMMENT ON TABLE webhook_logs IS 'TradingView webhook request logs for audit and debugging'; | |
| COMMENT ON COLUMN tenants.webhook_secret IS 'Secret token for validating TradingView webhook requests'; | |
| COMMENT ON COLUMN tenants.webhook_enabled IS 'Whether webhook is enabled for this tenant'; | |
| -- Record that this migration has been applied | |
| INSERT INTO schema_migrations (id) VALUES ('004_webhook_support'); | |
| END IF; | |
| END | |
| $$; |
| def allocate_redis_db(self) -> int: | ||
| """ | ||
| Allocate an available Redis database number (0-15). | ||
|
|
||
| Returns the lowest available number, or raises an error if all are used. | ||
| """ | ||
| used_dbs = set( | ||
| row[0] for row in self.db.query(WorkerInstance.redis_db).filter( | ||
| WorkerInstance.status.in_([ | ||
| WorkerStatus.PENDING.value, | ||
| WorkerStatus.STARTING.value, | ||
| WorkerStatus.RUNNING.value, | ||
| WorkerStatus.HIBERNATING.value, | ||
| ]) | ||
| ).all() | ||
| ) | ||
|
|
||
| for db_num in range(16): | ||
| if db_num not in used_dbs: | ||
| return db_num | ||
|
|
||
| raise TenantServiceError("No available Redis database slots (max 15 tenants)") |
There was a problem hiding this comment.
The allocate_redis_db method has a limit of 15 tenants (Redis databases 0-15), but the comment says "max 15 tenants" which is technically 16 databases (0-15 inclusive). The error message should clarify this is 16 total tenants. Additionally, this hard limit might be too restrictive for a SaaS architecture - consider documenting this limitation prominently and potentially implementing Redis clustering or using key prefixes instead of separate databases for better scalability.
| async def get_current_user_id( | ||
| x_user_id: Optional[str] = Header(None, alias="X-User-ID"), | ||
| ) -> str: | ||
| """ | ||
| Extract user ID from X-User-ID header. | ||
|
|
||
| In production, this should be validated against the auth token. | ||
| The frontend should extract the user ID from the Supabase session | ||
| and pass it in this header. | ||
| """ | ||
| if not x_user_id: | ||
| raise HTTPException( | ||
| status_code=401, | ||
| detail="Missing X-User-ID header. User authentication required." | ||
| ) | ||
| return x_user_id |
There was a problem hiding this comment.
The user_id from X-User-ID header is used directly for ownership checks without any validation against the authentication token. This means any authenticated user can impersonate another user by simply changing the X-User-ID header. The comment on lines 99-101 acknowledges this issue ("In production, this should be validated against the auth token"), but this is a critical security vulnerability. The user_id should be extracted from the verified JWT token, not from an unvalidated header.
| response = client.place_entry_order( | ||
| symbol=symbol, | ||
| quantity=quantity, | ||
| action="Buy" if internal_action == "long_entry" else "Sell", |
There was a problem hiding this comment.
The action mapping for "Sell" in entry orders should be "Sell" for short entry, but the action parameter passed to place_entry_order should match the TradingView action format. The current code passes "Buy" for long_entry and "Sell" for short_entry, but based on the action parameter in the OrderRequest model and trading logic, this should use the internal action format (e.g., "long_entry" or "short_entry") or the Shioaji API action format. Review the place_entry_order method signature to ensure the correct action value is passed.
| action="Buy" if internal_action == "long_entry" else "Sell", | |
| action=internal_action, |
| Tenant, | ||
| TenantCredential, | ||
| WorkerInstance, | ||
| WorkerStatus, | ||
| HealthStatus, | ||
| CredentialType, | ||
| CredentialStatus, |
There was a problem hiding this comment.
Import of 'Tenant' is not used.
Import of 'CredentialStatus' is not used.
| Tenant, | |
| TenantCredential, | |
| WorkerInstance, | |
| WorkerStatus, | |
| HealthStatus, | |
| CredentialType, | |
| CredentialStatus, | |
| TenantCredential, | |
| WorkerInstance, | |
| WorkerStatus, | |
| HealthStatus, | |
| CredentialType, |
| AuditAction, | ||
| ) | ||
| from credentials.encrypted_storage import get_credential_storage | ||
| from admin.tenant_service import TenantService, TenantNotFoundError, TenantServiceError |
There was a problem hiding this comment.
Import of 'TenantServiceError' is not used.
| from admin.tenant_service import TenantService, TenantNotFoundError, TenantServiceError | |
| from admin.tenant_service import TenantService, TenantNotFoundError |
| # Stop first (ignore if not running) | ||
| try: | ||
| await manager.stop_worker(tenant_id) | ||
| except WorkerNotFoundError: |
There was a problem hiding this comment.
'except' clause does nothing but pass and there is no explanatory comment.
| except WorkerNotFoundError: | |
| except WorkerNotFoundError: | |
| # Worker was not running; this is expected and safe to ignore when restarting. |
| container = self._docker.containers.get(instance.container_id) | ||
| container.stop(timeout=10) | ||
| except NotFound: | ||
| pass |
There was a problem hiding this comment.
'except' clause does nothing but pass and there is no explanatory comment.
| pass | |
| logger.debug( | |
| "Container %s not found when attempting to stop for hibernation.", | |
| instance.container_id, | |
| ) |
| container = self._docker.containers.get(instance.container_id) | ||
| container.remove(force=True) | ||
| except NotFound: | ||
| pass |
There was a problem hiding this comment.
'except' clause does nothing but pass and there is no explanatory comment.
| pass | |
| logger.debug( | |
| "Container %s not found during destroy_worker; assuming already removed", | |
| instance.container_id, | |
| ) |
|
here is the frontend website. https://shioaji-trading-dashboard-saas.pages.dev/ all function is work |
There was a problem hiding this comment.
This is the final PR Bugbot will review for you during this billing cycle
Your free Bugbot reviews will reset on January 29
Details
Your team is on the Bugbot Free tier. On this plan, Bugbot will review limited PRs each billing cycle for each member of your team.
To receive Bugbot reviews on all of your PRs, visit the Cursor dashboard to activate Pro and start your 14-day free trial.
| ) | ||
| return TenantResponse(**tenant.to_dict()) | ||
| except TenantNotFoundError as e: | ||
| raise HTTPException(status_code=404, detail=str(e)) |
There was a problem hiding this comment.
Missing tenant ownership checks in admin API endpoints
Multiple admin API endpoints verify the admin token but don't verify tenant ownership. Endpoints like update_tenant, delete_tenant, activate_tenant, suspend_tenant, credential management endpoints, and worker management endpoints only check verify_admin_token without using get_current_user_id to verify the tenant belongs to the requesting user. In contrast, webhook management endpoints correctly check if tenant.owner_id != user_id. This inconsistency allows any authenticated user to modify, delete, or access other users' tenants, credentials, and workers.
Additional Locations (2)
| symbol=request.symbol, | ||
| position_direction="long" if request.action == "long_exit" else "short", | ||
| simulation=simulation, | ||
| ) |
There was a problem hiding this comment.
Gateway API passes wrong order parameter values
The gateway's place_order endpoint passes incorrect parameter values to the trading queue. For entry orders, action=request.action passes raw strings like "long_entry"/"short_entry" instead of converting to "Buy"/"Sell" as the existing main.py does. For exit orders, position_direction="long"/"short" is used instead of "Buy"/"Sell". The webhook handler at lines 697-708 correctly converts these values, confirming this is a bug. Orders placed through the gateway API will fail or behave incorrectly because the trading worker expects "Buy"/"Sell" format.
| except APIError as e: | ||
| instance.status = WorkerStatus.ERROR.value | ||
| instance.error_message = str(e) | ||
| raise WorkerManagerError(f"Failed to start container: {e}") |
There was a problem hiding this comment.
Worker error status not persisted when Docker operations fail
In start_worker, stop_worker, and wake_worker, when Docker operations fail (NotFound or APIError), the code sets instance.status to ERROR and instance.error_message before raising an exception. However, the exception causes execution to skip past db.commit(), so these status updates are never persisted. Subsequent calls to get_worker_status will show stale status instead of the actual error state, making it difficult for users to diagnose why their worker isn't running.
Additional Locations (2)
| cred_files = storage.export_for_worker(str(tenant_id)) | ||
|
|
||
| if not cred_files: | ||
| raise CredentialsNotFoundError(f"Failed to export credentials for tenant {tenant.slug}") |
There was a problem hiding this comment.
Early commit in create_worker leaves orphaned instances blocking retries
When creating a new worker, create_worker_instance commits the database transaction immediately (line 638 in tenant_service.py). If subsequent operations fail—such as export_for_worker returning empty or Docker container creation failing—the worker instance remains in the database with status PENDING and no container_id. On the next create_worker call, the check at line 172 sees status PENDING (which is not in "stopped" or "error") and raises WorkerAlreadyRunningError. The tenant becomes permanently stuck, unable to create a worker without manual database cleanup.
Summary
/webhook/{tenant_slug}Key Features
Multi-tenant Architecture
TradingView Webhook
POST /webhook/{tenant_slug}endpoint for trading signalsDeployment Options
docker-compose.yaml)docker-compose.multitenant.yaml)deploy/synology/)Files Changed
gateway/main.pyadmin/main.py,admin/tenant_service.pyorchestrator/worker_manager.pycredentials/manager.py,credentials/encryption.pymodels/tenant.pyutils/log_sanitizer.pydeploy/synology/*,docker-compose.multitenant.yamlTest plan
🤖 Generated with Claude Code
Note
Enables a managed multi-tenant trading backend with tenant isolation, secure credentials, and webhook-driven trading.
admin/main.py) to manage tenants, credentials (encrypted), webhooks, and worker lifecycle with rate limiting and CORSgateway/main.py) with tenant-scoped routes (/api/v1/{tenant_slug}/...) and a secure TradingViewPOST /webhookthat logs/validates requests and enqueues tradesorchestrator/worker_manager.py) to provision per-tenant Docker containers, inject secrets, allocate Redis DBs, and start/stop/hibernate workerstenants,tenant_credentials,worker_instances,tenant_audit_log,webhook_logs; linksorder_historytotenant_idtrading_queue.py(tenant-prefixed queues),trading_worker.py(secrets via files, CA support, mock mode),main.py(Bearer auth/CORS, new/api/v1/*endpoints), and sanitized logging (utils/log_sanitizer.py)docker-compose.multitenant.yaml, Synology setup (deploy/synology/*),Dockerfile.worker; expands.gitignoreandrequirements.txtWritten by Cursor Bugbot for commit df3c96a. This will update automatically on new commits. Configure here.