"""OSINT Dashboard — API key store (keyv-style Postgres table). Keys live in the ``api_keys`` table, shared between the dashboard app and the ingest services (both connect to the same Postgres). Only the app WRITES keys via the management API; ingest services read them with :func:`get_api_key`. Storage: the table is created lazily with ``CREATE TABLE IF NOT EXISTS`` on first use in each process (no alembic migration, so it can never fork the migration chain or block container startup). DDL is idempotent and safe when the app and ingester containers race on first boot. Security contract: * Values are stored in Postgres, never in the container image or frontend. * ``GET /api/keys`` returns only set/missing status plus a masked "****last4" hint. Full values are NEVER returned by the API. * Registered keys get cheap format validation on save (regex), e.g. the FIRMS map key must be a 32-char hex string. """ from __future__ import annotations import asyncio import re from datetime import datetime, timezone from sqlalchemy import Column, DateTime, String, Table, Text, func, select, text from database import async_session, engine, metadata # ── Table definition (bound to the shared metadata; created lazily) ─────── api_keys = Table( "api_keys", metadata, Column("name", String(128), primary_key=True), Column("value", Text, nullable=False), Column("created_at", DateTime(timezone=True), server_default=func.now(), nullable=False), Column("updated_at", DateTime(timezone=True), server_default=func.now(), nullable=False), ) _CREATE_TABLE_SQL = text( """ CREATE TABLE IF NOT EXISTS api_keys ( name VARCHAR(128) PRIMARY KEY, value TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ) """ ) # ── Registry of keys the ingest services understand ─────────────────────── # Each entry: human description (shown in the UI) + optional ``pattern`` for # cheap format validation and an ``example`` for error/placeholder text. KEY_REGISTRY: dict[str, dict] = { "FIRMS_MAP_KEY": { "description": "NASA FIRMS API key — active fire / satellite ingest.", "pattern": r"^[0-9a-fA-F]{32}$", "example": "32-char hex string (e.g. 5f3c…9a02)", }, "GEMINI_API_KEY": { "description": "Google Gemini API key — LLM event analysis / summarization.", "pattern": r"^AIza[0-9A-Za-z_-]{35}$", "example": "AIza… (Google API key, 39 chars)", }, "TELEGRAM_TOKEN": { "description": "Telegram bot token — push alert notifications to a channel.", "pattern": r"^\d{8,10}:[0-9A-Za-z_-]{35}$", "example": "123456789:AA… (bot token from @BotFather)", }, "AISSTREAM_API_KEY": { "description": "AISStream WebSocket key — live vessel positions (server-side only).", "pattern": r"^.{8,}$", "example": "key from https://aisstream.io/account (GitHub login)", }, "OPENSKY_CLIENT_ID": { "description": "OpenSky OAuth client id — optional ADS-B fallback (unused until enabled).", "example": "client id from opensky-network.org account", }, "OPENSKY_CLIENT_SECRET": { "description": "OpenSky OAuth client secret — optional ADS-B fallback.", "example": "client secret from the OpenSky account page", }, } # Any stored key must at least be a sane UPPER_SNAKE name. _NAME_RE = re.compile(r"^[A-Z][A-Z0-9_]{1,63}$") class KeyFormatError(ValueError): """Raised when a key name or value fails format validation.""" # ── Lazy table bootstrap ────────────────────────────────────────────────── _ensure_lock = asyncio.Lock() _ensured = False async def ensure_api_keys_table() -> None: """Create the api_keys table if it doesn't exist (idempotent, per process).""" global _ensured if _ensured: return async with _ensure_lock: if _ensured: return async with engine.begin() as conn: await conn.execute(_CREATE_TABLE_SQL) _ensured = True # ── Validation ──────────────────────────────────────────────────────────── def is_registered(name: str) -> bool: """True if ``name`` has an entry in the registry (gets format validation).""" return name in KEY_REGISTRY def validate_name(name: str) -> None: """Reject malformed key names (must be UPPER_SNAKE).""" if not _NAME_RE.match(name or ""): raise KeyFormatError( "Invalid key name — use UPPER_SNAKE_CASE (e.g. FIRMS_MAP_KEY)." ) def validate_value(name: str, value: str) -> None: """Cheap format validation for registered keys; rejects empty values.""" if not value or not value.strip(): raise KeyFormatError("Value must not be empty.") info = KEY_REGISTRY.get(name) pattern = (info or {}).get("pattern") if pattern and not re.match(pattern, value.strip()): example = (info or {}).get("example", "a matching value") raise KeyFormatError(f"'{name}' has an invalid format — expected {example}.") def mask_value(value: str | None) -> str: """Mask a stored value as '****last4' (or '****' when shorter than 4).""" if not value: return "" v = value.strip() tail = v[-4:] if len(v) >= 4 else "" return f"****{tail}" # ── Store operations ────────────────────────────────────────────────────── async def list_keys() -> list[dict]: """Registry keys + any extra stored keys, with masked set-status only.""" await ensure_api_keys_table() async with async_session() as session: rows = (await session.execute(select(api_keys))).mappings().all() stored = {r["name"]: r["value"] for r in rows} names = list(KEY_REGISTRY) + [n for n in stored if n not in KEY_REGISTRY] out = [] for name in names: value = stored.get(name) info = KEY_REGISTRY.get(name, {}) out.append( { "name": name, "set": value is not None, "masked": mask_value(value) if value is not None else None, "description": info.get("description", ""), "example": info.get("example", ""), "validated": name in KEY_REGISTRY, } ) out.sort(key=lambda k: k["name"].lower()) return out async def set_key(name: str, value: str) -> dict: """Upsert a key value. Validates registered formats; rejects bad names. Returns the masked status (never the raw value). """ validate_name(name) validate_value(name, value) value = value.strip() now = datetime.now(timezone.utc) await ensure_api_keys_table() async with async_session() as session: existing = ( await session.execute(select(api_keys).where(api_keys.c.name == name)) ).mappings().one_or_none() if existing: await session.execute( api_keys.update() .where(api_keys.c.name == name) .values(value=value, updated_at=now) ) else: await session.execute( api_keys.insert().values(name=name, value=value, updated_at=now) ) await session.commit() return {"name": name, "set": True, "masked": mask_value(value)} async def delete_key(name: str) -> bool: """Remove a stored key. Returns True if something was deleted.""" await ensure_api_keys_table() async with async_session() as session: result = await session.execute(api_keys.delete().where(api_keys.c.name == name)) await session.commit() return bool(result.rowcount) async def get_api_key(name: str) -> str | None: """Read a stored key value — used by ingest services, never by the API. Returns the raw value (or None when unset) so producers can pass it to external APIs (FIRMS, Gemini, Telegram, …). Reads live from Postgres, so a key set via the dashboard is picked up on the next poll — no restart. """ await ensure_api_keys_table() async with async_session() as session: row = ( await session.execute(select(api_keys).where(api_keys.c.name == name)) ).mappings().one_or_none() return row["value"] if row else None