bharatgraph / config /credentials.py
abinazebinoy's picture
Add python -m config.credentials CLI (D6, ahead of D5's UI)
10d643d
Raw History Blame Contribute Delete
8.14 kB
"""
Phase D6 -- OS-keychain-backed credential storage.
Extends config.settings's existing 2-level precedence cascade (real env
var > local config.json) with a third, LOWEST-precedence source: the OS
keychain (Windows Credential Manager / macOS Keychain / Linux Secret
Service), accessed via the `keyring` package.
Mirrors config.settings's established "inject into os.environ" design
exactly, so no other call site in the codebase (every existing
os.getenv(...) call) needs to change -- this module is only ever
consumed via load_credentials_into_environ(), called once from
config/settings.py, after the local-config injection loop and before
NEO4J_URI etc. are read as module-level constants.
A closed, explicit list of 6 managed keys -- deliberately not "whatever
key someone passes in" -- because the OS keychain is a shared, per-user
credential store; writing arbitrary app data into it would misuse the
mechanism.
Every keyring call is wrapped in try/except at the point of *use*
(get_password/set_password), not at import or backend-selection time,
because that is empirically where a missing backend actually raises
(NoKeyringError): keyring itself does not raise on `import keyring` or
`keyring.get_keyring()` even with zero usable backends -- it silently
falls back to a no-op "fail" backend and only errors when something
actually tries to read or write through it.
"""
import os
try:
import keyring
except ImportError: # pragma: no cover -- keyring is a requirements.txt
# dependency now, but importing this module must never crash a
# process that somehow lacks it (e.g. an old frozen build made
# before keyring was added) -- degrade to "no keychain available."
keyring = None
_SERVICE_NAME = "BharatGraph"
# Closed list -- the ONLY keys this module will ever read from or write
# to the OS keychain. Anything else is a programming error, not a
# missing-value case.
MANAGED_KEYS = frozenset({
"ADMIN_SECRET",
"NEO4J_URI",
"NEO4J_USER",
"NEO4J_PASSWORD",
"DATAGOV_API_KEY",
"OPENSANCTIONS_API_KEY",
})
def _check_managed(key: str) -> None:
assert key in MANAGED_KEYS, (
f"{key!r} is not one of config.credentials.MANAGED_KEYS -- the "
"OS keychain is a shared, per-user store and this module "
"deliberately only manages a fixed, explicit set of keys."
)
def get_credential(key: str) -> str:
"""Read one managed credential from the OS keychain.
Returns "" (never raises) if keyring isn't importable, no backend
is available, or the key was never set -- callers throughout this
codebase already treat an empty string as "not configured" (every
os.getenv(key, "") call site), so this matches that convention
instead of introducing a new one.
"""
_check_managed(key)
if keyring is None:
return ""
try:
value = keyring.get_password(_SERVICE_NAME, key)
except Exception:
# Broad on purpose: a missing backend raises NoKeyringError, but
# a *working* backend can still raise its own backend-specific
# error (e.g. a locked Secret Service prompt the user dismissed).
# Both mean "treat as unset" here.
return ""
return value or ""
def set_credential(key: str, value: str) -> bool:
"""Write one managed credential to the OS keychain.
Returns True on success, False on any failure (no keyring, no
backend, backend-specific error) -- never raises. Callers (today,
only api/routes/admin.py's auto-generated ADMIN_SECRET) fall back to
the legacy config.json mechanism when this returns False.
"""
_check_managed(key)
if keyring is None:
return False
try:
keyring.set_password(_SERVICE_NAME, key, value)
return True
except Exception:
return False
def load_credentials_into_environ() -> None:
"""Inject every managed credential into os.environ, at the lowest
precedence: a value already present in os.environ -- whether from a
real environment variable, .env, or config.settings's own local
config.json injection, which runs before this is called -- is never
overwritten.
Safe to call unconditionally, including in web mode: with no OS
keychain backend available, get_credential() returns "" for every
key and this becomes a no-op.
"""
for key in MANAGED_KEYS:
if os.getenv(key):
continue
value = get_credential(key)
if value:
os.environ[key] = value
def _mask(value: str) -> str:
"""Mask a secret for display: show at most its last 4 characters."""
if len(value) <= 4:
return "*" * len(value)
return "*" * (len(value) - 4) + value[-4:]
def _cli() -> int:
"""Tiny CLI for Phase D6, ahead of D5's setup-wizard UI.
Usage:
python -m config.credentials list
python -m config.credentials get KEY [--reveal]
python -m config.credentials set KEY [VALUE]
python -m config.credentials delete KEY
Exists so the 5 managed keys with no other write path yet (every-
thing except ADMIN_SECRET, which api/routes/admin.py already writes
automatically) can be tested against a REAL OS keychain -- something
no amount of sandbox simulation can substitute for -- before D5's
setup wizard exists to do this through an actual UI. Not a
replacement for D5: set_credential()/get_credential() are the exact
same functions a future UI would call, so nothing here needs to
change when that UI is built.
"""
import argparse
import getpass
parser = argparse.ArgumentParser(
prog="python -m config.credentials",
description="Manage BharatGraph's OS-keychain-stored credentials (Phase D6).",
)
sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser("list", help="Show which managed keys currently have a value (masked).")
p_get = sub.add_parser("get", help="Read one managed key.")
p_get.add_argument("key", choices=sorted(MANAGED_KEYS))
p_get.add_argument("--reveal", action="store_true", help="Print the full value instead of masking it.")
p_set = sub.add_parser("set", help="Write one managed key.")
p_set.add_argument("key", choices=sorted(MANAGED_KEYS))
p_set.add_argument("value", nargs="?", help="Value to store. Omit to be prompted (hidden input, not echoed).")
p_del = sub.add_parser("delete", help="Remove one managed key from the keychain.")
p_del.add_argument("key", choices=sorted(MANAGED_KEYS))
args = parser.parse_args()
if keyring is None:
print("keyring is not installed -- nothing to do.")
return 1
print(f"Using keychain backend: {keyring.get_keyring()}")
if args.command == "list":
for key in sorted(MANAGED_KEYS):
value = get_credential(key)
print(f" {key:24s} {_mask(value) if value else '(not set)'}")
return 0
if args.command == "get":
value = get_credential(args.key)
if not value:
print(f"{args.key}: (not set)")
return 1
print(f"{args.key}: {value if args.reveal else _mask(value)}")
return 0
if args.command == "set":
value = args.value if args.value is not None else getpass.getpass(f"Value for {args.key}: ")
if not value:
print("Refusing to store an empty value.")
return 1
if set_credential(args.key, value):
print(f"{args.key}: stored in OS keychain.")
return 0
print(f"{args.key}: could not store in OS keychain (no backend available?). Nothing was written.")
return 1
if args.command == "delete":
try:
keyring.delete_password(_SERVICE_NAME, args.key)
print(f"{args.key}: deleted from OS keychain.")
return 0
except Exception as e:
print(f"{args.key}: could not delete ({type(e).__name__}: {e}).")
return 1
return 1 # pragma: no cover -- argparse's required=True makes this unreachable
if __name__ == "__main__":
import sys as _sys
_sys.exit(_cli())