332 lines
14 KiB
Python
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")
|