Record standby subscription events with signed durable receipts
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create durable subscription event receipts/history, with a SQLite backup.
|
||||
|
||||
Stop the production backend before --apply and pass --service-stopped to confirm.
|
||||
No automatic startup DDL exists. Dry run is read-only; only hashes enter history.
|
||||
Receipt retention is independent of the most recent ten visible update records.
|
||||
"""
|
||||
import argparse
|
||||
import datetime
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
|
||||
DDL = (
|
||||
"""CREATE TABLE IF NOT EXISTS subscription_event_receipt (
|
||||
event_id TEXT PRIMARY KEY NOT NULL, key_hash TEXT NOT NULL,
|
||||
client TEXT NOT NULL, occurred_at INTEGER NOT NULL, source TEXT NOT NULL,
|
||||
ingested_at INTEGER NOT NULL, fingerprint TEXT NOT NULL, outcome TEXT NOT NULL
|
||||
)""",
|
||||
"""CREATE TABLE IF NOT EXISTS subscription_event_binding (
|
||||
key_hash TEXT PRIMARY KEY NOT NULL, user_id INTEGER,
|
||||
user_name TEXT NOT NULL, first_seen_at INTEGER NOT NULL, last_seen_at INTEGER NOT NULL
|
||||
)""",
|
||||
"CREATE INDEX IF NOT EXISTS idx_subscription_event_binding_user ON subscription_event_binding(user_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_sub_record_user_time ON sub_update_record(user_id,time DESC,id DESC)",
|
||||
)
|
||||
|
||||
|
||||
def migrate(database: Path, apply=False, service_stopped=False):
|
||||
if not database.is_file():
|
||||
raise ValueError("database must already exist")
|
||||
if apply and not service_stopped:
|
||||
raise ValueError("stop backend and supply --service-stopped before applying")
|
||||
uri = database.resolve().as_uri() + ("?mode=rw" if apply else "?mode=ro")
|
||||
with sqlite3.connect(uri, uri=True) as db:
|
||||
for table in ("User", "sub_bind", "sub_update_record"):
|
||||
columns = {row[1] for row in db.execute(f'PRAGMA table_info("{table}")')}
|
||||
if not columns or (table != "User" and "user_id" not in columns):
|
||||
raise ValueError("stable user links migration is required first")
|
||||
unresolved = db.execute("SELECT count(*) FROM sub_bind sb LEFT JOIN User u ON u.id=sb.user_id WHERE sb.user_id IS NOT NULL AND u.id IS NULL").fetchone()[0]
|
||||
if unresolved:
|
||||
raise ValueError("resolve non-null dangling subscription user IDs before migration")
|
||||
tables = {row[0] for row in db.execute("SELECT name FROM sqlite_master WHERE type='table'")}
|
||||
result = {"apply": apply, "tables_to_create": sorted({"subscription_event_receipt", "subscription_event_binding"} - tables)}
|
||||
if not apply:
|
||||
return result
|
||||
stamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
|
||||
backup = database.with_name(database.name + ".before-subscription-events." + stamp)
|
||||
# Create mode 600 before any sensitive database bytes are written.
|
||||
backup.touch(mode=0o600, exist_ok=False)
|
||||
with sqlite3.connect(backup) as snapshot:
|
||||
db.backup(snapshot)
|
||||
try:
|
||||
db.execute("BEGIN IMMEDIATE")
|
||||
for statement in DDL:
|
||||
db.execute(statement)
|
||||
now = int(datetime.datetime.now(datetime.timezone.utc).timestamp() * 1000)
|
||||
for key, user_id, name in db.execute("SELECT sb.key,sb.user_id,coalesce(u.username,sb.user) FROM sub_bind sb LEFT JOIN User u ON u.id=sb.user_id"):
|
||||
key_hash = hashlib.sha256(key.encode("utf-8")).hexdigest()
|
||||
db.execute("""INSERT INTO subscription_event_binding VALUES(?,?,?,?,?)
|
||||
ON CONFLICT(key_hash) DO UPDATE SET user_name=excluded.user_name,last_seen_at=excluded.last_seen_at
|
||||
WHERE subscription_event_binding.user_id IS excluded.user_id""", (key_hash, user_id, name, now, now))
|
||||
db.commit()
|
||||
except BaseException:
|
||||
db.rollback()
|
||||
raise
|
||||
result["backup"] = str(backup)
|
||||
result["history_bindings"] = db.execute("SELECT count(*) FROM subscription_event_binding").fetchone()[0]
|
||||
result["integrity"] = db.execute("PRAGMA quick_check").fetchone()[0]
|
||||
return result
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("database", type=Path)
|
||||
parser.add_argument("--apply", action="store_true")
|
||||
parser.add_argument("--service-stopped", action="store_true")
|
||||
args = parser.parse_args()
|
||||
print(json.dumps(migrate(args.database, args.apply, args.service_stopped), ensure_ascii=False))
|
||||
@@ -0,0 +1,82 @@
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
import hashlib
|
||||
|
||||
spec = importlib.util.spec_from_file_location("migration", Path(__file__).with_name("migrate_subscription_events.py"))
|
||||
migration = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(migration)
|
||||
|
||||
|
||||
class SubscriptionEventsMigrationTest(unittest.TestCase):
|
||||
def test_explicit_backup_atomic_and_idempotent(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "main.db"
|
||||
with sqlite3.connect(path) as db:
|
||||
db.executescript("""
|
||||
CREATE TABLE User(id INTEGER PRIMARY KEY,username TEXT);
|
||||
CREATE TABLE sub_bind(key TEXT,user TEXT,user_id INTEGER);
|
||||
CREATE TABLE sub_update_record(id INTEGER PRIMARY KEY,user_id INTEGER,time INTEGER);
|
||||
INSERT INTO User VALUES(3,'alice');
|
||||
INSERT INTO sub_bind VALUES('public-test-key','alice',3);
|
||||
""")
|
||||
dry = migration.migrate(path)
|
||||
self.assertEqual(2, len(dry["tables_to_create"]))
|
||||
with self.assertRaises(ValueError):
|
||||
migration.migrate(path, True)
|
||||
first = migration.migrate(path, True, True)
|
||||
self.assertEqual("ok", first["integrity"])
|
||||
self.assertEqual(0o600, Path(first["backup"]).stat().st_mode & 0o777)
|
||||
with sqlite3.connect(first["backup"]) as db:
|
||||
self.assertEqual(0, db.execute("SELECT count(*) FROM sqlite_master WHERE name='subscription_event_binding'").fetchone()[0])
|
||||
with sqlite3.connect(path) as db:
|
||||
row = db.execute("SELECT key_hash,user_id,user_name FROM subscription_event_binding").fetchone()
|
||||
self.assertEqual((hashlib.sha256(b"public-test-key").hexdigest(), 3, "alice"), row)
|
||||
db.execute("INSERT INTO subscription_event_receipt VALUES('id','hash','v2',0,'primary',1,'fingerprint','recorded')")
|
||||
second = migration.migrate(path, True, True)
|
||||
self.assertEqual([], second["tables_to_create"])
|
||||
with sqlite3.connect(path) as db:
|
||||
self.assertEqual(1, db.execute("SELECT count(*) FROM subscription_event_receipt").fetchone()[0])
|
||||
self.assertEqual(1, db.execute("SELECT count(*) FROM subscription_event_binding").fetchone()[0])
|
||||
|
||||
def test_legacy_labels_without_user_ids_remain_null(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "main.db"
|
||||
with sqlite3.connect(path) as db:
|
||||
db.executescript("""
|
||||
CREATE TABLE User(id INTEGER PRIMARY KEY,username TEXT);
|
||||
CREATE TABLE sub_bind(key TEXT,user TEXT,user_id INTEGER);
|
||||
CREATE TABLE sub_update_record(id INTEGER PRIMARY KEY,user_id INTEGER,time INTEGER);
|
||||
INSERT INTO sub_bind VALUES('legacy-key','legacy-label',NULL);
|
||||
""")
|
||||
result = migration.migrate(path, True, True)
|
||||
self.assertEqual(1, result["history_bindings"])
|
||||
with sqlite3.connect(path) as db:
|
||||
row = db.execute("SELECT key_hash,user_id,user_name FROM subscription_event_binding").fetchone()
|
||||
self.assertEqual((hashlib.sha256(b"legacy-key").hexdigest(), None, "legacy-label"), row)
|
||||
self.assertIsNone(db.execute("SELECT user_id FROM sub_bind").fetchone()[0])
|
||||
self.assertEqual(0, db.execute("SELECT count(*) FROM User").fetchone()[0])
|
||||
migration.migrate(path, True, True)
|
||||
with sqlite3.connect(path) as db:
|
||||
self.assertEqual(1, db.execute("SELECT count(*) FROM subscription_event_binding").fetchone()[0])
|
||||
|
||||
def test_orphan_binding_aborts_without_ddl(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "main.db"
|
||||
with sqlite3.connect(path) as db:
|
||||
db.executescript("""
|
||||
CREATE TABLE User(id INTEGER PRIMARY KEY,username TEXT);
|
||||
CREATE TABLE sub_bind(key TEXT,user TEXT,user_id INTEGER);
|
||||
CREATE TABLE sub_update_record(id INTEGER PRIMARY KEY,user_id INTEGER,time INTEGER);
|
||||
INSERT INTO sub_bind VALUES('test','deleted',9);
|
||||
""")
|
||||
with self.assertRaises(ValueError):
|
||||
migration.migrate(path, True, True)
|
||||
with sqlite3.connect(path) as db:
|
||||
self.assertEqual(0, db.execute("SELECT count(*) FROM sqlite_master WHERE name LIKE 'subscription_event_%'").fetchone()[0])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user