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 sqlalchemy import text
from app.config.settings import settings from app.config.settings import settings
from app.config.database import engine from app.config.database import engine
from app.routes.admin import audit_logs
import app.models.auth.user_model import app.models.auth.user_model
import app.models.auth.role_model import app.models.auth.role_model
import app.models.auth.tenant_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.sso_grant_model
import app.models.auth.access_model import app.models.auth.access_model
import app.models.system.event_log_model import app.models.system.event_log_model
import app.models.system.audit_log
import asyncio import asyncio
from app.services.auth.event_service import EventService from app.services.auth.event_service import EventService
from app.config.database import SessionLocal 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.modules import router as admin_modules_router
from app.routes.admin.module_environments import router as admin_module_env_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 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_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_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"]) 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 import uuid as uuid_module
from sqlalchemy.orm import Session
from typing import List from typing import List
from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session
from app.config.database import get_db from app.config.database import get_db
from app.middleware.auth_middleware import get_current_user, require_access, User 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.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() 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]) @router.get("/{module_id}/environments", response_model=List[EnvironmentResponse])
def list_environments( def list_environments(
module_id: str, module_id: str,
@@ -17,18 +39,35 @@ def list_environments(
): ):
return ModuleEnvironmentController.list_environments(db, module_id) 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( def create_environment(
request: Request,
module_id: str, module_id: str,
env_data: EnvironmentCreate, env_data: EnvironmentCreate,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
_: bool = Depends(require_access("modules.manage")), _: bool = Depends(require_access("modules.manage")),
db: Session = Depends(get_db) 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) @router.put("/{module_id}/environments/{env_id}", response_model=EnvironmentResponse)
def update_environment( def update_environment(
request: Request,
module_id: str, module_id: str,
env_id: str, env_id: str,
env_data: EnvironmentUpdate, env_data: EnvironmentUpdate,
@@ -36,24 +75,76 @@ def update_environment(
_: bool = Depends(require_access("modules.manage")), _: bool = Depends(require_access("modules.manage")),
db: Session = Depends(get_db) 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") @router.patch("/{module_id}/environments/{env_id}/default")
def set_default_environment( def set_default_environment(
request: Request,
module_id: str, module_id: str,
env_id: str, env_id: str,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
_: bool = Depends(require_access("modules.manage")), _: bool = Depends(require_access("modules.manage")),
db: Session = Depends(get_db) 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}") @router.delete("/{module_id}/environments/{env_id}")
def delete_environment( def delete_environment(
request: Request,
module_id: str, module_id: str,
env_id: str, env_id: str,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
_: bool = Depends(require_access("modules.manage")), _: bool = Depends(require_access("modules.manage")),
db: Session = Depends(get_db) 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 sqlalchemy.orm import Session
from typing import List from typing import List
from app.models.auth.module_model import Module 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.middleware.auth_middleware import get_current_user, require_access, User
from app.schemas.auth.module_schema import ModuleCreate, ModuleUpdate, ModuleResponse from app.schemas.auth.module_schema import ModuleCreate, ModuleUpdate, ModuleResponse
from app.controllers.auth.module_controller import ModuleController 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() router = APIRouter()
@@ -21,14 +23,30 @@ def list_modules(
): ):
return ModuleController.list_modules(db) return ModuleController.list_modules(db)
@router.post("/", response_model=ModuleResponse) @router.post("/", response_model=ModuleResponse, status_code=201)
def create_module( def create_module(
request: Request,
module_data: ModuleCreate, module_data: ModuleCreate,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
_: bool = Depends(require_access("modules.manage")), _: bool = Depends(require_access("modules.manage")),
db: Session = Depends(get_db) 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) @router.get("/{module_id}", response_model=ModuleResponse)
def get_module( def get_module(
@@ -41,48 +59,72 @@ def get_module(
@router.put("/{module_id}", response_model=ModuleResponse) @router.put("/{module_id}", response_model=ModuleResponse)
def update_module( def update_module(
request: Request,
module_id: str, module_id: str,
module_data: ModuleUpdate, module_data: ModuleUpdate,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
_: bool = Depends(require_access("modules.manage")), _: bool = Depends(require_access("modules.manage")),
db: Session = Depends(get_db) 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}") @router.delete("/{module_id}")
def delete_module( def delete_module(
request: Request,
module_id: str, module_id: str,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
_: bool = Depends(require_access("modules.manage")), _: bool = Depends(require_access("modules.manage")),
db: Session = Depends(get_db) 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( def get_module_permissions(
module_id: str, module_id: str,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
_: bool = Depends(require_access("modules.view")), _: bool = Depends(require_access("modules.view")),
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
# Now using the Service method you just shared!
module = db.query(Module).filter(Module.id == uuid.UUID(module_id)).first() return ModulePermissionService.get_module_permissions(db, module_id)
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]
@router.post("/{module_id}/permissions/sync") @router.post("/{module_id}/permissions/sync")
def sync_module_permissions( def sync_module_permissions(
@@ -91,11 +133,12 @@ def sync_module_permissions(
_: bool = Depends(require_access("modules.manage")), _: bool = Depends(require_access("modules.manage")),
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
try: try:
result = ModulePermissionService.sync_permissions(db, module_id) return ModulePermissionService.sync_permissions(db, module_id)
return result
except Exception as e: except Exception as e:
if isinstance(e, HTTPException): if isinstance(e, HTTPException):
raise e 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 sqlalchemy.orm import Session
from typing import List 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.middleware.auth_middleware import get_current_user, require_access, User
from app.schemas.auth.tenant_module_schema import TenantModuleCreate, TenantModuleUpdate, TenantModuleResponse from app.schemas.auth.tenant_module_schema import TenantModuleCreate, TenantModuleUpdate, TenantModuleResponse
from app.controllers.auth.tenant_module_controller import TenantModuleController 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]) @router.get("/{tenant_id}/modules", response_model=List[TenantModuleResponse])
def list_tenant_modules( def list_tenant_modules(
@@ -20,16 +22,32 @@ def list_tenant_modules(
@router.post("/{tenant_id}/modules", response_model=TenantModuleResponse) @router.post("/{tenant_id}/modules", response_model=TenantModuleResponse)
def assign_module_to_tenant( def assign_module_to_tenant(
request: Request,
tenant_id: str, tenant_id: str,
assignment_data: TenantModuleCreate, assignment_data: TenantModuleCreate,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
_: bool = Depends(require_access("tenants.manage")), _: bool = Depends(require_access("tenants.manage")),
db: Session = Depends(get_db) 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) @router.put("/{tenant_id}/modules/{tenant_module_id}", response_model=TenantModuleResponse)
def update_tenant_module( def update_tenant_module(
request: Request,
tenant_id: str, tenant_id: str,
tenant_module_id: str, tenant_module_id: str,
update_data: TenantModuleUpdate, update_data: TenantModuleUpdate,
@@ -37,14 +55,42 @@ def update_tenant_module(
_: bool = Depends(require_access("tenants.manage")), _: bool = Depends(require_access("tenants.manage")),
db: Session = Depends(get_db) 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}") @router.delete("/{tenant_id}/modules/{tenant_module_id}")
def remove_module_from_tenant( def remove_module_from_tenant(
request: Request,
tenant_id: str, tenant_id: str,
tenant_module_id: str, tenant_module_id: str,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
_: bool = Depends(require_access("tenants.manage")), _: bool = Depends(require_access("tenants.manage")),
db: Session = Depends(get_db) 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 sqlalchemy.orm import Session
from typing import List, Optional from typing import List, Optional
import uuid 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.middleware.auth_middleware import get_current_user, require_access
from app.models.auth.user_model import 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 = APIRouter()
@router.post("/create", response_model=RoleResponse, status_code=status.HTTP_201_CREATED) @router.post("/create", response_model=RoleResponse, status_code=status.HTTP_201_CREATED)
def create_role( def create_role(
request: Request,
role_data: RoleCreate, role_data: RoleCreate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
_=Depends(require_access("admin.role.create")), _=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 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]) @router.get("/get", response_model=List[RoleResponse])
def get_all_roles( def get_all_roles(
db: Session = Depends(get_db), db: Session = Depends(get_db),
@@ -48,26 +68,67 @@ def get_role(
@router.put("/update/{role_id}", response_model=RoleResponse) @router.put("/update/{role_id}", response_model=RoleResponse)
def update_role( def update_role(
request: Request,
role_id: uuid.UUID, role_id: uuid.UUID,
role_data: RoleUpdate, role_data: RoleUpdate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
_=Depends(require_access("admin.role.update")), _=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 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 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}") @router.delete("/delete/{role_id}")
def delete_role( def delete_role(
request: Request,
role_id: uuid.UUID, role_id: uuid.UUID,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
_=Depends(require_access("admin.role.delete")), _=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 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) @router.get("/list", response_model=RolePaginatedResponse)
def list_roles( 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 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.config.database import get_db
from app.controllers.auth.tenant_controller import TenantController from app.controllers.auth.tenant_controller import TenantController
from app.schemas.auth.tenant_schema import TenantCreate, TenantUpdate, TenantResponse, TenantPaginatedResponse 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.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 = APIRouter()
@router.post("/create", response_model=TenantResponse, status_code=status.HTTP_201_CREATED) @router.post("/create", response_model=TenantResponse, status_code=status.HTTP_201_CREATED)
def create_tenant( def create_tenant(
request: Request,
tenant_data: TenantCreate, tenant_data: TenantCreate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
_ = Depends(require_access("superadmin.tenant.create")) _ = 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]) @router.get("/get", response_model=List[TenantResponse])
def get_all_tenants( def get_all_tenants(
@@ -26,17 +43,9 @@ def get_all_tenants(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
_ = Depends(require_access("superadmin.tenant.read")) _ = Depends(require_access("superadmin.tenant.read"))
): ):
# READ actions are typically not logged to avoid DB bloat
return TenantController.get_all_tenants(db) 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) @router.get("/me", response_model=TenantResponse)
def get_my_tenant( def get_my_tenant(
db: Session = Depends(get_db), db: Session = Depends(get_db),
@@ -44,30 +53,79 @@ def get_my_tenant(
): ):
tenant_id = get_tenant_id_from_user(current_user) tenant_id = get_tenant_id_from_user(current_user)
if not tenant_id: if not tenant_id:
raise HTTPException( raise HTTPException(status_code=404, detail="Tenant not found")
status_code=status.HTTP_404_NOT_FOUND,
detail="Tenant not found"
)
return TenantController.get_tenant_by_id(db, tenant_id) return TenantController.get_tenant_by_id(db, tenant_id)
@router.put("/update/{tenant_id}", response_model=TenantResponse) @router.put("/update/{tenant_id}", response_model=TenantResponse)
def update_tenant( def update_tenant(
request: Request,
tenant_id: uuid.UUID, tenant_id: uuid.UUID,
tenant_data: TenantUpdate, tenant_data: TenantUpdate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
_ = Depends(require_access("superadmin.tenant.update")) _ = 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}") @router.delete("/delete/{tenant_id}")
def delete_tenant( def delete_tenant(
request: Request,
tenant_id: uuid.UUID, tenant_id: uuid.UUID,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
_ = Depends(require_access("superadmin.tenant.delete")) _ = 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) @router.get("/list", response_model=TenantPaginatedResponse)
def list_tenants( 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 sqlalchemy.orm import Session
from typing import List, Optional from typing import List, Optional
import uuid 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.schemas.auth.user_schema import UserCreate, UserUpdate, UserResponse, UserPaginatedResponse
from app.middleware.auth_middleware import get_current_user, require_access from app.middleware.auth_middleware import get_current_user, require_access
from app.models.auth.user_model import 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 = APIRouter()
@router.post("/create", response_model=UserResponse, status_code=status.HTTP_201_CREATED) @router.post("/create", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
def create_user( def create_user(
request: Request,
user_data: UserCreate, user_data: UserCreate,
background_tasks: BackgroundTasks, background_tasks: BackgroundTasks,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
_ = Depends(require_access("admin.user.create")) _ = 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]) @router.get("/get", response_model=List[UserResponse])
def get_all_users( def get_all_users(
@@ -39,6 +59,7 @@ def get_user(
@router.put("/update/{user_id}", response_model=UserResponse) @router.put("/update/{user_id}", response_model=UserResponse)
def update_user( def update_user(
request: Request,
user_id: uuid.UUID, user_id: uuid.UUID,
user_data: UserUpdate, user_data: UserUpdate,
background_tasks: BackgroundTasks, background_tasks: BackgroundTasks,
@@ -46,16 +67,54 @@ def update_user(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
_ = Depends(require_access("admin.user.update")) _ = 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}") @router.delete("/delete/{user_id}")
def delete_user( def delete_user(
request: Request,
user_id: uuid.UUID, user_id: uuid.UUID,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
_ = Depends(require_access("admin.user.delete")) _ = 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) @router.get("/list", response_model=UserPaginatedResponse)
def list_users( 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.update", "Administration", "Allow access to update users", None),
("admin.user.delete", "Administration", "Allow access to delete users", None), ("admin.user.delete", "Administration", "Allow access to delete users", None),
("admin.access.read", "Administration", "Allow access to view accesses", None), ("admin.access.read", "Administration", "Allow access to view accesses", None),
("admin.logs.read", "Administration", "Allow access to view system audit logs", None),
] ]