Files
orion/docs/architecture/audit-provider-pattern.md
Samir Boulahtit 39dff4ab7d refactor: fix architecture violations with provider patterns and dependency inversion
Major changes:
- Add AuditProvider protocol for cross-module audit logging
- Move customer order operations to orders module (dependency inversion)
- Add customer order metrics via MetricsProvider pattern
- Fix missing db parameter in get_admin_context() calls
- Move ProductMedia relationship to catalog module (proper ownership)
- Add marketplace breakdown stats to marketplace_widgets

New files:
- contracts/audit.py - AuditProviderProtocol
- core/services/audit_aggregator.py - Aggregates audit providers
- monitoring/services/audit_provider.py - Monitoring audit implementation
- orders/services/customer_order_service.py - Customer order operations
- orders/routes/api/vendor_customer_orders.py - Customer order endpoints
- catalog/services/product_media_service.py - Product media service
- Architecture documentation for patterns

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 21:32:32 +01:00

9.7 KiB

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:

# 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:

# 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:

# 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:

# 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

# 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

# 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

# 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:

# 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