chore: sync GovOPlaN module split state

This commit is contained in:
2026-07-10 12:51:16 +02:00
parent 22943c72d9
commit 920f38e4c7
30 changed files with 533 additions and 78 deletions

23
.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
__pycache__/
*.py[cod]
*.egg-info/
.pytest_cache/
.mypy_cache/
.ruff_cache/
.venv/
build/
dist/
node_modules/
webui/node_modules/
webui/dist/
*.tsbuildinfo
.component-test-build/
.module-test-build/
.policy-test-build/
.template-preview-test-build/
.import-test-build/
webui/.component-test-build/
webui/.module-test-build/
webui/.policy-test-build/
webui/.template-preview-test-build/
webui/.import-test-build/

View File

@@ -3,6 +3,10 @@
`govoplan-audit` owns audit API route contributions during the GovOPlaN module `govoplan-audit` owns audit API route contributions during the GovOPlaN module
split. split.
This repository owns the live `audit_log` table while preserving the This repository owns the live `audit_log` table, audit API route
historical table name. It provides audit API route contributions and is the contributions, and the target boundary for future audit sink/export capability
target boundary for future audit sink/export capability work. work.
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
entries.

View File

@@ -0,0 +1,82 @@
# Audit Trace Context
Operational audit entries should let an administrator answer four questions:
- who initiated the operation
- what object was changed
- which lifecycle request or run carried the change
- how the entry connects to surrounding request, installer, or worker logs
The actor, tenant, scope, action, object type, and object ID remain first-class
`audit_log` columns. Additional operational context belongs in `details`.
## Standard Fields
Use these fields for admin, module lifecycle, installer, and worker operations:
- `module_id`: module affected by the operation, when a single module is the
target.
- `request_id`: queued installer/admin request identifier.
- `run_id`: installer/worker run identifier.
- `outcome`: compact state such as `planned`, `queued`, `applied`,
`cancelled`, `failed`, or `completed`.
- `_trace.correlation_id`: request or workflow correlation ID.
- `_trace.causation_id`: event, run, or operation that caused this audit entry.
Bulk operations may also include concise arrays such as `activated`,
`deactivated`, `mounted`, `planned_modules`, or sanitized `items`.
## Access Provenance
Access-sensitive events should include a compact provenance array when the
decision depends on semantic access state. Use `details.access_provenance` with
items shaped like `AccessDecisionProvenance.to_dict()` from
`govoplan_core.core.access`.
Expected provenance kinds:
- `identity`
- `account`
- `tenant_membership`
- `organization_unit`
- `function`
- `group`
- `role`
- `right`
- `delegation`
- `policy`
- `system_actor`
Delegation and acting-in-place events must show both the real actor account and
the represented/delegated account or function assignment. Feature modules
should obtain this shape through `access.explanation` instead of inspecting
access tables directly.
## Redaction
Audit detail payloads must not contain credentials, tokens, raw cookies,
authorization headers, private keys, message bodies, uploaded file content, or
other secret material. Use stable IDs, vault references, or package identifiers
instead.
The compatibility helper in core, `audit_operation_context`, keeps the standard
fields compact and applies the shared audit redaction pass to additional detail
values. Feature modules should follow the same shape even when they later write
through a dedicated audit sink capability.
## Module Lifecycle Events
Module install, uninstall, enable, disable, rollback, and package-catalog
acceptance events should include:
- `object_type`: `module_install_plan`, `module_install_request`,
`module_state`, or another stable lifecycle object type.
- `object_id`: module ID for single-module actions, otherwise `global` or the
request/run ID.
- `details.module_id`, `details.request_id`, or `details.run_id` when present.
- `details.outcome` with the lifecycle result.
- `details._trace` when the operation originated from a request or queued
daemon action.
This keeps admin UI timelines, audit exports, and rollback diagnostics aligned
without coupling modules to the audit table implementation.

View File

@@ -1,18 +0,0 @@
Metadata-Version: 2.4
Name: govoplan-audit
Version: 0.1.4
Summary: GovOPlaN audit platform module.
Author: GovOPlaN
Requires-Python: >=3.12
Description-Content-Type: text/markdown
Requires-Dist: govoplan-core>=0.1.4
Requires-Dist: govoplan-access>=0.1.4
# GovOPlaN Audit
`govoplan-audit` owns audit API route contributions during the GovOPlaN module
split.
This repository owns the live `audit_log` table while preserving the
historical table name. It provides audit API route contributions and is the
target boundary for future audit sink/export capability work.

View File

@@ -1,18 +0,0 @@
README.md
pyproject.toml
src/govoplan_audit/__init__.py
src/govoplan_audit/py.typed
src/govoplan_audit.egg-info/PKG-INFO
src/govoplan_audit.egg-info/SOURCES.txt
src/govoplan_audit.egg-info/dependency_links.txt
src/govoplan_audit.egg-info/entry_points.txt
src/govoplan_audit.egg-info/requires.txt
src/govoplan_audit.egg-info/top_level.txt
src/govoplan_audit/backend/__init__.py
src/govoplan_audit/backend/manifest.py
src/govoplan_audit/backend/api/__init__.py
src/govoplan_audit/backend/api/v1/__init__.py
src/govoplan_audit/backend/api/v1/routes.py
src/govoplan_audit/backend/api/v1/schemas.py
src/govoplan_audit/backend/db/__init__.py
src/govoplan_audit/backend/db/models.py

View File

@@ -1,2 +0,0 @@
[govoplan.modules]
audit = govoplan_audit.backend.manifest:get_manifest

View File

@@ -1,2 +0,0 @@
govoplan-core>=0.1.4
govoplan-access>=0.1.4

View File

@@ -1 +0,0 @@
govoplan_audit

View File

@@ -3,20 +3,25 @@ from __future__ import annotations
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import false, func, or_ from sqlalchemy import and_, false, func, or_
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_access.backend.auth.dependencies import ApiPrincipal, has_scope, require_any_scope from govoplan_access.auth import ApiPrincipal, has_scope, require_any_scope
from govoplan_audit.backend.db.models import AuditLog from govoplan_audit.backend.db.models import AuditLog
from govoplan_core.audit.logging import AUDIT_MODULE_ID, AUDIT_SYSTEM_EVENTS_COLLECTION, AUDIT_TENANT_EVENTS_COLLECTION
from govoplan_core.core.access import CAPABILITY_ACCESS_ADMINISTRATION, AccessAdministration from govoplan_core.core.access import CAPABILITY_ACCESS_ADMINISTRATION, AccessAdministration
from govoplan_core.core.change_sequence import decode_sequence_watermark, encode_sequence_watermark, max_sequence_id, sequence_entries_since, sequence_watermark_is_expired
from govoplan_core.core.pagination import KeysetCursorError, decode_keyset_cursor, encode_keyset_cursor, keyset_query_fingerprint
from govoplan_core.core.runtime import get_registry from govoplan_core.core.runtime import get_registry
from govoplan_core.db.session import get_session from govoplan_core.db.session import get_session
from govoplan_tenancy.backend.db.models import Tenant from govoplan_tenancy.backend.db.models import Tenant
from .schemas import AuditAdminItem, AuditAdminListResponse, AuditLogItemResponse, AuditLogListResponse from .schemas import AuditAdminDeltaResponse, AuditAdminItem, AuditAdminListResponse, AuditLogItemResponse, AuditLogListResponse
router = APIRouter(tags=["audit"]) router = APIRouter(tags=["audit"])
AUDIT_ADMIN_CURSOR_SCOPE = "audit.admin"
def _access_administration() -> AccessAdministration: def _access_administration() -> AccessAdministration:
registry = get_registry() registry = get_registry()
@@ -91,7 +96,7 @@ def _audit_time_filter(raw: str | None):
try: try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Invalid audit date filter.") from exc raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Invalid audit date filter.") from exc
if parsed.tzinfo is None: if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc) parsed = parsed.replace(tzinfo=timezone.utc)
else: else:
@@ -111,6 +116,176 @@ def _audit_time_filter(raw: str | None):
return AuditLog.created_at == parsed return AuditLog.created_at == parsed
def _audit_delta_collections(effective_scope: str) -> tuple[str, ...]:
if effective_scope == "system":
return (AUDIT_SYSTEM_EVENTS_COLLECTION,)
if effective_scope == "tenant":
return (AUDIT_TENANT_EVENTS_COLLECTION,)
return (AUDIT_TENANT_EVENTS_COLLECTION, AUDIT_SYSTEM_EVENTS_COLLECTION)
def _audit_delta_watermark(session: Session, *, effective_scope: str, tenant_id: str | None) -> str:
return encode_sequence_watermark(
max_sequence_id(
session,
tenant_id=tenant_id if effective_scope == "tenant" else None,
module_id=AUDIT_MODULE_ID,
collections=_audit_delta_collections(effective_scope),
)
)
def _audit_delta_entries(
session: Session,
*,
effective_scope: str,
tenant_id: str | None,
since: str,
limit: int,
):
try:
since_sequence = decode_sequence_watermark(since)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
scoped_tenant_id = tenant_id if effective_scope == "tenant" else None
collections = _audit_delta_collections(effective_scope)
if sequence_watermark_is_expired(
session,
since=since_sequence,
tenant_id=scoped_tenant_id,
module_id=AUDIT_MODULE_ID,
collections=collections,
):
return None, False
entries_plus_one = sequence_entries_since(
session,
since=since_sequence,
tenant_id=scoped_tenant_id,
module_id=AUDIT_MODULE_ID,
collections=collections,
limit=limit + 1,
)
has_more = len(entries_plus_one) > limit
return entries_plus_one[:limit], has_more
def _audit_delta_response_watermark(
session: Session,
*,
effective_scope: str,
tenant_id: str | None,
entries,
has_more: bool,
) -> str:
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 _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})
return [
AuditAdminItem(
id=row.id,
scope=row.scope,
tenant_id=row.tenant_id,
actor_email=actor_email_by_user_id.get(row.user_id) if row.user_id else None,
action=row.action,
object_type=row.object_type,
object_id=row.object_id,
details=row.details or {},
created_at=row.created_at,
)
for row in rows
]
def _audit_filter_params(
*,
filter_time: str | None,
filter_actor: str | None,
filter_action: str | None,
filter_object: str | None,
filter_tenant: str | None,
) -> dict[str, str]:
return {
"time": filter_time or "",
"actor": filter_actor or "",
"action": filter_action or "",
"object": filter_object or "",
"tenant": filter_tenant or "",
}
def _audit_cursor_fingerprint(
*,
effective_scope: str,
tenant_id: str | None,
page_size: int,
sort_by: str,
sort_direction: str,
filters: dict[str, str],
) -> str:
return keyset_query_fingerprint(
AUDIT_ADMIN_CURSOR_SCOPE,
{
"scope": effective_scope,
"tenant_id": tenant_id or "",
"page_size": page_size,
"sort_by": sort_by,
"sort_direction": sort_direction,
"filters": filters,
},
)
def _audit_sort_value(row: AuditLog, sort_by: str):
if sort_by == "time":
return row.created_at
if sort_by == "actor":
return row.user_id or "System"
if sort_by == "action":
return row.action
if sort_by == "object":
return f"{row.object_type or ''} {row.object_id or ''}"
if sort_by == "tenant":
return row.tenant_id or ""
raise KeysetCursorError("Unsupported audit sort column")
def _audit_cursor_for_row(row: AuditLog, *, sort_by: str, sort_direction: str, fingerprint: str) -> str:
return encode_keyset_cursor(
AUDIT_ADMIN_CURSOR_SCOPE,
fingerprint=fingerprint,
values={
"id": row.id,
"sort_by": sort_by,
"sort_direction": sort_direction,
"sort_value": _audit_sort_value(row, sort_by),
},
)
def _audit_decode_sort_value(sort_by: str, value):
if sort_by == "time":
if not isinstance(value, str):
raise KeysetCursorError("Invalid pagination cursor")
try:
return datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError as exc:
raise KeysetCursorError("Invalid pagination cursor") from exc
if value is None:
return ""
return str(value)
def _audit_cursor_condition(sort_column, *, sort_by: str, sort_direction: str, cursor_values: dict[str, object]):
cursor_id = cursor_values.get("id")
if not isinstance(cursor_id, str) or not cursor_id:
raise KeysetCursorError("Invalid pagination cursor")
sort_value = _audit_decode_sort_value(sort_by, cursor_values.get("sort_value"))
primary_after = sort_column > sort_value if sort_direction == "asc" else sort_column < sort_value
return or_(primary_after, and_(sort_column == sort_value, AuditLog.id < cursor_id))
@router.get("/admin/audit", response_model=AuditAdminListResponse) @router.get("/admin/audit", response_model=AuditAdminListResponse)
def list_admin_audit( def list_admin_audit(
tenant_id: str | None = Query(default=None), tenant_id: str | None = Query(default=None),
@@ -120,6 +295,7 @@ def list_admin_audit(
offset: int = Query(default=0, ge=0), offset: int = Query(default=0, ge=0),
page: int | None = Query(default=None, ge=1), page: int | None = Query(default=None, ge=1),
page_size: int | None = Query(default=None, ge=1, le=500), page_size: int | None = Query(default=None, ge=1, le=500),
cursor: str | None = Query(default=None),
sort_by: str = Query(default="time"), sort_by: str = Query(default="time"),
sort_direction: str = Query(default="desc"), sort_direction: str = Query(default="desc"),
filter_time: str | None = Query(default=None), filter_time: str | None = Query(default=None),
@@ -132,13 +308,14 @@ def list_admin_audit(
): ):
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_ENTITY, 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.")
if sort_by not in {"time", "actor", "action", "object", "tenant"}: if sort_by not in {"time", "actor", "action", "object", "tenant"}:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Unsupported audit sort column.") raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Unsupported audit sort column.")
if sort_direction not in {"asc", "desc"}: if sort_direction not in {"asc", "desc"}:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Audit sort direction must be asc or desc.") raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Audit sort direction must be asc or desc.")
query = session.query(AuditLog) query = session.query(AuditLog)
resolved_tenant_id: str | None = None
if effective_scope != "all": if effective_scope != "all":
query = query.filter(AuditLog.scope == effective_scope) query = query.filter(AuditLog.scope == effective_scope)
if effective_scope == "system": if effective_scope == "system":
@@ -151,6 +328,7 @@ def list_admin_audit(
if not has_scope(principal, "audit:read"): if not has_scope(principal, "audit:read"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Missing scope: audit:read") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Missing scope: audit:read")
tenant = _resolve_tenant(session, principal, tenant_id) tenant = _resolve_tenant(session, principal, tenant_id)
resolved_tenant_id = tenant.id
query = query.filter(AuditLog.tenant_id == tenant.id) query = query.filter(AuditLog.tenant_id == tenant.id)
object_text = func.coalesce(AuditLog.object_type, "") + " " + func.coalesce(AuditLog.object_id, "") object_text = func.coalesce(AuditLog.object_type, "") + " " + func.coalesce(AuditLog.object_id, "")
@@ -166,15 +344,134 @@ def list_admin_audit(
if condition is not None: if condition is not None:
query = query.filter(condition) 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()
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)
filters = _audit_filter_params(
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
if cursor:
try:
cursor_values = decode_keyset_cursor(AUDIT_ADMIN_CURSOR_SCOPE, cursor, fingerprint=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))
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_offset = 0
start_cursor = cursor
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, pages)
effective_offset = (effective_page - 1) * effective_page_size effective_offset = (effective_page - 1) * effective_page_size
else: else:
effective_page = offset // effective_page_size + 1 effective_page = offset // effective_page_size + 1
effective_offset = offset effective_offset = offset
page_query = 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)
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]
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
)
return AuditAdminListResponse(
total=total,
page=effective_page,
page_size=effective_page_size,
pages=pages,
cursor=start_cursor,
next_cursor=next_cursor,
items=_audit_items(session, rows, access_admin),
)
@router.get("/admin/audit/delta", response_model=AuditAdminDeltaResponse)
def list_admin_audit_delta(
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),
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),
since: str | None = Query(default=None),
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 = { sort_columns = {
"time": AuditLog.created_at, "time": AuditLog.created_at,
@@ -185,28 +482,108 @@ 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()
rows = query.order_by(order, AuditLog.id.desc()).offset(effective_offset).limit(effective_page_size).all() total = query.count()
actor_email_by_user_id = access_admin.actor_email_by_user_id(session, {row.user_id for row in rows if row.user_id}) 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_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
if cursor:
try:
cursor_values = decode_keyset_cursor(AUDIT_ADMIN_CURSOR_SCOPE, cursor, fingerprint=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))
start_cursor = cursor
except KeysetCursorError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
return AuditAdminListResponse( 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]
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
)
return AuditAdminDeltaResponse(
total=total, total=total,
page=effective_page, page=1,
page_size=effective_page_size, page_size=effective_page_size,
pages=pages, pages=pages,
items=[ cursor=start_cursor,
AuditAdminItem( next_cursor=next_cursor,
id=row.id, items=_audit_items(session, rows, access_admin),
scope=row.scope, deleted=[],
tenant_id=row.tenant_id, watermark=_audit_delta_watermark(session, effective_scope=effective_scope, tenant_id=resolved_tenant_id),
actor_email=actor_email_by_user_id.get(row.user_id) if row.user_id else None, has_more=False,
action=row.action, full=True,
object_type=row.object_type,
object_id=row.object_id,
details=row.details or {},
created_at=row.created_at,
) )
for row in rows
], entries, has_more = _audit_delta_entries(
session,
effective_scope=effective_scope,
tenant_id=resolved_tenant_id,
since=since,
limit=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]
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
)
return AuditAdminDeltaResponse(
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"]
rows = (
page_query.filter(AuditLog.id.in_(changed_ids)).order_by(order, AuditLog.id.desc()).limit(effective_page_size).all()
if changed_ids else []
)
return AuditAdminDeltaResponse(
total=total,
page=1,
page_size=effective_page_size,
pages=pages,
cursor=start_cursor,
next_cursor=None,
items=_audit_items(session, rows, access_admin),
deleted=[],
watermark=_audit_delta_response_watermark(
session,
effective_scope=effective_scope,
tenant_id=resolved_tenant_id,
entries=entries,
has_more=has_more,
),
has_more=has_more,
full=False,
) )

View File

@@ -5,6 +5,8 @@ from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator from pydantic import BaseModel, ConfigDict, Field, field_validator
from govoplan_core.api.v1.schemas import DeltaDeletedItem
class AuditAdminItem(BaseModel): class AuditAdminItem(BaseModel):
id: str id: str
@@ -24,6 +26,15 @@ class AuditAdminListResponse(BaseModel):
page: int = 1 page: int = 1
page_size: int = 100 page_size: int = 100
pages: int = 1 pages: int = 1
cursor: str | None = None
next_cursor: str | None = None
class AuditAdminDeltaResponse(AuditAdminListResponse):
deleted: list[DeltaDeletedItem] = Field(default_factory=list)
watermark: str | None = None
has_more: bool = False
full: bool = False
class AuditLogItemResponse(BaseModel): class AuditLogItemResponse(BaseModel):

View File

@@ -22,9 +22,9 @@ class AuditLog(Base, TimestampMixin):
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
scope: Mapped[str] = mapped_column(String(20), default="tenant", nullable=False, index=True) scope: Mapped[str] = mapped_column(String(20), default="tenant", nullable=False, index=True)
tenant_id: Mapped[str | None] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True, index=True) tenant_id: Mapped[str | None] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=True, index=True)
user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
api_key_id: Mapped[str | None] = mapped_column(ForeignKey("api_keys.id", ondelete="SET NULL"), nullable=True, index=True) api_key_id: Mapped[str | None] = mapped_column(ForeignKey("access_api_keys.id", ondelete="SET NULL"), nullable=True, index=True)
action: Mapped[str] = mapped_column(String(100), nullable=False, index=True) action: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
object_type: Mapped[str | None] = mapped_column(String(100), index=True) object_type: Mapped[str | None] = mapped_column(String(100), index=True)
object_id: Mapped[str | None] = mapped_column(String(100), index=True) object_id: Mapped[str | None] = mapped_column(String(100), index=True)