2026-04-25 22:51:36 +12:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from dataclasses import dataclass
|
|
|
|
|
|
|
|
|
|
from fastapi import Depends, HTTPException, status
|
|
|
|
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
2026-04-29 01:21:16 +12:00
|
|
|
from sqlalchemy import select
|
|
|
|
|
from sqlalchemy.orm import Session, selectinload
|
2026-04-25 22:51:36 +12:00
|
|
|
|
2026-05-08 00:00:56 +12:00
|
|
|
from app.core.access import (
|
|
|
|
|
INTERNAL_USER_SUBJECT,
|
|
|
|
|
INTERNAL_USER_TENANT_ID,
|
|
|
|
|
get_user_permissions,
|
|
|
|
|
permissions_to_module_map,
|
|
|
|
|
)
|
2026-04-25 22:51:36 +12:00
|
|
|
from app.core.security import verify_token
|
2026-04-29 01:21:16 +12:00
|
|
|
from app.db.session import get_db
|
2026-05-08 00:00:56 +12:00
|
|
|
from app.models.access import Role, User
|
2026-04-29 01:21:16 +12:00
|
|
|
from app.models.client_access import ClientFeatureAccess, ClientUser
|
|
|
|
|
from app.services.client_access_service import has_access_level, module_access_map
|
2026-04-25 22:51:36 +12:00
|
|
|
|
|
|
|
|
bearer_scheme = HTTPBearer(auto_error=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class AuthSession:
|
|
|
|
|
role: str
|
|
|
|
|
email: str
|
|
|
|
|
name: str
|
|
|
|
|
tenant_id: str | None = None
|
2026-04-29 01:21:16 +12:00
|
|
|
client_role: str | None = None
|
|
|
|
|
user_id: int | None = None
|
|
|
|
|
client_account_id: int | None = None
|
|
|
|
|
module_permissions: dict[str, str] | None = None
|
2026-04-25 22:51:36 +12:00
|
|
|
|
|
|
|
|
|
2026-05-08 00:00:56 +12:00
|
|
|
def _build_internal_auth_session(db: Session, payload: dict) -> AuthSession:
|
|
|
|
|
"""Translate an internal-user token into an AuthSession the shared route
|
|
|
|
|
dependencies can consume. Internal users present `role="internal"` so
|
|
|
|
|
`require_client_module_access` can spot them and skip the ClientUser DB
|
|
|
|
|
lookup, deriving their module_permissions from the role-permission table.
|
|
|
|
|
"""
|
|
|
|
|
user_id = payload.get("user_id")
|
|
|
|
|
if not isinstance(user_id, int):
|
|
|
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid authentication token")
|
|
|
|
|
|
|
|
|
|
user = db.scalar(
|
|
|
|
|
select(User)
|
|
|
|
|
.where(User.id == user_id)
|
|
|
|
|
.options(selectinload(User.role).selectinload(Role.permissions))
|
|
|
|
|
)
|
|
|
|
|
if user is None:
|
|
|
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User no longer exists")
|
|
|
|
|
if not user.is_active:
|
|
|
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is inactive")
|
|
|
|
|
|
|
|
|
|
return AuthSession(
|
|
|
|
|
role="internal",
|
|
|
|
|
email=user.email,
|
|
|
|
|
name=user.name,
|
|
|
|
|
tenant_id=INTERNAL_USER_TENANT_ID,
|
|
|
|
|
client_role=user.role.name if user.role else None,
|
|
|
|
|
user_id=user.id,
|
|
|
|
|
client_account_id=None,
|
|
|
|
|
module_permissions=permissions_to_module_map(get_user_permissions(user)),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_auth_session(
|
|
|
|
|
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
|
|
|
|
|
db: Session = Depends(get_db),
|
|
|
|
|
) -> AuthSession:
|
2026-04-25 22:51:36 +12:00
|
|
|
if credentials is None:
|
|
|
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required")
|
|
|
|
|
|
|
|
|
|
payload = verify_token(credentials.credentials)
|
2026-05-08 00:00:56 +12:00
|
|
|
|
|
|
|
|
# Internal Hunter Stock Feeds users get an auth session derived from the
|
|
|
|
|
# role/permission tables rather than the client-portal ClientUser tables.
|
|
|
|
|
if payload.get("sub") == INTERNAL_USER_SUBJECT:
|
|
|
|
|
return _build_internal_auth_session(db, payload)
|
|
|
|
|
|
2026-04-25 22:51:36 +12:00
|
|
|
return AuthSession(
|
|
|
|
|
role=str(payload.get("role", "")),
|
|
|
|
|
email=str(payload.get("email", "")),
|
|
|
|
|
name=str(payload.get("name", "")),
|
|
|
|
|
tenant_id=payload.get("tenant_id"),
|
2026-04-29 01:21:16 +12:00
|
|
|
client_role=payload.get("client_role"),
|
|
|
|
|
user_id=payload.get("user_id"),
|
|
|
|
|
client_account_id=payload.get("client_account_id"),
|
|
|
|
|
module_permissions={},
|
2026-04-25 22:51:36 +12:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def require_client_session(session: AuthSession = Depends(get_auth_session)) -> AuthSession:
|
2026-05-08 00:00:56 +12:00
|
|
|
# Internal Hunter Stock Feeds users share the workspace with client users
|
|
|
|
|
# but don't have a ClientUser row, so we accept them here and let
|
|
|
|
|
# `require_client_module_access` enforce the per-module checks.
|
|
|
|
|
if session.role == "internal":
|
|
|
|
|
if not session.tenant_id or not session.user_id:
|
|
|
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Internal user context is missing")
|
|
|
|
|
return session
|
2026-04-25 22:51:36 +12:00
|
|
|
if session.role != "client":
|
|
|
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Client access required")
|
|
|
|
|
if not session.tenant_id:
|
|
|
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Client tenant is missing")
|
2026-04-29 01:21:16 +12:00
|
|
|
if not session.user_id or not session.client_account_id:
|
|
|
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Client user context is missing")
|
2026-04-25 22:51:36 +12:00
|
|
|
return session
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def require_admin_session(session: AuthSession = Depends(get_auth_session)) -> AuthSession:
|
|
|
|
|
if session.role != "admin":
|
|
|
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
|
|
|
|
|
return session
|
2026-04-29 01:21:16 +12:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def load_current_client_user(db: Session, session: AuthSession) -> ClientUser:
|
|
|
|
|
user = db.scalar(
|
|
|
|
|
select(ClientUser)
|
|
|
|
|
.where(
|
|
|
|
|
ClientUser.id == session.user_id,
|
|
|
|
|
ClientUser.client_account_id == session.client_account_id,
|
|
|
|
|
ClientUser.tenant_id == session.tenant_id,
|
|
|
|
|
)
|
|
|
|
|
.options(selectinload(ClientUser.module_permissions))
|
|
|
|
|
)
|
|
|
|
|
if user is None:
|
|
|
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Client user access is no longer valid")
|
|
|
|
|
if user.status == "suspended":
|
|
|
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Client user access is suspended")
|
|
|
|
|
return user
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def require_client_module_access(module_key: str, minimum_level: str = "view"):
|
|
|
|
|
def dependency(
|
|
|
|
|
session: AuthSession = Depends(require_client_session),
|
|
|
|
|
db: Session = Depends(get_db),
|
|
|
|
|
) -> AuthSession:
|
2026-05-08 00:00:56 +12:00
|
|
|
# Internal users have their permissions baked into the AuthSession at
|
|
|
|
|
# token-resolve time; skip the ClientUser/feature DB lookups and check
|
|
|
|
|
# the in-memory module_permissions map directly.
|
|
|
|
|
if session.role == "internal":
|
|
|
|
|
permissions = session.module_permissions or {}
|
|
|
|
|
if not has_access_level(permissions.get(module_key), minimum_level):
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
|
|
|
detail=f"{module_key} access is not permitted",
|
|
|
|
|
)
|
|
|
|
|
return session
|
|
|
|
|
|
2026-04-29 01:21:16 +12:00
|
|
|
user = load_current_client_user(db, session)
|
|
|
|
|
feature = db.scalar(
|
|
|
|
|
select(ClientFeatureAccess).where(
|
|
|
|
|
ClientFeatureAccess.client_account_id == user.client_account_id,
|
|
|
|
|
ClientFeatureAccess.tenant_id == user.tenant_id,
|
|
|
|
|
ClientFeatureAccess.feature_key == module_key,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
if feature is not None and not feature.enabled:
|
|
|
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"{module_key} is disabled for this client")
|
|
|
|
|
|
|
|
|
|
permissions = module_access_map(user)
|
|
|
|
|
if not has_access_level(permissions.get(module_key), minimum_level):
|
|
|
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"{module_key} access is not permitted")
|
|
|
|
|
|
|
|
|
|
return AuthSession(
|
|
|
|
|
role=session.role,
|
|
|
|
|
email=session.email,
|
|
|
|
|
name=session.name,
|
|
|
|
|
tenant_id=session.tenant_id,
|
|
|
|
|
client_role=user.role,
|
|
|
|
|
user_id=user.id,
|
|
|
|
|
client_account_id=user.client_account_id,
|
|
|
|
|
module_permissions=permissions,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return dependency
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def require_client_access_manager_session(
|
|
|
|
|
session: AuthSession = Depends(get_auth_session),
|
|
|
|
|
db: Session = Depends(get_db),
|
|
|
|
|
) -> AuthSession:
|
|
|
|
|
if session.role == "admin":
|
|
|
|
|
return session
|
|
|
|
|
if session.role != "client":
|
|
|
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Client access management requires admin or superadmin access")
|
|
|
|
|
|
|
|
|
|
user = load_current_client_user(db, require_client_session(session))
|
|
|
|
|
permissions = module_access_map(user)
|
|
|
|
|
if user.role != "superadmin" or not has_access_level(permissions.get("client_access"), "manage"):
|
|
|
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Superadmin client access is required")
|
|
|
|
|
|
|
|
|
|
return AuthSession(
|
|
|
|
|
role=session.role,
|
|
|
|
|
email=session.email,
|
|
|
|
|
name=session.name,
|
|
|
|
|
tenant_id=session.tenant_id,
|
|
|
|
|
client_role=user.role,
|
|
|
|
|
user_id=user.id,
|
|
|
|
|
client_account_id=user.client_account_id,
|
|
|
|
|
module_permissions=permissions,
|
|
|
|
|
)
|