307 lines
9.4 KiB
Python
307 lines
9.4 KiB
Python
"""
|
|
Security Audit Logging for Multi-Tenancy
|
|
|
|
Tracks security-relevant events for compliance and incident investigation:
|
|
- Login attempts (success/failure)
|
|
- Org switches
|
|
- Permission denials
|
|
- API key usage
|
|
- Suspicious activities
|
|
|
|
Usage:
|
|
from common.audit_log import audit_log
|
|
|
|
audit_log.login_success(user, org, request)
|
|
audit_log.org_switch(user, from_org, to_org, request)
|
|
audit_log.permission_denied(user, org, action, resource, request)
|
|
"""
|
|
|
|
import logging
|
|
|
|
from django.db import models
|
|
from django.utils import timezone
|
|
|
|
from common.base import BaseModel
|
|
|
|
logger = logging.getLogger("security.audit")
|
|
|
|
|
|
class SecurityAuditLog(BaseModel):
|
|
"""
|
|
Model to store security audit events.
|
|
|
|
This is separate from Activity model which tracks business operations.
|
|
SecurityAuditLog tracks authentication and authorization events.
|
|
"""
|
|
|
|
EVENT_TYPES = (
|
|
("LOGIN_SUCCESS", "Login Success"),
|
|
("LOGIN_FAILURE", "Login Failure"),
|
|
("LOGOUT", "Logout"),
|
|
("ORG_SWITCH", "Organization Switch"),
|
|
("TOKEN_REFRESH", "Token Refresh"),
|
|
("TOKEN_REVOKED", "Token Revoked"),
|
|
("PERMISSION_DENIED", "Permission Denied"),
|
|
("CROSS_ORG_ATTEMPT", "Cross-Org Access Attempt"),
|
|
("API_KEY_USED", "API Key Used"),
|
|
("API_KEY_INVALID", "Invalid API Key"),
|
|
("MEMBERSHIP_REVOKED", "Membership Revoked"),
|
|
("SUSPICIOUS_ACTIVITY", "Suspicious Activity"),
|
|
)
|
|
|
|
event_type = models.CharField(max_length=50, choices=EVENT_TYPES, db_index=True)
|
|
user = models.ForeignKey(
|
|
"common.User",
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="security_audit_logs",
|
|
)
|
|
org = models.ForeignKey(
|
|
"common.Org",
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="security_audit_logs",
|
|
)
|
|
|
|
# Event details
|
|
description = models.TextField(blank=True, default="")
|
|
metadata = models.JSONField(default=dict, blank=True)
|
|
|
|
# Request information
|
|
ip_address = models.GenericIPAddressField(null=True, blank=True)
|
|
user_agent = models.TextField(blank=True, default="")
|
|
request_path = models.CharField(max_length=500, blank=True, default="")
|
|
request_method = models.CharField(max_length=10, blank=True, default="")
|
|
|
|
# Outcome
|
|
success = models.BooleanField(default=True)
|
|
|
|
class Meta:
|
|
verbose_name = "Security Audit Log"
|
|
verbose_name_plural = "Security Audit Logs"
|
|
db_table = "security_audit_log"
|
|
ordering = ("-created_at",)
|
|
indexes = [
|
|
models.Index(fields=["event_type", "-created_at"]),
|
|
models.Index(fields=["user", "-created_at"]),
|
|
models.Index(fields=["org", "-created_at"]),
|
|
models.Index(fields=["ip_address"]),
|
|
models.Index(fields=["success", "-created_at"]),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.event_type} - {self.user} - {self.created_at}"
|
|
|
|
|
|
class AuditLogger:
|
|
"""
|
|
Helper class for logging security events.
|
|
|
|
Logs to both database (SecurityAuditLog) and Python logger.
|
|
"""
|
|
|
|
def _get_request_info(self, request):
|
|
"""Extract request information for logging."""
|
|
if not request:
|
|
return {}
|
|
|
|
# Get IP address
|
|
ip = request.META.get("HTTP_X_FORWARDED_FOR")
|
|
if ip:
|
|
ip = ip.split(",")[0].strip()
|
|
else:
|
|
ip = request.META.get("REMOTE_ADDR")
|
|
|
|
return {
|
|
"ip_address": ip,
|
|
"user_agent": request.META.get("HTTP_USER_AGENT", "")[:500],
|
|
"request_path": request.path[:500],
|
|
"request_method": request.method,
|
|
}
|
|
|
|
def _log(
|
|
self,
|
|
event_type,
|
|
user=None,
|
|
org=None,
|
|
description="",
|
|
metadata=None,
|
|
success=True,
|
|
request=None,
|
|
):
|
|
"""
|
|
Internal method to create audit log entry.
|
|
"""
|
|
request_info = self._get_request_info(request)
|
|
|
|
# Log to database
|
|
try:
|
|
SecurityAuditLog.objects.create(
|
|
event_type=event_type,
|
|
user=user,
|
|
org=org,
|
|
description=description,
|
|
metadata=metadata or {},
|
|
success=success,
|
|
**request_info,
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Failed to create audit log: {e}")
|
|
|
|
# Log to Python logger
|
|
log_level = logging.INFO if success else logging.WARNING
|
|
user_id = str(user.id) if user else "anonymous"
|
|
org_id = str(org.id) if org else "none"
|
|
|
|
logger.log(
|
|
log_level,
|
|
f"{event_type} | user_id={user_id} | org_id={org_id} | "
|
|
f"ip={request_info.get('ip_address')} | success={success}",
|
|
)
|
|
|
|
def login_success(self, user, org, request=None):
|
|
"""Log successful login."""
|
|
self._log(
|
|
"LOGIN_SUCCESS",
|
|
user=user,
|
|
org=org,
|
|
description=f"User logged in to {org.name if org else 'platform'}",
|
|
request=request,
|
|
)
|
|
|
|
def login_failure(self, email, reason, request=None):
|
|
"""Log failed login attempt."""
|
|
from common.models import User
|
|
|
|
user = User.objects.filter(email=email).first()
|
|
|
|
self._log(
|
|
"LOGIN_FAILURE",
|
|
user=user,
|
|
description=f"Login failed for {email}: {reason}",
|
|
metadata={"email": email, "reason": reason},
|
|
success=False,
|
|
request=request,
|
|
)
|
|
|
|
def logout(self, user, org, request=None):
|
|
"""Log user logout."""
|
|
self._log(
|
|
"LOGOUT",
|
|
user=user,
|
|
org=org,
|
|
description=f"User logged out",
|
|
request=request,
|
|
)
|
|
|
|
def org_switch(self, user, from_org, to_org, request=None):
|
|
"""Log organization switch."""
|
|
self._log(
|
|
"ORG_SWITCH",
|
|
user=user,
|
|
org=to_org,
|
|
description=f"Switched from {from_org.name if from_org else 'none'} to {to_org.name}",
|
|
metadata={
|
|
"from_org_id": str(from_org.id) if from_org else None,
|
|
"to_org_id": str(to_org.id),
|
|
},
|
|
request=request,
|
|
)
|
|
|
|
def token_refresh(self, user, org, request=None):
|
|
"""Log token refresh."""
|
|
self._log(
|
|
"TOKEN_REFRESH",
|
|
user=user,
|
|
org=org,
|
|
description="Token refreshed",
|
|
request=request,
|
|
)
|
|
|
|
def token_revoked(self, user, org, reason, request=None):
|
|
"""Log token revocation."""
|
|
self._log(
|
|
"TOKEN_REVOKED",
|
|
user=user,
|
|
org=org,
|
|
description=f"Token revoked: {reason}",
|
|
metadata={"reason": reason},
|
|
request=request,
|
|
)
|
|
|
|
def permission_denied(self, user, org, action, resource, request=None):
|
|
"""Log permission denial."""
|
|
self._log(
|
|
"PERMISSION_DENIED",
|
|
user=user,
|
|
org=org,
|
|
description=f"Permission denied for {action} on {resource}",
|
|
metadata={"action": action, "resource": resource},
|
|
success=False,
|
|
request=request,
|
|
)
|
|
|
|
def cross_org_attempt(self, user, user_org, target_org, resource, request=None):
|
|
"""Log attempted cross-org data access."""
|
|
self._log(
|
|
"CROSS_ORG_ATTEMPT",
|
|
user=user,
|
|
org=user_org,
|
|
description=f"Attempted to access {resource} in org {target_org.name if target_org else 'unknown'}",
|
|
metadata={
|
|
"user_org_id": str(user_org.id) if user_org else None,
|
|
"target_org_id": str(target_org.id) if target_org else None,
|
|
"resource": resource,
|
|
},
|
|
success=False,
|
|
request=request,
|
|
)
|
|
|
|
def api_key_used(self, org, endpoint, request=None):
|
|
"""Log API key usage."""
|
|
self._log(
|
|
"API_KEY_USED",
|
|
org=org,
|
|
description=f"API key used for {endpoint}",
|
|
metadata={"endpoint": endpoint},
|
|
request=request,
|
|
)
|
|
|
|
def api_key_invalid(self, api_key_prefix, request=None):
|
|
"""Log invalid API key attempt."""
|
|
self._log(
|
|
"API_KEY_INVALID",
|
|
description=f"Invalid API key attempted: {api_key_prefix}...",
|
|
metadata={"api_key_prefix": api_key_prefix},
|
|
success=False,
|
|
request=request,
|
|
)
|
|
|
|
def membership_revoked(self, user, org, revoked_by=None, request=None):
|
|
"""Log when user's org membership is revoked."""
|
|
self._log(
|
|
"MEMBERSHIP_REVOKED",
|
|
user=user,
|
|
org=org,
|
|
description=f"Membership revoked by {revoked_by.email if revoked_by else 'system'}",
|
|
metadata={"revoked_by": str(revoked_by.id) if revoked_by else None},
|
|
request=request,
|
|
)
|
|
|
|
def suspicious_activity(self, user, org, activity_type, details, request=None):
|
|
"""Log suspicious activity for security review."""
|
|
self._log(
|
|
"SUSPICIOUS_ACTIVITY",
|
|
user=user,
|
|
org=org,
|
|
description=f"Suspicious activity: {activity_type}",
|
|
metadata={"activity_type": activity_type, "details": details},
|
|
success=False,
|
|
request=request,
|
|
)
|
|
|
|
|
|
# Global audit logger instance
|
|
audit_log = AuditLogger()
|