Spaces:
Build error
Build error
Merge pull request #86 from abinaze/feat/d1-dataset-provenance-schema
Browse filesfeat(D1): local config-file loading + desktop-mode admin-auth hardening
- api/main.py +6 -0
- api/routes/admin.py +57 -0
- config/settings.py +91 -0
api/main.py
CHANGED
|
@@ -16,6 +16,12 @@ from api.models import HealthResponse, StatsResponse
|
|
| 16 |
from processing.alias_graph import AliasGraph # Phase 32
|
| 17 |
from processing.entity_resolver_v2 import EntityResolverV2 # Phase 32
|
| 18 |
from config.runtime_profile import PROFILE as _RUNTIME_PROFILE
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
|
| 21 |
@asynccontextmanager
|
|
|
|
| 16 |
from processing.alias_graph import AliasGraph # Phase 32
|
| 17 |
from processing.entity_resolver_v2 import EntityResolverV2 # Phase 32
|
| 18 |
from config.runtime_profile import PROFILE as _RUNTIME_PROFILE
|
| 19 |
+
import config.settings # noqa: F401 -- Phase D1: importing this loads any
|
| 20 |
+
# local desktop config file into os.environ as a
|
| 21 |
+
# side effect (mirrors load_dotenv() above). Must
|
| 22 |
+
# happen at real application startup, not just
|
| 23 |
+
# lazily inside admin.py, so every route -- not
|
| 24 |
+
# only /admin/* -- sees locally-configured values.
|
| 25 |
|
| 26 |
|
| 27 |
@asynccontextmanager
|
api/routes/admin.py
CHANGED
|
@@ -18,13 +18,70 @@ _pipeline_status = {
|
|
| 18 |
_pipeline_lock = threading.Lock()
|
| 19 |
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
def _require_admin(x_admin_secret: str = Header(default="")):
|
| 22 |
"""
|
| 23 |
H-04 FIX: even in DEBUG_MODE, require a non-empty X-Admin-Secret header
|
| 24 |
if ADMIN_SECRET is set. Previously DEBUG_MODE=true bypassed ALL auth,
|
| 25 |
allowing anyone to POST /admin/pipeline (database wipe/reseed) on the
|
| 26 |
production HuggingFace server if DEBUG_MODE was accidentally left on.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
secret = os.getenv("ADMIN_SECRET", "")
|
| 29 |
is_debug = os.getenv("DEBUG_MODE", "").lower() in ("1", "true", "yes")
|
| 30 |
|
|
|
|
| 18 |
_pipeline_lock = threading.Lock()
|
| 19 |
|
| 20 |
|
| 21 |
+
def _generate_and_persist_admin_secret() -> str:
|
| 22 |
+
"""Generate a random per-install admin secret and persist it locally.
|
| 23 |
+
|
| 24 |
+
Desktop groundwork (Phase D1). Persists via config.settings's local
|
| 25 |
+
config-file mechanism introduced in this same phase -- NOT yet the
|
| 26 |
+
real OS credential store (Windows Credential Manager) SECURITY.md's
|
| 27 |
+
Desktop Application Threat Model describes; that upgrade is Phase
|
| 28 |
+
D6's scope (API Key Manager, OS-keychain-backed storage). This is a
|
| 29 |
+
real, cryptographically random secret either way (secrets.token_urlsafe,
|
| 30 |
+
not a placeholder) -- just not yet in the most secure available
|
| 31 |
+
location for a desktop app specifically.
|
| 32 |
+
"""
|
| 33 |
+
import secrets as _secrets_module
|
| 34 |
+
from config.settings import set_local_setting
|
| 35 |
+
|
| 36 |
+
new_secret = _secrets_module.token_urlsafe(32)
|
| 37 |
+
set_local_setting("ADMIN_SECRET", new_secret)
|
| 38 |
+
logger.info("[Admin] Generated new per-install ADMIN_SECRET (desktop mode)")
|
| 39 |
+
return new_secret
|
| 40 |
+
|
| 41 |
+
|
| 42 |
def _require_admin(x_admin_secret: str = Header(default="")):
|
| 43 |
"""
|
| 44 |
H-04 FIX: even in DEBUG_MODE, require a non-empty X-Admin-Secret header
|
| 45 |
if ADMIN_SECRET is set. Previously DEBUG_MODE=true bypassed ALL auth,
|
| 46 |
allowing anyone to POST /admin/pipeline (database wipe/reseed) on the
|
| 47 |
production HuggingFace server if DEBUG_MODE was accidentally left on.
|
| 48 |
+
|
| 49 |
+
Phase D1 FIX: desktop builds (BHARATGRAPH_DESKTOP_MODE=true, a flag
|
| 50 |
+
the not-yet-built Tauri shell's backend-spawning code will set --
|
| 51 |
+
see PHASE_ROADMAP.md Phase D4) skip the DEBUG_MODE path entirely --
|
| 52 |
+
per SECURITY.md's Desktop Application Threat Model: "The web
|
| 53 |
+
deployment's DEBUG_MODE auth-bypass path... is not present in
|
| 54 |
+
desktop builds at all -- there is no bypass path to accidentally
|
| 55 |
+
leave enabled." Instead, a missing secret is generated and persisted
|
| 56 |
+
on the spot. Since BHARATGRAPH_DESKTOP_MODE is never set by any
|
| 57 |
+
current deployment (nothing sets it until Phase D4 exists), this
|
| 58 |
+
branch is unreachable today and the web deployment's behavior below
|
| 59 |
+
is completely unchanged.
|
| 60 |
"""
|
| 61 |
+
# Importing config.settings (even though nothing below references it
|
| 62 |
+
# by name) is not decorative -- it's what actually loads the local
|
| 63 |
+
# config file's persisted values into os.environ. api/main.py, the
|
| 64 |
+
# real application entry point, does the same import for the same
|
| 65 |
+
# reason (defense in depth: this function's correctness must not
|
| 66 |
+
# silently depend on something else having imported it first, since
|
| 67 |
+
# this file can be imported and tested in isolation, as it was
|
| 68 |
+
# during verification -- see the D1 commit message).
|
| 69 |
+
import config.settings # noqa: F401
|
| 70 |
+
|
| 71 |
+
is_desktop_mode = os.getenv("BHARATGRAPH_DESKTOP_MODE", "").lower() in ("1", "true", "yes")
|
| 72 |
+
|
| 73 |
+
if is_desktop_mode:
|
| 74 |
+
secret = os.getenv("ADMIN_SECRET", "")
|
| 75 |
+
if not secret or not secret.strip():
|
| 76 |
+
secret = _generate_and_persist_admin_secret()
|
| 77 |
+
if x_admin_secret != secret:
|
| 78 |
+
raise HTTPException(
|
| 79 |
+
status_code=403,
|
| 80 |
+
detail="Forbidden: invalid or missing X-Admin-Secret header",
|
| 81 |
+
)
|
| 82 |
+
return
|
| 83 |
+
|
| 84 |
+
# Web deployment: existing behavior, completely unchanged.
|
| 85 |
secret = os.getenv("ADMIN_SECRET", "")
|
| 86 |
is_debug = os.getenv("DEBUG_MODE", "").lower() in ("1", "true", "yes")
|
| 87 |
|
config/settings.py
CHANGED
|
@@ -1,9 +1,100 @@
|
|
| 1 |
import os
|
|
|
|
|
|
|
| 2 |
import warnings
|
|
|
|
| 3 |
from dotenv import load_dotenv
|
| 4 |
|
| 5 |
load_dotenv()
|
| 6 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
# Project Info
|
| 8 |
PROJECT_NAME = "BharatGraph"
|
| 9 |
VERSION = "0.36.0" # NEW-A7 FIX: bumped from 0.31.0 after 3 bug sprints
|
|
|
|
| 1 |
import os
|
| 2 |
+
import sys
|
| 3 |
+
import json
|
| 4 |
import warnings
|
| 5 |
+
from pathlib import Path
|
| 6 |
from dotenv import load_dotenv
|
| 7 |
|
| 8 |
load_dotenv()
|
| 9 |
|
| 10 |
+
# -- Local config-file support (Phase D1 -- desktop groundwork) --------
|
| 11 |
+
# Added ALONGSIDE the .env loading above, not replacing it -- a web
|
| 12 |
+
# deployment has no local config file, so this whole block is a no-op
|
| 13 |
+
# for it. Desktop builds (once Phase D4+ exist) can persist settings
|
| 14 |
+
# here -- e.g. a per-install ADMIN_SECRET generated by
|
| 15 |
+
# api/routes/admin.py (see SECURITY.md's Desktop Application Threat
|
| 16 |
+
# Model), or values a setup wizard collects in Phase D5 -- without a
|
| 17 |
+
# .env file, which desktop users won't have or know how to edit.
|
| 18 |
+
#
|
| 19 |
+
# Mirrors exactly how load_dotenv() above already works: injects any
|
| 20 |
+
# locally-configured value into os.environ, so every existing
|
| 21 |
+
# os.getenv(...) call site throughout the codebase (api/dependencies.py,
|
| 22 |
+
# every scraper, this file's own constants below) picks it up
|
| 23 |
+
# transparently -- nothing outside this block needs to change.
|
| 24 |
+
# Precedence: a real environment variable (including one already loaded
|
| 25 |
+
# from .env) always wins; the local config file only fills in keys that
|
| 26 |
+
# aren't already set.
|
| 27 |
+
|
| 28 |
+
def get_app_data_dir() -> Path:
|
| 29 |
+
"""OS-appropriate application-data directory for BharatGraph.
|
| 30 |
+
|
| 31 |
+
Desktop groundwork only -- not yet used for anything except the
|
| 32 |
+
local config file below. The full three-way app-files/user-data/
|
| 33 |
+
cache path split is a separate, larger piece of work (see
|
| 34 |
+
PHASE_ROADMAP.md, audit Finding P-1/F-3), not attempted here.
|
| 35 |
+
"""
|
| 36 |
+
if sys.platform == "win32":
|
| 37 |
+
base = os.getenv("APPDATA", str(Path.home() / "AppData" / "Roaming"))
|
| 38 |
+
elif sys.platform == "darwin":
|
| 39 |
+
base = str(Path.home() / "Library" / "Application Support")
|
| 40 |
+
else:
|
| 41 |
+
base = os.getenv("XDG_CONFIG_HOME", str(Path.home() / ".config"))
|
| 42 |
+
return Path(base) / "BharatGraph"
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _local_config_path() -> Path:
|
| 46 |
+
return get_app_data_dir() / "config.json"
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _load_local_config() -> dict:
|
| 50 |
+
"""Load config.json from the app-data dir. Returns {} if absent/invalid.
|
| 51 |
+
|
| 52 |
+
A missing or malformed local config must never crash startup --
|
| 53 |
+
.env and hardcoded defaults remain the fallback either way.
|
| 54 |
+
"""
|
| 55 |
+
path = _local_config_path()
|
| 56 |
+
if not path.exists():
|
| 57 |
+
return {}
|
| 58 |
+
try:
|
| 59 |
+
with open(path, "r", encoding="utf-8") as f:
|
| 60 |
+
data = json.load(f)
|
| 61 |
+
if not isinstance(data, dict):
|
| 62 |
+
warnings.warn(
|
| 63 |
+
f"Local config at {path} is not a JSON object -- ignoring.",
|
| 64 |
+
stacklevel=1,
|
| 65 |
+
)
|
| 66 |
+
return {}
|
| 67 |
+
return data
|
| 68 |
+
except (json.JSONDecodeError, OSError) as e:
|
| 69 |
+
warnings.warn(
|
| 70 |
+
f"Could not read local config at {path}: {e} -- ignoring.",
|
| 71 |
+
stacklevel=1,
|
| 72 |
+
)
|
| 73 |
+
return {}
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def set_local_setting(key: str, value: str) -> None:
|
| 77 |
+
"""Persist one key/value to the local config file AND this process's
|
| 78 |
+
environment, so it's picked up by every existing os.getenv(...) call
|
| 79 |
+
site immediately, not just on next restart.
|
| 80 |
+
|
| 81 |
+
Used by api/routes/admin.py to persist an auto-generated per-install
|
| 82 |
+
ADMIN_SECRET (see SECURITY.md's Desktop Application Threat Model).
|
| 83 |
+
"""
|
| 84 |
+
config = _load_local_config()
|
| 85 |
+
config[key] = value
|
| 86 |
+
path = _local_config_path()
|
| 87 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 88 |
+
with open(path, "w", encoding="utf-8") as f:
|
| 89 |
+
json.dump(config, f, indent=2)
|
| 90 |
+
os.environ[key] = value
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
_local_config = _load_local_config()
|
| 94 |
+
for _k, _v in _local_config.items():
|
| 95 |
+
if _v and not os.getenv(_k):
|
| 96 |
+
os.environ[_k] = str(_v)
|
| 97 |
+
|
| 98 |
# Project Info
|
| 99 |
PROJECT_NAME = "BharatGraph"
|
| 100 |
VERSION = "0.36.0" # NEW-A7 FIX: bumped from 0.31.0 after 3 bug sprints
|