Files
aza/AzA march 2026/aza_device_enforcement.py
2026-04-16 13:32:32 +02:00

253 lines
8.7 KiB
Python

import hashlib
import sqlite3
import sys
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
DEVICES_PER_LICENSE_FLOOR = 2
def _runtime_base_dir() -> Path:
if getattr(sys, "frozen", False):
return Path(getattr(sys, "_MEIPASS", Path(sys.executable).resolve().parent))
return Path(__file__).resolve().parent
def _resolve_db_path() -> str:
if getattr(sys, "frozen", False):
import os as _os
try:
from aza_config import get_writable_data_dir
writable = Path(get_writable_data_dir()) / "data"
except Exception:
writable = Path(_os.environ.get("APPDATA", "")) / "AZA Desktop" / "data"
writable.mkdir(parents=True, exist_ok=True)
return str(writable / "stripe_webhook.sqlite")
return str(_runtime_base_dir() / "data" / "stripe_webhook.sqlite")
DB_PATH = _resolve_db_path()
@dataclass(frozen=True)
class DeviceDecision:
allowed: bool
reason: str # "ok"|"missing_device_id"|"license_not_found"|"device_limit_reached"|"user_limit_reached"
devices_used: int
devices_allowed: int
users_used: int
users_allowed: int
license_active: bool = False
license_count: int = 0
def _hash_device_id(device_id: str) -> str:
return hashlib.sha256(device_id.encode("utf-8")).hexdigest()
def ensure_device_table(conn: sqlite3.Connection) -> None:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS device_bindings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_email TEXT NOT NULL,
user_key TEXT NOT NULL,
device_hash TEXT NOT NULL,
first_seen_at INTEGER NOT NULL,
last_seen_at INTEGER NOT NULL,
UNIQUE(customer_email, user_key, device_hash)
);
"""
)
cols = [row[1] for row in conn.execute("PRAGMA table_info(device_bindings)").fetchall()]
if "device_name" not in cols:
conn.execute("ALTER TABLE device_bindings ADD COLUMN device_name TEXT DEFAULT ''")
if "is_active" not in cols:
conn.execute("ALTER TABLE device_bindings ADD COLUMN is_active INTEGER DEFAULT 1")
if "app_version" not in cols:
conn.execute("ALTER TABLE device_bindings ADD COLUMN app_version TEXT DEFAULT ''")
conn.commit()
def _count_active_licenses(conn: sqlite3.Connection, customer_email: str) -> Tuple[int, int]:
"""Returns (n_active_licenses, total_allowed_devices).
Multiple active licenses for the same email stack:
1 license = 2 devices, 2 licenses = 4 devices, etc.
"""
cur = conn.execute(
"""SELECT COUNT(*),
COALESCE(SUM(
CASE WHEN devices_per_user IS NULL OR devices_per_user < ?
THEN ? ELSE devices_per_user END
), 0)
FROM licenses
WHERE lower(customer_email) = lower(?)
AND status = 'active'""",
(DEVICES_PER_LICENSE_FLOOR, DEVICES_PER_LICENSE_FLOOR, customer_email),
)
row = cur.fetchone()
if not row or int(row[0]) == 0:
return (0, 0)
return (int(row[0]), int(row[1]))
def _get_license_row(conn: sqlite3.Connection, customer_email: str) -> Optional[Tuple[int, int]]:
"""Backward-compat wrapper. Returns (allowed_users, total_devices)."""
n_licenses, total_devices = _count_active_licenses(conn, customer_email)
if n_licenses == 0:
return None
return (1, total_devices)
def enforce_and_touch_device(
customer_email: str,
user_key: str,
device_id: Optional[str],
db_path: Optional[str] = None,
device_name: str = "",
app_version: str = "",
) -> DeviceDecision:
"""Enforce device limit for a customer email.
Rules:
- License not active → deny
- device_id already known → allow, update last_seen
- New device + under limit → register + allow
- New device + at/over limit → deny (device_limit_reached)
"""
if not device_id:
return DeviceDecision(
allowed=False, reason="missing_device_id",
devices_used=0, devices_allowed=0,
users_used=0, users_allowed=0,
)
device_hash = _hash_device_id(device_id)
now = int(time.time())
conn = sqlite3.connect(db_path or DB_PATH)
try:
ensure_device_table(conn)
n_licenses, total_devices = _count_active_licenses(conn, customer_email)
if n_licenses == 0:
return DeviceDecision(
allowed=False, reason="license_not_found",
devices_used=0, devices_allowed=0,
users_used=0, users_allowed=0,
license_active=False, license_count=0,
)
cur_devices = conn.execute(
"""SELECT COUNT(*) FROM device_bindings
WHERE lower(customer_email) = lower(?) AND user_key = ?
AND COALESCE(is_active, 1) = 1""",
(customer_email, user_key),
)
used_devices = int(cur_devices.fetchone()[0])
cur_users = conn.execute(
"SELECT COUNT(DISTINCT user_key) FROM device_bindings WHERE lower(customer_email) = lower(?)",
(customer_email,),
)
users_used = int(cur_users.fetchone()[0])
# Is this device already bound?
cur = conn.execute(
"""SELECT id FROM device_bindings
WHERE lower(customer_email) = lower(?) AND user_key = ? AND device_hash = ?
LIMIT 1""",
(customer_email, user_key, device_hash),
)
existing = cur.fetchone()
if existing:
conn.execute(
"""UPDATE device_bindings
SET last_seen_at = ?,
device_name = COALESCE(NULLIF(?, ''), device_name),
app_version = COALESCE(NULLIF(?, ''), app_version),
is_active = 1
WHERE id = ?""",
(now, device_name, app_version, int(existing[0])),
)
conn.commit()
return DeviceDecision(
allowed=True, reason="ok",
devices_used=used_devices, devices_allowed=total_devices,
users_used=users_used, users_allowed=1,
license_active=True, license_count=n_licenses,
)
# New device — check limit
if used_devices >= total_devices:
return DeviceDecision(
allowed=False, reason="device_limit_reached",
devices_used=used_devices, devices_allowed=total_devices,
users_used=users_used, users_allowed=1,
license_active=True, license_count=n_licenses,
)
# Register new device
conn.execute(
"""INSERT INTO device_bindings
(customer_email, user_key, device_hash, first_seen_at, last_seen_at,
device_name, is_active, app_version)
VALUES (?, ?, ?, ?, ?, ?, 1, ?)""",
(customer_email, user_key, device_hash, now, now, device_name, app_version),
)
conn.commit()
return DeviceDecision(
allowed=True, reason="ok",
devices_used=used_devices + 1, devices_allowed=total_devices,
users_used=users_used if used_devices > 0 else users_used + 1,
users_allowed=1,
license_active=True, license_count=n_licenses,
)
finally:
conn.close()
def list_devices_for_email(
customer_email: str, db_path: Optional[str] = None,
) -> Dict[str, Any]:
"""Admin/debug: list all registered devices and license info for an email."""
conn = sqlite3.connect(db_path or DB_PATH)
try:
ensure_device_table(conn)
n_licenses, total_devices = _count_active_licenses(conn, customer_email)
rows = conn.execute(
"""SELECT device_hash, device_name, COALESCE(is_active, 1),
COALESCE(app_version, ''), first_seen_at, last_seen_at
FROM device_bindings
WHERE lower(customer_email) = lower(?)
ORDER BY last_seen_at DESC""",
(customer_email,),
).fetchall()
devices: List[Dict[str, Any]] = []
for r in rows:
devices.append({
"device_hash_short": (r[0] or "")[:12] + "",
"device_name": r[1] or "",
"is_active": bool(r[2]),
"app_version": r[3] or "",
"first_seen": r[4],
"last_seen": r[5],
})
return {
"email": customer_email,
"active_licenses": n_licenses,
"allowed_devices": total_devices,
"registered_devices": len(devices),
"active_devices": sum(1 for d in devices if d["is_active"]),
"devices": devices,
}
finally:
conn.close()