import uuid import logging from datetime import datetime, timedelta, timezone from typing import Dict, Any, Optional from sqlalchemy.orm import Session from fastapi import HTTPException, status from app.models.auth.sso_grant_model import SSOGrant from app.models.auth.module_model import Module from app.models.auth.module_environment_model import ModuleEnvironment from app.models.auth.tenant_module_model import TenantModule from app.models.auth.user_model import User from app.config.security import security from app.services.auth.trust_service import TrustService from app.core.redis import sync_redis_client import json import time from app.services.auth.subscription_entitlement_service import ( SubscriptionEntitlementService, ) logger = logging.getLogger(__name__) class SSOService: @staticmethod def generate_grant( db: Session, user_id: uuid.UUID, module_id: str, tenant_id: Optional[uuid.UUID] = None ) -> Dict[str, str]: """ Generates a one-time SSO grant code for the specified module. Resolves the correct environment URL based on tenant/user config. """ module = db.query(Module).filter(Module.module_id == module_id).first() if not module: raise HTTPException(status_code=404, detail="Module not found") if module.status != "active": raise HTTPException(status_code=403, detail="Module is disabled") environment_slug = "prod" if tenant_id: tm = db.query(TenantModule).filter( TenantModule.tenant_id == tenant_id, TenantModule.module_id == module.id ).first() if not tm or not tm.is_active: raise HTTPException(status_code=403, detail="Tenant does not have access to this module") if tm.assigned_environment_slug: environment_slug = tm.assigned_environment_slug env = db.query(ModuleEnvironment).filter( ModuleEnvironment.module_id == module.id, ModuleEnvironment.slug == environment_slug ).first() if not env: env = db.query(ModuleEnvironment).filter( ModuleEnvironment.module_id == module.id, ModuleEnvironment.is_default == True ).first() if not env: raise HTTPException(status_code=404, detail="No active environment found for module. Please configure an environment in the Admin Console.") grant_code = str(uuid.uuid4().hex) redirect_url = f"{env.frontend_base_url}{env.sso_entry_path}?grant={grant_code}" grant_data = json.dumps({ "user_id": str(user_id), "module_id": str(module.id), "tenant_id": str(tenant_id) if tenant_id else None, "environment_slug": env.slug, "created_at": datetime.now(timezone.utc).isoformat(), }) if not sync_redis_client.client: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="SSO service temporarily unavailable" ) sync_redis_client.client.setex(f"sso_grant:{grant_code}", 60, grant_data) return { "grant_code": grant_code, "redirect_url": redirect_url } @staticmethod def generate_signed_payload( db: Session, user_id: uuid.UUID, module_id: str, tenant_id: Optional[uuid.UUID] = None ) -> Dict[str, Any]: """ Generates a signed payload for the client to POST directly to the module backend. """ module = db.query(Module).filter(Module.module_id == module_id).first() if not module or module.status != "active": raise HTTPException(status_code=404, detail="Module not found or disabled") environment_slug = "prod" if tenant_id: tm = db.query(TenantModule).filter(TenantModule.tenant_id == tenant_id, TenantModule.module_id == module.id).first() if tm and tm.is_active and tm.assigned_environment_slug: environment_slug = tm.assigned_environment_slug env = db.query(ModuleEnvironment).filter(ModuleEnvironment.module_id == module.id, ModuleEnvironment.slug == environment_slug).first() if not env: env = db.query(ModuleEnvironment).filter(ModuleEnvironment.module_id == module.id, ModuleEnvironment.is_default == True).first() if not env: raise HTTPException(status_code=404, detail="No active environment found for module. Please configure an environment in the Admin Console.") user = db.query(User).filter(User.id == user_id).first() if not user: raise HTTPException(status_code=404, detail="User not found") permissions = sorted( SubscriptionEntitlementService.get_plan_module_access_codes( db, tenant_id, module.id ) ) if not permissions and user.role and user.role.role_module_accesses: permissions = sorted( { rma.module_access.access_code for rma in user.role.role_module_accesses if rma.module_access and rma.module_access.module_id == module.id } ) timestamp = int(time.time() * 1000) subscription_details = SubscriptionEntitlementService.get_subscription_summary( db, tenant_id ) payload_data = { "user_id": str(user.id), "email": user.email, "tenant_id": str(tenant_id) if tenant_id else None, "permissions": permissions, "tenant_name": user.tenant.tenant_name if user.tenant else None, "subscription": subscription_details, "timestamp": timestamp, "first_name": user.first_name, "last_name": user.last_name, "role": user.role.role_name if user.role else None } tenant_id_str = str(tenant_id) if tenant_id else "" canonical_string = f"user_id={user.id}&email={user.email}&tenant_id={tenant_id_str}×tamp={timestamp}" try: signature = TrustService.sign_payload(env, canonical_string) except ValueError: raise HTTPException(status_code=500, detail="Module trust configuration error (missing HMAC secret)") base_url = env.backend_base_url.rstrip('/') path = env.sso_entry_path if env.sso_entry_path else "/sso/login" if not path.startswith('/'): path = '/' + path target_url = f"{base_url}{path}" return { "target_url": target_url, "payload": payload_data, "headers": { "X-App-Id": "saas", "X-App-Id": module.module_id, "X-Signature": signature }, "redirect_url": env.frontend_base_url } @staticmethod def exchange_grant( db: Session, grant_code: str, module_id: str, environment_slug: str ) -> Dict[str, Any]: """ Validates grant and returns a short-lived module-scoped token. This is called by the Module Backend. Grants are stored in Redis — atomically deleted on exchange (one-time use). """ if not sync_redis_client.client: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="SSO service temporarily unavailable" ) redis_key = f"sso_grant:{grant_code}" pipe = sync_redis_client.client.pipeline() pipe.get(redis_key) pipe.delete(redis_key) grant_json, _ = pipe.execute() if not grant_json: raise HTTPException(status_code=401, detail="Invalid or expired grant code") grant_data = json.loads(grant_json) module = db.query(Module).filter(Module.module_id == module_id).first() if not module or str(module.id) != grant_data["module_id"]: raise HTTPException(status_code=401, detail="Grant invalid for this module") if grant_data["environment_slug"] != environment_slug: raise HTTPException(status_code=401, detail="Grant invalid for this environment") user = db.query(User).filter(User.id == grant_data["user_id"]).first() if not user: raise HTTPException(status_code=401, detail="User not found") grant_tenant_id = grant_data.get("tenant_id") if grant_tenant_id: if str(user.tenant_id) != grant_tenant_id: raise HTTPException( status_code=401, detail="Tenant mismatch for SSO grant" ) permissions = sorted( SubscriptionEntitlementService.get_plan_module_access_codes( db, uuid.UUID(grant_tenant_id) if grant_tenant_id else None, module.id, ) ) if not permissions and user.role and user.role.role_module_accesses: permissions = sorted( { rma.module_access.access_code for rma in user.role.role_module_accesses if rma.module_access and rma.module_access.module_id == module.id } ) token_payload = { "sub": str(user.id), "email": user.email, "tenant_id": grant_tenant_id, "module_id": module_id, "environment": environment_slug, "permissions": permissions, "roles": [user.role.role_name] if user.role else [] } token = security.generate_module_token(token_payload, module_id) return { "access_token": token, "token_type": "bearer", "expires_in": 900, "user": { "id": str(user.id), "email": user.email, "first_name": user.first_name, "last_name": user.last_name } }