petter2025 commited on
Commit
84013a9
·
1 Parent(s): 5b1428f

Upload folder using huggingface_hub

Browse files
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._get_conn() as conn:
86
- rows = conn.execute(
 
87
  "SELECT lookup_hash, tier, created_at, last_used_at, is_active FROM api_keys "
88
- "WHERE lookup_hash != '' ORDER BY created_at DESC LIMIT ? OFFSET ?",
89
  (limit, offset)
90
  ).fetchall()
 
91
  keys = [
92
  {
93
  "key_id": row["lookup_hash"],
94
  "tier": row["tier"],
95
- "created_at": datetime.fromtimestamp(row["created_at"]).isoformat(),
96
- "last_used_at": datetime.fromtimestamp(row["last_used_at"]).isoformat()
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._get_conn() as conn:
114
- row = conn.execute(
115
- "SELECT lookup_hash FROM api_keys WHERE lookup_hash = ?", (key_id,)).fetchone()
116
  if not row:
 
117
  raise HTTPException(status_code=404, detail="API key not found")
118
- conn.execute("UPDATE api_keys SET tier = ? WHERE lookup_hash = ?",
119
- (req.tier, key_id))
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._get_conn() as conn:
128
- row = conn.execute(
129
- "SELECT lookup_hash FROM api_keys WHERE lookup_hash = ?", (key_id,)).fetchone()
130
  if not row:
 
131
  raise HTTPException(status_code=404, detail="API key not found")
132
- conn.execute(
133
- "UPDATE api_keys SET is_active = 0 WHERE lookup_hash = ?", (key_id,))
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, which both services may
14
- point at the same underlying database file for -- same column names and
15
- hash algorithm, so rows created by either service validate on both. Only
16
- this table is affected: `monthly_counts` and `usage_log` are unchanged and
17
- still keyed by the raw API key, since arf-gateway reads them that way.
 
 
 
 
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[sqlite3.Row]:
129
  """Look up a row by pepper-HMAC, then verify with the salted hash.
130
- Returns the row (tenant_id, tier, is_active, salt, key_hash) if the
131
- key is valid and active, else None."""
132
- row = conn.execute(
 
 
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
- def _init_db(self):
154
- """Initialise SQLite tables with tenant_id support."""
155
- with self._get_conn() as conn:
156
- # `key` is kept only for backward compatibility with pre-pepper
157
- # databases during migration below -- new rows never populate
158
- # it with a real value (see get_or_create_api_key).
159
- conn.execute("""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
160
  CREATE TABLE IF NOT EXISTS api_keys (
161
- key TEXT PRIMARY KEY,
162
- tenant_id TEXT NOT NULL,
163
- tier TEXT NOT NULL,
164
- created_at REAL NOT NULL,
165
- last_used_at REAL,
166
- is_active INTEGER DEFAULT 1,
167
- salt TEXT NOT NULL DEFAULT '',
168
- key_hash TEXT NOT NULL DEFAULT '',
169
- lookup_hash TEXT NOT NULL DEFAULT ''
170
  )
171
  """)
172
- # Idempotent: add security columns to a pre-existing table that
173
- # predates this fix.
174
- existing_cols = {
175
- row["name"] for row in conn.execute("PRAGMA table_info(api_keys)").fetchall()
176
- }
177
- for col in ("salt", "key_hash", "lookup_hash"):
178
- if col not in existing_cols:
179
- conn.execute(f"ALTER TABLE api_keys ADD COLUMN {col} TEXT NOT NULL DEFAULT ''")
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._get_conn() as conn:
265
- row = conn.execute(
266
- "SELECT tenant_id FROM api_keys WHERE lookup_hash = ?", (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
- conn.execute(
275
- # `key` is left NULL (not '') so a second row can be created
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
- "(key, tenant_id, tier, created_at, is_active, salt, key_hash, lookup_hash) "
281
- "VALUES (NULL, ?, ?, ?, ?, ?, ?, ?)",
282
- (tenant_id, tier.value, time.time(), 1,
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._get_conn() as conn:
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._get_conn() as conn:
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._get_conn() as conn:
304
- row = conn.execute(
305
- "SELECT lookup_hash FROM api_keys WHERE lookup_hash = ?", (lookup_hash,)
306
  ).fetchone()
307
  if not row:
 
308
  return False
309
- conn.execute(
310
- "UPDATE api_keys SET tier = ? WHERE lookup_hash = ?",
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