Files
saas_backend/app/services/auth/tenant_service.py
T

747 lines
30 KiB
Python

from datetime import date, datetime, timezone
from sqlalchemy.orm import Session
from sqlalchemy import or_, cast, String, asc, desc
from fastapi import HTTPException, status
from app.models.auth.tenant_model import Tenant
from app.models.auth.tenant_module_model import TenantModule
from app.models.auth.subscription_plan_model import SubscriptionPlan
from app.models.auth.plan_access_model import PlanAccess
from app.models.auth.plan_module_access_model import PlanModuleAccess
from app.models.auth.module_access_model import ModuleAccess
from app.models.auth.role_model import Role
from app.models.auth.role_module_access_model import RoleModuleAccess
from app.models.auth.user_model import User
from app.models.auth.module_model import Module
from app.models.auth.module_environment_model import ModuleEnvironment
from app.schemas.auth.tenant_schema import TenantCreate, TenantUpdate, TenantPaginatedResponse, TenantResponse
from app.schemas.auth.role_schema import RoleCreate, RoleUpdate
from app.services.auth.role_service import RoleService
from app.config.security import security
import uuid
from typing import List, Optional
from app.services.auth.event_service import EventService
import logging
logger = logging.getLogger(__name__)
ONBOARDING_MODULE_CODES = {"pim", "inventory", "fulfillment"}
class TenantService:
STATUS_ACTIVE = "ACTIVE"
STATUS_INACTIVE = "INACTIVE"
STATUS_EXPIRED = "EXPIRED"
@staticmethod
def _today() -> date:
return datetime.now(timezone.utc).date()
@staticmethod
def _normalize_status(status_value: Optional[str], is_active: bool) -> str:
normalized = (status_value or "").strip().upper()
if normalized:
return normalized
return TenantService.STATUS_ACTIVE if is_active else TenantService.STATUS_INACTIVE
@staticmethod
def _resolve_lifecycle(
*,
start_date: Optional[date],
end_date: Optional[date],
status_value: Optional[str],
is_active: bool,
) -> tuple[str, bool]:
if start_date and end_date and end_date < start_date:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="end_date must be greater than or equal to start_date",
)
normalized_status = TenantService._normalize_status(status_value, is_active)
if end_date and end_date <= TenantService._today():
return TenantService.STATUS_EXPIRED, False
if normalized_status in {
TenantService.STATUS_INACTIVE,
TenantService.STATUS_EXPIRED,
}:
return normalized_status, False
return normalized_status, True
@staticmethod
def _sync_tenant_lifecycle(tenant: Tenant) -> bool:
resolved_status, resolved_is_active = TenantService._resolve_lifecycle(
start_date=tenant.start_date,
end_date=tenant.end_date,
status_value=tenant.status,
is_active=tenant.is_active,
)
changed = (
tenant.status != resolved_status
or tenant.is_active != resolved_is_active
)
if changed:
tenant.status = resolved_status
tenant.is_active = resolved_is_active
return changed
@staticmethod
def create_tenant(db: Session, tenant_data: TenantCreate, commit: bool = True) -> Tenant:
# Pre-validation 1: Name and Domain Uniqueness
existing = db.query(Tenant).filter(Tenant.tenant_name == tenant_data.tenant_name).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Tenant name already exists"
)
existing = db.query(Tenant).filter(Tenant.tenant_domain == tenant_data.tenant_domain).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Tenant domain already exists"
)
# Pre-validation 2: Plan existence
plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.id == tenant_data.plan_id).first()
if not plan:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid plan_id provided"
)
# Pre-validation 3: Owner Credentials Validation (Mandatory)
if not tenant_data.owner:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Tenant owner information is required"
)
owner_email = str(tenant_data.owner.email).strip().lower()
# Email uniqueness check across system
existing_user = db.query(User).filter(User.email == owner_email).first()
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Owner email '{owner_email}' is already registered"
)
# Password strength check
if not security.validate_password_strength(tenant_data.owner.password):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Owner password is too weak. Must be at least 8 characters with uppercase, lowercase, number, and special character."
)
# Pre-validation 4: Resolve Plan Modules and Validate Selection (Mandatory non-empty)
if not tenant_data.selected_module_ids:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="At least one application must be selected"
)
# Check duplicate module IDs
if len(tenant_data.selected_module_ids) != len(set(tenant_data.selected_module_ids)):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Duplicate module IDs in selected_module_ids"
)
plan_module_accesses = db.query(PlanModuleAccess).filter(PlanModuleAccess.plan_id == plan.id).all()
plan_mod_access_ids = [pma.module_access_id for pma in plan_module_accesses]
plan_distinct_module_ids = set()
if plan_mod_access_ids:
modules_query = db.query(ModuleAccess.module_id).filter(ModuleAccess.id.in_(plan_mod_access_ids)).distinct().all()
plan_distinct_module_ids = {m[0] for m in modules_query}
# Check if plan has explicit onboarding suite modules (PIM, Inventory, Fulfillment)
onboarding_mods_in_plan = []
if plan_distinct_module_ids:
onboarding_mods_in_plan = db.query(Module).filter(
Module.id.in_(plan_distinct_module_ids),
Module.status == "active",
Module.module_id.in_(ONBOARDING_MODULE_CODES)
).all()
active_onboarding_modules = onboarding_mods_in_plan
allowed_module_ids = {m.id for m in active_onboarding_modules}
selected_set = set(tenant_data.selected_module_ids)
invalid_modules = selected_set - allowed_module_ids
if invalid_modules:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Selected application is not included in the selected plan or is inactive"
)
effective_selected_module_ids = [m.id for m in active_onboarding_modules if m.id in selected_set]
# Pre-validation 5: Validate Module Environments (Explicit 1-to-1 required, reject duplicates)
module_env_map = {}
seen_env_modules = set()
if tenant_data.module_environments:
for me in tenant_data.module_environments:
if me.module_id in seen_env_modules:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Duplicate environment assignment for module '{me.module_id}'"
)
seen_env_modules.add(me.module_id)
if me.module_id not in selected_set:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Environment assignment specified for module '{me.module_id}' which is not in selected applications"
)
# Verify environment exists, belongs to this module, and is active
env_exists = db.query(ModuleEnvironment).filter(
ModuleEnvironment.module_id == me.module_id,
ModuleEnvironment.slug == me.environment_slug,
ModuleEnvironment.is_active == True
).first()
if not env_exists:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Environment '{me.environment_slug}' does not exist or is inactive for module '{me.module_id}'"
)
module_env_map[me.module_id] = me.environment_slug
# Require an explicit environment assignment for EVERY selected application
resolved_event_targets = []
for mod_id in effective_selected_module_ids:
env_slug = module_env_map.get(mod_id)
if not env_slug:
mod_obj = db.query(Module).filter(Module.id == mod_id).first()
mod_name = mod_obj.module_name if mod_obj else str(mod_id)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Explicit environment assignment is required for selected application '{mod_name}'"
)
resolved_event_targets.append({
"module_id": str(mod_id),
"environment_slug": env_slug
})
provisioning_id = str(uuid.uuid4())
try:
# 1. Create Tenant
tenant = Tenant(
tenant_name=tenant_data.tenant_name,
tenant_domain=tenant_data.tenant_domain,
tenant_logo_url=tenant_data.tenant_logo_url,
plan_id=tenant_data.plan_id,
start_date=tenant_data.start_date,
end_date=tenant_data.end_date,
)
tenant.status, tenant.is_active = TenantService._resolve_lifecycle(
start_date=tenant.start_date,
end_date=tenant.end_date,
status_value=tenant_data.status,
is_active=tenant.is_active,
)
db.add(tenant)
db.flush()
# 2. Create TenantModule assignments for selected modules only
for mod_id in effective_selected_module_ids:
env_slug = module_env_map[mod_id]
tm = TenantModule(
tenant_id=tenant.id,
module_id=mod_id,
assigned_environment_slug=env_slug,
is_active=True
)
db.add(tm)
db.flush()
# 3. Create Primary Admin Role
# SaaS accesses from plan
plan_saas_accesses = db.query(PlanAccess).filter(PlanAccess.plan_id == plan.id).all()
saas_access_ids = [a.access_id for a in plan_saas_accesses]
# Module launch accesses ONLY for selected modules
selected_module_accesses = db.query(ModuleAccess.id).filter(
ModuleAccess.id.in_(plan_mod_access_ids),
ModuleAccess.module_id.in_(effective_selected_module_ids)
).all()
selected_mod_access_ids = [ma[0] for ma in selected_module_accesses]
role_create_data = RoleCreate(
role_name="Primary Admin",
tenant_id=tenant.id,
is_default=True,
access_ids=saas_access_ids + selected_mod_access_ids
)
role = RoleService.create_role(
db,
role_create_data,
emit_events=False,
commit=False,
)
db.flush()
# 4. Create Owner User if requested
owner_user = None
if tenant_data.owner:
owner_user = User(
email=tenant_data.owner.email.strip().lower(),
password=security.hash_password(tenant_data.owner.password),
first_name=tenant_data.owner.first_name.strip(),
last_name=tenant_data.owner.last_name.strip(),
phone_number=tenant_data.owner.phone_number,
status="active",
tenant_id=tenant.id,
role_id=role.id
)
db.add(owner_user)
db.flush()
# 5. Build Outbox Events (durable log rows)
role_module_perms = (
db.query(RoleModuleAccess, ModuleAccess)
.join(ModuleAccess, RoleModuleAccess.module_access_id == ModuleAccess.id)
.filter(RoleModuleAccess.role_id == role.id)
.all()
)
role_follow_up = None
if role_module_perms:
module_map = {}
for rma, ma in role_module_perms:
mid = str(ma.module_id)
if mid not in module_map:
module_map[mid] = []
module_map[mid].append(ma.access_code)
role_targets = []
for target in resolved_event_targets:
mid = target["module_id"]
if mid in module_map:
role_targets.append({
"module_id": mid,
"environment_slug": target["environment_slug"],
"permissions": module_map[mid]
})
if role_targets:
role_follow_up = {
"event_type": "ROLE_PROVISION_REQUESTED",
"tenant_id": str(tenant.id),
"payload": {
"role_id": str(role.id),
"role_name": role.role_name,
"role_code": "TENANT_OWNER",
"is_owner": True,
"tenant_id": str(tenant.id),
"provisioning_id": str(uuid.uuid4()),
"targets": role_targets
}
}
emitted_event_ids = []
if resolved_event_targets:
logger.info(f"Emitting TENANT_PROVISION_REQUESTED for tenant '{tenant.tenant_name}' across {len(resolved_event_targets)} targets.")
payload = {
"tenant_id": str(tenant.id),
"canonical_tenant_id": str(tenant.id),
"tenant_name": tenant.tenant_name,
"tenant_domain": tenant.tenant_domain,
"tenant_logo_url": tenant.tenant_logo_url,
"max_users_allowed": plan.max_users_allowed,
"provisioning_id": provisioning_id,
"targets": resolved_event_targets
}
tenant_event_id = EventService.emit_event(
db,
event_type="TENANT_PROVISION_REQUESTED",
payload=payload,
tenant_id=tenant.id,
follow_up_event=role_follow_up
)
if tenant_event_id:
emitted_event_ids.append(tenant_event_id)
# If owner user created, emit USER_PROVISION_REQUESTED outbox event with canonical_user_id
if owner_user:
user_payload = {
"user_id": str(owner_user.id),
"canonical_user_id": str(owner_user.id),
"email": owner_user.email,
"first_name": owner_user.first_name,
"last_name": owner_user.last_name,
"phone_number": owner_user.phone_number,
"tenant_id": str(tenant.id),
"canonical_tenant_id": str(tenant.id),
"role_id": str(role.id),
"role_name": role.role_name,
"role_code": "TENANT_OWNER",
"is_owner": True,
"status": owner_user.status,
"targets": role_targets if role_module_perms else []
}
user_event_id = EventService.emit_event(
db,
event_type="USER_PROVISION_REQUESTED",
payload=user_payload,
tenant_id=tenant.id
)
if user_event_id:
emitted_event_ids.append(user_event_id)
tenant.pending_event_ids = emitted_event_ids
# Controlled final commit or flush
if commit:
db.commit()
db.refresh(tenant)
# Best-effort wake-up notification to Redis after successful commit
EventService.enqueue_wakeups(emitted_event_ids)
else:
db.flush()
return tenant
except Exception as e:
db.rollback()
raise e
@staticmethod
def get_all_tenants(db: Session):
tenants = db.query(Tenant).all()
changed = False
for tenant in tenants:
changed = TenantService._sync_tenant_lifecycle(tenant) or changed
if changed:
db.commit()
for tenant in tenants:
db.refresh(tenant)
return tenants
@staticmethod
def get_tenant_by_id(db: Session, tenant_id: uuid.UUID) -> Tenant:
tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first()
if not tenant:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tenant not found"
)
if TenantService._sync_tenant_lifecycle(tenant):
db.commit()
db.refresh(tenant)
return tenant
@staticmethod
def update_tenant(db: Session, tenant_id: uuid.UUID, tenant_data: TenantUpdate) -> Tenant:
tenant = TenantService.get_tenant_by_id(db, tenant_id)
update_dict = tenant_data.model_dump(exclude_unset=True)
should_emit_update = False
should_emit_status = False
if "tenant_name" in update_dict and update_dict["tenant_name"] != tenant.tenant_name:
existing = db.query(Tenant).filter(Tenant.tenant_name == update_dict["tenant_name"]).first()
if existing:
raise HTTPException(status_code=400, detail="Tenant name already exists")
should_emit_update = True
if "tenant_domain" in update_dict and update_dict["tenant_domain"] != tenant.tenant_domain:
existing = db.query(Tenant).filter(Tenant.tenant_domain == update_dict["tenant_domain"]).first()
if existing:
raise HTTPException(status_code=400, detail="Tenant domain already exists")
should_emit_update = True
if "tenant_logo_url" in update_dict and update_dict["tenant_logo_url"] != tenant.tenant_logo_url:
should_emit_update = True
if "is_active" in update_dict and update_dict["is_active"] != tenant.is_active:
should_emit_status = True
if "plan_id" in update_dict and update_dict["plan_id"] != tenant.plan_id:
new_plan_id = update_dict["plan_id"]
plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.id == new_plan_id).first()
if not plan:
raise HTTPException(status_code=400, detail="Invalid plan_id")
plan_module_accesses = db.query(PlanModuleAccess).filter(PlanModuleAccess.plan_id == plan.id).all()
mod_access_ids = [pma.module_access_id for pma in plan_module_accesses]
new_modules_set = set()
if mod_access_ids:
modules_query = db.query(ModuleAccess.module_id).filter(ModuleAccess.id.in_(mod_access_ids)).distinct().all()
new_modules_set = {m[0] for m in modules_query}
current_modules = db.query(TenantModule).filter(TenantModule.tenant_id == tenant.id).all()
current_map = {tm.module_id: tm for tm in current_modules}
event_targets = []
provisioning_id = str(uuid.uuid4())
default_env = "prod"
for mod_id in new_modules_set:
if mod_id in current_map:
tm = current_map[mod_id]
if not tm.is_active:
tm.is_active = True
event_targets.append({
"module_id": str(mod_id),
"environment_slug": tm.assigned_environment_slug or default_env
})
else:
tm = TenantModule(
tenant_id=tenant.id,
module_id=mod_id,
assigned_environment_slug=default_env,
is_active=True
)
db.add(tm)
event_targets.append({
"module_id": str(mod_id),
"environment_slug": default_env
})
for mod_id, tm in current_map.items():
if mod_id not in new_modules_set and tm.is_active:
tm.is_active = False
db.flush()
plan_saas_accesses = db.query(PlanAccess).filter(PlanAccess.plan_id == plan.id).all()
all_access_ids = [a.access_id for a in plan_saas_accesses] + mod_access_ids
default_role = db.query(Role).filter(Role.tenant_id == tenant.id, Role.is_default == True).first()
if default_role:
role_update_data = RoleUpdate(access_ids=all_access_ids)
RoleService.update_role(db, default_role.id, role_update_data, is_superadmin=True)
role_follow_up = None
if event_targets and default_role:
role_module_perms = (
db.query(RoleModuleAccess, ModuleAccess)
.join(ModuleAccess, RoleModuleAccess.module_access_id == ModuleAccess.id)
.filter(RoleModuleAccess.role_id == default_role.id)
.all()
)
module_map = {}
for rma, ma in role_module_perms:
mid = str(ma.module_id)
if mid not in module_map:
module_map[mid] = []
module_map[mid].append(ma.access_code)
role_targets = []
for target in event_targets:
mid = target["module_id"]
if mid in module_map:
role_targets.append({
"module_id": mid,
"environment_slug": target["environment_slug"],
"permissions": module_map[mid]
})
if role_targets:
role_follow_up = {
"event_type": "ROLE_PROVISION_REQUESTED",
"tenant_id": str(tenant.id),
"payload": {
"role_id": str(default_role.id),
"role_name": default_role.role_name,
"tenant_id": str(tenant.id),
"provisioning_id": str(uuid.uuid4()),
"targets": role_targets
}
}
if event_targets:
payload = {
"tenant_id": str(tenant.id),
"tenant_name": tenant.tenant_name,
"max_users_allowed": plan.max_users_allowed,
"provisioning_id": provisioning_id,
"targets": event_targets
}
EventService.emit_event(
db,
event_type="TENANT_PROVISION_REQUESTED",
payload=payload,
tenant_id=tenant.id,
follow_up_event=role_follow_up
)
for key, value in update_dict.items():
setattr(tenant, key, value)
if any(key in update_dict for key in ("start_date", "end_date", "status", "is_active")):
tenant.status, tenant.is_active = TenantService._resolve_lifecycle(
start_date=tenant.start_date,
end_date=tenant.end_date,
status_value=update_dict.get("status"),
is_active=tenant.is_active,
)
should_emit_status = True
if should_emit_update or should_emit_status:
active_modules = db.query(TenantModule).filter(
TenantModule.tenant_id == tenant.id,
TenantModule.is_active == True
).all()
broadcast_targets = [
{"module_id": str(tm.module_id), "environment_slug": tm.assigned_environment_slug or "prod"}
for tm in active_modules
]
if broadcast_targets:
if should_emit_update:
payload = {
"tenant_id": str(tenant.id),
"tenant_name": tenant.tenant_name,
"tenant_domain": tenant.tenant_domain,
"tenant_logo_url": tenant.tenant_logo_url,
"max_users_allowed": tenant.plan.max_users_allowed if tenant.plan else None,
"targets": broadcast_targets
}
EventService.emit_event(
db,
event_type="TENANT_UPDATED",
payload=payload,
tenant_id=tenant.id
)
if should_emit_status:
payload = {
"tenant_id": str(tenant.id),
"is_active": tenant.is_active,
"status": "ACTIVE" if tenant.is_active else "INACTIVE",
"targets": broadcast_targets
}
EventService.emit_event(
db,
event_type="TENANT_STATUS_CHANGED",
payload=payload,
tenant_id=tenant.id
)
db.commit()
db.refresh(tenant)
return tenant
@staticmethod
def delete_tenant(db: Session, tenant_id: uuid.UUID):
tenant = TenantService.get_tenant_by_id(db, tenant_id)
active_modules = db.query(TenantModule).filter(
TenantModule.tenant_id == tenant.id,
TenantModule.is_active == True
).all()
if active_modules:
broadcast_targets = [
{"module_id": str(tm.module_id), "environment_slug": tm.assigned_environment_slug or "prod"}
for tm in active_modules
]
if broadcast_targets:
payload = {
"tenant_id": str(tenant.id),
"tenant_name": tenant.tenant_name,
"targets": broadcast_targets
}
EventService.emit_event(
db,
event_type="TENANT_DEPROVISION_REQUESTED",
payload=payload,
tenant_id=tenant.id
)
db.delete(tenant)
db.commit()
return {"message": "Tenant deleted successfully"}
@staticmethod
def get_tenants_paginated(
db: Session,
page: int = 1,
page_size: int = 10,
search: Optional[str] = None,
is_active: Optional[bool] = None,
filter_tenant_names: Optional[List[str]] = None,
filter_tenant_domains: Optional[List[str]] = None,
filter_plan_ids: Optional[List[uuid.UUID]] = None,
statuses: Optional[List[bool]] = None,
sort_by: Optional[str] = None,
sort_order: Optional[str] = None,
) -> TenantPaginatedResponse:
query = db.query(Tenant)
if filter_tenant_names:
query = query.filter(Tenant.tenant_name.in_(filter_tenant_names))
if filter_tenant_domains:
query = query.filter(Tenant.tenant_domain.in_(filter_tenant_domains))
if filter_plan_ids:
query = query.filter(Tenant.plan_id.in_(filter_plan_ids))
if search and search.strip():
search_term = search.strip()
query = query.filter(
or_(
Tenant.tenant_name.ilike(f"%{search_term}%"),
Tenant.tenant_domain.ilike(f"%{search_term}%"),
cast(Tenant.id, String).ilike(f"%{search_term}%"),
)
)
if is_active is not None:
query = query.filter(Tenant.is_active == is_active)
if statuses is not None and len(statuses) > 0:
query = query.filter(Tenant.is_active.in_(statuses))
sort_column_map = {
"name": Tenant.tenant_name,
"domain": Tenant.tenant_domain,
"status": Tenant.is_active,
"plan": Tenant.plan_id,
}
sort_column = sort_column_map.get(sort_by or "")
if sort_column is not None:
order_fn = desc if (sort_order or "").lower() == "desc" else asc
query = query.order_by(order_fn(sort_column))
total = query.count()
offset = (page - 1) * page_size
tenants = query.offset(offset).limit(page_size).all()
changed = False
for tenant in tenants:
changed = TenantService._sync_tenant_lifecycle(tenant) or changed
if changed:
db.commit()
for tenant in tenants:
db.refresh(tenant)
total_pages = (total + page_size - 1) // page_size if total > 0 else 0
return TenantPaginatedResponse(
items=[TenantResponse.from_orm(tenant) for tenant in tenants],
total=total,
page=page,
page_size=page_size,
total_pages=total_pages,
)