fix: audit logs for tenants, user, roles, modules and environment and modules of tenant and modules.

2 migration scripts
This commit is contained in:
amee
2026-04-03 12:15:56 +05:30
parent 5fc241e59c
commit dc711fe82c
16 changed files with 701 additions and 72 deletions
@@ -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 ###
@@ -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 ###
+4 -1
View File
@@ -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"])
+1
View File
@@ -0,0 +1 @@
from .helpers import get_client_ip
+7
View File
@@ -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"
+38
View File
@@ -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"<AuditLog {self.action_type} on {self.module_name}"
f" by {self.performed_by_email}"
)
+50
View File
@@ -0,0 +1,50 @@
from typing import Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.config.database import get_db
from app.middleware.auth_middleware import get_current_user, User
from app.models.system.audit_log import AuditLog
from app.schemas.auth.audit_schema import AuditLogListResponse
router = APIRouter()
@router.get("/", response_model=AuditLogListResponse)
def get_audit_logs(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
module_name: Optional[str] = Query(None, description="Filter by module name"),
action_type: Optional[str] = Query(None, description="CREATE | UPDATE | DELETE"),
performed_by_email: Optional[str] = Query(None, description="Filter by actor email"),
limit: int = Query(50, ge=1, le=500),
offset: int = Query(0, ge=0),
):
"""
Fetch audit logs with optional filters.
Only accessible to authenticated users (super-admin in practice).
"""
query = db.query(AuditLog)
if module_name:
query = query.filter(AuditLog.module_name == module_name)
if action_type:
query = query.filter(AuditLog.action_type == action_type.upper())
if performed_by_email:
query = query.filter(
AuditLog.performed_by_email.ilike(f"%{performed_by_email}%")
)
total = query.count()
items = (
query.order_by(AuditLog.created_at.desc())
.offset(offset)
.limit(limit)
.all()
)
return AuditLogListResponse(
items=items,
total=total,
limit=limit,
offset=offset,
)
+99 -8
View File
@@ -1,13 +1,35 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
import uuid as uuid_module
from typing import List
from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session
from app.config.database import get_db
from app.middleware.auth_middleware import get_current_user, require_access, User
from app.schemas.auth.module_environment_schema import EnvironmentCreate, EnvironmentUpdate, EnvironmentResponse
from app.controllers.auth.module_environment_controller import ModuleEnvironmentController
from app.models.auth.module_environment_model import ModuleEnvironment
from app.schemas.auth.module_environment_schema import EnvironmentCreate, EnvironmentResponse, EnvironmentUpdate
from app.services.auth.module_service import ModuleService
from app.services.system.audit_log_service import AuditLogService
from app.helper.helpers import get_client_ip
router = APIRouter()
def _get_env_snapshot(db: Session, module_id: str, env_id: str) -> 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)
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
+71 -28
View File
@@ -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)}")
raise HTTPException(
status_code=500,
detail=f"Permission sync failed: {str(e)}"
)
+51 -5
View File
@@ -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)
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
+65 -4
View File
@@ -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(
+79 -21
View File
@@ -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(
+64 -5
View File
@@ -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(
+26
View File
@@ -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
+52
View File
@@ -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)
+1
View File
@@ -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),
]