diff --git a/ai-service/app/api/admin/__init__.py b/ai-service/app/api/admin/__init__.py index 40b96bb..5bc4b10 100644 --- a/ai-service/app/api/admin/__init__.py +++ b/ai-service/app/api/admin/__init__.py @@ -1,8 +1,9 @@ """ Admin API routes for AI Service management. -[AC-ASA-01, AC-ASA-02, AC-ASA-05, AC-ASA-07, AC-ASA-08] Admin management endpoints. +[AC-ASA-01, AC-ASA-02, AC-ASA-05, AC-ASA-07, AC-ASA-08, AC-AISVC-50] Admin management endpoints. """ +from app.api.admin.api_key import router as api_key_router from app.api.admin.dashboard import router as dashboard_router from app.api.admin.embedding import router as embedding_router from app.api.admin.kb import router as kb_router @@ -11,4 +12,4 @@ from app.api.admin.rag import router as rag_router from app.api.admin.sessions import router as sessions_router from app.api.admin.tenants import router as tenants_router -__all__ = ["dashboard_router", "embedding_router", "kb_router", "llm_router", "rag_router", "sessions_router", "tenants_router"] +__all__ = ["api_key_router", "dashboard_router", "embedding_router", "kb_router", "llm_router", "rag_router", "sessions_router", "tenants_router"] diff --git a/ai-service/app/api/admin/api_key.py b/ai-service/app/api/admin/api_key.py new file mode 100644 index 0000000..b73b78b --- /dev/null +++ b/ai-service/app/api/admin/api_key.py @@ -0,0 +1,154 @@ +""" +API Key management endpoints. +[AC-AISVC-50] CRUD operations for API keys. +""" + +import logging +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel, Field +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_session +from app.models.entities import ApiKey, ApiKeyCreate +from app.services.api_key import get_api_key_service + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/admin/api-keys", tags=["API Keys"]) + + +class ApiKeyResponse(BaseModel): + """Response model for API key.""" + + id: str = Field(..., description="API key ID") + key: str = Field(..., description="API key value") + name: str = Field(..., description="API key name") + is_active: bool = Field(..., description="Whether the key is active") + created_at: str = Field(..., description="Creation time") + updated_at: str = Field(..., description="Last update time") + + +class ApiKeyListResponse(BaseModel): + """Response model for API key list.""" + + keys: list[ApiKeyResponse] = Field(..., description="List of API keys") + total: int = Field(..., description="Total count") + + +class CreateApiKeyRequest(BaseModel): + """Request model for creating API key.""" + + name: str = Field(..., description="API key name/description") + key: str | None = Field(default=None, description="Custom API key (auto-generated if not provided)") + + +class ToggleApiKeyRequest(BaseModel): + """Request model for toggling API key status.""" + + is_active: bool = Field(..., description="New active status") + + +def api_key_to_response(api_key: ApiKey) -> ApiKeyResponse: + """Convert ApiKey entity to response model.""" + return ApiKeyResponse( + id=str(api_key.id), + key=api_key.key, + name=api_key.name, + is_active=api_key.is_active, + created_at=api_key.created_at.isoformat(), + updated_at=api_key.updated_at.isoformat(), + ) + + +@router.get("", response_model=ApiKeyListResponse) +async def list_api_keys( + session: Annotated[AsyncSession, Depends(get_session)], +): + """ + [AC-AISVC-50] List all API keys. + """ + service = get_api_key_service() + keys = await service.list_keys(session) + + return ApiKeyListResponse( + keys=[api_key_to_response(k) for k in keys], + total=len(keys), + ) + + +@router.post("", response_model=ApiKeyResponse, status_code=status.HTTP_201_CREATED) +async def create_api_key( + request: CreateApiKeyRequest, + session: Annotated[AsyncSession, Depends(get_session)], +): + """ + [AC-AISVC-50] Create a new API key. + """ + service = get_api_key_service() + + key_value = request.key or service.generate_key() + + key_create = ApiKeyCreate( + key=key_value, + name=request.name, + is_active=True, + ) + + api_key = await service.create_key(session, key_create) + logger.info(f"[AC-AISVC-50] Created API key: {api_key.name}") + + return api_key_to_response(api_key) + + +@router.delete("/{key_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_api_key( + key_id: str, + session: Annotated[AsyncSession, Depends(get_session)], +): + """ + [AC-AISVC-50] Delete an API key. + """ + service = get_api_key_service() + + deleted = await service.delete_key(session, key_id) + + if not deleted: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="API key not found", + ) + + +@router.patch("/{key_id}/toggle", response_model=ApiKeyResponse) +async def toggle_api_key( + key_id: str, + request: ToggleApiKeyRequest, + session: Annotated[AsyncSession, Depends(get_session)], +): + """ + [AC-AISVC-50] Toggle API key active status. + """ + service = get_api_key_service() + + api_key = await service.toggle_key(session, key_id, request.is_active) + + if not api_key: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="API key not found", + ) + + return api_key_to_response(api_key) + + +@router.post("/reload-cache", status_code=status.HTTP_204_NO_CONTENT) +async def reload_api_key_cache( + session: Annotated[AsyncSession, Depends(get_session)], +): + """ + [AC-AISVC-50] Reload API key cache from database. + """ + service = get_api_key_service() + await service.reload_cache(session) diff --git a/ai-service/app/main.py b/ai-service/app/main.py index c18afba..56d7ccc 100644 --- a/ai-service/app/main.py +++ b/ai-service/app/main.py @@ -12,7 +12,7 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from app.api import chat_router, health_router -from app.api.admin import dashboard_router, embedding_router, kb_router, llm_router, rag_router, sessions_router, tenants_router +from app.api.admin import api_key_router, dashboard_router, embedding_router, kb_router, llm_router, rag_router, sessions_router, tenants_router from app.api.admin.kb_optimized import router as kb_optimized_router from app.core.config import get_settings from app.core.database import close_db, init_db @@ -24,7 +24,7 @@ from app.core.exceptions import ( generic_exception_handler, http_exception_handler, ) -from app.core.middleware import TenantContextMiddleware +from app.core.middleware import ApiKeyMiddleware, TenantContextMiddleware from app.core.qdrant_client import close_qdrant_client settings = get_settings() @@ -40,7 +40,7 @@ logger = logging.getLogger(__name__) @asynccontextmanager async def lifespan(app: FastAPI): """ - [AC-AISVC-01, AC-AISVC-11] Application lifespan manager. + [AC-AISVC-01, AC-AISVC-11, AC-AISVC-50] Application lifespan manager. Handles startup and shutdown of database and external connections. """ logger.info(f"[AC-AISVC-01] Starting {settings.app_name} v{settings.app_version}") @@ -51,6 +51,19 @@ async def lifespan(app: FastAPI): except Exception as e: logger.warning(f"[AC-AISVC-11] Database initialization skipped: {e}") + try: + from app.core.database import async_session_maker + from app.services.api_key import get_api_key_service + + async with async_session_maker() as session: + api_key_service = get_api_key_service() + await api_key_service.initialize(session) + default_key = await api_key_service.create_default_key(session) + if default_key: + logger.info(f"[AC-AISVC-50] Default API key created: {default_key.key}") + except Exception as e: + logger.warning(f"[AC-AISVC-50] API key initialization skipped: {e}") + yield await close_db() @@ -87,6 +100,7 @@ app.add_middleware( ) app.add_middleware(TenantContextMiddleware) +app.add_middleware(ApiKeyMiddleware) app.add_exception_handler(AIServiceException, ai_service_exception_handler) app.add_exception_handler(HTTPException, http_exception_handler) @@ -113,6 +127,7 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE app.include_router(health_router) app.include_router(chat_router) +app.include_router(api_key_router) app.include_router(dashboard_router) app.include_router(embedding_router) app.include_router(kb_router) diff --git a/ai-service/app/models/__init__.py b/ai-service/app/models/__init__.py index cbbe9e7..f30da8b 100644 --- a/ai-service/app/models/__init__.py +++ b/ai-service/app/models/__init__.py @@ -50,6 +50,7 @@ class ErrorCode(str, Enum): INVALID_REQUEST = "INVALID_REQUEST" MISSING_TENANT_ID = "MISSING_TENANT_ID" INVALID_TENANT_ID = "INVALID_TENANT_ID" + UNAUTHORIZED = "UNAUTHORIZED" INTERNAL_ERROR = "INTERNAL_ERROR" SERVICE_UNAVAILABLE = "SERVICE_UNAVAILABLE" TIMEOUT = "TIMEOUT" diff --git a/ai-service/app/models/entities.py b/ai-service/app/models/entities.py index cf41595..dd9363a 100644 --- a/ai-service/app/models/entities.py +++ b/ai-service/app/models/entities.py @@ -198,3 +198,27 @@ class DocumentCreate(SQLModel): file_path: str | None = None file_size: int | None = None file_type: str | None = None + + +class ApiKey(SQLModel, table=True): + """ + [AC-AISVC-50] API Key entity for lightweight authentication. + Keys are loaded into memory on startup for fast validation. + """ + + __tablename__ = "api_keys" + + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + key: str = Field(..., description="API Key (unique)", unique=True, index=True) + name: str = Field(..., description="Key name/description for identification") + is_active: bool = Field(default=True, description="Whether the key is active") + created_at: datetime = Field(default_factory=datetime.utcnow, description="Creation time") + updated_at: datetime = Field(default_factory=datetime.utcnow, description="Last update time") + + +class ApiKeyCreate(SQLModel): + """Schema for creating a new API key.""" + + key: str + name: str + is_active: bool = True diff --git a/ai-service/app/services/api_key.py b/ai-service/app/services/api_key.py new file mode 100644 index 0000000..a4c14a6 --- /dev/null +++ b/ai-service/app/services/api_key.py @@ -0,0 +1,249 @@ +""" +API Key management service. +[AC-AISVC-50] Lightweight authentication with in-memory cache. +""" + +import logging +import secrets +from datetime import datetime +from typing import Optional + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.entities import ApiKey, ApiKeyCreate + +logger = logging.getLogger(__name__) + + +class ApiKeyService: + """ + [AC-AISVC-50] API Key management service. + + Features: + - In-memory cache for fast validation + - Database persistence + - Hot-reload support + """ + + def __init__(self): + self._keys_cache: set[str] = set() + self._initialized: bool = False + + async def initialize(self, session: AsyncSession) -> None: + """ + Load all active API keys from database into memory. + Should be called on application startup. + """ + result = await session.execute( + select(ApiKey).where(ApiKey.is_active == True) + ) + keys = result.scalars().all() + + self._keys_cache = {key.key for key in keys} + self._initialized = True + + logger.info(f"[AC-AISVC-50] Loaded {len(self._keys_cache)} API keys into memory") + + def validate_key(self, key: str) -> bool: + """ + Validate an API key against the in-memory cache. + + Args: + key: The API key to validate + + Returns: + True if the key is valid, False otherwise + """ + if not self._initialized: + logger.warning("[AC-AISVC-50] API key service not initialized") + return False + + return key in self._keys_cache + + def generate_key(self) -> str: + """ + Generate a new secure API key. + + Returns: + A URL-safe random string + """ + return secrets.token_urlsafe(32) + + async def create_key( + self, + session: AsyncSession, + key_create: ApiKeyCreate + ) -> ApiKey: + """ + Create a new API key. + + Args: + session: Database session + key_create: Key creation data + + Returns: + The created ApiKey entity + """ + api_key = ApiKey( + key=key_create.key, + name=key_create.name, + is_active=key_create.is_active, + ) + + session.add(api_key) + await session.commit() + await session.refresh(api_key) + + if api_key.is_active: + self._keys_cache.add(api_key.key) + + logger.info(f"[AC-AISVC-50] Created API key: {api_key.name}") + return api_key + + async def create_default_key(self, session: AsyncSession) -> Optional[ApiKey]: + """ + Create a default API key if none exists. + + Returns: + The created ApiKey or None if keys already exist + """ + result = await session.execute(select(ApiKey).limit(1)) + existing = result.scalar_one_or_none() + + if existing: + return None + + default_key = secrets.token_urlsafe(32) + api_key = ApiKey( + key=default_key, + name="Default API Key", + is_active=True, + ) + + session.add(api_key) + await session.commit() + await session.refresh(api_key) + + self._keys_cache.add(api_key.key) + + logger.info(f"[AC-AISVC-50] Created default API key: {api_key.key}") + return api_key + + async def delete_key( + self, + session: AsyncSession, + key_id: str + ) -> bool: + """ + Delete an API key. + + Args: + session: Database session + key_id: The key ID to delete + + Returns: + True if deleted, False if not found + """ + import uuid + + try: + key_uuid = uuid.UUID(key_id) + except ValueError: + return False + + result = await session.execute( + select(ApiKey).where(ApiKey.id == key_uuid) + ) + api_key = result.scalar_one_or_none() + + if not api_key: + return False + + key_value = api_key.key + await session.delete(api_key) + await session.commit() + + self._keys_cache.discard(key_value) + + logger.info(f"[AC-AISVC-50] Deleted API key: {api_key.name}") + return True + + async def toggle_key( + self, + session: AsyncSession, + key_id: str, + is_active: bool + ) -> Optional[ApiKey]: + """ + Toggle API key active status. + + Args: + session: Database session + key_id: The key ID to toggle + is_active: New active status + + Returns: + The updated ApiKey or None if not found + """ + import uuid + + try: + key_uuid = uuid.UUID(key_id) + except ValueError: + return None + + result = await session.execute( + select(ApiKey).where(ApiKey.id == key_uuid) + ) + api_key = result.scalar_one_or_none() + + if not api_key: + return None + + api_key.is_active = is_active + api_key.updated_at = datetime.utcnow() + + session.add(api_key) + await session.commit() + await session.refresh(api_key) + + if is_active: + self._keys_cache.add(api_key.key) + else: + self._keys_cache.discard(api_key.key) + + logger.info(f"[AC-AISVC-50] Toggled API key {api_key.name}: active={is_active}") + return api_key + + async def list_keys(self, session: AsyncSession) -> list[ApiKey]: + """ + List all API keys. + + Args: + session: Database session + + Returns: + List of all ApiKey entities + """ + result = await session.execute(select(ApiKey)) + return list(result.scalars().all()) + + async def reload_cache(self, session: AsyncSession) -> None: + """ + Reload all API keys from database into memory. + """ + self._keys_cache.clear() + await self.initialize(session) + logger.info("[AC-AISVC-50] API key cache reloaded") + + +_api_key_service: ApiKeyService | None = None + + +def get_api_key_service() -> ApiKeyService: + """Get the global API key service instance.""" + global _api_key_service + if _api_key_service is None: + _api_key_service = ApiKeyService() + return _api_key_service diff --git a/ai-service/scripts/init_db.sql b/ai-service/scripts/init_db.sql index 9a68dac..b4690ea 100644 --- a/ai-service/scripts/init_db.sql +++ b/ai-service/scripts/init_db.sql @@ -74,6 +74,18 @@ CREATE TABLE IF NOT EXISTS index_jobs ( updated_at TIMESTAMP WITHOUT TIME ZONE NOT NULL ); +-- ============================================ +-- API Keys Table [AC-AISVC-50] +-- ============================================ +CREATE TABLE IF NOT EXISTS api_keys ( + id UUID NOT NULL PRIMARY KEY, + key VARCHAR NOT NULL UNIQUE, + name VARCHAR NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL, + updated_at TIMESTAMP WITHOUT TIME ZONE NOT NULL +); + -- ============================================ -- Indexes -- ============================================ @@ -100,6 +112,10 @@ CREATE INDEX IF NOT EXISTS ix_index_jobs_tenant_id ON index_jobs (tenant_id); CREATE INDEX IF NOT EXISTS ix_index_jobs_tenant_doc ON index_jobs (tenant_id, doc_id); CREATE INDEX IF NOT EXISTS ix_index_jobs_tenant_status ON index_jobs (tenant_id, status); +-- API Keys Indexes [AC-AISVC-50] +CREATE INDEX IF NOT EXISTS ix_api_keys_key ON api_keys (key); +CREATE INDEX IF NOT EXISTS ix_api_keys_is_active ON api_keys (is_active); + -- ============================================ -- Verification -- ============================================