diff --git a/aperag/api/components/schemas/audit.yaml b/aperag/api/components/schemas/audit.yaml new file mode 100644 index 000000000..dd8710d49 --- /dev/null +++ b/aperag/api/components/schemas/audit.yaml @@ -0,0 +1,89 @@ +auditLog: + type: object + description: Audit log entry + properties: + id: + type: string + description: Audit log ID + user_id: + type: string + nullable: true + description: User ID who performed the action + username: + type: string + nullable: true + description: Username for display + resource_type: + type: string + enum: [collection, document, bot, chat, message, api_key, llm_provider, llm_provider_model, model_service_provider, user, flow, search_test] + nullable: true + description: Type of resource + resource_id: + type: string + nullable: true + description: ID of the resource (extracted at query time) + api_name: + type: string + description: API operation name + http_method: + type: string + description: HTTP method (POST, PUT, DELETE) + path: + type: string + description: API path + status_code: + type: integer + nullable: true + description: HTTP status code + start_time: + type: integer + format: int64 + description: Request start time (milliseconds since epoch) + end_time: + type: integer + format: int64 + nullable: true + description: Request end time (milliseconds since epoch) + duration_ms: + type: integer + nullable: true + description: Request duration in milliseconds (calculated) + request_data: + type: string + nullable: true + description: Request data (JSON string) + response_data: + type: string + nullable: true + description: Response data (JSON string) + error_message: + type: string + nullable: true + description: Error message if failed + ip_address: + type: string + nullable: true + description: Client IP address + user_agent: + type: string + nullable: true + description: User agent string + request_id: + type: string + description: Request ID for tracking + created: + type: string + format: date-time + description: Created timestamp + +auditLogList: + type: object + description: List of audit logs + properties: + items: + type: array + description: Audit log entries + items: + $ref: '#/auditLog' + + \ No newline at end of file diff --git a/aperag/api/openapi.yaml b/aperag/api/openapi.yaml index 453930cb7..43f2c119d 100644 --- a/aperag/api/openapi.yaml +++ b/aperag/api/openapi.yaml @@ -88,6 +88,12 @@ paths: /prompt-templates: $ref: './paths/prompt_templates.yaml#/promptTemplates' + # audit + /audit-logs: + $ref: './paths/audit.yaml#/audit_logs' + /audit-logs/{audit_id}: + $ref: './paths/audit.yaml#/audit_log_detail' + # users /invite: $ref: './paths/auth.yaml#/invite' diff --git a/aperag/api/paths/audit.yaml b/aperag/api/paths/audit.yaml new file mode 100644 index 000000000..839d8db3f --- /dev/null +++ b/aperag/api/paths/audit.yaml @@ -0,0 +1,73 @@ +audit_logs: + get: + tags: + - audit + summary: List audit logs + description: List audit logs with filtering options + operationId: list_audit_logs + parameters: + - name: api_name + in: query + required: false + schema: + type: string + description: Filter by API name + - name: start_date + in: query + required: false + schema: + type: string + format: date-time + description: Filter by start date + - name: end_date + in: query + required: false + schema: + type: string + format: date-time + description: Filter by end date + - name: limit + in: query + required: false + schema: + type: integer + maximum: 5000 + default: 1000 + description: Maximum number of records + responses: + "200": + description: Audit logs retrieved successfully + content: + application/json: + schema: + $ref: "../components/schemas/audit.yaml#/auditLogList" + "403": + description: Admin access required + +audit_log_detail: + get: + tags: + - audit + summary: Get audit log detail + description: Get a specific audit log by ID + operationId: get_audit_log + parameters: + - name: audit_id + in: path + required: true + schema: + type: string + description: Audit log ID + responses: + "200": + description: Audit log retrieved successfully + content: + application/json: + schema: + $ref: "../components/schemas/audit.yaml#/auditLog" + "403": + description: Admin access required + "404": + description: Audit log not found + + \ No newline at end of file diff --git a/aperag/app.py b/aperag/app.py index 572d14bf7..f8ad72fdf 100644 --- a/aperag/app.py +++ b/aperag/app.py @@ -17,6 +17,7 @@ from aperag.exception_handlers import register_exception_handlers from aperag.llm.litellm_track import register_opik_llm_track from aperag.views.api_key import router as api_key_router +from aperag.views.audit import router as audit_router from aperag.views.auth import router as auth_router from aperag.views.chat_completion import router as chat_completion_router from aperag.views.config import router as config_router @@ -38,6 +39,7 @@ app.include_router(auth_router, prefix="/api/v1") app.include_router(main_router, prefix="/api/v1") app.include_router(api_key_router, prefix="/api/v1") +app.include_router(audit_router, prefix="/api/v1") # Add audit router app.include_router(flow_router, prefix="/api/v1") app.include_router(llm_router, prefix="/api/v1") app.include_router(chat_completion_router, prefix="/v1") diff --git a/aperag/db/models.py b/aperag/db/models.py index 90e12c185..e77d06d54 100644 --- a/aperag/db/models.py +++ b/aperag/db/models.py @@ -24,6 +24,7 @@ Boolean, Column, DateTime, + Index, Integer, String, Text, @@ -746,3 +747,67 @@ def update_spec(self, desired_state: IndexDesiredState = None, created_by: str = self.created_by = created_by self.version += 1 self.gmt_updated = utc_now() + + +class AuditResource(str, Enum): + """Audit resource types""" + + COLLECTION = "collection" + DOCUMENT = "document" + BOT = "bot" + CHAT = "chat" + MESSAGE = "message" + API_KEY = "api_key" + LLM_PROVIDER = "llm_provider" + LLM_PROVIDER_MODEL = "llm_provider_model" + MODEL_SERVICE_PROVIDER = "model_service_provider" + USER = "user" + CONFIG = "config" + INVITATION = "invitation" + AUTH = "auth" + CHAT_COMPLETION = "chat_completion" + SEARCH_TEST = "search_test" + LLM = "llm" + FLOW = "flow" + SYSTEM = "system" + + +class AuditLog(Base): + """Audit log model to track all system operations""" + + __tablename__ = "audit_log" + + id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + user_id = Column(String(36), nullable=True, comment="User ID") + username = Column(String(255), nullable=True, comment="Username") + resource_type = Column(EnumColumn(AuditResource), nullable=True, comment="Resource type") + resource_id = Column(String(255), nullable=True, comment="Resource ID (extracted at query time)") + api_name = Column(String(255), nullable=False, comment="API operation name") + http_method = Column(String(10), nullable=False, comment="HTTP method (POST, PUT, DELETE)") + path = Column(String(512), nullable=False, comment="API path") + status_code = Column(Integer, nullable=True, comment="HTTP status code") + request_data = Column(Text, nullable=True, comment="Request data (JSON)") + response_data = Column(Text, nullable=True, comment="Response data (JSON)") + error_message = Column(Text, nullable=True, comment="Error message if failed") + ip_address = Column(String(45), nullable=True, comment="Client IP address") + user_agent = Column(String(500), nullable=True, comment="User agent string") + request_id = Column(String(255), nullable=False, comment="Request ID for tracking") + start_time = Column(BigInteger, nullable=False, comment="Request start time (milliseconds since epoch)") + end_time = Column(BigInteger, nullable=True, comment="Request end time (milliseconds since epoch)") + gmt_created = Column(DateTime(timezone=True), nullable=False, default=utc_now, comment="Created time") + + # Index for better query performance + __table_args__ = ( + Index("idx_audit_user_id", "user_id"), + Index("idx_audit_resource_type", "resource_type"), + Index("idx_audit_api_name", "api_name"), + Index("idx_audit_http_method", "http_method"), + Index("idx_audit_status_code", "status_code"), + Index("idx_audit_gmt_created", "gmt_created"), + Index("idx_audit_resource_id", "resource_id"), + Index("idx_audit_request_id", "request_id"), + Index("idx_audit_start_time", "start_time"), + ) + + def __repr__(self): + return f"" diff --git a/aperag/exceptions.py b/aperag/exceptions.py index e460e9dc8..bf9fa09a4 100644 --- a/aperag/exceptions.py +++ b/aperag/exceptions.py @@ -12,7 +12,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -import traceback from enum import Enum from http import HTTPStatus from typing import Any, Dict, Optional @@ -90,7 +89,6 @@ def __init__(self, error_code: ErrorCode, message: Optional[str] = None, details self.error_code = error_code self.message = message or error_code.error_name.replace("_", " ").title() self.details = details or {} - traceback.print_stack() super().__init__(self.message) @property diff --git a/aperag/migration/versions/20250621002836-2768dfee8bbc.py b/aperag/migration/versions/20250621002836-2768dfee8bbc.py new file mode 100644 index 000000000..6ca1ff9e7 --- /dev/null +++ b/aperag/migration/versions/20250621002836-2768dfee8bbc.py @@ -0,0 +1,70 @@ +"""empty message + +Revision ID: 2768dfee8bbc +Revises: 12ea6d2bf365 +Create Date: 2025-06-21 00:28:36.443046 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '2768dfee8bbc' +down_revision: Union[str, None] = '12ea6d2bf365' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('audit_log', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('user_id', sa.String(length=36), nullable=True, comment='User ID'), + sa.Column('username', sa.String(length=255), nullable=True, comment='Username'), + sa.Column('resource_type', sa.Enum('collection', 'document', 'bot', 'chat', 'message', 'api_key', 'llm_provider', 'llm_provider_model', 'model_service_provider', 'user', 'config', 'invitation', 'auth', 'chat_completion', 'search_test', 'llm', 'flow', 'system', name='auditresource'), nullable=True, comment='Resource type'), + sa.Column('resource_id', sa.String(length=255), nullable=True, comment='Resource ID (extracted at query time)'), + sa.Column('api_name', sa.String(length=255), nullable=False, comment='API operation name'), + sa.Column('http_method', sa.String(length=10), nullable=False, comment='HTTP method (POST, PUT, DELETE)'), + sa.Column('path', sa.String(length=512), nullable=False, comment='API path'), + sa.Column('status_code', sa.Integer(), nullable=True, comment='HTTP status code'), + sa.Column('request_data', sa.Text(), nullable=True, comment='Request data (JSON)'), + sa.Column('response_data', sa.Text(), nullable=True, comment='Response data (JSON)'), + sa.Column('error_message', sa.Text(), nullable=True, comment='Error message if failed'), + sa.Column('ip_address', sa.String(length=45), nullable=True, comment='Client IP address'), + sa.Column('user_agent', sa.String(length=500), nullable=True, comment='User agent string'), + sa.Column('request_id', sa.String(length=255), nullable=False, comment='Request ID for tracking'), + sa.Column('start_time', sa.BigInteger(), nullable=False, comment='Request start time (milliseconds since epoch)'), + sa.Column('end_time', sa.BigInteger(), nullable=True, comment='Request end time (milliseconds since epoch)'), + sa.Column('gmt_created', sa.DateTime(timezone=True), nullable=False, comment='Created time'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('idx_audit_api_name', 'audit_log', ['api_name'], unique=False) + op.create_index('idx_audit_gmt_created', 'audit_log', ['gmt_created'], unique=False) + op.create_index('idx_audit_http_method', 'audit_log', ['http_method'], unique=False) + op.create_index('idx_audit_request_id', 'audit_log', ['request_id'], unique=False) + op.create_index('idx_audit_resource_id', 'audit_log', ['resource_id'], unique=False) + op.create_index('idx_audit_resource_type', 'audit_log', ['resource_type'], unique=False) + op.create_index('idx_audit_start_time', 'audit_log', ['start_time'], unique=False) + op.create_index('idx_audit_status_code', 'audit_log', ['status_code'], unique=False) + op.create_index('idx_audit_user_id', 'audit_log', ['user_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index('idx_audit_user_id', table_name='audit_log') + op.drop_index('idx_audit_status_code', table_name='audit_log') + op.drop_index('idx_audit_start_time', table_name='audit_log') + op.drop_index('idx_audit_resource_type', table_name='audit_log') + op.drop_index('idx_audit_resource_id', table_name='audit_log') + op.drop_index('idx_audit_request_id', table_name='audit_log') + op.drop_index('idx_audit_http_method', table_name='audit_log') + op.drop_index('idx_audit_gmt_created', table_name='audit_log') + op.drop_index('idx_audit_api_name', table_name='audit_log') + op.drop_table('audit_log') + # ### end Alembic commands ### diff --git a/aperag/schema/view_models.py b/aperag/schema/view_models.py index 97de5a975..6d2b1e10f 100644 --- a/aperag/schema/view_models.py +++ b/aperag/schema/view_models.py @@ -1,20 +1,6 @@ -# Copyright 2025 ApeCloud, Inc. -# -# 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. - # generated by datamodel-codegen: # filename: openapi.merged.yaml -# timestamp: 2025-06-16T12:13:33+00:00 +# timestamp: 2025-06-20T16:37:07+00:00 from __future__ import annotations @@ -1063,6 +1049,67 @@ class PromptTemplateList(BaseModel): pageResult: Optional[PageResult] = None +class AuditLog(BaseModel): + """ + Audit log entry + """ + + id: Optional[str] = Field(None, description='Audit log ID') + user_id: Optional[str] = Field(None, description='User ID who performed the action') + username: Optional[str] = Field(None, description='Username for display') + resource_type: Optional[ + Literal[ + 'collection', + 'document', + 'bot', + 'chat', + 'message', + 'api_key', + 'llm_provider', + 'llm_provider_model', + 'model_service_provider', + 'user', + 'flow', + 'search_test', + ] + ] = Field(None, description='Type of resource') + resource_id: Optional[str] = Field( + None, description='ID of the resource (extracted at query time)' + ) + api_name: Optional[str] = Field(None, description='API operation name') + http_method: Optional[str] = Field( + None, description='HTTP method (POST, PUT, DELETE)' + ) + path: Optional[str] = Field(None, description='API path') + status_code: Optional[int] = Field(None, description='HTTP status code') + start_time: Optional[int] = Field( + None, description='Request start time (milliseconds since epoch)' + ) + end_time: Optional[int] = Field( + None, description='Request end time (milliseconds since epoch)' + ) + duration_ms: Optional[int] = Field( + None, description='Request duration in milliseconds (calculated)' + ) + request_data: Optional[str] = Field(None, description='Request data (JSON string)') + response_data: Optional[str] = Field( + None, description='Response data (JSON string)' + ) + error_message: Optional[str] = Field(None, description='Error message if failed') + ip_address: Optional[str] = Field(None, description='Client IP address') + user_agent: Optional[str] = Field(None, description='User agent string') + request_id: Optional[str] = Field(None, description='Request ID for tracking') + created: Optional[datetime] = Field(None, description='Created timestamp') + + +class AuditLogList(BaseModel): + """ + List of audit logs + """ + + items: Optional[list[AuditLog]] = Field(None, description='Audit log entries') + + class InvitationCreate(BaseModel): username: Optional[str] = Field(None, description='The username of the user') email: Optional[str] = Field(None, description='The email of the user') diff --git a/aperag/service/audit_service.py b/aperag/service/audit_service.py new file mode 100644 index 000000000..6fa1a1e8b --- /dev/null +++ b/aperag/service/audit_service.py @@ -0,0 +1,239 @@ +# Copyright 2025 ApeCloud, Inc. +# +# 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. + +import json +import logging +import re +import uuid +from datetime import datetime +from typing import Any, Dict, List, Optional + +from sqlalchemy import and_, desc, select + +from aperag.config import get_async_session +from aperag.db.models import AuditLog, AuditResource + +logger = logging.getLogger(__name__) + + +class AuditService: + """Service for handling audit logs""" + + def __init__(self): + self.enabled = True + # Sensitive fields that should be filtered from logs + self.sensitive_fields = { + "password", + "token", + "api_key", + "secret", + "authorization", + "access_token", + "refresh_token", + "private_key", + "credential", + } + + def _filter_sensitive_data(self, data: Dict[str, Any]) -> Dict[str, Any]: + """Filter sensitive information from data""" + if not isinstance(data, dict): + return data + + filtered = {} + for key, value in data.items(): + lower_key = key.lower() + if any(sensitive in lower_key for sensitive in self.sensitive_fields): + filtered[key] = "***FILTERED***" + elif isinstance(value, dict): + filtered[key] = self._filter_sensitive_data(value) + elif isinstance(value, list): + filtered[key] = [ + self._filter_sensitive_data(item) if isinstance(item, dict) else item for item in value + ] + else: + filtered[key] = value + return filtered + + def _safe_json_serialize(self, data: Any) -> str: + """Safely serialize data to JSON string""" + if data is None: + return None + + try: + # Filter sensitive data first + if isinstance(data, dict): + data = self._filter_sensitive_data(data) + + # Handle special types that aren't JSON serializable + def json_serializer(obj): + if hasattr(obj, "dict"): # Pydantic models + return obj.dict() + elif hasattr(obj, "__dict__"): # Regular objects + return obj.__dict__ + else: + return str(obj) + + return json.dumps(data, default=json_serializer, ensure_ascii=False) + except Exception as e: + logger.warning(f"Failed to serialize data: {e}") + return str(data) + + def extract_resource_id_from_path(self, path: str, resource_type: AuditResource) -> Optional[str]: + """Extract resource ID from path - called during query time""" + try: + # Define ID extraction patterns for different resource types + id_patterns = { + AuditResource.MESSAGE: r"/messages/([^/]+)", + AuditResource.CHAT: r"/chats/([^/]+)", + AuditResource.DOCUMENT: r"/documents/([^/]+)", + AuditResource.BOT: r"/bots/([^/]+)", + AuditResource.COLLECTION: r"/collections/([^/]+)", + AuditResource.API_KEY: r"/apikeys/([^/]+)", + AuditResource.LLM_PROVIDER: r"/llm_providers/([^/]+)", + AuditResource.LLM_PROVIDER_MODEL: r"/models/([^/]+/[^/]+)", + AuditResource.USER: r"/users/([^/]+)", + } + + pattern = id_patterns.get(resource_type) + if pattern: + match = re.search(pattern, path) + if match: + return match.group(1) + + except Exception as e: + logger.warning(f"Failed to extract resource ID: {e}") + + return None + + async def log_audit( + self, + user_id: Optional[str], + username: Optional[str], + resource_type: AuditResource, + api_name: str, + http_method: str, + path: str, + status_code: int, + start_time: int, + end_time: Optional[int] = None, + request_data: Optional[Dict[str, Any]] = None, + response_data: Optional[Dict[str, Any]] = None, + error_message: Optional[str] = None, + ip_address: Optional[str] = None, + user_agent: Optional[str] = None, + request_id: Optional[str] = None, + ): + """Log an audit entry""" + if not self.enabled: + return + + try: + # Create audit log entry + audit_log = AuditLog( + id=str(uuid.uuid4()), + user_id=user_id, + username=username, + resource_type=resource_type, + api_name=api_name, + http_method=http_method, + path=path, + status_code=status_code, + start_time=start_time, + end_time=end_time, + request_data=self._safe_json_serialize(request_data), + response_data=self._safe_json_serialize(response_data), + error_message=error_message, + ip_address=ip_address, + user_agent=user_agent, + request_id=request_id or str(uuid.uuid4()), + ) + + # Save to database asynchronously + async for session in get_async_session(): + session.add(audit_log) + await session.commit() + + except Exception as e: + logger.error(f"Failed to log audit: {e}") + + async def list_audit_logs( + self, + user_id: Optional[str] = None, + resource_type: Optional[AuditResource] = None, + api_name: Optional[str] = None, + http_method: Optional[str] = None, + status_code: Optional[int] = None, + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None, + limit: int = 1000, + ) -> List[AuditLog]: + """List audit logs with filtering""" + async for session in get_async_session(): + # Build query + stmt = select(AuditLog) + + # Add filters + conditions = [] + if user_id: + conditions.append(AuditLog.user_id == user_id) + if resource_type: + conditions.append(AuditLog.resource_type == resource_type) + if api_name: + conditions.append(AuditLog.api_name.like(f"%{api_name}%")) + if http_method: + conditions.append(AuditLog.http_method == http_method) + if status_code: + conditions.append(AuditLog.status_code == status_code) + if start_date: + conditions.append(AuditLog.gmt_created >= start_date) + if end_date: + conditions.append(AuditLog.gmt_created <= end_date) + + if conditions: + stmt = stmt.where(and_(*conditions)) + + # Order by creation time (newest first) and limit + stmt = stmt.order_by(desc(AuditLog.gmt_created)).limit(limit) + + # Execute query + result = await session.execute(stmt) + audit_logs = result.scalars().all() + + # Extract resource_id for each log during query time + for log in audit_logs: + if log.resource_type and log.path: + # Convert string to enum if needed + resource_type_enum = log.resource_type + if isinstance(log.resource_type, str): + try: + resource_type_enum = AuditResource(log.resource_type) + except ValueError: + resource_type_enum = None + + if resource_type_enum: + log.resource_id = self.extract_resource_id_from_path(log.path, resource_type_enum) + else: + log.resource_id = None + + # Calculate duration if both times are available + if log.start_time and log.end_time: + log.duration_ms = log.end_time - log.start_time + else: + log.duration_ms = None + + return audit_logs + + +# Global audit service instance +audit_service = AuditService() diff --git a/aperag/utils/audit_decorator.py b/aperag/utils/audit_decorator.py new file mode 100644 index 000000000..a38c5e71d --- /dev/null +++ b/aperag/utils/audit_decorator.py @@ -0,0 +1,321 @@ +# Copyright 2025 ApeCloud, Inc. +# +# 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. + +import functools +import logging +import time +from typing import Any, Dict, Optional + +from fastapi import Request + +from aperag.service.audit_service import audit_service + +logger = logging.getLogger(__name__) + + +def _extract_response_data(response: Any) -> Optional[Dict[str, Any]]: + """Extract response data from the returned response object""" + try: + # If response is already a dict (common for JSON APIs) + if isinstance(response, dict): + return response + + # If response has a dict() method (Pydantic models) + elif hasattr(response, "dict"): + return response.dict() + + # If response has a model_dump() method (Pydantic v2) + elif hasattr(response, "model_dump"): + return response.model_dump() + + # If response is a list of dicts or models + elif isinstance(response, list): + result = [] + for item in response: + if isinstance(item, dict): + result.append(item) + elif hasattr(item, "dict"): + result.append(item.dict()) + elif hasattr(item, "model_dump"): + result.append(item.model_dump()) + else: + result.append(str(item)) + return {"items": result} + + # For other types, try to convert to string + else: + return {"response": str(response)} + + except Exception as e: + logger.debug(f"Failed to extract response data: {e}") + return {"status": "success", "type": type(response).__name__} + + +def _clean_data_for_audit(data): + """Clean data for audit logging - remove null values and sensitive information""" + if data is None: + return None + + if isinstance(data, dict): + cleaned = {} + for key, value in data.items(): + # Skip null/None values + if value is None: + continue + + # Filter out sensitive fields + key_lower = key.lower() + if any(sensitive in key_lower for sensitive in ["password", "secret", "token", "key"]): + cleaned[key] = "***FILTERED***" + else: + # Recursively clean nested data + cleaned_value = _clean_data_for_audit(value) + if cleaned_value is not None or isinstance(cleaned_value, (bool, int, float, str)): + # Keep non-null values and primitive types (including False, 0, empty string) + if not (isinstance(cleaned_value, dict) and len(cleaned_value) == 0): + # Don't add empty dicts + cleaned[key] = cleaned_value + + return cleaned if cleaned else None + + elif isinstance(data, list): + cleaned = [] + for item in data: + cleaned_item = _clean_data_for_audit(item) + if cleaned_item is not None: + cleaned.append(cleaned_item) + return cleaned if cleaned else None + + else: + # For primitive types, return as-is + return data + + +def _extract_request_data_from_args(request: Request, kwargs: dict) -> Optional[Dict[str, Any]]: + """Extract request data from function arguments (after FastAPI parsing)""" + try: + # Extract parsed data from function arguments + # FastAPI injects parsed JSON data as function parameters + parsed_data = {} + for key, value in kwargs.items(): + # Skip the request object itself + if isinstance(value, Request): + continue + + # Skip User objects and other database model objects + if hasattr(value, "__tablename__"): # SQLAlchemy model + continue + + # Try to serialize the value + try: + if hasattr(value, "dict"): # Pydantic model + serialized = value.dict() + # Clean up the serialized data - remove null values and filter sensitive data + cleaned_data = _clean_data_for_audit(serialized) + if cleaned_data: # Only add if there's actual data + parsed_data[key] = cleaned_data + elif hasattr(value, "model_dump"): # Pydantic v2 + serialized = value.model_dump() + # Clean up the serialized data + cleaned_data = _clean_data_for_audit(serialized) + if cleaned_data: # Only add if there's actual data + parsed_data[key] = cleaned_data + elif isinstance(value, (dict, list, str, int, float, bool)): + # For basic types, also clean the data + cleaned_data = _clean_data_for_audit(value) + if cleaned_data is not None: # Allow False, 0, empty string but not None + parsed_data[key] = cleaned_data + else: + # For other types, convert to string but skip if it looks like an object + str_value = str(value) + if " object at 0x" not in str_value: # Skip object representations + parsed_data[key] = str_value + except Exception: + # Skip problematic values + continue + + # Return the actual data directly, not wrapped in any structure + # If there's only one main data object, return it directly + if len(parsed_data) == 1: + return list(parsed_data.values())[0] + elif len(parsed_data) > 1: + return parsed_data + else: + return None + + except Exception as e: + logger.warning(f"Failed to extract request data from args: {e}") + return None + + +def _extract_client_info(request) -> tuple[Optional[str], Optional[str]]: + """Extract client IP and User-Agent from request""" + try: + # Get IP address + ip_address = None + if hasattr(request, "client") and request.client: + ip_address = request.client.host + + # Check for forwarded headers + if hasattr(request, "headers"): + forwarded_for = request.headers.get("X-Forwarded-For") + if forwarded_for: + ip_address = forwarded_for.split(",")[0].strip() + elif request.headers.get("X-Real-IP"): + ip_address = request.headers.get("X-Real-IP") + + # Get User-Agent + user_agent = None + if hasattr(request, "headers"): + user_agent = request.headers.get("User-Agent") + + return ip_address, user_agent + except Exception as e: + logger.warning(f"Failed to extract client info: {e}") + return None, None + + +async def _log_audit_async( + request: Request, + resource_type: str, + api_name: str, + start_time_ms: int, + end_time_ms: int, + status_code: int, + request_data: dict, + response_data: dict, + error_message: str = None, +): + """Log audit information asynchronously""" + try: + # Get user info from request state + user_id = getattr(request.state, "user_id", None) + username = getattr(request.state, "username", None) + + # Extract client info + ip_address, user_agent = _extract_client_info(request) + + # Log audit in background + import asyncio + + asyncio.create_task( + audit_service.log_audit( + user_id=user_id, + username=username, + resource_type=resource_type, + api_name=api_name, + http_method=request.method, + path=request.url.path, + status_code=status_code, + start_time=start_time_ms, + end_time=end_time_ms, + request_data=request_data, + response_data=response_data, + error_message=error_message, + ip_address=ip_address, + user_agent=user_agent, + ) + ) + except Exception as audit_error: + logger.error(f"Failed to log audit: {audit_error}") + + +def audit(resource_type: str, api_name: str = None): + """ + Decorator for API endpoints to enable automatic audit logging + + Args: + resource_type: The resource type for audit (e.g., 'collection', 'user', etc.) + api_name: Optional API name override (defaults to function name) + """ + + def decorator(func): + @functools.wraps(func) + async def wrapper(*args, **kwargs): + # Find the request object in the arguments + request = None + for v in kwargs.values(): + if isinstance(v, Request): + request = v + break + + if not request: + # If no request found, just call the original function + return await func(*args, **kwargs) + + # Skip GET requests - only audit change operations + if request.method.upper() == "GET": + return await func(*args, **kwargs) + + # Record start time + start_time_ms = int(time.time() * 1000) + actual_api_name = api_name or func.__name__ + + try: + # Call the original function first to get the parsed data + response = await func(*args, **kwargs) + + # Record end time + end_time_ms = int(time.time() * 1000) + + # Extract request data from function arguments (after parsing) + request_data = _extract_request_data_from_args(request, kwargs) + + # Extract response data + response_data = _extract_response_data(response) + + # Log audit asynchronously + await _log_audit_async( + request=request, + resource_type=resource_type, + api_name=actual_api_name, + start_time_ms=start_time_ms, + end_time_ms=end_time_ms, + status_code=200, # Success + request_data=request_data, + response_data=response_data, + error_message=None, + ) + + return response + + except Exception as e: + # Record end time for error case + end_time_ms = int(time.time() * 1000) + + # Extract request data if possible + try: + request_data = _extract_request_data_from_args(request, kwargs) + except Exception: + request_data = {"method": request.method, "path": request.url.path} + + # Log audit for error case + await _log_audit_async( + request=request, + resource_type=resource_type, + api_name=actual_api_name, + start_time_ms=start_time_ms, + end_time_ms=end_time_ms, + status_code=500, # Error + request_data=request_data, + response_data={"error": str(e)}, + error_message=str(e), + ) + + # Re-raise the exception + raise + + return wrapper + + return decorator diff --git a/aperag/views/api_key.py b/aperag/views/api_key.py index 6fc2fed55..462dc796c 100644 --- a/aperag/views/api_key.py +++ b/aperag/views/api_key.py @@ -18,6 +18,7 @@ from aperag.db.models import User from aperag.schema.view_models import ApiKeyCreate, ApiKeyList, ApiKeyUpdate from aperag.service.api_key_service import api_key_service +from aperag.utils.audit_decorator import audit from aperag.views.auth import current_user router = APIRouter() @@ -30,6 +31,7 @@ async def list_api_keys_view(request: Request, user: User = Depends(current_user @router.post("/apikeys") +@audit(resource_type="api_key", api_name="CreateApiKey") async def create_api_key_view( request: Request, api_key_create: ApiKeyCreate, @@ -40,12 +42,14 @@ async def create_api_key_view( @router.delete("/apikeys/{apikey_id}") +@audit(resource_type="api_key", api_name="DeleteApiKey") async def delete_api_key_view(request: Request, apikey_id: str, user: User = Depends(current_user)): """Delete an API key""" return await api_key_service.delete_api_key(str(user.id), apikey_id) @router.put("/apikeys/{apikey_id}") +@audit(resource_type="api_key", api_name="UpdateApiKey") async def update_api_key_view( request: Request, apikey_id: str, diff --git a/aperag/views/audit.py b/aperag/views/audit.py new file mode 100644 index 000000000..e4b0ce473 --- /dev/null +++ b/aperag/views/audit.py @@ -0,0 +1,150 @@ +# Copyright 2025 ApeCloud, Inc. +# +# 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 datetime import datetime +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import select + +from aperag.config import get_async_session +from aperag.db.models import AuditLog, AuditResource, User +from aperag.schema import view_models +from aperag.service.audit_service import audit_service +from aperag.views.auth import current_user + +router = APIRouter() + + +@router.get("/audit-logs") +async def list_audit_logs( + user_id: Optional[str] = Query(None, description="Filter by user ID"), + username: Optional[str] = Query(None, description="Filter by username"), + resource_type: Optional[str] = Query(None, description="Filter by resource type"), + resource_id: Optional[str] = Query(None, description="Filter by resource ID"), + api_name: Optional[str] = Query(None, description="Filter by API name"), + http_method: Optional[str] = Query(None, description="Filter by HTTP method"), + status_code: Optional[int] = Query(None, description="Filter by status code"), + start_date: Optional[datetime] = Query(None, description="Filter by start date"), + end_date: Optional[datetime] = Query(None, description="Filter by end date"), + limit: int = Query(1000, le=5000, description="Maximum number of records"), + user: User = Depends(current_user), +): + """List audit logs with filtering""" + + # Convert string enums to actual enum values + audit_resource = None + + if resource_type: + try: + audit_resource = AuditResource(resource_type) + except ValueError: + raise HTTPException(status_code=400, detail=f"Invalid resource_type: {resource_type}") + + # Get audit logs + audit_logs = await audit_service.list_audit_logs( + user_id=user_id, + resource_type=audit_resource, + api_name=api_name, + http_method=http_method, + status_code=status_code, + start_date=start_date, + end_date=end_date, + limit=limit, + ) + + # Convert to view models + items = [] + for log in audit_logs: + items.append( + view_models.AuditLog( + id=str(log.id), + user_id=log.user_id, + username=log.username, + resource_type=log.resource_type.value if hasattr(log.resource_type, "value") else log.resource_type, + resource_id=getattr(log, "resource_id", None), # This is set during query + api_name=log.api_name, + http_method=log.http_method, + path=log.path, + status_code=log.status_code, + start_time=log.start_time, + end_time=log.end_time, + duration_ms=getattr(log, "duration_ms", None), # Calculated during query + request_data=log.request_data, + response_data=log.response_data, + error_message=log.error_message, + ip_address=log.ip_address, + user_agent=log.user_agent, + request_id=log.request_id, + created=log.gmt_created, + ) + ) + + return view_models.AuditLogList(items=items) + + +@router.get("/audit-logs/{audit_id}") +async def get_audit_log(audit_id: str, user: User = Depends(current_user)): + """Get a specific audit log by ID""" + + async for session in get_async_session(): + stmt = select(AuditLog).where(AuditLog.id == audit_id) + result = await session.execute(stmt) + audit_log = result.scalar_one_or_none() + + if not audit_log: + raise HTTPException(status_code=404, detail="Audit log not found") + + # Extract resource_id for this specific log + resource_id = None + if audit_log.resource_type and audit_log.path: + # Convert string to enum if needed + resource_type_enum = audit_log.resource_type + if isinstance(audit_log.resource_type, str): + try: + resource_type_enum = AuditResource(audit_log.resource_type) + except ValueError: + resource_type_enum = None + + if resource_type_enum: + resource_id = audit_service.extract_resource_id_from_path(audit_log.path, resource_type_enum) + + # Calculate duration if both times are available + duration_ms = None + if audit_log.start_time and audit_log.end_time: + duration_ms = audit_log.end_time - audit_log.start_time + + return view_models.AuditLog( + id=str(audit_log.id), + user_id=audit_log.user_id, + username=audit_log.username, + resource_type=audit_log.resource_type.value + if hasattr(audit_log.resource_type, "value") + else audit_log.resource_type, + resource_id=resource_id, + api_name=audit_log.api_name, + http_method=audit_log.http_method, + path=audit_log.path, + status_code=audit_log.status_code, + start_time=audit_log.start_time, + end_time=audit_log.end_time, + duration_ms=duration_ms, + request_data=audit_log.request_data, + response_data=audit_log.response_data, + error_message=audit_log.error_message, + ip_address=audit_log.ip_address, + user_agent=audit_log.user_agent, + request_id=audit_log.request_id, + created=audit_log.gmt_created, + ) diff --git a/aperag/views/auth.py b/aperag/views/auth.py index e7cbd8e9f..6338aff54 100644 --- a/aperag/views/auth.py +++ b/aperag/views/auth.py @@ -26,6 +26,7 @@ from aperag.db.models import ApiKey, ApiKeyStatus, Invitation, Role, User from aperag.db.ops import async_db_ops from aperag.schema import view_models +from aperag.utils.audit_decorator import audit from aperag.utils.utils import utc_now logger = logging.getLogger(__name__) @@ -207,11 +208,13 @@ async def current_user( api_user = await authenticate_api_key(request, session) if api_user: request.state.user_id = api_user.id + request.state.username = api_user.username return api_user # Then try JWT/Cookie authentication if user: request.state.user_id = user.id + request.state.username = user.username return user raise HTTPException(status_code=401, detail="Unauthorized") @@ -239,8 +242,12 @@ async def get_current_admin(session: AsyncSessionDep, user: User = Depends(get_c @router.post("/invite") +@audit(resource_type="invitation", api_name="CreateInvitation") async def create_invitation_view( - data: view_models.InvitationCreate, session: AsyncSessionDep, user: User = Depends(get_current_admin) + request: Request, + data: view_models.InvitationCreate, + session: AsyncSessionDep, + user: User = Depends(get_current_admin), ) -> view_models.Invitation: # Check if user already exists from sqlalchemy import select @@ -297,8 +304,12 @@ async def list_invitations_view( @router.post("/register") +@audit(resource_type="user", api_name="RegisterUser") async def register_view( - data: view_models.Register, session: AsyncSessionDep, user_manager: UserManager = Depends(get_user_manager) + request: Request, + data: view_models.Register, + session: AsyncSessionDep, + user_manager: UserManager = Depends(get_user_manager), ) -> view_models.User: from sqlalchemy import select @@ -438,7 +449,9 @@ async def list_users_view(session: AsyncSessionDep, user: User = Depends(get_cur @router.post("/change-password") +@audit(resource_type="user", api_name="ChangePassword") async def change_password_view( + request: Request, data: view_models.ChangePassword, session: AsyncSessionDep, user_manager: UserManager = Depends(get_user_manager), @@ -468,7 +481,10 @@ async def change_password_view( @router.delete("/users/{user_id}") -async def delete_user_view(user_id: str, session: AsyncSessionDep, user: User = Depends(get_current_admin)): +@audit(resource_type="user", api_name="DeleteUser") +async def delete_user_view( + request: Request, user_id: str, session: AsyncSessionDep, user: User = Depends(get_current_admin) +): from sqlalchemy import select result = await session.execute(select(User).where(User.id == user_id)) diff --git a/aperag/views/flow.py b/aperag/views/flow.py index 88650a80a..74dad4751 100644 --- a/aperag/views/flow.py +++ b/aperag/views/flow.py @@ -19,6 +19,7 @@ from aperag.db.models import User from aperag.schema.view_models import WorkflowDefinition from aperag.service.flow_service import flow_service_global +from aperag.utils.audit_decorator import audit from aperag.views.auth import current_user router = APIRouter() @@ -32,6 +33,7 @@ async def get_flow_view( @router.put("/bots/{bot_id}/flow") +@audit(resource_type="flow", api_name="UpdateFlow") async def update_flow_view( request: Request, bot_id: str, diff --git a/aperag/views/llm.py b/aperag/views/llm.py index 4611776ec..0e05ebb31 100644 --- a/aperag/views/llm.py +++ b/aperag/views/llm.py @@ -14,7 +14,7 @@ import logging -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request from aperag.db.models import User from aperag.db.ops import async_db_ops @@ -40,6 +40,7 @@ RerankResponse, RerankUsage, ) +from aperag.utils.audit_decorator import audit from aperag.views.auth import current_user logger = logging.getLogger(__name__) @@ -48,7 +49,8 @@ @router.post("/embeddings", response_model=EmbeddingResponse) -async def create_embeddings(request: EmbeddingRequest, user: User = Depends(current_user)): +@audit(resource_type="llm", api_name="CreateEmbeddings") +async def create_embeddings(http_request: Request, request: EmbeddingRequest, user: User = Depends(current_user)): """ Create embeddings for the given input text(s). Compatible with OpenAI embeddings API format. @@ -157,7 +159,8 @@ async def _get_provider_info(provider: str, model: str, user_id: str, api_type: @router.post("/rerank", response_model=RerankResponse) -async def create_rerank(request: RerankRequest, user: User = Depends(current_user)): +@audit(resource_type="llm", api_name="CreateRerank") +async def create_rerank(http_request: Request, request: RerankRequest, user: User = Depends(current_user)): """ Rerank documents based on relevance to a query. Compatible with industry-standard rerank API format used by Cohere, Jina AI, etc. diff --git a/aperag/views/main.py b/aperag/views/main.py index c6f213570..716f776d7 100644 --- a/aperag/views/main.py +++ b/aperag/views/main.py @@ -37,6 +37,7 @@ update_llm_provider_model, ) from aperag.service.prompt_template_service import list_prompt_templates +from aperag.utils.audit_decorator import audit # Import authentication dependencies from aperag.views.auth import UserManager, authenticate_websocket_user, current_user, get_user_manager @@ -55,6 +56,7 @@ async def list_prompt_templates_view( @router.post("/collections") +@audit(resource_type="collection", api_name="CreateCollection") async def create_collection_view( request: Request, collection: view_models.CollectionCreate, @@ -76,6 +78,7 @@ async def get_collection_view( @router.put("/collections/{collection_id}") +@audit(resource_type="collection", api_name="UpdateCollection") async def update_collection_view( request: Request, collection_id: str, @@ -86,6 +89,7 @@ async def update_collection_view( @router.delete("/collections/{collection_id}") +@audit(resource_type="collection", api_name="DeleteCollection") async def delete_collection_view( request: Request, collection_id: str, user: User = Depends(current_user) ) -> view_models.Collection: @@ -93,6 +97,7 @@ async def delete_collection_view( @router.post("/collections/{collection_id}/documents") +@audit(resource_type="document", api_name="CreateDocuments") async def create_documents_view( request: Request, collection_id: str, @@ -120,6 +125,7 @@ async def get_document_view( @router.put("/collections/{collection_id}/documents/{document_id}") +@audit(resource_type="document", api_name="UpdateDocument") async def update_document_view( request: Request, collection_id: str, @@ -131,6 +137,7 @@ async def update_document_view( @router.delete("/collections/{collection_id}/documents/{document_id}") +@audit(resource_type="document", api_name="DeleteDocument") async def delete_document_view( request: Request, collection_id: str, @@ -141,6 +148,7 @@ async def delete_document_view( @router.delete("/collections/{collection_id}/documents") +@audit(resource_type="document", api_name="DeleteDocuments") async def delete_documents_view( request: Request, collection_id: str, @@ -151,6 +159,7 @@ async def delete_documents_view( @router.post("/bots/{bot_id}/chats") +@audit(resource_type="chat", api_name="CreateChat") async def create_chat_view(request: Request, bot_id: str, user: User = Depends(current_user)) -> view_models.Chat: return await chat_service_global.create_chat(str(user.id), bot_id) @@ -168,6 +177,7 @@ async def get_chat_view( @router.put("/bots/{bot_id}/chats/{chat_id}") +@audit(resource_type="chat", api_name="UpdateChat") async def update_chat_view( request: Request, bot_id: str, @@ -179,6 +189,7 @@ async def update_chat_view( @router.post("/bots/{bot_id}/chats/{chat_id}/messages/{message_id}") +@audit(resource_type="message", api_name="FeedbackMessage") async def feedback_message_view( request: Request, bot_id: str, @@ -193,12 +204,14 @@ async def feedback_message_view( @router.delete("/bots/{bot_id}/chats/{chat_id}") +@audit(resource_type="chat", api_name="DeleteChat") async def delete_chat_view(request: Request, bot_id: str, chat_id: str, user: User = Depends(current_user)): await chat_service_global.delete_chat(str(user.id), bot_id, chat_id) return Response(status_code=204) @router.post("/bots") +@audit(resource_type="bot", api_name="CreateBot") async def create_bot_view( request: Request, bot_in: view_models.BotCreate, @@ -218,6 +231,7 @@ async def get_bot_view(request: Request, bot_id: str, user: User = Depends(curre @router.put("/bots/{bot_id}") +@audit(resource_type="bot", api_name="UpdateBot") async def update_bot_view( request: Request, bot_id: str, @@ -228,6 +242,7 @@ async def update_bot_view( @router.delete("/bots/{bot_id}") +@audit(resource_type="bot", api_name="DeleteBot") async def delete_bot_view(request: Request, bot_id: str, user: User = Depends(current_user)): await bot_service.delete_bot(str(user.id), bot_id) return Response(status_code=204) @@ -260,6 +275,7 @@ async def frontend_chat_completions_view(request: Request, user: User = Depends( @router.post("/collections/{collection_id}/searchTests") +@audit(resource_type="search_test", api_name="CreateSearchTest") async def create_search_test_view( request: Request, collection_id: str, @@ -269,7 +285,10 @@ async def create_search_test_view( return await collection_service.create_search_test(str(user.id), collection_id, data) -@router.delete("/collections/{collection_id}/searchTests/{search_test_id}") +@router.delete( + "/collections/{collection_id}/searchTests/{search_test_id}", tags=["search_test"], name="DeleteSearchTest" +) +@audit(resource_type="search_test", api_name="DeleteSearchTest") async def delete_search_test_view( request: Request, collection_id: str, @@ -323,6 +342,7 @@ async def get_llm_configuration_view(request: Request, user: User = Depends(curr @router.post("/llm_providers") +@audit(resource_type="llm_provider", api_name="CreateLLMProvider") async def create_llm_provider_view( request: Request, provider_data: view_models.LlmProviderCreateWithApiKey, @@ -345,6 +365,7 @@ async def get_llm_provider_view(request: Request, provider_name: str, user: User @router.put("/llm_providers/{provider_name}") +@audit(resource_type="llm_provider", api_name="UpdateLLMProvider") async def update_llm_provider_view( request: Request, provider_name: str, @@ -359,6 +380,7 @@ async def update_llm_provider_view( @router.delete("/llm_providers/{provider_name}") +@audit(resource_type="llm_provider", api_name="DeleteLLMProvider") async def delete_llm_provider_view(request: Request, provider_name: str, user: User = Depends(current_user)): """Delete an LLM provider""" from aperag.db.models import Role @@ -388,6 +410,7 @@ async def get_provider_models_view(request: Request, provider_name: str, user: U @router.post("/llm_providers/{provider_name}/models") +@audit(resource_type="llm_provider_model", api_name="CreateProviderModel") async def create_provider_model_view(request: Request, provider_name: str, user: User = Depends(current_user)): """Create a new model for a specific provider""" import json @@ -401,6 +424,7 @@ async def create_provider_model_view(request: Request, provider_name: str, user: @router.put("/llm_providers/{provider_name}/models/{api}/{model}") +@audit(resource_type="llm_provider_model", api_name="UpdateProviderModel") async def update_provider_model_view( request: Request, provider_name: str, api: str, model: str, user: User = Depends(current_user) ): @@ -416,6 +440,7 @@ async def update_provider_model_view( @router.delete("/llm_providers/{provider_name}/models/{api}/{model}") +@audit(resource_type="llm_provider_model", api_name="DeleteProviderModel") async def delete_provider_model_view( request: Request, provider_name: str, api: str, model: str, user: User = Depends(current_user) ): diff --git a/frontend/src/api/api.ts b/frontend/src/api/api.ts index 01311bd26..646e009f2 100644 --- a/frontend/src/api/api.ts +++ b/frontend/src/api/api.ts @@ -14,6 +14,7 @@ +export * from './apis/audit-api'; export * from './apis/default-api'; export * from './apis/llmapi'; diff --git a/frontend/src/api/apis/audit-api.ts b/frontend/src/api/apis/audit-api.ts new file mode 100644 index 000000000..4c1c42a4d --- /dev/null +++ b/frontend/src/api/apis/audit-api.ts @@ -0,0 +1,406 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * ApeRAG API + * ApeRAG API Documentation + * + * The version of the OpenAPI document: 1.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +import type { Configuration } from '../configuration'; +import type { AxiosPromise, AxiosInstance, RawAxiosRequestConfig } from 'axios'; +import globalAxios from 'axios'; +// Some imports not used depending on template conditions +// @ts-ignore +import { DUMMY_BASE_URL, assertParamExists, setApiKeyToObject, setBasicAuthToObject, setBearerAuthToObject, setOAuthToObject, setSearchParams, serializeDataIfNeeded, toPathString, createRequestFunction } from '../common'; +// @ts-ignore +import { BASE_PATH, COLLECTION_FORMATS, type RequestArgs, BaseAPI, RequiredError, operationServerMap } from '../base'; +// @ts-ignore +import type { AuditLog } from '../models'; +// @ts-ignore +import type { AuditLogList } from '../models'; +/** + * AuditApi - axios parameter creator + * @export + */ +export const AuditApiAxiosParamCreator = function (configuration?: Configuration) { + return { + /** + * Get a specific audit log by ID + * @summary Get audit log detail + * @param {string} auditId Audit log ID + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getAuditLog: async (auditId: string, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'auditId' is not null or undefined + assertParamExists('getAuditLog', 'auditId', auditId) + const localVarPath = `/audit-logs/{audit_id}` + .replace(`{${"audit_id"}}`, encodeURIComponent(String(auditId))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * List audit logs with filtering options + * @summary List audit logs + * @param {string} [userId] Filter by user ID + * @param {string} [username] Filter by username + * @param {ListAuditLogsResourceTypeEnum} [resourceType] Filter by resource type + * @param {string} [resourceId] Filter by resource ID + * @param {string} [apiName] Filter by API name + * @param {ListAuditLogsHttpMethodEnum} [httpMethod] Filter by HTTP method + * @param {number} [statusCode] Filter by status code + * @param {string} [startDate] Filter by start date + * @param {string} [endDate] Filter by end date + * @param {number} [limit] Maximum number of records + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + listAuditLogs: async (userId?: string, username?: string, resourceType?: ListAuditLogsResourceTypeEnum, resourceId?: string, apiName?: string, httpMethod?: ListAuditLogsHttpMethodEnum, statusCode?: number, startDate?: string, endDate?: string, limit?: number, options: RawAxiosRequestConfig = {}): Promise => { + const localVarPath = `/audit-logs`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + if (userId !== undefined) { + localVarQueryParameter['user_id'] = userId; + } + + if (username !== undefined) { + localVarQueryParameter['username'] = username; + } + + if (resourceType !== undefined) { + localVarQueryParameter['resource_type'] = resourceType; + } + + if (resourceId !== undefined) { + localVarQueryParameter['resource_id'] = resourceId; + } + + if (apiName !== undefined) { + localVarQueryParameter['api_name'] = apiName; + } + + if (httpMethod !== undefined) { + localVarQueryParameter['http_method'] = httpMethod; + } + + if (statusCode !== undefined) { + localVarQueryParameter['status_code'] = statusCode; + } + + if (startDate !== undefined) { + localVarQueryParameter['start_date'] = (startDate as any instanceof Date) ? + (startDate as any).toISOString() : + startDate; + } + + if (endDate !== undefined) { + localVarQueryParameter['end_date'] = (endDate as any instanceof Date) ? + (endDate as any).toISOString() : + endDate; + } + + if (limit !== undefined) { + localVarQueryParameter['limit'] = limit; + } + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + } +}; + +/** + * AuditApi - functional programming interface + * @export + */ +export const AuditApiFp = function(configuration?: Configuration) { + const localVarAxiosParamCreator = AuditApiAxiosParamCreator(configuration) + return { + /** + * Get a specific audit log by ID + * @summary Get audit log detail + * @param {string} auditId Audit log ID + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getAuditLog(auditId: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getAuditLog(auditId, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['AuditApi.getAuditLog']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, + /** + * List audit logs with filtering options + * @summary List audit logs + * @param {string} [userId] Filter by user ID + * @param {string} [username] Filter by username + * @param {ListAuditLogsResourceTypeEnum} [resourceType] Filter by resource type + * @param {string} [resourceId] Filter by resource ID + * @param {string} [apiName] Filter by API name + * @param {ListAuditLogsHttpMethodEnum} [httpMethod] Filter by HTTP method + * @param {number} [statusCode] Filter by status code + * @param {string} [startDate] Filter by start date + * @param {string} [endDate] Filter by end date + * @param {number} [limit] Maximum number of records + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async listAuditLogs(userId?: string, username?: string, resourceType?: ListAuditLogsResourceTypeEnum, resourceId?: string, apiName?: string, httpMethod?: ListAuditLogsHttpMethodEnum, statusCode?: number, startDate?: string, endDate?: string, limit?: number, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.listAuditLogs(userId, username, resourceType, resourceId, apiName, httpMethod, statusCode, startDate, endDate, limit, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['AuditApi.listAuditLogs']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, + } +}; + +/** + * AuditApi - factory interface + * @export + */ +export const AuditApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { + const localVarFp = AuditApiFp(configuration) + return { + /** + * Get a specific audit log by ID + * @summary Get audit log detail + * @param {AuditApiGetAuditLogRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getAuditLog(requestParameters: AuditApiGetAuditLogRequest, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.getAuditLog(requestParameters.auditId, options).then((request) => request(axios, basePath)); + }, + /** + * List audit logs with filtering options + * @summary List audit logs + * @param {AuditApiListAuditLogsRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + listAuditLogs(requestParameters: AuditApiListAuditLogsRequest = {}, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.listAuditLogs(requestParameters.userId, requestParameters.username, requestParameters.resourceType, requestParameters.resourceId, requestParameters.apiName, requestParameters.httpMethod, requestParameters.statusCode, requestParameters.startDate, requestParameters.endDate, requestParameters.limit, options).then((request) => request(axios, basePath)); + }, + }; +}; + +/** + * AuditApi - interface + * @export + * @interface AuditApi + */ +export interface AuditApiInterface { + /** + * Get a specific audit log by ID + * @summary Get audit log detail + * @param {AuditApiGetAuditLogRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof AuditApiInterface + */ + getAuditLog(requestParameters: AuditApiGetAuditLogRequest, options?: RawAxiosRequestConfig): AxiosPromise; + + /** + * List audit logs with filtering options + * @summary List audit logs + * @param {AuditApiListAuditLogsRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof AuditApiInterface + */ + listAuditLogs(requestParameters?: AuditApiListAuditLogsRequest, options?: RawAxiosRequestConfig): AxiosPromise; + +} + +/** + * Request parameters for getAuditLog operation in AuditApi. + * @export + * @interface AuditApiGetAuditLogRequest + */ +export interface AuditApiGetAuditLogRequest { + /** + * Audit log ID + * @type {string} + * @memberof AuditApiGetAuditLog + */ + readonly auditId: string +} + +/** + * Request parameters for listAuditLogs operation in AuditApi. + * @export + * @interface AuditApiListAuditLogsRequest + */ +export interface AuditApiListAuditLogsRequest { + /** + * Filter by user ID + * @type {string} + * @memberof AuditApiListAuditLogs + */ + readonly userId?: string + + /** + * Filter by username + * @type {string} + * @memberof AuditApiListAuditLogs + */ + readonly username?: string + + /** + * Filter by resource type + * @type {'collection' | 'document' | 'bot' | 'chat' | 'message' | 'api_key' | 'llm_provider' | 'llm_provider_model' | 'model_service_provider' | 'user' | 'config'} + * @memberof AuditApiListAuditLogs + */ + readonly resourceType?: ListAuditLogsResourceTypeEnum + + /** + * Filter by resource ID + * @type {string} + * @memberof AuditApiListAuditLogs + */ + readonly resourceId?: string + + /** + * Filter by API name + * @type {string} + * @memberof AuditApiListAuditLogs + */ + readonly apiName?: string + + /** + * Filter by HTTP method + * @type {'POST' | 'PUT' | 'DELETE'} + * @memberof AuditApiListAuditLogs + */ + readonly httpMethod?: ListAuditLogsHttpMethodEnum + + /** + * Filter by status code + * @type {number} + * @memberof AuditApiListAuditLogs + */ + readonly statusCode?: number + + /** + * Filter by start date + * @type {string} + * @memberof AuditApiListAuditLogs + */ + readonly startDate?: string + + /** + * Filter by end date + * @type {string} + * @memberof AuditApiListAuditLogs + */ + readonly endDate?: string + + /** + * Maximum number of records + * @type {number} + * @memberof AuditApiListAuditLogs + */ + readonly limit?: number +} + +/** + * AuditApi - object-oriented interface + * @export + * @class AuditApi + * @extends {BaseAPI} + */ +export class AuditApi extends BaseAPI implements AuditApiInterface { + /** + * Get a specific audit log by ID + * @summary Get audit log detail + * @param {AuditApiGetAuditLogRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof AuditApi + */ + public getAuditLog(requestParameters: AuditApiGetAuditLogRequest, options?: RawAxiosRequestConfig) { + return AuditApiFp(this.configuration).getAuditLog(requestParameters.auditId, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * List audit logs with filtering options + * @summary List audit logs + * @param {AuditApiListAuditLogsRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof AuditApi + */ + public listAuditLogs(requestParameters: AuditApiListAuditLogsRequest = {}, options?: RawAxiosRequestConfig) { + return AuditApiFp(this.configuration).listAuditLogs(requestParameters.userId, requestParameters.username, requestParameters.resourceType, requestParameters.resourceId, requestParameters.apiName, requestParameters.httpMethod, requestParameters.statusCode, requestParameters.startDate, requestParameters.endDate, requestParameters.limit, options).then((request) => request(this.axios, this.basePath)); + } +} + +/** + * @export + */ +export const ListAuditLogsResourceTypeEnum = { + collection: 'collection', + document: 'document', + bot: 'bot', + chat: 'chat', + message: 'message', + api_key: 'api_key', + llm_provider: 'llm_provider', + llm_provider_model: 'llm_provider_model', + model_service_provider: 'model_service_provider', + user: 'user', + config: 'config' +} as const; +export type ListAuditLogsResourceTypeEnum = typeof ListAuditLogsResourceTypeEnum[keyof typeof ListAuditLogsResourceTypeEnum]; +/** + * @export + */ +export const ListAuditLogsHttpMethodEnum = { + POST: 'POST', + PUT: 'PUT', + DELETE: 'DELETE' +} as const; +export type ListAuditLogsHttpMethodEnum = typeof ListAuditLogsHttpMethodEnum[keyof typeof ListAuditLogsHttpMethodEnum]; diff --git a/frontend/src/api/apis/default-api.ts b/frontend/src/api/apis/default-api.ts index 3644963da..5462a8334 100644 --- a/frontend/src/api/apis/default-api.ts +++ b/frontend/src/api/apis/default-api.ts @@ -310,7 +310,7 @@ export const DefaultApiAxiosParamCreator = function (configuration?: Configurati }; }, /** - * Delete a chat + * Delete a chat (idempotent operation) * @summary Delete a chat * @param {string} botId * @param {string} chatId @@ -582,7 +582,7 @@ export const DefaultApiAxiosParamCreator = function (configuration?: Configurati }; }, /** - * Delete a bot + * Delete a bot (idempotent operation) * @summary Delete a bot * @param {string} botId * @param {*} [options] Override http request option. @@ -2266,7 +2266,7 @@ export const DefaultApiFp = function(configuration?: Configuration) { return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, /** - * Delete a chat + * Delete a chat (idempotent operation) * @summary Delete a chat * @param {string} botId * @param {string} chatId @@ -2354,7 +2354,7 @@ export const DefaultApiFp = function(configuration?: Configuration) { return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, /** - * Delete a bot + * Delete a bot (idempotent operation) * @summary Delete a bot * @param {string} botId * @param {*} [options] Override http request option. @@ -2974,7 +2974,7 @@ export const DefaultApiFactory = function (configuration?: Configuration, basePa return localVarFp.availableModelsPost(requestParameters.tagFilterRequest, options).then((request) => request(axios, basePath)); }, /** - * Delete a chat + * Delete a chat (idempotent operation) * @summary Delete a chat * @param {DefaultApiBotsBotIdChatsChatIdDeleteRequest} requestParameters Request parameters. * @param {*} [options] Override http request option. @@ -3034,7 +3034,7 @@ export const DefaultApiFactory = function (configuration?: Configuration, basePa return localVarFp.botsBotIdChatsPost(requestParameters.botId, requestParameters.chatCreate, options).then((request) => request(axios, basePath)); }, /** - * Delete a bot + * Delete a bot (idempotent operation) * @summary Delete a bot * @param {DefaultApiBotsBotIdDeleteRequest} requestParameters Request parameters. * @param {*} [options] Override http request option. @@ -3505,7 +3505,7 @@ export interface DefaultApiInterface { availableModelsPost(requestParameters?: DefaultApiAvailableModelsPostRequest, options?: RawAxiosRequestConfig): AxiosPromise; /** - * Delete a chat + * Delete a chat (idempotent operation) * @summary Delete a chat * @param {DefaultApiBotsBotIdChatsChatIdDeleteRequest} requestParameters Request parameters. * @param {*} [options] Override http request option. @@ -3565,7 +3565,7 @@ export interface DefaultApiInterface { botsBotIdChatsPost(requestParameters: DefaultApiBotsBotIdChatsPostRequest, options?: RawAxiosRequestConfig): AxiosPromise; /** - * Delete a bot + * Delete a bot (idempotent operation) * @summary Delete a bot * @param {DefaultApiBotsBotIdDeleteRequest} requestParameters Request parameters. * @param {*} [options] Override http request option. @@ -4907,7 +4907,7 @@ export class DefaultApi extends BaseAPI implements DefaultApiInterface { } /** - * Delete a chat + * Delete a chat (idempotent operation) * @summary Delete a chat * @param {DefaultApiBotsBotIdChatsChatIdDeleteRequest} requestParameters Request parameters. * @param {*} [options] Override http request option. @@ -4979,7 +4979,7 @@ export class DefaultApi extends BaseAPI implements DefaultApiInterface { } /** - * Delete a bot + * Delete a bot (idempotent operation) * @summary Delete a bot * @param {DefaultApiBotsBotIdDeleteRequest} requestParameters Request parameters. * @param {*} [options] Override http request option. diff --git a/frontend/src/api/models/audit-log-list.ts b/frontend/src/api/models/audit-log-list.ts new file mode 100644 index 000000000..08f6c8645 --- /dev/null +++ b/frontend/src/api/models/audit-log-list.ts @@ -0,0 +1,33 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * ApeRAG API + * ApeRAG API Documentation + * + * The version of the OpenAPI document: 1.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import type { AuditLog } from './audit-log'; + +/** + * List of audit logs + * @export + * @interface AuditLogList + */ +export interface AuditLogList { + /** + * Audit log entries + * @type {Array} + * @memberof AuditLogList + */ + 'items'?: Array; +} + diff --git a/frontend/src/api/models/audit-log.ts b/frontend/src/api/models/audit-log.ts new file mode 100644 index 000000000..21673bf2c --- /dev/null +++ b/frontend/src/api/models/audit-log.ts @@ -0,0 +1,155 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * ApeRAG API + * ApeRAG API Documentation + * + * The version of the OpenAPI document: 1.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +/** + * Audit log entry + * @export + * @interface AuditLog + */ +export interface AuditLog { + /** + * Audit log ID + * @type {string} + * @memberof AuditLog + */ + 'id'?: string; + /** + * User ID who performed the action + * @type {string} + * @memberof AuditLog + */ + 'user_id'?: string | null; + /** + * Username for display + * @type {string} + * @memberof AuditLog + */ + 'username'?: string | null; + /** + * Type of resource + * @type {string} + * @memberof AuditLog + */ + 'resource_type'?: AuditLogResourceTypeEnum | null; + /** + * ID of the resource (extracted at query time) + * @type {string} + * @memberof AuditLog + */ + 'resource_id'?: string | null; + /** + * API operation name + * @type {string} + * @memberof AuditLog + */ + 'api_name'?: string; + /** + * HTTP method (POST, PUT, DELETE) + * @type {string} + * @memberof AuditLog + */ + 'http_method'?: string; + /** + * API path + * @type {string} + * @memberof AuditLog + */ + 'path'?: string; + /** + * HTTP status code + * @type {number} + * @memberof AuditLog + */ + 'status_code'?: number | null; + /** + * Request start time (milliseconds since epoch) + * @type {number} + * @memberof AuditLog + */ + 'start_time'?: number; + /** + * Request end time (milliseconds since epoch) + * @type {number} + * @memberof AuditLog + */ + 'end_time'?: number | null; + /** + * Request duration in milliseconds (calculated) + * @type {number} + * @memberof AuditLog + */ + 'duration_ms'?: number | null; + /** + * Request data (JSON string) + * @type {string} + * @memberof AuditLog + */ + 'request_data'?: string | null; + /** + * Response data (JSON string) + * @type {string} + * @memberof AuditLog + */ + 'response_data'?: string | null; + /** + * Error message if failed + * @type {string} + * @memberof AuditLog + */ + 'error_message'?: string | null; + /** + * Client IP address + * @type {string} + * @memberof AuditLog + */ + 'ip_address'?: string | null; + /** + * User agent string + * @type {string} + * @memberof AuditLog + */ + 'user_agent'?: string | null; + /** + * Request ID for tracking + * @type {string} + * @memberof AuditLog + */ + 'request_id'?: string; + /** + * Created timestamp + * @type {string} + * @memberof AuditLog + */ + 'created'?: string; +} + +export const AuditLogResourceTypeEnum = { + collection: 'collection', + document: 'document', + bot: 'bot', + chat: 'chat', + message: 'message', + api_key: 'api_key', + llm_provider: 'llm_provider', + llm_provider_model: 'llm_provider_model', + model_service_provider: 'model_service_provider', + user: 'user', + config: 'config' +} as const; + +export type AuditLogResourceTypeEnum = typeof AuditLogResourceTypeEnum[keyof typeof AuditLogResourceTypeEnum]; + + diff --git a/frontend/src/api/models/index.ts b/frontend/src/api/models/index.ts index a45663215..16c6dea67 100644 --- a/frontend/src/api/models/index.ts +++ b/frontend/src/api/models/index.ts @@ -2,6 +2,8 @@ export * from './api-key'; export * from './api-key-create'; export * from './api-key-list'; export * from './api-key-update'; +export * from './audit-log'; +export * from './audit-log-list'; export * from './bot'; export * from './bot-create'; export * from './bot-list'; diff --git a/frontend/src/api/openapi.merged.yaml b/frontend/src/api/openapi.merged.yaml index ddc78cd56..f32c4bb50 100644 --- a/frontend/src/api/openapi.merged.yaml +++ b/frontend/src/api/openapi.merged.yaml @@ -94,7 +94,7 @@ paths: $ref: '#/components/schemas/failResponse' delete: summary: Delete a bot - description: Delete a bot + description: Delete a bot (idempotent operation) security: - BearerAuth: [] parameters: @@ -105,7 +105,7 @@ paths: type: string responses: '204': - description: Bot deleted successfully + description: Bot deleted successfully (or already deleted) put: summary: Update a bot description: Update a bot @@ -344,7 +344,7 @@ paths: $ref: '#/components/schemas/failResponse' delete: summary: Delete a chat - description: Delete a chat + description: Delete a chat (idempotent operation) security: - BearerAuth: [] parameters: @@ -360,19 +360,13 @@ paths: type: string responses: '204': - description: Chat deleted successfully + description: Chat deleted successfully (or already deleted) '401': description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/failResponse' - '404': - description: Chat not found - content: - application/json: - schema: - $ref: '#/components/schemas/failResponse' /bots/{bot_id}/chats/{chat_id}/messages/{message_id}: post: summary: Feedback a message @@ -1667,6 +1661,128 @@ paths: application/json: schema: $ref: '#/components/schemas/failResponse' + /audit-logs: + get: + tags: + - audit + summary: List audit logs + description: List audit logs with filtering options + operationId: list_audit_logs + parameters: + - name: user_id + in: query + required: false + schema: + type: string + description: Filter by user ID + - name: username + in: query + required: false + schema: + type: string + description: Filter by username + - name: resource_type + in: query + required: false + schema: + type: string + enum: + - collection + - document + - bot + - chat + - message + - api_key + - llm_provider + - llm_provider_model + - model_service_provider + - user + - config + description: Filter by resource type + - name: resource_id + in: query + required: false + schema: + type: string + description: Filter by resource ID + - name: api_name + in: query + required: false + schema: + type: string + description: Filter by API name + - name: http_method + in: query + required: false + schema: + type: string + enum: + - POST + - PUT + - DELETE + description: Filter by HTTP method + - name: status_code + in: query + required: false + schema: + type: integer + description: Filter by status code + - name: start_date + in: query + required: false + schema: + type: string + format: date-time + description: Filter by start date + - name: end_date + in: query + required: false + schema: + type: string + format: date-time + description: Filter by end date + - name: limit + in: query + required: false + schema: + type: integer + maximum: 5000 + default: 1000 + description: Maximum number of records + responses: + '200': + description: Audit logs retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/auditLogList' + '403': + description: Admin access required + /audit-logs/{audit_id}: + get: + tags: + - audit + summary: Get audit log detail + description: Get a specific audit log by ID + operationId: get_audit_log + parameters: + - name: audit_id + in: path + required: true + schema: + type: string + description: Audit log ID + responses: + '200': + description: Audit log retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/auditLog' + '403': + description: Admin access required + '404': + description: Audit log not found /invite: post: summary: Create an invitation @@ -3461,6 +3577,103 @@ components: $ref: '#/components/schemas/promptTemplate' pageResult: $ref: '#/components/schemas/pageResult' + auditLog: + type: object + description: Audit log entry + properties: + id: + type: string + description: Audit log ID + user_id: + type: string + nullable: true + description: User ID who performed the action + username: + type: string + nullable: true + description: Username for display + resource_type: + type: string + enum: + - collection + - document + - bot + - chat + - message + - api_key + - llm_provider + - llm_provider_model + - model_service_provider + - user + - config + nullable: true + description: Type of resource + resource_id: + type: string + nullable: true + description: ID of the resource (extracted at query time) + api_name: + type: string + description: API operation name + http_method: + type: string + description: HTTP method (POST, PUT, DELETE) + path: + type: string + description: API path + status_code: + type: integer + nullable: true + description: HTTP status code + start_time: + type: integer + format: int64 + description: Request start time (milliseconds since epoch) + end_time: + type: integer + format: int64 + nullable: true + description: Request end time (milliseconds since epoch) + duration_ms: + type: integer + nullable: true + description: Request duration in milliseconds (calculated) + request_data: + type: string + nullable: true + description: Request data (JSON string) + response_data: + type: string + nullable: true + description: Response data (JSON string) + error_message: + type: string + nullable: true + description: Error message if failed + ip_address: + type: string + nullable: true + description: Client IP address + user_agent: + type: string + nullable: true + description: User agent string + request_id: + type: string + description: Request ID for tracking + created: + type: string + format: date-time + description: Created timestamp + auditLogList: + type: object + description: List of audit logs + properties: + items: + type: array + description: Audit log entries + items: + $ref: '#/components/schemas/auditLog' invitationCreate: type: object properties: diff --git a/frontend/src/locales/en-US.ts b/frontend/src/locales/en-US.ts index 92712c0bc..c48a4bd77 100644 --- a/frontend/src/locales/en-US.ts +++ b/frontend/src/locales/en-US.ts @@ -13,6 +13,44 @@ export default { ...collection, ...apiKeys, + // Audit logs + 'audit.logs.title': 'Audit Logs', + 'audit.logs.description': 'View detailed audit records of system operations', + 'audit.logs.username': 'Username', + 'audit.logs.apiName': 'API Name', + 'audit.logs.resourceType': 'Resource Type', + 'audit.logs.resourceId': 'Resource ID', + 'audit.logs.status': 'Status', + 'audit.logs.startTime': 'Start Time', + 'audit.logs.endTime': 'End Time', + 'audit.logs.timeRange': 'Time Range', + 'audit.logs.apiNamePlaceholder': 'Enter API name', + 'audit.logs.detail.title': 'Audit Log Details', + 'audit.logs.detail.apiName': 'API Name', + 'audit.logs.detail.status': 'Status', + 'audit.logs.detail.path': 'Request Path', + 'audit.logs.detail.startTime': 'Start Time', + 'audit.logs.detail.endTime': 'End Time', + 'audit.logs.detail.duration': 'Duration', + 'audit.logs.detail.ipAddress': 'IP Address', + 'audit.logs.detail.userAgent': 'User Agent', + 'audit.logs.detail.errorMessage': 'Error Message', + 'audit.logs.detail.requestData': 'Request Data', + 'audit.logs.detail.responseData': 'Response Data', + 'audit.logs.detail.noData': 'No data', + 'audit.logs.fetchError': 'Failed to fetch audit logs', + 'audit.logs.detailError': 'Failed to get audit details', + + // Common + 'common.search': 'Search', + 'common.actions': 'Actions', + 'common.detail': 'Detail', + 'common.system': 'System', + 'common.status.unknown': 'Unknown', + 'common.status.success': 'Success', + 'common.status.failed': 'Failed', + 'common.pagination.total': 'Showing {start}-{end} of {total} records', + 'text.welcome': 'Welcome to ApeRAG', 'text.authorizing': 'Authorizing', 'text.authorize.error': 'Authorization', diff --git a/frontend/src/locales/zh-CN.ts b/frontend/src/locales/zh-CN.ts index c667cdc48..8404e43b9 100644 --- a/frontend/src/locales/zh-CN.ts +++ b/frontend/src/locales/zh-CN.ts @@ -13,6 +13,44 @@ export default { ...collection, ...apiKeys, + // Audit logs + 'audit.logs.title': '操作日志', + 'audit.logs.description': '查看系统操作的详细审计记录', + 'audit.logs.username': '用户名', + 'audit.logs.apiName': 'API 名称', + 'audit.logs.resourceType': '资源类型', + 'audit.logs.resourceId': '资源 ID', + 'audit.logs.status': '状态', + 'audit.logs.startTime': '开始时间', + 'audit.logs.endTime': '结束时间', + 'audit.logs.timeRange': '时间范围', + 'audit.logs.apiNamePlaceholder': '输入API名称', + 'audit.logs.detail.title': '审计日志详情', + 'audit.logs.detail.apiName': 'API 名称', + 'audit.logs.detail.status': '状态', + 'audit.logs.detail.path': '请求路径', + 'audit.logs.detail.startTime': '开始时间', + 'audit.logs.detail.endTime': '结束时间', + 'audit.logs.detail.duration': '持续时间', + 'audit.logs.detail.ipAddress': 'IP 地址', + 'audit.logs.detail.userAgent': 'User Agent', + 'audit.logs.detail.errorMessage': '错误信息', + 'audit.logs.detail.requestData': '请求内容', + 'audit.logs.detail.responseData': '响应内容', + 'audit.logs.detail.noData': '无数据', + 'audit.logs.fetchError': '获取审计日志失败', + 'audit.logs.detailError': '获取详细信息失败', + + // Common + 'common.search': '搜索', + 'common.actions': '操作', + 'common.detail': '详情', + 'common.system': '系统', + 'common.status.unknown': '未知', + 'common.status.success': '成功', + 'common.status.failed': '失败', + 'common.pagination.total': '显示 {start}-{end} 条,共 {total} 条记录', + 'text.welcome': '欢迎使用ApeRAG', 'text.authorizing': '授权中', 'text.authorize.error': '认证失败', diff --git a/frontend/src/pages/settings/_navbar.tsx b/frontend/src/pages/settings/_navbar.tsx index ed4c9eb5b..34c734e6b 100644 --- a/frontend/src/pages/settings/_navbar.tsx +++ b/frontend/src/pages/settings/_navbar.tsx @@ -22,6 +22,10 @@ export const NavbarSettings = () => { label: , key: `/settings/apiKeys`, }, + { + label: , + key: `/settings/auditLogs`, + }, ], [], ); diff --git a/frontend/src/pages/settings/auditLogs.tsx b/frontend/src/pages/settings/auditLogs.tsx new file mode 100644 index 000000000..cb1bfaa39 --- /dev/null +++ b/frontend/src/pages/settings/auditLogs.tsx @@ -0,0 +1,430 @@ +import React, { useState, useEffect } from 'react'; +import { + Table, + Card, + Form, + Input, + Button, + DatePicker, + Space, + Tag, + Drawer, + message, + Typography, + Descriptions, + Divider, + Tooltip, + theme, +} from 'antd'; +import { SearchOutlined, EyeOutlined, CopyOutlined } from '@ant-design/icons'; +import { useIntl } from 'umi'; +import type { ColumnsType } from 'antd/es/table'; +import dayjs from 'dayjs'; +import { AuditApi } from '@/api/apis/audit-api'; +import type { AuditLog } from '@/api/models'; + +const { RangePicker } = DatePicker; +const { Text, Title } = Typography; + +const AuditLogsPage: React.FC = () => { + const intl = useIntl(); + const { token } = theme.useToken(); + const [form] = Form.useForm(); + const [loading, setLoading] = useState(false); + const [data, setData] = useState([]); + const [selectedRecord, setSelectedRecord] = useState(null); + const [detailDrawerVisible, setDetailDrawerVisible] = useState(false); + + // Format duration + const formatDuration = (ms?: number): string => { + if (!ms) return '-'; + if (ms < 1000) return `${ms.toFixed(0)}ms`; + return `${(ms / 1000).toFixed(2)}s`; + }; + + + + // Get status display + const getStatusDisplay = (statusCode?: number): { text: string; color: string } => { + if (!statusCode) return { + text: intl.formatMessage({ id: 'common.status.unknown', defaultMessage: 'Unknown' }), + color: 'default' + }; + if (statusCode >= 200 && statusCode < 300) return { + text: intl.formatMessage({ id: 'common.status.success', defaultMessage: 'Success' }), + color: 'success' + }; + return { + text: intl.formatMessage({ id: 'common.status.failed', defaultMessage: 'Failed' }), + color: 'error' + }; + }; + + // Fetch audit logs + const fetchData = async (params?: any) => { + setLoading(true); + try { + const api = new AuditApi(); + const response = await api.listAuditLogs(params || {}); + setData(response.data.items || []); + } catch (error) { + console.error('Failed to fetch audit logs:', error); + message.error(intl.formatMessage({ id: 'audit.logs.fetchError', defaultMessage: 'Failed to fetch audit logs' })); + } finally { + setLoading(false); + } + }; + + // Initial load + useEffect(() => { + // 默认展示最近一天的数据 + const endTime = dayjs(); + const startTime = endTime.subtract(1, 'day'); + fetchData({ + limit: 100, + startDate: startTime.toISOString(), + endDate: endTime.toISOString() + }); + + // 设置表单默认值 + form.setFieldsValue({ + dateRange: [startTime, endTime] + }); + }, []); + + // Handle search + const handleSearch = () => { + const values = form.getFieldsValue(); + const params: any = { ...values, limit: 100 }; + + // Handle date range + if (values.dateRange) { + params.startDate = values.dateRange[0]?.toISOString(); + params.endDate = values.dateRange[1]?.toISOString(); + delete params.dateRange; + } + + fetchData(params); + }; + + // Handle view details + const handleViewDetails = async (record: AuditLog) => { + try { + const api = new AuditApi(); + const log = await api.getAuditLog({ auditId: record.id! }); + console.log('Audit log details:', log.data); + console.log('Request data:', log.data.request_data); + console.log('Response data:', log.data.response_data); + setSelectedRecord(log.data); + setDetailDrawerVisible(true); + } catch (error) { + console.error('Failed to get audit details:', error); + message.error(intl.formatMessage({ id: 'audit.logs.detailError', defaultMessage: 'Failed to get audit details' })); + } + }; + + // Handle copy to clipboard + const handleCopy = async (text: string, type: 'request' | 'response') => { + try { + await navigator.clipboard.writeText(text); + message.success( + intl.formatMessage( + { id: 'audit.logs.copySuccess', defaultMessage: '{type} data copied to clipboard' }, + { type: type === 'request' ? 'Request' : 'Response' } + ) + ); + } catch (error) { + message.error(intl.formatMessage({ id: 'audit.logs.copyError', defaultMessage: 'Failed to copy to clipboard' })); + } + }; + + // Format JSON data for display + const formatJsonData = (data: any): string => { + if (!data) return ''; + try { + if (typeof data === 'string') { + return JSON.stringify(JSON.parse(data), null, 2); + } + return JSON.stringify(data, null, 2); + } catch { + return typeof data === 'string' ? data : JSON.stringify(data); + } + }; + + // Table columns + const columns: ColumnsType = [ + { + title: intl.formatMessage({ id: 'audit.logs.username', defaultMessage: 'Username' }), + dataIndex: 'username', + key: 'username', + width: 180, + render: (text?: string) => ( + {text || '-'} + ), + }, + { + title: intl.formatMessage({ id: 'audit.logs.apiName', defaultMessage: 'API Name' }), + dataIndex: 'api_name', + key: 'api_name', + width: 200, + render: (text?: string) => ( + + + {text && text.length > 30 ? `${text.substring(0, 30)}...` : text || '-'} + + + ), + }, + { + title: intl.formatMessage({ id: 'audit.logs.resourceType', defaultMessage: 'Resource Type' }), + dataIndex: 'resource_type', + key: 'resource_type', + width: 120, + render: (type?: string) => { + return type ? ( + + {type} + + ) : '-'; + }, + }, + { + title: intl.formatMessage({ id: 'audit.logs.resourceId', defaultMessage: 'Resource ID' }), + dataIndex: 'resource_id', + key: 'resource_id', + width: 240, + render: (id?: string) => ( + id ? ( + + {id} + + ) : '-' + ), + }, + { + title: intl.formatMessage({ id: 'audit.logs.status', defaultMessage: 'Status' }), + dataIndex: 'status_code', + key: 'status_code', + width: 100, + align: 'center' as const, + render: (code?: number) => { + const status = getStatusDisplay(code); + return ( + + {status.text} + + ); + }, + }, + { + title: intl.formatMessage({ id: 'audit.logs.startTime', defaultMessage: 'Start Time' }), + dataIndex: 'start_time', + key: 'start_time', + width: 180, + render: (time?: number) => ( + time ? ( + + {dayjs(time).format('YYYY-MM-DD HH:mm:ss.SSS')} + + ) : '-' + ), + }, + { + title: intl.formatMessage({ id: 'audit.logs.endTime', defaultMessage: 'End Time' }), + dataIndex: 'end_time', + key: 'end_time', + width: 180, + render: (time?: number) => ( + time ? ( + + {dayjs(time).format('YYYY-MM-DD HH:mm:ss.SSS')} + + ) : '-' + ), + }, + { + title: intl.formatMessage({ id: 'common.actions', defaultMessage: 'Actions' }), + key: 'actions', + width: 80, + align: 'center' as const, + render: (_, record) => ( + + ), + }, + ]; + + return ( +
+ +
+ + {intl.formatMessage({ id: 'audit.logs.title', defaultMessage: 'Audit Logs' })} + + + {intl.formatMessage({ id: 'audit.logs.description', defaultMessage: 'View detailed audit records of system operations' })} + +
+ +
+ + + + + + + + + + + + + +
+ + + + + intl.formatMessage( + { id: 'common.pagination.total', defaultMessage: 'Showing {start}-{end} of {total} records' }, + { start: range[0] || 0, end: range[1] || 0, total } + ), + pageSizeOptions: ['20', '50', '100'], + defaultPageSize: 20, + }} + scroll={{ x: 1240 }} + size="small" + bordered + /> + + + setDetailDrawerVisible(false)} + width={800} + destroyOnClose + > + {selectedRecord && ( + +
+
+ + {intl.formatMessage({ id: 'audit.logs.detail.requestData', defaultMessage: 'Request Data' })} + + +
+
+
+                  {formatJsonData(selectedRecord.request_data)}
+                
+
+
+ +
+
+ + {intl.formatMessage({ id: 'audit.logs.detail.responseData', defaultMessage: 'Response Data' })} + + +
+
+
+                  {formatJsonData(selectedRecord.response_data)}
+                
+
+
+
+ )} +
+ + ); +}; + +export default AuditLogsPage; \ No newline at end of file