abinazebinoy commited on
Commit
4cdca05
·
unverified ·
2 Parent(s): 879eaa7e26a730

Merge pull request #86 from abinaze/feat/d1-dataset-provenance-schema

Browse files

feat(D1): local config-file loading + desktop-mode admin-auth hardening

Files changed (3) hide show
  1. api/main.py +6 -0
  2. api/routes/admin.py +57 -0
  3. 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