Preserve hosted website UX inside Archipelago, add isolated Business and owner-authorized native wallet setup, and include deployment handoff. Based on main with native signer import already accepted.
276 lines
15 KiB
Python
276 lines
15 KiB
Python
"""Operational rollout and browser-bound Business SSO, not a new identity provider."""
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import secrets
|
|
import time
|
|
from http.cookies import SimpleCookie
|
|
from urllib.parse import urlsplit, urlencode
|
|
|
|
import business_security as security
|
|
|
|
GROUPS = ("merchant_config", "staff_reads", "staff_mutations")
|
|
DEFAULT_ORIGINS = ("https://business.justworks.cash", "https://pos.justworks.cash")
|
|
SSO_COOKIE = "__Host-jwb_exchange"
|
|
TABLES = ("products", "tables", "orders", "payments", "quick_payments", "bookings", "merchant_modules")
|
|
|
|
|
|
def migrate(conn):
|
|
conn.executescript("""
|
|
CREATE TABLE IF NOT EXISTS business_session_audiences (
|
|
token_hash TEXT PRIMARY KEY, audience TEXT NOT NULL);
|
|
CREATE TABLE IF NOT EXISTS business_identity_verifications (
|
|
npub TEXT PRIMARY KEY, slug TEXT NOT NULL, verified_at INTEGER NOT NULL);
|
|
CREATE TABLE IF NOT EXISTS business_merchant_aliases (
|
|
alias TEXT PRIMARY KEY, npub TEXT NOT NULL UNIQUE, verified_slug TEXT NOT NULL,
|
|
migrated_at INTEGER NOT NULL);
|
|
CREATE TABLE IF NOT EXISTS business_rollout_approvals (
|
|
route_group TEXT NOT NULL, npub TEXT NOT NULL, approved_at INTEGER NOT NULL,
|
|
PRIMARY KEY(route_group,npub));
|
|
CREATE TABLE IF NOT EXISTS business_exchanges (
|
|
request_hash TEXT PRIMARY KEY, binder_hash TEXT NOT NULL, npub TEXT NOT NULL,
|
|
audience TEXT NOT NULL, return_path TEXT NOT NULL, expires_at INTEGER NOT NULL,
|
|
code_hash TEXT UNIQUE, source_session TEXT, consumed INTEGER NOT NULL DEFAULT 0);
|
|
CREATE INDEX IF NOT EXISTS business_exchanges_expiry ON business_exchanges(expires_at);
|
|
""")
|
|
|
|
|
|
def legacy_merchant(conn):
|
|
row = conn.execute("SELECT npub FROM business_merchant_aliases WHERE alias='localpub-demo'").fetchone()
|
|
return row[0] if row else "localpub-demo"
|
|
|
|
|
|
def group_for(method, path):
|
|
if method == "GET" and path in security.PRIVILEGED_GET | {"/api/business"}:
|
|
return "staff_reads"
|
|
if method == "POST" and path in security.PRIVILEGED_POST:
|
|
return "merchant_config"
|
|
if method == "POST" and re.fullmatch(r"/api/(orders|payments|bookings)/[^/]+/(status|acknowledge)", path):
|
|
return "staff_mutations"
|
|
return None
|
|
|
|
|
|
def policy():
|
|
groups = json.loads(os.environ.get("JWB_AUTH_GROUPS", "{}"))
|
|
if not isinstance(groups, dict) or any(k not in GROUPS or v not in ("shadow", "enforce") for k, v in groups.items()):
|
|
raise ValueError("invalid JWB_AUTH_GROUPS")
|
|
canaries = set(filter(None, (x.strip() for x in os.environ.get("JWB_AUTH_CANARIES", "").split(","))))
|
|
for merchant in canaries:
|
|
security.canonical_npub(merchant)
|
|
return groups, canaries
|
|
|
|
|
|
def effective_mode(conn, method, path, merchant):
|
|
global_mode = security.mode()
|
|
# Existing global enforce is retained for regression/local tests only.
|
|
# Operators use shadow + groups; force-shadow overrides all group settings.
|
|
if global_mode == "off" or os.environ.get("JWB_AUTH_FORCE_SHADOW", "0") == "1":
|
|
return "off" if global_mode == "off" else "shadow"
|
|
if global_mode == "enforce":
|
|
return "enforce"
|
|
groups, canaries = policy()
|
|
group = group_for(method, path)
|
|
if groups.get(group) != "enforce" or merchant not in canaries:
|
|
return "shadow"
|
|
approved = conn.execute("SELECT 1 FROM business_rollout_approvals WHERE route_group=? AND npub=?", (group, merchant)).fetchone()
|
|
return "enforce" if approved else "shadow"
|
|
|
|
|
|
def verification(conn, npub, slug):
|
|
conn.execute("INSERT OR REPLACE INTO business_identity_verifications VALUES (?,?,?)", (security.canonical_npub(npub), slug, int(time.time())))
|
|
conn.commit()
|
|
|
|
|
|
def migration_plan(conn, npub, slug):
|
|
npub = security.canonical_npub(npub)
|
|
existing = conn.execute("SELECT * FROM business_merchant_aliases WHERE alias='localpub-demo'").fetchone()
|
|
if existing:
|
|
if existing["npub"] != npub or existing["verified_slug"] != slug:
|
|
raise ValueError("legacy merchant already mapped to a different identity")
|
|
return {"already_applied": True, "source": "localpub-demo", "target": npub, "counts": {}}
|
|
verified = conn.execute("SELECT slug,verified_at FROM business_identity_verifications WHERE npub=?", (npub,)).fetchone()
|
|
if not verified or verified[0] != slug or verified[1] < int(time.time()) - 86400:
|
|
raise ValueError("recent Core-verified owner login and matching slug required")
|
|
if not conn.execute("SELECT 1 FROM merchant WHERE id='localpub-demo'").fetchone():
|
|
raise ValueError("legacy merchant missing")
|
|
counts = {table: conn.execute(f"SELECT COUNT(*) FROM {table} WHERE merchant_id='localpub-demo'").fetchone()[0] for table in TABLES}
|
|
# Never merge unrelated tenant records or choose which configuration wins.
|
|
if conn.execute("SELECT 1 FROM merchant WHERE id=?", (npub,)).fetchone() or any(
|
|
conn.execute(f"SELECT 1 FROM {table} WHERE merchant_id=? LIMIT 1", (npub,)).fetchone() for table in TABLES
|
|
):
|
|
raise ValueError("target has existing data; explicit reconciliation required before migration")
|
|
return {"already_applied": False, "source": "localpub-demo", "target": npub, "counts": counts}
|
|
|
|
|
|
def migrate_legacy(conn, npub, slug, apply=False):
|
|
conn.execute("BEGIN IMMEDIATE")
|
|
try:
|
|
report = migration_plan(conn, npub, slug)
|
|
if apply and not report["already_applied"]:
|
|
conn.execute("UPDATE merchant SET id=? WHERE id='localpub-demo'", (npub,))
|
|
for table in TABLES:
|
|
conn.execute(f"UPDATE {table} SET merchant_id=? WHERE merchant_id='localpub-demo'", (npub,))
|
|
conn.execute("INSERT INTO business_merchant_aliases VALUES ('localpub-demo',?,?,?)", (npub, slug, int(time.time())))
|
|
conn.execute("INSERT INTO business_audit(occurred_at,event,route,merchant,actor,reason) VALUES (?,'merchant_migrated','operator',?,?,'explicit_verified_mapping')", (int(time.time()), npub, npub))
|
|
conn.commit()
|
|
else:
|
|
conn.rollback()
|
|
return {**report, "applied": apply}
|
|
except Exception:
|
|
conn.rollback()
|
|
raise
|
|
|
|
|
|
def origins():
|
|
values = tuple(x.strip() for x in os.environ.get("JWB_SSO_ORIGINS", ",".join(DEFAULT_ORIGINS)).split(",") if x.strip())
|
|
for value in values:
|
|
p = urlsplit(value)
|
|
if p.scheme != "https" or not p.hostname or p.username or p.password or p.port or p.path or p.query or p.fragment or value != f"https://{p.hostname}" or not p.hostname.endswith(".justworks.cash"):
|
|
raise ValueError("SSO origins must be exact HTTPS JustWorks subdomains")
|
|
if DEFAULT_ORIGINS[0] not in values:
|
|
raise ValueError("business.justworks.cash must remain the Business session hub")
|
|
return values
|
|
|
|
|
|
def request_origin(headers):
|
|
host = headers.get("Host", "").lower()
|
|
origin = "https://" + host
|
|
if origin not in origins():
|
|
raise ValueError("SSO host not allowed")
|
|
if headers.get("Origin") != origin or headers.get("Sec-Fetch-Site") == "cross-site":
|
|
raise ValueError("explicit same-origin request required")
|
|
return origin
|
|
|
|
|
|
def enabled():
|
|
return os.environ.get("JWB_SSO_ENABLED", "0") == "1"
|
|
|
|
|
|
def safe_return(value):
|
|
if not isinstance(value, str) or len(value) > 2000 or not value.startswith("/") or value.startswith("//") or "\\" in value or any(ord(c) < 32 for c in value):
|
|
raise ValueError("invalid return path")
|
|
p = urlsplit(value)
|
|
if p.scheme or p.netloc or p.fragment or "%" in p.path:
|
|
raise ValueError("invalid return path")
|
|
return value
|
|
|
|
|
|
def opaque(value):
|
|
if not isinstance(value, str) or not re.fullmatch(r"[A-Za-z0-9_-]{43}", value):
|
|
raise ValueError("invalid exchange")
|
|
return value
|
|
|
|
|
|
def exchange_cookie(token="", ttl=0):
|
|
return f"{SSO_COOKIE}={token}; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age={ttl}"
|
|
|
|
|
|
def start(conn, values, headers):
|
|
audience = request_origin(headers)
|
|
npub = security.canonical_npub(values.get("npub"))
|
|
target = safe_return(values.get("return_path", "/"))
|
|
request_id, binder = secrets.token_urlsafe(32), secrets.token_urlsafe(32)
|
|
timestamp = int(time.time())
|
|
conn.execute("DELETE FROM business_exchanges WHERE expires_at<=? OR consumed=1", (timestamp,))
|
|
if conn.execute("SELECT COUNT(*) FROM business_exchanges").fetchone()[0] >= 1000:
|
|
raise ValueError("exchange capacity reached")
|
|
conn.execute("INSERT INTO business_exchanges(request_hash,binder_hash,npub,audience,return_path,expires_at) VALUES (?,?,?,?,?,?)", (security.token_hash(request_id), security.token_hash(binder), npub, audience, target, timestamp + 180))
|
|
conn.commit()
|
|
return {"request_id": request_id, "authorize_url": DEFAULT_ORIGINS[0] + "/business-sso?" + urlencode({"npub": npub}) + "#" + request_id, "source_origin": DEFAULT_ORIGINS[0]}, exchange_cookie(binder, 180)
|
|
|
|
|
|
def pending(conn, request_id):
|
|
row = conn.execute("SELECT * FROM business_exchanges WHERE request_hash=? AND expires_at>? AND consumed=0", (security.token_hash(opaque(request_id)), int(time.time()))).fetchone()
|
|
if not row:
|
|
raise ValueError("exchange expired or used")
|
|
return row
|
|
|
|
|
|
def source_details(conn, values, headers, approve=False):
|
|
if request_origin(headers) != DEFAULT_ORIGINS[0]:
|
|
raise ValueError("only Business session hub may approve")
|
|
row = pending(conn, values.get("request_id"))
|
|
current = security.session(conn, headers)
|
|
if not current or current["npub"] != row["npub"]:
|
|
raise ValueError("matching verified Business login required")
|
|
if row["audience"] not in origins():
|
|
raise ValueError("audience no longer allowed")
|
|
result = {"audience": row["audience"], "npub": row["npub"]}
|
|
if approve:
|
|
if row["code_hash"]:
|
|
raise ValueError("exchange already approved")
|
|
code = secrets.token_urlsafe(32)
|
|
cookie = SimpleCookie(); cookie.load(headers.get("Cookie", ""))
|
|
source_hash = security.token_hash(cookie[security.COOKIE].value)
|
|
conn.execute("UPDATE business_exchanges SET code_hash=?,source_session=?,expires_at=? WHERE request_hash=?", (security.token_hash(code), source_hash, min(row["expires_at"], int(time.time()) + 60), row["request_hash"]))
|
|
conn.commit()
|
|
result["code"] = code
|
|
security.audit(conn, "exchange_approved", "/api/business-sso/approve", row["npub"], current["npub"])
|
|
return result
|
|
|
|
|
|
def complete(conn, values, headers):
|
|
audience = request_origin(headers)
|
|
row = pending(conn, values.get("request_id"))
|
|
cookie = SimpleCookie(); cookie.load(headers.get("Cookie", ""))
|
|
binder = cookie[SSO_COOKIE].value if SSO_COOKIE in cookie else ""
|
|
code_hash = security.token_hash(opaque(values.get("code")))
|
|
source = conn.execute("SELECT s.npub FROM business_sessions s JOIN business_session_audiences a ON a.token_hash=s.token_hash WHERE s.token_hash=? AND s.expires_at>? AND a.audience=?", (row["source_session"], int(time.time()), DEFAULT_ORIGINS[0][8:])).fetchone()
|
|
if row["audience"] != audience or not secrets.compare_digest(row["binder_hash"], security.token_hash(binder)) or not secrets.compare_digest(row["code_hash"] or "", code_hash) or not source or source[0] != row["npub"]:
|
|
raise ValueError("exchange binding mismatch")
|
|
# The caller serializes HTTP operations with the application lock. This UPDATE
|
|
# also makes consumption conditional for other operator/SQLite clients.
|
|
updated = conn.execute("UPDATE business_exchanges SET consumed=1 WHERE request_hash=? AND consumed=0", (row["request_hash"],))
|
|
if updated.rowcount != 1:
|
|
raise ValueError("exchange already used")
|
|
conn.commit()
|
|
session_cookie, expires = security.issue(conn, row["npub"], headers)
|
|
security.audit(conn, "exchange_completed", "/api/business-sso/complete", row["npub"], row["npub"])
|
|
return {"ok": True, "npub": row["npub"], "expires_at": expires, "return_path": row["return_path"]}, session_cookie
|
|
|
|
|
|
def readiness(conn, npub, group):
|
|
npub = security.canonical_npub(npub)
|
|
if group not in GROUPS:
|
|
raise ValueError("unknown route group")
|
|
blockers = []
|
|
if not conn.execute("SELECT 1 FROM business_identity_verifications WHERE npub=?", (npub,)).fetchone():
|
|
blockers.append("no_verified_owner_login")
|
|
hosts = {row[0] for row in conn.execute("SELECT DISTINCT a.audience FROM business_sessions s JOIN business_session_audiences a ON a.token_hash=s.token_hash WHERE s.npub=? AND s.expires_at>?", (npub, int(time.time())))}
|
|
if not all(urlsplit(origin).hostname in hosts for origin in origins()):
|
|
blockers.append("missing_verified_session_on_configured_hosts")
|
|
if not enabled() or not conn.execute("SELECT 1 FROM business_audit WHERE event='exchange_completed' AND merchant=?", (npub,)).fetchone():
|
|
blockers.append("subdomain_exchange_not_verified")
|
|
if legacy_merchant(conn) == "localpub-demo":
|
|
blockers.append("legacy_owner_mapping_unresolved")
|
|
return blockers
|
|
|
|
|
|
def approve_group(conn, npub, group):
|
|
blockers = readiness(conn, npub, group)
|
|
if blockers:
|
|
raise ValueError("readiness blocked: " + ", ".join(blockers))
|
|
conn.execute("INSERT OR REPLACE INTO business_rollout_approvals VALUES (?,?,?)", (group, npub, int(time.time())))
|
|
conn.commit()
|
|
security.audit(conn, "rollout_approved", "operator", npub, npub, group)
|
|
|
|
|
|
def audit_report(conn, since=0, reveal=False):
|
|
def identity(value):
|
|
return value if reveal or value == "localpub-demo" else "merchant:" + hashlib.sha256(value.encode()).hexdigest()[:12] if value else "unknown"
|
|
rows = conn.execute("SELECT event,route,merchant,reason,COUNT(*) AS events,MIN(occurred_at) AS first_seen,MAX(occurred_at) AS last_seen FROM business_audit WHERE occurred_at>=? AND event IN ('would_deny','denied') GROUP BY event,route,merchant,reason ORDER BY events DESC", (since,))
|
|
observations = [{**dict(row), "merchant": identity(row["merchant"]), "group": group_for("GET" if row["route"] in security.PRIVILEGED_GET else "POST", row["route"])} for row in rows]
|
|
groups, canaries = policy()
|
|
active_sessions = conn.execute("SELECT COUNT(*) FROM business_sessions WHERE expires_at>?", (int(time.time()),)).fetchone()[0]
|
|
blockers = []
|
|
if not active_sessions: blockers.append("no_active_business_sessions")
|
|
if legacy_merchant(conn) == 'localpub-demo': blockers.append("legacy_owner_mapping_unresolved")
|
|
if not enabled(): blockers.append("subdomain_exchange_disabled")
|
|
if not canaries: blockers.append("no_explicit_canary_merchants")
|
|
return {"mode": security.mode(), "force_shadow": os.environ.get("JWB_AUTH_FORCE_SHADOW") == "1", "groups": groups,
|
|
"canaries": [identity(x) for x in sorted(canaries)], "legacy_mapping": identity(legacy_merchant(conn)),
|
|
"active_sessions": active_sessions, "blockers": blockers,
|
|
"observations": observations, "coalesced_events_not_request_counts": True,
|
|
"canary_readiness": [{"merchant": identity(x), "group": group, "blockers": readiness(conn, x, group), "approved": bool(conn.execute("SELECT 1 FROM business_rollout_approvals WHERE route_group=? AND npub=?", (group, x)).fetchone())} for x in sorted(canaries) for group in GROUPS]}
|