Prepare ERP source for Gitea deployment
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db.common import CommonBase
|
||||
|
||||
|
||||
class AuditLog(CommonBase):
|
||||
__tablename__ = "audit_logs"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
created_at_utc: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
||||
|
||||
actor_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), index=True, nullable=True)
|
||||
actor_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
actor_tenant_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||
actor_branch_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||
|
||||
action: Mapped[str] = mapped_column(String(120), index=True)
|
||||
entity_type: Mapped[str] = mapped_column(String(120), index=True)
|
||||
entity_id: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
entity_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(30), default="success", index=True)
|
||||
|
||||
target_tenant_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||
target_branch_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||
|
||||
ip_address: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
user_agent: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
details_json: Mapped[str] = mapped_column(Text, default="{}")
|
||||
@@ -0,0 +1,112 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Request
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.core.audit.models import AuditLog
|
||||
from app.modules.core.iam.models import User
|
||||
from app.modules.core.iam.scope import UserScope
|
||||
|
||||
|
||||
def _safe_json(value: Any) -> str:
|
||||
return json.dumps(value or {}, ensure_ascii=False, default=_json_default, sort_keys=True)
|
||||
|
||||
|
||||
def _json_default(value: Any):
|
||||
if isinstance(value, datetime):
|
||||
return value.isoformat()
|
||||
if hasattr(value, "isoformat"):
|
||||
try:
|
||||
return value.isoformat()
|
||||
except Exception:
|
||||
pass
|
||||
return str(value)
|
||||
|
||||
|
||||
def _request_meta(request: Request | None) -> tuple[str | None, str | None]:
|
||||
if not request:
|
||||
return None, None
|
||||
ip = request.client.host if request.client else None
|
||||
user_agent = request.headers.get("user-agent")
|
||||
return ip, user_agent
|
||||
|
||||
|
||||
def write_audit_log(
|
||||
db: Session,
|
||||
*,
|
||||
action: str,
|
||||
entity_type: str,
|
||||
actor: User | None = None,
|
||||
request: Request | None = None,
|
||||
entity_id: str | int | None = None,
|
||||
entity_name: str | None = None,
|
||||
status: str = "success",
|
||||
target_tenant_id: int | None = None,
|
||||
target_branch_id: int | None = None,
|
||||
details: dict[str, Any] | None = None,
|
||||
actor_email: str | None = None,
|
||||
) -> AuditLog:
|
||||
ip_address, user_agent = _request_meta(request)
|
||||
log = AuditLog(
|
||||
actor_user_id=actor.id if actor else None,
|
||||
actor_email=(actor.email if actor else actor_email),
|
||||
actor_tenant_id=(actor.tenant_id if actor else None),
|
||||
actor_branch_id=(actor.branch_id if actor else None),
|
||||
action=action,
|
||||
entity_type=entity_type,
|
||||
entity_id=str(entity_id) if entity_id is not None else None,
|
||||
entity_name=entity_name,
|
||||
status=status,
|
||||
target_tenant_id=target_tenant_id,
|
||||
target_branch_id=target_branch_id,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
details_json=_safe_json(details),
|
||||
)
|
||||
db.add(log)
|
||||
db.commit()
|
||||
db.refresh(log)
|
||||
return log
|
||||
|
||||
|
||||
def model_snapshot(obj: Any, fields: list[str]) -> dict[str, Any]:
|
||||
return {field: getattr(obj, field, None) for field in fields}
|
||||
|
||||
|
||||
def pair_before_after(before: dict[str, Any], after: dict[str, Any]) -> dict[str, Any]:
|
||||
return {"before": before, "after": after}
|
||||
|
||||
|
||||
def list_audit_logs(db: Session, scope: UserScope, limit: int = 200) -> list[AuditLog]:
|
||||
q = select(AuditLog)
|
||||
if not scope.is_system_admin:
|
||||
q = q.where(AuditLog.target_tenant_id == scope.actor.tenant_id)
|
||||
if scope.branch_scoped:
|
||||
q = q.where(AuditLog.target_branch_id == scope.actor.branch_id)
|
||||
return db.execute(q.order_by(AuditLog.created_at_utc.desc(), AuditLog.id.desc()).limit(limit)).scalars().all()
|
||||
|
||||
|
||||
def parse_details(log: AuditLog) -> dict[str, Any]:
|
||||
try:
|
||||
return json.loads(log.details_json or "{}")
|
||||
except Exception:
|
||||
return {"raw": log.details_json}
|
||||
|
||||
|
||||
|
||||
def search_audit_logs(db: Session, scope: UserScope, q: str | None = None) -> list[AuditLog]:
|
||||
rows = list_audit_logs(db, scope, limit=1000)
|
||||
query = (q or "").strip().lower()
|
||||
if not query:
|
||||
return rows
|
||||
result = []
|
||||
for row in rows:
|
||||
hay = " ".join([str(row.action or ""), str(row.entity_type or ""), str(row.entity_name or ""), str(row.actor_email or ""), str(row.details_json or "")]).lower()
|
||||
if query in hay:
|
||||
result.append(row)
|
||||
return result
|
||||
@@ -0,0 +1,14 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% import "ui/templates/components/macros.html" as ui %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
{{ ui.page_shell('Audit Logs', 'Latest security and admin changes captured from IAM, RBAC, login, audit firm and branch operations.') }}
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-soft">
|
||||
{{ ui.search_bar('/system-settings/audit-logs', filters.q, filters.per_page) }}
|
||||
{% if logs %}
|
||||
<div class="overflow-x-auto"><table class="min-w-full divide-y divide-slate-200 text-sm"><thead class="bg-slate-50 text-slate-600"><tr><th class="px-4 py-3 text-left font-semibold">When</th><th class="px-4 py-3 text-left font-semibold">Action</th><th class="px-4 py-3 text-left font-semibold">Entity</th><th class="px-4 py-3 text-left font-semibold">Actor</th><th class="px-4 py-3 text-left font-semibold">Target Scope</th><th class="px-4 py-3 text-left font-semibold">Details</th><th class="px-4 py-3 text-left font-semibold">Status</th></tr></thead><tbody class="divide-y divide-slate-100">{% for row in logs %}<tr class="align-top"><td class="px-4 py-3 whitespace-nowrap text-slate-600">{{ row.created_at_utc }}</td><td class="px-4 py-3"><div class="font-medium text-slate-900">{{ row.action }}</div><div class="text-xs text-slate-500">IP {{ row.ip_address or '-' }}</div></td><td class="px-4 py-3"><div class="font-medium text-slate-900">{{ row.entity_type }}</div><div class="text-xs text-slate-500">{{ row.entity_name or row.entity_id or '-' }}</div></td><td class="px-4 py-3"><div class="font-medium text-slate-900">{{ row.actor_email or 'System' }}</div><div class="text-xs text-slate-500">Audit Firm {{ row.actor_tenant_id or '-' }} • Branch {{ row.actor_branch_id or '-' }}</div></td><td class="px-4 py-3 text-slate-600">Audit Firm {{ row.target_tenant_id or '-' }} • Branch {{ row.target_branch_id or '-' }}</td><td class="px-4 py-3 max-w-[28rem]"><pre class="whitespace-pre-wrap break-words rounded-xl bg-slate-50 p-3 text-xs text-slate-700">{{ row.pretty_details }}</pre></td><td class="px-4 py-3">{% if row.status == 'success' %}{{ ui.badge(row.status, 'emerald') }}{% elif row.status == 'denied' %}{{ ui.badge(row.status, 'amber') }}{% else %}{{ ui.badge(row.status, 'rose') }}{% endif %}</td></tr>{% endfor %}</tbody></table></div>
|
||||
{{ ui.pagination(logs_page, '/system-settings/audit-logs', request.url.query) }}
|
||||
{% else %}<div class="p-6">{{ ui.empty_state('No audit entries found for the current filter.') }}</div>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,77 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
from app.core.templating import templates
|
||||
from app.core.db.common import CommonSessionLocal
|
||||
from app.core.security.csrf import get_or_create_csrf_token
|
||||
from app.core.security.session_auth import get_current_user
|
||||
from app.modules.core.audit.service import parse_details, search_audit_logs
|
||||
from app.modules.core.iam.scope import build_scope
|
||||
from app.modules.core.iam.services import paginate_list
|
||||
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
|
||||
from app.modules.core.rbac.permission_guard import require_permission
|
||||
|
||||
router = APIRouter(prefix="/system-settings/audit-logs", tags=["audit-ui"])
|
||||
|
||||
|
||||
def _is_system_admin(db, user) -> bool:
|
||||
return "System Admin" in get_user_roles(db, user.id)
|
||||
|
||||
|
||||
def _is_firm_admin(db, user) -> bool:
|
||||
return "Firm Admin" in get_user_roles(db, user.id)
|
||||
|
||||
|
||||
@router.get("")
|
||||
def logs(request: Request, q: str = "", page: int = 1, per_page: int = 20):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user = get_current_user(request, db=db)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
|
||||
try:
|
||||
require_permission(db, user, "audit.view")
|
||||
except Exception:
|
||||
return RedirectResponse(url="/system-settings", status_code=303)
|
||||
|
||||
if not _is_system_admin(db, user) and not _is_firm_admin(db, user):
|
||||
return RedirectResponse(url="/system-settings", status_code=303)
|
||||
|
||||
scope = build_scope(db, user)
|
||||
rows = search_audit_logs(db, scope, q=q)
|
||||
|
||||
# Enforce final matrix explicitly:
|
||||
# - System Admin: all logs
|
||||
# - Firm Admin: own tenant logs only
|
||||
# - others: none
|
||||
if _is_firm_admin(db, user) and not _is_system_admin(db, user):
|
||||
rows = [r for r in rows if getattr(r, "target_tenant_id", None) == user.tenant_id]
|
||||
|
||||
paged = paginate_list(rows, page=page, per_page=per_page)
|
||||
decorated = []
|
||||
for row in paged.items:
|
||||
pretty = json.dumps(parse_details(row), indent=2, ensure_ascii=False, default=str)
|
||||
setattr(row, "pretty_details", pretty)
|
||||
decorated.append(row)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"modules/core/audit/templates/logs.html",
|
||||
{
|
||||
"request": request,
|
||||
"current_user": user,
|
||||
"current_user_roles": get_user_roles(db, user.id),
|
||||
"current_user_permissions": get_user_permissions(db, user.id),
|
||||
"csrf_token": get_or_create_csrf_token(request),
|
||||
"title": "Audit Logs",
|
||||
"logs": decorated,
|
||||
"logs_page": paged,
|
||||
"filters": {"q": (q or "").strip(), "per_page": paged.per_page},
|
||||
},
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
Reference in New Issue
Block a user