chore: sync GovOPlaN module split state
This commit is contained in:
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal 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/
|
||||
10
README.md
10
README.md
@@ -3,6 +3,10 @@
|
||||
`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.
|
||||
This repository owns the live `audit_log` table, audit API route
|
||||
contributions, and the target boundary for future audit sink/export capability
|
||||
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.
|
||||
|
||||
82
docs/AUDIT_TRACE_CONTEXT.md
Normal file
82
docs/AUDIT_TRACE_CONTEXT.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
[govoplan.modules]
|
||||
audit = govoplan_audit.backend.manifest:get_manifest
|
||||
@@ -1,2 +0,0 @@
|
||||
govoplan-core>=0.1.4
|
||||
govoplan-access>=0.1.4
|
||||
@@ -1 +0,0 @@
|
||||
govoplan_audit
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -3,20 +3,25 @@ from __future__ import annotations
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
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 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_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.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.db.session import get_session
|
||||
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"])
|
||||
|
||||
AUDIT_ADMIN_CURSOR_SCOPE = "audit.admin"
|
||||
|
||||
|
||||
def _access_administration() -> AccessAdministration:
|
||||
registry = get_registry()
|
||||
@@ -91,7 +96,7 @@ def _audit_time_filter(raw: str | None):
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
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:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
else:
|
||||
@@ -111,6 +116,176 @@ def _audit_time_filter(raw: str | None):
|
||||
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)
|
||||
def list_admin_audit(
|
||||
tenant_id: str | None = Query(default=None),
|
||||
@@ -120,6 +295,7 @@ def list_admin_audit(
|
||||
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),
|
||||
@@ -132,13 +308,14 @@ def list_admin_audit(
|
||||
):
|
||||
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_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"}:
|
||||
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"}:
|
||||
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)
|
||||
resolved_tenant_id: str | None = None
|
||||
if effective_scope != "all":
|
||||
query = query.filter(AuditLog.scope == effective_scope)
|
||||
if effective_scope == "system":
|
||||
@@ -151,6 +328,7 @@ def list_admin_audit(
|
||||
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, "")
|
||||
@@ -166,15 +344,134 @@ def list_admin_audit(
|
||||
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()
|
||||
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)
|
||||
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:
|
||||
effective_page = min(page or 1, pages)
|
||||
effective_offset = (effective_page - 1) * effective_page_size
|
||||
else:
|
||||
effective_page = offset // effective_page_size + 1
|
||||
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 = {
|
||||
"time": AuditLog.created_at,
|
||||
@@ -185,28 +482,108 @@ def list_admin_audit(
|
||||
}
|
||||
sort_column = sort_columns[sort_by]
|
||||
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()
|
||||
actor_email_by_user_id = access_admin.actor_email_by_user_id(session, {row.user_id for row in rows if row.user_id})
|
||||
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_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,
|
||||
page=effective_page,
|
||||
page=1,
|
||||
page_size=effective_page_size,
|
||||
pages=pages,
|
||||
items=[
|
||||
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,
|
||||
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,
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from govoplan_core.api.v1.schemas import DeltaDeletedItem
|
||||
|
||||
|
||||
class AuditAdminItem(BaseModel):
|
||||
id: str
|
||||
@@ -24,6 +26,15 @@ class AuditAdminListResponse(BaseModel):
|
||||
page: int = 1
|
||||
page_size: int = 100
|
||||
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):
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -22,9 +22,9 @@ class AuditLog(Base, TimestampMixin):
|
||||
|
||||
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)
|
||||
tenant_id: Mapped[str | None] = mapped_column(ForeignKey("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)
|
||||
api_key_id: Mapped[str | None] = mapped_column(ForeignKey("api_keys.id", ondelete="SET NULL"), 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("access_users.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)
|
||||
object_type: Mapped[str | None] = mapped_column(String(100), index=True)
|
||||
object_id: Mapped[str | None] = mapped_column(String(100), index=True)
|
||||
|
||||
Reference in New Issue
Block a user