517 lines
21 KiB
Python
517 lines
21 KiB
Python
"""
|
|||
|
|
Copyright 2024-2026 ReplyPier
|
||
|
|
|
||
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||
|
|
you may not use this file except in compliance with the License.
|
||
|
|
You may obtain a copy of the License at
|
||
|
|
|
||
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
||
|
|
|
||
|
|
Unless required by applicable law or agreed to in writing, software
|
||
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||
|
|
See the License for the specific language governing permissions and
|
||
|
|
limitations under the License.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from fastapi import Depends, HTTPException, status, Cookie, Request
|
||
|
|
from fastapi.security import HTTPBearer
|
||
|
|
from sqlalchemy.orm import Session
|
||
|
|
from typing import Callable, Iterable, Optional, List
|
||
|
|
from uuid import UUID
|
||
|
|
|
||
|
|
from app.database import get_db
|
||
|
|
from app.models.user import User
|
||
|
|
from app.models.organization import Organization
|
||
|
|
from app.services.auth_sessions import ACCESS, live_payload
|
||
|
|
from app.core.logger import get_logger
|
||
|
|
from app.models.role import Role
|
||
|
|
|
||
|
|
logger = get_logger(__name__)
|
||
|
|
|
||
|
|
# Enterprise: Personal Access Tokens (PATs). PATs are an enterprise-only feature, so the
|
||
|
|
# resolver lives in the enterprise package. In the community edition that package is absent
|
||
|
|
# and this falls back to a no-op, meaning "rpat_" bearer tokens simply fail normal JWT
|
||
|
|
# verification like any other invalid token. See app/enterprise/services/pat.py.
|
||
|
|
#
|
||
|
|
# The enterprise resolver is imported lazily (on first use) rather than at module load: the
|
||
|
|
# enterprise package pulls in the API routers, which import this module, so an eager import
|
||
|
|
# here would create a circular import during application start-up.
|
||
|
|
PAT_TOKEN_PREFIX = "rpat_"
|
||
|
|
_pat_resolver = None
|
||
|
|
_pat_resolver_loaded = False
|
||
|
|
|
||
|
|
|
||
|
|
def _resolve_pat_user(token: str, db: Session) -> Optional[User]:
|
||
|
|
global _pat_resolver, _pat_resolver_loaded
|
||
|
|
if not _pat_resolver_loaded:
|
||
|
|
_pat_resolver_loaded = True
|
||
|
|
try:
|
||
|
|
from app.enterprise.services.pat import resolve_pat_user
|
||
|
|
_pat_resolver = resolve_pat_user
|
||
|
|
except ImportError: # community edition / enterprise package not installed
|
||
|
|
_pat_resolver = None
|
||
|
|
if _pat_resolver is None:
|
||
|
|
return None
|
||
|
|
return _pat_resolver(token, db)
|
||
|
|
|
||
|
|
def check_permissions(user: User, required_permissions: List[str]) -> bool:
|
||
|
|
"""Check if user has required permissions through their role"""
|
||
|
|
if not user.role or not user.role.permissions:
|
||
|
|
return False
|
||
|
|
user_permissions = [p.name for p in user.role.permissions]
|
||
|
|
# If user has super_admin permission, they have access to everything
|
||
|
|
if "super_admin" in user_permissions:
|
||
|
|
return True
|
||
|
|
return all(perm in user_permissions for perm in required_permissions)
|
||
|
|
|
||
|
|
|
||
|
|
def require_grantable(current_user: User, permission_names: Iterable[str]) -> None:
|
||
|
|
"""You cannot grant a permission you do not hold yourself.
|
||
|
|
|
||
|
|
Without this, manage_roles was equivalent to super_admin — and in fact so
|
||
|
|
was *any* authenticated session, since the /roles endpoints carried no
|
||
|
|
permission check at all: POST /roles/{my_role_id}/permissions/super_admin
|
||
|
|
was enough. check_permissions short-circuits on super_admin, so an org
|
||
|
|
owner granting anything is unaffected.
|
||
|
|
|
||
|
|
Lives here rather than in api/roles.py because granting is not confined to
|
||
|
|
that router — the chat-scope toggles on the user form widen a role too.
|
||
|
|
"""
|
||
|
|
ungrantable = sorted(
|
||
|
|
name for name in permission_names
|
||
|
|
if not check_permissions(current_user, [name])
|
||
|
|
)
|
||
|
|
if ungrantable:
|
||
|
|
raise HTTPException(
|
||
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
||
|
|
detail=f"Cannot grant a permission you do not hold: {', '.join(ungrantable)}"
|
||
|
|
)
|
||
|
|
|
||
|
|
def require_permissions(*required_permissions: str):
|
||
|
|
"""Dependency to check user permissions"""
|
||
|
|
async def permission_checker(
|
||
|
|
current_user: User = Depends(get_current_user)
|
||
|
|
) -> User:
|
||
|
|
if not check_permissions(current_user, list(required_permissions)):
|
||
|
|
raise HTTPException(
|
||
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
||
|
|
detail="Not enough permissions"
|
||
|
|
)
|
||
|
|
return current_user
|
||
|
|
return permission_checker
|
||
|
|
|
||
|
|
|
||
|
|
# Seeing conversations. Either scope grants it — which require_permissions,
|
||
|
|
# whose semantics are AND, cannot express.
|
||
|
|
#
|
||
|
|
# manage_all_chats, not "manage_chats" — the latter appears in several hand-
|
||
|
|
# rolled checks but is not in Permission.default_permissions(), so it has
|
||
|
|
# never matched anyone.
|
||
|
|
CHAT_VIEW_PERMISSIONS = (
|
||
|
|
"view_all_chats",
|
||
|
|
"view_assigned_chats",
|
||
|
|
"view_unassigned_chats",
|
||
|
|
)
|
||
|
|
|
||
|
|
# Acting on a conversation: taking it over, reassigning it, sending outbound.
|
||
|
|
# Deliberately NOT derived from INBOX_PERMISSIONS — folding the view grants in
|
||
|
|
# here would let a read-only role reassign other people's conversations.
|
||
|
|
CHAT_MANAGE_PERMISSIONS = ("manage_all_chats", "manage_assigned_chats")
|
||
|
|
|
||
|
|
# Working the inbox, in the sense the surfaces around it mean it: an agent who
|
||
|
|
# can open a conversation must also be able to read the channel accounts and
|
||
|
|
# message templates needed to answer one, or the feature exists only in theory.
|
||
|
|
# This used to be just the two org-wide grants, which excluded the very agents
|
||
|
|
# the comment described.
|
||
|
|
INBOX_PERMISSIONS = CHAT_VIEW_PERMISSIONS + CHAT_MANAGE_PERMISSIONS
|
||
|
|
|
||
|
|
# The narrower org-wide scope, for the places that genuinely mean "can see the
|
||
|
|
# whole org's chats" rather than "works the inbox".
|
||
|
|
ORG_CHAT_PERMISSIONS = ("view_all_chats", "manage_all_chats")
|
||
|
|
|
||
|
|
# The people directory.
|
||
|
|
PEOPLE_READ_PERMISSIONS = ("view_people",) + INBOX_PERMISSIONS
|
||
|
|
|
||
|
|
# Correcting a person's details, marking them a customer and pushing them to a
|
||
|
|
# connected CRM are inbox work, not administration — people.py documents the
|
||
|
|
# PATCH as the agent's identification tool. So view_people grants both; there
|
||
|
|
# is no separate read-only directory role today.
|
||
|
|
PEOPLE_WRITE_PERMISSIONS = PEOPLE_READ_PERMISSIONS
|
||
|
|
|
||
|
|
|
||
|
|
def has_any_permission(user: User, permissions: Iterable[str]) -> bool:
|
||
|
|
"""True when the user holds AT LEAST ONE of `permissions`.
|
||
|
|
|
||
|
|
The OR counterpart to check_permissions, and it honours the same
|
||
|
|
super_admin bypass — hand-rolled `isdisjoint` checks kept forgetting it,
|
||
|
|
so a super_admin could send templates but got 403 from People.
|
||
|
|
"""
|
||
|
|
if not user.role or not user.role.permissions:
|
||
|
|
return False
|
||
|
|
held = {p.name for p in user.role.permissions}
|
||
|
|
return "super_admin" in held or not held.isdisjoint(set(permissions))
|
||
|
|
|
||
|
|
|
||
|
|
def require_any_permission(*permissions: str):
|
||
|
|
"""Dependency: the user needs any one of `permissions`."""
|
||
|
|
async def permission_checker(
|
||
|
|
current_user: User = Depends(get_current_user)
|
||
|
|
) -> User:
|
||
|
|
if not has_any_permission(current_user, permissions):
|
||
|
|
raise HTTPException(
|
||
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
||
|
|
detail="Not enough permissions"
|
||
|
|
)
|
||
|
|
return current_user
|
||
|
|
return permission_checker
|
||
|
|
|
||
|
|
def request_token(request: Request) -> Optional[str]:
|
||
|
|
"""The bearer credential a request carries: the access_token cookie, else
|
||
|
|
an Authorization: Bearer header."""
|
||
|
|
cookie_token = request.cookies.get('access_token')
|
||
|
|
# Ensure we got a valid string, not a mock or None
|
||
|
|
if cookie_token and isinstance(cookie_token, str):
|
||
|
|
return cookie_token
|
||
|
|
auth_header = request.headers.get('Authorization')
|
||
|
|
if auth_header and auth_header.startswith('Bearer '):
|
||
|
|
return auth_header.split(' ')[1]
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
def _unauthorized(detail: str) -> HTTPException:
|
||
|
|
return HTTPException(
|
||
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||
|
|
detail=detail,
|
||
|
|
headers={"WWW-Authenticate": "Bearer"},
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _jwt_user(access_token: str, db: Session) -> User:
|
||
|
|
"""The active user a live access JWT belongs to, or a 401.
|
||
|
|
|
||
|
|
live_payload rejects refresh tokens and tokens whose session was revoked
|
||
|
|
(signed out, or ended by a password change).
|
||
|
|
"""
|
||
|
|
payload = live_payload(db, access_token, ACCESS)
|
||
|
|
if payload is None:
|
||
|
|
raise _unauthorized("Invalid authentication token")
|
||
|
|
|
||
|
|
user_id = payload.get("sub")
|
||
|
|
if user_id is None:
|
||
|
|
raise _unauthorized("Invalid token payload")
|
||
|
|
|
||
|
|
user = db.query(User).filter(User.id == UUID(str(user_id))).first()
|
||
|
|
if user is None:
|
||
|
|
raise _unauthorized("User not found")
|
||
|
|
|
||
|
|
if not user.is_active:
|
||
|
|
raise _unauthorized("User is inactive")
|
||
|
|
|
||
|
|
return user
|
||
|
|
|
||
|
|
|
||
|
|
async def get_current_user(
|
||
|
|
request: Request,
|
||
|
|
access_token: Optional[str] = Cookie(None),
|
||
|
|
db: Session = Depends(get_db)
|
||
|
|
) -> User:
|
||
|
|
"""Get current user from access token cookie"""
|
||
|
|
try:
|
||
|
|
if not access_token or not isinstance(access_token, str):
|
||
|
|
access_token = request_token(request)
|
||
|
|
if not access_token:
|
||
|
|
raise _unauthorized("Not authenticated")
|
||
|
|
|
||
|
|
# Enterprise: a Personal Access Token resolves directly to its owning user.
|
||
|
|
if access_token.startswith(PAT_TOKEN_PREFIX):
|
||
|
|
pat_user = _resolve_pat_user(access_token, db)
|
||
|
|
if pat_user is not None and pat_user.is_active:
|
||
|
|
return pat_user
|
||
|
|
raise HTTPException(
|
||
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||
|
|
detail="Invalid or revoked access token",
|
||
|
|
headers={"WWW-Authenticate": "Bearer"},
|
||
|
|
)
|
||
|
|
|
||
|
|
return _jwt_user(access_token, db)
|
||
|
|
|
||
|
|
except HTTPException:
|
||
|
|
raise
|
||
|
|
except Exception as e:
|
||
|
|
logger.error(f"Authentication error: {str(e)}", exc_info=True)
|
||
|
|
raise HTTPException(
|
||
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||
|
|
detail="Could not validate credentials",
|
||
|
|
headers={"WWW-Authenticate": "Bearer"},
|
||
|
|
)
|
||
|
|
|
||
|
|
async def get_current_organization(
|
||
|
|
current_user: User = Depends(get_current_user),
|
||
|
|
db: Session = Depends(get_db)
|
||
|
|
) -> Organization:
|
||
|
|
"""Get current user's organization"""
|
||
|
|
if not current_user.organization_id:
|
||
|
|
raise HTTPException(
|
||
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
||
|
|
detail="User is not associated with any organization"
|
||
|
|
)
|
||
|
|
|
||
|
|
organization = db.query(Organization).filter(
|
||
|
|
Organization.id == current_user.organization_id,
|
||
|
|
Organization.is_active == True
|
||
|
|
).first()
|
||
|
|
|
||
|
|
if not organization:
|
||
|
|
raise HTTPException(
|
||
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
||
|
|
detail="Organization not found or inactive"
|
||
|
|
)
|
||
|
|
|
||
|
|
return organization
|
||
|
|
|
||
|
|
def require_permission(permission: str):
|
||
|
|
"""Dependency for checking if user has specific permission"""
|
||
|
|
async def permission_dependency(
|
||
|
|
current_user: User = Depends(get_current_user)
|
||
|
|
) -> User:
|
||
|
|
if not current_user.role or not current_user.role.has_permission(permission):
|
||
|
|
raise HTTPException(
|
||
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
||
|
|
detail=f"User does not have required permission: {permission}"
|
||
|
|
)
|
||
|
|
return current_user
|
||
|
|
return permission_dependency
|
||
|
|
|
||
|
|
def require_subscription_management(
|
||
|
|
current_user: User = Depends(get_current_user)
|
||
|
|
) -> User:
|
||
|
|
"""Dependency for checking if user can manage subscriptions"""
|
||
|
|
if not current_user.role or not current_user.role.can_manage_subscription():
|
||
|
|
raise HTTPException(
|
||
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
||
|
|
detail="User does not have permission to manage subscriptions"
|
||
|
|
)
|
||
|
|
return current_user
|
||
|
|
|
||
|
|
|
||
|
|
# Unified Authentication Functions
|
||
|
|
def get_auth_info_from_request(request: Request) -> dict:
|
||
|
|
"""Determine if this is a Shopify request based on the URL path"""
|
||
|
|
return {"is_shopify": "/shopify" in str(request.url)}
|
||
|
|
|
||
|
|
|
||
|
|
async def get_unified_auth(request: Request, db: Session = Depends(get_db)) -> dict:
|
||
|
|
"""Authenticate a JWT, a PAT, or a Shopify session token.
|
||
|
|
|
||
|
|
Authentication only — it deliberately holds no permission check. Wrap it in
|
||
|
|
require_unified_permissions / require_any_unified_permission to declare what
|
||
|
|
a route needs.
|
||
|
|
"""
|
||
|
|
from app.services.shopify_session import require_shopify_or_jwt_auth
|
||
|
|
|
||
|
|
try:
|
||
|
|
# Check if this is a Shopify-related endpoint or has query params indicating Shopify context
|
||
|
|
url_str = str(request.url)
|
||
|
|
query_params = dict(request.query_params)
|
||
|
|
is_shopify_context = (
|
||
|
|
"/shopify" in url_str or
|
||
|
|
url_str.endswith("/shopify") or
|
||
|
|
"shop" in query_params or
|
||
|
|
"host" in query_params
|
||
|
|
)
|
||
|
|
|
||
|
|
if is_shopify_context:
|
||
|
|
# For Shopify endpoints or context, use the shopify/JWT auth
|
||
|
|
auth_result = await require_shopify_or_jwt_auth(request, db)
|
||
|
|
return {
|
||
|
|
"auth_type": auth_result.get("auth_type", "shopify"),
|
||
|
|
"organization_id": auth_result["organization_id"],
|
||
|
|
"user_id": auth_result.get("user_id"),
|
||
|
|
"current_user": auth_result.get("current_user")
|
||
|
|
}
|
||
|
|
else:
|
||
|
|
# For regular endpoints, use JWT auth - inline the logic to avoid calling dependency as function
|
||
|
|
try:
|
||
|
|
# Get token from cookie or Authorization header
|
||
|
|
access_token = request_token(request)
|
||
|
|
if not access_token:
|
||
|
|
raise _unauthorized("Not authenticated")
|
||
|
|
|
||
|
|
# Enterprise: a Personal Access Token resolves directly to its owning user.
|
||
|
|
if access_token.startswith(PAT_TOKEN_PREFIX):
|
||
|
|
current_user = _resolve_pat_user(access_token, db)
|
||
|
|
if current_user is None or not current_user.is_active:
|
||
|
|
raise HTTPException(
|
||
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||
|
|
detail="Invalid or revoked access token",
|
||
|
|
headers={"WWW-Authenticate": "Bearer"},
|
||
|
|
)
|
||
|
|
return {
|
||
|
|
"auth_type": "pat",
|
||
|
|
"organization_id": current_user.organization_id,
|
||
|
|
"user_id": current_user.id,
|
||
|
|
"current_user": current_user
|
||
|
|
}
|
||
|
|
|
||
|
|
current_user = _jwt_user(access_token, db)
|
||
|
|
|
||
|
|
return {
|
||
|
|
"auth_type": "jwt",
|
||
|
|
"organization_id": current_user.organization_id, # Keep as UUID
|
||
|
|
"user_id": current_user.id,
|
||
|
|
"current_user": current_user
|
||
|
|
}
|
||
|
|
except HTTPException:
|
||
|
|
raise
|
||
|
|
except Exception as e:
|
||
|
|
logger.error(f"JWT authentication error in unified auth: {str(e)}", exc_info=True)
|
||
|
|
raise HTTPException(
|
||
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||
|
|
detail="Could not validate credentials",
|
||
|
|
headers={"WWW-Authenticate": "Bearer"},
|
||
|
|
)
|
||
|
|
except HTTPException:
|
||
|
|
# Re-raise HTTP exceptions as-is (don't wrap them)
|
||
|
|
raise
|
||
|
|
except Exception as e:
|
||
|
|
logger.error(f"Authentication error: {str(e)}")
|
||
|
|
raise HTTPException(status_code=401, detail="Authentication failed")
|
||
|
|
|
||
|
|
|
||
|
|
def _authorize_unified(auth_info: dict, permitted: Callable[[User], bool]) -> dict:
|
||
|
|
"""Apply a permission check to a get_unified_auth result.
|
||
|
|
|
||
|
|
A Shopify shop session authenticates a *store*, not a person, so it carries
|
||
|
|
no `current_user` and there is no role to check — those requests pass
|
||
|
|
through exactly as they did before authorization moved out of the helper.
|
||
|
|
"""
|
||
|
|
current_user = auth_info.get("current_user")
|
||
|
|
if current_user is None:
|
||
|
|
return auth_info
|
||
|
|
if not permitted(current_user):
|
||
|
|
raise HTTPException(
|
||
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
||
|
|
detail="Not enough permissions"
|
||
|
|
)
|
||
|
|
return auth_info
|
||
|
|
|
||
|
|
|
||
|
|
def require_unified_permissions(*required_permissions: str):
|
||
|
|
"""get_unified_auth + AND-semantics permissions, for JWT/PAT callers.
|
||
|
|
|
||
|
|
get_unified_auth used to hardcode a manage_agents check, which meant every
|
||
|
|
route sharing the helper inherited it — including reading your own org's
|
||
|
|
subscription, which locked every non-admin out of the whole app. The helper
|
||
|
|
now only authenticates; each route declares what it needs.
|
||
|
|
"""
|
||
|
|
async def permission_checker(
|
||
|
|
auth_info: dict = Depends(get_unified_auth)
|
||
|
|
) -> dict:
|
||
|
|
return _authorize_unified(
|
||
|
|
auth_info,
|
||
|
|
lambda user: check_permissions(user, list(required_permissions)),
|
||
|
|
)
|
||
|
|
return permission_checker
|
||
|
|
|
||
|
|
|
||
|
|
def require_any_unified_permission(*permissions: str):
|
||
|
|
"""The OR counterpart of require_unified_permissions."""
|
||
|
|
async def permission_checker(
|
||
|
|
auth_info: dict = Depends(get_unified_auth)
|
||
|
|
) -> dict:
|
||
|
|
return _authorize_unified(
|
||
|
|
auth_info,
|
||
|
|
lambda user: has_any_permission(user, permissions),
|
||
|
|
)
|
||
|
|
return permission_checker
|
||
|
|
|
||
|
|
|
||
|
|
async def get_unified_chat_auth(request: Request, db: Session = Depends(get_db)) -> dict:
|
||
|
|
"""Unified authentication that handles both regular JWT and Shopify session tokens for chat endpoints"""
|
||
|
|
from app.services.shopify_session import require_shopify_or_jwt_auth
|
||
|
|
|
||
|
|
try:
|
||
|
|
if "/shopify" in str(request.url):
|
||
|
|
# For Shopify endpoints, use the shopify auth
|
||
|
|
auth_result = await require_shopify_or_jwt_auth(request, db)
|
||
|
|
return {
|
||
|
|
"auth_type": auth_result.get("auth_type", "shopify"),
|
||
|
|
"organization_id": auth_result["organization_id"],
|
||
|
|
"user_id": auth_result.get("user_id"),
|
||
|
|
"current_user": auth_result.get("current_user")
|
||
|
|
}
|
||
|
|
else:
|
||
|
|
# For regular endpoints, use JWT auth with chat permissions - inline the logic
|
||
|
|
try:
|
||
|
|
# Get token from cookie or Authorization header
|
||
|
|
access_token = request_token(request)
|
||
|
|
if not access_token:
|
||
|
|
raise _unauthorized("Not authenticated")
|
||
|
|
|
||
|
|
# Enterprise: a Personal Access Token resolves directly to its owning user.
|
||
|
|
if access_token.startswith(PAT_TOKEN_PREFIX):
|
||
|
|
current_user = _resolve_pat_user(access_token, db)
|
||
|
|
if current_user is None or not current_user.is_active:
|
||
|
|
raise HTTPException(
|
||
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||
|
|
detail="Invalid or revoked access token",
|
||
|
|
headers={"WWW-Authenticate": "Bearer"},
|
||
|
|
)
|
||
|
|
pat_permissions = {p.name for p in current_user.role.permissions}
|
||
|
|
pat_is_super_admin = "super_admin" in pat_permissions
|
||
|
|
pat_can_view_all = pat_is_super_admin or "view_all_chats" in pat_permissions
|
||
|
|
pat_can_view_assigned = pat_is_super_admin or "view_assigned_chats" in pat_permissions
|
||
|
|
pat_can_view_unassigned = pat_is_super_admin or "view_unassigned_chats" in pat_permissions
|
||
|
|
if not (pat_can_view_all or pat_can_view_assigned or pat_can_view_unassigned):
|
||
|
|
raise HTTPException(status_code=403, detail="Not enough permissions")
|
||
|
|
return {
|
||
|
|
"auth_type": "pat",
|
||
|
|
"organization_id": current_user.organization_id,
|
||
|
|
"user_id": current_user.id,
|
||
|
|
"current_user": current_user,
|
||
|
|
"can_view_all": pat_can_view_all,
|
||
|
|
"can_view_assigned": pat_can_view_assigned,
|
||
|
|
"can_view_unassigned": pat_can_view_unassigned
|
||
|
|
}
|
||
|
|
|
||
|
|
current_user = _jwt_user(access_token, db)
|
||
|
|
|
||
|
|
# Check chat permissions
|
||
|
|
user_permissions = {p.name for p in current_user.role.permissions}
|
||
|
|
is_super_admin = "super_admin" in user_permissions
|
||
|
|
can_view_all = is_super_admin or "view_all_chats" in user_permissions
|
||
|
|
can_view_assigned = is_super_admin or "view_assigned_chats" in user_permissions
|
||
|
|
# The unclaimed AI queue — sessions no human has taken yet
|
||
|
|
can_view_unassigned = is_super_admin or "view_unassigned_chats" in user_permissions
|
||
|
|
|
||
|
|
if not (can_view_all or can_view_assigned or can_view_unassigned):
|
||
|
|
raise HTTPException(
|
||
|
|
status_code=403,
|
||
|
|
detail="Not enough permissions"
|
||
|
|
)
|
||
|
|
|
||
|
|
return {
|
||
|
|
"auth_type": "jwt",
|
||
|
|
"organization_id": current_user.organization_id, # Keep as UUID
|
||
|
|
"user_id": current_user.id,
|
||
|
|
"current_user": current_user,
|
||
|
|
"can_view_all": can_view_all,
|
||
|
|
"can_view_assigned": can_view_assigned,
|
||
|
|
"can_view_unassigned": can_view_unassigned
|
||
|
|
}
|
||
|
|
except HTTPException:
|
||
|
|
raise
|
||
|
|
except Exception as e:
|
||
|
|
logger.error(f"JWT authentication error in unified chat auth: {str(e)}", exc_info=True)
|
||
|
|
raise HTTPException(
|
||
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||
|
|
detail="Could not validate credentials",
|
||
|
|
headers={"WWW-Authenticate": "Bearer"},
|
||
|
|
)
|
||
|
|
except HTTPException:
|
||
|
|
# Re-raise HTTP exceptions as-is (don't wrap them)
|
||
|
|
raise
|
||
|
|
except Exception as e:
|
||
|
|
logger.error(f"Chat authentication error: {str(e)}")
|
||
|
|
raise HTTPException(status_code=401, detail="Authentication failed")
|