Files
docqube_backend/app/middleware/auth.py
T

332 lines
14 KiB
Python

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")