|
|
|
@@ -1,10 +1,19 @@
|
|
|
|
|
"""
|
|
|
|
|
audit_seal_router.py — HTTP surface for the Polygon PoS sealing module
|
|
|
|
|
audit_seal_router.py — HTTP surface for sys_audit log + Polygon PoS sealing
|
|
|
|
|
Author: Damir Radulić (damir@rinet.one) / dradulic@outlook.com
|
|
|
|
|
Date: 2026-05-04
|
|
|
|
|
Date: 2026-05-04 (R3) / 2026-05-05 (R4 — log+stats+helper)
|
|
|
|
|
|
|
|
|
|
Endpoints (all under /sport/api):
|
|
|
|
|
|
|
|
|
|
GET /audit/log?limit=200&action=&resource=&q=&user=
|
|
|
|
|
Tail of pgz_sport.sys_audit with optional filters. Returns:
|
|
|
|
|
{ count, items: [{id, user_id, user_email, action, resource_type, resource_id,
|
|
|
|
|
details, tx_hash, created_at}], total }
|
|
|
|
|
Aliases: target_type/resource_type → resource for legacy frontend.
|
|
|
|
|
|
|
|
|
|
GET /audit/stats
|
|
|
|
|
{ total, today, sealed, users }
|
|
|
|
|
|
|
|
|
|
POST /audit/seal
|
|
|
|
|
body: {
|
|
|
|
|
action: "sufinanciranje.approved",
|
|
|
|
@@ -23,12 +32,21 @@ GET /audit/seal/{seal_id}
|
|
|
|
|
|
|
|
|
|
The legacy hash-chain audit endpoints (/api/admin/audit-chain*) live in
|
|
|
|
|
pgz_sport_api.py and remain unchanged.
|
|
|
|
|
|
|
|
|
|
----------------------------------------------------------------------
|
|
|
|
|
audit_log() — shared helper for other routers (cc2/4/5/6)
|
|
|
|
|
from audit_seal_router import audit_log
|
|
|
|
|
audit_log(action='users.update', target_type='users', target_id=7,
|
|
|
|
|
payload={'changed':['email']}, user_id=u['id'], user_email=u['email'])
|
|
|
|
|
Fail-soft: never raises, only writes to stderr on error.
|
|
|
|
|
"""
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
import sys, os
|
|
|
|
|
import sys, os, json, traceback
|
|
|
|
|
from datetime import date, datetime
|
|
|
|
|
from typing import Any, Optional
|
|
|
|
|
|
|
|
|
|
from fastapi import APIRouter, Body, HTTPException, Header
|
|
|
|
|
import psycopg2, psycopg2.extras
|
|
|
|
|
from fastapi import APIRouter, Body, HTTPException, Header, Query
|
|
|
|
|
|
|
|
|
|
# blockchain.seal lives at /opt/pgz-sport/blockchain/seal.py
|
|
|
|
|
sys.path.insert(0, '/opt/pgz-sport')
|
|
|
|
@@ -36,6 +54,152 @@ from blockchain import seal as seal_mod # noqa: E402
|
|
|
|
|
|
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
|
|
|
|
# ── DB helper (own connection, mirrors enrich_router DSN logic) ──────────
|
|
|
|
|
_pgh = os.environ.get('PG_HOST', '10.10.0.2')
|
|
|
|
|
_pgp = int(os.environ.get('PG_PORT', '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'))
|
|
|
|
|
|
|
|
|
|
def _conn():
|
|
|
|
|
c = psycopg2.connect(**_DB); c.autocommit = True; return c
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def audit_log(action: str,
|
|
|
|
|
target_type: Optional[str] = None,
|
|
|
|
|
target_id: Optional[int] = None,
|
|
|
|
|
target_text: Optional[str] = None,
|
|
|
|
|
payload: Optional[dict] = None,
|
|
|
|
|
user_id: Optional[int] = None,
|
|
|
|
|
user_email: Optional[str] = None,
|
|
|
|
|
ip_address: Optional[str] = None,
|
|
|
|
|
user_agent: Optional[str] = None) -> Optional[int]:
|
|
|
|
|
"""Insert one row into pgz_sport.sys_audit. Fail-soft.
|
|
|
|
|
|
|
|
|
|
Other routers should call this after any successful DB mutation.
|
|
|
|
|
The DB trigger trg_audit_chain populates row_hash + chain_idx automatically.
|
|
|
|
|
|
|
|
|
|
Returns the new row id, or None on failure.
|
|
|
|
|
"""
|
|
|
|
|
if not action:
|
|
|
|
|
return None
|
|
|
|
|
try:
|
|
|
|
|
with _conn() as c, c.cursor() as cur:
|
|
|
|
|
cur.execute("""
|
|
|
|
|
INSERT INTO pgz_sport.sys_audit
|
|
|
|
|
(user_id, user_email, action, target_type, target_id, target_text,
|
|
|
|
|
payload, ip_address, user_agent)
|
|
|
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s::jsonb, %s, %s)
|
|
|
|
|
RETURNING id
|
|
|
|
|
""", (
|
|
|
|
|
user_id,
|
|
|
|
|
user_email,
|
|
|
|
|
action[:100],
|
|
|
|
|
(target_type or None) and str(target_type)[:50],
|
|
|
|
|
target_id,
|
|
|
|
|
target_text,
|
|
|
|
|
json.dumps(payload, default=str, ensure_ascii=False) if payload is not None else None,
|
|
|
|
|
ip_address,
|
|
|
|
|
user_agent,
|
|
|
|
|
))
|
|
|
|
|
row = cur.fetchone()
|
|
|
|
|
return row[0] if row else None
|
|
|
|
|
except Exception:
|
|
|
|
|
# Never block business logic on audit failure — log and move on.
|
|
|
|
|
sys.stderr.write('[audit_log] insert failed for action='+str(action)+'\n')
|
|
|
|
|
sys.stderr.write(traceback.format_exc())
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _row_to_item(r: dict) -> dict:
|
|
|
|
|
"""Normalise a sys_audit row for the audit.html UI."""
|
|
|
|
|
payload = r.get('payload') or {}
|
|
|
|
|
if isinstance(payload, str):
|
|
|
|
|
try: payload = json.loads(payload)
|
|
|
|
|
except: pass
|
|
|
|
|
out = {
|
|
|
|
|
'id': r.get('id'),
|
|
|
|
|
'user_id': r.get('user_id'),
|
|
|
|
|
'user_email': r.get('user_email'),
|
|
|
|
|
'action': r.get('action'),
|
|
|
|
|
'resource_type': r.get('target_type'),
|
|
|
|
|
'resource_id': r.get('target_id'),
|
|
|
|
|
'target_text': r.get('target_text'),
|
|
|
|
|
'details': payload,
|
|
|
|
|
'row_hash': r.get('row_hash'),
|
|
|
|
|
'chain_idx': r.get('chain_idx'),
|
|
|
|
|
'created_at': (r.get('created_at').isoformat() if isinstance(r.get('created_at'), datetime) else r.get('created_at')),
|
|
|
|
|
}
|
|
|
|
|
# Surface a polygon tx hash if it was stored in payload (seal_to_polygon does this)
|
|
|
|
|
if isinstance(payload, dict):
|
|
|
|
|
out['tx_hash'] = payload.get('tx_hash') or payload.get('polygon_tx') or payload.get('seal_tx_hash')
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── R4: audit log + stats endpoints ─────────────────────────────────────
|
|
|
|
|
@router.get("/audit/log")
|
|
|
|
|
def audit_log_list(limit: int = Query(200, ge=1, le=1000),
|
|
|
|
|
action: Optional[str] = None,
|
|
|
|
|
resource: Optional[str] = None,
|
|
|
|
|
user: Optional[str] = None,
|
|
|
|
|
q: Optional[str] = None,
|
|
|
|
|
since: Optional[str] = None):
|
|
|
|
|
"""Recent audit rows with simple filters. resource matches target_type."""
|
|
|
|
|
where = []
|
|
|
|
|
params: list = []
|
|
|
|
|
if action:
|
|
|
|
|
where.append("action ILIKE %s"); params.append('%'+action+'%')
|
|
|
|
|
if resource:
|
|
|
|
|
where.append("target_type ILIKE %s"); params.append('%'+resource+'%')
|
|
|
|
|
if user:
|
|
|
|
|
where.append("(user_email ILIKE %s OR CAST(user_id AS text)=%s)")
|
|
|
|
|
params.append('%'+user+'%'); params.append(user)
|
|
|
|
|
if q:
|
|
|
|
|
where.append("(action ILIKE %s OR target_type ILIKE %s OR target_text ILIKE %s OR user_email ILIKE %s OR payload::text ILIKE %s)")
|
|
|
|
|
params.extend(['%'+q+'%']*5)
|
|
|
|
|
if since:
|
|
|
|
|
where.append("created_at >= %s::timestamptz"); params.append(since)
|
|
|
|
|
sql = ("""
|
|
|
|
|
SELECT id, user_id, user_email, action, target_type, target_id, target_text,
|
|
|
|
|
payload, ip_address, user_agent, row_hash, chain_idx, created_at
|
|
|
|
|
FROM pgz_sport.sys_audit
|
|
|
|
|
""" + (" WHERE "+ ' AND '.join(where) if where else '') + """
|
|
|
|
|
ORDER BY id DESC LIMIT %s
|
|
|
|
|
""")
|
|
|
|
|
params.append(limit)
|
|
|
|
|
with _conn() as c, c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
|
|
|
|
cur.execute(sql, params)
|
|
|
|
|
rows = [_row_to_item(dict(r)) for r in cur.fetchall()]
|
|
|
|
|
cur.execute("SELECT count(*) AS n FROM pgz_sport.sys_audit")
|
|
|
|
|
total = cur.fetchone()['n']
|
|
|
|
|
return {'count': len(rows), 'total': total, 'items': rows}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/audit/stats")
|
|
|
|
|
def audit_stats():
|
|
|
|
|
with _conn() as c, c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
|
|
|
|
cur.execute("""
|
|
|
|
|
SELECT
|
|
|
|
|
(SELECT count(*) FROM pgz_sport.sys_audit) AS total,
|
|
|
|
|
(SELECT count(*) FROM pgz_sport.sys_audit WHERE created_at::date = current_date) AS today,
|
|
|
|
|
(SELECT count(DISTINCT user_email) FROM pgz_sport.sys_audit
|
|
|
|
|
WHERE user_email IS NOT NULL AND created_at >= now()-interval '30 days') AS users,
|
|
|
|
|
(SELECT count(*) FROM pgz_sport.sys_audit
|
|
|
|
|
WHERE payload ? 'tx_hash' OR payload ? 'polygon_tx' OR payload ? 'seal_tx_hash')
|
|
|
|
|
AS sealed
|
|
|
|
|
""")
|
|
|
|
|
r = cur.fetchone() or {}
|
|
|
|
|
return {
|
|
|
|
|
'total': int(r.get('total') or 0),
|
|
|
|
|
'today': int(r.get('today') or 0),
|
|
|
|
|
'sealed': int(r.get('sealed') or 0),
|
|
|
|
|
'users': int(r.get('users') or 0),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/audit/seal")
|
|
|
|
|
def audit_seal(body: dict = Body(...),
|
|
|
|
|