verifile-x-api / backend /services /api_key_manager.py
abinazebinoy's picture
fix(api-keys): add write lock to verify_key; resolve path via __file__
7064157
Raw
History Blame Contribute Delete
4.72 kB
"""
API Key Management and RBAC.
Raw keys are never stored — only SHA-256 hash is persisted.
Storage: api_keys.jsonl (append-only)
Roles:
viewer - GET only
analyst - GET + POST (all analysis endpoints)
admin - all methods including key management
"""
import json
import uuid
import hashlib
import secrets
import logging
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, Any, Optional, List
logger = logging.getLogger(__name__)
import threading as _threading
_key_write_lock = _threading.Lock()
KEYS_PATH = Path(__file__).parent.parent / "data" / "api_keys.jsonl"
ROLES = {
"viewer": {"GET"},
"analyst": {"GET", "POST"},
"admin": {"GET", "POST", "PATCH", "DELETE"},
}
_ANALYST_PATHS = {"/api/v1/analyze/", "/api/v1/cases/"}
_ADMIN_PATHS = {"/api/v1/keys/"}
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def _hash_key(raw_key: str) -> str:
return hashlib.sha256(raw_key.encode("utf-8")).hexdigest()
def _load_keys() -> Dict[str, Dict[str, Any]]:
keys: Dict[str, Dict[str, Any]] = {}
if not KEYS_PATH.exists():
return keys
try:
for line in KEYS_PATH.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line:
continue
try:
entry = json.loads(line)
kid = entry.get("key_id")
if kid:
keys[kid] = entry
except json.JSONDecodeError:
continue
except Exception as e:
logger.error(f"Failed to load API keys: {e}")
return keys
def _save_key(entry: Dict[str, Any]) -> None:
try:
with open(KEYS_PATH, "a", encoding="utf-8") as f:
f.write(json.dumps(entry) + "\n")
except Exception as e:
logger.error(f"Failed to save API key: {e}")
def create_key(name: str, role: str = "analyst",
description: str = "", created_by: str = "system") -> Dict[str, Any]:
if role not in ROLES:
return {"error": f"Invalid role. Must be one of: {list(ROLES.keys())}"}
if not name or not name.strip():
return {"error": "Key name is required."}
raw_key = f"vfx_{secrets.token_urlsafe(32)}"
key_hash = _hash_key(raw_key)
key_id = str(uuid.uuid4())
entry = {
"key_id": key_id, "name": name.strip()[:100],
"description": description.strip()[:500], "role": role,
"key_hash": key_hash, "created_at": _now(), "created_by": created_by,
"last_used": None, "use_count": 0, "active": True,
}
_save_key(entry)
logger.info(f"API key created: {key_id} name={name} role={role}")
return {**entry, "key": raw_key,
"warning": "Save this key now. It will not be shown again.",
"key_hash": "[hidden]"}
def verify_key(raw_key: str) -> Optional[Dict[str, Any]]:
if not raw_key or not raw_key.startswith("vfx_"):
return None
key_hash = _hash_key(raw_key)
keys = _load_keys()
for entry in keys.values():
if entry.get("key_hash") == key_hash and entry.get("active", False):
entry["last_used"] = _now()
entry["use_count"] = entry.get("use_count", 0) + 1
with _key_write_lock:
_save_key(entry)
return {k: v for k, v in entry.items() if k != "key_hash"}
return None
def check_permission(role: str, method: str, path: str) -> bool:
allowed_methods = ROLES.get(role, set())
if method.upper() not in allowed_methods:
return False
if role == "viewer":
for restricted in list(_ANALYST_PATHS) + list(_ADMIN_PATHS):
if path.startswith(restricted):
return False
if role == "analyst":
for restricted in _ADMIN_PATHS:
if path.startswith(restricted):
return False
return True
def revoke_key(key_id: str, revoked_by: str = "system") -> Dict[str, Any]:
keys = _load_keys()
if key_id not in keys:
return {"error": f"Key not found: {key_id}"}
entry = keys[key_id]
entry["active"] = False
entry["revoked_at"] = _now()
entry["revoked_by"] = revoked_by
_save_key(entry)
logger.info(f"API key revoked: {key_id}")
return {k: v for k, v in entry.items() if k != "key_hash"}
def list_keys(include_inactive: bool = False) -> List[Dict[str, Any]]:
keys = _load_keys()
result = []
for entry in keys.values():
if not include_inactive and not entry.get("active", False):
continue
result.append({k: v for k, v in entry.items() if k != "key_hash"})
return sorted(result, key=lambda x: x.get("created_at", ""), reverse=True)