commit 0f601fc693dededb675a86d6bce9f1857102c30b Author: Albrecht Degering Date: Tue Jul 7 15:49:06 2026 +0200 Release v0.1.5 diff --git a/.gitea/ISSUE_TEMPLATE/bug_report.md b/.gitea/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..3cfefae --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/bug_report.md @@ -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: diff --git a/.gitea/ISSUE_TEMPLATE/config.yaml b/.gitea/ISSUE_TEMPLATE/config.yaml new file mode 100644 index 0000000..3ba13e0 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/config.yaml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/.gitea/ISSUE_TEMPLATE/docs_workflow.md b/.gitea/ISSUE_TEMPLATE/docs_workflow.md new file mode 100644 index 0000000..e24ce27 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/docs_workflow.md @@ -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? diff --git a/.gitea/ISSUE_TEMPLATE/feature_request.md b/.gitea/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..b8f3497 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/feature_request.md @@ -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: diff --git a/.gitea/ISSUE_TEMPLATE/task.md b/.gitea/ISSUE_TEMPLATE/task.md new file mode 100644 index 0000000..caedbbe --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/task.md @@ -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: diff --git a/.gitea/ISSUE_TEMPLATE/tech_debt.md b/.gitea/ISSUE_TEMPLATE/tech_debt.md new file mode 100644 index 0000000..24ef15a --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/tech_debt.md @@ -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: diff --git a/.gitea/PULL_REQUEST_TEMPLATE.md b/.gitea/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..1984736 --- /dev/null +++ b/.gitea/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,15 @@ +## Issue + +Closes # + +## Summary + +- + +## Verification + +- + +## Notes + +Follow-up issues: diff --git a/README.md b/README.md new file mode 100644 index 0000000..8c50939 --- /dev/null +++ b/README.md @@ -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. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9ffbe60 --- /dev/null +++ b/pyproject.toml @@ -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" + diff --git a/src/govoplan_audit.egg-info/PKG-INFO b/src/govoplan_audit.egg-info/PKG-INFO new file mode 100644 index 0000000..c1e5d60 --- /dev/null +++ b/src/govoplan_audit.egg-info/PKG-INFO @@ -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. diff --git a/src/govoplan_audit.egg-info/SOURCES.txt b/src/govoplan_audit.egg-info/SOURCES.txt new file mode 100644 index 0000000..6128ccb --- /dev/null +++ b/src/govoplan_audit.egg-info/SOURCES.txt @@ -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 \ No newline at end of file diff --git a/src/govoplan_audit.egg-info/dependency_links.txt b/src/govoplan_audit.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/govoplan_audit.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/src/govoplan_audit.egg-info/entry_points.txt b/src/govoplan_audit.egg-info/entry_points.txt new file mode 100644 index 0000000..6c4e37e --- /dev/null +++ b/src/govoplan_audit.egg-info/entry_points.txt @@ -0,0 +1,2 @@ +[govoplan.modules] +audit = govoplan_audit.backend.manifest:get_manifest diff --git a/src/govoplan_audit.egg-info/requires.txt b/src/govoplan_audit.egg-info/requires.txt new file mode 100644 index 0000000..58ec5d6 --- /dev/null +++ b/src/govoplan_audit.egg-info/requires.txt @@ -0,0 +1,2 @@ +govoplan-core>=0.1.4 +govoplan-access>=0.1.4 diff --git a/src/govoplan_audit.egg-info/top_level.txt b/src/govoplan_audit.egg-info/top_level.txt new file mode 100644 index 0000000..e6ae8f4 --- /dev/null +++ b/src/govoplan_audit.egg-info/top_level.txt @@ -0,0 +1 @@ +govoplan_audit diff --git a/src/govoplan_audit/__init__.py b/src/govoplan_audit/__init__.py new file mode 100644 index 0000000..68c5967 --- /dev/null +++ b/src/govoplan_audit/__init__.py @@ -0,0 +1,2 @@ +"""GovOPlaN audit module.""" + diff --git a/src/govoplan_audit/__pycache__/__init__.cpython-312.pyc b/src/govoplan_audit/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..502716b Binary files /dev/null and b/src/govoplan_audit/__pycache__/__init__.cpython-312.pyc differ diff --git a/src/govoplan_audit/__pycache__/__init__.cpython-313.pyc b/src/govoplan_audit/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..d6b9675 Binary files /dev/null and b/src/govoplan_audit/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/govoplan_audit/backend/__init__.py b/src/govoplan_audit/backend/__init__.py new file mode 100644 index 0000000..2b2d0e9 --- /dev/null +++ b/src/govoplan_audit/backend/__init__.py @@ -0,0 +1,2 @@ +"""Backend integration for the GovOPlaN audit module.""" + diff --git a/src/govoplan_audit/backend/__pycache__/__init__.cpython-312.pyc b/src/govoplan_audit/backend/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..34dcb39 Binary files /dev/null and b/src/govoplan_audit/backend/__pycache__/__init__.cpython-312.pyc differ diff --git a/src/govoplan_audit/backend/__pycache__/__init__.cpython-313.pyc b/src/govoplan_audit/backend/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..8d611c2 Binary files /dev/null and b/src/govoplan_audit/backend/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/govoplan_audit/backend/__pycache__/manifest.cpython-312.pyc b/src/govoplan_audit/backend/__pycache__/manifest.cpython-312.pyc new file mode 100644 index 0000000..eca5a9e Binary files /dev/null and b/src/govoplan_audit/backend/__pycache__/manifest.cpython-312.pyc differ diff --git a/src/govoplan_audit/backend/__pycache__/manifest.cpython-313.pyc b/src/govoplan_audit/backend/__pycache__/manifest.cpython-313.pyc new file mode 100644 index 0000000..f5099c2 Binary files /dev/null and b/src/govoplan_audit/backend/__pycache__/manifest.cpython-313.pyc differ diff --git a/src/govoplan_audit/backend/api/__init__.py b/src/govoplan_audit/backend/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/govoplan_audit/backend/api/__pycache__/__init__.cpython-312.pyc b/src/govoplan_audit/backend/api/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..91960e2 Binary files /dev/null and b/src/govoplan_audit/backend/api/__pycache__/__init__.cpython-312.pyc differ diff --git a/src/govoplan_audit/backend/api/__pycache__/__init__.cpython-313.pyc b/src/govoplan_audit/backend/api/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..0b0e21a Binary files /dev/null and b/src/govoplan_audit/backend/api/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/govoplan_audit/backend/api/v1/__init__.py b/src/govoplan_audit/backend/api/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/govoplan_audit/backend/api/v1/__pycache__/__init__.cpython-312.pyc b/src/govoplan_audit/backend/api/v1/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..1524f72 Binary files /dev/null and b/src/govoplan_audit/backend/api/v1/__pycache__/__init__.cpython-312.pyc differ diff --git a/src/govoplan_audit/backend/api/v1/__pycache__/__init__.cpython-313.pyc b/src/govoplan_audit/backend/api/v1/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..9a82f6e Binary files /dev/null and b/src/govoplan_audit/backend/api/v1/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/govoplan_audit/backend/api/v1/__pycache__/routes.cpython-312.pyc b/src/govoplan_audit/backend/api/v1/__pycache__/routes.cpython-312.pyc new file mode 100644 index 0000000..a02f93d Binary files /dev/null and b/src/govoplan_audit/backend/api/v1/__pycache__/routes.cpython-312.pyc differ diff --git a/src/govoplan_audit/backend/api/v1/__pycache__/routes.cpython-313.pyc b/src/govoplan_audit/backend/api/v1/__pycache__/routes.cpython-313.pyc new file mode 100644 index 0000000..6061599 Binary files /dev/null and b/src/govoplan_audit/backend/api/v1/__pycache__/routes.cpython-313.pyc differ diff --git a/src/govoplan_audit/backend/api/v1/__pycache__/schemas.cpython-312.pyc b/src/govoplan_audit/backend/api/v1/__pycache__/schemas.cpython-312.pyc new file mode 100644 index 0000000..241b0c2 Binary files /dev/null and b/src/govoplan_audit/backend/api/v1/__pycache__/schemas.cpython-312.pyc differ diff --git a/src/govoplan_audit/backend/api/v1/__pycache__/schemas.cpython-313.pyc b/src/govoplan_audit/backend/api/v1/__pycache__/schemas.cpython-313.pyc new file mode 100644 index 0000000..aed55ce Binary files /dev/null and b/src/govoplan_audit/backend/api/v1/__pycache__/schemas.cpython-313.pyc differ diff --git a/src/govoplan_audit/backend/api/v1/routes.py b/src/govoplan_audit/backend/api/v1/routes.py new file mode 100644 index 0000000..671d06b --- /dev/null +++ b/src/govoplan_audit/backend/api/v1/routes.py @@ -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]) diff --git a/src/govoplan_audit/backend/api/v1/schemas.py b/src/govoplan_audit/backend/api/v1/schemas.py new file mode 100644 index 0000000..2e5f6f6 --- /dev/null +++ b/src/govoplan_audit/backend/api/v1/schemas.py @@ -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] diff --git a/src/govoplan_audit/backend/db/__init__.py b/src/govoplan_audit/backend/db/__init__.py new file mode 100644 index 0000000..af0bc55 --- /dev/null +++ b/src/govoplan_audit/backend/db/__init__.py @@ -0,0 +1,2 @@ +"""Audit-owned SQLAlchemy metadata and models.""" + diff --git a/src/govoplan_audit/backend/db/__pycache__/__init__.cpython-312.pyc b/src/govoplan_audit/backend/db/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..da34137 Binary files /dev/null and b/src/govoplan_audit/backend/db/__pycache__/__init__.cpython-312.pyc differ diff --git a/src/govoplan_audit/backend/db/__pycache__/__init__.cpython-313.pyc b/src/govoplan_audit/backend/db/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..b6489e8 Binary files /dev/null and b/src/govoplan_audit/backend/db/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/govoplan_audit/backend/db/__pycache__/models.cpython-312.pyc b/src/govoplan_audit/backend/db/__pycache__/models.cpython-312.pyc new file mode 100644 index 0000000..2787412 Binary files /dev/null and b/src/govoplan_audit/backend/db/__pycache__/models.cpython-312.pyc differ diff --git a/src/govoplan_audit/backend/db/__pycache__/models.cpython-313.pyc b/src/govoplan_audit/backend/db/__pycache__/models.cpython-313.pyc new file mode 100644 index 0000000..33aba81 Binary files /dev/null and b/src/govoplan_audit/backend/db/__pycache__/models.cpython-313.pyc differ diff --git a/src/govoplan_audit/backend/db/models.py b/src/govoplan_audit/backend/db/models.py new file mode 100644 index 0000000..f43f5c5 --- /dev/null +++ b/src/govoplan_audit/backend/db/models.py @@ -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"] diff --git a/src/govoplan_audit/backend/manifest.py b/src/govoplan_audit/backend/manifest.py new file mode 100644 index 0000000..04957d3 --- /dev/null +++ b/src/govoplan_audit/backend/manifest.py @@ -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 diff --git a/src/govoplan_audit/py.typed b/src/govoplan_audit/py.typed new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/govoplan_audit/py.typed @@ -0,0 +1 @@ +