"""Immutable audit logger for HAD Digital MVP.
Every action is timestamped and attributed.
The audit_log table is append-only (no UPDATE or DELETE allowed in application code).
"""
from database import get_db
def log_action(
user_id: int = None,
username: str = None,
action: str = "",
resource_type: str = None,
resource_id: int = None,
details: str = None,
ip_address: str = None,
user_agent: str = None,
):
"""Log an auditable action.
Args:
user_id: ID of the user performing the action (None for system actions)
username: Username for display (auto-resolved from user_id if not provided)
action: The action being performed (e.g., 'login', 'submit_report', 'acknowledge_alert')
resource_type: Type of resource being acted upon (e.g., 'patient', 'report', 'alert')
resource_id: ID of the resource
details: Human-readable details of the action
ip_address: Client IP address
user_agent: Client user agent string
"""
db = get_db()
# Auto-resolve username if not provided
if username is None and user_id is not None:
user = db.fetchone("SELECT username FROM users WHERE id = ?", (user_id,))
if user:
username = user["username"]
db.execute(
"""INSERT INTO audit_log
(user_id, username, action, resource_type, resource_id, details, ip_address, user_agent)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(user_id, username, action, resource_type, resource_id, details, ip_address, user_agent),
)
db.commit()
def get_audit_log(
user_id: int = None,
action: str = None,
resource_type: str = None,
resource_id: int = None,
limit: int = 100,
offset: int = 0,
) -> list[dict]:
"""Query the audit log with optional filters.
Returns entries in reverse chronological order (newest first).
"""
db = get_db()
conditions = []
params = []
if user_id is not None:
conditions.append("user_id = ?")
params.append(user_id)
if action is not None:
conditions.append("action = ?")
params.append(action)
if resource_type is not None:
conditions.append("resource_type = ?")
params.append(resource_type)
if resource_id is not None:
conditions.append("resource_id = ?")
params.append(resource_id)
where_clause = " AND ".join(conditions) if conditions else "1=1"
rows = db.fetchall(
f"SELECT * FROM audit_log WHERE {where_clause} ORDER BY timestamp DESC LIMIT ? OFFSET ?",
tuple(params) + (limit, offset),
)
return [dict(r) for r in rows]
def get_audit_count(
user_id: int = None,
action: str = None,
resource_type: str = None,
) -> int:
"""Count audit log entries matching the filters."""
db = get_db()
conditions = []
params = []
if user_id is not None:
conditions.append("user_id = ?")
params.append(user_id)
if action is not None:
conditions.append("action = ?")
params.append(action)
if resource_type is not None:
conditions.append("resource_type = ?")
params.append(resource_type)
where_clause = " AND ".join(conditions) if conditions else "1=1"
row = db.fetchone(
f"SELECT COUNT(*) as cnt FROM audit_log WHERE {where_clause}",
tuple(params),
)
return row["cnt"] if row else 0