- keystore KEY_REGISTRY copy splits the two keys (open/shared US-coast WS vs commercial Hormuz 5x/day REST); VesselAPI no longer reads as an AIS backup. - vesselapi.py docstring drops 'fallback'; states both are first-class. - /api/vessels docstring: last-known is the union (extra.src aisstream vs vesselapi). - tests: VesselAPI idles/polls independently of the AISStream key.
230 lines
8.9 KiB
Python
230 lines
8.9 KiB
Python
"""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)",
|
||
},
|
||
"NOUS_API_KEY": {
|
||
"description": "Nous Portal API key — 15-min news summarizer (inference-api.nousresearch.com).",
|
||
"pattern": r"^.{16,}$",
|
||
"example": "key from https://portal.nousresearch.com (API keys page)",
|
||
},
|
||
"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 (open/shared) — live US-coast AIS. Server-side WebSocket only.",
|
||
"pattern": r"^.{8,}$",
|
||
"example": "key from https://aisstream.io/account (GitHub login)",
|
||
},
|
||
"VESSELAPI_API_KEY": {
|
||
"description": "VesselAPI (commercial) — Strait of Hormuz AIS, 5×/day cache. Paste the Bearer token from dashboard.vesselapi.com. Not a US-coast feed.",
|
||
"pattern": r"^.{8,}$",
|
||
"example": "Bearer token from https://dashboard.vesselapi.com/",
|
||
},
|
||
"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, Nous, 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
|