diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 356003d8..2c6bb908 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -149,7 +149,7 @@ jobs: SM_USERS_BOOTSTRAP_PASSWORD: admin # Exclude Keycloak module — SM020 prevents both users and keycloak # from running simultaneously. E2E tests use the users module. - SM_MODULES_ENABLED: '["Auth","Users","Dashboard","Permissions","Settings","BackgroundTasks","FileStorage","FeatureFlags"]' + SM_MODULES_ENABLED: '["Auth","Users","Dashboard","Permissions","Settings","BackgroundTasks","FileStorage","FeatureFlags","AuditLog"]' E2E_BASE_URL: http://localhost:8000 steps: - uses: actions/checkout@v6 diff --git a/.qa/reports/qa-report-iteration-1.md b/.qa/reports/qa-report-iteration-1.md new file mode 100644 index 00000000..a4228524 --- /dev/null +++ b/.qa/reports/qa-report-iteration-1.md @@ -0,0 +1,83 @@ +# QA Report: Audit Log Module +**Date:** 2026-05-28 +**Tester:** Claude QA (Senior) +**Target:** http://localhost:8000/audit_log +**Depth:** normal +**Iteration:** 1 of 3 + +## Summary +| Category | Passed | Failed | Skipped | +|----------|--------|--------|---------| +| Happy Path | 8 | 2 | 0 | +| Form Validation | — | — | — (agent timed out) | +| Error States | 11 | 6 | 0 | +| **Total** | **19** | **8** | **0** | + +## Critical Issues (P0) +None. + +## Major Issues (P1) + +### BUG-001: Invalid query params render raw JSON validation errors +- **Severity:** P1 (4 instances: ES-005, ES-006, ES-007, ES-008) +- **Steps to reproduce:** Navigate to /audit_log?page_size=0 or /audit_log?page=-1 or /audit_log?page=abc or /audit_log?page_size=500 +- **Expected:** Graceful fallback — clamp to defaults or show user-friendly error page +- **Actual:** Raw FastAPI JSON validation error shown as entire page content: `{"detail":[{"type":"greater_than_equal",...}]}` +- **Root cause:** Inertia view endpoint uses `Query(ge=1, le=200)` constraints which raise `RequestValidationError` — no handler converts these to Inertia-friendly responses +- **Fix hint:** Add a `RequestValidationError` exception handler in the view that either clamps to defaults or renders an Inertia error page. Could be framework-level (benefits all modules). + +## Minor Issues (P2) + +### BUG-002: Setting entity_id is empty string for integer-PK entities +- **Severity:** P2 +- **Steps to reproduce:** Create a Setting via POST /api/settings/, then check the audit log for that Setting's "Created" entry +- **Expected:** entity_id shows the Setting's actual ID (e.g., "1") +- **Actual:** entity_id is "" (empty string) — the entity column shows "Setting" with no ID +- **Root cause:** Known limitation — `_entity_pk_str` runs during before_flush when integer PKs haven't been assigned yet. UUID PKs work fine. +- **Fix hint:** Architectural change needed — move to after_flush or accept as known limitation for integer-PK models. + +## Observations (P3) + +### OBS-001: Sidebar "Audit Log" link has no active/highlighted state +- **Severity:** P3 +- **Details:** All sidebar links share identical CSS classes regardless of current page. No `aria-current="page"` is set. This is a framework-wide issue affecting all modules, not specific to Audit Log. + +### OBS-002: Entity Type dropdown opens on Tab focus +- **Severity:** P3 +- **Details:** The Radix Select component's Entity Type dropdown auto-opens when receiving Tab focus, potentially trapping keyboard navigation. This is a known Radix UI behavior. + +### OBS-003: Out-of-range page shows empty state without context +- **Severity:** P3 +- **Details:** /audit_log?page=999 shows "No audit entries" empty state. Could show "Page out of range" or redirect to last valid page. + +### OBS-004: Form validation agent timed out +- **Details:** The form validation agent stalled while testing datetime-local inputs (likely Playwright interaction complexity with date pickers). Core form validation was partially covered by other agents. + +## Passed Tests + + +Click to expand (19 tests passed) + +| # | Category | Scenario | Result | +|---|----------|----------|--------| +| 1 | Happy Path | Page loads with data | PASS | +| 2 | Happy Path | Filter by Entity Type | PASS | +| 3 | Happy Path | Filter by Action | PASS | +| 4 | Happy Path | Filter by User ID | PASS | +| 5 | Happy Path | Clear filters | PASS | +| 6 | Happy Path | Pagination (Next/Previous) | PASS | +| 7 | Happy Path | New entity generates audit entry | PASS | +| 8 | Happy Path | Empty state display | PASS | +| 9 | Error States | Empty state for zero results | PASS | +| 10 | Error States | URL-based filter preselection | PASS | +| 11 | Error States | page_size=5 via URL | PASS | +| 12 | Error States | Browser back preserves state | PASS | +| 13 | Error States | Page refresh preserves filters | PASS | +| 14 | Error States | Enter key submits filter form | PASS | +| 15 | Error States | Rapid Apply clicks (5x) | PASS | +| 16 | Error States | Unauthenticated redirect to login | PASS | +| 17 | Error States | API endpoint returns JSON | PASS | +| 18 | Error States | API returns 401 unauthenticated | PASS | +| 19 | Error States | XSS in params safely handled | PASS | + + diff --git a/.qa/screenshots/iteration-1/00-initial-state.png b/.qa/screenshots/iteration-1/00-initial-state.png new file mode 100644 index 00000000..685d0517 Binary files /dev/null and b/.qa/screenshots/iteration-1/00-initial-state.png differ diff --git a/.qa/screenshots/iteration-1/99-fix-verify.png b/.qa/screenshots/iteration-1/99-fix-verify.png new file mode 100644 index 00000000..518bb294 Binary files /dev/null and b/.qa/screenshots/iteration-1/99-fix-verify.png differ diff --git a/.verify/screenshot.png b/.verify/screenshot.png index fb7b42e6..518bb294 100644 Binary files a/.verify/screenshot.png and b/.verify/screenshot.png differ diff --git a/docs/superpowers/plans/2026-05-27-audit-log-module.md b/docs/superpowers/plans/2026-05-27-audit-log-module.md new file mode 100644 index 00000000..916b8304 --- /dev/null +++ b/docs/superpowers/plans/2026-05-27-audit-log-module.md @@ -0,0 +1,1993 @@ +# Audit Log Module Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Automatically track field-level changes to all SQLModel entities across the framework, persisted atomically in an audit_log table, with a filterable admin UI. + +**Architecture:** A `before_flush` callback on `DatabaseState` collects field-level diffs (via SQLAlchemy attribute history) for every entity in `session.new`/`.dirty`/`.deleted`. The `audit_log` module registers itself as the callback consumer during `on_startup`, writing `AuditEntry` rows into the same session. A standard module (models, service, API, Inertia Browse page) provides querying and display. + +**Tech Stack:** Python 3.12, FastAPI, SQLModel, SQLAlchemy (attribute history API), Inertia.js + React 19, Tailwind 4, shadcn/ui components. + +--- + +## File Map + +### Framework changes (simple_module_db) + +| File | Action | Responsibility | +|---|---|---| +| `framework/db/simple_module_db/audit.py` | **Create** | `AuditRecord` dataclass + `collect_audit_records()` diff-collection logic | +| `framework/db/simple_module_db/session.py` | **Modify** | Add `audit_callback` field to `DatabaseState` | +| `framework/db/simple_module_db/listeners.py` | **Modify** | Store `_db_state` ref in `register_listeners`, call `collect_audit_records` + callback at end of `_before_flush_listener` | +| `framework/db/simple_module_db/__init__.py` | **Modify** | Re-export `AuditRecord` | +| `framework/db/tests/test_audit.py` | **Create** | Unit tests for diff collection + exclusion logic | + +### Module files (audit_log) + +| File | Action | Responsibility | +|---|---|---| +| `modules/audit_log/pyproject.toml` | **Create** | Package metadata + entry point | +| `modules/audit_log/package.json` | **Create** | JS peer deps | +| `modules/audit_log/tsconfig.json` | **Create** | TS config extending shared base | +| `modules/audit_log/audit_log/__init__.py` | **Create** | Empty | +| `modules/audit_log/audit_log/py.typed` | **Create** | PEP 561 marker | +| `modules/audit_log/audit_log/constants.py` | **Create** | All module constants | +| `modules/audit_log/audit_log/models.py` | **Create** | `AuditEntry` table model | +| `modules/audit_log/audit_log/contracts/__init__.py` | **Create** | Empty | +| `modules/audit_log/audit_log/contracts/schemas.py` | **Create** | `AuditEntryRead` + `AuditEntryList` DTOs | +| `modules/audit_log/audit_log/service.py` | **Create** | Query logic (list with filters + pagination) | +| `modules/audit_log/audit_log/capture.py` | **Create** | Callback that converts `AuditRecord` → `AuditEntry` and adds to session | +| `modules/audit_log/audit_log/deps.py` | **Create** | FastAPI dependencies | +| `modules/audit_log/audit_log/endpoints/__init__.py` | **Create** | Empty | +| `modules/audit_log/audit_log/endpoints/api.py` | **Create** | `GET /api/audit_log` | +| `modules/audit_log/audit_log/endpoints/views.py` | **Create** | `GET /audit_log` → Browse page | +| `modules/audit_log/audit_log/module.py` | **Create** | `AuditLogModule(ModuleBase)` | +| `modules/audit_log/audit_log/pages/Browse.tsx` | **Create** | Filterable admin UI | +| `modules/audit_log/audit_log/locales/en.json` | **Create** | i18n strings | + +### Host wiring + +| File | Action | Responsibility | +|---|---|---| +| `host/pyproject.toml` | **Modify** | Add `simple_module_audit_log` dependency | + +### Tests + +| File | Action | Responsibility | +|---|---|---| +| `framework/db/tests/test_audit.py` | **Create** | Unit tests for `collect_audit_records` | +| `tests/test_audit_log.py` | **Create** | Integration tests (API, filters, recursion guard) | + +--- + +## Task 1: Framework — AuditRecord dataclass and diff collection + +**Files:** +- Create: `framework/db/simple_module_db/audit.py` +- Create: `framework/db/tests/test_audit.py` + +This task builds the pure-logic core: given SQLAlchemy session state, produce a list of `AuditRecord` structs describing what changed. No module code, no DB writes — just data extraction. + +- [ ] **Step 1: Write test for AuditRecord creation from a new entity** + +Create `framework/db/tests/test_audit.py`: + +```python +"""Tests for the audit diff-collection logic.""" + +from __future__ import annotations + +import uuid + +from sqlalchemy import inspect as sa_inspect +from sqlmodel import Field, SQLModel + +from simple_module_db.audit import AuditRecord, collect_audit_records +from simple_module_db.base import create_module_base +from simple_module_db.mixins import AuditMixin + +Base = create_module_base("test_audit") + + +class AuditTestItem(Base, AuditMixin, table=True): + __tablename__ = "test_audit_audit_test_item" + + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + name: str = Field(max_length=100) + value: int = Field(default=0) + + +class ExcludedModel(Base, table=True): + __tablename__ = "test_audit_excluded_model" + __audit_exclude__ = True + + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + secret: str = Field(max_length=100) + + +class PartialExcludeModel(Base, AuditMixin, table=True): + __tablename__ = "test_audit_partial_exclude" + __audit_exclude_fields__: set[str] = {"password_hash"} + + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + name: str = Field(max_length=100) + password_hash: str = Field(max_length=255, default="") + + +async def test_collect_records_for_new_entity(db_state, engine): + """New entities produce a 'created' record with all field values.""" + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + async with db_state.session_factory() as session: + item = AuditTestItem(name="test", value=42) + session.add(item) + + sync_session = session.sync_session + sync_session.flush() + + # After flush, session.new is cleared, so we test via the listener + # For unit testing, call collect directly with pre-flush state + # We'll verify via integration tests; here just test the dataclass + record = AuditRecord( + entity_type="AuditTestItem", + entity_id=str(item.id), + action="created", + changes=[{"field": "name", "new": "test"}, {"field": "value", "new": 42}], + user_id=None, + correlation_id=None, + ) + assert record.entity_type == "AuditTestItem" + assert record.action == "created" + assert len(record.changes) == 2 + + +async def test_audit_record_is_frozen(): + """AuditRecord is immutable.""" + record = AuditRecord( + entity_type="Foo", + entity_id="1", + action="created", + changes=[], + user_id=None, + correlation_id=None, + ) + try: + record.entity_type = "Bar" # type: ignore[misc] + assert False, "Should have raised" + except AttributeError: + pass +``` + +- [ ] **Step 2: Run tests to verify they pass on the dataclass (and fail on missing import)** + +Run: `uv run pytest framework/db/tests/test_audit.py -v` +Expected: `ModuleNotFoundError` or `ImportError` for `simple_module_db.audit` + +- [ ] **Step 3: Implement AuditRecord dataclass and collect_audit_records function** + +Create `framework/db/simple_module_db/audit.py`: + +```python +"""Audit record collection from SQLAlchemy session state. + +Called by the before_flush listener when an audit callback is registered. +Framework-safe: no imports from modules/. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from sqlalchemy import inspect as sa_inspect +from sqlalchemy.orm import Session + +from simple_module_db.mixins import AuditMixin + +_AUDIT_MIXIN_FIELDS = frozenset({"created_at", "updated_at", "created_by", "updated_by"}) + + +@dataclass(frozen=True, slots=True) +class AuditRecord: + entity_type: str + entity_id: str + action: str + changes: list[dict[str, Any]] + user_id: str | None + correlation_id: str | None + + +def _is_excluded(obj: object) -> bool: + return getattr(type(obj), "__audit_exclude__", False) is True + + +def _excluded_fields(obj: object) -> frozenset[str]: + cls_excludes = getattr(type(obj), "__audit_exclude_fields__", set()) + return _AUDIT_MIXIN_FIELDS | frozenset(cls_excludes) + + +def _entity_pk_str(obj: object) -> str: + try: + inspector = sa_inspect(obj) + identity = inspector.identity + if identity and len(identity) == 1: + return str(identity[0]) + if identity: + return str(identity) + except Exception: + pass + pk_cols = sa_inspect(type(obj)).mapper.primary_key + vals = [getattr(obj, c.name, None) for c in pk_cols] + if len(vals) == 1: + return str(vals[0]) if vals[0] is not None else "" + return str(tuple(vals)) + + +def _column_names(obj: object) -> list[str]: + mapper = sa_inspect(type(obj)).mapper + return [c.key for c in mapper.column_attrs] + + +def _serialize(value: Any) -> Any: + if value is None: + return None + if isinstance(value, (str, int, float, bool)): + return value + return str(value) + + +def collect_audit_records( + session: Session, + user_id: str | None, + correlation_id: str | None, +) -> list[AuditRecord]: + records: list[AuditRecord] = [] + excludes_cache: dict[type, frozenset[str]] = {} + + def _get_excludes(obj: object) -> frozenset[str]: + cls = type(obj) + if cls not in excludes_cache: + excludes_cache[cls] = _excluded_fields(obj) + return excludes_cache[cls] + + for obj in list(session.new): + if _is_excluded(obj): + continue + excludes = _get_excludes(obj) + changes = [] + for col in _column_names(obj): + if col in excludes: + continue + mapper = sa_inspect(type(obj)).mapper + pk_names = {c.name for c in mapper.primary_key} + if col in pk_names: + continue + val = getattr(obj, col, None) + changes.append({"field": col, "new": _serialize(val)}) + records.append(AuditRecord( + entity_type=type(obj).__name__, + entity_id=_entity_pk_str(obj), + action="created", + changes=changes, + user_id=user_id, + correlation_id=correlation_id, + )) + + for obj in list(session.dirty): + if not session.is_modified(obj): + continue + if _is_excluded(obj): + continue + excludes = _get_excludes(obj) + changes = [] + inspector = sa_inspect(obj) + for col in _column_names(obj): + if col in excludes: + continue + hist = inspector.attrs[col].history + if not hist.has_changes(): + continue + old_val = hist.deleted[0] if hist.deleted else None + new_val = hist.added[0] if hist.added else None + changes.append({ + "field": col, + "old": _serialize(old_val), + "new": _serialize(new_val), + }) + if changes: + records.append(AuditRecord( + entity_type=type(obj).__name__, + entity_id=_entity_pk_str(obj), + action="updated", + changes=changes, + user_id=user_id, + correlation_id=correlation_id, + )) + + for obj in list(session.deleted): + if _is_excluded(obj): + continue + records.append(AuditRecord( + entity_type=type(obj).__name__, + entity_id=_entity_pk_str(obj), + action="deleted", + changes=[], + user_id=user_id, + correlation_id=correlation_id, + )) + + return records +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest framework/db/tests/test_audit.py -v` +Expected: PASS + +- [ ] **Step 5: Add comprehensive tests for exclusion logic and update diffs** + +Add to `framework/db/tests/test_audit.py`: + +```python +async def test_excluded_model_produces_no_records(db_state, engine): + """Models with __audit_exclude__ = True are skipped entirely.""" + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + async with db_state.session_factory() as session: + item = ExcludedModel(secret="hidden") + session.add(item) + records = collect_audit_records(session.sync_session, None, None) + assert not any(r.entity_type == "ExcludedModel" for r in records) + + +async def test_excluded_fields_are_omitted(db_state, engine): + """Fields in __audit_exclude_fields__ don't appear in changes.""" + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + async with db_state.session_factory() as session: + item = PartialExcludeModel(name="alice", password_hash="secret123") + session.add(item) + records = collect_audit_records(session.sync_session, None, None) + partial_records = [r for r in records if r.entity_type == "PartialExcludeModel"] + assert len(partial_records) == 1 + field_names = {c["field"] for c in partial_records[0].changes} + assert "name" in field_names + assert "password_hash" not in field_names + + +async def test_audit_mixin_fields_excluded_by_default(db_state, engine): + """AuditMixin fields (created_at, updated_at, etc.) are never tracked.""" + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + async with db_state.session_factory() as session: + item = AuditTestItem(name="test", value=1) + session.add(item) + records = collect_audit_records(session.sync_session, None, None) + test_records = [r for r in records if r.entity_type == "AuditTestItem"] + assert len(test_records) == 1 + field_names = {c["field"] for c in test_records[0].changes} + assert "created_at" not in field_names + assert "updated_at" not in field_names + assert "created_by" not in field_names + assert "updated_by" not in field_names + + +async def test_collect_records_for_update(db_state, engine): + """Updated entities produce an 'updated' record with old/new diffs.""" + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + async with db_state.session_factory() as session: + item = AuditTestItem(name="original", value=1) + session.add(item) + await session.flush() + + item.name = "changed" + item.value = 99 + records = collect_audit_records(session.sync_session, "user-1", "req-abc") + update_records = [r for r in records if r.action == "updated"] + assert len(update_records) == 1 + rec = update_records[0] + assert rec.user_id == "user-1" + assert rec.correlation_id == "req-abc" + changes_by_field = {c["field"]: c for c in rec.changes} + assert changes_by_field["name"]["old"] == "original" + assert changes_by_field["name"]["new"] == "changed" + assert changes_by_field["value"]["old"] == 1 + assert changes_by_field["value"]["new"] == 99 +``` + +- [ ] **Step 6: Run all audit tests** + +Run: `uv run pytest framework/db/tests/test_audit.py -v` +Expected: All PASS + +- [ ] **Step 7: Commit** + +```bash +git add framework/db/simple_module_db/audit.py framework/db/tests/test_audit.py +git commit -m "feat(db): add AuditRecord dataclass and collect_audit_records diff logic" +``` + +--- + +## Task 2: Framework — Wire audit callback into DatabaseState and listener + +**Files:** +- Modify: `framework/db/simple_module_db/session.py:19-25` +- Modify: `framework/db/simple_module_db/listeners.py:78-92,95-186` +- Modify: `framework/db/simple_module_db/__init__.py` + +- [ ] **Step 1: Add audit_callback to DatabaseState** + +In `framework/db/simple_module_db/session.py`, add the field to the dataclass: + +```python +from collections.abc import Callable + +@dataclass +class DatabaseState: + """Holds all database state for a single application instance.""" + + engine: AsyncEngine + session_factory: async_sessionmaker[AsyncSession] + sync_session_class: type[Session] = field(repr=False, default=Session) + audit_callback: Callable | None = field(default=None, repr=False) + _listeners_registered: bool = field(default=False, repr=False) +``` + +- [ ] **Step 2: Store db_state reference in listeners.py and call collect + callback** + +In `framework/db/simple_module_db/listeners.py`: + +Add a module-level `_db_state` reference at the top (after the existing module-level constants around line 39): + +```python +_db_state: DatabaseState | None = None +``` + +In `register_listeners`, store the reference (after line 91): + +```python +def register_listeners(db_state: DatabaseState) -> None: + if db_state._listeners_registered: + logger.debug("Listeners already registered, skipping") + return + + global _db_state + _db_state = db_state + + event.listen(db_state.sync_session_class, "before_flush", _before_flush_listener) + event.listen(db_state.sync_session_class, "after_flush", _mark_session_written) + event.listen(db_state.sync_session_class, "do_orm_execute", _filter_select_statements) + db_state._listeners_registered = True + logger.info("Registered SQLAlchemy entity listeners") +``` + +At the end of `_before_flush_listener` (after the deleted loop, around line 186), add: + +```python + # Audit callback — collect diffs and delegate to the registered consumer + if _db_state is not None and _db_state.audit_callback is not None: + from simple_module_db.audit import collect_audit_records + + correlation_id_val: str | None = None + try: + from simple_module_hosting.logging import correlation_id as _cid_var + + correlation_id_val = _cid_var.get("") or None + except ImportError: + pass + + records = collect_audit_records(session, user_id, correlation_id_val) + if records: + _db_state.audit_callback(session, records) +``` + +- [ ] **Step 3: Re-export AuditRecord from __init__.py** + +Add to `framework/db/simple_module_db/__init__.py`: + +```python +from simple_module_db.audit import AuditRecord +``` + +And add `"AuditRecord"` to the `__all__` list. + +- [ ] **Step 4: Run existing framework tests to verify no regressions** + +Run: `uv run pytest framework/db/tests/ -v` +Expected: All existing tests PASS (no audit callback registered = no change in behavior) + +- [ ] **Step 5: Commit** + +```bash +git add framework/db/simple_module_db/session.py framework/db/simple_module_db/listeners.py framework/db/simple_module_db/__init__.py +git commit -m "feat(db): wire audit callback into DatabaseState and before_flush listener" +``` + +--- + +## Task 3: Module scaffold — pyproject.toml, package.json, tsconfig, constants + +**Files:** +- Create: `modules/audit_log/pyproject.toml` +- Create: `modules/audit_log/package.json` +- Create: `modules/audit_log/tsconfig.json` +- Create: `modules/audit_log/audit_log/__init__.py` +- Create: `modules/audit_log/audit_log/py.typed` +- Create: `modules/audit_log/audit_log/constants.py` +- Modify: `host/pyproject.toml` + +- [ ] **Step 1: Create pyproject.toml** + +Create `modules/audit_log/pyproject.toml`: + +```toml +[project] +name = "simple_module_audit_log" +version = "0.0.15" +description = "Automatic field-level audit trail for all SQLModel entities" +readme = "README.md" +license = "MIT" +license-files = ["LICENSE"] +requires-python = ">=3.12" +authors = [{ name = "Anto Subash", email = "antosubash@live.com" }] +keywords = ["simple-module", "audit-log", "change-tracking"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Framework :: FastAPI", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.12", + "Topic :: Internet :: WWW/HTTP", + "Topic :: Software Development :: Libraries :: Application Frameworks", + "Typing :: Typed", +] +dependencies = [ + "simple_module_core==0.0.15", + "simple_module_db==0.0.15", + "simple_module_hosting==0.0.15", +] + +[project.entry-points.simple_module] +audit_log = "audit_log.module:AuditLogModule" + +[project.urls] +Homepage = "https://github.com/antosubash/simple_module_python" +Repository = "https://github.com/antosubash/simple_module_python" +Issues = "https://github.com/antosubash/simple_module_python/issues" +Changelog = "https://github.com/antosubash/simple_module_python/blob/main/CHANGELOG.md" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["audit_log"] + +[tool.hatch.build.targets.wheel.force-include] +"package.json" = "audit_log/package.json" + +[tool.uv.sources] +simple_module_core = { workspace = true } +simple_module_db = { workspace = true } +simple_module_hosting = { workspace = true } +``` + +- [ ] **Step 2: Create package.json** + +Create `modules/audit_log/package.json`: + +```json +{ + "name": "@simple-module-py/audit-log", + "version": "0.1.0", + "private": true, + "description": "Frontend assets for the Audit Log module", + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0", + "@inertiajs/react": "^2.0.0", + "@simple-module-py/ui": "*" + }, + "devDependencies": { + "@simple-module-py/tsconfig": "*" + }, + "dependencies": {} +} +``` + +- [ ] **Step 3: Create tsconfig.json** + +Create `modules/audit_log/tsconfig.json`: + +```json +{ + "extends": "@simple-module-py/tsconfig/base.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./audit_log/*"], + "@simple-module-py/ui/*": ["../../packages/ui/src/*"] + } + }, + "include": ["audit_log/**/*.ts", "audit_log/**/*.tsx"] +} +``` + +- [ ] **Step 4: Create __init__.py and py.typed** + +Create `modules/audit_log/audit_log/__init__.py` (empty file). + +Create `modules/audit_log/audit_log/py.typed` (empty file). + +- [ ] **Step 5: Create constants.py** + +Create `modules/audit_log/audit_log/constants.py`: + +```python +"""Centralized constants for the Audit Log module.""" + +from __future__ import annotations + +from typing import Final + +# ── Module identity ────────────────────────────────────────────────── +MODULE_NAME: Final = "AuditLog" +MODULE_PACKAGE: Final = "audit_log" +LOCALE_NAMESPACE: Final = MODULE_PACKAGE + +# ── Routing ────────────────────────────────────────────────────────── +API_PREFIX: Final = "/api/audit_log" +VIEW_PREFIX: Final = "/audit_log" + +# ── Menu ───────────────────────────────────────────────────────────── +MENU_LABEL: Final = "Audit Log" +MENU_URL: Final = VIEW_PREFIX +MENU_ICON: Final = "scroll-text" +MENU_ORDER: Final = 210 + +# ── Permissions ────────────────────────────────────────────────────── +PERM_GROUP: Final = MODULE_NAME +PERM_VIEW: Final = "audit_log.view" +ALL_PERMISSIONS: Final = (PERM_VIEW,) + +# ── Database ───────────────────────────────────────────────────────── +TABLE_AUDIT_ENTRY: Final = "audit_log_audit_entry" + +# ── Actions ────────────────────────────────────────────────────────── +ACTION_CREATED: Final = "created" +ACTION_UPDATED: Final = "updated" +ACTION_DELETED: Final = "deleted" +ACTION_SOFT_DELETED: Final = "soft_deleted" +ALL_ACTIONS: Final = (ACTION_CREATED, ACTION_UPDATED, ACTION_DELETED, ACTION_SOFT_DELETED) + +# ── Field limits ───────────────────────────────────────────────────── +ENTITY_TYPE_MAX_LENGTH: Final = 255 +ENTITY_ID_MAX_LENGTH: Final = 255 +ACTION_MAX_LENGTH: Final = 20 +USER_ID_MAX_LENGTH: Final = 255 +CORRELATION_ID_MAX_LENGTH: Final = 255 + +# ── Pagination ─────────────────────────────────────────────────────── +DEFAULT_PAGE_SIZE: Final = 50 +MAX_PAGE_SIZE: Final = 200 + +# ── Inertia ────────────────────────────────────────────────────────── +PAGE_BROWSE: Final = f"{MODULE_NAME}/Browse" + +# ── HTTP ───────────────────────────────────────────────────────────── +STATUS_OK: Final = 200 +``` + +- [ ] **Step 6: Add dependency to host/pyproject.toml** + +Add `"simple_module_audit_log",` to the `dependencies` list in `host/pyproject.toml`, and add `simple_module_audit_log = { workspace = true }` to the `[tool.uv.sources]` section. + +- [ ] **Step 7: Install deps** + +Run: `uv sync --all-packages` +Expected: resolves without errors + +- [ ] **Step 8: Commit** + +```bash +git add modules/audit_log/pyproject.toml modules/audit_log/package.json modules/audit_log/tsconfig.json modules/audit_log/audit_log/__init__.py modules/audit_log/audit_log/py.typed modules/audit_log/audit_log/constants.py host/pyproject.toml +git commit -m "feat(audit_log): scaffold module package with constants and host wiring" +``` + +--- + +## Task 4: Module — AuditEntry model and contracts + +**Files:** +- Create: `modules/audit_log/audit_log/models.py` +- Create: `modules/audit_log/audit_log/contracts/__init__.py` +- Create: `modules/audit_log/audit_log/contracts/schemas.py` + +- [ ] **Step 1: Create AuditEntry model** + +Create `modules/audit_log/audit_log/models.py`: + +```python +"""SQLModel table for the Audit Log module.""" + +from __future__ import annotations + +import uuid +from datetime import UTC, datetime + +from simple_module_db.base import create_module_base +from sqlalchemy import DateTime, Index, func +from sqlmodel import Column, Field +from sqlalchemy import JSON + +from audit_log.constants import ( + ACTION_MAX_LENGTH, + CORRELATION_ID_MAX_LENGTH, + ENTITY_ID_MAX_LENGTH, + ENTITY_TYPE_MAX_LENGTH, + MODULE_PACKAGE, + TABLE_AUDIT_ENTRY, + USER_ID_MAX_LENGTH, +) + +Base = create_module_base(MODULE_PACKAGE) + + +class AuditEntry(Base, table=True): # ty: ignore[unsupported-base] + """Immutable audit trail entry tracking a single entity change.""" + + __tablename__ = TABLE_AUDIT_ENTRY + __audit_exclude__ = True + + __table_args__ = ( + Index("ix_audit_entry_entity_type", "entity_type"), + Index("ix_audit_entry_entity_id", "entity_id"), + Index("ix_audit_entry_user_id", "user_id"), + Index("ix_audit_entry_created_at", "created_at"), + ) + + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + entity_type: str = Field(max_length=ENTITY_TYPE_MAX_LENGTH) + entity_id: str = Field(max_length=ENTITY_ID_MAX_LENGTH) + action: str = Field(max_length=ACTION_MAX_LENGTH) + changes: dict | list = Field(default_factory=list, sa_column=Column(JSON)) + user_id: str | None = Field(default=None, max_length=USER_ID_MAX_LENGTH) + correlation_id: str | None = Field( + default=None, max_length=CORRELATION_ID_MAX_LENGTH + ) + created_at: datetime = Field( + default_factory=lambda: datetime.now(UTC), + sa_type=DateTime(timezone=True), + sa_column_kwargs={"server_default": func.now()}, + ) +``` + +- [ ] **Step 2: Create contracts** + +Create `modules/audit_log/audit_log/contracts/__init__.py` (empty file). + +Create `modules/audit_log/audit_log/contracts/schemas.py`: + +```python +"""SQLModel DTOs for the Audit Log module.""" + +from __future__ import annotations + +from datetime import datetime + +from pydantic import ConfigDict +from sqlmodel import SQLModel + + +class AuditEntryRead(SQLModel): + """Single audit entry returned by the API.""" + + model_config = ConfigDict(from_attributes=True) + + id: str + entity_type: str + entity_id: str + action: str + changes: list[dict] + user_id: str | None = None + correlation_id: str | None = None + created_at: datetime | None = None + + +class AuditEntryList(SQLModel): + """Paginated response for audit log queries.""" + + items: list[AuditEntryRead] + total: int + page: int + page_size: int +``` + +- [ ] **Step 3: Commit** + +```bash +git add modules/audit_log/audit_log/models.py modules/audit_log/audit_log/contracts/ +git commit -m "feat(audit_log): add AuditEntry model and DTO schemas" +``` + +--- + +## Task 5: Module — Capture callback + +**Files:** +- Create: `modules/audit_log/audit_log/capture.py` + +- [ ] **Step 1: Create capture.py** + +Create `modules/audit_log/audit_log/capture.py`: + +```python +"""Audit callback that converts AuditRecords into AuditEntry rows.""" + +from __future__ import annotations + +from simple_module_db.audit import AuditRecord +from sqlalchemy.orm import Session + +from audit_log.models import AuditEntry + + +def audit_callback(session: Session, records: list[AuditRecord]) -> None: + for record in records: + entry = AuditEntry( + entity_type=record.entity_type, + entity_id=record.entity_id, + action=record.action, + changes=record.changes, + user_id=record.user_id, + correlation_id=record.correlation_id, + ) + session.add(entry) +``` + +- [ ] **Step 2: Commit** + +```bash +git add modules/audit_log/audit_log/capture.py +git commit -m "feat(audit_log): add capture callback converting AuditRecords to AuditEntry rows" +``` + +--- + +## Task 6: Module — Service layer + +**Files:** +- Create: `modules/audit_log/audit_log/service.py` +- Create: `modules/audit_log/audit_log/deps.py` + +- [ ] **Step 1: Create service.py** + +Create `modules/audit_log/audit_log/service.py`: + +```python +"""Read-only query service for audit log entries.""" + +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from audit_log.constants import DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE +from audit_log.contracts.schemas import AuditEntryList, AuditEntryRead +from audit_log.models import AuditEntry + + +class AuditLogService: + def __init__(self, db: AsyncSession) -> None: + self.db = db + + async def list_entries( + self, + *, + entity_type: str | None = None, + entity_id: str | None = None, + action: str | None = None, + user_id: str | None = None, + from_date: datetime | None = None, + to_date: datetime | None = None, + page: int = 1, + page_size: int = DEFAULT_PAGE_SIZE, + ) -> AuditEntryList: + page_size = min(max(page_size, 1), MAX_PAGE_SIZE) + page = max(page, 1) + + base = select(AuditEntry) + count_base = select(func.count()).select_from(AuditEntry) + + if entity_type: + base = base.where(AuditEntry.entity_type == entity_type) + count_base = count_base.where(AuditEntry.entity_type == entity_type) + if entity_id: + base = base.where(AuditEntry.entity_id == entity_id) + count_base = count_base.where(AuditEntry.entity_id == entity_id) + if action: + base = base.where(AuditEntry.action == action) + count_base = count_base.where(AuditEntry.action == action) + if user_id: + base = base.where(AuditEntry.user_id == user_id) + count_base = count_base.where(AuditEntry.user_id == user_id) + if from_date: + base = base.where(AuditEntry.created_at >= from_date) + count_base = count_base.where(AuditEntry.created_at >= from_date) + if to_date: + base = base.where(AuditEntry.created_at <= to_date) + count_base = count_base.where(AuditEntry.created_at <= to_date) + + total_result = await self.db.execute(count_base) + total = total_result.scalar_one() + + offset = (page - 1) * page_size + stmt = base.order_by(AuditEntry.created_at.desc()).offset(offset).limit(page_size) + result = await self.db.execute(stmt) + items = [AuditEntryRead.model_validate(row) for row in result.scalars()] + + return AuditEntryList( + items=items, + total=total, + page=page, + page_size=page_size, + ) + + async def distinct_entity_types(self) -> list[str]: + stmt = ( + select(AuditEntry.entity_type) + .distinct() + .order_by(AuditEntry.entity_type) + ) + result = await self.db.execute(stmt) + return list(result.scalars()) +``` + +- [ ] **Step 2: Create deps.py** + +Create `modules/audit_log/audit_log/deps.py`: + +```python +"""FastAPI dependencies for the Audit Log module.""" + +from __future__ import annotations + +from typing import Annotated + +from fastapi import Depends +from simple_module_db.deps import get_db +from sqlalchemy.ext.asyncio import AsyncSession + +from audit_log.service import AuditLogService + + +async def get_audit_log_service( + db: AsyncSession = Depends(get_db), +) -> AuditLogService: + return AuditLogService(db) + + +AuditLogServiceDep = Annotated[AuditLogService, Depends(get_audit_log_service)] +``` + +- [ ] **Step 3: Commit** + +```bash +git add modules/audit_log/audit_log/service.py modules/audit_log/audit_log/deps.py +git commit -m "feat(audit_log): add read-only service layer and FastAPI deps" +``` + +--- + +## Task 7: Module — API and view endpoints + +**Files:** +- Create: `modules/audit_log/audit_log/endpoints/__init__.py` +- Create: `modules/audit_log/audit_log/endpoints/api.py` +- Create: `modules/audit_log/audit_log/endpoints/views.py` + +- [ ] **Step 1: Create endpoints/__init__.py (empty)** + +- [ ] **Step 2: Create api.py** + +Create `modules/audit_log/audit_log/endpoints/api.py`: + +```python +"""REST API endpoints for the Audit Log module.""" + +from __future__ import annotations + +from datetime import datetime + +from fastapi import APIRouter, Depends, Query +from simple_module_hosting.permissions import RequiresPermission + +from audit_log.constants import DEFAULT_PAGE_SIZE, PERM_VIEW +from audit_log.contracts.schemas import AuditEntryList +from audit_log.deps import AuditLogServiceDep + +router = APIRouter() + +_VIEW = [Depends(RequiresPermission(PERM_VIEW))] + + +@router.get("/", response_model=AuditEntryList, dependencies=_VIEW) +async def list_audit_entries( + service: AuditLogServiceDep, + entity_type: str | None = Query(default=None), + entity_id: str | None = Query(default=None), + action: str | None = Query(default=None), + user_id: str | None = Query(default=None), + from_date: datetime | None = Query(default=None), + to_date: datetime | None = Query(default=None), + page: int = Query(default=1, ge=1), + page_size: int = Query(default=DEFAULT_PAGE_SIZE, ge=1, le=200), +) -> AuditEntryList: + return await service.list_entries( + entity_type=entity_type, + entity_id=entity_id, + action=action, + user_id=user_id, + from_date=from_date, + to_date=to_date, + page=page, + page_size=page_size, + ) +``` + +- [ ] **Step 3: Create views.py** + +Create `modules/audit_log/audit_log/endpoints/views.py`: + +```python +"""Inertia view endpoints for the Audit Log admin UI.""" + +from __future__ import annotations + +from datetime import datetime + +from fastapi import APIRouter, Depends, Query +from inertia import InertiaResponse +from simple_module_hosting.inertia_deps import InertiaDep +from simple_module_hosting.permissions import RequiresPermission + +from audit_log.constants import DEFAULT_PAGE_SIZE, PAGE_BROWSE, PERM_VIEW +from audit_log.deps import AuditLogServiceDep + +router = APIRouter() + + +@router.get( + "/", + response_model=None, + dependencies=[Depends(RequiresPermission(PERM_VIEW))], +) +async def browse( + inertia: InertiaDep, + service: AuditLogServiceDep, + entity_type: str | None = Query(default=None), + action: str | None = Query(default=None), + user_id: str | None = Query(default=None), + from_date: datetime | None = Query(default=None), + to_date: datetime | None = Query(default=None), + page: int = Query(default=1, ge=1), + page_size: int = Query(default=DEFAULT_PAGE_SIZE, ge=1, le=200), +) -> InertiaResponse: + result = await service.list_entries( + entity_type=entity_type, + action=action, + user_id=user_id, + from_date=from_date, + to_date=to_date, + page=page, + page_size=page_size, + ) + entity_types = await service.distinct_entity_types() + + return await inertia.render( + PAGE_BROWSE, + { + "items": [item.model_dump(mode="json") for item in result.items], + "total": result.total, + "page": result.page, + "page_size": result.page_size, + "entity_types": entity_types, + "filters": { + "entity_type": entity_type, + "action": action, + "user_id": user_id, + "from_date": from_date.isoformat() if from_date else None, + "to_date": to_date.isoformat() if to_date else None, + }, + }, + ) +``` + +- [ ] **Step 4: Commit** + +```bash +git add modules/audit_log/audit_log/endpoints/ +git commit -m "feat(audit_log): add API and Inertia view endpoints" +``` + +--- + +## Task 8: Module — module.py (lifecycle hooks) + +**Files:** +- Create: `modules/audit_log/audit_log/module.py` + +- [ ] **Step 1: Create module.py** + +Create `modules/audit_log/audit_log/module.py`: + +```python +"""Audit Log module definition.""" + +from __future__ import annotations + +import importlib.resources +from pathlib import Path + +from fastapi import APIRouter, FastAPI +from simple_module_core.menu import MenuItem, MenuRegistry, MenuSection +from simple_module_core.module import ModuleBase, ModuleMeta +from simple_module_core.permissions import PermissionRegistry + +from audit_log.constants import ( + ALL_PERMISSIONS, + API_PREFIX, + LOCALE_NAMESPACE, + MENU_ICON, + MENU_LABEL, + MENU_ORDER, + MENU_URL, + MODULE_NAME, + MODULE_PACKAGE, + PERM_GROUP, + VIEW_PREFIX, +) + + +class AuditLogModule(ModuleBase): + meta = ModuleMeta( + name=MODULE_NAME, + route_prefix=API_PREFIX, + view_prefix=VIEW_PREFIX, + depends_on=["Users"], + ) + + def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None: + from audit_log.endpoints.api import router as api + from audit_log.endpoints.views import router as views + + api_router.include_router(api) + view_router.include_router(views) + + def register_menu_items(self, registry: MenuRegistry) -> None: + registry.add( + MenuItem( + label=MENU_LABEL, + url=MENU_URL, + icon=MENU_ICON, + order=MENU_ORDER, + section=MenuSection.SIDEBAR, + group="System", + ) + ) + + def register_permissions(self, registry: PermissionRegistry) -> None: + registry.add_group(PERM_GROUP, list(ALL_PERMISSIONS)) + + async def on_startup(self, app: FastAPI) -> None: + from audit_log.capture import audit_callback + + app.state.sm.db.audit_callback = audit_callback + + async def on_shutdown(self, app: FastAPI) -> None: + app.state.sm.db.audit_callback = None + + def locale_dirs(self) -> dict[str, Path]: + base = Path(str(importlib.resources.files(__package__) / "locales")) + return {LOCALE_NAMESPACE: base} +``` + +- [ ] **Step 2: Commit** + +```bash +git add modules/audit_log/audit_log/module.py +git commit -m "feat(audit_log): add AuditLogModule with lifecycle hooks and callback registration" +``` + +--- + +## Task 9: Module — Locales + +**Files:** +- Create: `modules/audit_log/audit_log/locales/en.json` + +- [ ] **Step 1: Create en.json** + +Create `modules/audit_log/audit_log/locales/en.json`: + +```json +{ + "browse": { + "title": "Audit Log", + "description": "Track all entity changes across the system.", + "empty_title": "No audit entries", + "empty_description": "Changes to entities will appear here automatically.", + "showing": "Showing {from}–{to} of {total} entries", + "previous": "Previous", + "next": "Next" + }, + "filters": { + "entity_type_label": "Entity Type", + "entity_type_all": "All types", + "action_label": "Action", + "action_all": "All actions", + "user_label": "User ID", + "user_placeholder": "Filter by user…", + "from_date_label": "From", + "to_date_label": "To", + "apply": "Apply", + "clear": "Clear" + }, + "table": { + "timestamp": "Timestamp", + "action": "Action", + "entity": "Entity", + "user": "User", + "changes": "Changes" + }, + "actions": { + "created": "Created", + "updated": "Updated", + "deleted": "Deleted", + "soft_deleted": "Archived" + }, + "changes": { + "fields_set": "{count} fields set", + "show_more": "Show {count} more…", + "show_less": "Show less", + "system_user": "System", + "no_changes": "—" + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add modules/audit_log/audit_log/locales/ +git commit -m "feat(audit_log): add i18n locale strings" +``` + +--- + +## Task 10: Module — Browse.tsx frontend page + +**Files:** +- Create: `modules/audit_log/audit_log/pages/Browse.tsx` + +- [ ] **Step 1: Create Browse.tsx** + +Create `modules/audit_log/audit_log/pages/Browse.tsx`: + +```tsx +import { router, usePage } from '@inertiajs/react'; +import { keys, useT } from '@simple-module-py/i18n'; +import { PageShell } from '@simple-module-py/ui/components/PageShell'; +import { Badge } from '@simple-module-py/ui/components/ui/badge'; +import { Button } from '@simple-module-py/ui/components/ui/button'; +import { Card } from '@simple-module-py/ui/components/ui/card'; +import { + Empty, + EmptyDescription, + EmptyMedia, + EmptyTitle, +} from '@simple-module-py/ui/components/ui/empty'; +import { Input } from '@simple-module-py/ui/components/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@simple-module-py/ui/components/ui/select'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@simple-module-py/ui/components/ui/table'; +import { usePermissions } from '@simple-module-py/ui/hooks/use-permissions'; +import { AuthenticatedLayout } from '@simple-module-py/ui/layouts/AuthenticatedLayout'; +import { ScrollText } from 'lucide-react'; +import { type ReactNode, useState } from 'react'; + +interface AuditEntryRead { + id: string; + entity_type: string; + entity_id: string; + action: 'created' | 'updated' | 'deleted' | 'soft_deleted'; + changes: Array<{ field: string; old?: unknown; new?: unknown }>; + user_id: string | null; + correlation_id: string | null; + created_at: string; +} + +interface Filters { + entity_type: string | null; + action: string | null; + user_id: string | null; + from_date: string | null; + to_date: string | null; +} + +interface Props { + items: AuditEntryRead[]; + total: number; + page: number; + page_size: number; + entity_types: string[]; + filters: Filters; +} + +const ACTION_COLORS: Record = { + created: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200', + updated: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200', + deleted: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200', + soft_deleted: + 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200', +}; + +const ALL_MARKER = '__all__'; +const VISIBLE_CHANGES = 3; + +function formatTimestamp(iso: string): string { + return new Date(iso).toLocaleString(); +} + +function truncateId(id: string, len = 12): string { + return id.length > len ? `${id.slice(0, len)}…` : id; +} + +function ChangesList({ entry }: { entry: AuditEntryRead }) { + const { t } = useT(); + const [expanded, setExpanded] = useState(false); + + if (entry.action === 'deleted' || entry.changes.length === 0) { + return ( + + {t(keys.audit_log.changes.no_changes)} + + ); + } + + if (entry.action === 'created') { + return ( + + {t(keys.audit_log.changes.fields_set, { + count: entry.changes.length, + })} + + ); + } + + const visible = expanded + ? entry.changes + : entry.changes.slice(0, VISIBLE_CHANGES); + const remaining = entry.changes.length - VISIBLE_CHANGES; + + return ( + + {visible.map((c) => ( + + {c.field}{' '} + + {String(c.old ?? '∅')} + {' '} + → {String(c.new ?? '∅')} + + ))} + {remaining > 0 && ( + setExpanded(!expanded)} + > + {expanded + ? t(keys.audit_log.changes.show_less) + : t(keys.audit_log.changes.show_more, { count: remaining })} + + )} + + ); +} + +function Browse() { + const { items, total, page, page_size, entity_types, filters } = usePage<{ + props: Props; + }>().props as unknown as Props; + const { t } = useT(); + const { can } = usePermissions(); + + const [entityType, setEntityType] = useState(filters.entity_type ?? ''); + const [action, setAction] = useState(filters.action ?? ''); + const [userId, setUserId] = useState(filters.user_id ?? ''); + const [fromDate, setFromDate] = useState(filters.from_date ?? ''); + const [toDate, setToDate] = useState(filters.to_date ?? ''); + + if (!can('audit_log.view')) return null; + + function applyFilters() { + const params: Record = {}; + if (entityType) params.entity_type = entityType; + if (action) params.action = action; + if (userId) params.user_id = userId; + if (fromDate) params.from_date = fromDate; + if (toDate) params.to_date = toDate; + const qs = new URLSearchParams(params).toString(); + router.visit(`/audit_log${qs ? `?${qs}` : ''}`); + } + + function clearFilters() { + setEntityType(''); + setAction(''); + setUserId(''); + setFromDate(''); + setToDate(''); + router.visit('/audit_log'); + } + + function goToPage(p: number) { + const params: Record = { page: String(p) }; + if (entityType) params.entity_type = entityType; + if (action) params.action = action; + if (userId) params.user_id = userId; + if (fromDate) params.from_date = fromDate; + if (toDate) params.to_date = toDate; + const qs = new URLSearchParams(params).toString(); + router.visit(`/audit_log?${qs}`); + } + + const from = (page - 1) * page_size + 1; + const to = Math.min(page * page_size, total); + const hasNext = page * page_size < total; + const hasPrev = page > 1; + + return ( + + + { + e.preventDefault(); + applyFilters(); + }} + > + + + {t(keys.audit_log.filters.entity_type_label)} + + + setEntityType(v === ALL_MARKER ? '' : v) + } + > + + + + + + {t(keys.audit_log.filters.entity_type_all)} + + {entity_types.map((et) => ( + + {et} + + ))} + + + + + + + {t(keys.audit_log.filters.action_label)} + + + setAction(v === ALL_MARKER ? '' : v) + } + > + + + + + + {t(keys.audit_log.filters.action_all)} + + + {t(keys.audit_log.actions.created)} + + + {t(keys.audit_log.actions.updated)} + + + {t(keys.audit_log.actions.deleted)} + + + {t(keys.audit_log.actions.soft_deleted)} + + + + + + + + {t(keys.audit_log.filters.user_label)} + + setUserId(e.target.value)} + placeholder={t(keys.audit_log.filters.user_placeholder)} + /> + + + + + {t(keys.audit_log.filters.from_date_label)} + + setFromDate(e.target.value)} + /> + + + + + {t(keys.audit_log.filters.to_date_label)} + + setToDate(e.target.value)} + /> + + + + {t(keys.audit_log.filters.apply)} + + + {t(keys.audit_log.filters.clear)} + + + + + {total > 0 && ( + + + {t(keys.audit_log.browse.showing, { + from, + to, + total, + })} + + + goToPage(page - 1)} + > + {t(keys.audit_log.browse.previous)} + + goToPage(page + 1)} + > + {t(keys.audit_log.browse.next)} + + + + )} + + + + + + + {t(keys.audit_log.table.timestamp)} + + + {t(keys.audit_log.table.action)} + + + {t(keys.audit_log.table.entity)} + + + {t(keys.audit_log.table.user)} + + + {t(keys.audit_log.table.changes)} + + + + + {items.map((entry) => ( + + + {formatTimestamp(entry.created_at)} + + + + {t( + keys.audit_log.actions[ + entry.action as keyof typeof keys.audit_log.actions + ], + )} + + + + + + {entry.entity_type} + + + {truncateId(entry.entity_id)} + + + + + {entry.user_id + ? truncateId(entry.user_id) + : t(keys.audit_log.changes.system_user)} + + + + + + ))} + {items.length === 0 && ( + + + + + + + + {t(keys.audit_log.browse.empty_title)} + + + {t(keys.audit_log.browse.empty_description)} + + + + + )} + + + + + {total > 0 && ( + + goToPage(page - 1)} + > + {t(keys.audit_log.browse.previous)} + + goToPage(page + 1)} + > + {t(keys.audit_log.browse.next)} + + + )} + + ); +} + +Browse.layout = (page: ReactNode) => ( + {page} +); +export default Browse; +``` + +- [ ] **Step 2: Run gen-pages and TS type-check** + +Run: `make gen-pages && npx tsc --noEmit -p modules/audit_log/tsconfig.json` +Expected: No errors (i18n keys may need generation first — run `npm run build` in packages/i18n if needed) + +- [ ] **Step 3: Commit** + +```bash +git add modules/audit_log/audit_log/pages/ +git commit -m "feat(audit_log): add Browse.tsx page with filters, pagination, and change diffs" +``` + +--- + +## Task 11: Integration tests + +**Files:** +- Create: `tests/test_audit_log.py` + +- [ ] **Step 1: Write integration tests** + +Create `tests/test_audit_log.py`: + +```python +"""Integration tests for the audit_log module.""" + +from __future__ import annotations + +import httpx +import pytest + + +class TestAuditLogCapture: + """Verify audit entries are created when entities change.""" + + async def test_create_entity_produces_audit_entry( + self, authenticated_client: httpx.AsyncClient + ): + """Creating a setting should produce a 'created' audit entry.""" + await authenticated_client.post( + "/api/settings/", + json={ + "scope": "system", + "scope_id": "", + "key": "audit.test.key", + "value": "hello", + "value_type": "string", + }, + ) + + resp = await authenticated_client.get( + "/api/audit_log/", + params={"entity_type": "Setting"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["total"] >= 1 + created_entries = [i for i in data["items"] if i["action"] == "created"] + assert len(created_entries) >= 1 + entry = created_entries[0] + assert entry["entity_type"] == "Setting" + assert any(c["field"] == "key" for c in entry["changes"]) + + async def test_update_entity_produces_diff( + self, authenticated_client: httpx.AsyncClient + ): + """Updating a setting should record old/new values.""" + create_resp = await authenticated_client.post( + "/api/settings/", + json={ + "scope": "system", + "scope_id": "", + "key": "audit.update.test", + "value": "before", + "value_type": "string", + }, + ) + setting_id = create_resp.json()["id"] + + await authenticated_client.put( + f"/api/settings/{setting_id}", + json={"value": "after"}, + ) + + resp = await authenticated_client.get( + "/api/audit_log/", + params={"entity_type": "Setting", "action": "updated"}, + ) + assert resp.status_code == 200 + data = resp.json() + update_entries = [i for i in data["items"] if i["action"] == "updated"] + assert len(update_entries) >= 1 + changes = update_entries[0]["changes"] + value_change = next((c for c in changes if c["field"] == "value"), None) + assert value_change is not None + assert value_change["old"] == "before" + assert value_change["new"] == "after" + + async def test_delete_entity_produces_audit_entry( + self, authenticated_client: httpx.AsyncClient + ): + """Deleting a setting should produce a 'deleted' or 'soft_deleted' entry.""" + create_resp = await authenticated_client.post( + "/api/settings/", + json={ + "scope": "system", + "scope_id": "", + "key": "audit.delete.test", + "value": "gone", + "value_type": "string", + }, + ) + setting_id = create_resp.json()["id"] + + await authenticated_client.delete(f"/api/settings/{setting_id}") + + resp = await authenticated_client.get( + "/api/audit_log/", + params={"entity_type": "Setting"}, + ) + assert resp.status_code == 200 + data = resp.json() + delete_entries = [ + i for i in data["items"] if i["action"] in ("deleted", "soft_deleted") + ] + assert len(delete_entries) >= 1 + + +class TestAuditLogAPI: + """Verify the audit log REST API filtering and pagination.""" + + async def test_filter_by_action(self, authenticated_client: httpx.AsyncClient): + resp = await authenticated_client.get( + "/api/audit_log/", + params={"action": "created"}, + ) + assert resp.status_code == 200 + data = resp.json() + for item in data["items"]: + assert item["action"] == "created" + + async def test_pagination(self, authenticated_client: httpx.AsyncClient): + resp = await authenticated_client.get( + "/api/audit_log/", + params={"page": 1, "page_size": 2}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["page"] == 1 + assert data["page_size"] == 2 + assert len(data["items"]) <= 2 + + async def test_unauthenticated_returns_redirect( + self, client: httpx.AsyncClient + ): + resp = await client.get("/api/audit_log/", follow_redirects=False) + assert resp.status_code in (302, 303, 403) + + +class TestAuditLogRecursionGuard: + """Verify that AuditEntry writes don't trigger more audit entries.""" + + async def test_no_infinite_recursion( + self, authenticated_client: httpx.AsyncClient + ): + """Creating a setting should not cause exponential audit entries.""" + await authenticated_client.post( + "/api/settings/", + json={ + "scope": "system", + "scope_id": "", + "key": "recursion.guard.test", + "value": "ok", + "value_type": "string", + }, + ) + + resp = await authenticated_client.get( + "/api/audit_log/", + params={"entity_type": "AuditEntry"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 0, "AuditEntry should not audit itself" +``` + +- [ ] **Step 2: Run integration tests** + +Run: `uv run pytest tests/test_audit_log.py -v` +Expected: All PASS + +- [ ] **Step 3: Commit** + +```bash +git add tests/test_audit_log.py +git commit -m "test(audit_log): add integration tests for capture, API filtering, and recursion guard" +``` + +--- + +## Task 12: Alembic migration + +**Files:** +- Create: `host/migrations/versions/_add_audit_log_tables.py` (auto-generated) + +- [ ] **Step 1: Generate migration** + +Run: `uv run alembic revision --autogenerate -m "add audit_log tables"` + +- [ ] **Step 2: Edit migration to add branch_labels** + +Open the generated file and add `branch_labels = ("audit_log",)` to the migration header, after the `down_revision` line. + +- [ ] **Step 3: Apply migration** + +Run: `uv run alembic upgrade head` +Expected: Migration applies successfully + +- [ ] **Step 4: Commit** + +```bash +git add host/migrations/versions/ +git commit -m "migration(audit_log): add audit_log_audit_entry table" +``` + +--- + +## Task 13: Install JS deps and regenerate pages + +- [ ] **Step 1: Install all deps** + +Run: `npm install && make gen-pages` +Expected: `modules.manifest.json` includes `audit_log` + +- [ ] **Step 2: Run lint** + +Run: `make lint` +Expected: No errors (fix any that arise) + +- [ ] **Step 3: Run full test suite** + +Run: `make test` +Expected: All tests pass, including the new audit_log tests + +- [ ] **Step 4: Commit any generated files** + +```bash +git add host/client_app/modules.manifest.json host/client_app/modules.generated.ts host/client_app/modules.generated.css +git commit -m "chore: regenerate module manifest with audit_log" +``` diff --git a/docs/superpowers/specs/2026-05-27-audit-log-module-design.md b/docs/superpowers/specs/2026-05-27-audit-log-module-design.md new file mode 100644 index 00000000..24ee4bc0 --- /dev/null +++ b/docs/superpowers/specs/2026-05-27-audit-log-module-design.md @@ -0,0 +1,242 @@ +# Audit Log Module Design + +**Date:** 2026-05-27 +**Status:** Draft + +## Overview + +A new `audit_log` module that automatically tracks field-level changes to all SQLModel entities across every installed module. Changes are captured in the existing SQLAlchemy `before_flush` listener and persisted atomically alongside the original write. An admin-only Inertia Browse page provides filtering and pagination over the audit trail. + +## Scope + +- **In scope:** DB entity changes (create, update, delete, soft-delete) with field-level diffs, REST API, admin UI +- **Out of scope (future):** Auth events (login/logout), API call logging, custom domain events via explicit `audit_log.record()` API. These can be added later via EventBus subscriptions. + +## Data Model + +`AuditEntry` table in the `audit_log` schema (Postgres) or prefixed `audit_log_audit_entry` (SQLite). + +| Column | Type | Purpose | +|---|---|---| +| `id` | `UUID` (PK) | Entry identifier | +| `entity_type` | `str(255)`, indexed | Class name, e.g. `"User"`, `"FeatureFlagOverride"` | +| `entity_id` | `str(255)`, indexed | Primary key of the changed entity (stringified) | +| `action` | `str(20)` | `"created"`, `"updated"`, `"deleted"`, `"soft_deleted"` | +| `changes` | `JSON` | List of `{field, old, new}` dicts for updates; full entity dict for creates; empty for deletes | +| `user_id` | `str(255)`, nullable, indexed | Who made the change (from `current_user_id` ContextVar) | +| `correlation_id` | `str(255)`, nullable | Request correlation ID (from `correlation_id` ContextVar) | +| `created_at` | `datetime(tz)`, indexed | When the change happened | + +Design decisions: +- `entity_id` is `str` not `UUID` — some modules may use integer PKs. +- `changes` is JSON — flexible for field-level diffs without a separate table per field. +- No `AuditMixin` on `AuditEntry` — would cause infinite recursion. +- No `SoftDeleteMixin` — audit entries are immutable, never deleted through the app. +- `__audit_exclude__ = True` class attribute prevents self-tracking. + +## Capture Mechanism + +### Callback registration pattern + +The framework layer (`simple_module_db/listeners.py`) cannot import from `modules/` (SM009). Instead, `DatabaseState` gains an optional `audit_callback` attribute — a callable that the `_before_flush_listener` invokes when present. + +### Flow + +1. **Module installs callback.** During `on_startup`, the `audit_log` module sets `app.state.sm.db_state.audit_callback` to its capture function. `on_shutdown` clears it. + +2. **Listener collects diffs.** Inside `_before_flush_listener`, after existing mixin processing: + - For each entity in `session.new`: snapshot all non-excluded column values as `{field: value}` in `changes`. + - For each entity in `session.dirty` (where `session.is_modified(obj)` is true): use `sa_inspect(obj).attrs[col].history` to get `(added, unchanged, deleted)` tuples. Build `{field, old, new}` for each changed column. + - For each entity in `session.deleted` / soft-deleted: record entity type and ID with empty changes. + - Skip any model with `__audit_exclude__ = True`. + - Skip fields listed in `__audit_exclude_fields__` (class-level `ClassVar[set[str]]`). + - Always skip `AuditMixin` fields (`created_at`, `updated_at`, `created_by`, `updated_by`) — they're metadata, not business data. + +3. **Callback writes entries.** The callback receives the list of change records, constructs `AuditEntry` instances, and adds them to the same session. They commit atomically with the original change. + +### Exclusion mechanism + +- `__audit_exclude__ = True` on a model class: skip the entire model. Used by `AuditEntry` itself. +- `__audit_exclude_fields__: ClassVar[set[str]]` on a model class: skip specific fields (e.g. `{"password_hash", "session_data"}`). +- `AuditMixin` fields are excluded by default. + +### Framework changes + +Two changes to `simple_module_db`: +1. Add `audit_callback: Callable | None = None` field to `DatabaseState`. +2. At the end of `_before_flush_listener`, if the module-level `_audit_callback` reference is set, call it with the collected change records and the session. + +The `register_listeners` function already receives the `DatabaseState` instance. It stores a module-level reference to `db_state` so the `_before_flush_listener` can access `db_state.audit_callback` without changing its signature. This follows the same pattern as the existing `_mixin_flags_cache` module-level state. + +The callback signature: + +```python +def audit_callback( + session: Session, + entries: list[AuditRecord], +) -> None: ... +``` + +Where `AuditRecord` is a simple dataclass defined in `simple_module_db` (framework-safe): + +```python +@dataclass(frozen=True, slots=True) +class AuditRecord: + entity_type: str + entity_id: str + action: str # "created" | "updated" | "deleted" | "soft_deleted" + changes: list[dict[str, Any]] + user_id: str | None + correlation_id: str | None +``` + +## Module Structure + +``` +modules/audit_log/audit_log/ +├── module.py # AuditLogModule(ModuleBase) +├── models.py # AuditEntry table +├── contracts/schemas.py # AuditEntryRead DTO +├── service.py # query logic (list, filter) +├── capture.py # audit callback + diff collection logic +├── deps.py # FastAPI dependencies +├── endpoints/ +│ ├── api.py # GET /api/audit_log +│ └── views.py # GET /audit_log → Browse page +├── pages/Browse.tsx # filterable table UI +└── locales/en.json +``` + +### ModuleMeta + +```python +meta = ModuleMeta( + name="audit_log", + route_prefix="/api/audit_log", + view_prefix="/audit_log", + depends_on=["users"], +) +``` + +Depends on `users` for user context and display names. + +### Lifecycle hooks + +| Hook | Purpose | +|---|---| +| `register_permissions` | `audit_log.view` — gates API and UI access | +| `register_menu_items` | Sidebar entry under "System" group | +| `register_routes` | API + view routers | +| `on_startup` | Register audit callback on `db_state` | +| `on_shutdown` | Unregister the callback | + +## REST API + +### `GET /api/audit_log` + +Paginated, filterable list. Requires `audit_log.view` permission. + +**Query parameters:** + +| Param | Type | Default | Description | +|---|---|---|---| +| `entity_type` | `str` | — | Filter by class name | +| `entity_id` | `str` | — | Filter by specific entity | +| `action` | `str` | — | `created` / `updated` / `deleted` / `soft_deleted` | +| `user_id` | `str` | — | Filter by who made the change | +| `from_date` | `datetime` | — | Entries after this timestamp | +| `to_date` | `datetime` | — | Entries before this timestamp | +| `page` | `int` | 1 | Page number | +| `page_size` | `int` | 50 | Items per page (max 200) | + +**Response:** + +```json +{ + "items": [ + { + "id": "...", + "entity_type": "User", + "entity_id": "550e8400-...", + "action": "updated", + "changes": [ + {"field": "email", "old": "a@b.com", "new": "c@d.com"} + ], + "user_id": "...", + "correlation_id": "...", + "created_at": "2026-05-27T14:30:00Z" + } + ], + "total": 1234, + "page": 1, + "page_size": 50 +} +``` + +No create/update/delete endpoints — entries are write-once, immutable. + +## Frontend (Browse Page) + +Admin-only page gated behind `audit_log.view` permission. + +### Layout + +- `PageShell` with title "Audit Log" +- Filter bar (Card) at top: + - **Entity type** dropdown (populated from distinct `entity_type` values) + - **Action** dropdown (created / updated / deleted / soft_deleted) + - **User** text input + - **Date range** from/to inputs + - **Apply** button (Inertia `router.visit` with query params) + - **Clear** button to reset +- Paginated table below + +### Table columns + +| Column | Responsive | Content | +|---|---|---| +| Timestamp | always visible | `created_at` formatted | +| Action | always visible | Badge with color: green=created, blue=updated, red=deleted, amber=soft_deleted | +| Entity | always visible | `entity_type` + truncated `entity_id` | +| User | `sm:table-cell` | User ID or "System" if null | +| Changes | `md:table-cell` | Compact diff: field old→new. Creates: "N fields set". Deletes: "—". Show first 3 fields, expandable | + +### Pagination + +Server-side. View endpoint passes `items`, `total`, `page`, `page_size`, `entity_types`. +Previous/Next buttons. "Showing 1-50 of 1,234 entries" text. + +### Props from server + +```typescript +interface AuditEntryRead { + id: string; + entity_type: string; + entity_id: string; + action: "created" | "updated" | "deleted" | "soft_deleted"; + changes: Array<{ field: string; old: unknown; new: unknown }>; + user_id: string | null; + correlation_id: string | null; + created_at: string; +} + +interface Props { + items: AuditEntryRead[]; + total: number; + page: number; + page_size: number; + entity_types: string[]; +} +``` + +## Testing + +- **Unit tests for diff logic:** Given a SQLModel instance with known attribute history, verify correct `AuditRecord` generation for creates, updates, deletes, and soft-deletes. +- **Unit tests for exclusion:** Verify `__audit_exclude__` and `__audit_exclude_fields__` are respected. +- **Integration test:** Create/update/delete an entity via `authenticated_client`, then query the audit_log API to verify entries were persisted with correct field-level diffs. +- **API filter tests:** Verify each query parameter filters correctly. +- **Recursion guard:** Verify that `AuditEntry` writes do not trigger additional audit entries. + +## Migration + +Single Alembic migration with `branch_labels = ("audit_log",)` creating the `audit_log.audit_entries` table (Postgres) or `audit_log_audit_entry` (SQLite). Indexes on `entity_type`, `entity_id`, `user_id`, `created_at`. diff --git a/framework/db/simple_module_db/__init__.py b/framework/db/simple_module_db/__init__.py index da3f13c5..f209110b 100644 --- a/framework/db/simple_module_db/__init__.py +++ b/framework/db/simple_module_db/__init__.py @@ -1,5 +1,6 @@ """SimpleModule DB - SQLAlchemy async support with per-module schema isolation.""" +from simple_module_db.audit import AuditRecord from simple_module_db.base import create_module_base from simple_module_db.deps import get_db from simple_module_db.listeners import TenantIsolationError, current_tenant_id @@ -15,6 +16,7 @@ __all__ = [ "AuditMixin", + "AuditRecord", "DatabaseProvider", "DatabaseState", "MultiTenantMixin", diff --git a/framework/db/simple_module_db/audit.py b/framework/db/simple_module_db/audit.py new file mode 100644 index 00000000..145f6f0e --- /dev/null +++ b/framework/db/simple_module_db/audit.py @@ -0,0 +1,278 @@ +"""Audit-record collection: extract change diffs from SQLAlchemy session state. + +Pure logic — no DB writes, no module imports. Given a flushing session, +``collect_audit_records`` returns a list of frozen ``AuditRecord`` structs +describing every entity that was created, updated, or deleted. + +Two-phase capture +----------------- + +Entities whose primary key is assigned by the database (e.g. integer ``id`` +columns populated by ``AUTOINCREMENT`` / ``SERIAL``) do not have a usable PK +during ``before_flush`` — it gets populated only when the INSERT executes. +``snapshot_changes`` captures the diff in ``before_flush`` (the only place +SQLAlchemy attribute *history* is still intact) and ``finalize_records`` +resolves the now-stable ``entity_id`` in ``after_flush_postexec``. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from sqlalchemy import inspect as sa_inspect +from sqlalchemy.orm import Session + +from simple_module_db.mixins import SoftDeleteMixin + +# Fields injected by AuditMixin — always excluded from change diffs +# because they are bookkeeping, not business data. +_AUDIT_MIXIN_FIELDS: frozenset[str] = frozenset( + {"created_at", "updated_at", "created_by", "updated_by"} +) + +# Fields injected by SoftDeleteMixin — also excluded because they are +# bookkeeping managed by the soft-delete listener, not business data. +_SOFT_DELETE_MIXIN_FIELDS: frozenset[str] = frozenset({"is_deleted", "deleted_at", "deleted_by"}) + +_EXCLUDED_MIXIN_FIELDS: frozenset[str] = _AUDIT_MIXIN_FIELDS | _SOFT_DELETE_MIXIN_FIELDS + + +@dataclass(frozen=True, slots=True) +class AuditRecord: + """Immutable snapshot of a single entity change.""" + + entity_type: str + entity_id: str + action: str + changes: list[dict[str, Any]] = field(default_factory=list) + user_id: str | None = None + correlation_id: str | None = None + + +@dataclass(frozen=True, slots=True) +class _PendingChange: + """Intermediate snapshot — obj_ref preserved so entity_id can be resolved later. + + Produced in ``before_flush`` (when attribute history is still available) + and consumed in ``after_flush_postexec`` (when DB-assigned PKs are + populated on the live object). + """ + + obj_ref: object + entity_type: str + action: str # "created" | "updated" | "deleted" | "soft_deleted" + changes: list[dict[str, Any]] + user_id: str | None + correlation_id: str | None + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _is_excluded(obj: object) -> bool: + """Return True if the model opts out of auditing entirely.""" + return getattr(obj, "__audit_exclude__", False) is True + + +def _excluded_fields(obj: object) -> frozenset[str]: + """Return the set of field names that should be skipped for this model.""" + per_model: set[str] = getattr(obj, "__audit_exclude_fields__", set()) + return _EXCLUDED_MIXIN_FIELDS | per_model + + +def _entity_pk_str(obj: object) -> str: + """Return the primary-key value(s) as a string.""" + inspector = sa_inspect(obj) + identity = inspector.identity + if identity is None: + # Pre-flush: fall back to the mapper-level key attrs + mapper = inspector.mapper + vals = tuple(getattr(obj, col.key) for col in mapper.primary_key) + if len(vals) == 1: + return str(vals[0]) if vals[0] is not None else "" + return str(vals) + if len(identity) == 1: + return str(identity[0]) + return str(identity) + + +def _column_names(obj: object) -> list[str]: + """Return all mapped column attribute names for *obj*.""" + mapper = sa_inspect(type(obj)) + return [col.key for col in mapper.column_attrs] + + +def _serialize(value: Any) -> Any: + """Normalize a column value for JSON-safe storage.""" + if value is None: + return None + if isinstance(value, (str, int, float, bool)): + return value + return str(value) + + +# --------------------------------------------------------------------------- +# Two-phase capture +# --------------------------------------------------------------------------- + + +def snapshot_changes( + session: Session, + user_id: str | None = None, + correlation_id: str | None = None, +) -> list[_PendingChange]: + """Phase 1: capture diffs from session state. Called in ``before_flush``. + + Returns intermediate records holding *object references* (not entity_ids) + because DB-assigned PKs aren't available yet. Attribute *history* is wiped + after flush, so the diff itself must be captured now. + """ + pending: list[_PendingChange] = [] + + excluded_cache: dict[type, frozenset[str]] = {} + pk_cols_cache: dict[type, set[str]] = {} + + def _get_excluded(obj: object) -> frozenset[str]: + cls = type(obj) + if cls not in excluded_cache: + excluded_cache[cls] = _excluded_fields(obj) + return excluded_cache[cls] + + def _get_pk_cols(obj: object) -> set[str]: + cls = type(obj) + if cls not in pk_cols_cache: + mapper = sa_inspect(cls) + pk_cols_cache[cls] = {col.key for col in mapper.primary_key} + return pk_cols_cache[cls] + + # ── Created entities ─────────────────────────────────────────────── + for obj in list(session.new): + if _is_excluded(obj): + continue + + # Soft-delete listener moves objects from session.deleted → session.new + # with is_deleted=True. Classify them as "soft_deleted", not "created". + if isinstance(obj, SoftDeleteMixin) and getattr(obj, "is_deleted", False): + pending.append( + _PendingChange( + obj_ref=obj, + entity_type=type(obj).__name__, + action="soft_deleted", + changes=[], + user_id=user_id, + correlation_id=correlation_id, + ) + ) + continue + + excl = _get_excluded(obj) + pk_cols = _get_pk_cols(obj) + changes: list[dict[str, Any]] = [] + for col_name in _column_names(obj): + if col_name in excl or col_name in pk_cols: + continue + changes.append({"field": col_name, "new": _serialize(getattr(obj, col_name))}) + pending.append( + _PendingChange( + obj_ref=obj, + entity_type=type(obj).__name__, + action="created", + changes=changes, + user_id=user_id, + correlation_id=correlation_id, + ) + ) + + # ── Updated entities ─────────────────────────────────────────────── + for obj in list(session.dirty): + if not session.is_modified(obj): + continue + if _is_excluded(obj): + continue + excl = _get_excluded(obj) + pk_cols = _get_pk_cols(obj) + inspector = sa_inspect(obj) + changes = [] + for col_name in _column_names(obj): + if col_name in excl or col_name in pk_cols: + continue + hist = inspector.attrs[col_name].history + if not hist.has_changes(): + continue + old_val = hist.deleted[0] if hist.deleted else None + new_val = hist.added[0] if hist.added else None + changes.append( + {"field": col_name, "old": _serialize(old_val), "new": _serialize(new_val)} + ) + if changes: + pending.append( + _PendingChange( + obj_ref=obj, + entity_type=type(obj).__name__, + action="updated", + changes=changes, + user_id=user_id, + correlation_id=correlation_id, + ) + ) + + # ── Deleted entities ─────────────────────────────────────────────── + for obj in list(session.deleted): + if _is_excluded(obj): + continue + pending.append( + _PendingChange( + obj_ref=obj, + entity_type=type(obj).__name__, + action="deleted", + changes=[], + user_id=user_id, + correlation_id=correlation_id, + ) + ) + + return pending + + +def finalize_records(pending: list[_PendingChange]) -> list[AuditRecord]: + """Phase 2: resolve entity_ids now that DB-assigned PKs are populated. + + Called from ``after_flush_postexec``. By this point the object's primary + key is stable for new entities (the INSERT has executed). + """ + return [ + AuditRecord( + entity_type=p.entity_type, + entity_id=_entity_pk_str(p.obj_ref), + action=p.action, + changes=p.changes, + user_id=p.user_id, + correlation_id=p.correlation_id, + ) + for p in pending + ] + + +# --------------------------------------------------------------------------- +# Single-phase public API (used directly by tests / callers with stable PKs) +# --------------------------------------------------------------------------- + + +def collect_audit_records( + session: Session, + user_id: str | None = None, + correlation_id: str | None = None, +) -> list[AuditRecord]: + """Inspect session state and return a list of :class:`AuditRecord`. + + Convenience wrapper around :func:`snapshot_changes` + + :func:`finalize_records` for callers that don't need the two-phase split + (e.g. tests, or contexts where PKs are already populated client-side). + + Meant to be called from a ``before_flush`` listener *before* the session + state is cleared. Does not modify the session. + """ + return finalize_records(snapshot_changes(session, user_id, correlation_id)) diff --git a/framework/db/simple_module_db/listeners.py b/framework/db/simple_module_db/listeners.py index 88a49a66..7815183c 100644 --- a/framework/db/simple_module_db/listeners.py +++ b/framework/db/simple_module_db/listeners.py @@ -32,12 +32,18 @@ class TenantIsolationError(Exception): # flush has cleared ``session.new/.dirty/.deleted``. SESSION_HAS_WRITES_KEY = "has_writes" +# Key on ``Session.info`` for pending audit snapshots produced in before_flush +# and consumed in after_flush_postexec (when DB-assigned PKs are populated). +_AUDIT_PENDING_KEY = "_audit_pending" + # DB audit event names _EVENT_ENTITY_CREATED = "db.entity.created" _EVENT_ENTITY_UPDATED = "db.entity.updated" _EVENT_ENTITY_SOFT_DELETED = "db.entity.soft_deleted" _EVENT_ENTITY_DELETED = "db.entity.deleted" +_db_state: DatabaseState | None = None + # DB operation strings used in log extra dicts _OP_CREATE = "create" _OP_UPDATE = "update" @@ -85,8 +91,12 @@ def register_listeners(db_state: DatabaseState) -> None: logger.debug("Listeners already registered, skipping") return + global _db_state + _db_state = db_state + event.listen(db_state.sync_session_class, "before_flush", _before_flush_listener) event.listen(db_state.sync_session_class, "after_flush", _mark_session_written) + event.listen(db_state.sync_session_class, "after_flush_postexec", _after_flush_audit) event.listen(db_state.sync_session_class, "do_orm_execute", _filter_select_statements) db_state._listeners_registered = True logger.info("Registered SQLAlchemy entity listeners") @@ -185,6 +195,45 @@ def _before_flush_listener( }, ) + # Audit phase 1: snapshot diffs while attribute history is still available. + # entity_id resolution is deferred to after_flush_postexec because + # DB-assigned integer PKs aren't populated until the INSERT executes. + if _db_state is not None and _db_state.audit_callback is not None: + from simple_module_db.audit import snapshot_changes + + correlation_id_val: str | None = None + try: + from simple_module_hosting.logging import correlation_id as _cid_var + + correlation_id_val = _cid_var.get("") or None + except ImportError: + pass + + pending = snapshot_changes(session, user_id, correlation_id_val) + if pending: + session.info[_AUDIT_PENDING_KEY] = pending + + +def _after_flush_audit(session: Session, flush_context: object) -> None: + """Phase 2: finalize audit records now that DB-assigned PKs are populated. + + Called via ``after_flush_postexec`` — after INSERTs have executed and + SQLAlchemy has refreshed PK columns on the live object. Records added + here land in ``session.new`` for the *next* flush (triggered by commit's + autoflush). The recursion guard relies on AuditEntry having + ``__audit_exclude__ = True`` so its own flush produces no pending records. + """ + if _db_state is None or _db_state.audit_callback is None: + return + pending = session.info.pop(_AUDIT_PENDING_KEY, None) + if not pending: + return + from simple_module_db.audit import finalize_records + + records = finalize_records(pending) + if records: + _db_state.audit_callback(session, records) + # Cache ``(is_soft_delete, is_multi_tenant)`` flags per mapper class so the # ``do_orm_execute`` hot path skips redundant ``issubclass`` work on every query. diff --git a/framework/db/simple_module_db/session.py b/framework/db/simple_module_db/session.py index eb3843f5..d32c0ae9 100644 --- a/framework/db/simple_module_db/session.py +++ b/framework/db/simple_module_db/session.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass, field from sqlalchemy.ext.asyncio import ( @@ -22,6 +23,7 @@ class DatabaseState: engine: AsyncEngine session_factory: async_sessionmaker[AsyncSession] sync_session_class: type[Session] = field(repr=False, default=Session) + audit_callback: Callable | None = field(default=None, repr=False) _listeners_registered: bool = field(default=False, repr=False) diff --git a/framework/db/tests/_audit_models.py b/framework/db/tests/_audit_models.py new file mode 100644 index 00000000..2106851a --- /dev/null +++ b/framework/db/tests/_audit_models.py @@ -0,0 +1,60 @@ +"""Test-only SQLModel tables for the audit test suite. + +Kept in a dedicated module so the model definitions don't push +``test_audit.py`` over the 300-line file size cap. +""" + +from __future__ import annotations + +from typing import ClassVar + +from simple_module_db.base import create_module_base +from simple_module_db.mixins import AuditMixin, SoftDeleteMixin +from simple_module_db.provider import DatabaseProvider +from sqlmodel import Field + +AuditBase = create_module_base("test_audit", provider=DatabaseProvider.SQLITE) + + +class AuditTestItem(AuditBase, AuditMixin, table=True): # type: ignore[call-arg] # ty: ignore[unsupported-base] + """Standard audited entity for testing.""" + + __tablename__ = "test_audit_item" + id: int | None = Field(default=None, primary_key=True) + name: str = Field(max_length=100) + value: int = Field(default=0) + + +class ExcludedModel(AuditBase, table=True): # type: ignore[call-arg] # ty: ignore[unsupported-base] + """Model that opts out of auditing entirely.""" + + __tablename__ = "test_audit_excluded" + __audit_exclude__ = True + id: int | None = Field(default=None, primary_key=True) + name: str = Field(max_length=100) + + +class PartialExcludeModel(AuditBase, AuditMixin, table=True): # type: ignore[call-arg] # ty: ignore[unsupported-base] + """Model with specific fields excluded from audit tracking.""" + + __tablename__ = "test_audit_partial" + __audit_exclude_fields__: ClassVar[set[str]] = {"password_hash"} + id: int | None = Field(default=None, primary_key=True) + username: str = Field(max_length=100) + password_hash: str = Field(max_length=255, default="") + + +class SoftDeleteItem(AuditBase, AuditMixin, SoftDeleteMixin, table=True): # type: ignore[call-arg] # ty: ignore[unsupported-base] + """Audited entity with soft-delete support for testing.""" + + __tablename__ = "test_audit_soft_delete_item" + id: int | None = Field(default=None, primary_key=True) + title: str = Field(max_length=100) + + +class IntPKItem(AuditBase, AuditMixin, table=True): # type: ignore[call-arg] # ty: ignore[unsupported-base] + """Entity with a DB-assigned integer primary key (BUG-002 regression case).""" + + __tablename__ = "test_audit_int_pk_item" + id: int | None = Field(default=None, primary_key=True) + name: str = Field(max_length=100) diff --git a/framework/db/tests/test_audit.py b/framework/db/tests/test_audit.py new file mode 100644 index 00000000..5d61f294 --- /dev/null +++ b/framework/db/tests/test_audit.py @@ -0,0 +1,293 @@ +"""Tests for AuditRecord dataclass and collect_audit_records diff collection. + +Verifies the pure-logic core: given SQLAlchemy session state, produce a list +of ``AuditRecord`` structs describing what changed. +""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator +from dataclasses import FrozenInstanceError + +import pytest +from _audit_models import ( + AuditBase, + AuditTestItem, + ExcludedModel, + IntPKItem, + PartialExcludeModel, + SoftDeleteItem, +) +from simple_module_db.audit import ( + AuditRecord, + collect_audit_records, + finalize_records, + snapshot_changes, +) +from simple_module_db.listeners import register_listeners +from simple_module_db.session import init_db +from sqlalchemy.ext.asyncio import AsyncSession + + +@pytest.fixture +async def audit_session() -> AsyncGenerator[AsyncSession, None]: + """Session backed by in-memory SQLite with listeners registered.""" + db_state = init_db("sqlite+aiosqlite:///:memory:") + try: + register_listeners(db_state) + async with db_state.engine.begin() as conn: + await conn.run_sync(AuditBase.metadata.create_all) + async with db_state.session_factory() as session: + yield session + finally: + await db_state.engine.dispose() + + +# ── AuditRecord dataclass ───────────────────────────────────────────────── + + +def test_audit_record_is_frozen(): + record = AuditRecord( + entity_type="Item", + entity_id="1", + action="created", + changes=[{"field": "name", "new": "test"}], + user_id="alice", + correlation_id="req-123", + ) + with pytest.raises(FrozenInstanceError): + record.action = "updated" + + +# ── collect_audit_records: created ───────────────────────────────────────── + + +async def test_collect_records_for_new_entity(audit_session: AsyncSession): + item = AuditTestItem(name="widget", value=42) + audit_session.add(item) + + # Collect before flush (inside the sync session via run_sync) + records: list[AuditRecord] = [] + + def _collect(session): + records.extend(collect_audit_records(session, user_id="alice", correlation_id="req-1")) + + await audit_session.run_sync(_collect) + + assert len(records) == 1 + rec = records[0] + assert rec.entity_type == "AuditTestItem" + assert rec.action == "created" + assert rec.user_id == "alice" + assert rec.correlation_id == "req-1" + + # Should contain name and value, but not PK or AuditMixin fields + field_names = {c["field"] for c in rec.changes} + assert "name" in field_names + assert "value" in field_names + assert "id" not in field_names + assert "created_at" not in field_names + assert "updated_at" not in field_names + + # Check values + name_change = next(c for c in rec.changes if c["field"] == "name") + assert name_change["new"] == "widget" + value_change = next(c for c in rec.changes if c["field"] == "value") + assert value_change["new"] == 42 + + +# ── collect_audit_records: excluded model ────────────────────────────────── + + +async def test_excluded_model_produces_no_records(audit_session: AsyncSession): + excluded = ExcludedModel(name="secret") + audit_session.add(excluded) + + records: list[AuditRecord] = [] + + def _collect(session): + records.extend(collect_audit_records(session)) + + await audit_session.run_sync(_collect) + + assert len(records) == 0 + + +# ── collect_audit_records: excluded fields ───────────────────────────────── + + +async def test_excluded_fields_are_omitted(audit_session: AsyncSession): + user = PartialExcludeModel(username="bob", password_hash="s3cret") + audit_session.add(user) + + records: list[AuditRecord] = [] + + def _collect(session): + records.extend(collect_audit_records(session)) + + await audit_session.run_sync(_collect) + + assert len(records) == 1 + field_names = {c["field"] for c in records[0].changes} + assert "username" in field_names + assert "password_hash" not in field_names + + +# ── collect_audit_records: AuditMixin fields always excluded ─────────────── + + +async def test_audit_mixin_fields_excluded_by_default(audit_session: AsyncSession): + item = AuditTestItem(name="audited", value=1) + audit_session.add(item) + + records: list[AuditRecord] = [] + + def _collect(session): + records.extend(collect_audit_records(session)) + + await audit_session.run_sync(_collect) + + assert len(records) == 1 + field_names = {c["field"] for c in records[0].changes} + for mixin_field in ("created_at", "updated_at", "created_by", "updated_by"): + assert mixin_field not in field_names, f"{mixin_field} should be excluded" + + +# ── collect_audit_records: updated ───────────────────────────────────────── + + +async def test_collect_records_for_update(audit_session: AsyncSession): + # First create and commit so the entity is persistent + item = AuditTestItem(name="original", value=10) + audit_session.add(item) + await audit_session.commit() + await audit_session.refresh(item) + + # Now modify it + item.name = "renamed" + + records: list[AuditRecord] = [] + + def _collect(session): + records.extend(collect_audit_records(session, user_id="bob", correlation_id="req-2")) + + await audit_session.run_sync(_collect) + + assert len(records) == 1 + rec = records[0] + assert rec.entity_type == "AuditTestItem" + assert rec.action == "updated" + assert rec.entity_id == str(item.id) + assert rec.user_id == "bob" + + # Should only contain the changed field + assert len(rec.changes) == 1 + change = rec.changes[0] + assert change["field"] == "name" + assert change["old"] == "original" + assert change["new"] == "renamed" + + +# ── collect_audit_records: soft-deleted entity ──────────────────────────── + + +async def test_soft_deleted_entity_produces_soft_deleted_record( + audit_session: AsyncSession, +): + """A SoftDeleteMixin object re-added to session.new with is_deleted=True + should produce action='soft_deleted', not action='created'.""" + item = SoftDeleteItem(title="doomed") + audit_session.add(item) + await audit_session.commit() + await audit_session.refresh(item) + + # Simulate what the soft-delete listener does: expunge from deleted, + # set is_deleted=True, re-add to session.new. We use make_transient + # so SQLAlchemy treats the re-added object as new (matching the + # session state that triggers Bug 1). + await audit_session.delete(item) + + def _simulate_soft_delete(session): + from sqlalchemy.orm import make_transient + + session.expunge(item) + item.is_deleted = True + make_transient(item) + session.add(item) + + await audit_session.run_sync(_simulate_soft_delete) + + records: list[AuditRecord] = [] + + def _collect(session): + records.extend(collect_audit_records(session, user_id="admin", correlation_id="req-sd")) + + await audit_session.run_sync(_collect) + + assert len(records) == 1 + rec = records[0] + assert rec.action == "soft_deleted" + assert rec.entity_type == "SoftDeleteItem" + assert rec.entity_id == str(item.id) + assert rec.changes == [] + assert rec.user_id == "admin" + assert rec.correlation_id == "req-sd" + + +# ── collect_audit_records: soft-delete fields excluded from diffs ───────── + + +async def test_soft_delete_fields_excluded(audit_session: AsyncSession): + """is_deleted, deleted_at, deleted_by should never appear in changes.""" + item = SoftDeleteItem(title="widget") + audit_session.add(item) + + records: list[AuditRecord] = [] + + def _collect(session): + records.extend(collect_audit_records(session)) + + await audit_session.run_sync(_collect) + + assert len(records) == 1 + field_names = {c["field"] for c in records[0].changes} + assert "title" in field_names + for soft_field in ("is_deleted", "deleted_at", "deleted_by"): + assert soft_field not in field_names, f"{soft_field} should be excluded" + + +# ── Two-phase capture: DB-assigned integer PKs (BUG-002) ────────────────── + + +async def test_created_entry_has_resolved_int_pk(audit_session: AsyncSession): + """Integer PKs are populated by the DB during INSERT, so entity_id should + be resolved correctly in the audit log via the two-phase capture. + + Regression test for BUG-002: single-phase ``collect_audit_records`` ran + in ``before_flush`` where the PK was still ``None``, yielding + ``entity_id=""``. The two-phase flow snapshots the diff up-front but + defers ``entity_id`` resolution until after the flush has assigned it. + """ + item = IntPKItem(name="hello") + audit_session.add(item) + + # Phase 1: snapshot while id is still None (pre-flush). + pending_holder: list = [] + + def _phase1(session): + pending_holder.extend(snapshot_changes(session, None, None)) + + await audit_session.run_sync(_phase1) + assert any(p.entity_type == "IntPKItem" for p in pending_holder) + assert item.id is None # PK not yet assigned + + # Now flush to assign the PK + await audit_session.flush() + assert item.id is not None # DB assigned it + + # Phase 2: finalize — entity_id should now be the real PK + records = finalize_records(pending_holder) + int_records = [r for r in records if r.entity_type == "IntPKItem"] + assert len(int_records) == 1 + assert int_records[0].entity_id == str(item.id) + assert int_records[0].entity_id != "" diff --git a/host/migrations/versions/70786227af4c_add_audit_log_tables.py b/host/migrations/versions/70786227af4c_add_audit_log_tables.py new file mode 100644 index 00000000..269781ba --- /dev/null +++ b/host/migrations/versions/70786227af4c_add_audit_log_tables.py @@ -0,0 +1,59 @@ +"""add audit_log tables + +Revision ID: 70786227af4c +Revises: 41cf2c53660e +Create Date: 2026-05-27 22:56:05.494513 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "70786227af4c" +down_revision: str | None = "41cf2c53660e" +branch_labels: str | Sequence[str] | None = ("audit_log",) +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "audit_log_audit_entry", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("entity_type", sa.String(length=255), nullable=False), + sa.Column("entity_id", sa.String(length=255), nullable=False), + sa.Column("action", sa.String(length=20), nullable=False), + sa.Column("changes", sa.JSON(), nullable=True), + sa.Column("user_id", sa.String(length=255), nullable=True), + sa.Column("correlation_id", sa.String(length=255), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_audit_log_audit_entry")), + ) + op.create_index( + "ix_audit_entry_created_at", "audit_log_audit_entry", ["created_at"], unique=False + ) + op.create_index( + "ix_audit_entry_entity_id", "audit_log_audit_entry", ["entity_id"], unique=False + ) + op.create_index( + "ix_audit_entry_entity_type", "audit_log_audit_entry", ["entity_type"], unique=False + ) + op.create_index("ix_audit_entry_user_id", "audit_log_audit_entry", ["user_id"], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index("ix_audit_entry_user_id", table_name="audit_log_audit_entry") + op.drop_index("ix_audit_entry_entity_type", table_name="audit_log_audit_entry") + op.drop_index("ix_audit_entry_entity_id", table_name="audit_log_audit_entry") + op.drop_index("ix_audit_entry_created_at", table_name="audit_log_audit_entry") + op.drop_table("audit_log_audit_entry") + # ### end Alembic commands ### diff --git a/host/pyproject.toml b/host/pyproject.toml index 35bce23f..fe5b8a1c 100644 --- a/host/pyproject.toml +++ b/host/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [ "simple_module_file_storage", "simple_module_settings", "simple_module_feature_flags", + "simple_module_audit_log", "python-multipart>=0.0.6", ] @@ -24,3 +25,4 @@ simple_module_background_tasks = { workspace = true } simple_module_file_storage = { workspace = true } simple_module_settings = { workspace = true } simple_module_feature_flags = { workspace = true } +simple_module_audit_log = { workspace = true } diff --git a/modules/audit_log/LICENSE b/modules/audit_log/LICENSE new file mode 100644 index 00000000..1e610348 --- /dev/null +++ b/modules/audit_log/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Anto Subash + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/modules/audit_log/README.md b/modules/audit_log/README.md new file mode 100644 index 00000000..4b8132ce --- /dev/null +++ b/modules/audit_log/README.md @@ -0,0 +1,54 @@ +# simple_module_audit_log + +Automatic field-level audit trail for all SQLModel entities in [simple_module](https://github.com/antosubash/simple_module_python) apps. Every create / update / delete / soft-delete is captured in `audit_log.audit_entries` with the changed fields, the user who made the change, and the request correlation id — no instrumentation required in module code. + +## Install + +```bash +pip install simple_module_audit_log +``` + +Add `simple_module_audit_log` to your host's dependencies and the entry point will be discovered automatically. + +## What it provides + +- Two-phase capture (`before_flush` snapshots diffs, `after_flush_postexec` resolves DB-assigned primary keys) so integer-PK entities record the real id, not the empty string. +- `AuditEntry` SQLModel table with indexed `entity_type`, `entity_id`, `user_id`, `created_at` columns. +- `GET /api/audit_log/` REST endpoint with `entity_type`, `entity_id`, `action`, `user_id`, `from_date`, `to_date`, paging filters. +- `/audit_log` Inertia browse page with the same filter set rendered as a table, gated by the `audit_log.view` permission. +- Opt-outs: set `__audit_exclude__ = True` on a model to skip it entirely, or `__audit_exclude_fields__ = {"password_hash"}` to skip specific columns. `AuditMixin` / `SoftDeleteMixin` bookkeeping fields are excluded automatically. + +## Usage + +Install the module and grant the `audit_log.view` permission to your admin role. Audit entries appear automatically for every entity that does not opt out. + +To exclude a sensitive column: + +```python +from typing import ClassVar +from sqlmodel import Field +from simple_module_db.base import create_module_base +from simple_module_db.mixins import AuditMixin + +Base = create_module_base("auth") + + +class Credential(Base, AuditMixin, table=True): + __audit_exclude_fields__: ClassVar[set[str]] = {"password_hash"} + + id: int | None = Field(default=None, primary_key=True) + user_id: str + password_hash: str +``` + +To opt a whole table out: + +```python +class Cache(Base, table=True): + __audit_exclude__ = True + ... +``` + +## License + +MIT diff --git a/modules/audit_log/audit_log/__init__.py b/modules/audit_log/audit_log/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/modules/audit_log/audit_log/capture.py b/modules/audit_log/audit_log/capture.py new file mode 100644 index 00000000..2a79d546 --- /dev/null +++ b/modules/audit_log/audit_log/capture.py @@ -0,0 +1,28 @@ +"""Audit callback that converts AuditRecords into AuditEntry rows.""" + +from __future__ import annotations + +import logging + +from simple_module_db.audit import AuditRecord +from sqlalchemy.orm import Session + +from audit_log.models import AuditEntry + +logger = logging.getLogger(__name__) + + +def audit_callback(session: Session, records: list[AuditRecord]) -> None: + try: + for record in records: + entry = AuditEntry( + entity_type=record.entity_type, + entity_id=record.entity_id, + action=record.action, + changes=record.changes, + user_id=record.user_id, + correlation_id=record.correlation_id, + ) + session.add(entry) + except Exception: + logger.exception("Failed to write audit entries") diff --git a/modules/audit_log/audit_log/constants.py b/modules/audit_log/audit_log/constants.py new file mode 100644 index 00000000..02822c0b --- /dev/null +++ b/modules/audit_log/audit_log/constants.py @@ -0,0 +1,42 @@ +"""Centralized constants for the Audit Log module.""" + +from __future__ import annotations + +from typing import Final + +MODULE_NAME: Final = "AuditLog" +MODULE_PACKAGE: Final = "audit_log" +LOCALE_NAMESPACE: Final = MODULE_PACKAGE + +API_PREFIX: Final = "/api/audit_log" +VIEW_PREFIX: Final = "/audit_log" + +MENU_LABEL: Final = "Audit Log" +MENU_URL: Final = VIEW_PREFIX +MENU_ICON: Final = "scroll-text" +MENU_ORDER: Final = 210 + +PERM_GROUP: Final = MODULE_NAME +PERM_VIEW: Final = "audit_log.view" +ALL_PERMISSIONS: Final = (PERM_VIEW,) + +TABLE_AUDIT_ENTRY: Final = "audit_log_audit_entry" + +ACTION_CREATED: Final = "created" +ACTION_UPDATED: Final = "updated" +ACTION_DELETED: Final = "deleted" +ACTION_SOFT_DELETED: Final = "soft_deleted" +ALL_ACTIONS: Final = (ACTION_CREATED, ACTION_UPDATED, ACTION_DELETED, ACTION_SOFT_DELETED) + +ENTITY_TYPE_MAX_LENGTH: Final = 255 +ENTITY_ID_MAX_LENGTH: Final = 255 +ACTION_MAX_LENGTH: Final = 20 +USER_ID_MAX_LENGTH: Final = 255 +CORRELATION_ID_MAX_LENGTH: Final = 255 + +DEFAULT_PAGE_SIZE: Final = 50 +MAX_PAGE_SIZE: Final = 200 + +PAGE_BROWSE: Final = f"{MODULE_NAME}/Browse" + +STATUS_OK: Final = 200 diff --git a/modules/audit_log/audit_log/contracts/__init__.py b/modules/audit_log/audit_log/contracts/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/modules/audit_log/audit_log/contracts/schemas.py b/modules/audit_log/audit_log/contracts/schemas.py new file mode 100644 index 00000000..febb9334 --- /dev/null +++ b/modules/audit_log/audit_log/contracts/schemas.py @@ -0,0 +1,29 @@ +"""SQLModel DTOs for the Audit Log module.""" + +from __future__ import annotations + +import uuid +from datetime import datetime + +from pydantic import ConfigDict +from sqlmodel import SQLModel + + +class AuditEntryRead(SQLModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + entity_type: str + entity_id: str + action: str + changes: list[dict] + user_id: str | None = None + correlation_id: str | None = None + created_at: datetime + + +class AuditEntryList(SQLModel): + items: list[AuditEntryRead] + total: int + page: int + page_size: int diff --git a/modules/audit_log/audit_log/deps.py b/modules/audit_log/audit_log/deps.py new file mode 100644 index 00000000..99747f9a --- /dev/null +++ b/modules/audit_log/audit_log/deps.py @@ -0,0 +1,20 @@ +"""FastAPI dependencies for the Audit Log module.""" + +from __future__ import annotations + +from typing import Annotated + +from fastapi import Depends +from simple_module_db.deps import get_db +from sqlalchemy.ext.asyncio import AsyncSession + +from audit_log.service import AuditLogService + + +async def get_audit_log_service( + db: AsyncSession = Depends(get_db), +) -> AuditLogService: + return AuditLogService(db) + + +AuditLogServiceDep = Annotated[AuditLogService, Depends(get_audit_log_service)] diff --git a/modules/audit_log/audit_log/endpoints/__init__.py b/modules/audit_log/audit_log/endpoints/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/modules/audit_log/audit_log/endpoints/api.py b/modules/audit_log/audit_log/endpoints/api.py new file mode 100644 index 00000000..d0ac35f9 --- /dev/null +++ b/modules/audit_log/audit_log/endpoints/api.py @@ -0,0 +1,40 @@ +"""REST API endpoints for the Audit Log module.""" + +from __future__ import annotations + +from datetime import datetime + +from fastapi import APIRouter, Depends, Query +from simple_module_hosting.permissions import RequiresPermission + +from audit_log.constants import DEFAULT_PAGE_SIZE, PERM_VIEW +from audit_log.contracts.schemas import AuditEntryList +from audit_log.deps import AuditLogServiceDep + +router = APIRouter() + +_VIEW = [Depends(RequiresPermission(PERM_VIEW))] + + +@router.get("/", response_model=AuditEntryList, dependencies=_VIEW) +async def list_audit_entries( + service: AuditLogServiceDep, + entity_type: str | None = Query(default=None), + entity_id: str | None = Query(default=None), + action: str | None = Query(default=None), + user_id: str | None = Query(default=None), + from_date: datetime | None = Query(default=None), + to_date: datetime | None = Query(default=None), + page: int = Query(default=1, ge=1), + page_size: int = Query(default=DEFAULT_PAGE_SIZE, ge=1, le=200), +) -> AuditEntryList: + return await service.list_entries( + entity_type=entity_type, + entity_id=entity_id, + action=action, + user_id=user_id, + from_date=from_date, + to_date=to_date, + page=page, + page_size=page_size, + ) diff --git a/modules/audit_log/audit_log/endpoints/views.py b/modules/audit_log/audit_log/endpoints/views.py new file mode 100644 index 00000000..ed29a419 --- /dev/null +++ b/modules/audit_log/audit_log/endpoints/views.py @@ -0,0 +1,82 @@ +"""Inertia view endpoints for the Audit Log admin UI.""" + +from __future__ import annotations + +from datetime import datetime + +from fastapi import APIRouter, Depends, Query +from inertia import InertiaResponse +from simple_module_hosting.inertia_deps import InertiaDep +from simple_module_hosting.permissions import RequiresPermission + +from audit_log.constants import ( + DEFAULT_PAGE_SIZE, + MAX_PAGE_SIZE, + PAGE_BROWSE, + PERM_VIEW, +) +from audit_log.deps import AuditLogServiceDep + +router = APIRouter() + + +def _safe_int(raw: str | None, default: int) -> int: + """Parse *raw* as an integer, returning *default* on failure.""" + if raw is None: + return default + try: + return int(raw) + except (ValueError, TypeError): + return default + + +@router.get( + "/", + response_model=None, + dependencies=[Depends(RequiresPermission(PERM_VIEW))], +) +async def browse( + inertia: InertiaDep, + service: AuditLogServiceDep, + entity_type: str | None = Query(default=None), + entity_id: str | None = Query(default=None), + action: str | None = Query(default=None), + user_id: str | None = Query(default=None), + from_date: datetime | None = Query(default=None), + to_date: datetime | None = Query(default=None), + page: str | None = Query(default=None), + page_size: str | None = Query(default=None), +) -> InertiaResponse: + # Sanitize pagination — never raise a validation error for bad values. + page_int = max(_safe_int(page, 1), 1) + page_size_int = max(1, min(_safe_int(page_size, DEFAULT_PAGE_SIZE), MAX_PAGE_SIZE)) + result = await service.list_entries( + entity_type=entity_type, + entity_id=entity_id, + action=action, + user_id=user_id, + from_date=from_date, + to_date=to_date, + page=page_int, + page_size=page_size_int, + ) + entity_types = await service.distinct_entity_types() + + return await inertia.render( + PAGE_BROWSE, + { + "items": [item.model_dump(mode="json") for item in result.items], + "total": result.total, + "page": result.page, + "page_size": result.page_size, + "entity_types": entity_types, + "filters": { + "entity_type": entity_type, + "entity_id": entity_id, + "action": action, + "user_id": user_id, + "from_date": from_date.isoformat() if from_date else None, + "to_date": to_date.isoformat() if to_date else None, + }, + }, + ) diff --git a/modules/audit_log/audit_log/locales/en.json b/modules/audit_log/audit_log/locales/en.json new file mode 100644 index 00000000..691e0f81 --- /dev/null +++ b/modules/audit_log/audit_log/locales/en.json @@ -0,0 +1,43 @@ +{ + "browse": { + "title": "Audit Log", + "description": "Track all entity changes across the system.", + "empty_title": "No audit entries", + "empty_description": "Changes to entities will appear here automatically.", + "showing": "Showing {from}–{to} of {total} entries", + "previous": "Previous", + "next": "Next" + }, + "filters": { + "entity_type_label": "Entity Type", + "entity_type_all": "All types", + "action_label": "Action", + "action_all": "All actions", + "user_label": "User ID", + "user_placeholder": "Filter by user…", + "from_date_label": "From", + "to_date_label": "To", + "apply": "Apply", + "clear": "Clear" + }, + "table": { + "timestamp": "Timestamp", + "action": "Action", + "entity": "Entity", + "user": "User", + "changes": "Changes" + }, + "actions": { + "created": "Created", + "updated": "Updated", + "deleted": "Deleted", + "soft_deleted": "Archived" + }, + "changes": { + "fields_set": "{count} fields set", + "show_more": "Show {count} more…", + "show_less": "Show less", + "system_user": "System", + "no_changes": "—" + } +} diff --git a/modules/audit_log/audit_log/models.py b/modules/audit_log/audit_log/models.py new file mode 100644 index 00000000..10baad0c --- /dev/null +++ b/modules/audit_log/audit_log/models.py @@ -0,0 +1,47 @@ +"""SQLModel table for the Audit Log module.""" + +from __future__ import annotations + +import uuid +from datetime import UTC, datetime + +from simple_module_db.base import create_module_base +from sqlalchemy import JSON, DateTime, Index, func +from sqlmodel import Column, Field + +from audit_log.constants import ( + ACTION_MAX_LENGTH, + CORRELATION_ID_MAX_LENGTH, + ENTITY_ID_MAX_LENGTH, + ENTITY_TYPE_MAX_LENGTH, + MODULE_PACKAGE, + TABLE_AUDIT_ENTRY, + USER_ID_MAX_LENGTH, +) + +Base = create_module_base(MODULE_PACKAGE) + + +class AuditEntry(Base, table=True): # ty: ignore[unsupported-base] + __tablename__ = TABLE_AUDIT_ENTRY + __audit_exclude__ = True + + __table_args__ = ( + Index("ix_audit_entry_entity_type", "entity_type"), + Index("ix_audit_entry_entity_id", "entity_id"), + Index("ix_audit_entry_user_id", "user_id"), + Index("ix_audit_entry_created_at", "created_at"), + ) + + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + entity_type: str = Field(max_length=ENTITY_TYPE_MAX_LENGTH) + entity_id: str = Field(max_length=ENTITY_ID_MAX_LENGTH) + action: str = Field(max_length=ACTION_MAX_LENGTH) + changes: dict | list = Field(default_factory=list, sa_column=Column(JSON)) + user_id: str | None = Field(default=None, max_length=USER_ID_MAX_LENGTH) + correlation_id: str | None = Field(default=None, max_length=CORRELATION_ID_MAX_LENGTH) + created_at: datetime = Field( + default_factory=lambda: datetime.now(UTC), + sa_type=DateTime(timezone=True), + sa_column_kwargs={"server_default": func.now()}, + ) diff --git a/modules/audit_log/audit_log/module.py b/modules/audit_log/audit_log/module.py new file mode 100644 index 00000000..982a4283 --- /dev/null +++ b/modules/audit_log/audit_log/module.py @@ -0,0 +1,69 @@ +"""Audit Log module definition.""" + +from __future__ import annotations + +import importlib.resources +from pathlib import Path + +from fastapi import APIRouter, FastAPI +from simple_module_core.menu import MenuItem, MenuRegistry, MenuSection +from simple_module_core.module import ModuleBase, ModuleMeta +from simple_module_core.permissions import PermissionRegistry + +from audit_log.constants import ( + ALL_PERMISSIONS, + API_PREFIX, + LOCALE_NAMESPACE, + MENU_ICON, + MENU_LABEL, + MENU_ORDER, + MENU_URL, + MODULE_NAME, + PERM_GROUP, + VIEW_PREFIX, +) + +_MODULE_USERS = "Users" + + +class AuditLogModule(ModuleBase): + meta = ModuleMeta( + name=MODULE_NAME, + route_prefix=API_PREFIX, + view_prefix=VIEW_PREFIX, + depends_on=[_MODULE_USERS], + ) + + def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None: + from audit_log.endpoints.api import router as api + from audit_log.endpoints.views import router as views + + api_router.include_router(api) + view_router.include_router(views) + + def register_menu_items(self, registry: MenuRegistry) -> None: + registry.add( + MenuItem( + label=MENU_LABEL, + url=MENU_URL, + icon=MENU_ICON, + order=MENU_ORDER, + section=MenuSection.SIDEBAR, + group="System", + ) + ) + + def register_permissions(self, registry: PermissionRegistry) -> None: + registry.add_group(PERM_GROUP, list(ALL_PERMISSIONS)) + + async def on_startup(self, app: FastAPI) -> None: + from audit_log.capture import audit_callback + + app.state.sm.db.audit_callback = audit_callback + + async def on_shutdown(self, app: FastAPI) -> None: + app.state.sm.db.audit_callback = None + + def locale_dirs(self) -> dict[str, Path]: + base = Path(str(importlib.resources.files(__package__) / "locales")) + return {LOCALE_NAMESPACE: base} diff --git a/modules/audit_log/audit_log/pages/Browse.tsx b/modules/audit_log/audit_log/pages/Browse.tsx new file mode 100644 index 00000000..558a91b7 --- /dev/null +++ b/modules/audit_log/audit_log/pages/Browse.tsx @@ -0,0 +1,245 @@ +import { Head, router, usePage } from '@inertiajs/react'; +import { keys, useT } from '@simple-module-py/i18n'; +import { PageShell } from '@simple-module-py/ui/components/PageShell'; +import { Badge } from '@simple-module-py/ui/components/ui/badge'; +import { Button } from '@simple-module-py/ui/components/ui/button'; +import { Card } from '@simple-module-py/ui/components/ui/card'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@simple-module-py/ui/components/ui/table'; +import { AuthenticatedLayout } from '@simple-module-py/ui/layouts/AuthenticatedLayout'; +import { ScrollText } from 'lucide-react'; +import type React from 'react'; +import { useState } from 'react'; +import { ALL, FilterBar, type FilterState } from './components/FilterBar'; + +interface Change { + field: string; + old?: unknown; + new?: unknown; +} + +interface AuditEntryRead { + id: string; + entity_type: string; + entity_id: string; + action: 'created' | 'updated' | 'deleted' | 'soft_deleted'; + changes: Change[]; + user_id: string | null; + correlation_id: string | null; + created_at: string; +} + +interface Filters { + entity_type: string | null; + action: string | null; + user_id: string | null; + from_date: string | null; + to_date: string | null; +} + +interface Props { + items: AuditEntryRead[]; + total: number; + page: number; + page_size: number; + entity_types: string[]; + filters: Filters; +} + +const ACTION_BADGE: Record = { + created: 'border-green-200 bg-green-50 text-green-700', + updated: 'border-blue-200 bg-blue-50 text-blue-700', + deleted: 'border-red-200 bg-red-50 text-red-700', + soft_deleted: 'border-amber-200 bg-amber-50 text-amber-700', +}; +const TH = 'sm:px-6 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground'; + +function ChangesList({ entry }: { entry: AuditEntryRead }) { + const { t } = useT(); + const [expanded, setExpanded] = useState(false); + if (entry.action === 'deleted' || entry.action === 'soft_deleted') + return {t(keys.audit_log.changes.no_changes)}; + if (entry.action === 'created') + return ( + + {t(keys.audit_log.changes.fields_set, { count: entry.changes.length })} + + ); + + const visible = expanded ? entry.changes : entry.changes.slice(0, 3); + const remaining = entry.changes.length - 3; + return ( + + {visible.map((c) => ( + + {c.field}{' '} + + {String(c.old ?? '""')}→{String(c.new ?? '""')} + + + ))} + {remaining > 0 && ( + setExpanded(!expanded)} + > + {expanded + ? t(keys.audit_log.changes.show_less) + : t(keys.audit_log.changes.show_more, { count: remaining })} + + )} + + ); +} + +function Browse() { + const { items, total, page, page_size, entity_types, filters } = usePage<{ props: Props }>() + .props as unknown as Props; + const { t } = useT(); + + const [state, setState] = useState({ + entityType: filters.entity_type ?? ALL, + action: filters.action ?? ALL, + userId: filters.user_id ?? '', + fromDate: filters.from_date ?? '', + toDate: filters.to_date ?? '', + }); + + function navigate(next: FilterState, nextPage = 1) { + const p: Record = {}; + if (next.entityType && next.entityType !== ALL) p.entity_type = next.entityType; + if (next.action && next.action !== ALL) p.action = next.action; + if (next.userId) p.user_id = next.userId; + if (next.fromDate) p.from_date = next.fromDate; + if (next.toDate) p.to_date = next.toDate; + if (nextPage > 1) p.page = String(nextPage); + if (page_size !== 50) p.page_size = String(page_size); + router.visit(`/audit_log?${new URLSearchParams(p).toString()}`); + } + + function handleClear() { + const cleared: FilterState = { + entityType: ALL, + action: ALL, + userId: '', + fromDate: '', + toDate: '', + }; + setState(cleared); + navigate(cleared); + } + + const totalPages = Math.ceil(total / page_size); + const from = total === 0 ? 0 : (page - 1) * page_size + 1; + const to = Math.min(page * page_size, total); + + return ( + <> + + + navigate(state)} + onClear={handleClear} + /> + + {items.length === 0 ? ( + + + + + {t(keys.audit_log.browse.empty_title)} + + {t(keys.audit_log.browse.empty_description)} + + + ) : ( + + + + + {t(keys.audit_log.table.timestamp)} + {t(keys.audit_log.table.action)} + {t(keys.audit_log.table.entity)} + + {t(keys.audit_log.table.user)} + + + {t(keys.audit_log.table.changes)} + + + + + {items.map((entry) => ( + + + {new Date(entry.created_at).toLocaleString()} + + + + {t(keys.audit_log.actions[entry.action])} + + + + {entry.entity_type} + + {entry.entity_id} + + + + {entry.user_id ?? t(keys.audit_log.changes.system_user)} + + + + + + ))} + + + + )} + + {totalPages > 1 && ( + + + {t(keys.audit_log.browse.showing, { from, to, total })} + + + navigate(state, page - 1)} + > + {t(keys.audit_log.browse.previous)} + + = totalPages} + onClick={() => navigate(state, page + 1)} + > + {t(keys.audit_log.browse.next)} + + + + )} + + > + ); +} + +Browse.layout = (page: React.ReactNode) => {page}; +export default Browse; diff --git a/modules/audit_log/audit_log/pages/components/FilterBar.tsx b/modules/audit_log/audit_log/pages/components/FilterBar.tsx new file mode 100644 index 00000000..b4b9daea --- /dev/null +++ b/modules/audit_log/audit_log/pages/components/FilterBar.tsx @@ -0,0 +1,126 @@ +import { keys, useT } from '@simple-module-py/i18n'; +import { Button } from '@simple-module-py/ui/components/ui/button'; +import { Card } from '@simple-module-py/ui/components/ui/card'; +import { Input } from '@simple-module-py/ui/components/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@simple-module-py/ui/components/ui/select'; + +export const ALL = '__all__'; +const ACTIONS = ['created', 'updated', 'deleted', 'soft_deleted'] as const; + +export interface FilterState { + entityType: string; + action: string; + userId: string; + fromDate: string; + toDate: string; +} + +interface FilterBarProps { + state: FilterState; + entity_types: string[]; + onChange: (next: FilterState) => void; + onSubmit: () => void; + onClear: () => void; +} + +export function FilterBar({ state, entity_types, onChange, onSubmit, onClear }: FilterBarProps) { + const { t } = useT(); + const set = (patch: Partial) => onChange({ ...state, ...patch }); + + return ( + + { + e.preventDefault(); + onSubmit(); + }} + > + + + {t(keys.audit_log.filters.entity_type_label)} + + set({ entityType: v })}> + + + + + {t(keys.audit_log.filters.entity_type_all)} + {entity_types.map((et) => ( + + {et} + + ))} + + + + + + {t(keys.audit_log.filters.action_label)} + + set({ action: v })}> + + + + + {t(keys.audit_log.filters.action_all)} + {ACTIONS.map((a) => ( + + {t(keys.audit_log.actions[a])} + + ))} + + + + + + {t(keys.audit_log.filters.user_label)} + + set({ userId: e.target.value })} + placeholder={t(keys.audit_log.filters.user_placeholder)} + className="h-8 text-sm" + /> + + + + {t(keys.audit_log.filters.from_date_label)} + + set({ fromDate: e.target.value })} + className="h-8 text-sm" + /> + + + + {t(keys.audit_log.filters.to_date_label)} + + set({ toDate: e.target.value })} + className="h-8 text-sm" + /> + + + {t(keys.audit_log.filters.apply)} + + + {t(keys.audit_log.filters.clear)} + + + + ); +} diff --git a/modules/audit_log/audit_log/py.typed b/modules/audit_log/audit_log/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/modules/audit_log/audit_log/service.py b/modules/audit_log/audit_log/service.py new file mode 100644 index 00000000..5a338c11 --- /dev/null +++ b/modules/audit_log/audit_log/service.py @@ -0,0 +1,66 @@ +"""Read-only query service for audit log entries.""" + +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from audit_log.constants import DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE +from audit_log.contracts.schemas import AuditEntryList, AuditEntryRead +from audit_log.models import AuditEntry + + +class AuditLogService: + def __init__(self, db: AsyncSession) -> None: + self.db = db + + async def list_entries( + self, + *, + entity_type: str | None = None, + entity_id: str | None = None, + action: str | None = None, + user_id: str | None = None, + from_date: datetime | None = None, + to_date: datetime | None = None, + page: int = 1, + page_size: int = DEFAULT_PAGE_SIZE, + ) -> AuditEntryList: + page_size = min(max(page_size, 1), MAX_PAGE_SIZE) + page = max(page, 1) + + base = select(AuditEntry) + + if entity_type: + base = base.where(AuditEntry.entity_type == entity_type) + if entity_id: + base = base.where(AuditEntry.entity_id == entity_id) + if action: + base = base.where(AuditEntry.action == action) + if user_id: + base = base.where(AuditEntry.user_id == user_id) + if from_date: + base = base.where(AuditEntry.created_at >= from_date) + if to_date: + base = base.where(AuditEntry.created_at <= to_date) + + total_result = await self.db.execute(select(func.count()).select_from(base.subquery())) + total = total_result.scalar_one() + + offset = (page - 1) * page_size + stmt = ( + base.order_by(AuditEntry.created_at.desc(), AuditEntry.id) + .offset(offset) + .limit(page_size) + ) + result = await self.db.execute(stmt) + items = [AuditEntryRead.model_validate(row) for row in result.scalars()] + + return AuditEntryList(items=items, total=total, page=page, page_size=page_size) + + async def distinct_entity_types(self) -> list[str]: + stmt = select(AuditEntry.entity_type).distinct().order_by(AuditEntry.entity_type) + result = await self.db.execute(stmt) + return list(result.scalars()) diff --git a/modules/audit_log/package.json b/modules/audit_log/package.json new file mode 100644 index 00000000..33d6fd3c --- /dev/null +++ b/modules/audit_log/package.json @@ -0,0 +1,16 @@ +{ + "name": "@simple-module-py/audit-log", + "version": "0.1.0", + "private": true, + "description": "Frontend assets for the Audit Log module", + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0", + "@inertiajs/react": "^2.0.0", + "@simple-module-py/ui": "*" + }, + "devDependencies": { + "@simple-module-py/tsconfig": "*" + }, + "dependencies": {} +} diff --git a/modules/audit_log/pyproject.toml b/modules/audit_log/pyproject.toml new file mode 100644 index 00000000..52fb8a5b --- /dev/null +++ b/modules/audit_log/pyproject.toml @@ -0,0 +1,51 @@ +[project] +name = "simple_module_audit_log" +version = "0.0.15" +description = "Automatic field-level audit trail for all SQLModel entities" +readme = "README.md" +license = "MIT" +license-files = ["LICENSE"] +requires-python = ">=3.12" +authors = [{ name = "Anto Subash", email = "antosubash@live.com" }] +keywords = ["simple-module", "audit-log", "change-tracking"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Framework :: FastAPI", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.12", + "Topic :: Internet :: WWW/HTTP", + "Topic :: Software Development :: Libraries :: Application Frameworks", + "Typing :: Typed", +] +dependencies = [ + "simple_module_core==0.0.15", + "simple_module_db==0.0.15", + "simple_module_hosting==0.0.15", +] + +[project.entry-points.simple_module] +audit_log = "audit_log.module:AuditLogModule" + +[project.urls] +Homepage = "https://github.com/antosubash/simple_module_python" +Repository = "https://github.com/antosubash/simple_module_python" +Issues = "https://github.com/antosubash/simple_module_python/issues" +Changelog = "https://github.com/antosubash/simple_module_python/blob/main/CHANGELOG.md" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["audit_log"] + +[tool.hatch.build.targets.wheel.force-include] +"package.json" = "audit_log/package.json" + +[tool.uv.sources] +simple_module_core = { workspace = true } +simple_module_db = { workspace = true } +simple_module_hosting = { workspace = true } diff --git a/modules/audit_log/tsconfig.json b/modules/audit_log/tsconfig.json new file mode 100644 index 00000000..b4b221a7 --- /dev/null +++ b/modules/audit_log/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@simple-module-py/tsconfig/base.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./audit_log/*"], + "@simple-module-py/ui/*": ["../../packages/ui/src/*"] + } + }, + "include": ["audit_log/**/*.ts", "audit_log/**/*.tsx"] +} diff --git a/package-lock.json b/package-lock.json index 2d546a1a..3cb8d63e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -94,6 +94,19 @@ "node": "^10 || ^12 || >=14" } }, + "modules/audit_log": { + "name": "@simple-module-py/audit-log", + "version": "0.1.0", + "devDependencies": { + "@simple-module-py/tsconfig": "*" + }, + "peerDependencies": { + "@inertiajs/react": "^2.0.0", + "@simple-module-py/ui": "*", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, "modules/auth": { "name": "@simple-module-py/auth", "version": "0.1.0", @@ -4505,6 +4518,10 @@ "dev": true, "license": "MIT" }, + "node_modules/@simple-module-py/audit-log": { + "resolved": "modules/audit_log", + "link": true + }, "node_modules/@simple-module-py/auth": { "resolved": "modules/auth", "link": true diff --git a/packages/i18n/src/generated-resources.ts b/packages/i18n/src/generated-resources.ts index 48d2b993..d2415120 100644 --- a/packages/i18n/src/generated-resources.ts +++ b/packages/i18n/src/generated-resources.ts @@ -2,6 +2,37 @@ // Regenerate by booting the host in development mode. export default { translation: { + 'audit_log.actions.created': '', + 'audit_log.actions.deleted': '', + 'audit_log.actions.soft_deleted': '', + 'audit_log.actions.updated': '', + 'audit_log.browse.description': '', + 'audit_log.browse.empty_description': '', + 'audit_log.browse.empty_title': '', + 'audit_log.browse.next': '', + 'audit_log.browse.previous': '', + 'audit_log.browse.showing': '', + 'audit_log.browse.title': '', + 'audit_log.changes.fields_set': '', + 'audit_log.changes.no_changes': '', + 'audit_log.changes.show_less': '', + 'audit_log.changes.show_more': '', + 'audit_log.changes.system_user': '', + 'audit_log.filters.action_all': '', + 'audit_log.filters.action_label': '', + 'audit_log.filters.apply': '', + 'audit_log.filters.clear': '', + 'audit_log.filters.entity_type_all': '', + 'audit_log.filters.entity_type_label': '', + 'audit_log.filters.from_date_label': '', + 'audit_log.filters.to_date_label': '', + 'audit_log.filters.user_label': '', + 'audit_log.filters.user_placeholder': '', + 'audit_log.table.action': '', + 'audit_log.table.changes': '', + 'audit_log.table.entity': '', + 'audit_log.table.timestamp': '', + 'audit_log.table.user': '', 'auth.errors.missing_permission': '', 'auth.errors.not_authenticated': '', 'background_tasks.detail.args': '', diff --git a/packages/i18n/src/keys.generated.ts b/packages/i18n/src/keys.generated.ts index d050834c..f2de105a 100644 --- a/packages/i18n/src/keys.generated.ts +++ b/packages/i18n/src/keys.generated.ts @@ -2,6 +2,49 @@ // Regenerate by booting the host in development mode. export const keys = { + audit_log: { + actions: { + created: 'audit_log.actions.created', + deleted: 'audit_log.actions.deleted', + soft_deleted: 'audit_log.actions.soft_deleted', + updated: 'audit_log.actions.updated', + }, + browse: { + description: 'audit_log.browse.description', + empty_description: 'audit_log.browse.empty_description', + empty_title: 'audit_log.browse.empty_title', + next: 'audit_log.browse.next', + previous: 'audit_log.browse.previous', + showing: 'audit_log.browse.showing', + title: 'audit_log.browse.title', + }, + changes: { + fields_set: 'audit_log.changes.fields_set', + no_changes: 'audit_log.changes.no_changes', + show_less: 'audit_log.changes.show_less', + show_more: 'audit_log.changes.show_more', + system_user: 'audit_log.changes.system_user', + }, + filters: { + action_all: 'audit_log.filters.action_all', + action_label: 'audit_log.filters.action_label', + apply: 'audit_log.filters.apply', + clear: 'audit_log.filters.clear', + entity_type_all: 'audit_log.filters.entity_type_all', + entity_type_label: 'audit_log.filters.entity_type_label', + from_date_label: 'audit_log.filters.from_date_label', + to_date_label: 'audit_log.filters.to_date_label', + user_label: 'audit_log.filters.user_label', + user_placeholder: 'audit_log.filters.user_placeholder', + }, + table: { + action: 'audit_log.table.action', + changes: 'audit_log.table.changes', + entity: 'audit_log.table.entity', + timestamp: 'audit_log.table.timestamp', + user: 'audit_log.table.user', + }, + }, auth: { errors: { missing_permission: 'auth.errors.missing_permission', diff --git a/pyproject.toml b/pyproject.toml index ceb8a21b..edfede6e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,6 +73,7 @@ extra-paths = [ "modules/settings", "modules/feature_flags", "modules/keycloak", + "modules/audit_log", "host", "scripts", ] @@ -108,7 +109,7 @@ invalid-assignment = "ignore" [tool.pytest.ini_options] asyncio_mode = "auto" -testpaths = ["framework/cli/tests", "framework/core/tests", "framework/db/tests", "framework/hosting/tests", "framework/testing/tests", "host/tests", "modules/auth/tests", "modules/dashboard/tests", "modules/users/tests", "modules/permissions/tests", "modules/background_tasks/tests", "modules/file_storage/tests", "modules/settings/tests", "modules/feature_flags/tests", "modules/keycloak/tests", "scripts/tests", "tests/integration", "tests/e2e", "tests/benchmarks"] +testpaths = ["framework/cli/tests", "framework/core/tests", "framework/db/tests", "framework/hosting/tests", "framework/testing/tests", "host/tests", "modules/auth/tests", "modules/dashboard/tests", "modules/users/tests", "modules/permissions/tests", "modules/background_tasks/tests", "modules/file_storage/tests", "modules/settings/tests", "modules/feature_flags/tests", "modules/keycloak/tests", "modules/audit_log/tests", "scripts/tests", "tests/integration", "tests/e2e", "tests/benchmarks"] markers = [ "e2e: end-to-end tests requiring a live browser", "perf: performance benchmarks (opt-in; run via `make bench`)", diff --git a/tests/e2e/test_audit_log_ui.py b/tests/e2e/test_audit_log_ui.py new file mode 100644 index 00000000..bf20d3f0 --- /dev/null +++ b/tests/e2e/test_audit_log_ui.py @@ -0,0 +1,103 @@ +"""E2E smoke test for the Audit Log admin UI. + +Drives a real browser to /audit_log, verifies the page renders with data +captured by the framework's audit listener, and confirms a freshly-created +Setting produces an audit entry with a resolved (non-empty) entity_id — +the regression test for the two-phase capture fix. +""" + +from __future__ import annotations + +import time + +import pytest +from playwright.sync_api import Page, expect + +pytestmark = pytest.mark.e2e + + +def _login(page: Page, username: str, password: str) -> None: + page.get_by_role("link", name="Log in").first.click() + page.locator("#email").fill(username) + page.locator("#password").fill(password) + page.get_by_role("button", name="Log in").click() + page.wait_for_url("**/dashboard/**", timeout=15_000) + + +def test_audit_log_renders_with_data(page: Page, e2e_username: str, e2e_password: str) -> None: + """The Audit Log page renders the filter bar, table headers, and at + least one audit entry (login itself produces a User update entry). + """ + page.goto("/") + _login(page, e2e_username, e2e_password) + + page.goto("/audit_log") + + expect(page.get_by_role("heading", name="Audit Log")).to_be_visible() + + expect(page.get_by_role("columnheader", name="Timestamp")).to_be_visible() + expect(page.get_by_role("columnheader", name="Action")).to_be_visible() + expect(page.get_by_role("columnheader", name="Entity")).to_be_visible() + + expect(page.get_by_role("button", name="Apply")).to_be_visible() + expect(page.get_by_role("button", name="Clear")).to_be_visible() + + rows = page.get_by_role("row") + expect(rows).not_to_have_count(1) + + +def test_audit_log_captures_integer_pk_setting( + page: Page, e2e_username: str, e2e_password: str, base_url: str +) -> None: + """Regression for BUG-002: creating a Setting (integer PK) produces an + audit entry with a resolved entity_id, not an empty string. + """ + page.goto("/") + _login(page, e2e_username, e2e_password) + + suffix = str(int(time.time() * 1000)) + setting_key = f"e2e.audit.intpk.{suffix}" + + create_resp = page.request.post( + f"{base_url}/api/settings/", + data={ + "scope": "system", + "scope_id": "", + "key": setting_key, + "value": "x", + "value_type": "string", + }, + ) + assert create_resp.ok, f"Setting create failed: {create_resp.status}" + setting_id = str(create_resp.json()["id"]) + assert setting_id and setting_id != "", "Setting must have a non-empty id" + + audit_resp = page.request.get( + f"{base_url}/api/audit_log/", + params={"entity_type": "Setting", "action": "created"}, + ) + assert audit_resp.ok, f"Audit API failed: {audit_resp.status}" + items = audit_resp.json()["items"] + + matching = [e for e in items if e["entity_id"] == setting_id] + assert len(matching) == 1, ( + f"Expected one audit entry with entity_id={setting_id}, " + f"got entity_ids={[e['entity_id'] for e in items[:5]]}" + ) + assert matching[0]["entity_id"] != "", "entity_id must not be empty" + + +def test_audit_log_filter_by_entity_type(page: Page, e2e_username: str, e2e_password: str) -> None: + """Selecting an entity type in the filter narrows the table.""" + page.goto("/") + _login(page, e2e_username, e2e_password) + + page.goto("/audit_log?entity_type=User&action=updated") + + expect(page.get_by_role("heading", name="Audit Log")).to_be_visible() + + cells = page.get_by_role("cell") + expect(cells.first).to_be_visible() + + user_cell_count = page.locator('css=td:has-text("User")').count() + assert user_cell_count > 0, "Expected at least one User row after filtering" diff --git a/tests/test_audit_log.py b/tests/test_audit_log.py new file mode 100644 index 00000000..4adbcb08 --- /dev/null +++ b/tests/test_audit_log.py @@ -0,0 +1,244 @@ +"""Integration tests for the audit_log module.""" + +from __future__ import annotations + +import httpx +import pytest + + +class TestAuditLogCapture: + """Verify audit entries are created when entities change.""" + + async def test_create_entity_produces_audit_entry( + self, authenticated_client: httpx.AsyncClient + ): + """Creating a setting should produce a 'created' audit entry.""" + await authenticated_client.post( + "/api/settings/", + json={ + "scope": "system", + "scope_id": "", + "key": "audit.test.key", + "value": "hello", + "value_type": "string", + }, + ) + + resp = await authenticated_client.get( + "/api/audit_log/", + params={"entity_type": "Setting"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["total"] >= 1 + created_entries = [i for i in data["items"] if i["action"] == "created"] + assert len(created_entries) >= 1 + entry = created_entries[0] + assert entry["entity_type"] == "Setting" + assert any(c["field"] == "key" for c in entry["changes"]) + + async def test_update_entity_produces_diff(self, authenticated_client: httpx.AsyncClient): + """Updating a setting should record old/new values.""" + create_resp = await authenticated_client.post( + "/api/settings/", + json={ + "scope": "system", + "scope_id": "", + "key": "audit.update.test", + "value": "before", + "value_type": "string", + }, + ) + setting_id = create_resp.json()["id"] + + await authenticated_client.put( + f"/api/settings/{setting_id}", + json={"value": "after"}, + ) + + resp = await authenticated_client.get( + "/api/audit_log/", + params={"entity_type": "Setting", "action": "updated"}, + ) + assert resp.status_code == 200 + data = resp.json() + update_entries = [i for i in data["items"] if i["action"] == "updated"] + assert len(update_entries) >= 1 + changes = update_entries[0]["changes"] + value_change = next((c for c in changes if c["field"] == "value"), None) + assert value_change is not None + assert value_change["old"] == "before" + assert value_change["new"] == "after" + + async def test_created_entity_has_resolved_int_pk_in_audit( + self, authenticated_client: httpx.AsyncClient + ): + """Settings use integer PKs; audit entry should record the actual ID, not ''. + + Regression test for BUG-002: previously the audit record was captured + in ``before_flush`` where the integer PK was still ``None``, so + ``entity_id`` ended up as an empty string. The two-phase capture + resolves the PK in ``after_flush_postexec`` instead. + """ + create_resp = await authenticated_client.post( + "/api/settings/", + json={ + "scope": "system", + "scope_id": "", + "key": "audit.intpk.test", + "value": "x", + "value_type": "string", + }, + ) + setting_id = str(create_resp.json()["id"]) + + resp = await authenticated_client.get( + "/api/audit_log/", + params={"entity_type": "Setting", "action": "created"}, + ) + data = resp.json() + # Find the audit entry for the setting we just created + matching = [e for e in data["items"] if e["entity_id"] == setting_id] + assert len(matching) == 1, ( + f"Expected entity_id={setting_id}, got entries: " + f"{[e['entity_id'] for e in data['items']]}" + ) + assert matching[0]["entity_id"] != "", "entity_id should not be empty" + + async def test_delete_entity_produces_audit_entry( + self, authenticated_client: httpx.AsyncClient + ): + """Deleting a setting should produce a 'deleted' entry.""" + create_resp = await authenticated_client.post( + "/api/settings/", + json={ + "scope": "system", + "scope_id": "", + "key": "audit.delete.test", + "value": "gone", + "value_type": "string", + }, + ) + setting_id = create_resp.json()["id"] + + await authenticated_client.delete(f"/api/settings/{setting_id}") + + resp = await authenticated_client.get( + "/api/audit_log/", + params={"entity_type": "Setting"}, + ) + assert resp.status_code == 200 + data = resp.json() + delete_entries = [i for i in data["items"] if i["action"] in ("deleted", "soft_deleted")] + assert len(delete_entries) >= 1 + + +class TestAuditLogAPI: + """Verify the audit log REST API filtering and pagination.""" + + async def test_filter_by_action(self, authenticated_client: httpx.AsyncClient): + # Ensure at least one "created" entry exists + await authenticated_client.post( + "/api/settings/", + json={ + "scope": "system", + "scope_id": "", + "key": "filter.action.test", + "value": "test", + "value_type": "string", + }, + ) + resp = await authenticated_client.get( + "/api/audit_log/", + params={"action": "created"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert len(data["items"]) > 0, "Expected at least one 'created' audit entry" + for item in data["items"]: + assert item["action"] == "created" + + async def test_pagination(self, authenticated_client: httpx.AsyncClient): + resp = await authenticated_client.get( + "/api/audit_log/", + params={"page": 1, "page_size": 2}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["page"] == 1 + assert data["page_size"] == 2 + assert len(data["items"]) <= 2 + + async def test_unauthenticated_returns_redirect(self, client: httpx.AsyncClient): + resp = await client.get("/api/audit_log/", follow_redirects=False) + assert resp.status_code in (302, 303, 401, 403) + + +class TestAuditLogViewInvalidParams: + """BUG-001: Invalid query params on view routes must not return raw JSON errors.""" + + @pytest.mark.parametrize( + "params", + [ + {"page": "abc"}, + {"page_size": "0"}, + {"page": "-1"}, + {"page_size": "-5"}, + {"page": "abc", "page_size": "xyz"}, + {"page_size": "999"}, + ], + ids=[ + "non-integer-page", + "zero-page-size", + "negative-page", + "negative-page-size", + "both-non-integer", + "page-size-over-max", + ], + ) + async def test_invalid_pagination_returns_html( + self, authenticated_client: httpx.AsyncClient, params: dict[str, str] + ): + """View endpoint should clamp bad pagination values, never 422.""" + resp = await authenticated_client.get( + "/audit_log/", + params=params, + follow_redirects=False, + ) + # Should succeed (200 full-page) — not a 422 validation error. + assert resp.status_code == 200, ( + f"Expected 200 for params {params}, got {resp.status_code}: {resp.text[:300]}" + ) + + async def test_api_still_rejects_invalid_params(self, authenticated_client: httpx.AsyncClient): + """API endpoint should still return 422 for invalid pagination.""" + resp = await authenticated_client.get( + "/api/audit_log/", + params={"page_size": "0"}, + ) + assert resp.status_code == 422 + + +class TestAuditLogRecursionGuard: + """Verify that AuditEntry writes don't trigger more audit entries.""" + + async def test_no_infinite_recursion(self, authenticated_client: httpx.AsyncClient): + """Creating a setting should not cause exponential audit entries.""" + await authenticated_client.post( + "/api/settings/", + json={ + "scope": "system", + "scope_id": "", + "key": "recursion.guard.test", + "value": "ok", + "value_type": "string", + }, + ) + + resp = await authenticated_client.get( + "/api/audit_log/", + params={"entity_type": "AuditEntry"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 0, "AuditEntry should not audit itself"
+ {t(keys.audit_log.browse.showing, { + from, + to, + total, + })} +
{t(keys.audit_log.browse.empty_description)}