# Audit Provider Pattern The audit provider pattern enables modules to provide audit logging backends without creating cross-module dependencies. This allows the monitoring module to be truly optional while still providing audit logging when enabled. ## Overview ``` ┌─────────────────────────────────────────────────────────────────────┐ │ Admin Action Request │ │ (Settings update, User management, etc.) │ └─────────────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────────┐ │ AuditAggregatorService │ │ (app/modules/core/services/audit_aggregator.py) │ │ │ │ • Discovers AuditProviders from all registered modules │ │ • Calls log_action() on all available providers │ │ • Handles graceful degradation if no providers available │ └─────────────────────────────────────────────────────────────────────┘ │ ┌───────────────┼───────────────┐ ▼ ▼ ▼ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ │ DatabaseAudit │ │ (Future) │ │ (Future) │ │ Provider │ │ FileProvider │ │ CloudWatch │ │ (monitoring) │ │ │ │ │ └───────────────┘ └───────────────┘ └───────────────┘ │ ▼ ┌───────────────┐ │AdminAuditLog │ │ (table) │ └───────────────┘ ``` ## Problem Solved Before this pattern, core modules had **hard imports** from the monitoring module: ```python # BAD: Core module with hard dependency on optional/internal module from app.modules.monitoring.services.admin_audit_service import admin_audit_service def create_setting(...): result = settings_service.create_setting(...) admin_audit_service.log_action(...) # Crashes if monitoring disabled! ``` This violated the architecture rule: **Core modules cannot depend on optional modules.** ## Solution: Protocol-Based Audit Logging The monitoring module implements `AuditProviderProtocol` and registers it in its `definition.py`. The `AuditAggregatorService` in core discovers and uses audit providers from all modules. ## Key Components ### 1. AuditEvent Dataclass Standard structure for audit events: ```python # app/modules/contracts/audit.py from dataclasses import dataclass from typing import Any @dataclass class AuditEvent: admin_user_id: int # ID of admin performing action action: str # Action name (e.g., "create_setting") target_type: str # Target type (e.g., "setting", "user") target_id: str # Target identifier details: dict[str, Any] | None = None # Additional context ip_address: str | None = None # Admin's IP address user_agent: str | None = None # Browser user agent request_id: str | None = None # Request correlation ID ``` ### 2. AuditProviderProtocol Protocol that modules implement: ```python # app/modules/contracts/audit.py from typing import Protocol, runtime_checkable @runtime_checkable class AuditProviderProtocol(Protocol): @property def audit_backend(self) -> str: """Backend name (e.g., 'database', 'file', 'cloudwatch').""" ... def log_action(self, db: Session, event: AuditEvent) -> bool: """Log an audit event. Returns True on success.""" ... ``` ### 3. AuditAggregatorService The aggregator in core that discovers and uses providers: ```python # app/modules/core/services/audit_aggregator.py class AuditAggregatorService: def _get_enabled_providers(self, db: Session): """Discover audit providers from registered modules.""" from app.modules.registry import MODULES for module in MODULES.values(): if module.has_audit_provider(): provider = module.get_audit_provider_instance() if provider: yield (module, provider) def log_action(self, db: Session, event: AuditEvent) -> bool: """Log to all available providers.""" providers = list(self._get_enabled_providers(db)) if not providers: # No providers - acceptable, audit is optional return False any_success = False for module, provider in providers: try: if provider.log_action(db, event): any_success = True except Exception as e: logger.warning(f"Audit provider {module.code} failed: {e}") return any_success def log(self, db, admin_user_id, action, target_type, target_id, **kwargs): """Convenience method with individual parameters.""" event = AuditEvent( admin_user_id=admin_user_id, action=action, target_type=target_type, target_id=str(target_id), **kwargs ) return self.log_action(db, event) ``` ## Implementing a Provider ### Step 1: Create the Provider ```python # app/modules/monitoring/services/audit_provider.py from app.modules.contracts.audit import AuditEvent, AuditProviderProtocol from app.modules.tenancy.models import AdminAuditLog class DatabaseAuditProvider: @property def audit_backend(self) -> str: return "database" def log_action(self, db: Session, event: AuditEvent) -> bool: try: audit_log = AdminAuditLog( admin_user_id=event.admin_user_id, action=event.action, target_type=event.target_type, target_id=event.target_id, details=event.details or {}, ip_address=event.ip_address, user_agent=event.user_agent, request_id=event.request_id, ) db.add(audit_log) db.flush() return True except Exception as e: logger.error(f"Failed to log audit: {e}") return False audit_provider = DatabaseAuditProvider() ``` ### Step 2: Register in Module Definition ```python # app/modules/monitoring/definition.py def _get_audit_provider(): """Lazy import to avoid circular imports.""" from app.modules.monitoring.services.audit_provider import audit_provider return audit_provider monitoring_module = ModuleDefinition( code="monitoring", name="Platform Monitoring", audit_provider=_get_audit_provider, # Register the provider ... ) ``` ### Step 3: Use via Aggregator ```python # app/modules/core/routes/api/admin_settings.py from app.modules.core.services.audit_aggregator import audit_aggregator @router.post("/settings") def create_setting(setting_data: SettingCreate, ...): result = settings_service.create_setting(db, setting_data) # Log via aggregator - works even if monitoring is disabled audit_aggregator.log( db=db, admin_user_id=current_admin.id, action="create_setting", target_type="setting", target_id=setting_data.key, details={"category": setting_data.category}, ) db.commit() return result ``` ## Graceful Degradation When no audit providers are registered (e.g., monitoring module disabled): - `audit_aggregator.log()` returns `False` - No exceptions are raised - The operation continues normally - A debug log message notes the absence of providers This ensures core functionality works regardless of whether auditing is available. ## Multiple Backends The pattern supports multiple audit backends simultaneously: ```python # All registered providers receive the event audit_aggregator.log(...) # Provider 1: Database (AdminAuditLog table) # Provider 2: CloudWatch Logs (if aws-monitoring module enabled) # Provider 3: File-based audit log (if file-audit module enabled) ``` ## Benefits 1. **Decoupling**: Core modules don't depend on monitoring 2. **Optional Auditing**: Monitoring can be disabled without breaking the app 3. **Extensibility**: Easy to add new audit backends (file, cloud, SIEM) 4. **Testability**: Can mock audit providers in tests 5. **Fault Tolerance**: One provider failure doesn't affect others ## Related Documentation - [Cross-Module Import Rules](cross-module-import-rules.md) - Import restrictions - [Metrics Provider Pattern](metrics-provider-pattern.md) - Similar pattern for statistics - [Widget Provider Pattern](widget-provider-pattern.md) - Similar pattern for dashboard widgets - [Module System Architecture](module-system.md) - Module structure and auto-discovery