From e5d6c77c3c7cb7eeb1ec5b8c8da79c6fffda7bed Mon Sep 17 00:00:00 2001 From: MMD Date: Fri, 18 Sep 2026 22:23:34 +0330 Subject: [PATCH] feat(data-safety): scheduled DB backups + audit trail + verified actor attribution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - db-backup sidecar: pg_dump -Fc of procurement+casdoor every BACKUP_INTERVAL_SECONDS into BACKUP_DIR, retention via BACKUP_KEEP_DAYS - audit_log table (migration.sql + runtime ensure_audit_table) with model + transactional helper (utils/audit.record) — wired into user create/role-change and inventory transaction creation - get_actor: quote_events.actor now stores the VERIFIED user's display name instead of the raw bearer token --- .env.example | 5 ++++ backend/app/db/init_db.py | 24 +++++++++++++++ backend/app/main.py | 2 ++ backend/app/models/__init__.py | 1 + backend/app/models/audit.py | 19 ++++++++++++ backend/app/routers/inventory.py | 11 +++++++ backend/app/routers/users.py | 8 +++-- backend/app/utils/audit.py | 41 ++++++++++++++++++++++++++ backend/app/utils/security.py | 39 +++++++++++++++++------- docker-compose.yml | 33 +++++++++++++++++++++ infra/deploy-webhook/deploy_webhook.py | 7 ++++- sql/migration.sql | 11 +++++++ 12 files changed, 187 insertions(+), 14 deletions(-) create mode 100644 backend/app/models/audit.py create mode 100644 backend/app/utils/audit.py diff --git a/.env.example b/.env.example index f325d96..a7d9140 100644 --- a/.env.example +++ b/.env.example @@ -40,6 +40,11 @@ DEV_LOGIN_ENABLED=false # --- Deploy webhook (Gitea push → rebuild; see infra/deploy-webhook/) --- GITEA_WEBHOOK_SECRET=CHANGE_ME_long_random_webhook_secret +# --- Backups (db-backup sidecar; see docker-compose.yml) --- +BACKUP_DIR=/srv/erp/backups +BACKUP_INTERVAL_SECONDS=86400 +BACKUP_KEEP_DAYS=14 + # --- Monitoring stack (see monitoring/README.md) --- # API log format for Loki ingestion ("text" locally, "json" in production) LOG_FORMAT=json diff --git a/backend/app/db/init_db.py b/backend/app/db/init_db.py index 5201498..5455556 100644 --- a/backend/app/db/init_db.py +++ b/backend/app/db/init_db.py @@ -392,3 +392,27 @@ async def ensure_v26(engine: AsyncEngine) -> None: AFTER INSERT OR UPDATE OF status ON item_decisions FOR EACH ROW EXECUTE FUNCTION fn_sync_last_quoted_price() """) + + +async def ensure_audit_table(engine: AsyncEngine) -> None: + """v2.7: general activity trail (audit_log). Idempotent; safe on any volume.""" + ddl = """ + CREATE TABLE IF NOT EXISTS audit_log ( + id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, + occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(), + actor TEXT, + action TEXT NOT NULL, + entity TEXT NOT NULL, + entity_id TEXT, + details JSONB NOT NULL DEFAULT '{}' + ) + """ + indexes = ( + "CREATE INDEX IF NOT EXISTS idx_audit_log_entity ON audit_log(entity, entity_id)", + "CREATE INDEX IF NOT EXISTS idx_audit_log_occurred ON audit_log(occurred_at DESC)", + ) + async with engine.begin() as conn: + await conn.execute(text(ddl)) + for stmt in indexes: + await conn.execute(text(stmt)) + logger.info("audit_log table ensured") diff --git a/backend/app/main.py b/backend/app/main.py index 090acfe..e90e337 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -24,6 +24,7 @@ from app.config import settings from app.db.engine import AsyncSessionLocal, engine from app.db.init_db import ( ensure_attachment_table, + ensure_audit_table, ensure_dashboard_tables, ensure_material_topic_column, ensure_quote_projects, @@ -100,6 +101,7 @@ async def lifespan(_app: FastAPI): await ensure_material_topic_column(engine) await ensure_quote_projects(engine) await ensure_v26(engine) + await ensure_audit_table(engine) task = asyncio.create_task(_auto_expire_loop()) if settings.AUTO_EXPIRE_ENABLED else None try: yield diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index b4c1912..ceb57d8 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,5 +1,6 @@ """ORM models — import order matters only for registration in Base.metadata.""" from app.db.base import Base +from app.models.audit import AuditLog from app.models.catalog import ( AttributeDefinition, Brand, diff --git a/backend/app/models/audit.py b/backend/app/models/audit.py new file mode 100644 index 0000000..788bc29 --- /dev/null +++ b/backend/app/models/audit.py @@ -0,0 +1,19 @@ +"""Audit log model — general activity trail (v2.7).""" +import uuid + +from sqlalchemy import Column, DateTime, String, Text, func +from sqlalchemy.dialects.postgresql import JSONB, UUID + +from app.db.base import Base + + +class AuditLog(Base): + __tablename__ = "audit_log" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + occurred_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) + actor = Column(Text, nullable=True) # display name of the authenticated user + action = Column(String(64), nullable=False) # e.g. "user.role_changed" + entity = Column(String(64), nullable=False) # e.g. "user", "inventory_tx" + entity_id = Column(Text, nullable=True) + details = Column(JSONB, nullable=False, server_default="{}") diff --git a/backend/app/routers/inventory.py b/backend/app/routers/inventory.py index 2a3249f..3c0109f 100644 --- a/backend/app/routers/inventory.py +++ b/backend/app/routers/inventory.py @@ -43,6 +43,7 @@ from app.utils.errors import ( UnauthorizedError, ValidationError, ) +from app.utils.audit import record as audit_record from app.utils.pagination import paginate, sort_query from app.utils.serialization import to_iso @@ -494,6 +495,16 @@ async def create_transaction( if dest.project_id != scope.project_id: raise ForbiddenError("انبار مقصد متعلق به پروژه شما نیست") txs = await apply_transaction(db, payload, performed_by=scope.actor) + await audit_record( + db, + actor=scope.actor, + action="inventory.transaction_created", + entity="inventory", + entity_id=str(txs[0].inventory_id) if txs else None, + details={"type": payload.transaction_type, "quantity": str(payload.quantity), + "warehouse_id": str(payload.warehouse_id), + "tx_ids": [str(tx.id) for tx in txs]}, + ) return {"data": [transaction_to_dict(tx) for tx in txs]} diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py index 5e84675..702484d 100644 --- a/backend/app/routers/users.py +++ b/backend/app/routers/users.py @@ -21,6 +21,7 @@ from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession from app.auth import UserContext, get_current_user, require_role +from app.utils.audit import record as audit_record from app.config import settings from app.db.engine import get_db from app.models.procurement import Project, User @@ -168,7 +169,7 @@ async def list_users( @router.post("", status_code=201) async def create_user( payload: UserCreate, - _: UserContext = Depends(_org_manager), + ctx: UserContext = Depends(_org_manager), db: AsyncSession = Depends(get_db), ): """Define a new user (manual identity — casdoor_id optional).""" @@ -185,6 +186,7 @@ async def create_user( ) db.add(user) await db.flush() + await audit_record(db, actor=ctx.display_name, action="user.created", entity="user", entity_id=str(user.id), details={"role": payload.role}) return {"data": user_to_dict(user)} @@ -192,7 +194,7 @@ async def create_user( async def update_user_role( user_id, payload: UserRoleUpdate, - _: UserContext = Depends(require_role("admin")), + ctx: UserContext = Depends(require_role("admin")), db: AsyncSession = Depends(get_db), ): """Set/clear a role — ADMIN ONLY (RBAC): procurement managers must not @@ -201,10 +203,12 @@ async def update_user_role( user = await db.get(User, user_id) if user is None: raise NotFoundError("کاربر یافت نشد") + previous_role = user.role if user.role == "pm" and payload.role != "pm": await _unassign_projects(db, user.id) user.role = payload.role await db.flush() + await audit_record(db, actor=ctx.display_name, action="user.role_changed", entity="user", entity_id=str(user.id), details={"from": previous_role, "to": payload.role}) projects = await _user_projects(db, user) return {"data": user_to_dict(user, projects)} diff --git a/backend/app/utils/audit.py b/backend/app/utils/audit.py new file mode 100644 index 0000000..e23c8b0 --- /dev/null +++ b/backend/app/utils/audit.py @@ -0,0 +1,41 @@ +"""Audit helper — one row per sensitive mutation, written in the SAME +transaction as the change (rolled back together with it). + + await record(db, actor=ctx.display_name, action="user.role_changed", + entity="user", entity_id=str(user.id), + details={"from": old_role, "to": user.role}) + +Never raises: an audit failure must not fail the business operation. +""" +import logging +from typing import Any + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.audit import AuditLog + +logger = logging.getLogger("procurement.audit") + + +async def record( + db: AsyncSession, + *, + actor: str | None, + action: str, + entity: str, + entity_id: str | None = None, + details: dict[str, Any] | None = None, +) -> None: + try: + db.add( + AuditLog( + actor=actor, + action=action, + entity=entity, + entity_id=str(entity_id) if entity_id else None, + details=details or {}, + ) + ) + await db.flush() + except Exception: # noqa: BLE001 — audit must never break the caller + logger.exception("failed to write audit_log row (%s/%s)", action, entity) diff --git a/backend/app/utils/security.py b/backend/app/utils/security.py index a78c1cb..b5047d8 100644 --- a/backend/app/utils/security.py +++ b/backend/app/utils/security.py @@ -1,14 +1,31 @@ -"""Placeholder auth: no middleware logic, only a header read for `actor` attribution.""" -from fastapi import Request +"""Attribution for quote_events.actor — the VERIFIED user, not the raw token. + +Historically this stored the bearer token string verbatim (unverifiable and +a token leak into the database). All routes that use it now sit behind the +global JWT dependency, so the token is resolved to the authenticated user +and the display name is recorded instead. +""" +from fastapi import Depends, Request +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth import get_optional_user +from app.db.engine import get_db -def get_actor(request: Request) -> str | None: - """Extract the bearer token (if any) to attribute quote_events.actor. +async def get_actor(request: Request, db: AsyncSession = Depends(get_db)) -> str | None: + """Resolve the caller's display name from the Casdoor Bearer token.""" + ctx = await get_optional_user(request, db) + if ctx is not None: + return ctx.display_name + # Dev-mode header fallback (only active when DEV_LOGIN_ENABLED) carries + # the name in X-User-Name / X-User-Name-B64 — keep attribution useful. + name = request.headers.get("x-user-name") + import base64 - This is the reserved placeholder from the contract — no authentication - or authorization is enforced yet. - """ - auth = request.headers.get("Authorization") - if auth and auth.lower().startswith("bearer "): - return auth[7:].strip() or None - return None + b64 = request.headers.get("x-user-name-b64") + if b64: + try: + name = base64.b64decode(b64.encode()).decode("utf-8") + except Exception: + pass + return name diff --git a/docker-compose.yml b/docker-compose.yml index 9970cff..69da10f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -131,6 +131,39 @@ services: restart: "no" + # ========================================================================== + # Scheduled logical backups of BOTH databases (procurement + casdoor). + # Writes pg_dump -Fc archives to a bind mount (default ./backups, point + # BACKUP_DIR at /srv/erp/backups on the VPS) and prunes archives older + # than BACKUP_KEEP_DAYS. Runs on the same postgres:18 image already pulled. + # ========================================================================== + db-backup: + image: postgres:18 + container_name: procurement-db-backup + depends_on: + postgres: + condition: service_healthy + environment: + PGPASSWORD: ${POSTGRES_PASSWORD:-postgres} + BACKUP_INTERVAL_SECONDS: ${BACKUP_INTERVAL_SECONDS:-86400} + BACKUP_KEEP_DAYS: ${BACKUP_KEEP_DAYS:-14} + volumes: + - ${BACKUP_DIR:-./backups}:/backups + entrypoint: > + /bin/sh -c " + echo 'db-backup: dumping every $${BACKUP_INTERVAL_SECONDS}s, keeping $${BACKUP_KEEP_DAYS} days'; + while true; do + STAMP=$$(date +%Y%m%d-%H%M%S); + pg_dump -h postgres -U postgres -d procurement -Fc -f /backups/procurement-$${STAMP}.dump && + echo \"db-backup: procurement -> /backups/procurement-$${STAMP}.dump\"; + pg_dump -h postgres -U postgres -d casdoor -Fc -f /backups/casdoor-$${STAMP}.dump && + echo \"db-backup: casdoor -> /backups/casdoor-$${STAMP}.dump\"; + find /backups -name '*.dump' -mtime +$${BACKUP_KEEP_DAYS} -delete; + sleep $${BACKUP_INTERVAL_SECONDS}; + done + " + restart: unless-stopped + # ========================================================================== # v2.6 — Casdoor (OIDC identity provider) # Console: http://localhost:8080 (admin / 123) diff --git a/infra/deploy-webhook/deploy_webhook.py b/infra/deploy-webhook/deploy_webhook.py index 81e60f4..2c228b6 100644 --- a/infra/deploy-webhook/deploy_webhook.py +++ b/infra/deploy-webhook/deploy_webhook.py @@ -37,7 +37,12 @@ LISTEN_PORT = int(os.environ.get("WEBHOOK_PORT", "9000")) SECRET = os.environ.get("GITEA_WEBHOOK_SECRET", "") DEPLOY_DIR = os.environ.get("DEPLOY_DIR", "/srv/erp") DEPLOY_BRANCH = os.environ.get("DEPLOY_BRANCH", "production") -HEALTH_URL = os.environ.get("DEPLOY_HEALTH_URL", "") # e.g. http://127.0.0.1:8000/health/ready +# Post-deploy readiness gate: defaults to the public API host from .env +# (both DEPLOY_HEALTH_URL and API_HOST live in the same EnvironmentFile). +HEALTH_URL = ( + os.environ.get("DEPLOY_HEALTH_URL") + or (f"https://{os.environ['API_HOST']}/health/ready" if os.environ.get("API_HOST") else "") +) LOCK_FILE = os.environ.get("DEPLOY_LOCK", "/tmp/erp-deploy.lock") logging.basicConfig( diff --git a/sql/migration.sql b/sql/migration.sql index 1944fe8..2e038a7 100644 --- a/sql/migration.sql +++ b/sql/migration.sql @@ -634,4 +634,15 @@ CREATE TRIGGER trg_item_decisions_sync_price -- double-applied. Arrival auto-posting (POST /pm/receipts) goes through -- the same service path and is idempotent via receipt_allocation_id. +-- ---------- AUDIT LOG (general activity trail, v2.7) ---------- +CREATE TABLE IF NOT EXISTS audit_log ( + id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, + occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(), + actor TEXT, + action TEXT NOT NULL, + entity TEXT NOT NULL, + entity_id TEXT, + details JSONB NOT NULL DEFAULT '{}' +); + COMMIT;