mirror of
https://github.com/furyhawk/agent_delta.git
synced 2026-07-21 02:05:36 +00:00
452 lines
15 KiB
Python
452 lines
15 KiB
Python
"""API dependencies.
|
|
|
|
Dependency injection factories for services, repositories, and authentication.
|
|
"""
|
|
# ruff: noqa: I001 - Imports structured for Jinja2 template conditionals
|
|
|
|
from typing import Annotated
|
|
|
|
from fastapi import Depends
|
|
from fastapi.security import OAuth2PasswordBearer
|
|
|
|
from app.core.config import settings
|
|
from app.db.session import get_db_session
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
DBSession = Annotated[AsyncSession, Depends(get_db_session)]
|
|
from fastapi import Request
|
|
|
|
from app.clients.redis import RedisClient
|
|
|
|
|
|
async def get_redis(request: Request) -> RedisClient:
|
|
"""Get Redis client from lifespan state."""
|
|
return request.state.redis # type: ignore[no-any-return]
|
|
|
|
|
|
Redis = Annotated[RedisClient, Depends(get_redis)]
|
|
|
|
|
|
# === Service Dependencies ===
|
|
|
|
from app.services.user import UserService
|
|
from app.services.webhook import WebhookService
|
|
from app.services.conversation import ConversationService
|
|
|
|
|
|
def get_user_service(db: DBSession) -> UserService:
|
|
"""Create UserService instance with database session."""
|
|
return UserService(db)
|
|
|
|
|
|
UserSvc = Annotated[UserService, Depends(get_user_service)]
|
|
|
|
|
|
def get_webhook_service(db: DBSession) -> WebhookService:
|
|
"""Create WebhookService instance with database session."""
|
|
return WebhookService(db)
|
|
|
|
|
|
WebhookSvc = Annotated[WebhookService, Depends(get_webhook_service)]
|
|
|
|
|
|
def get_conversation_service(db: DBSession) -> ConversationService:
|
|
"""Create ConversationService instance with database session."""
|
|
return ConversationService(db)
|
|
|
|
|
|
ConversationSvc = Annotated[ConversationService, Depends(get_conversation_service)]
|
|
|
|
from app.services.conversation_share import ConversationShareService
|
|
|
|
|
|
def get_conversation_share_service(db: DBSession) -> ConversationShareService:
|
|
"""Create ConversationShareService instance with database session."""
|
|
return ConversationShareService(db)
|
|
|
|
|
|
ConversationShareSvc = Annotated[ConversationShareService, Depends(get_conversation_share_service)]
|
|
from app.services.project import ProjectService
|
|
|
|
|
|
def get_project_service(db: DBSession) -> ProjectService:
|
|
"""Create ProjectService instance with database session."""
|
|
return ProjectService(db)
|
|
|
|
|
|
ProjectSvc = Annotated[ProjectService, Depends(get_project_service)]
|
|
|
|
# Message rating service
|
|
from app.services.message_rating import MessageRatingService
|
|
|
|
|
|
def get_rating_service(db: DBSession) -> MessageRatingService:
|
|
"""Create MessageRatingService instance with database session."""
|
|
return MessageRatingService(db)
|
|
|
|
|
|
MessageRatingSvc = Annotated[MessageRatingService, Depends(get_rating_service)]
|
|
from app.services.rag_document import RAGDocumentService
|
|
from app.services.rag_sync import RAGSyncService
|
|
from app.services.sync_source import SyncSourceService
|
|
|
|
|
|
def get_rag_document_service(db: DBSession) -> RAGDocumentService:
|
|
"""Create RAGDocumentService instance with database session."""
|
|
return RAGDocumentService(db)
|
|
|
|
|
|
def get_rag_sync_service(db: DBSession) -> RAGSyncService:
|
|
"""Create RAGSyncService instance with database session."""
|
|
return RAGSyncService(db)
|
|
|
|
|
|
def get_sync_source_service(db: DBSession) -> SyncSourceService:
|
|
"""Create SyncSourceService instance with database session."""
|
|
return SyncSourceService(db)
|
|
|
|
|
|
RAGDocumentSvc = Annotated[RAGDocumentService, Depends(get_rag_document_service)]
|
|
RAGSyncSvc = Annotated[RAGSyncService, Depends(get_rag_sync_service)]
|
|
SyncSourceSvc = Annotated[SyncSourceService, Depends(get_sync_source_service)]
|
|
from app.services.rag_status import RAGStatusService
|
|
|
|
|
|
def get_rag_status_service() -> RAGStatusService:
|
|
"""Create RAGStatusService instance (no DB)."""
|
|
return RAGStatusService()
|
|
|
|
|
|
RAGStatusSvc = Annotated[RAGStatusService, Depends(get_rag_status_service)]
|
|
from app.services.file_upload import FileUploadService
|
|
|
|
|
|
def get_file_upload_service(db: DBSession) -> FileUploadService:
|
|
"""Create FileUploadService instance with database session."""
|
|
return FileUploadService(db)
|
|
|
|
|
|
FileUploadSvc = Annotated[FileUploadService, Depends(get_file_upload_service)]
|
|
# === Authentication Dependencies ===
|
|
|
|
from app.core.exceptions import AuthenticationError, AuthorizationError
|
|
from app.db.models.user import User, UserRole
|
|
|
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"{settings.API_V1_STR}/auth/login")
|
|
|
|
|
|
async def get_current_user(
|
|
token: Annotated[str, Depends(oauth2_scheme)],
|
|
user_service: UserSvc,
|
|
) -> User:
|
|
"""Get current authenticated user from JWT token.
|
|
|
|
Returns the full User object including role information.
|
|
|
|
Raises:
|
|
AuthenticationError: If token is invalid or user not found.
|
|
"""
|
|
from uuid import UUID
|
|
|
|
from app.core.security import verify_token
|
|
|
|
payload = verify_token(token)
|
|
if payload is None:
|
|
raise AuthenticationError(message="Invalid or expired token")
|
|
|
|
# Ensure this is an access token, not a refresh token
|
|
if payload.get("type") != "access":
|
|
raise AuthenticationError(message="Invalid token type")
|
|
|
|
user_id = payload.get("sub")
|
|
if user_id is None:
|
|
raise AuthenticationError(message="Invalid token payload")
|
|
|
|
user = await user_service.get_by_id(UUID(user_id))
|
|
if not user.is_active:
|
|
raise AuthenticationError(message="User account is disabled")
|
|
|
|
return user
|
|
|
|
|
|
class RoleChecker:
|
|
"""Dependency class for role-based access control.
|
|
|
|
Usage:
|
|
# Require admin role
|
|
@router.get("/admin-only")
|
|
async def admin_endpoint(
|
|
user: Annotated[User, Depends(RoleChecker(UserRole.ADMIN))]
|
|
):
|
|
...
|
|
|
|
# Require any authenticated user
|
|
@router.get("/users")
|
|
async def users_endpoint(
|
|
user: Annotated[User, Depends(get_current_user)]
|
|
):
|
|
...
|
|
"""
|
|
|
|
def __init__(self, required_role: UserRole) -> None:
|
|
self.required_role = required_role
|
|
|
|
async def __call__(
|
|
self,
|
|
user: Annotated[User, Depends(get_current_user)],
|
|
) -> User:
|
|
"""Check if user has the required role.
|
|
|
|
Raises:
|
|
AuthorizationError: If user doesn't have the required role.
|
|
"""
|
|
if not user.has_role(self.required_role):
|
|
raise AuthorizationError(
|
|
message=f"Role '{self.required_role.value}' required for this action"
|
|
)
|
|
return user
|
|
|
|
|
|
async def get_current_active_superuser(
|
|
current_user: Annotated[User, Depends(get_current_user)],
|
|
) -> User:
|
|
"""Get current user and verify they are a superuser.
|
|
|
|
Raises:
|
|
AuthorizationError: If user is not a superuser.
|
|
"""
|
|
if not current_user.has_role(UserRole.ADMIN):
|
|
raise AuthorizationError(message="Admin privileges required")
|
|
return current_user
|
|
|
|
|
|
# Type aliases for dependency injection
|
|
CurrentUser = Annotated[User, Depends(get_current_user)]
|
|
CurrentSuperuser = Annotated[User, Depends(get_current_active_superuser)]
|
|
CurrentAdmin = Annotated[User, Depends(RoleChecker(UserRole.ADMIN))]
|
|
|
|
|
|
# is_app_admin is a global flag on the User model — independent of team
|
|
# membership. Routes guarded by this dep (e.g. /admin/users) stay reachable
|
|
# even when teams are disabled, so the dep itself must not be gated.
|
|
async def _require_app_admin(user: CurrentUser) -> "User": # type: ignore[name-defined]
|
|
"""Raises 403 unless the user has the is_app_admin flag set."""
|
|
if not getattr(user, "is_app_admin", False):
|
|
raise AuthorizationError(message="App admin privileges required")
|
|
return user
|
|
|
|
|
|
CurrentAppAdmin = Annotated["User", Depends(_require_app_admin)] # type: ignore[valid-type]
|
|
|
|
|
|
# WebSocket authentication dependency
|
|
from fastapi import WebSocket, Cookie
|
|
|
|
|
|
_WS_TOKEN_PROTOCOL_PREFIX = "access_token."
|
|
|
|
|
|
def _extract_ws_auth(websocket: WebSocket) -> tuple[str | None, str | None]:
|
|
"""Parse Sec-WebSocket-Protocol header for an auth token + app subprotocol.
|
|
|
|
Clients pass the token as a subprotocol of the form
|
|
``access_token.<JWT>`` alongside an optional application subprotocol
|
|
(e.g. ``chat``). Returns (token, app_subprotocol) — either may be None.
|
|
"""
|
|
raw = websocket.headers.get("sec-websocket-protocol") or ""
|
|
token: str | None = None
|
|
app_subprotocol: str | None = None
|
|
for proto in (p.strip() for p in raw.split(",") if p.strip()):
|
|
if proto.startswith(_WS_TOKEN_PROTOCOL_PREFIX):
|
|
token = proto[len(_WS_TOKEN_PROTOCOL_PREFIX) :]
|
|
elif app_subprotocol is None:
|
|
app_subprotocol = proto
|
|
return token, app_subprotocol
|
|
|
|
|
|
async def get_current_user_ws(
|
|
websocket: WebSocket,
|
|
access_token: str | None = Cookie(None),
|
|
) -> User:
|
|
"""Authenticate a WebSocket connection.
|
|
|
|
Token sources, checked in order:
|
|
1. ``Sec-WebSocket-Protocol`` header, in the form ``access_token.<JWT>``.
|
|
The chosen application subprotocol (e.g. ``chat``) is echoed back on
|
|
``accept()`` via ``websocket.state.accept_subprotocol``.
|
|
2. Same-origin ``access_token`` cookie (fallback for same-origin clients).
|
|
|
|
Tokens in query strings are NOT accepted — they leak into logs and
|
|
Referer headers.
|
|
|
|
Raises:
|
|
AuthenticationError: If token is invalid or user not found.
|
|
"""
|
|
from uuid import UUID
|
|
|
|
from app.core.security import verify_token
|
|
|
|
subprotocol_token, app_subprotocol = _extract_ws_auth(websocket)
|
|
websocket.state.accept_subprotocol = app_subprotocol
|
|
|
|
auth_token = subprotocol_token or access_token
|
|
|
|
if not auth_token:
|
|
await websocket.close(code=4001, reason="Missing authentication token")
|
|
raise AuthenticationError(message="Missing authentication token")
|
|
|
|
payload = verify_token(auth_token)
|
|
if payload is None:
|
|
await websocket.close(code=4001, reason="Invalid or expired token")
|
|
raise AuthenticationError(message="Invalid or expired token")
|
|
|
|
if payload.get("type") != "access":
|
|
await websocket.close(code=4001, reason="Invalid token type")
|
|
raise AuthenticationError(message="Invalid token type")
|
|
|
|
user_id = payload.get("sub")
|
|
if user_id is None:
|
|
await websocket.close(code=4001, reason="Invalid token payload")
|
|
raise AuthenticationError(message="Invalid token payload")
|
|
|
|
from app.db.session import get_db_context
|
|
|
|
async with get_db_context() as db:
|
|
user_service = UserService(db)
|
|
user = await user_service.get_by_id(UUID(user_id))
|
|
|
|
if not user.is_active:
|
|
await websocket.close(code=4001, reason="User account is disabled")
|
|
raise AuthenticationError(message="User account is disabled")
|
|
|
|
# Eagerly load all columns, then detach from session to avoid
|
|
# "instance not bound to a Session" errors after the context manager exits
|
|
await db.refresh(user)
|
|
db.expunge(user)
|
|
return user
|
|
|
|
|
|
import secrets
|
|
|
|
from fastapi.security import APIKeyHeader
|
|
|
|
|
|
api_key_header = APIKeyHeader(name=settings.API_KEY_HEADER, auto_error=False)
|
|
|
|
|
|
async def verify_api_key(
|
|
api_key: Annotated[str | None, Depends(api_key_header)],
|
|
) -> str:
|
|
"""Verify API key from header.
|
|
|
|
Uses constant-time comparison to prevent timing attacks.
|
|
|
|
Raises:
|
|
AuthenticationError: If API key is missing.
|
|
AuthorizationError: If API key is invalid.
|
|
"""
|
|
if api_key is None:
|
|
raise AuthenticationError(message="API Key header missing")
|
|
if not secrets.compare_digest(api_key, settings.API_KEY):
|
|
raise AuthorizationError(message="Invalid API Key")
|
|
return api_key
|
|
|
|
|
|
ValidAPIKey = Annotated[str, Depends(verify_api_key)]
|
|
|
|
# === RAG Service Dependencies ===
|
|
|
|
from app.services.rag.embeddings import EmbeddingService
|
|
from app.services.rag.ingestion import IngestionService
|
|
from app.services.rag.documents import DocumentProcessor
|
|
from fastapi import Request
|
|
from app.core.config import settings
|
|
from app.services.rag.retrieval import RetrievalService
|
|
from app.services.rag.vectorstore import QdrantVectorStore
|
|
|
|
|
|
def get_embedding_service(request: Request) -> EmbeddingService:
|
|
"""Get embedding service from lifespan state or create new if not available."""
|
|
if request and hasattr(request.state, "embedding_service"):
|
|
return request.state.embedding_service # type: ignore[no-any-return]
|
|
return EmbeddingService(settings=settings.rag)
|
|
|
|
|
|
# Type Alias for the Embedder
|
|
EmbeddingSvc = Annotated[EmbeddingService, Depends(get_embedding_service)]
|
|
|
|
from app.services.rag.vectorstore import BaseVectorStore
|
|
|
|
|
|
def get_vectorstore(request: Request, embedder: EmbeddingSvc) -> BaseVectorStore:
|
|
"""Get vector store client from lifespan state or create new."""
|
|
if request and hasattr(request.state, "vector_store"):
|
|
return request.state.vector_store # type: ignore[no-any-return]
|
|
return QdrantVectorStore(settings=settings.rag, embedding_service=embedder)
|
|
|
|
|
|
VectorStoreSvc = Annotated[BaseVectorStore, Depends(get_vectorstore)]
|
|
|
|
|
|
def get_retrieval_service(vector_store: VectorStoreSvc) -> RetrievalService:
|
|
"""Create RetrievalService instance."""
|
|
from app.services.rag.reranker import RerankService
|
|
|
|
rerank_service = RerankService(settings=settings.rag)
|
|
return RetrievalService(
|
|
vector_store=vector_store,
|
|
settings=settings.rag,
|
|
rerank_service=rerank_service,
|
|
)
|
|
|
|
|
|
RetrievalSvc = Annotated[RetrievalService, Depends(get_retrieval_service)]
|
|
|
|
|
|
def get_document_processor() -> DocumentProcessor:
|
|
"""Create DocumentProcessor instance."""
|
|
return DocumentProcessor(settings=settings.rag)
|
|
|
|
|
|
DocumentProcessorSvc = Annotated[DocumentProcessor, Depends(get_document_processor)]
|
|
|
|
|
|
def get_ingestion_service(
|
|
processor: DocumentProcessorSvc,
|
|
vector_store: VectorStoreSvc,
|
|
request: Request,
|
|
) -> IngestionService:
|
|
"""Create IngestionService instance."""
|
|
|
|
# Wire webhook dispatch for RAG events
|
|
async def on_rag_event(event: str, data: dict):
|
|
from app.services.webhook import WebhookService
|
|
|
|
db = request.state.db if hasattr(request.state, "db") else None
|
|
if db:
|
|
webhook_service = WebhookService(db)
|
|
await webhook_service.dispatch_event(event, data)
|
|
|
|
return IngestionService(processor=processor, vector_store=vector_store, on_event=on_rag_event)
|
|
|
|
|
|
IngestionSvc = Annotated[IngestionService, Depends(get_ingestion_service)]
|
|
from app.services.user_slash_command import UserSlashCommandService
|
|
|
|
|
|
def get_user_slash_command_service(db: DBSession) -> UserSlashCommandService:
|
|
return UserSlashCommandService(db)
|
|
|
|
|
|
UserSlashCommandSvc = Annotated[UserSlashCommandService, Depends(get_user_slash_command_service)]
|
|
from app.services.admin import AdminService
|
|
|
|
|
|
def get_admin_service(db: DBSession) -> AdminService:
|
|
"""Create AdminService instance — used by admin REST routes (always
|
|
available, independent of the optional SQLAdmin UI)."""
|
|
return AdminService(db)
|
|
|
|
|
|
AdminSvc = Annotated[AdminService, Depends(get_admin_service)]
|