Files
docqube_backend/app/modules/auth/sso_router.py
T
2026-09-08 18:32:55 +05:30

380 lines
14 KiB
Python

from fastapi import APIRouter, Depends, HTTPException, status, Request, Response
from sqlalchemy.orm import Session
import time
import logging
import hmac
import hashlib
import uuid
from app.db.database import get_db
from app.core.settings import settings
from app.core.security import create_access_token, create_refresh_token
from app.core.request_body import read_json_body
from app.services.saas_service import SaaSService
from app.modules.tenant.models.tenant_model import Tenant
from app.modules.auth.models.saas_models import SaaSTenantMapping
router = APIRouter(prefix="/sso", tags=["SSO"])
logger = logging.getLogger(__name__)
@router.post("/login")
async def sso_login(
request: Request,
response: Response,
db: Session = Depends(get_db)
):
"""
Endpoint for SaaS to initiate a login session for a user.
Verified via HMAC signature.
"""
try:
data = await read_json_body(request, what="SSO login body")
user_id = data.get("user_id")
email = data.get("email")
tenant_id = data.get("tenant_id")
timestamp = data.get("timestamp")
signature = request.headers.get("X-Signature")
if not all([user_id, email, timestamp, signature]):
raise HTTPException(status_code=400, detail="Missing required SSO parameters")
now = int(time.time() * 1000)
req_timestamp = int(timestamp)
if abs(now - req_timestamp) > 5 * 60 * 1000:
raise HTTPException(status_code=401, detail="Invalid or stale timestamp")
import json
canonical_string = json.dumps(data, sort_keys=True, separators=(",", ":"))
if not SaaSService.verify_signature(signature, canonical_string):
raise HTTPException(status_code=401, detail="Invalid signature")
saas_data = {
"id": user_id,
"email": email,
"name": data.get("first_name") or email.split('@')[0],
"company_id": tenant_id,
"tenant_name": data.get("tenant_name"),
"role": data.get("role"),
"role_id": data.get("role_id"),
"is_superadmin": data.get("is_superadmin"),
"metadata": {
"permissions": data.get("permissions", []),
"subscription": data.get("subscription"),
},
}
user, mapping = SaaSService.ensure_saas_user(db, saas_data)
logger.info(f"SSO: User {email} mapped to local user ID {user.id}")
permissions = data.get("permissions", [])
if mapping:
mapping.metadata_ = {
"permissions": permissions,
"subscription": data.get("subscription"),
}
subscription = data.get("subscription")
if user.tenant and subscription:
try:
_sync_subscription(db, user.tenant, subscription.get("plan_code"), subscription)
except Exception as e:
logger.warning(f"Could not sync subscription for tenant {user.tenant_id}: {e}")
is_user_superadmin = bool(
getattr(user, "is_superadmin", False)
or data.get("is_superadmin")
or data.get("role") == "superadmin"
or (isinstance(data.get("permissions"), list) and "superadmin.main.view" in data.get("permissions"))
)
if is_user_superadmin and not user.is_superadmin:
user.is_superadmin = True
db.flush()
db.refresh(user)
access_token = create_access_token(
{
"sub": str(user.id),
"tenant_id": str(user.tenant_id) if user.tenant_id else None,
"is_superadmin": is_user_superadmin,
"saas_permissions": permissions,
"saas_subscription": data.get("subscription"),
}
)
refresh_token = create_refresh_token(
{
"sub": str(user.id),
}
)
is_secure = settings.APP_ENV in ["production", "test", "testing"] or request.url.scheme == "https"
samesite_mode = "none" if is_secure else "lax"
cookie_domain = getattr(settings, "COOKIE_DOMAIN", None) or None
response.set_cookie(
key="docqube_access_token",
value=access_token,
httponly=True,
secure=is_secure,
samesite=samesite_mode,
domain=cookie_domain,
path="/",
max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60
)
response.set_cookie(
key="docqube_refresh_token",
value=refresh_token,
httponly=True,
secure=is_secure,
samesite=samesite_mode,
domain=cookie_domain,
path="/",
max_age=7 * 24 * 3600
)
response.set_cookie(
key="csrf_token",
value=str(uuid.uuid4()),
httponly=False,
secure=is_secure,
samesite=samesite_mode,
domain=cookie_domain,
path="/"
)
return {
"status": "success",
"message": "SSO Login successful",
"access_token": access_token,
"refresh_token": refresh_token,
"user": {
"id": user.id,
"email": user.email,
"name": user.name,
"is_superadmin": is_user_superadmin,
"role": "superadmin" if is_user_superadmin else (user.role.name if user.role else None),
"permissions": permissions
}
}
except HTTPException as he:
raise he
except Exception as e:
logger.error("SSO Login Failed", exc_info=True)
raise HTTPException(status_code=500, detail="Internal Server Error")
@router.post("/sync-permissions")
def sync_permissions(request: Request, db: Session = Depends(get_db)):
"""
Return all permission nodes defined in DocQube to the SaaS platform.
"""
signature = request.headers.get("X-SaaS-Signature") or request.headers.get("X-Signature")
if not signature:
raise HTTPException(status_code=401, detail="Missing signature")
payload_body = "{}"
expected_signature = hmac.new(
settings.SAAS_TRUST_SECRET.encode("utf-8"),
payload_body.encode("utf-8"),
hashlib.sha256
).hexdigest()
if not hmac.compare_digest(signature, expected_signature):
raise HTTPException(status_code=401, detail="Invalid signature")
try:
from app.modules.auth.models.access_model import Access
all_nodes = db.query(Access).all()
node_map = {str(node.id): node.access_code for node in all_nodes}
permissions = []
for node in all_nodes:
parent_code = node_map.get(str(node.parent_id)) if node.parent_id else None
permissions.append({
"permission_code": node.access_code,
"name": node.name,
"category": node.category,
"parent_code": parent_code
})
return {"status": "success", "permissions": permissions}
except HTTPException:
raise
except Exception:
logger.exception("Failed to build the permission list for SaaS")
raise HTTPException(status_code=500, detail="Internal Server Error")
@router.post("/provision")
async def provision_tenant(request: Request, db: Session = Depends(get_db)):
"""
Receive tenant provisioning/update events from SaaS and sync the mapped
DocQube tenant, including max_users_allowed.
"""
raw_body = await request.body()
signature = request.headers.get("X-SaaS-Signature") or request.headers.get("X-Signature")
if not signature:
raise HTTPException(status_code=401, detail="Missing signature")
expected_signature = hmac.new(
settings.SAAS_TRUST_SECRET.encode("utf-8"),
raw_body,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(signature, expected_signature):
raise HTTPException(status_code=401, detail="Invalid signature")
payload = await read_json_body(request, what="webhook payload")
event_type = payload.get("event_type")
data = payload.get("data", {}) or {}
saas_tenant_id = data.get("tenant_id")
tenant_name = data.get("tenant_name") or "SaaS Tenant"
max_users_allowed = data.get("max_users_allowed")
is_active = data.get("is_active")
plan_code = data.get("plan_code") or data.get("plan")
subscription = data.get("subscription") or {}
try:
if event_type in {"TENANT_PROVISION_REQUESTED", "TENANT_UPDATED", "TENANT_STATUS_CHANGED"}:
if not saas_tenant_id:
raise HTTPException(status_code=400, detail="Missing tenant_id")
tenant = SaaSService.ensure_saas_tenant(
db,
saas_tenant_id=saas_tenant_id,
name=tenant_name,
max_users_allowed=max_users_allowed,
is_active=is_active,
)
_sync_subscription(db, tenant, plan_code, subscription)
return {
"status": "success",
"message": f"{event_type} synced",
"tenant_id": str(tenant.id),
"max_users_allowed": tenant.max_users_allowed,
}
if event_type == "TENANT_DEPROVISION_REQUESTED":
mapping = (
db.query(SaaSTenantMapping)
.filter(SaaSTenantMapping.saas_tenant_id == str(saas_tenant_id))
.first()
)
if mapping:
tenant = db.query(Tenant).filter(Tenant.id == mapping.docqube_tenant_id).first()
if tenant:
tenant.is_active = False
db.flush()
db.refresh(tenant)
return {"status": "success", "message": "Tenant deprovisioned"}
if event_type == "USER_PROVISION_REQUESTED":
user, _ = SaaSService.provision_user(db, data)
return {"status": "success", "message": "User provisioned", "user_id": str(user.id) if user else None}
if event_type == "USER_DEPROVISION_REQUESTED":
SaaSService.deprovision_user(db, data)
return {"status": "success", "message": "User deprovisioned"}
if event_type == "ROLE_PROVISION_REQUESTED":
role, _ = SaaSService.provision_role(db, data)
return {"status": "success", "message": "Role provisioned", "role_id": str(role.id) if role else None}
if event_type == "ROLE_DEPROVISION_REQUESTED":
SaaSService.deprovision_role(db, data)
return {"status": "success", "message": "Role deprovisioned"}
if event_type == "PLAN_PROVISION_REQUESTED":
plan = SaaSService.ensure_saas_plan(db, data)
db.commit()
return {"status": "success", "message": "Plan provisioned", "plan_id": str(plan.id)}
if event_type == "PLAN_UPDATED":
plan = SaaSService.update_saas_plan(db, data)
db.commit()
return {"status": "success", "message": "Plan updated", "plan_id": str(plan.id)}
if event_type == "PLAN_DEPROVISION_REQUESTED":
SaaSService.deprovision_plan(db, data)
db.commit()
return {"status": "success", "message": "Plan deprovisioned"}
return {"status": "ignored", "message": f"Unhandled event type: {event_type}"}
except HTTPException:
raise
except Exception as e:
logger.exception("Provisioning webhook failed for event %s", event_type)
raise HTTPException(status_code=500, detail=f"Provisioning error: {str(e)}")
def _sync_subscription(db, tenant, plan_code, subscription: dict) -> None:
"""
Record what the platform says about this tenant's plan.
**Unknown plan codes are logged and ignored, not created.** Auto-creating a
plan from a webhook would let the other system invent products here — with
no price, no limits, and no one having decided what it includes. A tenant
keeps its current plan until somebody adds the new one deliberately.
Nothing here is fatal. A provisioning webhook that fails because the plan
name changed would block the tenant from being created at all, which is a
worse outcome than a tenant whose plan is briefly stale.
"""
import logging
logger = logging.getLogger(__name__)
from app.modules.billing.models.plan_model import Plan, TenantSubscription
if not plan_code and not subscription:
return
row = (
db.query(TenantSubscription)
.filter(TenantSubscription.tenant_id == tenant.id)
.first()
)
plan = None
if plan_code:
plan = db.query(Plan).filter(Plan.code == str(plan_code)).first()
if plan is None:
logger.warning(
"SaaS webhook named plan %r, which does not exist here; leaving "
"tenant %s on its current plan",
plan_code,
tenant.id,
)
if row is None:
if plan is None:
return
row = TenantSubscription(tenant_id=tenant.id, plan_id=plan.id)
db.add(row)
elif plan is not None:
row.plan_id = plan.id
status = subscription.get("status")
if status:
mapped = {
"ACTIVE": "active",
"TRIAL": "trialing",
"TRIALING": "trialing",
"PAST_DUE": "past_due",
"CANCELLED": "cancelled",
"CANCELED": "cancelled",
"EXPIRED": "expired",
}.get(str(status).upper())
if mapped:
row.status = mapped
else:
logger.warning("SaaS webhook sent unknown status %r; leaving as-is", status)
external_ref = subscription.get("id") or subscription.get("external_ref")
if external_ref:
row.external_ref = str(external_ref)
db.flush()