feat: add audit log tables, indexes, and CLI/content recording hooks
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
# 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
|
||||
get_table(AUDIT_TABLE).insert(record)
|
||||
return record["uid"]
|
||||
|
||||
|
||||
def insert_links(audit_uid: str, links: list[dict]) -> int:
|
||||
if not links:
|
||||
return 0
|
||||
table = get_table(LINKS_TABLE)
|
||||
stamp = now()
|
||||
count = 0
|
||||
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
|
||||
table.insert(record)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
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)
|
||||
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
|
||||
db.query(
|
||||
f"DELETE FROM {LINKS_TABLE} WHERE audit_uid IN ({placeholders})",
|
||||
**params,
|
||||
)
|
||||
db.query(
|
||||
f"DELETE FROM {AUDIT_TABLE} WHERE uid IN ({placeholders})", **params
|
||||
)
|
||||
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
|
||||
Reference in New Issue
Block a user