feat: Implement module-based access control system with integrated authentication, authorization, and security utilities.

This commit is contained in:
Furqan-14
2026-02-02 17:33:35 +05:30
parent 249e03aa95
commit 7c326dea94
51 changed files with 2329 additions and 499 deletions
+133 -49
View File
@@ -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)