diff --git a/.env.example b/.env.example index 2ca263a..ee09317 100644 --- a/.env.example +++ b/.env.example @@ -43,3 +43,8 @@ ALLOWED_ORIGINS=http://localhost:3000,http://localhost # Request size limit (in bytes, default 100MB = 104857600) MAX_REQUEST_SIZE=104857600 + +# Database connection pool settings +DB_POOL_SIZE=20 +DB_MAX_OVERFLOW=40 +DB_POOL_RECYCLE=3600 diff --git a/backend/alembic/versions/004_add_rls_policies.py b/backend/alembic/versions/004_add_rls_policies.py new file mode 100644 index 0000000..86fa614 --- /dev/null +++ b/backend/alembic/versions/004_add_rls_policies.py @@ -0,0 +1,144 @@ +"""Add Row-Level Security policies for multi-tenant isolation + +Revision ID: 004_add_rls_policies +Revises: 003_add_settings_and_master_deck_fields +Create Date: 2026-02-27 18:30:00.000000 + +This migration implements database-level multi-tenant isolation using PostgreSQL +Row-Level Security (RLS) policies. This provides defense-in-depth security so that +even if application-level filtering fails, data remains isolated at the DB layer. +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '004_add_rls_policies' +down_revision = '003_add_settings_and_master_deck_fields' +branch_labels = None +depends_on = None + + +def upgrade(): + """Enable RLS and create policies for client-scoped tables.""" + + # Enable RLS on client-scoped tables + op.execute("ALTER TABLE presentations ENABLE ROW LEVEL SECURITY;") + op.execute("ALTER TABLE master_decks ENABLE ROW LEVEL SECURITY;") + op.execute("ALTER TABLE brand_configs ENABLE ROW LEVEL SECURITY;") + op.execute("ALTER TABLE slides ENABLE ROW LEVEL SECURITY;") + op.execute("ALTER TABLE templates ENABLE ROW LEVEL SECURITY;") + + # Policy for presentations table + op.execute(""" + CREATE POLICY presentation_client_isolation ON presentations + FOR ALL + USING ( + -- Super admin sees everything + current_setting('app.user_role', true) = 'super_admin' + OR + -- Client admin sees only accessible clients via team memberships + client_id IN ( + SELECT DISTINCT t.client_id + FROM teams t + JOIN team_memberships tm ON tm.team_id = t.id + WHERE tm.user_id = current_setting('app.current_user_id', true)::uuid + ) + OR + -- Users see their own presentations + owner_id = current_setting('app.current_user_id', true)::uuid + OR + -- Allow NULL client_id (for backward compatibility) + client_id IS NULL + ); + """) + + # Policy for master_decks table + op.execute(""" + CREATE POLICY master_deck_client_isolation ON master_decks + FOR ALL + USING ( + current_setting('app.user_role', true) = 'super_admin' + OR + client_id IN ( + SELECT DISTINCT t.client_id + FROM teams t + JOIN team_memberships tm ON tm.team_id = t.id + WHERE tm.user_id = current_setting('app.current_user_id', true)::uuid + ) + OR + client_id IS NULL + ); + """) + + # Policy for brand_configs table + op.execute(""" + CREATE POLICY brand_config_client_isolation ON brand_configs + FOR ALL + USING ( + current_setting('app.user_role', true) = 'super_admin' + OR + client_id IN ( + SELECT DISTINCT t.client_id + FROM teams t + JOIN team_memberships tm ON tm.team_id = t.id + WHERE tm.user_id = current_setting('app.current_user_id', true)::uuid + ) + ); + """) + + # Policy for slides table (inherits from presentation access) + op.execute(""" + CREATE POLICY slide_presentation_isolation ON slides + FOR ALL + USING ( + presentation IN ( + SELECT id FROM presentations + -- presentations table RLS will apply here + ) + ); + """) + + # Policy for templates table + op.execute(""" + CREATE POLICY template_client_isolation ON templates + FOR ALL + USING ( + current_setting('app.user_role', true) = 'super_admin' + OR + client_id IN ( + SELECT DISTINCT t.client_id + FROM teams t + JOIN team_memberships tm ON tm.team_id = t.id + WHERE tm.user_id = current_setting('app.current_user_id', true)::uuid + ) + OR + client_id IS NULL + ); + """) + + print("✅ RLS policies created successfully") + print("⚠️ IMPORTANT: Update AuthMiddleware to set session variables:") + print(" - app.current_user_id") + print(" - app.user_role") + + +def downgrade(): + """Remove RLS policies and disable RLS.""" + + # Drop policies + op.execute("DROP POLICY IF EXISTS presentation_client_isolation ON presentations;") + op.execute("DROP POLICY IF EXISTS master_deck_client_isolation ON master_decks;") + op.execute("DROP POLICY IF EXISTS brand_config_client_isolation ON brand_configs;") + op.execute("DROP POLICY IF EXISTS slide_presentation_isolation ON slides;") + op.execute("DROP POLICY IF EXISTS template_client_isolation ON templates;") + + # Disable RLS + op.execute("ALTER TABLE presentations DISABLE ROW LEVEL SECURITY;") + op.execute("ALTER TABLE master_decks DISABLE ROW LEVEL SECURITY;") + op.execute("ALTER TABLE brand_configs DISABLE ROW LEVEL SECURITY;") + op.execute("ALTER TABLE slides DISABLE ROW LEVEL SECURITY;") + op.execute("ALTER TABLE templates DISABLE ROW LEVEL SECURITY;") + + print("✅ RLS policies removed") diff --git a/backend/api/main.py b/backend/api/main.py index fdaa9e5..247e91f 100644 --- a/backend/api/main.py +++ b/backend/api/main.py @@ -9,7 +9,9 @@ from api.lifespan import app_lifespan from api.middlewares import UserConfigEnvUpdateMiddleware from api.middlewares.auth_middleware import AuthMiddleware from api.middlewares.rate_limit_middleware import limiter +from utils.safe_error_handler import safe_exception_handler from api.middlewares.request_size_middleware import RequestSizeLimitMiddleware +from api.middlewares.security_headers_middleware import SecurityHeadersMiddleware from api.v1.ppt.router import API_V1_PPT_ROUTER from api.v1.webhook.router import API_V1_WEBHOOK_ROUTER from api.v1.mock.router import API_V1_MOCK_ROUTER @@ -35,6 +37,9 @@ app = FastAPI(lifespan=app_lifespan) app.state.limiter = limiter app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) +# Configure safe error handling (prevent info disclosure) +app.add_exception_handler(Exception, safe_exception_handler) + # Admin router aggregator ADMIN_ROUTER = APIRouter(prefix="/api/v1/admin") ADMIN_ROUTER.include_router(USERS_ROUTER) @@ -79,15 +84,18 @@ app.add_middleware( allow_headers=["Authorization", "Content-Type", "Accept"], ) -# 2. Request size limit (reject large requests early) +# 2. Security headers (add protective HTTP headers) +app.add_middleware(SecurityHeadersMiddleware) + +# 3. Request size limit (reject large requests early) max_request_size = int(os.getenv("MAX_REQUEST_SIZE", str(100 * 1024 * 1024))) # 100MB app.add_middleware(RequestSizeLimitMiddleware, max_size=max_request_size) -# 3. Auth middleware (validates JWT, attaches user to request.state) +# 4. Auth middleware (validates JWT, attaches user to request.state) app.add_middleware(AuthMiddleware) -# 4. Audit middleware (fire-and-forget logging for mutations) +# 5. Audit middleware (fire-and-forget logging for mutations) app.add_middleware(AuditMiddleware) -# 5. User config middleware +# 6. User config middleware app.add_middleware(UserConfigEnvUpdateMiddleware) diff --git a/backend/api/middlewares/security_headers_middleware.py b/backend/api/middlewares/security_headers_middleware.py new file mode 100644 index 0000000..3c3787b --- /dev/null +++ b/backend/api/middlewares/security_headers_middleware.py @@ -0,0 +1,60 @@ +""" +Security headers middleware. + +Adds HTTP security headers to all responses: +- X-Content-Type-Options: Prevent MIME sniffing +- X-Frame-Options: Prevent clickjacking +- X-XSS-Protection: Enable XSS filter +- Strict-Transport-Security: Force HTTPS +- Content-Security-Policy: Restrict resource loading +""" + +from starlette.middleware.base import BaseHTTPMiddleware + + +class SecurityHeadersMiddleware(BaseHTTPMiddleware): + """Middleware that adds security headers to all responses.""" + + async def dispatch(self, request, call_next): + response = await call_next(request) + + # Prevent MIME type sniffing + response.headers["X-Content-Type-Options"] = "nosniff" + + # Prevent clickjacking + response.headers["X-Frame-Options"] = "DENY" + + # Enable XSS protection (legacy, but still useful for older browsers) + response.headers["X-XSS-Protection"] = "1; mode=block" + + # Force HTTPS (only if not in dev mode) + # Remove this in development if using HTTP + response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" + + # Content Security Policy + # Note: 'unsafe-inline' and 'unsafe-eval' needed for React and dynamic content + # Tighten these in production if possible + response.headers["Content-Security-Policy"] = ( + "default-src 'self'; " + "script-src 'self' 'unsafe-inline' 'unsafe-eval'; " + "style-src 'self' 'unsafe-inline'; " + "img-src 'self' data: https:; " + "font-src 'self' data:; " + "connect-src 'self'; " + "frame-ancestors 'none';" + ) + + # Referrer policy + response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" + + # Permissions policy (restrict browser features) + response.headers["Permissions-Policy"] = ( + "geolocation=(), " + "microphone=(), " + "camera=(), " + "payment=(), " + "usb=(), " + "magnetometer=()" + ) + + return response diff --git a/backend/services/database.py b/backend/services/database.py index e0ce806..a593e07 100644 --- a/backend/services/database.py +++ b/backend/services/database.py @@ -1,10 +1,12 @@ from collections.abc import AsyncGenerator +from fastapi import Request from sqlalchemy.ext.asyncio import ( AsyncEngine, create_async_engine, async_sessionmaker, AsyncSession, ) +from sqlalchemy import text from utils.db_utils import get_database_url_and_connect_args @@ -14,8 +16,31 @@ sql_engine: AsyncEngine = create_async_engine(database_url, connect_args=connect async_session_maker = async_sessionmaker(sql_engine, expire_on_commit=False) -async def get_async_session() -> AsyncGenerator[AsyncSession, None]: +async def get_async_session(request: Request = None) -> AsyncGenerator[AsyncSession, None]: + """ + Get async database session with RLS (Row-Level Security) context. + + Sets PostgreSQL session variables for RLS policies based on authenticated user: + - app.current_user_id: UUID of authenticated user + - app.user_role: User's role (super_admin, client_admin, user) + """ async with async_session_maker() as session: + # Set RLS session variables if user is authenticated + if request and hasattr(request.state, "user") and request.state.user: + user = request.state.user + try: + await session.execute( + text("SET LOCAL app.current_user_id = :user_id"), + {"user_id": str(user.id)} + ) + await session.execute( + text("SET LOCAL app.user_role = :role"), + {"role": user.role} + ) + except Exception as e: + # Log but don't fail - RLS variables are defense-in-depth + print(f"Warning: Failed to set RLS variables: {e}") + yield session diff --git a/backend/utils/safe_error_handler.py b/backend/utils/safe_error_handler.py new file mode 100644 index 0000000..6d45443 --- /dev/null +++ b/backend/utils/safe_error_handler.py @@ -0,0 +1,53 @@ +""" +Safe error handler for FastAPI. + +Logs full error details internally but returns generic messages to clients +to prevent information disclosure. +""" + +import logging +from fastapi import Request, HTTPException +from fastapi.responses import JSONResponse + +logger = logging.getLogger(__name__) + + +async def safe_exception_handler(request: Request, exc: Exception) -> JSONResponse: + """ + Handle unhandled exceptions safely. + + - Logs full error details with context for debugging + - Returns generic error message to client (prevents info disclosure) + - Preserves HTTPException details (those are intended for clients) + """ + + # Extract context from request state + user_id = getattr(request.state, "user", None) + user_id_str = str(user_id.id) if user_id and hasattr(user_id, "id") else "anonymous" + + # Log full error details internally + logger.error( + f"Unhandled exception on {request.method} {request.url.path}", + exc_info=True, + extra={ + "user_id": user_id_str, + "method": request.method, + "path": request.url.path, + "client_host": request.client.host if request.client else None, + }, + ) + + # If it's an HTTPException, return it as-is (these are intentional) + if isinstance(exc, HTTPException): + return JSONResponse( + status_code=exc.status_code, + content={"detail": exc.detail}, + ) + + # For all other exceptions, return generic message + return JSONResponse( + status_code=500, + content={ + "detail": "An internal error occurred. Please contact support if the problem persists." + }, + ) diff --git a/nginx.conf b/nginx.conf index c68099d..09c17c8 100644 --- a/nginx.conf +++ b/nginx.conf @@ -23,6 +23,14 @@ http { listen 80; server_name localhost; + # Security headers + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + # Note: HSTS removed for local dev - enable in production with HTTPS + # add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + # FastAPI backend location /api/v1/ { proxy_pass http://api_upstream;