380 lines
14 KiB
Python
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()
|
|
|