"""Persistent hashed API keys, owner approvals and atomic request quotas.""" import argparse import hashlib import json import secrets import sqlite3 import time from contextlib import contextmanager from pathlib import Path DEFAULT_DB = Path(__file__).resolve().parent / 'outputs/api-private/access.sqlite3' class AccessError(Exception): def __init__(self, status, message, retry_after=0): super().__init__(message) self.status, self.retry_after = status, retry_after class AccessStore: def __init__(self, path=DEFAULT_DB): self.path = Path(path) self.path.parent.mkdir(parents=True, exist_ok=True) with self.connect() as c: c.executescript('''CREATE TABLE IF NOT EXISTS clients (id TEXT PRIMARY KEY, key_hash TEXT UNIQUE, app TEXT, contact TEXT, status TEXT, rpm INTEGER, daily INTEGER, created REAL); CREATE TABLE IF NOT EXISTS events (identity TEXT, stamp REAL); CREATE INDEX IF NOT EXISTS events_lookup ON events(identity,stamp);''') @contextmanager def connect(self): connection = sqlite3.connect(self.path, timeout=10) try: with connection: yield connection finally: connection.close() def request(self, app, contact): if not isinstance(app, str) or not 1 <= len(app.strip()) <= 120: raise ValueError('app_name must contain 1..120 characters.') if not isinstance(contact, str) or not 1 <= len(contact.strip()) <= 200: raise ValueError('contact must contain 1..200 characters.') key = 'om_' + secrets.token_urlsafe(32) client_id = secrets.token_hex(8) with self.connect() as c: c.execute('INSERT INTO clients VALUES (?,?,?,?,?,?,?,?)', (client_id, hashlib.sha256(key.encode()).hexdigest(), app.strip(), contact.strip(), 'pending', 10, 500, time.time())) return dict(request_id=client_id, api_key=key, status='pending', message='Save this key now; it is shown once and works only after owner approval.') def list(self): with self.connect() as c: c.row_factory = sqlite3.Row return [dict(r) for r in c.execute('SELECT id,app,contact,status,rpm,daily,created FROM clients ORDER BY created')] def change(self, client_id, status, rpm=10, daily=500): if not 1 <= rpm <= 1000 or not 1 <= daily <= 100000: raise ValueError('Limits: 1..1000/minute, 1..100000/day.') with self.connect() as c: result = c.execute('UPDATE clients SET status=?,rpm=?,daily=? WHERE id=?', (status, rpm, daily, client_id)) if not result.rowcount: raise ValueError('Unknown request ID.') def charge(self, identity, rpm, daily, now=None, minute_period=60): now = time.time() if now is None else now day_start = now // 86400 * 86400 with self.connect() as c: c.execute('BEGIN IMMEDIATE') c.execute('DELETE FROM events WHERE stamp < ?', (now-172800,)) recent = c.execute('SELECT stamp FROM events WHERE identity=? AND stamp>? ORDER BY stamp', (identity, now-minute_period)).fetchall() day_count = c.execute('SELECT COUNT(*) FROM events WHERE identity=? AND stamp>=?', (identity, day_start)).fetchone()[0] if len(recent) >= rpm: raise AccessError(429, 'Request rate limit exceeded.', max(1, int(recent[0][0]+minute_period-now)+1)) if day_count >= daily: raise AccessError(429, 'Daily request quota exceeded.', max(1, int(day_start+86400-now))) c.execute('INSERT INTO events VALUES (?,?)', (identity, now)) def authorize(self, key, anonymous=False): if not key and anonymous: self.charge('anonymous-demo', 10, 500) return if not key: raise AccessError(401, 'Bearer API key required. Apply at /v1/access-requests.') with self.connect() as c: row = c.execute('SELECT id,status,rpm,daily FROM clients WHERE key_hash=?', (hashlib.sha256(key.encode()).hexdigest(),)).fetchone() if not row: raise AccessError(401, 'Invalid API key.') if row[1] != 'approved': raise AccessError(403, f'API access is {row[1]}; owner approval required.') self.charge('key:'+row[0], row[2], row[3]) def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('action', choices=['list', 'approve', 'deny', 'revoke']) parser.add_argument('request_id', nargs='?') parser.add_argument('--rpm', type=int, default=10) parser.add_argument('--daily', type=int, default=500) parser.add_argument('--db', type=Path, default=DEFAULT_DB) args = parser.parse_args() store = AccessStore(args.db) if args.action != 'list': if not args.request_id: parser.error('Request ID required.') store.change(args.request_id, {'approve':'approved','deny':'denied','revoke':'revoked'}[args.action], args.rpm, args.daily) print(json.dumps(store.list(), indent=2)) if __name__ == '__main__': main()