4 Commits
v0.1.6 ... main

Author SHA1 Message Date
5e4f84a789 intermittent commit 2026-07-14 13:22:10 +02:00
d3d2c60d7d Release v0.1.8 2026-07-11 16:49:00 +02:00
7ffb16d981 Release v0.1.7 2026-07-11 02:34:56 +02:00
ae70cac70f Add audit command and outbox foundations 2026-07-11 00:46:09 +02:00
16 changed files with 934 additions and 144 deletions

View File

@@ -1,11 +1,27 @@
# GovOPlaN Audit
`govoplan-audit` owns audit API route contributions during the GovOPlaN module
split.
<!-- govoplan-repository-type:start -->
**Repository type:** module (platform).
<!-- govoplan-repository-type:end -->
`govoplan-audit` owns audit API route contributions and audit administration
WebUI sections during the GovOPlaN module split.
This repository owns the live `audit_log` table, audit API route
contributions, and the target boundary for future audit sink/export capability
work.
contributions, the `@govoplan/audit-webui` package, and the target boundary
for future audit sink/export capability work.
The WebUI package contributes the `system-audit` and `tenant-audit` admin
sections through the shared `admin.sections` UI capability. The admin shell
does not render audit panels unless this module is installed and enabled.
It also owns the audit command/event separation and production delivery
foundation:
- `govoplan_audit.backend.commands` defines an in-process command bus for
requested work.
- `govoplan_audit.backend.outbox` persists governed platform events in
`audit_outbox_events` and dispatches pending rows with retry metadata.
See [docs/AUDIT_TRACE_CONTEXT.md](docs/AUDIT_TRACE_CONTEXT.md) for the standard
operational context fields used by admin, installer, and module lifecycle audit

View File

@@ -80,3 +80,26 @@ acceptance events should include:
This keeps admin UI timelines, audit exports, and rollback diagnostics aligned
without coupling modules to the audit table implementation.
## Commands, Events, And Outbox Delivery
Commands and events are separate concepts:
- Commands are imperative requests to do work, for example `retention.run` or
`module.install`. They use `govoplan_audit.backend.commands.AuditCommand`
and `CommandBus`.
- Events are completed facts, for example `tenant.created` or
`retention_policy.run`. They use the core `PlatformEvent` envelope and may
be written to the audit outbox before delivery.
`govoplan_audit.backend.outbox.SqlAuditOutbox` persists platform events in
`audit_outbox_events`. Dispatchers can later call
`dispatch_pending_platform_events()` to publish pending events and record retry
state. The outbox payload stores the full governed event envelope:
correlation/causation ids, actor, tenant, subject, resource, classification,
module id, event id, type, and payload.
Application code should enqueue or publish facts only after the state change
they describe is known. Long-running operators and installers should model
requested work as commands first, then emit facts as events as each step
completes.

32
package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "@govoplan/audit-webui",
"version": "0.1.8",
"private": true,
"type": "module",
"main": "webui/src/index.ts",
"module": "webui/src/index.ts",
"types": "webui/src/index.ts",
"exports": {
".": {
"types": "./webui/src/index.ts",
"import": "./webui/src/index.ts"
}
},
"files": [
"webui/src",
"README.md",
"LICENSE"
],
"peerDependencies": {
"@govoplan/core-webui": "^0.1.8",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
}

View File

@@ -4,14 +4,13 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-audit"
version = "0.1.6"
version = "0.1.8"
description = "GovOPlaN audit platform module."
readme = "README.md"
requires-python = ">=3.12"
authors = [{ name = "GovOPlaN" }]
dependencies = [
"govoplan-core>=0.1.6",
"govoplan-access>=0.1.6",
"govoplan-core>=0.1.8",
]
[tool.setuptools.packages.find]
@@ -22,4 +21,3 @@ govoplan_audit = ["py.typed"]
[project.entry-points."govoplan.modules"]
audit = "govoplan_audit.backend.manifest:get_manifest"

View File

@@ -1,6 +1,8 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import and_, false, func, or_
@@ -23,6 +25,20 @@ router = APIRouter(tags=["audit"])
AUDIT_ADMIN_CURSOR_SCOPE = "audit.admin"
@dataclass(slots=True)
class AuditAdminQueryContext:
query: Any
access_admin: AccessAdministration
effective_scope: str
resolved_tenant_id: str | None
sort_column: Any
order: Any
total: int
effective_page_size: int
pages: int
fingerprint: str
def _access_administration() -> AccessAdministration:
registry = get_registry()
if registry is None or not registry.has_capability(CAPABILITY_ACCESS_ADMINISTRATION):
@@ -286,26 +302,23 @@ def _audit_cursor_condition(sort_column, *, sort_by: str, sort_direction: str, c
return or_(primary_after, and_(sort_column == sort_value, AuditLog.id < cursor_id))
@router.get("/admin/audit", response_model=AuditAdminListResponse)
def list_admin_audit(
tenant_id: str | None = Query(default=None),
all_tenants: bool = Query(default=False),
audit_scope: str | None = Query(default=None, alias="scope"),
limit: int = Query(default=100, ge=1, le=500),
offset: int = Query(default=0, ge=0),
page: int | None = Query(default=None, ge=1),
page_size: int | None = Query(default=None, ge=1, le=500),
cursor: str | None = Query(default=None),
sort_by: str = Query(default="time"),
sort_direction: str = Query(default="desc"),
filter_time: str | None = Query(default=None),
filter_actor: str | None = Query(default=None),
filter_action: str | None = Query(default=None),
filter_object: str | None = Query(default=None),
filter_tenant: str | None = Query(default=None),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope("audit:read", "system:audit:read")),
):
def _prepare_audit_admin_query(
session: Session,
principal: ApiPrincipal,
*,
tenant_id: str | None,
all_tenants: bool,
audit_scope: str | None,
limit: int,
page_size: int | None,
sort_by: str,
sort_direction: str,
filter_time: str | None,
filter_actor: str | None,
filter_action: str | None,
filter_object: str | None,
filter_tenant: str | None,
) -> AuditAdminQueryContext:
effective_scope = audit_scope or ("all" if all_tenants else "tenant")
if effective_scope not in {"tenant", "system", "all"}:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Audit scope must be tenant, system or all.")
@@ -333,14 +346,13 @@ def list_admin_audit(
object_text = func.coalesce(AuditLog.object_type, "") + " " + func.coalesce(AuditLog.object_id, "")
access_admin = _access_administration()
filters = [
for condition in (
_audit_time_filter(filter_time),
_audit_actor_filter(access_admin, session, filter_actor),
_audit_text_filter(AuditLog.action, filter_action),
_audit_text_filter(object_text, filter_object),
_audit_text_filter(AuditLog.tenant_id, filter_tenant),
]
for condition in filters:
):
if condition is not None:
query = query.filter(condition)
@@ -353,7 +365,6 @@ def list_admin_audit(
}
sort_column = sort_columns[sort_by]
order = sort_column.asc() if sort_direction == "asc" else sort_column.desc()
ordered_query = query.order_by(order, AuditLog.id.desc())
total = query.count()
effective_page_size = page_size or limit
pages = max(1, (total + effective_page_size - 1) // effective_page_size)
@@ -372,47 +383,100 @@ def list_admin_audit(
sort_direction=sort_direction,
filters=filters,
)
return AuditAdminQueryContext(
query=query,
access_admin=access_admin,
effective_scope=effective_scope,
resolved_tenant_id=resolved_tenant_id,
sort_column=sort_column,
order=order,
total=total,
effective_page_size=effective_page_size,
pages=pages,
fingerprint=fingerprint,
)
@router.get("/admin/audit", response_model=AuditAdminListResponse)
def list_admin_audit(
tenant_id: str | None = Query(default=None),
all_tenants: bool = Query(default=False),
audit_scope: str | None = Query(default=None, alias="scope"),
limit: int = Query(default=100, ge=1, le=500),
offset: int = Query(default=0, ge=0),
page: int | None = Query(default=None, ge=1),
page_size: int | None = Query(default=None, ge=1, le=500),
cursor: str | None = Query(default=None),
sort_by: str = Query(default="time"),
sort_direction: str = Query(default="desc"),
filter_time: str | None = Query(default=None),
filter_actor: str | None = Query(default=None),
filter_action: str | None = Query(default=None),
filter_object: str | None = Query(default=None),
filter_tenant: str | None = Query(default=None),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope("audit:read", "system:audit:read")),
):
context = _prepare_audit_admin_query(
session,
principal,
tenant_id=tenant_id,
all_tenants=all_tenants,
audit_scope=audit_scope,
limit=limit,
page_size=page_size,
sort_by=sort_by,
sort_direction=sort_direction,
filter_time=filter_time,
filter_actor=filter_actor,
filter_action=filter_action,
filter_object=filter_object,
filter_tenant=filter_tenant,
)
ordered_query = context.query.order_by(context.order, AuditLog.id.desc())
start_cursor: str | None = None
if cursor:
try:
cursor_values = decode_keyset_cursor(AUDIT_ADMIN_CURSOR_SCOPE, cursor, fingerprint=fingerprint)
cursor_values = decode_keyset_cursor(AUDIT_ADMIN_CURSOR_SCOPE, cursor, fingerprint=context.fingerprint)
if cursor_values is None:
raise KeysetCursorError("Invalid pagination cursor")
page_query = query.filter(_audit_cursor_condition(sort_column, sort_by=sort_by, sort_direction=sort_direction, cursor_values=cursor_values))
page_query = context.query.filter(
_audit_cursor_condition(context.sort_column, sort_by=sort_by, sort_direction=sort_direction, cursor_values=cursor_values)
)
except KeysetCursorError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
effective_page = page or (offset // effective_page_size + 1)
effective_page = page or (offset // context.effective_page_size + 1)
effective_offset = 0
start_cursor = cursor
else:
if page is not None or page_size is not None:
effective_page = min(page or 1, pages)
effective_offset = (effective_page - 1) * effective_page_size
effective_page = min(page or 1, context.pages)
effective_offset = (effective_page - 1) * context.effective_page_size
else:
effective_page = offset // effective_page_size + 1
effective_page = offset // context.effective_page_size + 1
effective_offset = offset
page_query = query
page_query = context.query
if effective_offset > 0:
previous_row = ordered_query.offset(effective_offset - 1).limit(1).first()
if previous_row is not None:
start_cursor = _audit_cursor_for_row(previous_row, sort_by=sort_by, sort_direction=sort_direction, fingerprint=fingerprint)
start_cursor = _audit_cursor_for_row(previous_row, sort_by=sort_by, sort_direction=sort_direction, fingerprint=context.fingerprint)
rows_plus_one = page_query.order_by(order, AuditLog.id.desc()).offset(effective_offset).limit(effective_page_size + 1).all()
rows = rows_plus_one[:effective_page_size]
rows_plus_one = page_query.order_by(context.order, AuditLog.id.desc()).offset(effective_offset).limit(context.effective_page_size + 1).all()
rows = rows_plus_one[:context.effective_page_size]
next_cursor = (
_audit_cursor_for_row(rows[-1], sort_by=sort_by, sort_direction=sort_direction, fingerprint=fingerprint)
if len(rows_plus_one) > effective_page_size and rows else None
_audit_cursor_for_row(rows[-1], sort_by=sort_by, sort_direction=sort_direction, fingerprint=context.fingerprint)
if len(rows_plus_one) > context.effective_page_size and rows else None
)
return AuditAdminListResponse(
total=total,
total=context.total,
page=effective_page,
page_size=effective_page_size,
pages=pages,
page_size=context.effective_page_size,
pages=context.pages,
cursor=start_cursor,
next_cursor=next_cursor,
items=_audit_items(session, rows, access_admin),
items=_audit_items(session, rows, context.access_admin),
)
@@ -435,150 +499,103 @@ def list_admin_audit_delta(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope("audit:read", "system:audit:read")),
):
effective_scope = audit_scope or ("all" if all_tenants else "tenant")
if effective_scope not in {"tenant", "system", "all"}:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Audit scope must be tenant, system or all.")
if sort_by not in {"time", "actor", "action", "object", "tenant"}:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Unsupported audit sort column.")
if sort_direction not in {"asc", "desc"}:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Audit sort direction must be asc or desc.")
query = session.query(AuditLog)
resolved_tenant_id: str | None = None
if effective_scope != "all":
query = query.filter(AuditLog.scope == effective_scope)
if effective_scope == "system":
if not has_scope(principal, "system:audit:read"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Missing scope: system:audit:read")
elif effective_scope == "all" or all_tenants:
if not has_scope(principal, "system:audit:read"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Missing scope: system:audit:read")
else:
if not has_scope(principal, "audit:read"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Missing scope: audit:read")
tenant = _resolve_tenant(session, principal, tenant_id)
resolved_tenant_id = tenant.id
query = query.filter(AuditLog.tenant_id == tenant.id)
object_text = func.coalesce(AuditLog.object_type, "") + " " + func.coalesce(AuditLog.object_id, "")
access_admin = _access_administration()
filters = [
_audit_time_filter(filter_time),
_audit_actor_filter(access_admin, session, filter_actor),
_audit_text_filter(AuditLog.action, filter_action),
_audit_text_filter(object_text, filter_object),
_audit_text_filter(AuditLog.tenant_id, filter_tenant),
]
for condition in filters:
if condition is not None:
query = query.filter(condition)
sort_columns = {
"time": AuditLog.created_at,
"actor": func.coalesce(AuditLog.user_id, "System"),
"action": AuditLog.action,
"object": object_text,
"tenant": func.coalesce(AuditLog.tenant_id, ""),
}
sort_column = sort_columns[sort_by]
order = sort_column.asc() if sort_direction == "asc" else sort_column.desc()
total = query.count()
effective_page_size = page_size or limit
pages = max(1, (total + effective_page_size - 1) // effective_page_size)
filters = _audit_filter_params(
context = _prepare_audit_admin_query(
session,
principal,
tenant_id=tenant_id,
all_tenants=all_tenants,
audit_scope=audit_scope,
limit=limit,
page_size=page_size,
sort_by=sort_by,
sort_direction=sort_direction,
filter_time=filter_time,
filter_actor=filter_actor,
filter_action=filter_action,
filter_object=filter_object,
filter_tenant=filter_tenant,
)
fingerprint = _audit_cursor_fingerprint(
effective_scope=effective_scope,
tenant_id=resolved_tenant_id,
page_size=effective_page_size,
sort_by=sort_by,
sort_direction=sort_direction,
filters=filters,
)
start_cursor: str | None = None
page_query = query
page_query = context.query
if cursor:
try:
cursor_values = decode_keyset_cursor(AUDIT_ADMIN_CURSOR_SCOPE, cursor, fingerprint=fingerprint)
cursor_values = decode_keyset_cursor(AUDIT_ADMIN_CURSOR_SCOPE, cursor, fingerprint=context.fingerprint)
if cursor_values is None:
raise KeysetCursorError("Invalid pagination cursor")
page_query = query.filter(_audit_cursor_condition(sort_column, sort_by=sort_by, sort_direction=sort_direction, cursor_values=cursor_values))
page_query = context.query.filter(
_audit_cursor_condition(context.sort_column, sort_by=sort_by, sort_direction=sort_direction, cursor_values=cursor_values)
)
start_cursor = cursor
except KeysetCursorError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
if since is None:
rows_plus_one = page_query.order_by(order, AuditLog.id.desc()).limit(effective_page_size + 1).all()
rows = rows_plus_one[:effective_page_size]
rows_plus_one = page_query.order_by(context.order, AuditLog.id.desc()).limit(context.effective_page_size + 1).all()
rows = rows_plus_one[:context.effective_page_size]
next_cursor = (
_audit_cursor_for_row(rows[-1], sort_by=sort_by, sort_direction=sort_direction, fingerprint=fingerprint)
if len(rows_plus_one) > effective_page_size and rows else None
_audit_cursor_for_row(rows[-1], sort_by=sort_by, sort_direction=sort_direction, fingerprint=context.fingerprint)
if len(rows_plus_one) > context.effective_page_size and rows else None
)
return AuditAdminDeltaResponse(
total=total,
total=context.total,
page=1,
page_size=effective_page_size,
pages=pages,
page_size=context.effective_page_size,
pages=context.pages,
cursor=start_cursor,
next_cursor=next_cursor,
items=_audit_items(session, rows, access_admin),
items=_audit_items(session, rows, context.access_admin),
deleted=[],
watermark=_audit_delta_watermark(session, effective_scope=effective_scope, tenant_id=resolved_tenant_id),
watermark=_audit_delta_watermark(session, effective_scope=context.effective_scope, tenant_id=context.resolved_tenant_id),
has_more=False,
full=True,
)
entries, has_more = _audit_delta_entries(
session,
effective_scope=effective_scope,
tenant_id=resolved_tenant_id,
effective_scope=context.effective_scope,
tenant_id=context.resolved_tenant_id,
since=since,
limit=effective_page_size,
limit=context.effective_page_size,
)
if entries is None:
rows_plus_one = page_query.order_by(order, AuditLog.id.desc()).limit(effective_page_size + 1).all()
rows = rows_plus_one[:effective_page_size]
rows_plus_one = page_query.order_by(context.order, AuditLog.id.desc()).limit(context.effective_page_size + 1).all()
rows = rows_plus_one[:context.effective_page_size]
next_cursor = (
_audit_cursor_for_row(rows[-1], sort_by=sort_by, sort_direction=sort_direction, fingerprint=fingerprint)
if len(rows_plus_one) > effective_page_size and rows else None
_audit_cursor_for_row(rows[-1], sort_by=sort_by, sort_direction=sort_direction, fingerprint=context.fingerprint)
if len(rows_plus_one) > context.effective_page_size and rows else None
)
return AuditAdminDeltaResponse(
total=total,
total=context.total,
page=1,
page_size=effective_page_size,
pages=pages,
page_size=context.effective_page_size,
pages=context.pages,
cursor=start_cursor,
next_cursor=next_cursor,
items=_audit_items(session, rows, access_admin),
items=_audit_items(session, rows, context.access_admin),
deleted=[],
watermark=_audit_delta_watermark(session, effective_scope=effective_scope, tenant_id=resolved_tenant_id),
watermark=_audit_delta_watermark(session, effective_scope=context.effective_scope, tenant_id=context.resolved_tenant_id),
has_more=False,
full=True,
)
changed_ids = [entry.resource_id for entry in entries if entry.resource_type == "audit_log"]
rows = (
page_query.filter(AuditLog.id.in_(changed_ids)).order_by(order, AuditLog.id.desc()).limit(effective_page_size).all()
page_query.filter(AuditLog.id.in_(changed_ids)).order_by(context.order, AuditLog.id.desc()).limit(context.effective_page_size).all()
if changed_ids else []
)
return AuditAdminDeltaResponse(
total=total,
total=context.total,
page=1,
page_size=effective_page_size,
pages=pages,
page_size=context.effective_page_size,
pages=context.pages,
cursor=start_cursor,
next_cursor=None,
items=_audit_items(session, rows, access_admin),
items=_audit_items(session, rows, context.access_admin),
deleted=[],
watermark=_audit_delta_response_watermark(
session,
effective_scope=effective_scope,
tenant_id=resolved_tenant_id,
effective_scope=context.effective_scope,
tenant_id=context.resolved_tenant_id,
entries=entries,
has_more=has_more,
),

View File

@@ -0,0 +1,53 @@
from __future__ import annotations
from collections import defaultdict
from collections.abc import Callable, Mapping
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
import uuid
def new_command_id() -> str:
return str(uuid.uuid4())
@dataclass(frozen=True, slots=True)
class AuditCommand:
type: str
module_id: str
payload: Mapping[str, Any] = field(default_factory=dict)
command_id: str = field(default_factory=new_command_id)
requested_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
requested_by: str | None = None
correlation_id: str | None = None
causation_id: str | None = None
def to_dict(self) -> dict[str, Any]:
return {
"type": self.type,
"module_id": self.module_id,
"payload": dict(self.payload),
"command_id": self.command_id,
"requested_at": self.requested_at.isoformat(),
"requested_by": self.requested_by,
"correlation_id": self.correlation_id,
"causation_id": self.causation_id,
}
CommandHandler = Callable[[AuditCommand], None]
class CommandBus:
def __init__(self) -> None:
self._handlers: dict[str, list[CommandHandler]] = defaultdict(list)
def subscribe(self, command_type: str, handler: CommandHandler) -> None:
self._handlers[command_type].append(handler)
def dispatch(self, command: AuditCommand) -> None:
for handler in self._handlers.get(command.type, ()):
handler(command)
for handler in self._handlers.get("*", ()):
handler(command)

View File

@@ -3,7 +3,9 @@ from __future__ import annotations
import uuid
from typing import Any
from sqlalchemy import ForeignKey, Index, JSON, String
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from govoplan_core.db.base import Base, TimestampMixin
@@ -31,4 +33,28 @@ class AuditLog(Base, TimestampMixin):
details: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
__all__ = ["AuditLog", "new_uuid"]
class AuditOutboxEvent(Base, TimestampMixin):
__tablename__ = "audit_outbox_events"
__table_args__ = (
UniqueConstraint("event_id", name="uq_audit_outbox_events_event_id"),
Index("ix_audit_outbox_events_status_next_attempt_at", "status", "next_attempt_at"),
Index("ix_audit_outbox_events_event_type", "event_type"),
Index("ix_audit_outbox_events_correlation_id", "correlation_id"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
event_id: Mapped[str] = mapped_column(String(36), nullable=False)
event_type: Mapped[str] = mapped_column(String(200), nullable=False)
module_id: Mapped[str] = mapped_column(String(100), nullable=False)
correlation_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
causation_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
classification: Mapped[str] = mapped_column(String(40), nullable=False, default="internal")
payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending", index=True)
attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
next_attempt_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
dispatched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
__all__ = ["AuditLog", "AuditOutboxEvent", "new_uuid"]

View File

@@ -8,7 +8,7 @@ from govoplan_core.core.access import (
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
)
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
from govoplan_core.core.modules import MigrationSpec, ModuleContext, ModuleManifest
from govoplan_core.core.modules import FrontendModule, MigrationSpec, ModuleContext, ModuleManifest
from govoplan_core.db.base import Base
@@ -36,18 +36,22 @@ def _audit_retention(context: ModuleContext):
manifest = ModuleManifest(
id="audit",
name="Audit",
version="0.1.6",
version="0.1.8",
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
route_factory=_route_factory,
frontend=FrontendModule(
module_id="audit",
package_name="@govoplan/audit-webui",
),
migration_spec=MigrationSpec(
module_id="audit",
metadata=Base.metadata,
retirement_supported=True,
retirement_provider=drop_table_retirement_provider(audit_models.AuditLog, label="Audit"),
retirement_provider=drop_table_retirement_provider(audit_models.AuditLog, audit_models.AuditOutboxEvent, label="Audit"),
retirement_notes="Destructive retirement drops audit-owned database tables after the installer captures a database snapshot.",
),
uninstall_guard_providers=(
persistent_table_uninstall_guard(audit_models.AuditLog, label="Audit"),
persistent_table_uninstall_guard(audit_models.AuditLog, audit_models.AuditOutboxEvent, label="Audit"),
),
capability_factories={
CAPABILITY_AUDIT_RECORDER: _audit_recorder,

View File

@@ -0,0 +1,157 @@
from __future__ import annotations
from collections.abc import Callable, Mapping
from datetime import datetime, timedelta, timezone
from typing import Any, cast
from sqlalchemy import or_
from sqlalchemy.orm import Session
from govoplan_audit.backend.db.models import AuditOutboxEvent
from govoplan_core.core.events import (
EventActorRef,
EventClassification,
EventObjectRef,
EventTenantRef,
PlatformEvent,
ensure_event_trace,
publish_platform_event,
)
EventDispatcher = Callable[[PlatformEvent], None]
class SqlAuditOutbox:
def enqueue(self, session: object, event: PlatformEvent) -> AuditOutboxEvent:
db = _session(session)
traced = ensure_event_trace(event)
item = AuditOutboxEvent(
event_id=traced.event_id,
event_type=traced.type,
module_id=traced.module_id,
correlation_id=traced.correlation_id,
causation_id=traced.causation_id,
classification=traced.classification,
payload=traced.to_dict(),
status="pending",
)
db.add(item)
db.flush()
return item
def dispatch_pending(
self,
session: object,
*,
dispatcher: EventDispatcher = publish_platform_event,
limit: int = 100,
) -> dict[str, int]:
db = _session(session)
now = datetime.now(timezone.utc)
rows = (
db.query(AuditOutboxEvent)
.filter(
AuditOutboxEvent.status.in_(("pending", "failed")),
or_(AuditOutboxEvent.next_attempt_at.is_(None), AuditOutboxEvent.next_attempt_at <= now),
)
.order_by(AuditOutboxEvent.created_at.asc(), AuditOutboxEvent.id.asc())
.limit(max(1, min(int(limit), 500)))
.all()
)
counts = {"selected": len(rows), "dispatched": 0, "failed": 0}
for row in rows:
try:
dispatcher(_event_from_payload(row.payload))
except Exception as exc: # noqa: BLE001 - dispatcher errors must be retained for retry/diagnostics.
row.status = "failed"
row.attempts += 1
row.last_error = str(exc)
row.next_attempt_at = now + _retry_delay(row.attempts)
counts["failed"] += 1
continue
row.status = "dispatched"
row.attempts += 1
row.dispatched_at = now
row.next_attempt_at = None
row.last_error = None
counts["dispatched"] += 1
db.flush()
return counts
def enqueue_platform_event(session: object, event: PlatformEvent) -> AuditOutboxEvent:
return SqlAuditOutbox().enqueue(session, event)
def dispatch_pending_platform_events(
session: object,
*,
dispatcher: EventDispatcher = publish_platform_event,
limit: int = 100,
) -> dict[str, int]:
return SqlAuditOutbox().dispatch_pending(session, dispatcher=dispatcher, limit=limit)
def _retry_delay(attempts: int) -> timedelta:
seconds = min(300, max(1, 2 ** max(0, attempts - 1)))
return timedelta(seconds=seconds)
def _event_from_payload(payload: Mapping[str, Any]) -> PlatformEvent:
return PlatformEvent(
type=str(payload["type"]),
module_id=str(payload["module_id"]),
payload=_mapping(payload.get("payload")),
occurred_at=_datetime(payload.get("occurred_at")),
event_id=str(payload["event_id"]),
correlation_id=_optional_str(payload.get("correlation_id")),
causation_id=_optional_str(payload.get("causation_id")),
actor=_actor_ref(payload.get("actor")),
tenant=_tenant_ref(payload.get("tenant")),
subject=_object_ref(payload.get("subject")),
resource=_object_ref(payload.get("resource")),
classification=cast(EventClassification, str(payload.get("classification") or "internal")),
)
def _session(session: object) -> Session:
if not isinstance(session, Session):
raise TypeError("Audit outbox requires a SQLAlchemy Session")
return session
def _mapping(value: object) -> dict[str, Any]:
return dict(value) if isinstance(value, Mapping) else {}
def _optional_str(value: object) -> str | None:
return str(value) if value is not None else None
def _datetime(value: object) -> datetime:
if isinstance(value, datetime):
return value
if isinstance(value, str):
return datetime.fromisoformat(value)
return datetime.now(timezone.utc)
def _actor_ref(value: object) -> EventActorRef | None:
data = _mapping(value)
if not data:
return None
return EventActorRef(type=str(data["type"]), id=_optional_str(data.get("id")), label=_optional_str(data.get("label")))
def _tenant_ref(value: object) -> EventTenantRef | None:
data = _mapping(value)
if not data:
return None
return EventTenantRef(id=str(data["id"]), slug=_optional_str(data.get("slug")), label=_optional_str(data.get("label")))
def _object_ref(value: object) -> EventObjectRef | None:
data = _mapping(value)
if not data:
return None
return EventObjectRef(type=str(data["type"]), id=_optional_str(data.get("id")), label=_optional_str(data.get("label")))

View File

@@ -0,0 +1,83 @@
from __future__ import annotations
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from govoplan_audit.backend.commands import AuditCommand, CommandBus
from govoplan_audit.backend.db.models import AuditOutboxEvent
from govoplan_audit.backend.outbox import SqlAuditOutbox
from govoplan_core.core.events import EventActorRef, PlatformEvent
from govoplan_core.db.base import Base
class AuditCommandBusTests(unittest.TestCase):
def test_command_bus_dispatches_commands_separately_from_events(self) -> None:
bus = CommandBus()
seen: list[AuditCommand] = []
wildcard: list[AuditCommand] = []
bus.subscribe("retention.run", seen.append)
bus.subscribe("*", wildcard.append)
command = AuditCommand(type="retention.run", module_id="policy", payload={"dry_run": True})
bus.dispatch(command)
self.assertEqual([command], seen)
self.assertEqual([command], wildcard)
self.assertEqual("retention.run", command.to_dict()["type"])
class AuditOutboxTests(unittest.TestCase):
def test_outbox_enqueues_governed_event_and_dispatches_pending_rows(self) -> None:
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(bind=engine, tables=[AuditOutboxEvent.__table__])
Session = sessionmaker(bind=engine)
outbox = SqlAuditOutbox()
seen: list[PlatformEvent] = []
with Session() as session:
event = PlatformEvent(
type="tenant.created",
module_id="tenancy",
payload={"tenant_id": "tenant-1"},
actor=EventActorRef(type="user", id="user-1"),
)
row = outbox.enqueue(session, event)
self.assertEqual("pending", row.status)
self.assertEqual("tenant.created", row.event_type)
self.assertEqual(event.event_id, row.event_id)
self.assertEqual(event.event_id, row.correlation_id)
self.assertEqual("user-1", row.payload["actor"]["id"])
counts = outbox.dispatch_pending(session, dispatcher=seen.append)
self.assertEqual({"selected": 1, "dispatched": 1, "failed": 0}, counts)
self.assertEqual(1, len(seen))
self.assertEqual("tenant.created", seen[0].type)
self.assertEqual("dispatched", row.status)
self.assertEqual(1, row.attempts)
self.assertIsNotNone(row.dispatched_at)
def test_outbox_records_failed_dispatch_for_retry(self) -> None:
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(bind=engine, tables=[AuditOutboxEvent.__table__])
Session = sessionmaker(bind=engine)
outbox = SqlAuditOutbox()
with Session() as session:
row = outbox.enqueue(session, PlatformEvent(type="demo.failed", module_id="audit"))
counts = outbox.dispatch_pending(session, dispatcher=lambda event: (_ for _ in ()).throw(RuntimeError("offline")))
self.assertEqual({"selected": 1, "dispatched": 0, "failed": 1}, counts)
self.assertEqual("failed", row.status)
self.assertEqual(1, row.attempts)
self.assertEqual("offline", row.last_error)
self.assertIsNotNone(row.next_attempt_at)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,30 @@
from __future__ import annotations
import pathlib
import tomllib
import unittest
ROOT = pathlib.Path(__file__).resolve().parents[1]
class AuditModuleContractTests(unittest.TestCase):
def test_audit_package_does_not_hard_require_access(self) -> None:
project = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))["project"]
dependencies = tuple(project["dependencies"])
self.assertIn("govoplan-core>=0.1.8", dependencies)
self.assertFalse(any(item.startswith("govoplan-access") for item in dependencies))
def test_audit_source_does_not_import_access_implementation(self) -> None:
offenders: list[str] = []
for path in (ROOT / "src" / "govoplan_audit").rglob("*.py"):
source = path.read_text(encoding="utf-8")
if "govoplan_access" in source:
offenders.append(str(path.relative_to(ROOT)))
self.assertEqual([], offenders)
if __name__ == "__main__":
unittest.main()

27
webui/package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "@govoplan/audit-webui",
"version": "0.1.8",
"private": true,
"type": "module",
"main": "src/index.ts",
"module": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
}
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.8",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
}

74
webui/src/api/audit.ts Normal file
View File

@@ -0,0 +1,74 @@
import { apiFetch, type ApiSettings, type DeltaDeletedItem } from "@govoplan/core-webui";
export type AuditAdminItem = {
id: string;
scope: "tenant" | "system";
tenant_id?: string | null;
actor_email?: string | null;
action: string;
object_type?: string | null;
object_id?: string | null;
details: Record<string, unknown>;
created_at: string;
};
export type AuditSortBy = "time" | "actor" | "action" | "object" | "tenant";
export type AuditQueryOptions = {
tenantId?: string | null;
allTenants?: boolean;
scope?: "tenant" | "system";
limit?: number;
offset?: number;
page?: number;
pageSize?: number;
cursor?: string | null;
sortBy?: AuditSortBy;
sortDirection?: "asc" | "desc";
filters?: Partial<Record<AuditSortBy, string>>;
};
export type AuditAdminListResponse = {
items: AuditAdminItem[];
total: number;
page: number;
page_size: number;
pages: number;
cursor?: string | null;
next_cursor?: string | null;
};
export type AuditAdminDeltaResponse = AuditAdminListResponse & {
deleted: DeltaDeletedItem[];
watermark?: string | null;
has_more: boolean;
full: boolean;
};
function auditQuery(options: AuditQueryOptions & { since?: string | null } = {}): string {
const params = new URLSearchParams();
if (options.tenantId) params.set("tenant_id", options.tenantId);
if (options.allTenants) params.set("all_tenants", "true");
if (options.scope) params.set("scope", options.scope);
if (options.limit) params.set("limit", String(options.limit));
if (options.offset) params.set("offset", String(options.offset));
if (options.page) params.set("page", String(options.page));
if (options.pageSize) params.set("page_size", String(options.pageSize));
if (options.cursor) params.set("cursor", options.cursor);
if (options.sortBy) params.set("sort_by", options.sortBy);
if (options.sortDirection) params.set("sort_direction", options.sortDirection);
if (options.since) params.set("since", options.since);
for (const [column, value] of Object.entries(options.filters ?? {})) {
if (value?.trim()) params.set(`filter_${column}`, value);
}
const suffix = params.toString();
return suffix ? `?${suffix}` : "";
}
export function fetchAdminAudit(settings: ApiSettings, options: AuditQueryOptions = {}): Promise<AuditAdminListResponse> {
return apiFetch(settings, `/api/v1/admin/audit${auditQuery(options)}`);
}
export function fetchAdminAuditDelta(settings: ApiSettings, options: AuditQueryOptions & { since?: string | null } = {}): Promise<AuditAdminDeltaResponse> {
return apiFetch(settings, `/api/v1/admin/audit/delta${auditQuery(options)}`);
}

View File

@@ -0,0 +1,200 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Search } from "lucide-react";
import {
AdminIconButton,
AdminPageLayout,
adminErrorMessage,
Button,
DataGrid,
Dialog,
formatAdminDateTime as formatDateTime,
mergeDeltaRows,
useDeltaWatermarks,
type ApiSettings,
type AuthInfo,
type DataGridColumn,
type DataGridQueryState
} from "@govoplan/core-webui";
import { fetchAdminAudit, fetchAdminAuditDelta, type AuditAdminItem, type AuditSortBy } from "../../api/audit";
type Props = {
settings: ApiSettings;
auth: AuthInfo;
systemMode?: boolean;
};
const DEFAULT_QUERY: DataGridQueryState = {
sort: { columnId: "time", direction: "desc" },
filters: {}
};
export default function AdminAuditPanel({ settings, auth, systemMode = false }: Props) {
const [items, setItems] = useState<AuditAdminItem[]>([]);
const itemsRef = useRef<AuditAdminItem[]>([]);
const pageItemsRef = useRef<Record<string, AuditAdminItem[]>>({});
const pageCursorsRef = useRef<Record<number, string | null>>({ 1: null });
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [query, setQuery] = useState<DataGridQueryState>(DEFAULT_QUERY);
const [selected, setSelected] = useState<AuditAdminItem | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [reloadToken, setReloadToken] = useState(0);
const tenantId = (auth.active_tenant ?? auth.tenant).id;
const load = useCallback(async () => {
setLoading(true);
setError("");
try {
const sortColumn = query.sort?.columnId;
const sortBy = sortColumn && ["time", "actor", "action", "object", "tenant"].includes(sortColumn)
? sortColumn as AuditSortBy
: "time";
const sortDirection = query.sort?.direction ?? "desc";
const filters = query.filters;
const pageCursor = page === 1 ? null : pageCursorsRef.current[page];
const deltaMode = page === 1 || pageCursor !== undefined;
const requestOptions = {
scope: systemMode ? "system" as const : "tenant" as const,
page,
pageSize,
cursor: pageCursor,
sortBy,
sortDirection,
filters
};
const deltaKey = `audit:${systemMode ? "system" : "tenant"}:${tenantId}:${pageSize}:${page}:${pageCursor ?? "root"}:${JSON.stringify({ sortBy, sortDirection, filters })}`;
const response = deltaMode
? await fetchAdminAuditDelta(settings, { ...requestOptions, since: getDeltaWatermark(deltaKey) })
: await fetchAdminAudit(settings, requestOptions);
const baseItems = pageItemsRef.current[deltaKey] ?? [];
const nextItems = "full" in response && !response.full
? mergeDeltaRows(baseItems, response.items, response.deleted, (item) => item.id, { sort: compareAuditEvents(sortBy, sortDirection) }).slice(0, pageSize)
: response.items;
pageItemsRef.current[deltaKey] = nextItems;
itemsRef.current = nextItems;
setItems(nextItems);
setTotal(response.total);
if (!deltaMode && response.page !== page) setPage(response.page);
if (response.cursor !== undefined) pageCursorsRef.current[page] = response.cursor ?? null;
if (response.next_cursor !== undefined) {
if (response.next_cursor) pageCursorsRef.current[page + 1] = response.next_cursor;
else delete pageCursorsRef.current[page + 1];
}
if ("full" in response && !response.full && page === 1 && (response.items.length > 0 || response.deleted.length > 0)) {
pageCursorsRef.current = { 1: null };
}
if ("watermark" in response) setDeltaWatermark(deltaKey, response.watermark);
if (!deltaMode) resetDeltaWatermark(deltaKey);
} catch (err) {
setError(adminErrorMessage(err));
} finally {
setLoading(false);
}
}, [settings.accessToken, settings.apiBaseUrl, settings.apiKey, systemMode, tenantId, page, pageSize, query, reloadToken, getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark]);
useEffect(() => {
itemsRef.current = [];
pageItemsRef.current = {};
pageCursorsRef.current = { 1: null };
resetDeltaWatermark();
}, [settings.accessToken, settings.apiBaseUrl, settings.apiKey, systemMode, tenantId, pageSize, query, resetDeltaWatermark]);
useEffect(() => { void load(); }, [load]);
const handleQueryChange = useCallback((next: DataGridQueryState) => {
setQuery((current) => {
if (JSON.stringify(current) === JSON.stringify(next)) return current;
setPage(1);
return next;
});
}, []);
const columns = useMemo<DataGridColumn<AuditAdminItem>[]>(() => [
{ id: "time", header: "Time", width: 190, minWidth: 150, maxWidth: 260, resizable: true, sticky: "start", sortable: true, filterable: true, filterType: "date", value: (row) => row.created_at, render: (row) => formatDateTime(row.created_at) },
{ id: "actor", header: "Actor", width: 220, minWidth: 170, maxWidth: 360, resizable: true, sortable: true, filterable: true, value: (row) => row.actor_email || "System" },
{ id: "action", header: "Action", width: 250, minWidth: 170, maxWidth: 420, resizable: true, sortable: true, filterable: true, value: (row) => row.action },
{ id: "object", header: "Object", width: 300, minWidth: 180, maxWidth: 640, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => `${row.object_type || "-"} ${row.object_id || ""}`.trim() },
...(systemMode ? [{ id: "tenant", header: "Tenant context", width: 190, minWidth: 150, maxWidth: 300, resizable: true, sortable: true, filterable: true, value: (row: AuditAdminItem) => row.tenant_id || "-" }] : []),
{ id: "actions", header: "Actions", width: 70, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions"><AdminIconButton label="Inspect audit event" icon={<Search />} onClick={() => setSelected(row)} /></div> }
], [systemMode]);
const firstShown = total === 0 ? 0 : (page - 1) * pageSize + 1;
const lastShown = Math.min(total, page * pageSize);
const pageDescription = systemMode
? `System-level administrative history, showing ${firstShown}-${lastShown} of ${total}.`
: `Tenant-level administrative history for the active tenant, showing ${firstShown}-${lastShown} of ${total}.`;
return (
<>
<AdminPageLayout
title={systemMode ? "System audit" : "Tenant audit"}
description={pageDescription}
loading={loading}
error={error}
actions={<Button onClick={() => setReloadToken((value) => value + 1)} disabled={loading}>Reload</Button>}>
<div className="admin-table-surface">
<DataGrid
id={systemMode ? "admin-system-audit-v6" : "admin-tenant-audit-v6"}
rows={items}
columns={columns}
initialFit="container"
getRowKey={(row) => row.id}
emptyText="No administrative audit records found."
className="admin-audit-grid"
initialSort={{ columnId: "time", direction: "desc" }}
pagination={{
mode: "server",
page,
pageSize,
totalRows: total,
pageSizeOptions: [10, 25, 50, 100, 250],
disabled: loading,
onPageChange: setPage,
onPageSizeChange: (next) => { setPageSize(next); setPage(1); }
}}
onQueryChange={handleQueryChange}
/>
</div>
</AdminPageLayout>
<Dialog open={Boolean(selected)} title="Audit event details" onClose={() => setSelected(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setSelected(null)}>Close</Button>}>
{selected && (
<>
<dl className="admin-details-grid">
<div><dt>Scope</dt><dd>{selected.scope}</dd></div>
<div><dt>Action</dt><dd>{selected.action}</dd></div>
<div><dt>Actor</dt><dd>{selected.actor_email || "System"}</dd></div>
<div><dt>Object</dt><dd>{selected.object_type || "-"} {selected.object_id || ""}</dd></div>
<div><dt>Tenant context</dt><dd>{selected.tenant_id || "-"}</dd></div>
<div><dt>Time</dt><dd>{formatDateTime(selected.created_at)}</dd></div>
</dl>
<pre className="admin-json-preview">{JSON.stringify(selected.details, null, 2)}</pre>
</>
)}
</Dialog>
</>
);
}
function compareAuditEvents(sortBy: AuditSortBy, sortDirection: "asc" | "desc"): (left: AuditAdminItem, right: AuditAdminItem) => number {
return (left, right) => {
const primary = compareAuditValues(auditSortValue(left, sortBy), auditSortValue(right, sortBy));
const directed = sortDirection === "asc" ? primary : -primary;
return directed || right.id.localeCompare(left.id);
};
}
function auditSortValue(item: AuditAdminItem, sortBy: AuditSortBy): string | number {
if (sortBy === "time") return new Date(item.created_at).getTime();
if (sortBy === "actor") return item.actor_email || "System";
if (sortBy === "action") return item.action;
if (sortBy === "object") return `${item.object_type || ""} ${item.object_id || ""}`;
return item.tenant_id || "";
}
function compareAuditValues(left: string | number, right: string | number): number {
if (typeof left === "number" && typeof right === "number") return left - right;
return String(left).localeCompare(String(right));
}

5
webui/src/index.ts Normal file
View File

@@ -0,0 +1,5 @@
export { default } from "./module";
export * from "./module";
export * from "./api/audit";
export { default as AdminAuditPanel } from "./features/audit/AdminAuditPanel";
export type { PlatformWebModule } from "@govoplan/core-webui";

45
webui/src/module.ts Normal file
View File

@@ -0,0 +1,45 @@
import { createElement, lazy } from "react";
import { type AdminSectionsUiCapability, type PlatformWebModule } from "@govoplan/core-webui";
const AdminAuditPanel = lazy(() => import("./features/audit/AdminAuditPanel"));
const auditAdminSections: AdminSectionsUiCapability = {
sections: [
{
id: "system-audit",
label: "Audit",
group: "SYSTEM",
order: 90,
allOf: ["system:audit:read"],
render: ({ settings, auth }) => createElement(AdminAuditPanel, {
settings,
auth,
systemMode: true
})
},
{
id: "tenant-audit",
label: "Audit",
group: "TENANT",
order: 100,
allOf: ["audit:read"],
render: ({ settings, auth }) => createElement(AdminAuditPanel, {
settings,
auth,
systemMode: false
})
}
]
};
export const auditModule: PlatformWebModule = {
id: "audit",
label: "Audit",
version: "0.1.6",
dependencies: ["access", "admin"],
uiCapabilities: {
"admin.sections": auditAdminSections
}
};
export default auditModule;