Release v0.1.5
This commit is contained in:
@@ -0,0 +1,35 @@
|
|||||||
|
---
|
||||||
|
name: "Bug"
|
||||||
|
about: "Report a reproducible defect, regression, or incorrect behavior"
|
||||||
|
title: "[Bug] "
|
||||||
|
labels:
|
||||||
|
- type/bug
|
||||||
|
- status/triage
|
||||||
|
- module/audit
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Repository:
|
||||||
|
- Area/module:
|
||||||
|
- Affected version or commit:
|
||||||
|
|
||||||
|
## Behavior
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
Actual:
|
||||||
|
|
||||||
|
## Reproduction
|
||||||
|
|
||||||
|
1.
|
||||||
|
2.
|
||||||
|
3.
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
Logs, screenshots, traces, or failing test output:
|
||||||
|
|
||||||
|
## Verification Target
|
||||||
|
|
||||||
|
Command or workflow that should pass when fixed:
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
name: "Docs / workflow"
|
||||||
|
about: "Request documentation, process, or developer workflow changes"
|
||||||
|
title: "[Docs] "
|
||||||
|
labels:
|
||||||
|
- type/docs
|
||||||
|
- status/triage
|
||||||
|
- module/audit
|
||||||
|
- area/docs
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Repository:
|
||||||
|
- Document or workflow:
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
What is missing, unclear, duplicated, or stale?
|
||||||
|
|
||||||
|
## Desired State
|
||||||
|
|
||||||
|
What should the docs or workflow make clear?
|
||||||
|
|
||||||
|
## Verification Target
|
||||||
|
|
||||||
|
How should this be checked?
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
---
|
||||||
|
name: "Feature"
|
||||||
|
about: "Propose new user-visible behavior or platform capability"
|
||||||
|
title: "[Feature] "
|
||||||
|
labels:
|
||||||
|
- type/feature
|
||||||
|
- status/triage
|
||||||
|
- module/audit
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
What user, operator, or developer problem should this solve?
|
||||||
|
|
||||||
|
## Proposed Capability
|
||||||
|
|
||||||
|
What should exist when this is done?
|
||||||
|
|
||||||
|
## Ownership
|
||||||
|
|
||||||
|
- Owning repository:
|
||||||
|
- Related module repositories:
|
||||||
|
- Extension point or integration boundary:
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ]
|
||||||
|
- [ ]
|
||||||
|
|
||||||
|
## Verification Target
|
||||||
|
|
||||||
|
Command, scenario, or UI flow that should prove completion:
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
name: "Task"
|
||||||
|
about: "Track implementation, maintenance, or migration work"
|
||||||
|
title: "[Task] "
|
||||||
|
labels:
|
||||||
|
- type/task
|
||||||
|
- status/triage
|
||||||
|
- module/audit
|
||||||
|
---
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
What needs to be completed?
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Owning repository:
|
||||||
|
- In-scope:
|
||||||
|
- Out-of-scope:
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ]
|
||||||
|
- [ ]
|
||||||
|
|
||||||
|
## Verification Target
|
||||||
|
|
||||||
|
Command or manual check:
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
---
|
||||||
|
name: "Tech debt"
|
||||||
|
about: "Track cleanup, refactoring, risk reduction, or deferred engineering work"
|
||||||
|
title: "[Debt] "
|
||||||
|
labels:
|
||||||
|
- type/debt
|
||||||
|
- status/triage
|
||||||
|
- module/audit
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current Cost
|
||||||
|
|
||||||
|
What does this make harder, riskier, slower, or more fragile?
|
||||||
|
|
||||||
|
## Desired Shape
|
||||||
|
|
||||||
|
What should the code, tests, or architecture look like afterwards?
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
What behavior, compatibility, or module boundary must be preserved?
|
||||||
|
|
||||||
|
## Verification Target
|
||||||
|
|
||||||
|
Focused checks that should pass:
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
## Issue
|
||||||
|
|
||||||
|
Closes #
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
-
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
-
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
Follow-up issues:
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=69", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "govoplan-audit"
|
||||||
|
version = "0.1.5"
|
||||||
|
description = "GovOPlaN audit platform module."
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
authors = [{ name = "GovOPlaN" }]
|
||||||
|
dependencies = [
|
||||||
|
"govoplan-core>=0.1.5",
|
||||||
|
"govoplan-access>=0.1.5",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["src"]
|
||||||
|
|
||||||
|
[tool.setuptools.package-data]
|
||||||
|
govoplan_audit = ["py.typed"]
|
||||||
|
|
||||||
|
[project.entry-points."govoplan.modules"]
|
||||||
|
audit = "govoplan_audit.backend.manifest:get_manifest"
|
||||||
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
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.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
[govoplan.modules]
|
||||||
|
audit = govoplan_audit.backend.manifest:get_manifest
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
govoplan-core>=0.1.4
|
||||||
|
govoplan-access>=0.1.4
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
govoplan_audit
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"""GovOPlaN audit module."""
|
||||||
|
|
||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,2 @@
|
|||||||
|
"""Backend integration for the GovOPlaN audit module."""
|
||||||
|
|
||||||
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.
@@ -0,0 +1,233 @@
|
|||||||
|
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.orm import Session
|
||||||
|
|
||||||
|
from govoplan_access.backend.auth.dependencies import ApiPrincipal, has_scope, require_any_scope
|
||||||
|
from govoplan_audit.backend.db.models import AuditLog
|
||||||
|
from govoplan_core.core.access import CAPABILITY_ACCESS_ADMINISTRATION, AccessAdministration
|
||||||
|
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
|
||||||
|
|
||||||
|
router = APIRouter(tags=["audit"])
|
||||||
|
|
||||||
|
|
||||||
|
def _access_administration() -> AccessAdministration:
|
||||||
|
registry = get_registry()
|
||||||
|
if registry is None or not registry.has_capability(CAPABILITY_ACCESS_ADMINISTRATION):
|
||||||
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Access administration capability is not configured")
|
||||||
|
capability = registry.require_capability(CAPABILITY_ACCESS_ADMINISTRATION)
|
||||||
|
if not isinstance(capability, AccessAdministration):
|
||||||
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Access administration capability is invalid")
|
||||||
|
return capability
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_tenant(
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
tenant_id: str | None,
|
||||||
|
) -> Tenant:
|
||||||
|
target_id = tenant_id or principal.tenant_id
|
||||||
|
if target_id != principal.tenant_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="Switch to the target tenant before using tenant-administration endpoints.",
|
||||||
|
)
|
||||||
|
tenant = session.get(Tenant, target_id)
|
||||||
|
if tenant is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found")
|
||||||
|
return tenant
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_audit_filter(value: str | None) -> tuple[str, str]:
|
||||||
|
if not value:
|
||||||
|
return "contains", ""
|
||||||
|
if ":" not in value:
|
||||||
|
return "contains", value.strip()
|
||||||
|
operator, raw = value.split(":", 1)
|
||||||
|
if operator not in {"contains", "eq", "before", "after", "gt", "gte", "lt", "lte"}:
|
||||||
|
return "contains", value.strip()
|
||||||
|
return operator, raw.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _audit_text_filter(column, raw: str | None):
|
||||||
|
operator, value = _parse_audit_filter(raw)
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
normalized = value.casefold()
|
||||||
|
text = func.lower(func.coalesce(column, ""))
|
||||||
|
return text == normalized if operator == "eq" else text.contains(normalized)
|
||||||
|
|
||||||
|
|
||||||
|
def _text_matches(candidate: str, *, operator: str, value: str) -> bool:
|
||||||
|
normalized = value.casefold()
|
||||||
|
text = candidate.casefold()
|
||||||
|
return text == normalized if operator == "eq" else normalized in text
|
||||||
|
|
||||||
|
|
||||||
|
def _audit_actor_filter(access_admin: AccessAdministration, session: Session, raw: str | None):
|
||||||
|
operator, value = _parse_audit_filter(raw)
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
user_ids = access_admin.user_ids_for_actor_filter(session, operator=operator, value=value)
|
||||||
|
conditions = []
|
||||||
|
if user_ids:
|
||||||
|
conditions.append(AuditLog.user_id.in_(user_ids))
|
||||||
|
if _text_matches("System", operator=operator, value=value):
|
||||||
|
conditions.append(AuditLog.user_id.is_(None))
|
||||||
|
return or_(*conditions) if conditions else false()
|
||||||
|
|
||||||
|
|
||||||
|
def _audit_time_filter(raw: str | None):
|
||||||
|
operator, value = _parse_audit_filter(raw)
|
||||||
|
if not value:
|
||||||
|
return 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
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||||
|
else:
|
||||||
|
parsed = parsed.astimezone(timezone.utc)
|
||||||
|
if operator == "eq":
|
||||||
|
if "T" not in value and " " not in value:
|
||||||
|
return (AuditLog.created_at >= parsed) & (AuditLog.created_at < parsed + timedelta(days=1))
|
||||||
|
return AuditLog.created_at == parsed
|
||||||
|
if operator in {"before", "lt"}:
|
||||||
|
return AuditLog.created_at < parsed
|
||||||
|
if operator == "lte":
|
||||||
|
return AuditLog.created_at <= parsed
|
||||||
|
if operator in {"after", "gt"}:
|
||||||
|
return AuditLog.created_at > parsed
|
||||||
|
if operator == "gte":
|
||||||
|
return AuditLog.created_at >= parsed
|
||||||
|
return AuditLog.created_at == parsed
|
||||||
|
|
||||||
|
|
||||||
|
@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),
|
||||||
|
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")),
|
||||||
|
):
|
||||||
|
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.")
|
||||||
|
if sort_by not in {"time", "actor", "action", "object", "tenant"}:
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, 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.")
|
||||||
|
|
||||||
|
query = session.query(AuditLog)
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
|
||||||
|
total = query.count()
|
||||||
|
effective_page_size = page_size or limit
|
||||||
|
pages = max(1, (total + effective_page_size - 1) // effective_page_size)
|
||||||
|
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
|
||||||
|
|
||||||
|
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()
|
||||||
|
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})
|
||||||
|
|
||||||
|
return AuditAdminListResponse(
|
||||||
|
total=total,
|
||||||
|
page=effective_page,
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/audit", response_model=AuditLogListResponse)
|
||||||
|
def list_audit_log(
|
||||||
|
limit: int = Query(default=100, ge=1, le=500),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
action: str | None = None,
|
||||||
|
object_type: str | None = None,
|
||||||
|
object_id: str | None = None,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_any_scope("audit:read")),
|
||||||
|
):
|
||||||
|
if not has_scope(principal, "audit:read"):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Missing scope: audit:read")
|
||||||
|
query = session.query(AuditLog).filter(AuditLog.tenant_id == principal.tenant_id)
|
||||||
|
if action:
|
||||||
|
query = query.filter(AuditLog.action == action)
|
||||||
|
if object_type:
|
||||||
|
query = query.filter(AuditLog.object_type == object_type)
|
||||||
|
if object_id:
|
||||||
|
query = query.filter(AuditLog.object_id == object_id)
|
||||||
|
items = query.order_by(AuditLog.created_at.desc()).offset(offset).limit(limit).all()
|
||||||
|
return AuditLogListResponse(items=[AuditLogItemResponse.model_validate(item) for item in items])
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||||
|
|
||||||
|
|
||||||
|
class AuditAdminItem(BaseModel):
|
||||||
|
id: str
|
||||||
|
scope: Literal["tenant", "system"] = "tenant"
|
||||||
|
tenant_id: str | None = None
|
||||||
|
actor_email: str | None = None
|
||||||
|
action: str
|
||||||
|
object_type: str | None = None
|
||||||
|
object_id: str | None = None
|
||||||
|
details: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class AuditAdminListResponse(BaseModel):
|
||||||
|
items: list[AuditAdminItem]
|
||||||
|
total: int
|
||||||
|
page: int = 1
|
||||||
|
page_size: int = 100
|
||||||
|
pages: int = 1
|
||||||
|
|
||||||
|
|
||||||
|
class AuditLogItemResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: str
|
||||||
|
tenant_id: str | None = None
|
||||||
|
user_id: str | None = None
|
||||||
|
api_key_id: str | None = None
|
||||||
|
action: str
|
||||||
|
object_type: str | None = None
|
||||||
|
object_id: str | None = None
|
||||||
|
details: dict[str, Any] | None = None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class AuditLogListResponse(BaseModel):
|
||||||
|
items: list[AuditLogItemResponse]
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"""Audit-owned SQLAlchemy metadata and models."""
|
||||||
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,34 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import ForeignKey, Index, JSON, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from govoplan_core.db.base import Base, TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
|
def new_uuid() -> str:
|
||||||
|
return str(uuid.uuid4())
|
||||||
|
|
||||||
|
|
||||||
|
class AuditLog(Base, TimestampMixin):
|
||||||
|
__tablename__ = "audit_log"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_audit_log_scope_created_at", "scope", "created_at"),
|
||||||
|
Index("ix_audit_log_tenant_scope_created_at", "tenant_id", "scope", "created_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
details: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["AuditLog", "new_uuid"]
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from govoplan_audit.backend.db import models as audit_models # noqa: F401 - populate Audit ORM metadata
|
||||||
|
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.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
def _route_factory(context: ModuleContext):
|
||||||
|
del context
|
||||||
|
from govoplan_audit.backend.api.v1.routes import router
|
||||||
|
|
||||||
|
return router
|
||||||
|
|
||||||
|
|
||||||
|
manifest = ModuleManifest(
|
||||||
|
id="audit",
|
||||||
|
name="Audit",
|
||||||
|
version="0.1.5",
|
||||||
|
dependencies=("access",),
|
||||||
|
route_factory=_route_factory,
|
||||||
|
migration_spec=MigrationSpec(
|
||||||
|
module_id="audit",
|
||||||
|
metadata=Base.metadata,
|
||||||
|
retirement_supported=True,
|
||||||
|
retirement_provider=drop_table_retirement_provider(audit_models.AuditLog, 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"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_manifest() -> ModuleManifest:
|
||||||
|
return manifest
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
Reference in New Issue
Block a user