feat: Implement module-based access control system with integrated authentication, authorization, and security utilities.
This commit is contained in:
@@ -6,12 +6,14 @@ from datetime import datetime, timezone, timedelta
|
||||
from typing import Dict, Any, List, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
|
||||
import hmac
|
||||
import hashlib
|
||||
from app.models.auth.module_environment_model import ModuleEnvironment
|
||||
from app.models.auth.module_model import Module
|
||||
from app.models.auth.tenant_module_model import TenantModule
|
||||
from app.models.system.event_log_model import EventLog, EventStatus
|
||||
from app.services.auth.trust_service import TrustService
|
||||
from app.core.redis import sync_redis_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -27,10 +29,9 @@ class EventService:
|
||||
Emits an event by writing it to the Outbox (event_logs).
|
||||
Scopes delivery to relevant modules based on tenant_id.
|
||||
"""
|
||||
event_id = str(uuid.uuid4()) # Idempotency Key
|
||||
event_id = str(uuid.uuid4())
|
||||
timestamp = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
# Enforce Idempotency Contract: payload must include event_id
|
||||
if "event_id" not in payload:
|
||||
payload["event_id"] = event_id
|
||||
|
||||
@@ -41,24 +42,39 @@ class EventService:
|
||||
"data": payload
|
||||
}
|
||||
|
||||
# Scope: Find targets
|
||||
targets = []
|
||||
if tenant_id:
|
||||
# Send to modules active for this tenant
|
||||
targets: List[ModuleEnvironment] = []
|
||||
|
||||
payload_data = payload.get("data", payload) if isinstance(payload, dict) else {}
|
||||
|
||||
targets_list = payload.get("targets")
|
||||
|
||||
if targets_list and isinstance(targets_list, list):
|
||||
target_configs = targets_list
|
||||
for target in target_configs:
|
||||
module_id = target.get("module_id")
|
||||
env_slug = target.get("environment_slug")
|
||||
|
||||
if module_id and env_slug:
|
||||
env = db.query(ModuleEnvironment).filter(
|
||||
ModuleEnvironment.module_id == module_id,
|
||||
ModuleEnvironment.slug == env_slug
|
||||
).first()
|
||||
if env:
|
||||
targets.append(env)
|
||||
|
||||
elif tenant_id:
|
||||
tenant_modules = db.query(TenantModule).filter(
|
||||
TenantModule.tenant_id == tenant_id,
|
||||
TenantModule.is_active == True
|
||||
).all()
|
||||
|
||||
for tm in tenant_modules:
|
||||
# Resolve env
|
||||
env = db.query(ModuleEnvironment).filter(
|
||||
ModuleEnvironment.module_id == tm.module_id,
|
||||
ModuleEnvironment.slug == (tm.assigned_environment_slug or "prod") # fallback logic could be better
|
||||
ModuleEnvironment.slug == (tm.assigned_environment_slug or "prod")
|
||||
).first()
|
||||
|
||||
if not env:
|
||||
# Try default
|
||||
env = db.query(ModuleEnvironment).filter(
|
||||
ModuleEnvironment.module_id == tm.module_id,
|
||||
ModuleEnvironment.is_default == True
|
||||
@@ -66,16 +82,22 @@ class EventService:
|
||||
|
||||
if env:
|
||||
targets.append(env)
|
||||
else:
|
||||
# System-wide event? Or broadcast?
|
||||
# Plan says "No broadcasting". But if we update a global setting?
|
||||
# For now, we assume user/tenant scope. If no tenant, we might log warning or skip.
|
||||
logger.warning("Event emitted without tenant_id - skipping delivery scoping")
|
||||
|
||||
if not targets:
|
||||
logger.warning(f"Event {event_type} emitted with no resolved targets. Payload scoping: {'Explicit' if 'targets' in payload.get('data', {}) else 'Implicit'}")
|
||||
return
|
||||
|
||||
# Write to Outbox
|
||||
for env in targets:
|
||||
target_url = f"{env.backend_base_url}/internal/events"
|
||||
base = env.backend_base_url.rstrip('/')
|
||||
|
||||
if event_type == "TENANT_PROVISION_REQUESTED" and env.provisioning_endpoint:
|
||||
endpoint = env.provisioning_endpoint.lstrip('/')
|
||||
logger.info(f"Trace: base='{base}', endpoint='{endpoint}'")
|
||||
target_url = f"{base}/{endpoint}"
|
||||
logger.info(f"Trace: Calculated target_url='{target_url}'")
|
||||
else:
|
||||
logger.info(f"Using default event stream for env '{env.slug}'. ProvEndpoint: '{env.provisioning_endpoint}'")
|
||||
target_url = f"{base}/api/internal/events"
|
||||
|
||||
log = EventLog(
|
||||
event_id=uuid.UUID(event_id),
|
||||
@@ -84,14 +106,86 @@ class EventService:
|
||||
target_module_id=env.module_id,
|
||||
target_environment_slug=env.slug,
|
||||
target_url=target_url,
|
||||
status=EventStatus.PENDING
|
||||
status=EventStatus.PENDING,
|
||||
next_retry_at=datetime.now(timezone.utc)
|
||||
)
|
||||
db.add(log)
|
||||
|
||||
# IMPORTANT: emit_event must be called within a transaction
|
||||
# that is committed by the caller. db.flush details the insert
|
||||
# so it's ready for commit.
|
||||
db.flush()
|
||||
|
||||
try:
|
||||
sync_redis_client.rpush("saas:events:queue", event_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to push event to Redis queue: {e}")
|
||||
|
||||
@staticmethod
|
||||
def process_queue_item(db: Session, event_id: str):
|
||||
"""
|
||||
Process all pending EventLogs associated with the given logical event_id.
|
||||
"""
|
||||
logs = db.query(EventLog).filter(
|
||||
EventLog.event_id == uuid.UUID(event_id),
|
||||
EventLog.status == EventStatus.PENDING
|
||||
).all()
|
||||
|
||||
if not logs:
|
||||
return 0
|
||||
|
||||
processed_count = 0
|
||||
for log in logs:
|
||||
try:
|
||||
env = db.query(ModuleEnvironment).filter(
|
||||
ModuleEnvironment.module_id == log.target_module_id,
|
||||
ModuleEnvironment.slug == log.target_environment_slug
|
||||
).first()
|
||||
|
||||
if not env:
|
||||
log.status = EventStatus.FAILED
|
||||
log.error_log = "Target environment config missing"
|
||||
continue
|
||||
|
||||
payload_json = json.dumps(log.payload)
|
||||
secret = env.trust_credentials.get("hmac_secret") if env.trust_credentials else None
|
||||
|
||||
if secret:
|
||||
signature = hmac.new(
|
||||
secret.encode("utf-8"),
|
||||
payload_json.encode("utf-8"),
|
||||
hashlib.sha256
|
||||
).hexdigest()
|
||||
else:
|
||||
signature = ""
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-SaaS-Signature": signature,
|
||||
"X-SaaS-Event-Source": "saas-core"
|
||||
}
|
||||
|
||||
logger.info(f"Sending event {log.event_type} to {log.target_url}")
|
||||
response = requests.post(log.target_url, data=payload_json, headers=headers, timeout=5)
|
||||
|
||||
if response.status_code in range(200, 300):
|
||||
log.status = EventStatus.COMPLETED
|
||||
log.error_log = None
|
||||
processed_count += 1
|
||||
else:
|
||||
log.retry_count += 1
|
||||
backoff = min(60 * (2 ** log.retry_count), 86400)
|
||||
log.next_retry_at = datetime.now(timezone.utc) + timedelta(seconds=backoff)
|
||||
log.error_log = f"HTTP {response.status_code}: {response.text}"
|
||||
if log.retry_count > 10:
|
||||
log.status = EventStatus.FAILED
|
||||
|
||||
except Exception as e:
|
||||
log.retry_count += 1
|
||||
backoff = min(60 * (2 ** log.retry_count), 86400)
|
||||
log.next_retry_at = datetime.now(timezone.utc) + timedelta(seconds=backoff)
|
||||
log.error_log = str(e)
|
||||
|
||||
db.commit()
|
||||
|
||||
return processed_count
|
||||
|
||||
@staticmethod
|
||||
def process_outbox(db: Session, batch_size: int = 50):
|
||||
@@ -105,9 +199,11 @@ class EventService:
|
||||
EventLog.next_retry_at <= now
|
||||
).limit(batch_size).all()
|
||||
|
||||
if logs:
|
||||
logger.info(f"Found {len(logs)} events to process (next_retry_at <= {now})")
|
||||
|
||||
for log in logs:
|
||||
try:
|
||||
# 1. Resolve Credentials for Signing
|
||||
env = db.query(ModuleEnvironment).filter(
|
||||
ModuleEnvironment.module_id == log.target_module_id,
|
||||
ModuleEnvironment.slug == log.target_environment_slug
|
||||
@@ -118,50 +214,37 @@ class EventService:
|
||||
log.error_log = "Target environment config missing"
|
||||
continue
|
||||
|
||||
# 2. Sign Payload
|
||||
# We need to construct the request to sign it
|
||||
# Method POST, Path /internal/events (derived from target_url but we should be consistent)
|
||||
# But target_url might be full "http://.../internal/events"
|
||||
# We need relative path for signature if module expects it.
|
||||
# Standard convention: path is "/internal/events"
|
||||
|
||||
path = "/internal/events"
|
||||
# Note: if target_url has different path, signature validation will fail.
|
||||
# We assume standard convention or parse from target_url.
|
||||
|
||||
payload_json = json.dumps(log.payload)
|
||||
timestamp = datetime.now(timezone.utc).isoformat()
|
||||
secret = env.trust_credentials.get("hmac_secret")
|
||||
|
||||
signature = TrustService.sign_outbound_payload(
|
||||
environment=env,
|
||||
method="POST",
|
||||
path=path,
|
||||
payload_json=payload_json,
|
||||
timestamp=timestamp
|
||||
)
|
||||
if secret:
|
||||
signature = hmac.new(
|
||||
secret.encode("utf-8"),
|
||||
payload_json.encode("utf-8"),
|
||||
hashlib.sha256
|
||||
).hexdigest()
|
||||
else:
|
||||
signature = ""
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-SaaS-Signature": signature,
|
||||
"X-SaaS-Timestamp": timestamp,
|
||||
"X-SaaS-Event-Source": "saas-core"
|
||||
}
|
||||
|
||||
# 3. Send
|
||||
logger.info(f"Sending event {log.event_type} to {log.target_url}. Payload: {payload_json}")
|
||||
response = requests.post(log.target_url, data=payload_json, headers=headers, timeout=5)
|
||||
|
||||
# 4. Handle Result
|
||||
if response.status_code in range(200, 300):
|
||||
log.status = EventStatus.COMPLETED
|
||||
log.error_log = None # Clear errors if any
|
||||
log.error_log = None
|
||||
else:
|
||||
# Retry logic
|
||||
log.retry_count += 1
|
||||
backoff = min(60 * (2 ** log.retry_count), 86400) # Cap at 24h
|
||||
backoff = min(60 * (2 ** log.retry_count), 86400)
|
||||
log.next_retry_at = now + timedelta(seconds=backoff)
|
||||
log.error_log = f"HTTP {response.status_code}: {response.text}"
|
||||
|
||||
if log.retry_count > 10: # Max retries
|
||||
if log.retry_count > 10:
|
||||
log.status = EventStatus.FAILED
|
||||
|
||||
except Exception as e:
|
||||
@@ -170,5 +253,6 @@ class EventService:
|
||||
log.next_retry_at = now + timedelta(seconds=backoff)
|
||||
log.error_log = str(e)
|
||||
|
||||
# Commit processing state
|
||||
db.commit()
|
||||
|
||||
return len(logs)
|
||||
Reference in New Issue
Block a user