From dc711fe82cd63f841b35c44217242517d93743a1 Mon Sep 17 00:00:00 2001 From: amee Date: Fri, 3 Apr 2026 12:15:56 +0530 Subject: [PATCH] fix: audit logs for tenants, user, roles, modules and environment and modules of tenant and modules. 2 migration scripts --- .../73b754d5b2c5_create_audit_logs_table.py | 43 +++++++ ...9cf173f48f9_add_audit_log_extra_columns.py | 50 ++++++++ app/__init__.py | 5 +- app/helper/__init__.py | 1 + app/helper/helpers.py | 7 ++ app/models/system/audit_log.py | 38 +++++++ app/routes/admin/audit_logs.py | 50 ++++++++ app/routes/admin/module_environments.py | 107 ++++++++++++++++-- app/routes/admin/modules.py | 99 +++++++++++----- app/routes/admin/tenant_modules.py | 56 ++++++++- app/routes/auth/role.py | 69 ++++++++++- app/routes/auth/tenant.py | 100 ++++++++++++---- app/routes/auth/user.py | 69 ++++++++++- app/schemas/auth/audit_schema.py | 26 +++++ app/services/system/audit_log_service.py | 52 +++++++++ scripts/seed_superadmin.py | 1 + 16 files changed, 701 insertions(+), 72 deletions(-) create mode 100644 alembic/versions/73b754d5b2c5_create_audit_logs_table.py create mode 100644 alembic/versions/f9cf173f48f9_add_audit_log_extra_columns.py create mode 100644 app/helper/__init__.py create mode 100644 app/helper/helpers.py create mode 100644 app/models/system/audit_log.py create mode 100644 app/routes/admin/audit_logs.py create mode 100644 app/schemas/auth/audit_schema.py create mode 100644 app/services/system/audit_log_service.py diff --git a/alembic/versions/73b754d5b2c5_create_audit_logs_table.py b/alembic/versions/73b754d5b2c5_create_audit_logs_table.py new file mode 100644 index 0000000..a010b57 --- /dev/null +++ b/alembic/versions/73b754d5b2c5_create_audit_logs_table.py @@ -0,0 +1,43 @@ +"""create audit logs table + +Revision ID: 73b754d5b2c5 +Revises: c37ba6143f83 +Create Date: 2026-03-30 15:43:16.849980 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '73b754d5b2c5' +down_revision: Union[str, Sequence[str], None] = 'c37ba6143f83' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('audit_logs', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('module_name', sa.String(length=100), nullable=False), + sa.Column('action_type', sa.String(length=20), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_audit_logs_id'), 'audit_logs', ['id'], unique=False) + op.create_index(op.f('ix_audit_logs_module_name'), 'audit_logs', ['module_name'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_audit_logs_module_name'), table_name='audit_logs') + op.drop_index(op.f('ix_audit_logs_id'), table_name='audit_logs') + op.drop_table('audit_logs') + # ### end Alembic commands ### diff --git a/alembic/versions/f9cf173f48f9_add_audit_log_extra_columns.py b/alembic/versions/f9cf173f48f9_add_audit_log_extra_columns.py new file mode 100644 index 0000000..a80d8bf --- /dev/null +++ b/alembic/versions/f9cf173f48f9_add_audit_log_extra_columns.py @@ -0,0 +1,50 @@ +"""add_audit_log_extra_columns + +Revision ID: f9cf173f48f9 +Revises: 73b754d5b2c5 +Create Date: 2026-03-31 13:57:11.328101 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = 'f9cf173f48f9' +down_revision: Union[str, Sequence[str], None] = '73b754d5b2c5' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('audit_logs', sa.Column('entity_id', sa.String(length=255), nullable=True)) + op.add_column('audit_logs', sa.Column('entity_name', sa.String(length=255), nullable=True)) + op.add_column('audit_logs', sa.Column('performed_by_id', sa.UUID(), nullable=True)) + op.add_column('audit_logs', sa.Column('performed_by_email', sa.String(length=255), nullable=True)) + op.add_column('audit_logs', sa.Column('ip_address', sa.String(length=45), nullable=True)) + op.add_column('audit_logs', sa.Column('old_values', postgresql.JSONB(astext_type=sa.Text()), nullable=True)) + op.add_column('audit_logs', sa.Column('new_values', postgresql.JSONB(astext_type=sa.Text()), nullable=True)) + op.create_index(op.f('ix_audit_logs_action_type'), 'audit_logs', ['action_type'], unique=False) + op.create_index(op.f('ix_audit_logs_created_at'), 'audit_logs', ['created_at'], unique=False) + op.create_index(op.f('ix_audit_logs_performed_by_email'), 'audit_logs', ['performed_by_email'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_audit_logs_performed_by_email'), table_name='audit_logs') + op.drop_index(op.f('ix_audit_logs_created_at'), table_name='audit_logs') + op.drop_index(op.f('ix_audit_logs_action_type'), table_name='audit_logs') + op.drop_column('audit_logs', 'new_values') + op.drop_column('audit_logs', 'old_values') + op.drop_column('audit_logs', 'ip_address') + op.drop_column('audit_logs', 'performed_by_email') + op.drop_column('audit_logs', 'performed_by_id') + op.drop_column('audit_logs', 'entity_name') + op.drop_column('audit_logs', 'entity_id') + # ### end Alembic commands ### diff --git a/app/__init__.py b/app/__init__.py index c7cbaaf..7298afd 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -6,6 +6,7 @@ import logging from sqlalchemy import text from app.config.settings import settings from app.config.database import engine +from app.routes.admin import audit_logs import app.models.auth.user_model import app.models.auth.role_model import app.models.auth.tenant_model @@ -16,6 +17,7 @@ import app.models.auth.tenant_module_model import app.models.auth.sso_grant_model import app.models.auth.access_model import app.models.system.event_log_model +import app.models.system.audit_log import asyncio from app.services.auth.event_service import EventService from app.config.database import SessionLocal @@ -206,7 +208,8 @@ def create_app() -> FastAPI: from app.routes.admin.modules import router as admin_modules_router from app.routes.admin.module_environments import router as admin_module_env_router from app.routes.admin.tenant_modules import router as admin_tenant_modules_router - + + app.include_router(audit_logs.router, prefix="/api/admin/audit-logs", tags=["Admin - Audit Logs"]) app.include_router(admin_modules_router, prefix="/api/admin/modules", tags=["Admin - Modules"]) app.include_router(admin_module_env_router, prefix="/api/admin/modules", tags=["Admin - Module Environments"]) app.include_router(admin_tenant_modules_router, prefix="/api/admin/tenants", tags=["Admin - Tenant Modules"]) diff --git a/app/helper/__init__.py b/app/helper/__init__.py new file mode 100644 index 0000000..a617e16 --- /dev/null +++ b/app/helper/__init__.py @@ -0,0 +1 @@ +from .helpers import get_client_ip \ No newline at end of file diff --git a/app/helper/helpers.py b/app/helper/helpers.py new file mode 100644 index 0000000..6483670 --- /dev/null +++ b/app/helper/helpers.py @@ -0,0 +1,7 @@ +from fastapi import Request + +def get_client_ip(request: Request) -> str: + forwarded = request.headers.get("X-Forwarded-For") + if forwarded: + return forwarded.split(",")[0].strip() + return request.client.host if request.client else "unknown" \ No newline at end of file diff --git a/app/models/system/audit_log.py b/app/models/system/audit_log.py new file mode 100644 index 0000000..caceaa0 --- /dev/null +++ b/app/models/system/audit_log.py @@ -0,0 +1,38 @@ +import uuid +from sqlalchemy import Column, String, DateTime, Text +from sqlalchemy.dialects.postgresql import UUID, JSONB +from sqlalchemy.sql import func +from app.config.database import Base + +class AuditLog(Base): + __tablename__ = "audit_logs" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True) + + module_name = Column(String(100), nullable=False, index=True) + + action_type = Column(String(20), nullable=False, index=True) + + description = Column(Text, nullable=True) + + entity_id = Column(String(255), nullable=True) + + entity_name = Column(String(255), nullable=True) + + performed_by_id = Column(UUID(as_uuid=True), nullable=True) + performed_by_email = Column(String(255), nullable=True, index=True) + + ip_address = Column(String(45), nullable=True) + + old_values = Column(JSONB, nullable=True) + + new_values = Column(JSONB, nullable=True) + + created_at = Column(DateTime(timezone=True), server_default=func.now(), index=True) + + + def __repr__(self): + return ( + f" dict: + """Helper to capture state before changes for the Audit Log.""" + try: + env = db.query(ModuleEnvironment).filter( + ModuleEnvironment.id == uuid_module.UUID(env_id), + ModuleEnvironment.module_id == uuid_module.UUID(module_id) + ).first() + if not env: return {} + return { + "slug": env.slug, + "backend_base_url": env.backend_base_url, + "is_default": env.is_default, + "is_active": env.is_active + } + except: return {} + @router.get("/{module_id}/environments", response_model=List[EnvironmentResponse]) def list_environments( module_id: str, @@ -17,18 +39,35 @@ def list_environments( ): return ModuleEnvironmentController.list_environments(db, module_id) -@router.post("/{module_id}/environments", response_model=EnvironmentResponse) +@router.post("/{module_id}/environments", response_model=EnvironmentResponse, status_code=201) def create_environment( + request: Request, module_id: str, env_data: EnvironmentCreate, current_user: User = Depends(get_current_user), _: bool = Depends(require_access("modules.manage")), db: Session = Depends(get_db) ): - return ModuleEnvironmentController.create_environment(db, module_id, env_data) + module = ModuleService.get_module(db, module_id) + result = ModuleEnvironmentController.create_environment(db, module_id, env_data) + + AuditLogService.log( + db=db, + module_name="Module Environments", + action_type="CREATE", + entity_id=str(result.id), + entity_name=f"{module.module_name} / {result.slug}", + description=f"Environment '{result.slug}' created for '{module.module_name}'", + performed_by_id=str(current_user.id), + performed_by_email=current_user.email, + ip_address=_get_client_ip(request), + new_values=env_data.model_dump(exclude={"trust_credentials"}) + ) + return result @router.put("/{module_id}/environments/{env_id}", response_model=EnvironmentResponse) def update_environment( + request: Request, module_id: str, env_id: str, env_data: EnvironmentUpdate, @@ -36,24 +75,76 @@ def update_environment( _: bool = Depends(require_access("modules.manage")), db: Session = Depends(get_db) ): - return ModuleEnvironmentController.update_environment(db, module_id, env_id, env_data) + module = ModuleService.get_module(db, module_id) + old_values = _get_env_snapshot(db, module_id, env_id) + + result = ModuleEnvironmentController.update_environment(db, module_id, env_id, env_data) + + AuditLogService.log( + db=db, + module_name="Module Environments", + action_type="UPDATE", + entity_id=env_id, + entity_name=f"{module.module_name} / {result.slug}", + description=f"Environment '{result.slug}' updated", + performed_by_id=str(current_user.id), + performed_by_email=current_user.email, + ip_address=_get_client_ip(request), + old_values=old_values, + new_values=env_data.model_dump(exclude_unset=True, exclude={"trust_credentials"}) + ) + return result @router.patch("/{module_id}/environments/{env_id}/default") def set_default_environment( + request: Request, module_id: str, env_id: str, current_user: User = Depends(get_current_user), _: bool = Depends(require_access("modules.manage")), db: Session = Depends(get_db) ): - return ModuleEnvironmentController.set_default_environment(db, module_id, env_id) + module = ModuleService.get_module(db, module_id) + result = ModuleEnvironmentController.set_default_environment(db, module_id, env_id) + + AuditLogService.log( + db=db, + module_name="Module Environments", + action_type="UPDATE", + entity_id=env_id, + entity_name=f"{module.module_name}", + description=f"Set default environment for {module.module_name}", + performed_by_id=str(current_user.id), + performed_by_email=current_user.email, + ip_address=_get_client_ip(request), + new_values={"is_default": True} + ) + return result @router.delete("/{module_id}/environments/{env_id}") def delete_environment( + request: Request, module_id: str, env_id: str, current_user: User = Depends(get_current_user), _: bool = Depends(require_access("modules.manage")), db: Session = Depends(get_db) ): - return ModuleEnvironmentController.delete_environment(db, module_id, env_id) \ No newline at end of file + module = ModuleService.get_module(db, module_id) + snapshot = _get_env_snapshot(db, module_id, env_id) + + result = ModuleEnvironmentController.delete_environment(db, module_id, env_id) + + AuditLogService.log( + db=db, + module_name="Module Environments", + action_type="DELETE", + entity_id=env_id, + entity_name=f"{module.module_name} / {snapshot.get('slug')}", + description=f"Environment deleted from {module.module_name}", + performed_by_id=str(current_user.id), + performed_by_email=current_user.email, + ip_address=_get_client_ip(request), + old_values=snapshot + ) + return result \ No newline at end of file diff --git a/app/routes/admin/modules.py b/app/routes/admin/modules.py index 4777fda..2218e5c 100644 --- a/app/routes/admin/modules.py +++ b/app/routes/admin/modules.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request from sqlalchemy.orm import Session from typing import List from app.models.auth.module_model import Module @@ -10,6 +10,8 @@ from app.services.auth.module_permission_service import ModulePermissionService from app.middleware.auth_middleware import get_current_user, require_access, User from app.schemas.auth.module_schema import ModuleCreate, ModuleUpdate, ModuleResponse from app.controllers.auth.module_controller import ModuleController +from app.services.system.audit_log_service import AuditLogService +from app.helper.helpers import get_client_ip router = APIRouter() @@ -21,14 +23,30 @@ def list_modules( ): return ModuleController.list_modules(db) -@router.post("/", response_model=ModuleResponse) +@router.post("/", response_model=ModuleResponse, status_code=201) def create_module( + request: Request, module_data: ModuleCreate, current_user: User = Depends(get_current_user), _: bool = Depends(require_access("modules.manage")), db: Session = Depends(get_db) ): - return ModuleController.create_module(db, module_data) + result = ModuleController.create_module(db, module_data) + + # Professional Audit Logging + AuditLogService.log( + db=db, + module_name="Modules", + action_type="CREATE", + entity_id=str(result.id), + entity_name=result.module_name, + description=f"Module '{result.module_name}' created", + performed_by_id=str(current_user.id), + performed_by_email=current_user.email, + ip_address=_get_client_ip(request), + new_values=module_data.model_dump() + ) + return result @router.get("/{module_id}", response_model=ModuleResponse) def get_module( @@ -41,48 +59,72 @@ def get_module( @router.put("/{module_id}", response_model=ModuleResponse) def update_module( + request: Request, module_id: str, module_data: ModuleUpdate, current_user: User = Depends(get_current_user), _: bool = Depends(require_access("modules.manage")), db: Session = Depends(get_db) ): - return ModuleController.update_module(db, module_id, module_data) + # Snapshot before update + existing = ModuleController.get_module(db, module_id) + old_values = {"name": existing.module_name, "status": existing.status} + + result = ModuleController.update_module(db, module_id, module_data) + + AuditLogService.log( + db=db, + module_name="Modules", + action_type="UPDATE", + entity_id=module_id, + entity_name=result.module_name, + description=f"Module '{result.module_name}' updated", + performed_by_id=str(current_user.id), + performed_by_email=current_user.email, + ip_address=_get_client_ip(request), + old_values=old_values, + new_values=module_data.model_dump(exclude_unset=True) + ) + return result @router.delete("/{module_id}") def delete_module( + request: Request, module_id: str, current_user: User = Depends(get_current_user), _: bool = Depends(require_access("modules.manage")), db: Session = Depends(get_db) ): - return ModuleController.delete_module(db, module_id) + # Capture name for the log before it's deleted + existing = ModuleController.get_module(db, module_id) + module_name = existing.module_name -@router.get("/{module_id}/permissions", response_model=List[dict]) + result = ModuleController.delete_module(db, module_id) + + AuditLogService.log( + db=db, + module_name="Modules", + action_type="DELETE", + entity_id=module_id, + entity_name=module_name, + description=f"Module '{module_name}' deleted", + performed_by_id=str(current_user.id), + performed_by_email=current_user.email, + ip_address=_get_client_ip(request) + ) + return result + +# ── Permission Sync Routes ────────────────────────────────────────────────── + +@router.get("/{module_id}/permissions") def get_module_permissions( module_id: str, current_user: User = Depends(get_current_user), _: bool = Depends(require_access("modules.view")), db: Session = Depends(get_db) ): - - module = db.query(Module).filter(Module.id == uuid.UUID(module_id)).first() - if not module: - raise HTTPException(status_code=404, detail="Module not found") - - permissions = db.query(ModuleAccess).filter( - ModuleAccess.module_id == module.id - ).all() - - return [{ - "id": str(p.id), - "access_code": p.access_code, - "name": p.name, - "category": p.category, - "parent_id": str(p.parent_id) if p.parent_id else None, - "scope": "module", - "module_id": str(p.module_id) - } for p in permissions] + # Now using the Service method you just shared! + return ModulePermissionService.get_module_permissions(db, module_id) @router.post("/{module_id}/permissions/sync") def sync_module_permissions( @@ -91,11 +133,12 @@ def sync_module_permissions( _: bool = Depends(require_access("modules.manage")), db: Session = Depends(get_db) ): - try: - result = ModulePermissionService.sync_permissions(db, module_id) - return result + return ModulePermissionService.sync_permissions(db, module_id) except Exception as e: if isinstance(e, HTTPException): raise e - raise HTTPException(status_code=500, detail=f"Permission sync failed: {str(e)}") \ No newline at end of file + raise HTTPException( + status_code=500, + detail=f"Permission sync failed: {str(e)}" + ) diff --git a/app/routes/admin/tenant_modules.py b/app/routes/admin/tenant_modules.py index 1eb0928..e178fed 100644 --- a/app/routes/admin/tenant_modules.py +++ b/app/routes/admin/tenant_modules.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, status, HTTPException, Request, Query from sqlalchemy.orm import Session from typing import List @@ -6,8 +6,10 @@ from app.config.database import get_db from app.middleware.auth_middleware import get_current_user, require_access, User from app.schemas.auth.tenant_module_schema import TenantModuleCreate, TenantModuleUpdate, TenantModuleResponse from app.controllers.auth.tenant_module_controller import TenantModuleController +from app.services.system.audit_log_service import AuditLogService +from app.helper.helpers import get_client_ip -router = APIRouter() +router = APIRouter() @router.get("/{tenant_id}/modules", response_model=List[TenantModuleResponse]) def list_tenant_modules( @@ -20,16 +22,32 @@ def list_tenant_modules( @router.post("/{tenant_id}/modules", response_model=TenantModuleResponse) def assign_module_to_tenant( + request: Request, tenant_id: str, assignment_data: TenantModuleCreate, current_user: User = Depends(get_current_user), _: bool = Depends(require_access("tenants.manage")), db: Session = Depends(get_db) ): - return TenantModuleController.assign_module(db, tenant_id, assignment_data) + result = TenantModuleController.assign_module(db, tenant_id, assignment_data) + + AuditLogService.log( + db=db, + module_name="Tenant Modules", + action_type="CREATE", + entity_id=str(result.id), + entity_name=f"Tenant {tenant_id} / Module {assignment_data.module_id}", + description=f"Module assigned to tenant {tenant_id}", + performed_by_id=str(current_user.id), + performed_by_email=current_user.email, + ip_address=_get_client_ip(request), + new_values=assignment_data.model_dump(mode='json') + ) + return result @router.put("/{tenant_id}/modules/{tenant_module_id}", response_model=TenantModuleResponse) def update_tenant_module( + request: Request, tenant_id: str, tenant_module_id: str, update_data: TenantModuleUpdate, @@ -37,14 +55,42 @@ def update_tenant_module( _: bool = Depends(require_access("tenants.manage")), db: Session = Depends(get_db) ): - return TenantModuleController.update_assignment(db, tenant_id, tenant_module_id, update_data) + result = TenantModuleController.update_assignment(db, tenant_id, tenant_module_id, update_data) + + AuditLogService.log( + db=db, + module_name="Tenant Modules", + action_type="UPDATE", + entity_id=tenant_module_id, + entity_name=f"Tenant {tenant_id} / Module assignment", + description=f"Tenant module assignment updated", + performed_by_id=str(current_user.id), + performed_by_email=current_user.email, + ip_address=_get_client_ip(request), + new_values=update_data.model_dump(exclude_unset=True) + ) + return result @router.delete("/{tenant_id}/modules/{tenant_module_id}") def remove_module_from_tenant( + request: Request, tenant_id: str, tenant_module_id: str, current_user: User = Depends(get_current_user), _: bool = Depends(require_access("tenants.manage")), db: Session = Depends(get_db) ): - return TenantModuleController.remove_assignment(db, tenant_id, tenant_module_id) \ No newline at end of file + result = TenantModuleController.remove_assignment(db, tenant_id, tenant_module_id) + + AuditLogService.log( + db=db, + module_name="Tenant Modules", + action_type="DELETE", + entity_id=tenant_module_id, + entity_name=f"Tenant {tenant_id} / Module assignment", + description=f"Tenant module assignment deleted", + performed_by_id=str(current_user.id), + performed_by_email=current_user.email, + ip_address=_get_client_ip(request), + ) + return result \ No newline at end of file diff --git a/app/routes/auth/role.py b/app/routes/auth/role.py index bcc12be..f39b04d 100644 --- a/app/routes/auth/role.py +++ b/app/routes/auth/role.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, status, Query +from fastapi import APIRouter, Depends, status, Query, Request from sqlalchemy.orm import Session from typing import List, Optional import uuid @@ -13,20 +13,40 @@ from app.schemas.auth.role_schema import ( ) from app.middleware.auth_middleware import get_current_user, require_access from app.models.auth.user_model import User +from app.services.system.audit_log_service import AuditLogService +from app.helper.helpers import get_client_ip router = APIRouter() @router.post("/create", response_model=RoleResponse, status_code=status.HTTP_201_CREATED) def create_role( + request: Request, role_data: RoleCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), _=Depends(require_access("admin.role.create")), ): - return RoleController.create_role( + result = RoleController.create_role( db, role_data, current_user.tenant_id if current_user.tenant_id else None ) + AuditLogService.log( + db=db, + module_name="Roles", + action_type="CREATE", + entity_id=str(result.id), + entity_name=result.role_name, + description=f"Role '{result.role_name}' created", + performed_by_id=str(current_user.id), + performed_by_email=current_user.email, + ip_address=_get_client_ip(request), + new_values={ + "role_name": result.role_name, + "tenant_id": str(result.tenant_id) if result.tenant_id else None, + } + ) + return result + @router.get("/get", response_model=List[RoleResponse]) def get_all_roles( db: Session = Depends(get_db), @@ -48,26 +68,67 @@ def get_role( @router.put("/update/{role_id}", response_model=RoleResponse) def update_role( + request: Request, role_id: uuid.UUID, role_data: RoleUpdate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), _=Depends(require_access("admin.role.update")), ): + + existing = RoleController.get_role_by_id(db, role_id) + old_values = {"role_name": existing.role_name} + is_superadmin = current_user.tenant_id is None - return RoleController.update_role( + result = RoleController.update_role( db, role_id, role_data, is_superadmin=is_superadmin ) + new_values = role_data.model_dump(mode='json', exclude_unset=True) + + AuditLogService.log( + db=db, + module_name="Roles", + action_type="UPDATE", + entity_id=str(role_id), + entity_name=result.role_name, + description=f"Role '{result.role_name}' updated", + performed_by_id=str(current_user.id), + performed_by_email=current_user.email, + ip_address=_get_client_ip(request), + old_values=old_values, + new_values=new_values + ) + return result + @router.delete("/delete/{role_id}") def delete_role( + request: Request, role_id: uuid.UUID, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), _=Depends(require_access("admin.role.delete")), ): + + existing = RoleController.get_role_by_id(db, role_id) + role_name = existing.role_name + is_superadmin = current_user.tenant_id is None - return RoleController.delete_role(db, role_id, is_superadmin=is_superadmin) + result = RoleController.delete_role(db, role_id, is_superadmin=is_superadmin) + + AuditLogService.log( + db=db, + module_name="Roles", + action_type="DELETE", + entity_id=str(role_id), + entity_name=role_name, + description=f"Role '{result.role_name}' deleted", + performed_by_id=str(current_user.id), + performed_by_email=current_user.email, + ip_address=_get_client_ip(request), + old_values={"role_name": role_name} + ) + return result @router.get("/list", response_model=RolePaginatedResponse) def list_roles( diff --git a/app/routes/auth/tenant.py b/app/routes/auth/tenant.py index 69c93cb..a00f7d3 100644 --- a/app/routes/auth/tenant.py +++ b/app/routes/auth/tenant.py @@ -1,24 +1,41 @@ -from fastapi import APIRouter, Depends, status, HTTPException, Query -from sqlalchemy.orm import Session -from typing import List, Optional import uuid +from typing import List, Optional +from fastapi import APIRouter, Depends, status, HTTPException, Query, Request +from sqlalchemy.orm import Session + from app.config.database import get_db from app.controllers.auth.tenant_controller import TenantController from app.schemas.auth.tenant_schema import TenantCreate, TenantUpdate, TenantResponse, TenantPaginatedResponse -from app.middleware.auth_middleware import get_current_user, require_access +from app.middleware.auth_middleware import get_current_user, require_access, User from app.middleware.tenant_middleware import get_tenant_id_from_user -from app.models.auth.user_model import User +from app.services.system.audit_log_service import AuditLogService +from app.helper.helpers import get_client_ip router = APIRouter() @router.post("/create", response_model=TenantResponse, status_code=status.HTTP_201_CREATED) def create_tenant( + request: Request, tenant_data: TenantCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), _ = Depends(require_access("superadmin.tenant.create")) ): - return TenantController.create_tenant(db, tenant_data) + result = TenantController.create_tenant(db, tenant_data) + + AuditLogService.log( + db=db, + module_name="Tenants", + action_type="CREATE", + entity_id=str(result.id), + entity_name=result.tenant_name, + description=f"Tenant '{result.tenant_name}' onboarded", + performed_by_id=str(current_user.id), + performed_by_email=current_user.email, + ip_address=_get_client_ip(request), + new_values=tenant_data.model_dump(mode='json') + ) + return result @router.get("/get", response_model=List[TenantResponse]) def get_all_tenants( @@ -26,17 +43,9 @@ def get_all_tenants( current_user: User = Depends(get_current_user), _ = Depends(require_access("superadmin.tenant.read")) ): + # READ actions are typically not logged to avoid DB bloat return TenantController.get_all_tenants(db) -@router.get("/get/{tenant_id}", response_model=TenantResponse) -def get_tenant( - tenant_id: uuid.UUID, - db: Session = Depends(get_db), - current_user: User = Depends(get_current_user), - _ = Depends(require_access("superadmin.tenant.read")) -): - return TenantController.get_tenant_by_id(db, tenant_id) - @router.get("/me", response_model=TenantResponse) def get_my_tenant( db: Session = Depends(get_db), @@ -44,30 +53,79 @@ def get_my_tenant( ): tenant_id = get_tenant_id_from_user(current_user) if not tenant_id: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Tenant not found" - ) + raise HTTPException(status_code=404, detail="Tenant not found") return TenantController.get_tenant_by_id(db, tenant_id) @router.put("/update/{tenant_id}", response_model=TenantResponse) def update_tenant( + request: Request, tenant_id: uuid.UUID, tenant_data: TenantUpdate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), _ = Depends(require_access("superadmin.tenant.update")) ): - return TenantController.update_tenant(db, tenant_id, tenant_data) + # 1. Get snapshot BEFORE update for Audit Log + existing = TenantController.get_tenant_by_id(db, tenant_id) + old_snapshot = { + "tenant_name": existing.tenant_name, + "tenant_domain": existing.tenant_domain, + "is_active": existing.is_active + } + + # 2. Perform update + result = TenantController.update_tenant(db, tenant_id, tenant_data) + + # 3. Prepare new values and indentify deltas + new_snapshot = tenant_data.model_dump(mode='json', exclude_unset=True) + + old_values = {k: old_snapshot[k] for k in new_snapshot if k in old_snapshot and old_snapshot[k] != new_snapshot[k]} + new_values = {k: new_snapshot[k] for k in old_values} + + # 4. Log the change + AuditLogService.log( + db=db, + module_name="Tenants", + action_type="UPDATE", + entity_id=str(tenant_id), + entity_name=result.tenant_name, + description=f"Tenant '{result.tenant_name}' updated", + performed_by_id=str(current_user.id), + performed_by_email=current_user.email, + ip_address=_get_client_ip(request), + old_values=old_values, + new_values=new_values + ) + return result @router.delete("/delete/{tenant_id}") def delete_tenant( + request: Request, tenant_id: uuid.UUID, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), _ = Depends(require_access("superadmin.tenant.delete")) ): - return TenantController.delete_tenant(db, tenant_id) + # 1. Get snapshot before deletion + existing = TenantController.get_tenant_by_id(db, tenant_id) + + # 2. Perform deletion + result = TenantController.delete_tenant(db, tenant_id) + + # 3. Log the deletion + AuditLogService.log( + db=db, + module_name="Tenants", + action_type="DELETE", + entity_id=str(tenant_id), + entity_name=existing.tenant_name, + description=f"Tenant '{existing.tenant_name}' removed", + performed_by_id=str(current_user.id), + performed_by_email=current_user.email, + ip_address=_get_client_ip(request), + old_values={"tenant_name": existing.tenant_name, "domain": existing.tenant_domain} + ) + return result @router.get("/list", response_model=TenantPaginatedResponse) def list_tenants( diff --git a/app/routes/auth/user.py b/app/routes/auth/user.py index 8b6bedb..3b32751 100644 --- a/app/routes/auth/user.py +++ b/app/routes/auth/user.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, status, Query, BackgroundTasks +from fastapi import APIRouter, Depends, status, Query, BackgroundTasks, Request from sqlalchemy.orm import Session from typing import List, Optional import uuid @@ -7,18 +7,38 @@ from app.controllers.auth.user_controller import UserController from app.schemas.auth.user_schema import UserCreate, UserUpdate, UserResponse, UserPaginatedResponse from app.middleware.auth_middleware import get_current_user, require_access from app.models.auth.user_model import User +from app.services.system.audit_log_service import AuditLogService +from app.helper.helpers import get_client_ip router = APIRouter() - + @router.post("/create", response_model=UserResponse, status_code=status.HTTP_201_CREATED) def create_user( + request: Request, user_data: UserCreate, background_tasks: BackgroundTasks, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), _ = Depends(require_access("admin.user.create")) ): - return UserController.create_user(db, user_data, current_user, background_tasks) + result = UserController.create_user(db, user_data, current_user, background_tasks) + + AuditLogService.log( + db=db, + module_name="Users", + action_type="CREATE", + entity_id=str(result.id), + entity_name=result.email, + description=f"User '{result.email}' created", + performed_by_id=str(current_user.id), + performed_by_email=current_user.email, + ip_address=_get_client_ip(request), + new_values={ + "email": result.email, + "tenant_id": str(result.tenant_id) if result.tenant_id else None, + } + ) + return result @router.get("/get", response_model=List[UserResponse]) def get_all_users( @@ -39,6 +59,7 @@ def get_user( @router.put("/update/{user_id}", response_model=UserResponse) def update_user( + request: Request, user_id: uuid.UUID, user_data: UserUpdate, background_tasks: BackgroundTasks, @@ -46,16 +67,54 @@ def update_user( current_user: User = Depends(get_current_user), _ = Depends(require_access("admin.user.update")) ): - return UserController.update_user(db, user_id, user_data, current_user, background_tasks) + + existing = UserController.get_user_by_id(db, user_id, current_user) + old_values = {"email": existing.email} + + result = UserController.update_user(db, user_id, user_data, current_user, background_tasks) + + AuditLogService.log( + db=db, + module_name="Users", + action_type="UPDATE", + entity_id=str(user_id), + entity_name=result.email, + description=f"User '{result.email}' updated", + performed_by_id=str(current_user.id), + performed_by_email=current_user.email, + ip_address=_get_client_ip(request), + old_values=old_values, + new_values=user_data.model_dump(mode='json', exclude_unset=True) + ) + return result @router.delete("/delete/{user_id}") def delete_user( + request: Request, user_id: uuid.UUID, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), _ = Depends(require_access("admin.user.delete")) ): - return UserController.delete_user(db, user_id, current_user) + + existing = UserController.get_user_by_id(db, user_id, current_user) + user_email = existing.email + + result = UserController.delete_user(db, user_id, current_user) + + AuditLogService.log( + db=db, + module_name="Users", + action_type="DELETE", + entity_id=str(user_id), + entity_name=user_email, + description=f"User '{user_email}' deleted", + performed_by_id=str(current_user.id), + performed_by_email=current_user.email, + ip_address=_get_client_ip(request), + old_values={"email": user_email} + ) + return result @router.get("/list", response_model=UserPaginatedResponse) def list_users( diff --git a/app/schemas/auth/audit_schema.py b/app/schemas/auth/audit_schema.py new file mode 100644 index 0000000..c296059 --- /dev/null +++ b/app/schemas/auth/audit_schema.py @@ -0,0 +1,26 @@ +from pydantic import BaseModel +from typing import List, Optional, Dict, Any +from uuid import UUID +from datetime import datetime + +class AuditLogResponse(BaseModel): + id: UUID + module_name: str + action_type: str + entity_id: Optional[str] = None + entity_name: Optional[str] = None + description: Optional[str] = None + performed_by_email: Optional[str] = None + ip_address: Optional[str] = None + old_values: Optional[Dict[str, Any]] = None + new_values: Optional[Dict[str, Any]] = None + created_at: datetime + + class Config: + from_attributes = True + +class AuditLogListResponse(BaseModel): + items: List[AuditLogResponse] + total: int + limit: int + offset: int \ No newline at end of file diff --git a/app/services/system/audit_log_service.py b/app/services/system/audit_log_service.py new file mode 100644 index 0000000..6a9a48e --- /dev/null +++ b/app/services/system/audit_log_service.py @@ -0,0 +1,52 @@ +import logging +import uuid +from typing import Any, Dict, Optional + +from sqlalchemy.orm import Session + +from app.models.system.audit_log import AuditLog + +logger = logging.getLogger(__name__) + + +class AuditLogService: + @staticmethod + def log( + db: Session, + module_name: str, + action_type: str, + description: str, + entity_id: Optional[str] = None, + entity_name: Optional[str] = None, + performed_by_id: Optional[str] = None, + performed_by_email: Optional[str] = None, + ip_address: Optional[str] = None, + old_values: Optional[Dict[str, Any]] = None, + new_values: Optional[Dict[str, Any]] = None, + ) -> None: + """ + Write an audit log entry. + + Failures are silently swallowed and logged — they must NEVER break + the main operation that triggered them. + """ + try: + entry = AuditLog( + module_name=module_name, + action_type=action_type, + entity_id=entity_id, + entity_name=entity_name, + description=description, + performed_by_id=( + uuid.UUID(performed_by_id) if performed_by_id else None + ), + performed_by_email=performed_by_email, + ip_address=ip_address, + old_values=old_values, + new_values=new_values, + ) + db.add(entry) + db.commit() + except Exception as exc: + db.rollback() + logger.error("AuditLogService.log failed: %s", exc, exc_info=True) \ No newline at end of file diff --git a/scripts/seed_superadmin.py b/scripts/seed_superadmin.py index 9022ef2..f5eb64e 100644 --- a/scripts/seed_superadmin.py +++ b/scripts/seed_superadmin.py @@ -64,6 +64,7 @@ PREDEFINED_ACCESSES = [ ("admin.user.update", "Administration", "Allow access to update users", None), ("admin.user.delete", "Administration", "Allow access to delete users", None), ("admin.access.read", "Administration", "Allow access to view accesses", None), + ("admin.logs.read", "Administration", "Allow access to view system audit logs", None), ]