6 Commits
v0.1.8 ... main

9 changed files with 192 additions and 154 deletions

View File

@@ -18,7 +18,7 @@
"LICENSE" "LICENSE"
], ],
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.8", "@govoplan/core-webui": "^0.1.9",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",

View File

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

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from typing import Any, Literal from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator from pydantic import BaseModel, ConfigDict, Field
from govoplan_core.api.v1.schemas import DeltaDeletedItem from govoplan_core.api.v1.schemas import DeltaDeletedItem

View File

@@ -9,6 +9,7 @@ from govoplan_core.core.access import (
) )
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
from govoplan_core.core.modules import FrontendModule, MigrationSpec, ModuleContext, ModuleManifest from govoplan_core.core.modules import FrontendModule, MigrationSpec, ModuleContext, ModuleManifest
from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
@@ -42,6 +43,10 @@ manifest = ModuleManifest(
frontend=FrontendModule( frontend=FrontendModule(
module_id="audit", module_id="audit",
package_name="@govoplan/audit-webui", package_name="@govoplan/audit-webui",
view_surfaces=(
ViewSurface(id="audit.admin.system", module_id="audit", kind="section", label="System audit", order=90),
ViewSurface(id="audit.admin.tenant", module_id="audit", kind="section", label="Tenant audit", order=100),
),
), ),
migration_spec=MigrationSpec( migration_spec=MigrationSpec(
module_id="audit", module_id="audit",

View File

@@ -32,6 +32,7 @@ class AuditCommandBusTests(unittest.TestCase):
class AuditOutboxTests(unittest.TestCase): class AuditOutboxTests(unittest.TestCase):
def test_outbox_enqueues_governed_event_and_dispatches_pending_rows(self) -> None: def test_outbox_enqueues_governed_event_and_dispatches_pending_rows(self) -> None:
engine = create_engine("sqlite:///:memory:") engine = create_engine("sqlite:///:memory:")
self.addCleanup(engine.dispose)
Base.metadata.create_all(bind=engine, tables=[AuditOutboxEvent.__table__]) Base.metadata.create_all(bind=engine, tables=[AuditOutboxEvent.__table__])
Session = sessionmaker(bind=engine) Session = sessionmaker(bind=engine)
outbox = SqlAuditOutbox() outbox = SqlAuditOutbox()
@@ -63,6 +64,7 @@ class AuditOutboxTests(unittest.TestCase):
def test_outbox_records_failed_dispatch_for_retry(self) -> None: def test_outbox_records_failed_dispatch_for_retry(self) -> None:
engine = create_engine("sqlite:///:memory:") engine = create_engine("sqlite:///:memory:")
self.addCleanup(engine.dispose)
Base.metadata.create_all(bind=engine, tables=[AuditOutboxEvent.__table__]) Base.metadata.create_all(bind=engine, tables=[AuditOutboxEvent.__table__])
Session = sessionmaker(bind=engine) Session = sessionmaker(bind=engine)
outbox = SqlAuditOutbox() outbox = SqlAuditOutbox()

View File

@@ -13,7 +13,7 @@ class AuditModuleContractTests(unittest.TestCase):
project = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))["project"] project = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))["project"]
dependencies = tuple(project["dependencies"]) dependencies = tuple(project["dependencies"])
self.assertIn("govoplan-core>=0.1.6", dependencies) self.assertIn("govoplan-core>=0.1.8", dependencies)
self.assertFalse(any(item.startswith("govoplan-access") for item in dependencies)) self.assertFalse(any(item.startswith("govoplan-access") for item in dependencies))
def test_audit_source_does_not_import_access_implementation(self) -> None: def test_audit_source_does_not_import_access_implementation(self) -> None:

View File

@@ -13,7 +13,7 @@
} }
}, },
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.8", "@govoplan/core-webui": "^0.1.9",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",

View File

@@ -1,7 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Search } from "lucide-react"; import { Search } from "lucide-react";
import { import {
AdminIconButton,
AdminPageLayout, AdminPageLayout,
adminErrorMessage, adminErrorMessage,
Button, Button,
@@ -9,6 +8,7 @@ import {
Dialog, Dialog,
formatAdminDateTime as formatDateTime, formatAdminDateTime as formatDateTime,
mergeDeltaRows, mergeDeltaRows,
TableActionGroup,
useDeltaWatermarks, useDeltaWatermarks,
type ApiSettings, type ApiSettings,
type AuthInfo, type AuthInfo,
@@ -118,7 +118,7 @@ export default function AdminAuditPanel({ settings, auth, systemMode = false }:
{ id: "action", header: "Action", width: 250, minWidth: 170, maxWidth: 420, resizable: true, sortable: true, filterable: true, value: (row) => row.action }, { 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() }, { 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 || "-" }] : []), ...(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> } { id: "actions", header: "Actions", width: 70, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[{ id: "inspect", label: "Inspect audit event", icon: <Search />, onClick: () => setSelected(row) }]} /> }
], [systemMode]); ], [systemMode]);
const firstShown = total === 0 ? 0 : (page - 1) * pageSize + 1; const firstShown = total === 0 ? 0 : (page - 1) * pageSize + 1;

View File

@@ -7,6 +7,7 @@ const auditAdminSections: AdminSectionsUiCapability = {
sections: [ sections: [
{ {
id: "system-audit", id: "system-audit",
surfaceId: "audit.admin.system",
label: "Audit", label: "Audit",
group: "SYSTEM", group: "SYSTEM",
order: 90, order: 90,
@@ -19,6 +20,7 @@ const auditAdminSections: AdminSectionsUiCapability = {
}, },
{ {
id: "tenant-audit", id: "tenant-audit",
surfaceId: "audit.admin.tenant",
label: "Audit", label: "Audit",
group: "TENANT", group: "TENANT",
order: 100, order: 100,
@@ -37,6 +39,10 @@ export const auditModule: PlatformWebModule = {
label: "Audit", label: "Audit",
version: "0.1.6", version: "0.1.6",
dependencies: ["access", "admin"], dependencies: ["access", "admin"],
viewSurfaces: [
{ id: "audit.admin.system", moduleId: "audit", kind: "section", label: "System audit", order: 90 },
{ id: "audit.admin.tenant", moduleId: "audit", kind: "section", label: "Tenant audit", order: 100 }
],
uiCapabilities: { uiCapabilities: {
"admin.sections": auditAdminSections "admin.sections": auditAdminSections
} }