Files
saas_backend/app/middleware/auth_middleware.py
T
2026-08-31 20:39:41 -04:00

249 lines
8.5 KiB
Python

from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from typing import List
from datetime import datetime, timezone
from app.config.database import get_db
from app.config.security import security
from app.models.auth.user_model import User
from app.models.auth.access_model import Access
from app.models.auth.tenant_model import Tenant
from app.core.tenant_context import unscoped
from app.services.auth import api_key_service
from app.services.auth.subscription_lifecycle import (
SubscriptionState,
resolve as resolve_lifecycle,
)
_WRITE_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
def _lifecycle_message(lifecycle) -> str:
"""Say which of the several reasons applies, rather than "inactive".
"Tenant is inactive" covered suspension, cancellation and expiry alike, so
nobody reading it could tell whether to renew, contact support, or check
their own admin settings.
"""
if lifecycle.state is SubscriptionState.CANCELLED:
return "This workspace has been cancelled. Contact support to reinstate it."
if lifecycle.state is SubscriptionState.SUSPENDED:
return "This workspace has been suspended. Contact support."
if lifecycle.state is SubscriptionState.EXPIRED:
return "Your subscription has expired. Renew it to regain access."
return "This workspace is not active."
from app.services.auth.subscription_entitlement_service import (
SubscriptionEntitlementService,
)
security_scheme = HTTPBearer(auto_error=False)
def get_current_user(
request: Request,
credentials: HTTPAuthorizationCredentials = Depends(security_scheme),
db: Session = Depends(get_db)
) -> User:
token = (
credentials.credentials if credentials
else request.headers.get("X-API-Key") or request.cookies.get("access_token")
)
if not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated"
)
if api_key_service.looks_like_a_key(token):
return _user_for_api_key(request, token, db)
try:
payload = security.verify_access_token(token)
user_id = payload.get("sub")
if user_id is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token payload"
)
except HTTPException:
raise
except Exception:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials"
)
with unscoped():
user = db.query(User).filter(User.id == user_id).first()
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found"
)
setattr(user, "_saas_db_session", db)
_forget_api_key(user)
if user.status != "active":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User is inactive"
)
_assert_workspace_usable(request, user, db)
return user
def _forget_api_key(user: User) -> None:
for marker in ("_saas_api_key_scopes", "_saas_api_key_id", "_saas_api_key_name"):
if hasattr(user, marker):
delattr(user, marker)
def _user_for_api_key(request: Request, raw: str, db: Session) -> User:
"""Turn a key into the principal it acts as.
The principal is the **user who issued it**, not a separate kind of account.
That is the decision that makes everything else fall out: row-level security,
the entitlement chain, subscription lifecycle and audit attribution all work
unchanged, and deactivating somebody disables their integrations in the same
moment rather than leaving them running after that person has gone.
What the key adds is a ceiling. `_saas_api_key_scopes` is set on the returned
user, and `has_access` intersects with it — so a key can only ever be
narrower than its owner, never wider.
"""
key = api_key_service.resolve(db, raw)
if key is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid API key",
)
with unscoped():
owner = db.query(User).filter(User.id == key.user_id).first()
if owner is None or owner.status != "active":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid API key",
)
setattr(owner, "_saas_db_session", db)
setattr(owner, "_saas_api_key_scopes", api_key_service.effective_scopes(db, key, owner))
setattr(owner, "_saas_api_key_id", key.id)
setattr(owner, "_saas_api_key_name", key.name)
request.state.api_key_id = str(key.id)
_assert_workspace_usable(request, owner, db)
api_key_service.touch(key.id, key.last_used_at)
return owner
def _assert_workspace_usable(request: Request, user: User, db: Session) -> None:
"""The subscription checks, applied to any way of authenticating.
Extracted rather than repeated: a key that kept working through a
cancellation, or kept writing during the read-only grace period, would be a
hole that exists only because a second code path forgot about it.
"""
if user.tenant_id is None:
return
with unscoped():
tenant = db.query(Tenant).filter(Tenant.id == user.tenant_id).first()
if tenant is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
detail="Tenant not found")
lifecycle = resolve_lifecycle(tenant)
if not lifecycle.can_sign_in:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN,
detail=_lifecycle_message(lifecycle))
if not lifecycle.can_write and request.method in _WRITE_METHODS:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=(
"Your subscription has expired. You can still view and export your "
"data until "
f"{lifecycle.grace_until.isoformat() if lifecycle.grace_until else 'renewal'}"
", but changes are paused until it is renewed."
),
)
def require_active_user(current_user: User = Depends(get_current_user)) -> User:
if current_user.status != "active":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Inactive user"
)
return current_user
def has_access(user: User, access_code: str) -> bool:
db = getattr(user, "_saas_db_session", None)
if db is None:
return False
user_access_codes = SubscriptionEntitlementService.get_effective_access_codes(
db, user
)
if access_code not in user_access_codes:
return False
scopes = getattr(user, "_saas_api_key_scopes", None)
return access_code in scopes if scopes is not None else True
def can_access(user: User, access_code: str, db: Session) -> bool:
user_access_codes = SubscriptionEntitlementService.get_effective_access_codes(
db, user
)
if access_code in user_access_codes:
return True
requested_access = db.query(Access).filter(
Access.access_code == access_code
).first()
if not requested_access:
return False
current = requested_access
while current.parent:
if current.parent.access_code in user_access_codes:
return True
current = current.parent
return False
def get_user_accesses(user: User) -> List[str]:
db = getattr(user, "_saas_db_session", None)
if db is None:
return []
return sorted(SubscriptionEntitlementService.get_effective_access_codes(db, user))
def require_access(access_code: str):
def check_permission(current_user: User = Depends(get_current_user)) -> bool:
if not has_access(current_user, access_code):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Insufficient permissions. Required: {access_code}"
)
return True
return check_permission
def require_access_hierarchical(access_code: str):
def check_permission(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
) -> bool:
if not can_access(current_user, access_code, db):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Insufficient permissions. Required: {access_code}"
)
return True
return check_permission