Spaces:
Build error
Build error
Commit ·
84013a9
1
Parent(s): 5b1428f
Upload folder using huggingface_hub
Browse files- alembic/versions/a1f3c9d2e6b7_create_api_keys_table.py +58 -0
- app/api/routes_admin.py +23 -18
- app/api/routes_payments.py +7 -0
- app/core/usage_tracker.py +102 -87
- tests/test_routes_admin.py +83 -0
alembic/versions/a1f3c9d2e6b7_create_api_keys_table.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""create api_keys table (moves API key storage off ephemeral SQLite)
|
| 2 |
+
|
| 3 |
+
api_keys previously lived only in a SQLite file per service (arf_usage.db),
|
| 4 |
+
which is wiped on every Render deploy/restart on the Free plan and was also
|
| 5 |
+
independently duplicated between arf-api and arf-gateway. This creates the
|
| 6 |
+
durable, single source of truth in Postgres. Rows are hashed at rest
|
| 7 |
+
(pepper-HMAC lookup_hash + salted key_hash) -- no plaintext key column,
|
| 8 |
+
since there is no pre-pepper data to migrate here (confirmed with the user:
|
| 9 |
+
existing SQLite api_keys rows in both services are safe to discard, keys get
|
| 10 |
+
reissued via POST /admin/keys).
|
| 11 |
+
|
| 12 |
+
Revision ID: a1f3c9d2e6b7
|
| 13 |
+
Revises: d36deffe7fa2
|
| 14 |
+
Create Date: 2026-08-24 00:00:00.000000
|
| 15 |
+
|
| 16 |
+
"""
|
| 17 |
+
from typing import Sequence, Union
|
| 18 |
+
|
| 19 |
+
from alembic import op
|
| 20 |
+
import sqlalchemy as sa
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
# revision identifiers, used by Alembic.
|
| 24 |
+
revision: str = 'a1f3c9d2e6b7'
|
| 25 |
+
down_revision: Union[str, Sequence[str], None] = 'd36deffe7fa2'
|
| 26 |
+
branch_labels: Union[str, Sequence[str], None] = None
|
| 27 |
+
depends_on: Union[str, Sequence[str], None] = None
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def upgrade() -> None:
|
| 31 |
+
"""Upgrade schema."""
|
| 32 |
+
# No FK to tenants.id here, deliberately -- the pre-existing SQLite
|
| 33 |
+
# schema this replaces never enforced one either (seeded/demo keys via
|
| 34 |
+
# ARF_API_KEYS may reference synthetic tenant_ids that don't have a
|
| 35 |
+
# tenants row), and adding one now would be a behavior change beyond
|
| 36 |
+
# this migration's scope.
|
| 37 |
+
op.create_table(
|
| 38 |
+
'api_keys',
|
| 39 |
+
sa.Column('id', sa.Integer(), nullable=False),
|
| 40 |
+
sa.Column('tenant_id', sa.String(length=64), nullable=False),
|
| 41 |
+
sa.Column('tier', sa.String(length=32), nullable=False),
|
| 42 |
+
sa.Column('created_at', sa.DateTime(), nullable=False),
|
| 43 |
+
sa.Column('last_used_at', sa.DateTime(), nullable=True),
|
| 44 |
+
sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.true()),
|
| 45 |
+
sa.Column('salt', sa.String(length=64), nullable=False),
|
| 46 |
+
sa.Column('key_hash', sa.String(length=64), nullable=False),
|
| 47 |
+
sa.Column('lookup_hash', sa.String(length=64), nullable=False),
|
| 48 |
+
sa.PrimaryKeyConstraint('id'),
|
| 49 |
+
)
|
| 50 |
+
op.create_index(op.f('ix_api_keys_tenant_id'), 'api_keys', ['tenant_id'], unique=False)
|
| 51 |
+
op.create_unique_constraint('uq_api_keys_lookup_hash', 'api_keys', ['lookup_hash'])
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def downgrade() -> None:
|
| 55 |
+
"""Downgrade schema."""
|
| 56 |
+
op.drop_constraint('uq_api_keys_lookup_hash', 'api_keys', type_='unique')
|
| 57 |
+
op.drop_index(op.f('ix_api_keys_tenant_id'), table_name='api_keys')
|
| 58 |
+
op.drop_table('api_keys')
|
app/api/routes_admin.py
CHANGED
|
@@ -82,19 +82,20 @@ async def list_api_keys(limit: int = 100, offset: int = 0):
|
|
| 82 |
longer has -- query `/admin/keys/{api_key}/audit` with the real key for
|
| 83 |
per-key usage/audit history instead.
|
| 84 |
"""
|
| 85 |
-
with tracker.
|
| 86 |
-
rows =
|
|
|
|
| 87 |
"SELECT lookup_hash, tier, created_at, last_used_at, is_active FROM api_keys "
|
| 88 |
-
"
|
| 89 |
(limit, offset)
|
| 90 |
).fetchall()
|
|
|
|
| 91 |
keys = [
|
| 92 |
{
|
| 93 |
"key_id": row["lookup_hash"],
|
| 94 |
"tier": row["tier"],
|
| 95 |
-
"created_at":
|
| 96 |
-
"last_used_at":
|
| 97 |
-
if row["last_used_at"] else None,
|
| 98 |
"is_active": bool(row["is_active"]),
|
| 99 |
}
|
| 100 |
for row in rows
|
|
@@ -110,13 +111,14 @@ async def update_key_tier(
|
|
| 110 |
if req.tier not in [t.value for t in Tier]:
|
| 111 |
raise HTTPException(
|
| 112 |
status_code=400, detail=f"Invalid tier. Must be one of {[t.value for t in Tier]}")
|
| 113 |
-
with tracker.
|
| 114 |
-
row =
|
| 115 |
-
"SELECT lookup_hash FROM api_keys WHERE lookup_hash =
|
| 116 |
if not row:
|
|
|
|
| 117 |
raise HTTPException(status_code=404, detail="API key not found")
|
| 118 |
-
|
| 119 |
-
|
| 120 |
conn.commit()
|
| 121 |
return {"message": f"Tier updated to {req.tier}"}
|
| 122 |
|
|
@@ -124,13 +126,14 @@ async def update_key_tier(
|
|
| 124 |
@router.delete("/keys/{key_id}", dependencies=[Depends(verify_admin)])
|
| 125 |
async def deactivate_api_key(
|
| 126 |
key_id: str = Path(..., description="The key_id from GET /admin/keys (not the raw API key)")):
|
| 127 |
-
with tracker.
|
| 128 |
-
row =
|
| 129 |
-
"SELECT lookup_hash FROM api_keys WHERE lookup_hash =
|
| 130 |
if not row:
|
|
|
|
| 131 |
raise HTTPException(status_code=404, detail="API key not found")
|
| 132 |
-
|
| 133 |
-
"UPDATE api_keys SET is_active =
|
| 134 |
conn.commit()
|
| 135 |
return {"message": "API key deactivated"}
|
| 136 |
|
|
@@ -150,9 +153,11 @@ async def get_audit_logs(
|
|
| 150 |
|
| 151 |
@router.get("/stats", dependencies=[Depends(verify_admin)])
|
| 152 |
async def get_global_stats():
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
with tracker._get_conn() as conn:
|
| 154 |
-
total_keys = conn.execute(
|
| 155 |
-
"SELECT COUNT(*) FROM api_keys WHERE is_active = 1").fetchone()[0]
|
| 156 |
total_requests = conn.execute(
|
| 157 |
"SELECT COUNT(*) FROM usage_log").fetchone()[0]
|
| 158 |
by_tier = conn.execute(
|
|
|
|
| 82 |
longer has -- query `/admin/keys/{api_key}/audit` with the real key for
|
| 83 |
per-key usage/audit history instead.
|
| 84 |
"""
|
| 85 |
+
with tracker._get_pg_conn() as conn:
|
| 86 |
+
rows = tracker._pg_execute(
|
| 87 |
+
conn,
|
| 88 |
"SELECT lookup_hash, tier, created_at, last_used_at, is_active FROM api_keys "
|
| 89 |
+
"ORDER BY created_at DESC LIMIT %s OFFSET %s",
|
| 90 |
(limit, offset)
|
| 91 |
).fetchall()
|
| 92 |
+
conn.commit()
|
| 93 |
keys = [
|
| 94 |
{
|
| 95 |
"key_id": row["lookup_hash"],
|
| 96 |
"tier": row["tier"],
|
| 97 |
+
"created_at": row["created_at"].isoformat(),
|
| 98 |
+
"last_used_at": row["last_used_at"].isoformat() if row["last_used_at"] else None,
|
|
|
|
| 99 |
"is_active": bool(row["is_active"]),
|
| 100 |
}
|
| 101 |
for row in rows
|
|
|
|
| 111 |
if req.tier not in [t.value for t in Tier]:
|
| 112 |
raise HTTPException(
|
| 113 |
status_code=400, detail=f"Invalid tier. Must be one of {[t.value for t in Tier]}")
|
| 114 |
+
with tracker._get_pg_conn() as conn:
|
| 115 |
+
row = tracker._pg_execute(
|
| 116 |
+
conn, "SELECT lookup_hash FROM api_keys WHERE lookup_hash = %s", (key_id,)).fetchone()
|
| 117 |
if not row:
|
| 118 |
+
conn.rollback()
|
| 119 |
raise HTTPException(status_code=404, detail="API key not found")
|
| 120 |
+
tracker._pg_execute(
|
| 121 |
+
conn, "UPDATE api_keys SET tier = %s WHERE lookup_hash = %s", (req.tier, key_id))
|
| 122 |
conn.commit()
|
| 123 |
return {"message": f"Tier updated to {req.tier}"}
|
| 124 |
|
|
|
|
| 126 |
@router.delete("/keys/{key_id}", dependencies=[Depends(verify_admin)])
|
| 127 |
async def deactivate_api_key(
|
| 128 |
key_id: str = Path(..., description="The key_id from GET /admin/keys (not the raw API key)")):
|
| 129 |
+
with tracker._get_pg_conn() as conn:
|
| 130 |
+
row = tracker._pg_execute(
|
| 131 |
+
conn, "SELECT lookup_hash FROM api_keys WHERE lookup_hash = %s", (key_id,)).fetchone()
|
| 132 |
if not row:
|
| 133 |
+
conn.rollback()
|
| 134 |
raise HTTPException(status_code=404, detail="API key not found")
|
| 135 |
+
tracker._pg_execute(
|
| 136 |
+
conn, "UPDATE api_keys SET is_active = false WHERE lookup_hash = %s", (key_id,))
|
| 137 |
conn.commit()
|
| 138 |
return {"message": "API key deactivated"}
|
| 139 |
|
|
|
|
| 153 |
|
| 154 |
@router.get("/stats", dependencies=[Depends(verify_admin)])
|
| 155 |
async def get_global_stats():
|
| 156 |
+
with tracker._get_pg_conn() as pg_conn:
|
| 157 |
+
total_keys = tracker._pg_execute(
|
| 158 |
+
pg_conn, "SELECT COUNT(*) FROM api_keys WHERE is_active = true").fetchone()["count"]
|
| 159 |
+
pg_conn.commit()
|
| 160 |
with tracker._get_conn() as conn:
|
|
|
|
|
|
|
| 161 |
total_requests = conn.execute(
|
| 162 |
"SELECT COUNT(*) FROM usage_log").fetchone()[0]
|
| 163 |
by_tier = conn.execute(
|
app/api/routes_payments.py
CHANGED
|
@@ -50,6 +50,13 @@ async def create_checkout_session(req: CheckoutRequest):
|
|
| 50 |
cancel_url=req.cancel_url,
|
| 51 |
metadata={"api_key": req.api_key},
|
| 52 |
client_reference_id=req.api_key,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
)
|
| 54 |
return {"sessionId": checkout_session.id, "url": checkout_session.url}
|
| 55 |
except Exception as e:
|
|
|
|
| 50 |
cancel_url=req.cancel_url,
|
| 51 |
metadata={"api_key": req.api_key},
|
| 52 |
client_reference_id=req.api_key,
|
| 53 |
+
# checkout.session.completed carries this metadata via
|
| 54 |
+
# session.metadata (handled below), but customer.subscription.*
|
| 55 |
+
# events only carry the *subscription's own* metadata -- Stripe
|
| 56 |
+
# does not copy Session.metadata onto the Subscription it
|
| 57 |
+
# creates. Without this, cancellations can't be traced back to
|
| 58 |
+
# an api_key and PRO tier never downgrades.
|
| 59 |
+
subscription_data={"metadata": {"api_key": req.api_key}},
|
| 60 |
)
|
| 61 |
return {"sessionId": checkout_session.id, "url": checkout_session.url}
|
| 62 |
except Exception as e:
|
app/core/usage_tracker.py
CHANGED
|
@@ -10,11 +10,15 @@ HMAC-SHA256(pepper, key) -- a deterministic but one-way value computed with
|
|
| 10 |
a server-only secret (ARF_KEY_PEPPER), so a leaked database alone does not
|
| 11 |
expose usable keys. A per-row random salt plus a second SHA-256 check is a
|
| 12 |
defense-in-depth verification layer after the row is found by lookup hash.
|
| 13 |
-
This mirrors arf-gateway's internal/auth/apikey.go
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
this
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
"""
|
| 19 |
import hashlib
|
| 20 |
import hmac
|
|
@@ -24,6 +28,9 @@ import secrets
|
|
| 24 |
import sqlite3
|
| 25 |
import threading
|
| 26 |
import time
|
|
|
|
|
|
|
|
|
|
| 27 |
from contextlib import contextmanager
|
| 28 |
from datetime import datetime, timedelta
|
| 29 |
from dataclasses import dataclass
|
|
@@ -104,9 +111,14 @@ class UsageTracker:
|
|
| 104 |
f"ARF_KEY_PEPPER is too short ({len(self._pepper)} chars); "
|
| 105 |
"use at least 32 random characters."
|
| 106 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
self._local = threading.local()
|
| 108 |
self._init_db()
|
| 109 |
-
self._migrate_plaintext_keys()
|
| 110 |
|
| 111 |
self._redis_client = None
|
| 112 |
if redis_url and REDIS_AVAILABLE:
|
|
@@ -125,13 +137,15 @@ class UsageTracker:
|
|
| 125 |
already been found via lookup hash."""
|
| 126 |
return hashlib.sha256(bytes.fromhex(salt_hex) + key.encode()).hexdigest()
|
| 127 |
|
| 128 |
-
def _verify_key(self, conn, api_key: str) -> Optional[
|
| 129 |
"""Look up a row by pepper-HMAC, then verify with the salted hash.
|
| 130 |
-
|
| 131 |
-
key is valid and
|
| 132 |
-
|
|
|
|
|
|
|
| 133 |
"SELECT tenant_id, tier, is_active, salt, key_hash FROM api_keys "
|
| 134 |
-
"WHERE lookup_hash =
|
| 135 |
(self._lookup_hash(api_key),)
|
| 136 |
).fetchone()
|
| 137 |
if not row or not row["is_active"]:
|
|
@@ -142,7 +156,10 @@ class UsageTracker:
|
|
| 142 |
|
| 143 |
@contextmanager
|
| 144 |
def _get_conn(self):
|
| 145 |
-
"""Get a thread‑local SQLite connection with WAL and immediate transactions.
|
|
|
|
|
|
|
|
|
|
| 146 |
if not hasattr(self._local, "conn"):
|
| 147 |
self._local.conn = sqlite3.connect(
|
| 148 |
self.db_path, check_same_thread=False, isolation_level=None)
|
|
@@ -150,38 +167,65 @@ class UsageTracker:
|
|
| 150 |
self._local.conn.execute("PRAGMA journal_mode=WAL")
|
| 151 |
yield self._local.conn
|
| 152 |
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
CREATE TABLE IF NOT EXISTS api_keys (
|
| 161 |
-
|
| 162 |
-
tenant_id
|
| 163 |
-
tier
|
| 164 |
-
created_at
|
| 165 |
-
last_used_at
|
| 166 |
-
is_active
|
| 167 |
-
salt
|
| 168 |
-
key_hash
|
| 169 |
-
lookup_hash
|
| 170 |
)
|
| 171 |
""")
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
conn.execute(
|
| 181 |
-
"CREATE UNIQUE INDEX IF NOT EXISTS idx_api_keys_lookup_hash "
|
| 182 |
-
"ON api_keys(lookup_hash) WHERE lookup_hash != ''"
|
| 183 |
-
)
|
| 184 |
conn.commit()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
conn.execute("""
|
| 186 |
CREATE TABLE IF NOT EXISTS usage_log (
|
| 187 |
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
@@ -219,35 +263,6 @@ class UsageTracker:
|
|
| 219 |
def _get_month_key(self) -> str:
|
| 220 |
return datetime.now().strftime("%Y-%m")
|
| 221 |
|
| 222 |
-
def _migrate_plaintext_keys(self):
|
| 223 |
-
"""Find rows left over from before pepper-HMAC lookup existed
|
| 224 |
-
(lookup_hash empty, key non-empty), hash them, and clear the
|
| 225 |
-
plaintext key column. Safe to run on every startup: already-migrated
|
| 226 |
-
rows (key already cleared) are no-ops."""
|
| 227 |
-
with self._get_conn() as conn:
|
| 228 |
-
legacy = conn.execute(
|
| 229 |
-
"SELECT key FROM api_keys WHERE lookup_hash = '' AND key != ''"
|
| 230 |
-
).fetchall()
|
| 231 |
-
if not legacy:
|
| 232 |
-
return
|
| 233 |
-
import logging
|
| 234 |
-
logger = logging.getLogger(__name__)
|
| 235 |
-
for row in legacy:
|
| 236 |
-
plaintext_key = row["key"]
|
| 237 |
-
salt = secrets.token_hex(16)
|
| 238 |
-
conn.execute(
|
| 239 |
-
"UPDATE api_keys SET lookup_hash = ?, salt = ?, key_hash = ?, key = NULL "
|
| 240 |
-
"WHERE key = ?",
|
| 241 |
-
(self._lookup_hash(plaintext_key), salt,
|
| 242 |
-
self._salted_hash(plaintext_key, salt), plaintext_key)
|
| 243 |
-
)
|
| 244 |
-
conn.commit()
|
| 245 |
-
logger.warning(
|
| 246 |
-
"Migrated %d plaintext API key(s) to pepper-HMAC lookup; this database "
|
| 247 |
-
"previously stored keys in plaintext -- rotate keys and check backups/"
|
| 248 |
-
"exports for leaked copies.", len(legacy)
|
| 249 |
-
)
|
| 250 |
-
|
| 251 |
def get_or_create_api_key(self, key: str, tenant_id: str, tier: Tier = Tier.FREE) -> bool:
|
| 252 |
"""
|
| 253 |
Register a new API key for a given tenant.
|
|
@@ -261,25 +276,24 @@ class UsageTracker:
|
|
| 261 |
True if key was created (or already exists for the same tenant).
|
| 262 |
"""
|
| 263 |
lookup_hash = self._lookup_hash(key)
|
| 264 |
-
with self.
|
| 265 |
-
row =
|
| 266 |
-
"SELECT tenant_id FROM api_keys WHERE lookup_hash =
|
| 267 |
).fetchone()
|
| 268 |
if row:
|
| 269 |
# Key already exists – ensure it belongs to the same tenant
|
| 270 |
if row["tenant_id"] != tenant_id:
|
|
|
|
| 271 |
raise ValueError(f"Key {key[:8]}... already belongs to a different tenant.")
|
|
|
|
| 272 |
return True
|
| 273 |
salt = secrets.token_hex(16)
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
# without violating the PRIMARY KEY constraint on that
|
| 277 |
-
# column -- SQLite permits multiple NULLs in a non-INTEGER
|
| 278 |
-
# PRIMARY KEY column, but not multiple empty strings.
|
| 279 |
"INSERT INTO api_keys "
|
| 280 |
-
"(
|
| 281 |
-
"VALUES (
|
| 282 |
-
(tenant_id, tier.value,
|
| 283 |
salt, self._salted_hash(key, salt), lookup_hash)
|
| 284 |
)
|
| 285 |
conn.commit()
|
|
@@ -287,27 +301,28 @@ class UsageTracker:
|
|
| 287 |
|
| 288 |
def get_tier(self, api_key: str) -> Optional[Tier]:
|
| 289 |
"""Return the tier for a given API key, or None if key invalid/inactive."""
|
| 290 |
-
with self.
|
| 291 |
row = self._verify_key(conn, api_key)
|
| 292 |
return Tier(row["tier"]) if row else None
|
| 293 |
|
| 294 |
def get_tenant_id(self, api_key: str) -> Optional[str]:
|
| 295 |
"""Return the tenant ID associated with the API key, or None if key invalid."""
|
| 296 |
-
with self.
|
| 297 |
row = self._verify_key(conn, api_key)
|
| 298 |
return row["tenant_id"] if row else None
|
| 299 |
|
| 300 |
def update_api_key_tier(self, api_key: str, new_tier: Tier) -> bool:
|
| 301 |
"""Update the tier of an existing API key. Returns True if successful."""
|
| 302 |
lookup_hash = self._lookup_hash(api_key)
|
| 303 |
-
with self.
|
| 304 |
-
row =
|
| 305 |
-
"SELECT lookup_hash FROM api_keys WHERE lookup_hash =
|
| 306 |
).fetchone()
|
| 307 |
if not row:
|
|
|
|
| 308 |
return False
|
| 309 |
-
|
| 310 |
-
"UPDATE api_keys SET tier =
|
| 311 |
(new_tier.value, lookup_hash))
|
| 312 |
conn.commit()
|
| 313 |
return True
|
|
|
|
| 10 |
a server-only secret (ARF_KEY_PEPPER), so a leaked database alone does not
|
| 11 |
expose usable keys. A per-row random salt plus a second SHA-256 check is a
|
| 12 |
defense-in-depth verification layer after the row is found by lookup hash.
|
| 13 |
+
This mirrors arf-gateway's internal/auth/apikey.go.
|
| 14 |
+
|
| 15 |
+
`api_keys` itself lives in Postgres (DATABASE_URL), not in the SQLite file
|
| 16 |
+
the rest of this module uses -- a single durable table that both arf-api
|
| 17 |
+
and arf-gateway point at, instead of each service's own local SQLite copy
|
| 18 |
+
(which on Render's Free plan is wiped on every deploy/restart anyway).
|
| 19 |
+
`monthly_counts`, `usage_log`, and `idempotency_keys` are unchanged and
|
| 20 |
+
still SQLite, still keyed by the raw API key, since arf-gateway reads
|
| 21 |
+
`monthly_counts` that way.
|
| 22 |
"""
|
| 23 |
import hashlib
|
| 24 |
import hmac
|
|
|
|
| 28 |
import sqlite3
|
| 29 |
import threading
|
| 30 |
import time
|
| 31 |
+
|
| 32 |
+
import psycopg2
|
| 33 |
+
import psycopg2.extras
|
| 34 |
from contextlib import contextmanager
|
| 35 |
from datetime import datetime, timedelta
|
| 36 |
from dataclasses import dataclass
|
|
|
|
| 111 |
f"ARF_KEY_PEPPER is too short ({len(self._pepper)} chars); "
|
| 112 |
"use at least 32 random characters."
|
| 113 |
)
|
| 114 |
+
self._pg_dsn = os.getenv("DATABASE_URL", "")
|
| 115 |
+
if not self._pg_dsn:
|
| 116 |
+
raise RuntimeError(
|
| 117 |
+
"DATABASE_URL is not set -- refusing to start without it, "
|
| 118 |
+
"since api_keys is stored in Postgres, not SQLite."
|
| 119 |
+
)
|
| 120 |
self._local = threading.local()
|
| 121 |
self._init_db()
|
|
|
|
| 122 |
|
| 123 |
self._redis_client = None
|
| 124 |
if redis_url and REDIS_AVAILABLE:
|
|
|
|
| 137 |
already been found via lookup hash."""
|
| 138 |
return hashlib.sha256(bytes.fromhex(salt_hex) + key.encode()).hexdigest()
|
| 139 |
|
| 140 |
+
def _verify_key(self, conn, api_key: str) -> Optional[dict]:
|
| 141 |
"""Look up a row by pepper-HMAC, then verify with the salted hash.
|
| 142 |
+
`conn` is a Postgres connection from _get_pg_conn. Returns the row
|
| 143 |
+
(tenant_id, tier, is_active, salt, key_hash) if the key is valid and
|
| 144 |
+
active, else None."""
|
| 145 |
+
row = self._pg_execute(
|
| 146 |
+
conn,
|
| 147 |
"SELECT tenant_id, tier, is_active, salt, key_hash FROM api_keys "
|
| 148 |
+
"WHERE lookup_hash = %s",
|
| 149 |
(self._lookup_hash(api_key),)
|
| 150 |
).fetchone()
|
| 151 |
if not row or not row["is_active"]:
|
|
|
|
| 156 |
|
| 157 |
@contextmanager
|
| 158 |
def _get_conn(self):
|
| 159 |
+
"""Get a thread‑local SQLite connection with WAL and immediate transactions.
|
| 160 |
+
|
| 161 |
+
Backs usage_log/monthly_counts/idempotency_keys only -- api_keys
|
| 162 |
+
lives in Postgres, see _get_pg_conn below."""
|
| 163 |
if not hasattr(self._local, "conn"):
|
| 164 |
self._local.conn = sqlite3.connect(
|
| 165 |
self.db_path, check_same_thread=False, isolation_level=None)
|
|
|
|
| 167 |
self._local.conn.execute("PRAGMA journal_mode=WAL")
|
| 168 |
yield self._local.conn
|
| 169 |
|
| 170 |
+
@contextmanager
|
| 171 |
+
def _get_pg_conn(self):
|
| 172 |
+
"""Get a thread-local Postgres connection for the api_keys table.
|
| 173 |
+
Rows come back as dict-like objects (row["col"]) via RealDictCursor,
|
| 174 |
+
matching the sqlite3.Row access pattern used elsewhere in this file."""
|
| 175 |
+
if not hasattr(self._local, "pg_conn") or self._local.pg_conn.closed:
|
| 176 |
+
self._local.pg_conn = psycopg2.connect(
|
| 177 |
+
self._pg_dsn, cursor_factory=psycopg2.extras.RealDictCursor)
|
| 178 |
+
yield self._local.pg_conn
|
| 179 |
+
|
| 180 |
+
@staticmethod
|
| 181 |
+
def _pg_execute(conn, sql: str, params: tuple = ()):
|
| 182 |
+
"""Run a query against a Postgres connection and return the cursor,
|
| 183 |
+
so callers can chain .fetchone()/.fetchall() the same way sqlite3's
|
| 184 |
+
conn.execute(...) is used elsewhere in this file."""
|
| 185 |
+
cur = conn.cursor()
|
| 186 |
+
cur.execute(sql, params)
|
| 187 |
+
return cur
|
| 188 |
+
|
| 189 |
+
def _init_pg_db(self):
|
| 190 |
+
"""Idempotently ensure the Postgres api_keys table/index exist.
|
| 191 |
+
|
| 192 |
+
The canonical schema is the Alembic migration
|
| 193 |
+
(alembic/versions/*_create_api_keys_table.py) -- but nothing in this
|
| 194 |
+
codebase runs `alembic upgrade head` automatically on deploy (a
|
| 195 |
+
known gap, tracked separately), and arf-gateway's Go code needs the
|
| 196 |
+
same table without going through Python/Alembic at all. Mirroring
|
| 197 |
+
the same CREATE TABLE IF NOT EXISTS self-healing pattern this file
|
| 198 |
+
already uses for its SQLite tables keeps both services (and tests)
|
| 199 |
+
working whether or not the migration has actually been applied.
|
| 200 |
+
Column set/types must stay in sync with that migration."""
|
| 201 |
+
with self._get_pg_conn() as conn:
|
| 202 |
+
self._pg_execute(conn, """
|
| 203 |
CREATE TABLE IF NOT EXISTS api_keys (
|
| 204 |
+
id SERIAL PRIMARY KEY,
|
| 205 |
+
tenant_id VARCHAR(64) NOT NULL,
|
| 206 |
+
tier VARCHAR(32) NOT NULL,
|
| 207 |
+
created_at TIMESTAMP NOT NULL,
|
| 208 |
+
last_used_at TIMESTAMP,
|
| 209 |
+
is_active BOOLEAN NOT NULL DEFAULT true,
|
| 210 |
+
salt VARCHAR(64) NOT NULL,
|
| 211 |
+
key_hash VARCHAR(64) NOT NULL,
|
| 212 |
+
lookup_hash VARCHAR(64) NOT NULL
|
| 213 |
)
|
| 214 |
""")
|
| 215 |
+
self._pg_execute(conn, """
|
| 216 |
+
CREATE UNIQUE INDEX IF NOT EXISTS uq_api_keys_lookup_hash
|
| 217 |
+
ON api_keys (lookup_hash)
|
| 218 |
+
""")
|
| 219 |
+
self._pg_execute(conn, """
|
| 220 |
+
CREATE INDEX IF NOT EXISTS ix_api_keys_tenant_id
|
| 221 |
+
ON api_keys (tenant_id)
|
| 222 |
+
""")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 223 |
conn.commit()
|
| 224 |
+
|
| 225 |
+
def _init_db(self):
|
| 226 |
+
"""Initialise SQLite tables for usage_log/monthly_counts/idempotency_keys."""
|
| 227 |
+
self._init_pg_db()
|
| 228 |
+
with self._get_conn() as conn:
|
| 229 |
conn.execute("""
|
| 230 |
CREATE TABLE IF NOT EXISTS usage_log (
|
| 231 |
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
|
| 263 |
def _get_month_key(self) -> str:
|
| 264 |
return datetime.now().strftime("%Y-%m")
|
| 265 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
def get_or_create_api_key(self, key: str, tenant_id: str, tier: Tier = Tier.FREE) -> bool:
|
| 267 |
"""
|
| 268 |
Register a new API key for a given tenant.
|
|
|
|
| 276 |
True if key was created (or already exists for the same tenant).
|
| 277 |
"""
|
| 278 |
lookup_hash = self._lookup_hash(key)
|
| 279 |
+
with self._get_pg_conn() as conn:
|
| 280 |
+
row = self._pg_execute(
|
| 281 |
+
conn, "SELECT tenant_id FROM api_keys WHERE lookup_hash = %s", (lookup_hash,)
|
| 282 |
).fetchone()
|
| 283 |
if row:
|
| 284 |
# Key already exists – ensure it belongs to the same tenant
|
| 285 |
if row["tenant_id"] != tenant_id:
|
| 286 |
+
conn.rollback()
|
| 287 |
raise ValueError(f"Key {key[:8]}... already belongs to a different tenant.")
|
| 288 |
+
conn.commit()
|
| 289 |
return True
|
| 290 |
salt = secrets.token_hex(16)
|
| 291 |
+
self._pg_execute(
|
| 292 |
+
conn,
|
|
|
|
|
|
|
|
|
|
| 293 |
"INSERT INTO api_keys "
|
| 294 |
+
"(tenant_id, tier, created_at, is_active, salt, key_hash, lookup_hash) "
|
| 295 |
+
"VALUES (%s, %s, %s, %s, %s, %s, %s)",
|
| 296 |
+
(tenant_id, tier.value, datetime.utcnow(), True,
|
| 297 |
salt, self._salted_hash(key, salt), lookup_hash)
|
| 298 |
)
|
| 299 |
conn.commit()
|
|
|
|
| 301 |
|
| 302 |
def get_tier(self, api_key: str) -> Optional[Tier]:
|
| 303 |
"""Return the tier for a given API key, or None if key invalid/inactive."""
|
| 304 |
+
with self._get_pg_conn() as conn:
|
| 305 |
row = self._verify_key(conn, api_key)
|
| 306 |
return Tier(row["tier"]) if row else None
|
| 307 |
|
| 308 |
def get_tenant_id(self, api_key: str) -> Optional[str]:
|
| 309 |
"""Return the tenant ID associated with the API key, or None if key invalid."""
|
| 310 |
+
with self._get_pg_conn() as conn:
|
| 311 |
row = self._verify_key(conn, api_key)
|
| 312 |
return row["tenant_id"] if row else None
|
| 313 |
|
| 314 |
def update_api_key_tier(self, api_key: str, new_tier: Tier) -> bool:
|
| 315 |
"""Update the tier of an existing API key. Returns True if successful."""
|
| 316 |
lookup_hash = self._lookup_hash(api_key)
|
| 317 |
+
with self._get_pg_conn() as conn:
|
| 318 |
+
row = self._pg_execute(
|
| 319 |
+
conn, "SELECT lookup_hash FROM api_keys WHERE lookup_hash = %s", (lookup_hash,)
|
| 320 |
).fetchone()
|
| 321 |
if not row:
|
| 322 |
+
conn.rollback()
|
| 323 |
return False
|
| 324 |
+
self._pg_execute(
|
| 325 |
+
conn, "UPDATE api_keys SET tier = %s WHERE lookup_hash = %s",
|
| 326 |
(new_tier.value, lookup_hash))
|
| 327 |
conn.commit()
|
| 328 |
return True
|
tests/test_routes_admin.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Route-level tests for the /admin/keys endpoints against the real,
|
| 3 |
+
Postgres-backed UsageTracker -- these routes are normally exercised through
|
| 4 |
+
the module-level `tracker` singleton (conftest.py replaces it globally with
|
| 5 |
+
MockTracker for every other test), so both `tracker` and `ADMIN_API_KEY` are
|
| 6 |
+
monkeypatched directly on `app.api.routes_admin` for the duration of this
|
| 7 |
+
module. This is the main regression coverage for the api_keys-on-Postgres
|
| 8 |
+
migration: it exercises the exact raw SQL routes_admin.py runs against
|
| 9 |
+
`tracker._get_pg_conn()`.
|
| 10 |
+
"""
|
| 11 |
+
import hashlib
|
| 12 |
+
import hmac
|
| 13 |
+
import os
|
| 14 |
+
|
| 15 |
+
import pytest
|
| 16 |
+
|
| 17 |
+
from app.core.usage_tracker import UsageTracker
|
| 18 |
+
import app.api.routes_admin as routes_admin
|
| 19 |
+
|
| 20 |
+
TEST_ADMIN_KEY = "test-admin-key-for-routes-admin-tests"
|
| 21 |
+
TEST_PEPPER = os.environ["ARF_KEY_PEPPER"] # set in conftest.py before app import
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _key_id(raw_key: str) -> str:
|
| 25 |
+
"""Reproduce UsageTracker._lookup_hash without a tracker instance, so
|
| 26 |
+
tests can locate the row created for a given raw key deterministically."""
|
| 27 |
+
return hmac.new(TEST_PEPPER.encode(), raw_key.encode(), hashlib.sha256).hexdigest()
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@pytest.fixture(autouse=True)
|
| 31 |
+
def real_tracker(monkeypatch):
|
| 32 |
+
real = UsageTracker(db_path=":memory:")
|
| 33 |
+
monkeypatch.setattr(routes_admin, "tracker", real)
|
| 34 |
+
monkeypatch.setattr(routes_admin, "ADMIN_API_KEY", TEST_ADMIN_KEY)
|
| 35 |
+
yield real
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def test_create_list_update_deactivate_key(client):
|
| 39 |
+
create_resp = client.post(
|
| 40 |
+
"/admin/keys",
|
| 41 |
+
params={"admin_key": TEST_ADMIN_KEY},
|
| 42 |
+
json={"tier": "free", "org_name": "Test Org"},
|
| 43 |
+
)
|
| 44 |
+
assert create_resp.status_code == 200
|
| 45 |
+
body = create_resp.json()
|
| 46 |
+
api_key = body["api_key"]
|
| 47 |
+
assert body["tier"] == "free"
|
| 48 |
+
key_id = _key_id(api_key)
|
| 49 |
+
|
| 50 |
+
list_resp = client.get("/admin/keys", params={"admin_key": TEST_ADMIN_KEY})
|
| 51 |
+
assert list_resp.status_code == 200
|
| 52 |
+
keys_by_id = {row["key_id"]: row for row in list_resp.json()["keys"]}
|
| 53 |
+
assert key_id in keys_by_id
|
| 54 |
+
assert keys_by_id[key_id]["tier"] == "free"
|
| 55 |
+
assert keys_by_id[key_id]["is_active"] is True
|
| 56 |
+
|
| 57 |
+
patch_resp = client.patch(
|
| 58 |
+
f"/admin/keys/{key_id}/tier",
|
| 59 |
+
params={"admin_key": TEST_ADMIN_KEY},
|
| 60 |
+
json={"tier": "pro"},
|
| 61 |
+
)
|
| 62 |
+
assert patch_resp.status_code == 200
|
| 63 |
+
|
| 64 |
+
list_resp2 = client.get("/admin/keys", params={"admin_key": TEST_ADMIN_KEY})
|
| 65 |
+
assert list_resp2.json()["keys"][0] # non-empty, sanity check
|
| 66 |
+
keys_by_id2 = {row["key_id"]: row for row in list_resp2.json()["keys"]}
|
| 67 |
+
assert keys_by_id2[key_id]["tier"] == "pro"
|
| 68 |
+
|
| 69 |
+
delete_resp = client.delete(f"/admin/keys/{key_id}", params={"admin_key": TEST_ADMIN_KEY})
|
| 70 |
+
assert delete_resp.status_code == 200
|
| 71 |
+
|
| 72 |
+
list_resp3 = client.get("/admin/keys", params={"admin_key": TEST_ADMIN_KEY})
|
| 73 |
+
keys_by_id3 = {row["key_id"]: row for row in list_resp3.json()["keys"]}
|
| 74 |
+
assert keys_by_id3[key_id]["is_active"] is False
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def test_update_nonexistent_key_returns_404(client):
|
| 78 |
+
resp = client.patch(
|
| 79 |
+
"/admin/keys/does-not-exist/tier",
|
| 80 |
+
params={"admin_key": TEST_ADMIN_KEY},
|
| 81 |
+
json={"tier": "pro"},
|
| 82 |
+
)
|
| 83 |
+
assert resp.status_code == 404
|