This document describes OrcaZap's security model, including tenant isolation, authentication, and protection mechanisms.
Implementation:
- Middleware (
app/middleware/host_routing.py) classifies host on every request - Tenant subdomains:
{slug}.orcazap.com→ resolves tenant from database - Public hosts:
orcazap.com,www.orcazap.com→ no tenant context - API host:
api.orcazap.com→ no tenant context (operator-only)
Security:
- Tenant A cannot access tenant B's subdomain (DNS prevents this)
- Tenant routes validate
request.state.tenantexists (404 if not found) - All tenant-scoped queries include
tenant_idfilter
All tenant-scoped queries must include tenant_id filter:
# ✅ Correct
db.query(Contact).filter_by(tenant_id=tenant_id, phone=phone).first()
# ❌ Wrong (missing tenant_id)
db.query(Contact).filter_by(phone=phone).first()Unique constraints scoped to tenant:
(tenant_id, phone)for contacts(tenant_id, slug)for tenants- Foreign keys enforce tenant boundaries
Session scoping:
- Tenant users can only access their tenant's data
- Sessions include
tenant_idin session data - Dependencies inject tenant from request state
Dependencies:
get_current_tenant()dependency requires tenant host and valid tenant- All route handlers use tenant from dependency (not from user input)
- Tenant ID never comes from request parameters (only from host routing)
Data access functions:
- All data access functions require
tenant_idparameter - No global queries without tenant filter
- Tenant ID validated before database queries
Roles:
- Owner: Full access to tenant settings, pricing, freight rules
- Attendant: Can approve/reject quotes, view dashboard
Authentication:
- Email + password (bcrypt, 12 rounds)
- Session-based (Redis, 24h expiry)
- Session cookie:
session_idwithDomain=.orcazap.com(cross-subdomain)
Session Management:
- Sessions stored in Redis:
session:{session_id} - Session data:
{user_id, csrf_token, expires_at} - Session expiry: 24 hours (extendable on activity)
- Session deletion on logout
Password Security:
- Bcrypt hashing (12 rounds)
- Passwords never logged
- Password reset flow (future)
Purpose:
- System-wide admin access (cross-tenant)
- Operator admin panel on
api.orcazap.com - Create tenants, manage system settings
Authentication:
- Username + password (bcrypt, 12 rounds)
- Separate from tenant users
- Session-based (same Redis storage, different session key prefix)
Access Control:
- Operator routes require operator authentication
- Operator can access all tenants (for support/admin)
- Operator actions logged for audit
CSRF tokens:
- Generated on session creation:
secrets.token_urlsafe(32) - Stored in session (Redis):
session:{session_id}.csrf_token - Sent to client as cookie:
csrf_token - Required on all POST/PUT/DELETE requests
Validation:
- Token from header:
X-CSRF-Token(for HTMX/AJAX) - Token from cookie:
csrf_token(for form submissions) - Constant-time comparison:
secrets.compare_digest() - Validation in
app/core/csrf.py
HTMX Integration:
- HTMX automatically sends
X-CSRF-Tokenheader - Server validates token on every state-changing request
- CSRF middleware rejects invalid tokens (403 Forbidden)
Session cookie:
Domain=.orcazap.com(cross-subdomain access)HttpOnly=true(not accessible via JavaScript)Secure=true(HTTPS only in production)SameSite=Lax(CSRF protection)
CSRF token cookie:
- Same settings as session cookie
- Used for form submissions
WhatsApp webhook verification:
- Meta sends GET request with
hub.verify_token - Server validates token matches
WHATSAPP_VERIFY_TOKENenv var - Returns
hub.challengeif valid, 403 if invalid - Endpoint:
GET /webhooks/whatsapp(API host only)
Implementation:
- Verify token stored in environment variable
- Never logged or exposed in responses
- Required for webhook subscription
Status: Not implemented (optional enhancement)
Conceptual design:
- Meta can send
X-Hub-Signature-256header (SHA256 HMAC) - Compute HMAC-SHA256 of request body using
WHATSAPP_APP_SECRET - Compare signatures using constant-time comparison
- Reject if signature invalid (401 Unauthorized)
Note: For MVP, verify token is sufficient. Signature verification can be added for additional security.
Webhook endpoints only accessible on API host:
api.orcazap.com→ webhook accessible{slug}.orcazap.com→ 404 (webhook not found)orcazap.com→ 404 (webhook not found)
Implementation:
- Middleware checks
request.state.host_context == HostContext.API - Returns 404 if not API host
Technology: slowapi (Redis-backed)
Rate limit keys:
- Per tenant:
tenant:{tenant_id}(for tenant hosts) - Per IP:
{ip_address}(for public/API hosts)
Limits:
- Webhook endpoints: 1000/hour
- API endpoints: 100/hour
- Tenant dashboard: 200/hour
- Default: 1000/hour (if not specified)
Storage:
- Redis:
slowapi:rate_limit:{key}:{window} - Sliding window algorithm
- Automatic expiry
Response headers:
X-RateLimit-Limit: Maximum requests per windowX-RateLimit-Remaining: Remaining requests in windowX-RateLimit-Reset: Unix timestamp when window resets
Rate limit exceeded:
- Returns 429 Too Many Requests
- Logs warning with rate limit key
- Client should back off and retry
Key: provider_message_id (from WhatsApp)
Implementation:
- Unique constraint on
messages.provider_message_id - Check before insert: if exists, skip processing (idempotent)
- Log idempotency hits for monitoring
Benefits:
- Prevents duplicate message processing
- Safe to retry webhook (WhatsApp may send duplicates)
- Idempotent response (200 OK even if already processed)
Key: message.conversation_id (set after processing)
Implementation:
- Worker checks if
message.conversation_idis set - If set, skip processing (already processed)
- If not set, process and set
conversation_id
Benefits:
- Prevents duplicate job processing
- Safe to retry failed jobs
- Idempotent job execution
All inputs validated with Pydantic:
- Request bodies (webhooks, API endpoints)
- Query parameters
- Path parameters
- Form data
Validation:
- Type checking
- Required fields
- String length limits
- Enum validation
- Custom validators
SQLAlchemy ORM:
- All queries use ORM (no raw SQL)
- Parameterized queries (automatic)
- No string concatenation in queries
Example:
# ✅ Safe (ORM)
db.query(Contact).filter_by(tenant_id=tenant_id, phone=phone).first()
# ❌ Never do this
db.execute(f"SELECT * FROM contacts WHERE phone = '{phone}'")Jinja2 autoescape:
- All templates use Jinja2 autoescape
- User input automatically escaped in templates
- No
|safefilter on user input
HTMX:
- Server-rendered HTML (no client-side template rendering)
- XSS risk minimized
Prohibited:
- Passwords (hashed or plain)
- Access tokens
- API keys
- Session IDs (log session_id hash if needed)
- CSRF tokens
Allowed:
- Request IDs (for tracing)
- Tenant IDs (for debugging)
- User IDs (for audit)
- Provider message IDs (for tracing)
Log format:
- JSON in production (structured)
- Human-readable in development
- Request ID in every log entry
- Tenant ID in tenant-scoped logs
Log levels:
- DEBUG: Detailed debugging info
- INFO: Normal operations
- WARNING: Recoverable errors
- ERROR: Unrecoverable errors
Firewall rules (ufw):
- Only necessary ports open (22, 80, 443, 51820)
- Database/Redis only accessible via WireGuard VPN
- No public access to database/Redis
WireGuard VPN:
- Private network:
10.10.0.0/24 - Encrypted communication between servers
- No public IPs for internal services
Systemd services:
- Run as non-root user (
orcazap) - Limited file permissions
- No sudo access
Database:
- Strong passwords (generated, not hardcoded)
- Connection pooling (PgBouncer)
- No public access
Redis:
- Password protected (
requirepass) - No public access
- Bind to WireGuard interface only
- All queries include
tenant_idfilter - CSRF tokens on all POST/PUT/DELETE
- Input validation with Pydantic
- No secrets in code or logs
- Password hashing (bcrypt, 12 rounds)
- HTTPS enabled (TLS 1.2+)
- Secure cookies (HttpOnly, Secure, SameSite)
- Rate limiting enabled
- Firewall rules configured
- Database backups encrypted
- Environment variables secured
- Webhook verify token set
- Operator accounts with strong passwords
If security issue discovered:
- Assess impact (tenant isolation breach, data leak, etc.)
- Contain issue (disable affected endpoints, revoke tokens)
- Notify affected tenants (if applicable)
- Fix issue and deploy
- Review logs for unauthorized access
- Rotate secrets if compromised
Security contact: [To be determined]