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