249 lines
8.5 KiB
Python
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
|