from typing import Optional from fastapi import Depends, HTTPException, Request from fastapi.security import OAuth2PasswordBearer from jose import JWTError, jwt from sqlalchemy.orm import Session, selectinload import logging from datetime import datetime, timezone from app.db.database import get_db from app.modules.auth.models.role_model import Role from app.modules.auth.models.role_access_model import RoleAccess from app.modules.auth.models.user_model import User from app.core.settings import settings from app.core.tenant_context import set_context from app.core.token_blacklist import TokenBlacklist logger = logging.getLogger(__name__) oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login", auto_error=False) def _validate_saas_subscription(subscription_details: Optional[dict]) -> None: if not subscription_details: return status_value = str(subscription_details.get("status") or "").upper() is_active = subscription_details.get("is_active") today = datetime.now(timezone.utc).date() start_date_raw = subscription_details.get("start_date") end_date_raw = subscription_details.get("end_date") try: start_date = ( datetime.fromisoformat(start_date_raw).date() if start_date_raw else None ) except ValueError: start_date = None try: end_date = datetime.fromisoformat(end_date_raw).date() if end_date_raw else None except ValueError: end_date = None can_sign_in = subscription_details.get("can_sign_in", True) if can_sign_in is False or is_active is False or status_value in {"INACTIVE", "EXPIRED"}: raise HTTPException(status_code=403, detail="Tenant subscription is inactive") if not (can_sign_in and is_active and status_value == "ACTIVE"): if start_date and today < start_date: raise HTTPException(status_code=403, detail="Tenant subscription is not active yet") if end_date and today > end_date and not subscription_details.get("can_write", True): raise HTTPException(status_code=403, detail="Tenant subscription has expired") def _check_missing_subscription_claim(db, user, subscription_details) -> None: """ A platform-managed tenant arriving without a subscription claim. `_validate_saas_subscription` only checks a claim that is present. Direct login sets none, so subscription state is enforced for SSO and not for direct logins — which is either deliberate or a hole, depending on how the product is sold. `saas_tenant_mappings` is the evidence that settles it per tenant: a mapped tenant is billed on the platform, an unmapped one is not. So this only ever looks at mapped tenants, and it cannot affect a customer who was never on the platform. **Logs always, blocks only when configured.** Leave the setting off, watch the warnings, and enable it once the logs show mapped tenants always arrive with a claim. Enforcing a billing rule at the login door is the change that locks real customers out if the assumption is wrong — so the observation comes first and the enforcement is a deliberate second step. Only runs when the claim is *absent*, which is the exceptional path, so the extra query is not on the hot path for SSO users. """ if subscription_details: return if not user.tenant_id: return from app.modules.auth.models.saas_models import SaaSTenantMapping mapped = ( db.query(SaaSTenantMapping.id) .filter(SaaSTenantMapping.docqube_tenant_id == user.tenant_id) .first() is not None ) if not mapped: return logger.warning( "Tenant %s is managed by the SaaS platform but this token carries no " "subscription claim; subscription state is not being enforced for this " "request (user %s)", user.tenant_id, user.id, ) if getattr(settings, "SUBSCRIPTION_REQUIRED_FOR_MAPPED_TENANTS", False): raise HTTPException( status_code=403, detail="Your subscription could not be verified. Please sign in again.", ) _WRITE_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"}) def _enforce_subscription_access(db, request, user) -> None: """ A lapsed subscription degrades to read-only. It does not lock anybody out. `EntitlementService.is_active()` existed, was tested, and enforced nothing — it was read by two API responses and by nothing else, so a cancelled subscription restricted precisely nothing. That is the same failure as the tenant context in B1: machinery built, machinery proven, machinery never connected. **Read-only rather than refused**, ported from the base and matching how mature systems behave. An expired customer keeps access to their own documents and loses the ability to create more. Locking them out makes export impossible and is bad for renewals: the customer most likely to come back is the one who can still see what they would be coming back to. Off by default, like every other enforcement added in this work. Turning it on is a commercial decision, and it should be made after somebody has looked at how many tenants are currently in a lapsed state. """ if not getattr(settings, "SUBSCRIPTION_ENFORCEMENT_ENABLED", False): return if request.method not in _WRITE_METHODS: return tenant = getattr(user, "tenant", None) if tenant is None: return from app.modules.billing.services.entitlement_service import EntitlementService level = EntitlementService(db).access_level(tenant) if level == "full": return raise HTTPException( status_code=402, detail=( "Your subscription has lapsed. You can still read and export your " "documents; renewing restores the ability to make changes." ), ) def _attach_resolved_access(db, user) -> None: """ Give the user object a way to answer "what may I do" — **lazily**. Before this existed there were two answers to that question. The gate (`require_access` -> `PermissionService`) unioned the legacy `users.role_id` with every live `user_roles` grant. The *response* (`UserOut.accesses` -> `User.access_codes`) read the legacy role alone. So granting somebody a second role gave them API authority the interface would not render — no menu entry, no route, no button — and multi-role looked broken while being enforced correctly. **Closures, not results.** Resolving eagerly here cost three queries on every authenticated request, including the many that never ask — `/api/storage/usage` neither gates on an access code nor serialises the user, and it went from 20 queries to 23 for an answer nobody read. Attaching the resolvers instead means the cost lands only where the question is asked, and `app/core/request_cache.py` makes the second asker free. Net effect on a *gated* endpoint is a reduction: `PermissionService` used to issue three queries per `require_access` dependency with no memo at all. **Closures and not the session**, because `User` is a model and `.importlinter` forbids models from importing services — including inside a function body, which import-linter reads. Keeping the import here is what keeps the ORM registry independent of request handling. **Runs after `set_context`.** Both `user_roles` and `roles` are tenant-owned, so with `TENANT_FILTER_ENABLED` resolution must happen under the correct tenant context or it silently returns nothing — which would present as "the user lost all permissions" rather than as an error. """ from app.modules.auth.services.permission_service import PermissionService from app.modules.auth.services.user_role_service import UserRoleReader setattr( user, "_resolve_access_codes", lambda: PermissionService(db).user_access_codes(user), ) setattr( user, "_resolve_role_refs", lambda: UserRoleReader(db).assignments(user), ) def get_current_user( request: Request, db: Session = Depends(get_db), token: Optional[str] = Depends(oauth2_scheme), ) -> User: token_candidates = [] if token: token_candidates.append(token) if request.cookies.get("docqube_access_token"): token_candidates.append(request.cookies.get("docqube_access_token")) if request.cookies.get("access_token"): token_candidates.append(request.cookies.get("access_token")) if not token_candidates: raise HTTPException(status_code=401, detail="Authentication required") for tok in token_candidates: try: payload = jwt.decode( tok, settings.APP_SECRET, algorithms=[settings.ALGORITHM] ) jti = payload.get("jti") if payload.get("type") in ("refresh", "device_management"): logger.warning(f"Authentication candidate has invalid token type: {payload.get('type')}") continue sub = payload.get("sub") or payload.get("id") or payload.get("user_id") or payload.get("email") tenant_id = payload.get("tenant_id") if sub is None: logger.warning("Authentication candidate is missing a subject claim") continue if jti and TokenBlacklist.is_blacklisted(jti): logger.warning("AUTH DEBUG: Token blacklisted") continue session_id = payload.get("session_id") if session_id: from app.modules.auth.repositories.session_repository import SessionRepository session_repo = SessionRepository(db) session = session_repo.get_by_id(session_id) if not session or session.status != "ACTIVE": if session and session.status == "REVOKED": if session.revoked_by == "reauth": raise HTTPException(status_code=401, detail="You have logged in from another tab on this device. Please refresh.") else: raise HTTPException(status_code=401, detail="Your session was remotely logged out from another device.") raise HTTPException(status_code=401, detail="Your session has expired.") from datetime import timedelta now = datetime.now(timezone.utc) last_activity = session.last_activity if last_activity and last_activity.tzinfo is None: last_activity = last_activity.replace(tzinfo=timezone.utc) if not last_activity or now - last_activity > timedelta(minutes=5): session_repo.update_last_activity(session) user = None try: user_id = int(sub) user = ( db.query(User) .options( selectinload(User.role) .selectinload(Role.role_accesses) .selectinload(RoleAccess.access) ) .filter(User.id == user_id) .first() ) except (ValueError, TypeError): user = ( db.query(User) .options( selectinload(User.role) .selectinload(Role.role_accesses) .selectinload(RoleAccess.access) ) .filter(User.email == str(sub)) .first() ) if not user: logger.warning("AUTH DEBUG: User sub not found in database!") continue if getattr(user, "is_deleted", False) or not getattr(user, "is_active", True): logger.warning("AUTH DEBUG: User is deleted or inactive") continue if user.tenant_id and tenant_id and str(user.tenant_id) != str(tenant_id): logger.warning("AUTH DEBUG: Tenant mismatch user.tenant_id vs token") continue tenant = getattr(user, "tenant", None) if tenant is not None and ( not getattr(tenant, "is_active", True) or getattr(tenant, "is_deleted", False) ): logger.warning("Tenant %s is not active", user.tenant_id) raise HTTPException( status_code=403, detail="This workspace has been suspended. Contact support.", ) _enforce_subscription_access(db, request, user) saas_permissions = payload.get("saas_permissions", []) setattr(user, "saas_permissions", set(saas_permissions)) saas_subscription = payload.get("saas_subscription") _validate_saas_subscription(saas_subscription) _check_missing_subscription_claim(db, user, saas_subscription) setattr(user, "saas_subscription", saas_subscription) set_context( user.tenant_id, is_super=bool(getattr(user, "is_superadmin", False)), ) _attach_resolved_access(db, user) return user except JWTError as err: logger.warning("Authentication candidate JWT decoding failed: %s", type(err).__name__) continue logger.warning("AUTH DEBUG: All %d token candidates failed authentication!", len(token_candidates)) raise HTTPException(status_code=401, detail="Authentication required")