Spaces:
Build error
Build error
File size: 11,361 Bytes
b1d8714 e97313c eece913 e97313c eece913 b1d8714 eece913 e97313c e26a730 188acc4 e26a730 188acc4 e26a730 188acc4 e26a730 e97313c d39cad8 e26a730 d39cad8 e26a730 e97313c d39cad8 4040e0f d39cad8 e97313c d39cad8 e97313c d39cad8 e97313c eece913 b1d8714 e97313c d8672be b1d8714 06efe08 b1d8714 d8672be b1d8714 b70b0c6 b1d8714 06efe08 b1d8714 eece913 b1d8714 d8672be 06efe08 eece913 e97313c eece913 d8672be e97313c eece913 d8672be eece913 d8672be e97313c d8672be b1d8714 eece913 e97313c eece913 d8672be eece913 e97313c 66a6483 d8672be e97313c d8672be eece913 66a6483 d8672be 66a6483 e97313c 66a6483 eece913 66a6483 eece913 e97313c 66a6483 eece913 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 | import os, sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
import threading
from datetime import datetime
from fastapi import APIRouter, Depends, BackgroundTasks, HTTPException, Header
from loguru import logger
from api.dependencies import get_db
router = APIRouter()
_pipeline_status = {
"running": False, "last_run": None,
"last_summary": None, "last_error": None,
}
# NEW-A6 FIX: lock prevents two concurrent POST /admin/pipeline requests from
# both passing the running=False check before either sets it to True
_pipeline_lock = threading.Lock()
def _generate_and_persist_admin_secret() -> str:
"""Generate a random per-install admin secret and persist it.
Desktop groundwork (Phase D1), upgraded in Phase D6: tries the real
OS credential store (Windows Credential Manager / macOS Keychain /
Linux Secret Service) first, via config.credentials.set_credential().
Falls back to the legacy config.json mechanism (config.settings's
set_local_setting) only if the keychain genuinely isn't available --
e.g. no backend installed, a headless Linux box with no Secret
Service running. Either way this is a real, cryptographically random
secret (secrets.token_urlsafe, not a placeholder); D6 only changes
WHERE it's persisted, not how it's generated.
set_local_setting() already injects into os.environ itself; the
keychain path does not (that's load_credentials_into_environ()'s job,
called once at process startup), so it's done explicitly here too --
otherwise a secret generated via the keychain path would be correct
on disk/in-keychain but invisible to the REST of this same running
process until next restart.
"""
import secrets as _secrets_module
from config.credentials import set_credential
from config.settings import set_local_setting
new_secret = _secrets_module.token_urlsafe(32)
if set_credential("ADMIN_SECRET", new_secret):
os.environ["ADMIN_SECRET"] = new_secret
logger.info("[Admin] Generated new per-install ADMIN_SECRET (OS keychain)")
else:
set_local_setting("ADMIN_SECRET", new_secret)
logger.info(
"[Admin] Generated new per-install ADMIN_SECRET "
"(config.json fallback -- OS keychain unavailable)"
)
return new_secret
def _require_admin(x_admin_secret: str = Header(default="")):
"""
H-04 FIX: even in DEBUG_MODE, require a non-empty X-Admin-Secret header
if ADMIN_SECRET is set. Previously DEBUG_MODE=true bypassed ALL auth,
allowing anyone to POST /admin/pipeline (database wipe/reseed) on the
production HuggingFace server if DEBUG_MODE was accidentally left on.
Phase D1 FIX: desktop builds (BHARATGRAPH_DESKTOP_MODE=true, a flag
the not-yet-built Tauri shell's backend-spawning code will set --
see PHASE_ROADMAP.md Phase D4) skip the DEBUG_MODE path entirely --
per SECURITY.md's Desktop Application Threat Model: "The web
deployment's DEBUG_MODE auth-bypass path... is not present in
desktop builds at all -- there is no bypass path to accidentally
leave enabled." Instead, a missing secret is generated and persisted
on the spot. Since BHARATGRAPH_DESKTOP_MODE is never set by any
current deployment (nothing sets it until Phase D4 exists), this
branch is unreachable today and the web deployment's behavior below
is completely unchanged.
"""
# Importing config.settings (even though nothing below references it
# by name) is not decorative -- it's what actually loads the local
# config file's persisted values into os.environ. api/main.py, the
# real application entry point, does the same import for the same
# reason (defense in depth: this function's correctness must not
# silently depend on something else having imported it first, since
# this file can be imported and tested in isolation, as it was
# during verification -- see the D1 commit message).
import config.settings # noqa: F401
is_desktop_mode = os.getenv("BHARATGRAPH_DESKTOP_MODE", "").lower() in ("1", "true", "yes")
if is_desktop_mode:
secret = os.getenv("ADMIN_SECRET", "")
if not secret or not secret.strip():
secret = _generate_and_persist_admin_secret()
if x_admin_secret != secret:
raise HTTPException(
status_code=403,
detail="Forbidden: invalid or missing X-Admin-Secret header",
)
return
# Web deployment: existing behavior, completely unchanged.
secret = os.getenv("ADMIN_SECRET", "")
is_debug = os.getenv("DEBUG_MODE", "").lower() in ("1", "true", "yes")
if not secret or not secret.strip(): # SEC-6 FIX: empty string is not valid
if not is_debug:
raise HTTPException(
status_code=503,
detail="Admin endpoints disabled: set ADMIN_SECRET env var",
)
# DEBUG_MODE without ADMIN_SECRET: warn but allow (local dev only)
logger.warning("[Admin] No ADMIN_SECRET set -- auth bypassed (DEBUG_MODE)")
return
# ADMIN_SECRET is set: always enforce it regardless of DEBUG_MODE
if x_admin_secret != secret:
raise HTTPException(
status_code=403,
detail="Forbidden: invalid or missing X-Admin-Secret header",
)
@router.post("/admin/seed")
def seed_database(
driver=Depends(get_db),
_auth=Depends(_require_admin),
):
"""Load sample nodes AND relationships into Neo4j for demonstration."""
from graph.seed import (
SAMPLE_POLITICIANS, SAMPLE_COMPANIES,
SAMPLE_CONTRACTS, SAMPLE_AUDIT_REPORTS,
SAMPLE_DIRECTOR_LINKS,
)
from graph.loader import GraphLoader
loader = GraphLoader(driver=driver)
p = loader.load_politicians(SAMPLE_POLITICIANS)
c = loader.load_companies(SAMPLE_COMPANIES)
k = loader.load_contracts(SAMPLE_CONTRACTS)
a = loader.load_audit_reports(SAMPLE_AUDIT_REPORTS)
d = loader.load_politician_company_links(SAMPLE_DIRECTOR_LINKS)
return {
"status": "seeded",
"politicians": p,
"companies": c,
"contracts": k,
"audit_reports": a,
"director_links":d,
"try_searching": [
"Modi", "Gandhi", "Adani", "Tata", "Infosys",
"Amit Shah", "Anurag Thakur", "road construction", "audit Maharashtra"
],
"note": (
"Sample data loaded with DIRECTOR_OF and WON_CONTRACT relationships. "
"Search any name above to see the investigation graph."
),
"next": "POST /admin/pipeline to ingest all 21 live government sources",
}
@router.post("/admin/pipeline")
def trigger_pipeline(
background_tasks: BackgroundTasks,
scrapers: str = "all",
driver=Depends(get_db),
_auth=Depends(_require_admin),
):
"""Trigger full pipeline in background. scrapers: comma-separated or 'all'."""
# NEW-A6 FIX: lock prevents two simultaneous requests both passing the
# running=False check before either sets running=True
with _pipeline_lock:
if _pipeline_status["running"]:
return {
"status": "already_running",
"started": _pipeline_status["last_run"],
"message": "Pipeline already running. Check /admin/pipeline/status",
}
_pipeline_status["running"] = True
_pipeline_status["last_run"] = datetime.now().isoformat()
scraper_list = None if scrapers == "all" else scrapers.split(",")
background_tasks.add_task(_run_pipeline_background, scraper_list, driver)
return {
"status": "started",
"scrapers": scrapers,
"message": "Pipeline running in background. Check /admin/pipeline/status",
"note": "Full run takes 3-8 minutes depending on network.",
}
@router.get("/admin/pipeline/status")
def pipeline_status():
return {
"running": _pipeline_status["running"],
"last_run": _pipeline_status["last_run"],
"last_summary": _pipeline_status["last_summary"],
"last_error": _pipeline_status["last_error"],
}
def _run_pipeline_background(scraper_list, driver):
"""Background task -- runs pipeline then loads ALL scraped data into Neo4j."""
try:
logger.info("[Admin] Background pipeline started")
from processing.pipeline import BharatGraphPipeline
from graph.loader import GraphLoader
results = BharatGraphPipeline().run(scrapers=scraper_list, parallel=True)
summary = results["summary"]
raw = results.get("raw", {})
logger.info("[Admin] Pipeline done -- loading into Neo4j...")
loader = GraphLoader(driver=driver)
SCRAPER_TO_LOADER = {
"myneta": "load_politicians",
"mca": "load_companies",
"gem": "load_contracts",
"cag": "load_audit_reports",
"pib": "load_press_releases",
"sebi": "load_regulatory_orders",
"ed": "load_enforcement_actions",
"electoral_bond": "load_electoral_bonds",
"ibbi": "load_insolvency_orders",
"ngo_darpan": "load_ngos",
"cppp": "load_tenders",
"loksabha": "load_parliament_questions",
"cvc": "load_vigilance_circulars",
"icij": "load_icij_entities",
"opensanctions": "load_sanctioned_entities",
"njdg": "load_court_cases",
"lgd": "load_local_bodies",
"ncrb": "load_crime_reports",
"wikidata": "load_wikidata_enrichments",
"datagov": "load_datagov_documents",
}
loaded = {}
for scraper_key, method_name in SCRAPER_TO_LOADER.items():
records = raw.get(scraper_key, [])
if not records:
logger.debug(f"[Admin] No records for {scraper_key}, skipping")
continue
method = getattr(loader, method_name, None)
if method is None:
logger.warning(f"[Admin] loader.{method_name}() not found -- skipping {scraper_key}")
continue
try:
n = method(records)
loaded[scraper_key] = n
logger.info(f"[Admin] Loaded {n} {scraper_key} records via {method_name}()")
except Exception as load_err:
logger.error(f"[Admin] Failed loading {scraper_key}: {load_err}")
loaded[scraper_key] = 0
total_loaded = sum(loaded.values())
_pipeline_status["last_summary"] = {
**summary,
"loaded": loaded,
"total_loaded": total_loaded,
"completed_at": datetime.now().isoformat(),
}
logger.success(
f"[Admin] Pipeline complete -- "
f"{total_loaded} total records loaded across {len(loaded)} datasets"
)
except Exception as e:
logger.error(f"[Admin] Pipeline failed: {e}")
_pipeline_status["last_error"] = str(e)
finally:
_pipeline_status["running"] = False
|