qualora / app.py
prathamamritkar's picture
Add defensive error handling to blueprint imports for better HF Spaces debugging
856bef4
Raw
History Blame Contribute Delete
19.9 kB
"""
app.py β€” Qualora Enterprise Quality Auditor
===========================================
Core Flask app with modular architecture for MongoDB write-through,
Strict HTTP-Only JWT auth, RAG pipeline (Atlas Vector), and
multi-tier LLM fallback cascade. Hardened for Vercel deployment.
"""
import os
# Ensure ChromaDB telemetry is disabled as early as possible so imported
# libraries (including chromadb) do not attempt PostHog capture calls.
os.environ.setdefault("CHROMADB_DISABLE_TELEMETRY", "1")
os.environ.setdefault("CHROMA_DISABLE_TELEMETRY", "1")
os.environ.setdefault("ANONYMIZED_TELEMETRY", "True")
import sys
import jwt
from datetime import datetime, timezone
from flask import Flask, request, jsonify, send_from_directory, render_template, redirect, url_for, make_response
from flask_cors import CORS
from services.security import generate_csrf_token, decode_token
# ── Import infrastructure ─────────────────────────────────────────────────────
from core import (
BASE_DIR, ALLOWED_ORIGIN, MAX_CONTENT_LENGTH, DEBUG, GITHUB_URL,
JWT_SECRET, get_logger, log_request_middleware, get_db, get_db_status, LOG_LEVEL
)
# Import blueprints with graceful fallback if any fail
try:
from routes.auth_bp import auth_bp
except Exception as e:
auth_bp = None
import traceback
print(f"ERROR: Failed to import auth_bp: {e}\n{traceback.format_exc()}")
try:
from routes.audits_bp import audits_bp
except Exception as e:
audits_bp = None
import traceback
print(f"ERROR: Failed to import audits_bp: {e}\n{traceback.format_exc()}")
try:
from routes.kb_bp import kb_bp
except Exception as e:
kb_bp = None
import traceback
print(f"ERROR: Failed to import kb_bp: {e}\n{traceback.format_exc()}")
try:
from routes.admin_bp import admin_bp
except Exception as e:
admin_bp = None
import traceback
print(f"ERROR: Failed to import admin_bp: {e}\n{traceback.format_exc()}")
try:
from routes.dashboard_bp import dashboard_bp
except Exception as e:
dashboard_bp = None
import traceback
print(f"ERROR: Failed to import dashboard_bp: {e}\n{traceback.format_exc()}")
try:
from routes.alerts_bp import alerts_bp
except Exception as e:
alerts_bp = None
import traceback
print(f"ERROR: Failed to import alerts_bp: {e}\n{traceback.format_exc()}")
try:
from routes.agents_bp import agents_bp
except Exception as e:
agents_bp = None
import traceback
print(f"ERROR: Failed to import agents_bp: {e}\n{traceback.format_exc()}")
# ── Initialize logger ─────────────────────────────────────────────────────────
log = get_logger(__name__)
# ── Create Flask app ──────────────────────────────────────────────────────────
app = Flask(__name__, static_folder=None, template_folder=BASE_DIR)
app.config['MAX_CONTENT_LENGTH'] = MAX_CONTENT_LENGTH
# ── Enable CORS ───────────────────────────────────────────────────────────────
CORS(app,
resources={r"/*": {
"origins": ALLOWED_ORIGIN,
"methods": ["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"],
"allow_headers": ["Content-Type", "X-CSRF-Token"], # Removed Authorization, using strict HTTP-Only cookies
"expose_headers": ["X-CSRF-Token"],
"supports_credentials": True,
"max_age": 3600
}})
# ── Security Headers Middleware ────────────────────────────────────────────────
@app.after_request
def set_security_headers(response):
"""Add enterprise-grade security headers to all responses."""
response.headers['X-Frame-Options'] = 'DENY'
response.headers['X-Content-Type-Options'] = 'nosniff'
response.headers['X-XSS-Protection'] = '1; mode=block'
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
# Content Security Policy: Strict definitions
response.headers['Content-Security-Policy'] = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net https://fonts.googleapis.com; "
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
"font-src 'self' https://fonts.gstatic.com; "
"img-src 'self' data: https:; "
"connect-src 'self' https:; "
"object-src 'none'; "
"frame-ancestors 'none'; "
"upgrade-insecure-requests; "
"base-uri 'self';"
)
# HSTS: enforce HTTPS (Always on in production, Vercel standard)
if not DEBUG:
response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains; preload'
return response
# ── Global Jinja Context ─────────────────────────────────────────────────────
@app.context_processor
def inject_global_template_vars():
# Attempt to decode JWT from HTTP-only cookie so templates can
# perform server-side role-aware rendering (non-sensitive).
user = None
try:
token = request.cookies.get('access_token')
if token:
payload = decode_token(token)
if payload:
# expose only non-sensitive fields to templates
user = {
'email': payload.get('email'),
'role': payload.get('role'),
'org_id': payload.get('org_id')
}
except Exception:
user = None
return {
'github_url': GITHUB_URL,
'current_year': datetime.now(timezone.utc).year,
'get_csrf_token': generate_csrf_token,
'current_user': user,
# Frontend may opt to disable console logging when server is set to OFF
'quiet_logs': True if (isinstance(LOG_LEVEL, str) and LOG_LEVEL.upper() in ("OFF", "NONE", "DISABLED")) else False
}
# ── Register middleware & blueprints ──────────────────────────────────────────
try:
log_request_middleware(app)
except Exception as e:
log.warning(f"Failed to register request middleware: {e}")
blueprints_to_register = [
(auth_bp, '/api/auth'),
(audits_bp, '/api/audits'),
(kb_bp, '/api/kb'),
(admin_bp, '/api/admin'),
(dashboard_bp, '/api/dashboard'),
(alerts_bp, '/api/alerts'),
(agents_bp, '/api/agents'),
]
for bp, url_prefix in blueprints_to_register:
if bp is None:
log.warning(f"Skipping blueprint registration for {url_prefix} β€” blueprint failed to import")
continue
try:
app.register_blueprint(bp, url_prefix=url_prefix)
except Exception as e:
log.error(f"Failed to register blueprint {bp.name} at {url_prefix}: {e}", exc_info=True)
# ── Health checks ─────────────────────────────────────────────────────────────
@app.route('/api/health', methods=['GET'])
def health():
"""Shallow health check β€” fast, no aggregation (Vercel warm-up)."""
db = get_db()
ok = db is not None
return jsonify({
'status': 'healthy' if ok else 'degraded',
'database': 'connected' if ok else 'disconnected',
'timestamp': datetime.now(timezone.utc).isoformat()
}), (200 if ok else 503)
@app.route('/api/health/deep', methods=['GET'])
def health_deep():
"""Deep health check β€” ensures MongoDB Atlas Vector Search is functional."""
status = get_db_status()
db_connected = status.get('connected', False)
return jsonify({
'status': 'healthy' if db_connected else 'degraded',
'mongodb': status,
# RAG strictly uses Atlas Vector Search to avoid Vercel SQLite exhaustion
'vector_store': 'connected' if status.get('atlas_vector_index') else 'degraded',
'timestamp': datetime.now(timezone.utc).isoformat()
}), (200 if db_connected else 503)
@app.route('/api/config/social', methods=['GET'])
def get_social_config():
"""Return public configurations."""
return jsonify({'github': GITHUB_URL}), 200
# ── Authentication Helper ─────────────────────────────────────────────────────
def verify_http_only_jwt() -> bool:
"""
Strictly enforces HTTP-Only cookie JWT validation for MVC routing.
Protects against XSS token theft.
"""
token = request.cookies.get('access_token')
if not token:
return False
try:
jwt.decode(token, JWT_SECRET, algorithms=["HS256"])
return True
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError):
return False
# ── MVC Protected UI Routes ───────────────────────────────────────────────────
@app.route('/dashboard', methods=['GET'])
def dashboard():
if not verify_http_only_jwt():
return redirect(url_for('index'))
return render_template('dashboard.html')
@app.route('/audit', methods=['GET'])
def audit():
if not verify_http_only_jwt():
return redirect(url_for('index'))
return render_template('audit.html')
@app.route('/knowledge-base', methods=['GET'])
def knowledge_base():
if not verify_http_only_jwt():
return redirect(url_for('index'))
# Enforce server-side RBAC: only admins may access KB UI
token = request.cookies.get('access_token')
payload = decode_token(token) if token else None
if not payload or payload.get('role') != 'admin':
return redirect(url_for('dashboard'))
return render_template('knowledge-base.html')
@app.route('/agents', methods=['GET'])
def agents():
if not verify_http_only_jwt():
return redirect(url_for('index'))
# Enforce server-side RBAC: only admins may access Agents UI
token = request.cookies.get('access_token')
payload = decode_token(token) if token else None
if not payload or payload.get('role') != 'admin':
return redirect(url_for('dashboard'))
return render_template('agents.html')
# ── Public / Static Routes ────────────────────────────────────────────────────
@app.route('/', methods=['GET'])
def index():
"""Serve landing/login page. Redirect to dashboard if already authenticated."""
if verify_http_only_jwt():
return redirect(url_for('dashboard'))
return render_template('index.html')
@app.route('/assets/<path:filename>')
def serve_assets(filename):
"""
Securely serve static assets.
Relies on Werkzeug's `send_from_directory` C-level path sanitization
to prevent directory traversal attacks (e.g., ../../etc/passwd).
"""
assets_dir = os.path.join(BASE_DIR, 'assets')
if not os.path.isdir(assets_dir):
return jsonify({'error': 'Assets directory missing'}), 404
return send_from_directory(assets_dir, filename, max_age=3600)
# -- Deterministic custom static routes (consistent with routes.create_app)
@app.route('/style.css', methods=['GET'])
def serve_style():
project_root = os.path.dirname(BASE_DIR)
STATIC_DIR = os.path.join(project_root, 'static')
return send_from_directory(STATIC_DIR, 'css/style.css', mimetype='text/css')
@app.route('/script.js', methods=['GET'])
def serve_script():
project_root = os.path.dirname(BASE_DIR)
STATIC_DIR = os.path.join(project_root, 'static')
return send_from_directory(STATIC_DIR, 'js/script.js', mimetype='application/javascript')
@app.route('/auth-init.js', methods=['GET'])
def serve_auth_init():
project_root = os.path.dirname(BASE_DIR)
STATIC_DIR = os.path.join(project_root, 'static')
return send_from_directory(STATIC_DIR, 'js/auth-init.js', mimetype='application/javascript')
@app.route('/favicon.svg', methods=['GET'])
def serve_favicon():
project_root = os.path.dirname(BASE_DIR)
STATIC_DIR = os.path.join(project_root, 'static')
return send_from_directory(STATIC_DIR, 'img/favicon.svg', mimetype='image/svg+xml')
@app.route('/data/<path:filename>')
def serve_data(filename):
"""Serve public data files (policies, sitemap) from the project-level data/ directory."""
project_root = os.path.dirname(BASE_DIR)
data_dir = os.path.join(project_root, 'data')
# Security: only allow safe extensions
ALLOWED_DATA_EXTS = {'.md', '.json', '.txt'}
ext = os.path.splitext(filename)[1].lower()
if ext not in ALLOWED_DATA_EXTS:
return jsonify({'error': 'Access denied'}), 403
# Traversal guard
full = os.path.normpath(os.path.join(data_dir, filename))
data_abs = os.path.abspath(data_dir)
if not full.startswith(data_abs + os.sep) and full != data_abs:
return jsonify({'error': 'Access denied'}), 403
if not os.path.isfile(full):
return jsonify({'error': 'File not found'}), 404
MIME_MAP = {'.md': 'text/markdown', '.json': 'application/json', '.txt': 'text/plain'}
mime = MIME_MAP.get(ext)
if mime:
return send_from_directory(data_dir, filename, mimetype=mime, max_age=3600)
return send_from_directory(data_dir, filename, max_age=3600)
@app.route('/<path:filename>')
def serve_static(filename):
"""Serve public static assets with unified path resolution."""
project_root = os.path.dirname(BASE_DIR)
STATIC_DIR = os.path.join(project_root, 'static')
static_abs = os.path.abspath(STATIC_DIR)
# ── Security gates ────────────────────────────────────────────────────
ALLOWED_EXTS = {'.css', '.js', '.ico', '.png', '.jpg', '.jpeg', '.svg',
'.json', '.txt', '.pdf', '.webp', '.gif', '.md'}
BLOCKED = {'__pycache__', '.git', '.gitignore'}
ext = os.path.splitext(filename)[1].lower()
if ext in {'.py', '.env'}:
return jsonify({'error': 'Access denied'}), 403
if ext not in ALLOWED_EXTS:
return jsonify({'error': 'Access denied'}), 403
if any(b in filename for b in BLOCKED):
return jsonify({'error': 'Access denied'}), 403
# ── MIME type map ─────────────────────────────────────────────────────
MIME_MAP = {'.css': 'text/css', '.js': 'application/javascript',
'.svg': 'image/svg+xml', '.json': 'application/json'}
# ── Unified path resolution ───────────────────────────────────────────
# Strip redundant 'static/' prefix (templates may use /static/css/…)
clean = filename.replace('\\', '/')
if clean.startswith('static/'):
clean = clean[len('static/'):]
# Build ordered list of candidate relative paths to try
basename = clean.split('/')[-1]
EXT_DIRS = {'.css': 'css', '.js': 'js',
'.png': 'img', '.jpg': 'img', '.jpeg': 'img',
'.svg': 'img', '.ico': 'img', '.webp': 'img', '.gif': 'img'}
candidates = [clean] # 1) exact relative path (css/style.css)
if ext in EXT_DIRS:
mapped = f"{EXT_DIRS[ext]}/{basename}"
if mapped != clean:
candidates.append(mapped) # 2) extension-mapped path (css/style.css from style.css)
if ext in {'.json', '.txt', '.pdf', '.md'}:
candidates.append(f"assets/{basename}") # 3) assets fallback
candidates.append(basename) # 4) bare filename in root
for rel in candidates:
full = os.path.normpath(os.path.join(STATIC_DIR, rel))
# Directory traversal guard
if not full.startswith(static_abs + os.sep) and full != static_abs:
continue
if os.path.isfile(full):
safe_rel = os.path.relpath(full, STATIC_DIR).replace('\\', '/')
mime = MIME_MAP.get(ext)
if mime:
return send_from_directory(STATIC_DIR, safe_rel,
mimetype=mime, max_age=3600)
return send_from_directory(STATIC_DIR, safe_rel, max_age=3600)
# ── Log suppressing for noisy browser/extension probes ────────────────
NOISY_PROBES = {'.well-known', 'favicon.ico', 'manifest.json', '.map'}
is_noisy = any(probe in filename for probe in NOISY_PROBES)
if not is_noisy:
log.warning("Static file not found", extra={
"requested_file": filename, "candidates": candidates,
"static_dir": static_abs
})
return jsonify({'error': 'File not found'}), 404
# ── Error handlers ───────────────────────────────────────────────────────────
@app.errorhandler(400)
def bad_request(e):
return jsonify({'error': 'Bad request'}), 400
@app.errorhandler(401)
def unauthorized(e):
return jsonify({'error': 'Unauthorized', 'message': 'Invalid or missing HTTP-only cookie'}), 401
@app.errorhandler(403)
def forbidden(e):
return jsonify({'error': 'Forbidden', 'message': 'CSRF validation failed or insufficient permissions'}), 403
@app.errorhandler(404)
def not_found(e):
if request.path.startswith('/api/'):
log.debug("API endpoint not found", extra={"path": request.path})
return jsonify({'error': 'API endpoint not found'}), 404
# Redirect to landing page for unknown routes
return redirect(url_for('index'))
@app.errorhandler(500)
def server_error(e):
log.error("Unhandled server error", extra={
"error": str(e),
"path": request.path,
"method": request.method,
"remote_ip": request.remote_addr
})
return jsonify({'error': 'Internal server error'}), 500
# ── Startup ───────────────────────────────────────────────────────────────────
if __name__ == '__main__':
log.info("Starting Qualora Quality Auditor", extra={
"environment": "vercel" if os.getenv("VERCEL_ENV") else "local"
})
# Startup health check: verify critical services (warn, don't exit β€” for HF Spaces flexibility)
db = get_db()
if db is None:
log.warning("MongoDB connection unavailable at startup. Ensure MONGODB_URI is set via environment or secrets.")
else:
log.info("βœ“ MongoDB connected. Ready for audit operations.")
log.info("βœ“ Flask app initialized. Ready to handle requests.")
# Fix for Windows: Werkzeug's select() crashes with WinError 10038 during concurrent requests
is_windows = os.name == 'nt'
port = int(os.getenv("PORT", "8000"))
log.info(f"Listening on port {port}")
try:
app.run(debug=DEBUG, port=port, threaded=not is_windows)
except KeyboardInterrupt:
# Graceful local shutdown (Ctrl+C) without emitting a noisy traceback.
log.info("Shutdown requested by user (KeyboardInterrupt).")