Files
pgz-sport/blockchain/seal.py
T
Damir Radulić 8fe2478b84 CC2 R3 frontend: login.html + admin_users.html (M1+M2+M10 UI)
- static/login.html: dark Palantir-style login with PGŽ branding,
  Prijava se / Zaboravljena lozinka, demo account quick-fills,
  GDPR cookie banner, autostore tokens (local/session)
- static/admin_users.html: full user-management admin panel:
  - Collapsible left sidebar (Pregled, Korisnici, Tenanti, Audit log,
    Sigurnost, GDPR, links to ERP/CRM)
  - Users table with filters (q, tenant, role, status, limit)
  - + Dodaj korisnika modal (CRUD via /api/admin/users/*)
  - Suspend / unsuspend / reset-password / delete actions
  - Audit log viewer + Security KPIs + GDPR queue
  - Self-service: change pwd, export data (Art. 20), erasure request (Art. 17)
- pgz_sport_api.py: /login and /admin/users URL routes
- auth/seed_demo.py: added tajnik@atletski.pgz.hr/Atl2026!,
  admin@ak-kvarner.hr/Kvarner2026! demo users

5/5 live tests pass: login JWT, /me, /admin/users, /gdpr/consent, /gdpr/export

Note: existing admin.html (CC4 ERP/OCR work) preserved intact;
admin_users.html is dedicated user-mgmt page linked from sidebar.
2026-05-05 00:20:03 +02:00

376 lines
14 KiB
Python

#!/usr/bin/env python3
"""
seal.py — Polygon PoS sealing module for PGŽ Sport audit log
Author: Damir Radulić (damir@rinet.one) / dradulic@outlook.com
Date: 2026-05-04
Version: 1.0.0
Seals critical audit events to Polygon PoS (chain 137) using the wallet
0xD874345dcB17baBDfbFac9bD7838AdE0D4a5d368.
Two operating modes:
1. LIVE — environment provides POLYGON_PRIVKEY (and web3 is installed).
A 0-MATIC self-transaction is sent with the sha256 data hash encoded
in the `data` field. Returns the real 0x… 64-char tx hash.
2. PENDING — no key configured. The seal record is queued in
pgz_sport.polygon_seals with status='pending' and a deterministic
pseudo-tx-hash (the seal_id, prefixed with 'pending:'). A later
batch job (or operator) can flush the queue once a key is loaded.
Public surface
--------------
seal_to_polygon(data_hash, ref_id, action, **kw) -> dict
Returns: { seal_id, tx_hash, status, polygonscan_url, ... }
verify_seal(seal_id) -> dict
Read-back utility. Cross-checks the on-chain receipt (if web3 is wired up)
and returns the canonical row from polygon_seals.
list_seals(action=None, ref_type=None, ref_id=None, limit=50) -> list[dict]
Lightweight reader for the audit-seal UI.
The module is import-safe even on hosts without web3 installed; the LIVE branch
just becomes a no-op.
"""
from __future__ import annotations
import os
import json
import hashlib
import time
from datetime import datetime, timezone
from typing import Optional, Any
import psycopg2
import psycopg2.extras
# ─── Optional web3 dependency ────────────────────────────────────────────
try:
from web3 import Web3
from eth_account import Account
HAS_WEB3 = True
except Exception:
HAS_WEB3 = False
# ─── Configuration (env-driven) ──────────────────────────────────────────
POLYGON_RPC = os.environ.get("POLYGON_RPC", "https://polygon-rpc.com")
POLYGON_CHAIN_ID = int(os.environ.get("POLYGON_CHAIN_ID", "137"))
POLYGON_WALLET = os.environ.get(
"POLYGON_WALLET", "0xD874345dcB17baBDfbFac9bD7838AdE0D4a5d368"
).strip()
POLYGON_PRIVKEY = os.environ.get("POLYGON_PRIVKEY", "").strip()
POLYGONSCAN_BASE = os.environ.get("POLYGONSCAN_BASE", "https://polygonscan.com")
_pgh = os.environ.get("PG_HOST", "10.10.0.2")
_pgp = int(os.environ.get("PG_PORT", "6432"))
# pgz-sport.service inherits PG_HOST=localhost:5432 from /opt/.env.rinet which is
# stale (local PG was decommissioned). Honour the DB_HOST/DB_PORT override that
# points at canonical Server B (10.10.0.2:6432).
if _pgh in ("localhost", "127.0.0.1"):
_pgh = os.environ.get("DB_HOST", "10.10.0.2")
_pgp = int(os.environ.get("DB_PORT", "6432"))
DB = dict(
host=_pgh,
port=_pgp,
dbname=os.environ.get("PG_DB", "rinet_v3"),
user=os.environ.get("PG_USER", "rinet"),
password=os.environ.get("PG_PASS", "R1net2026!SecureDB#v7"),
)
# ─── helpers ─────────────────────────────────────────────────────────────
def _db():
c = psycopg2.connect(**DB)
c.autocommit = True
return c
def _sha256(*parts: Any) -> str:
h = hashlib.sha256()
for p in parts:
if p is None:
continue
if isinstance(p, (dict, list)):
p = json.dumps(p, sort_keys=True, ensure_ascii=False, default=str)
h.update(str(p).encode("utf-8", errors="replace"))
h.update(b"\x00")
return h.hexdigest()
def hash_payload(payload: Any) -> str:
"""Public helper — stable sha256 of a payload, JSON-canonicalised."""
if isinstance(payload, (dict, list)):
payload = json.dumps(payload, sort_keys=True, ensure_ascii=False, default=str)
return hashlib.sha256(str(payload).encode("utf-8", errors="replace")).hexdigest()
def polygonscan_url(tx_hash: str) -> Optional[str]:
if not tx_hash or tx_hash.startswith("pending:"):
return None
if not tx_hash.startswith("0x"):
tx_hash = "0x" + tx_hash
return f"{POLYGONSCAN_BASE}/tx/{tx_hash}"
# ─── live broadcast path ─────────────────────────────────────────────────
def _broadcast_live(data_hash: str, action: str, ref_id: str) -> dict:
"""Send a 0-MATIC self-tx encoding `data_hash` in the data field.
Returns dict with tx_hash, block_number (if mined within wait window),
and status. Raises on RPC errors so the caller can fall back.
"""
if not HAS_WEB3:
raise RuntimeError("web3 not installed")
if not POLYGON_PRIVKEY:
raise RuntimeError("POLYGON_PRIVKEY missing")
w3 = Web3(Web3.HTTPProvider(POLYGON_RPC, request_kwargs={"timeout": 15}))
acct = Account.from_key(POLYGON_PRIVKEY)
if acct.address.lower() != POLYGON_WALLET.lower():
raise RuntimeError(
f"key/address mismatch: key={acct.address} wallet={POLYGON_WALLET}"
)
nonce = w3.eth.get_transaction_count(acct.address)
gas_price = w3.eth.gas_price
# Encode "PGZ|action|ref_id|data_hash" into the data field as utf-8 hex.
memo = f"PGZ|{action}|{ref_id}|0x{data_hash}".encode("utf-8")
tx = {
"to": acct.address,
"value": 0,
"data": "0x" + memo.hex(),
"nonce": nonce,
"chainId": POLYGON_CHAIN_ID,
"gas": 60000,
"gasPrice": gas_price,
}
signed = acct.sign_transaction(tx)
tx_hash = w3.eth.send_raw_transaction(signed.rawTransaction).hex()
block_number = None
try:
receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=30)
block_number = int(receipt.blockNumber)
status = "confirmed" if receipt.status == 1 else "failed"
except Exception:
status = "broadcast"
return {"tx_hash": tx_hash, "block_number": block_number, "status": status}
# ─── public API ──────────────────────────────────────────────────────────
def seal_to_polygon(
data_hash: str,
ref_id: str,
action: str,
*,
ref_type: Optional[str] = None,
payload: Optional[Any] = None,
user_id: Optional[int] = None,
user_email: Optional[str] = None,
) -> dict:
"""Seal a sha256 hash to Polygon PoS.
Always persists a row in pgz_sport.polygon_seals. If LIVE mode succeeds,
the row carries the real tx_hash; otherwise it is left in 'pending' state
so a worker can flush the queue later.
Parameters
----------
data_hash : str
sha256 hex digest of the payload being sealed.
ref_id : str
opaque reference (e.g. "klub:42", "sufinanciranje:2026-001").
action : str
canonical action name (e.g. "sufinanciranje.approved").
"""
if not data_hash:
raise ValueError("data_hash required")
data_hash = data_hash.lower().lstrip("0x")
if len(data_hash) != 64 or not all(c in "0123456789abcdef" for c in data_hash):
raise ValueError("data_hash must be 64-char sha256 hex")
nonce = f"{int(time.time() * 1000):x}"
seal_id = _sha256(action, ref_id, data_hash, nonce)
row = {
"seal_id": seal_id,
"action": action[:80],
"ref_type": (ref_type or "")[:50] or None,
"ref_id": str(ref_id)[:80] if ref_id is not None else None,
"data_hash": data_hash,
"payload": json.dumps(payload, default=str) if payload is not None else None,
"wallet": POLYGON_WALLET,
"chain_id": POLYGON_CHAIN_ID,
"user_id": user_id,
"user_email": user_email,
}
tx_hash: Optional[str] = None
block_number: Optional[int] = None
error: Optional[str] = None
status = "pending"
if HAS_WEB3 and POLYGON_PRIVKEY:
try:
r = _broadcast_live(data_hash, action, str(ref_id))
tx_hash = r["tx_hash"]
block_number = r.get("block_number")
status = r.get("status", "broadcast")
except Exception as e:
error = f"{type(e).__name__}: {e}"[:500]
status = "pending"
tx_hash = None
else:
# No live key: deterministic "pending" reference.
tx_hash = "pending:" + seal_id[:32]
if not HAS_WEB3:
error = "web3 not installed"
elif not POLYGON_PRIVKEY:
error = "POLYGON_PRIVKEY not set"
sealed_at = datetime.now(timezone.utc) if status in ("broadcast", "confirmed") else None
with _db() as c, c.cursor() as cur:
cur.execute(
"""
INSERT INTO pgz_sport.polygon_seals
(seal_id, action, ref_type, ref_id, data_hash, payload, tx_hash,
chain_id, wallet, status, block_number, error,
user_id, user_email, sealed_at)
VALUES (%(seal_id)s, %(action)s, %(ref_type)s, %(ref_id)s, %(data_hash)s,
%(payload)s::jsonb, %(tx_hash)s, %(chain_id)s, %(wallet)s,
%(status)s, %(block_number)s, %(error)s,
%(user_id)s, %(user_email)s, %(sealed_at)s)
ON CONFLICT (seal_id) DO UPDATE
SET tx_hash = EXCLUDED.tx_hash,
status = EXCLUDED.status,
block_number = EXCLUDED.block_number,
error = EXCLUDED.error,
sealed_at = EXCLUDED.sealed_at
RETURNING id, created_at
""",
{
**row,
"tx_hash": tx_hash,
"status": status,
"block_number": block_number,
"error": error,
"sealed_at": sealed_at,
},
)
rid, created_at = cur.fetchone()
return {
"id": rid,
"seal_id": seal_id,
"action": action,
"ref_type": ref_type,
"ref_id": ref_id,
"data_hash": data_hash,
"tx_hash": tx_hash,
"status": status,
"block_number": block_number,
"wallet": POLYGON_WALLET,
"chain_id": POLYGON_CHAIN_ID,
"polygonscan_url": polygonscan_url(tx_hash),
"error": error,
"created_at": created_at.isoformat() if created_at else None,
"live": HAS_WEB3 and bool(POLYGON_PRIVKEY),
}
def verify_seal(seal_id: str) -> Optional[dict]:
with _db() as c, c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute(
"""SELECT id, seal_id, action, ref_type, ref_id, data_hash, tx_hash,
chain_id, wallet, status, block_number, error,
user_id, user_email, created_at, sealed_at, payload
FROM pgz_sport.polygon_seals WHERE seal_id=%s""",
(seal_id,),
)
row = cur.fetchone()
if not row:
return None
row = dict(row)
row["polygonscan_url"] = polygonscan_url(row.get("tx_hash"))
if row.get("created_at"):
row["created_at"] = row["created_at"].isoformat()
if row.get("sealed_at"):
row["sealed_at"] = row["sealed_at"].isoformat()
if HAS_WEB3 and row.get("tx_hash") and not str(row["tx_hash"]).startswith("pending:"):
try:
w3 = Web3(Web3.HTTPProvider(POLYGON_RPC, request_kwargs={"timeout": 8}))
r = w3.eth.get_transaction_receipt(row["tx_hash"])
row["onchain"] = {
"block_number": int(r.blockNumber),
"status": int(r.status),
"from": r["from"],
"to": r["to"],
}
except Exception as e:
row["onchain"] = {"error": str(e)[:200]}
return row
def list_seals(
action: Optional[str] = None,
ref_type: Optional[str] = None,
ref_id: Optional[str] = None,
limit: int = 50,
) -> list[dict]:
where, params = [], []
if action:
where.append("action = %s")
params.append(action)
if ref_type:
where.append("ref_type = %s")
params.append(ref_type)
if ref_id is not None:
where.append("ref_id = %s")
params.append(str(ref_id))
sql = (
"SELECT id, seal_id, action, ref_type, ref_id, data_hash, tx_hash, "
" chain_id, wallet, status, block_number, error, "
" user_id, user_email, created_at, sealed_at "
"FROM pgz_sport.polygon_seals "
+ ("WHERE " + " AND ".join(where) + " " if where else "")
+ "ORDER BY id DESC LIMIT %s"
)
params.append(min(int(limit or 50), 500))
with _db() as c, c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute(sql, params)
rows = [dict(r) for r in cur.fetchall()]
for r in rows:
r["polygonscan_url"] = polygonscan_url(r.get("tx_hash"))
if r.get("created_at"):
r["created_at"] = r["created_at"].isoformat()
if r.get("sealed_at"):
r["sealed_at"] = r["sealed_at"].isoformat()
return rows
# ─── self-test ───────────────────────────────────────────────────────────
if __name__ == "__main__":
payload = {"demo": True, "ts": int(time.time()), "msg": "PGŽ seal self-test"}
h = hash_payload(payload)
res = seal_to_polygon(
h,
ref_id="selftest:1",
action="selftest.run",
ref_type="selftest",
payload=payload,
)
print(json.dumps(res, indent=2, default=str, ensure_ascii=False))