2026-06-11 20:28:17 +00:00
|
|
|
# retoor <retoor@molodetz.nl>
|
|
|
|
|
|
|
|
|
|
import logging
|
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
from typing import Optional
|
|
|
|
|
|
|
|
|
|
from devplacepy.database import db, get_table
|
|
|
|
|
from devplacepy.utils import generate_uid
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
AUDIT_TABLE = "audit_log"
|
|
|
|
|
LINKS_TABLE = "audit_log_links"
|
|
|
|
|
|
|
|
|
|
EVENT_COLUMNS = (
|
|
|
|
|
"uid",
|
|
|
|
|
"created_at",
|
|
|
|
|
"event_key",
|
|
|
|
|
"category",
|
|
|
|
|
"actor_kind",
|
|
|
|
|
"actor_uid",
|
|
|
|
|
"actor_username",
|
|
|
|
|
"actor_role",
|
|
|
|
|
"origin",
|
|
|
|
|
"via_agent",
|
|
|
|
|
"request_method",
|
|
|
|
|
"request_path",
|
|
|
|
|
"actor_ip",
|
|
|
|
|
"user_agent",
|
|
|
|
|
"target_type",
|
|
|
|
|
"target_uid",
|
|
|
|
|
"target_label",
|
|
|
|
|
"old_value",
|
|
|
|
|
"new_value",
|
|
|
|
|
"summary",
|
|
|
|
|
"metadata",
|
|
|
|
|
"result",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
LINK_COLUMNS = (
|
|
|
|
|
"uid",
|
|
|
|
|
"audit_uid",
|
|
|
|
|
"relation",
|
|
|
|
|
"object_type",
|
|
|
|
|
"object_uid",
|
|
|
|
|
"object_label",
|
|
|
|
|
"created_at",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def now() -> str:
|
|
|
|
|
return datetime.now(timezone.utc).isoformat()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _empty_event() -> dict:
|
|
|
|
|
row: dict = {column: None for column in EVENT_COLUMNS}
|
|
|
|
|
row["via_agent"] = 0
|
|
|
|
|
return row
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _empty_link() -> dict:
|
|
|
|
|
return {column: None for column in LINK_COLUMNS}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def ensure_tables() -> None:
|
|
|
|
|
try:
|
|
|
|
|
if AUDIT_TABLE not in db.tables:
|
|
|
|
|
sentinel = _empty_event()
|
|
|
|
|
sentinel["uid"] = "__audit_sentinel__"
|
|
|
|
|
sentinel["created_at"] = now()
|
|
|
|
|
sentinel["event_key"] = "__sentinel__"
|
|
|
|
|
sentinel["category"] = "system"
|
|
|
|
|
sentinel["actor_kind"] = "system"
|
|
|
|
|
sentinel["actor_role"] = "system"
|
|
|
|
|
sentinel["origin"] = "system"
|
|
|
|
|
sentinel["result"] = "success"
|
|
|
|
|
table = get_table(AUDIT_TABLE)
|
|
|
|
|
table.insert(sentinel)
|
|
|
|
|
table.delete(uid="__audit_sentinel__")
|
|
|
|
|
if LINKS_TABLE not in db.tables:
|
|
|
|
|
sentinel = _empty_link()
|
|
|
|
|
sentinel["uid"] = "__audit_link_sentinel__"
|
|
|
|
|
sentinel["audit_uid"] = "__audit_sentinel__"
|
|
|
|
|
sentinel["relation"] = "actor"
|
|
|
|
|
sentinel["object_type"] = "user"
|
|
|
|
|
sentinel["object_uid"] = "__sentinel__"
|
|
|
|
|
sentinel["created_at"] = now()
|
|
|
|
|
table = get_table(LINKS_TABLE)
|
|
|
|
|
table.insert(sentinel)
|
|
|
|
|
table.delete(uid="__audit_link_sentinel__")
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
logger.warning("audit ensure_tables failed: %s", exc)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def insert_event(row: dict) -> str:
|
|
|
|
|
record = _empty_event()
|
|
|
|
|
record.update({key: value for key, value in row.items() if key in EVENT_COLUMNS})
|
|
|
|
|
if not record.get("uid"):
|
|
|
|
|
record["uid"] = generate_uid()
|
|
|
|
|
if not record.get("created_at"):
|
|
|
|
|
record["created_at"] = now()
|
|
|
|
|
if record.get("via_agent") is None:
|
|
|
|
|
record["via_agent"] = 0
|
2026-07-19 18:57:43 +02:00
|
|
|
get_table(AUDIT_TABLE).insert(record, ensure=False)
|
2026-06-11 20:28:17 +00:00
|
|
|
return record["uid"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def insert_links(audit_uid: str, links: list[dict]) -> int:
|
|
|
|
|
if not links:
|
|
|
|
|
return 0
|
|
|
|
|
stamp = now()
|
2026-07-19 18:57:43 +02:00
|
|
|
records = []
|
2026-06-11 20:28:17 +00:00
|
|
|
for link in links:
|
|
|
|
|
record = _empty_link()
|
|
|
|
|
record.update(
|
|
|
|
|
{key: value for key, value in link.items() if key in LINK_COLUMNS}
|
|
|
|
|
)
|
|
|
|
|
record["uid"] = generate_uid()
|
|
|
|
|
record["audit_uid"] = audit_uid
|
|
|
|
|
record["created_at"] = stamp
|
|
|
|
|
if not record.get("object_uid"):
|
|
|
|
|
continue
|
2026-07-19 18:57:43 +02:00
|
|
|
records.append(record)
|
|
|
|
|
if records:
|
|
|
|
|
get_table(LINKS_TABLE).insert_many(records, ensure=False)
|
|
|
|
|
return len(records)
|
2026-06-11 20:28:17 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_event(uid: str) -> Optional[dict]:
|
|
|
|
|
if AUDIT_TABLE not in db.tables:
|
|
|
|
|
return None
|
|
|
|
|
return get_table(AUDIT_TABLE).find_one(uid=uid)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_links(audit_uid: str) -> list[dict]:
|
|
|
|
|
if LINKS_TABLE not in db.tables:
|
|
|
|
|
return []
|
|
|
|
|
return list(
|
|
|
|
|
get_table(LINKS_TABLE).find(audit_uid=audit_uid, order_by=["created_at"])
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def sweep(cutoff: str, batch_size: int = 5000, max_batches: int = 100) -> tuple[int, int]:
|
|
|
|
|
if AUDIT_TABLE not in db.tables:
|
|
|
|
|
return (0, 0)
|
|
|
|
|
removed_links = 0
|
|
|
|
|
removed_events = 0
|
|
|
|
|
for _ in range(max_batches):
|
|
|
|
|
rows = list(
|
|
|
|
|
db.query(
|
|
|
|
|
f"SELECT uid FROM {AUDIT_TABLE} WHERE created_at < :cutoff "
|
|
|
|
|
f"ORDER BY created_at LIMIT :limit",
|
|
|
|
|
cutoff=cutoff,
|
|
|
|
|
limit=batch_size,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
uids = [row["uid"] for row in rows]
|
|
|
|
|
if not uids:
|
|
|
|
|
break
|
|
|
|
|
placeholders, params = _in_clause(uids)
|
2026-06-21 16:46:27 +00:00
|
|
|
with db:
|
|
|
|
|
if LINKS_TABLE in db.tables:
|
|
|
|
|
link_rows = list(
|
|
|
|
|
db.query(
|
|
|
|
|
f"SELECT COUNT(*) AS c FROM {LINKS_TABLE} "
|
|
|
|
|
f"WHERE audit_uid IN ({placeholders})",
|
|
|
|
|
**params,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
removed_links += int(link_rows[0]["c"]) if link_rows else 0
|
2026-06-11 20:28:17 +00:00
|
|
|
db.query(
|
2026-06-21 16:46:27 +00:00
|
|
|
f"DELETE FROM {LINKS_TABLE} WHERE audit_uid IN ({placeholders})",
|
2026-06-11 20:28:17 +00:00
|
|
|
**params,
|
|
|
|
|
)
|
|
|
|
|
db.query(
|
2026-06-21 16:46:27 +00:00
|
|
|
f"DELETE FROM {AUDIT_TABLE} WHERE uid IN ({placeholders})", **params
|
2026-06-11 20:28:17 +00:00
|
|
|
)
|
|
|
|
|
removed_events += len(uids)
|
|
|
|
|
if len(uids) < batch_size:
|
|
|
|
|
break
|
|
|
|
|
return (removed_links, removed_events)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _in_clause(uids: list[str], prefix: str = "u") -> tuple[str, dict]:
|
|
|
|
|
placeholders = ", ".join(f":{prefix}{index}" for index in range(len(uids)))
|
|
|
|
|
params = {f"{prefix}{index}": uid for index, uid in enumerate(uids)}
|
|
|
|
|
return placeholders, params
|