feat: add FastAPI server with auth, RBAC, and all API endpoints
- JWT auth with role-based access control (viewer/analyst/admin/km_admin) - Endpoints: health, sync manifest, data download, query, users CRUD, corporate memory, session/artifact upload - 18 API tests covering auth, RBAC, all endpoints
This commit is contained in:
parent
64acc8d731
commit
a3918d3833
17 changed files with 981 additions and 1 deletions
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
0
app/api/__init__.py
Normal file
0
app/api/__init__.py
Normal file
51
app/api/data.py
Normal file
51
app/api/data.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
"""Data download endpoint — streaming parquet files."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from app.auth.dependencies import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/api/data", tags=["data"])
|
||||
|
||||
|
||||
def _get_data_dir() -> Path:
|
||||
return Path(os.environ.get("DATA_DIR", "./data"))
|
||||
|
||||
|
||||
@router.get("/{table_id}/download")
|
||||
async def download_table(
|
||||
table_id: str,
|
||||
request: Request,
|
||||
user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Stream a parquet file for download. Supports ETag for caching."""
|
||||
data_dir = _get_data_dir()
|
||||
parquet_dir = data_dir / "src_data" / "parquet"
|
||||
|
||||
# Find the parquet file (may be in a subfolder)
|
||||
candidates = list(parquet_dir.rglob(f"{table_id}.parquet"))
|
||||
if not candidates:
|
||||
# Try with folder structure: folder/table.parquet
|
||||
candidates = list(parquet_dir.rglob(f"*/{table_id}.parquet"))
|
||||
if not candidates:
|
||||
raise HTTPException(status_code=404, detail=f"Table '{table_id}' not found")
|
||||
|
||||
file_path = candidates[0]
|
||||
|
||||
# ETag support
|
||||
stat = file_path.stat()
|
||||
etag = f'"{stat.st_mtime_ns}"'
|
||||
if_none_match = request.headers.get("if-none-match")
|
||||
if if_none_match == etag:
|
||||
from starlette.responses import Response
|
||||
return Response(status_code=304)
|
||||
|
||||
return FileResponse(
|
||||
path=file_path,
|
||||
filename=f"{table_id}.parquet",
|
||||
media_type="application/octet-stream",
|
||||
headers={"ETag": etag},
|
||||
)
|
||||
66
app/api/health.py
Normal file
66
app/api/health.py
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
"""Health check endpoint — structured diagnostics for AI agents."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
import duckdb
|
||||
|
||||
from app.auth.dependencies import _get_db
|
||||
from src.repositories.sync_state import SyncStateRepository
|
||||
|
||||
router = APIRouter(tags=["health"])
|
||||
|
||||
|
||||
@router.get("/api/health")
|
||||
async def health_check(conn: duckdb.DuckDBPyConnection = Depends(_get_db)):
|
||||
"""Structured health check. No auth required."""
|
||||
checks = {}
|
||||
|
||||
# DuckDB state
|
||||
try:
|
||||
conn.execute("SELECT 1").fetchone()
|
||||
checks["duckdb_state"] = {"status": "ok"}
|
||||
except Exception as e:
|
||||
checks["duckdb_state"] = {"status": "error", "detail": str(e)}
|
||||
|
||||
# Sync state summary
|
||||
try:
|
||||
repo = SyncStateRepository(conn)
|
||||
all_states = repo.get_all_states()
|
||||
total_tables = len(all_states)
|
||||
total_rows = sum(s.get("rows", 0) or 0 for s in all_states)
|
||||
stale = []
|
||||
now = datetime.now(timezone.utc)
|
||||
for s in all_states:
|
||||
last = s.get("last_sync")
|
||||
if last and (now - last).total_seconds() > 86400: # >24h
|
||||
stale.append(s["table_id"])
|
||||
checks["data"] = {
|
||||
"status": "ok" if not stale else "warning",
|
||||
"tables": total_tables,
|
||||
"total_rows": total_rows,
|
||||
"stale_tables": stale,
|
||||
}
|
||||
except Exception as e:
|
||||
checks["data"] = {"status": "error", "detail": str(e)}
|
||||
|
||||
# User count
|
||||
try:
|
||||
user_count = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
||||
checks["users"] = {"status": "ok", "count": user_count}
|
||||
except Exception as e:
|
||||
checks["users"] = {"status": "error", "detail": str(e)}
|
||||
|
||||
overall = "healthy"
|
||||
for check in checks.values():
|
||||
if check.get("status") == "error":
|
||||
overall = "unhealthy"
|
||||
break
|
||||
if check.get("status") == "warning":
|
||||
overall = "degraded"
|
||||
|
||||
return {
|
||||
"status": overall,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"services": checks,
|
||||
}
|
||||
101
app/api/memory.py
Normal file
101
app/api/memory.py
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
"""Corporate memory endpoints — knowledge items, voting."""
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
|
||||
import duckdb
|
||||
|
||||
from app.auth.dependencies import get_current_user, require_role, Role, _get_db
|
||||
from src.repositories.knowledge import KnowledgeRepository
|
||||
|
||||
router = APIRouter(prefix="/api/memory", tags=["memory"])
|
||||
|
||||
|
||||
class CreateKnowledgeRequest(BaseModel):
|
||||
title: str
|
||||
content: str
|
||||
category: str
|
||||
tags: Optional[List[str]] = None
|
||||
|
||||
|
||||
class VoteRequest(BaseModel):
|
||||
vote: int # 1 or -1
|
||||
|
||||
|
||||
class KnowledgeResponse(BaseModel):
|
||||
id: str
|
||||
title: str
|
||||
content: Optional[str]
|
||||
category: Optional[str]
|
||||
status: str
|
||||
created_at: Optional[str]
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_knowledge(
|
||||
status_filter: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
search: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
user: dict = Depends(get_current_user),
|
||||
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
||||
):
|
||||
repo = KnowledgeRepository(conn)
|
||||
if search:
|
||||
items = repo.search(search)
|
||||
else:
|
||||
statuses = [status_filter] if status_filter else None
|
||||
items = repo.list_items(statuses=statuses, category=category, limit=limit)
|
||||
return {"items": items, "count": len(items)}
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_knowledge(
|
||||
request: CreateKnowledgeRequest,
|
||||
user: dict = Depends(get_current_user),
|
||||
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
||||
):
|
||||
repo = KnowledgeRepository(conn)
|
||||
item_id = str(uuid.uuid4())
|
||||
repo.create(
|
||||
id=item_id,
|
||||
title=request.title,
|
||||
content=request.content,
|
||||
category=request.category,
|
||||
source_user=user.get("email"),
|
||||
tags=request.tags,
|
||||
)
|
||||
return {"id": item_id, "status": "pending"}
|
||||
|
||||
|
||||
@router.post("/{item_id}/vote")
|
||||
async def vote_knowledge(
|
||||
item_id: str,
|
||||
request: VoteRequest,
|
||||
user: dict = Depends(get_current_user),
|
||||
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
||||
):
|
||||
if request.vote not in (1, -1):
|
||||
raise HTTPException(status_code=400, detail="Vote must be 1 or -1")
|
||||
repo = KnowledgeRepository(conn)
|
||||
if not repo.get_by_id(item_id):
|
||||
raise HTTPException(status_code=404, detail="Knowledge item not found")
|
||||
repo.vote(item_id, user["id"], request.vote)
|
||||
return repo.get_votes(item_id)
|
||||
|
||||
|
||||
@router.put("/{item_id}/status")
|
||||
async def update_status(
|
||||
item_id: str,
|
||||
new_status: str,
|
||||
user: dict = Depends(require_role(Role.KM_ADMIN)),
|
||||
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
||||
):
|
||||
repo = KnowledgeRepository(conn)
|
||||
if not repo.get_by_id(item_id):
|
||||
raise HTTPException(status_code=404, detail="Knowledge item not found")
|
||||
repo.update_status(item_id, new_status)
|
||||
return {"id": item_id, "status": new_status}
|
||||
60
app/api/query.py
Normal file
60
app/api/query.py
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
"""Query endpoint — execute SQL against server DuckDB."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.auth.dependencies import get_current_user
|
||||
from src.db import get_analytics_db
|
||||
|
||||
router = APIRouter(prefix="/api/query", tags=["query"])
|
||||
|
||||
|
||||
class QueryRequest(BaseModel):
|
||||
sql: str
|
||||
limit: int = 1000
|
||||
|
||||
|
||||
class QueryResponse(BaseModel):
|
||||
columns: list
|
||||
rows: list
|
||||
row_count: int
|
||||
truncated: bool = False
|
||||
|
||||
|
||||
@router.post("", response_model=QueryResponse)
|
||||
async def execute_query(
|
||||
request: QueryRequest,
|
||||
user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Execute SQL against the server analytics DuckDB."""
|
||||
# Safety: basic SQL injection prevention
|
||||
sql_lower = request.sql.strip().lower()
|
||||
if any(keyword in sql_lower for keyword in ["drop ", "delete ", "insert ", "update ", "alter ", "create "]):
|
||||
raise HTTPException(status_code=400, detail="Only SELECT queries are allowed")
|
||||
|
||||
conn = get_analytics_db()
|
||||
try:
|
||||
result = conn.execute(request.sql).fetchmany(request.limit + 1)
|
||||
columns = [desc[0] for desc in conn.description] if conn.description else []
|
||||
truncated = len(result) > request.limit
|
||||
rows = result[:request.limit]
|
||||
# Convert to serializable types
|
||||
serializable_rows = []
|
||||
for row in rows:
|
||||
serializable_rows.append([
|
||||
str(v) if v is not None and not isinstance(v, (int, float, bool, str)) else v
|
||||
for v in row
|
||||
])
|
||||
return QueryResponse(
|
||||
columns=columns,
|
||||
rows=serializable_rows,
|
||||
row_count=len(serializable_rows),
|
||||
truncated=truncated,
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Query error: {str(e)}")
|
||||
finally:
|
||||
conn.close()
|
||||
92
app/api/sync.py
Normal file
92
app/api/sync.py
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
"""Sync endpoints — manifest, trigger."""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
import duckdb
|
||||
|
||||
from app.auth.dependencies import get_current_user, require_role, Role, _get_db
|
||||
from src.repositories.sync_state import SyncStateRepository
|
||||
|
||||
router = APIRouter(prefix="/api/sync", tags=["sync"])
|
||||
|
||||
|
||||
def _file_hash(path: Path) -> str:
|
||||
"""Compute MD5 hash of a file for change detection."""
|
||||
if not path.exists():
|
||||
return ""
|
||||
h = hashlib.md5()
|
||||
with open(path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(8192), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def _get_data_dir() -> Path:
|
||||
return Path(os.environ.get("DATA_DIR", "./data"))
|
||||
|
||||
|
||||
@router.get("/manifest")
|
||||
async def sync_manifest(
|
||||
user: dict = Depends(get_current_user),
|
||||
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
||||
):
|
||||
"""Return hash-based manifest of all synced data, filtered per user."""
|
||||
repo = SyncStateRepository(conn)
|
||||
all_states = repo.get_all_states()
|
||||
|
||||
data_dir = _get_data_dir()
|
||||
parquet_dir = data_dir / "src_data" / "parquet"
|
||||
|
||||
# Build table manifest
|
||||
tables = {}
|
||||
for state in all_states:
|
||||
table_id = state["table_id"]
|
||||
tables[table_id] = {
|
||||
"hash": state.get("hash", ""),
|
||||
"updated": state.get("last_sync").isoformat() if state.get("last_sync") else None,
|
||||
"size_bytes": state.get("file_size_bytes", 0),
|
||||
"rows": state.get("rows", 0),
|
||||
}
|
||||
|
||||
# Asset hashes
|
||||
docs_dir = data_dir / "docs"
|
||||
assets = {}
|
||||
for asset_name, asset_path in [
|
||||
("docs", docs_dir),
|
||||
("profiles", data_dir / "src_data" / "metadata" / "profiles.json"),
|
||||
]:
|
||||
if asset_path.exists():
|
||||
if asset_path.is_file():
|
||||
assets[asset_name] = {"hash": _file_hash(asset_path)}
|
||||
else:
|
||||
# Directory — hash based on mtime of newest file
|
||||
newest = max(
|
||||
(f.stat().st_mtime for f in asset_path.rglob("*") if f.is_file()),
|
||||
default=0,
|
||||
)
|
||||
assets[asset_name] = {"hash": str(int(newest))}
|
||||
|
||||
return {
|
||||
"tables": tables,
|
||||
"assets": assets,
|
||||
"server_time": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/trigger")
|
||||
async def trigger_sync(
|
||||
user: dict = Depends(require_role(Role.ADMIN)),
|
||||
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
||||
):
|
||||
"""Trigger data sync from configured source. Admin only."""
|
||||
# This will call DataSyncManager when integrated
|
||||
# For now, return a stub response
|
||||
return {
|
||||
"status": "triggered",
|
||||
"message": "Data sync triggered. Check /api/health for progress.",
|
||||
}
|
||||
75
app/api/upload.py
Normal file
75
app/api/upload.py
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
"""Upload endpoints — sessions, artifacts, CLAUDE.local.md."""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.auth.dependencies import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/api/upload", tags=["upload"])
|
||||
|
||||
|
||||
def _get_data_dir() -> Path:
|
||||
return Path(os.environ.get("DATA_DIR", "./data"))
|
||||
|
||||
|
||||
@router.post("/sessions")
|
||||
async def upload_session(
|
||||
file: UploadFile = File(...),
|
||||
user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Upload a Claude session transcript (JSONL)."""
|
||||
user_id = user["id"]
|
||||
sessions_dir = _get_data_dir() / "user_sessions" / user_id
|
||||
sessions_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
filename = file.filename or f"session_{uuid.uuid4().hex[:8]}.jsonl"
|
||||
target = sessions_dir / filename
|
||||
content = await file.read()
|
||||
target.write_bytes(content)
|
||||
return {"status": "ok", "path": str(target), "size": len(content)}
|
||||
|
||||
|
||||
@router.post("/artifacts")
|
||||
async def upload_artifact(
|
||||
file: UploadFile = File(...),
|
||||
user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Upload an artifact (HTML report, PNG chart, etc.)."""
|
||||
user_id = user["id"]
|
||||
artifacts_dir = _get_data_dir() / "user_artifacts" / user_id
|
||||
artifacts_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
filename = file.filename or f"artifact_{uuid.uuid4().hex[:8]}"
|
||||
target = artifacts_dir / filename
|
||||
content = await file.read()
|
||||
target.write_bytes(content)
|
||||
return {"status": "ok", "path": str(target), "size": len(content)}
|
||||
|
||||
|
||||
class LocalMdRequest(BaseModel):
|
||||
content: str
|
||||
|
||||
|
||||
@router.post("/local-md")
|
||||
async def upload_local_md(
|
||||
request: LocalMdRequest,
|
||||
user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Upload CLAUDE.local.md content for corporate memory processing."""
|
||||
user_id = user["id"]
|
||||
user_email = user["email"]
|
||||
md_dir = _get_data_dir() / "user_local_md"
|
||||
md_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
target = md_dir / f"{user_email}.md"
|
||||
target.write_text(request.content, encoding="utf-8")
|
||||
return {
|
||||
"status": "ok",
|
||||
"user": user_email,
|
||||
"size": len(request.content),
|
||||
}
|
||||
74
app/api/users.py
Normal file
74
app/api/users.py
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
"""User management endpoints."""
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
|
||||
import duckdb
|
||||
|
||||
from app.auth.dependencies import require_role, Role, _get_db
|
||||
from src.repositories.users import UserRepository
|
||||
|
||||
router = APIRouter(prefix="/api/users", tags=["users"])
|
||||
|
||||
|
||||
class CreateUserRequest(BaseModel):
|
||||
email: str
|
||||
name: str
|
||||
role: str = "analyst"
|
||||
|
||||
|
||||
class UpdateUserRequest(BaseModel):
|
||||
name: Optional[str] = None
|
||||
role: Optional[str] = None
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
id: str
|
||||
email: str
|
||||
name: Optional[str]
|
||||
role: str
|
||||
created_at: Optional[str]
|
||||
|
||||
|
||||
@router.get("", response_model=List[UserResponse])
|
||||
async def list_users(
|
||||
user: dict = Depends(require_role(Role.ADMIN)),
|
||||
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
||||
):
|
||||
repo = UserRepository(conn)
|
||||
users = repo.list_all()
|
||||
return [
|
||||
UserResponse(
|
||||
id=u["id"], email=u["email"], name=u.get("name"),
|
||||
role=u["role"], created_at=str(u.get("created_at", "")),
|
||||
) for u in users
|
||||
]
|
||||
|
||||
|
||||
@router.post("", response_model=UserResponse, status_code=201)
|
||||
async def create_user(
|
||||
request: CreateUserRequest,
|
||||
user: dict = Depends(require_role(Role.ADMIN)),
|
||||
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
||||
):
|
||||
repo = UserRepository(conn)
|
||||
if repo.get_by_email(request.email):
|
||||
raise HTTPException(status_code=409, detail="User with this email already exists")
|
||||
user_id = str(uuid.uuid4())
|
||||
repo.create(id=user_id, email=request.email, name=request.name, role=request.role)
|
||||
return UserResponse(id=user_id, email=request.email, name=request.name, role=request.role, created_at=None)
|
||||
|
||||
|
||||
@router.delete("/{user_id}", status_code=204)
|
||||
async def delete_user(
|
||||
user_id: str,
|
||||
user: dict = Depends(require_role(Role.ADMIN)),
|
||||
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
||||
):
|
||||
repo = UserRepository(conn)
|
||||
if not repo.get_by_id(user_id):
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
repo.delete(user_id)
|
||||
0
app/auth/__init__.py
Normal file
0
app/auth/__init__.py
Normal file
88
app/auth/dependencies.py
Normal file
88
app/auth/dependencies.py
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
"""FastAPI auth dependencies — current user, role checking."""
|
||||
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
import duckdb
|
||||
from fastapi import Depends, HTTPException, Header, status
|
||||
|
||||
from app.auth.jwt import verify_token
|
||||
from src.db import get_system_db
|
||||
from src.repositories.users import UserRepository
|
||||
|
||||
|
||||
class Role(str, Enum):
|
||||
VIEWER = "viewer"
|
||||
ANALYST = "analyst"
|
||||
ADMIN = "admin"
|
||||
KM_ADMIN = "km_admin"
|
||||
|
||||
|
||||
ROLE_HIERARCHY = {
|
||||
Role.VIEWER: 0,
|
||||
Role.ANALYST: 1,
|
||||
Role.KM_ADMIN: 2,
|
||||
Role.ADMIN: 3,
|
||||
}
|
||||
|
||||
|
||||
def _get_db():
|
||||
conn = get_system_db()
|
||||
try:
|
||||
yield conn
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
authorization: Optional[str] = Header(None),
|
||||
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
||||
) -> dict:
|
||||
"""Extract and validate JWT from Authorization header. Returns user dict."""
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Missing or invalid Authorization header",
|
||||
)
|
||||
token = authorization.removeprefix("Bearer ")
|
||||
payload = verify_token(token)
|
||||
if not payload:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired token",
|
||||
)
|
||||
|
||||
repo = UserRepository(conn)
|
||||
user = repo.get_by_id(payload.get("sub", ""))
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User not found",
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
async def get_optional_user(
|
||||
authorization: Optional[str] = Header(None),
|
||||
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
||||
) -> Optional[dict]:
|
||||
"""Like get_current_user but returns None instead of 401 if no token."""
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
return None
|
||||
try:
|
||||
return await get_current_user(authorization, conn)
|
||||
except HTTPException:
|
||||
return None
|
||||
|
||||
|
||||
def require_role(minimum_role: Role):
|
||||
"""Dependency factory: require user has at least the given role."""
|
||||
async def _check(user: dict = Depends(get_current_user)):
|
||||
user_role = Role(user.get("role", "viewer"))
|
||||
if ROLE_HIERARCHY.get(user_role, 0) < ROLE_HIERARCHY.get(minimum_role, 0):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Requires role {minimum_role.value} or higher",
|
||||
)
|
||||
return user
|
||||
return _check
|
||||
41
app/auth/jwt.py
Normal file
41
app/auth/jwt.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
"""JWT token creation and verification for API auth."""
|
||||
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
import jwt
|
||||
|
||||
SECRET_KEY = os.environ.get("JWT_SECRET_KEY", "dev-jwt-secret-change-in-production")
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_HOURS = 24 * 30 # 30 days
|
||||
|
||||
|
||||
def create_access_token(
|
||||
user_id: str,
|
||||
email: str,
|
||||
role: str = "analyst",
|
||||
expires_delta: Optional[timedelta] = None,
|
||||
) -> str:
|
||||
expire = datetime.now(timezone.utc) + (
|
||||
expires_delta or timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS)
|
||||
)
|
||||
payload = {
|
||||
"sub": user_id,
|
||||
"email": email,
|
||||
"role": role,
|
||||
"exp": expire,
|
||||
"iat": datetime.now(timezone.utc),
|
||||
}
|
||||
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def verify_token(token: str) -> Optional[dict]:
|
||||
"""Verify and decode a JWT token. Returns payload dict or None."""
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
return payload
|
||||
except jwt.ExpiredSignatureError:
|
||||
return None
|
||||
except jwt.InvalidTokenError:
|
||||
return None
|
||||
51
app/auth/router.py
Normal file
51
app/auth/router.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
"""Auth endpoints — login, token generation."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
import duckdb
|
||||
|
||||
from app.auth.jwt import create_access_token
|
||||
from app.auth.dependencies import _get_db
|
||||
from src.repositories.users import UserRepository
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
class TokenRequest(BaseModel):
|
||||
email: str
|
||||
password: str = ""
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
user_id: str
|
||||
email: str
|
||||
role: str
|
||||
|
||||
|
||||
@router.post("/token", response_model=TokenResponse)
|
||||
async def create_token(
|
||||
request: TokenRequest,
|
||||
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
||||
):
|
||||
"""Issue a JWT token. For dev/demo: any registered user gets a token."""
|
||||
repo = UserRepository(conn)
|
||||
user = repo.get_by_email(request.email)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="User not found")
|
||||
|
||||
# TODO: In production, verify password_hash with argon2
|
||||
# For greenfield demo, we issue tokens to any registered user
|
||||
token = create_access_token(
|
||||
user_id=user["id"],
|
||||
email=user["email"],
|
||||
role=user["role"],
|
||||
)
|
||||
return TokenResponse(
|
||||
access_token=token,
|
||||
user_id=user["id"],
|
||||
email=user["email"],
|
||||
role=user["role"],
|
||||
)
|
||||
45
app/main.py
Normal file
45
app/main.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
"""FastAPI main application — unified server for web UI + API."""
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.auth.router import router as auth_router
|
||||
from app.api.health import router as health_router
|
||||
from app.api.sync import router as sync_router
|
||||
from app.api.data import router as data_router
|
||||
from app.api.query import router as query_router
|
||||
from app.api.users import router as users_router
|
||||
from app.api.memory import router as memory_router
|
||||
from app.api.upload import router as upload_router
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
app = FastAPI(
|
||||
title="AI Data Analyst",
|
||||
description="Data distribution platform for AI analytical systems",
|
||||
version="2.0.0",
|
||||
)
|
||||
|
||||
# CORS for CLI and web UI
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Register routers
|
||||
app.include_router(auth_router)
|
||||
app.include_router(health_router)
|
||||
app.include_router(sync_router)
|
||||
app.include_router(data_router)
|
||||
app.include_router(query_router)
|
||||
app.include_router(users_router)
|
||||
app.include_router(memory_router)
|
||||
app.include_router(upload_router)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
0
app/web/__init__.py
Normal file
0
app/web/__init__.py
Normal file
|
|
@ -26,13 +26,19 @@ pyyaml>=6.0
|
|||
tqdm>=4.65.0
|
||||
|
||||
# Web application (Google SSO portal)
|
||||
# flask - web framework for self-service portal
|
||||
# flask - web framework for self-service portal (legacy, being replaced by FastAPI)
|
||||
# authlib - OAuth 2.0 / OpenID Connect library for Google SSO
|
||||
# gunicorn - WSGI server for production deployment
|
||||
flask>=3.0.0
|
||||
authlib>=1.3.0
|
||||
gunicorn>=21.0.0
|
||||
|
||||
# FastAPI - new unified web framework (API + web UI)
|
||||
fastapi>=0.115.0
|
||||
uvicorn[standard]>=0.32.0
|
||||
python-multipart>=0.0.9
|
||||
jinja2>=3.1.0
|
||||
|
||||
# Telegram notification bot
|
||||
# httpx - async HTTP client for Telegram API and unix socket communication
|
||||
# aiohttp - async HTTP server for bot's internal send API
|
||||
|
|
|
|||
230
tests/test_api.py
Normal file
230
tests/test_api.py
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
"""Tests for FastAPI endpoints."""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app_client(tmp_path):
|
||||
os.environ["DATA_DIR"] = str(tmp_path)
|
||||
os.environ["JWT_SECRET_KEY"] = "test-secret"
|
||||
from app.main import create_app
|
||||
app = create_app()
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def seeded_client(tmp_path):
|
||||
"""Client with a pre-created admin user and JWT token."""
|
||||
os.environ["DATA_DIR"] = str(tmp_path)
|
||||
os.environ["JWT_SECRET_KEY"] = "test-secret"
|
||||
from app.main import create_app
|
||||
from src.db import get_system_db
|
||||
from src.repositories.users import UserRepository
|
||||
from app.auth.jwt import create_access_token
|
||||
|
||||
conn = get_system_db()
|
||||
repo = UserRepository(conn)
|
||||
repo.create(id="admin1", email="admin@acme.com", name="Admin", role="admin")
|
||||
repo.create(id="analyst1", email="analyst@acme.com", name="Analyst", role="analyst")
|
||||
conn.close()
|
||||
|
||||
app = create_app()
|
||||
client = TestClient(app)
|
||||
|
||||
admin_token = create_access_token("admin1", "admin@acme.com", "admin")
|
||||
analyst_token = create_access_token("analyst1", "analyst@acme.com", "analyst")
|
||||
|
||||
return client, admin_token, analyst_token
|
||||
|
||||
|
||||
# ---- Health ----
|
||||
|
||||
class TestHealth:
|
||||
def test_health_no_auth(self, app_client):
|
||||
resp = app_client.get("/api/health")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["status"] in ("healthy", "degraded", "unhealthy")
|
||||
assert "services" in data
|
||||
|
||||
def test_health_has_duckdb_check(self, app_client):
|
||||
resp = app_client.get("/api/health")
|
||||
data = resp.json()
|
||||
assert "duckdb_state" in data["services"]
|
||||
assert data["services"]["duckdb_state"]["status"] == "ok"
|
||||
|
||||
|
||||
# ---- Auth ----
|
||||
|
||||
class TestAuth:
|
||||
def test_token_for_existing_user(self, seeded_client):
|
||||
client, _, _ = seeded_client
|
||||
resp = client.post("/auth/token", json={"email": "admin@acme.com"})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "access_token" in data
|
||||
assert data["role"] == "admin"
|
||||
|
||||
def test_token_for_unknown_user(self, seeded_client):
|
||||
client, _, _ = seeded_client
|
||||
resp = client.post("/auth/token", json={"email": "nobody@acme.com"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_protected_endpoint_without_token(self, seeded_client):
|
||||
client, _, _ = seeded_client
|
||||
resp = client.get("/api/users")
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_protected_endpoint_with_token(self, seeded_client):
|
||||
client, admin_token, _ = seeded_client
|
||||
resp = client.get("/api/users", headers={"Authorization": f"Bearer {admin_token}"})
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
# ---- RBAC ----
|
||||
|
||||
class TestRBAC:
|
||||
def test_admin_can_list_users(self, seeded_client):
|
||||
client, admin_token, _ = seeded_client
|
||||
resp = client.get("/api/users", headers={"Authorization": f"Bearer {admin_token}"})
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()) == 2
|
||||
|
||||
def test_analyst_cannot_list_users(self, seeded_client):
|
||||
client, _, analyst_token = seeded_client
|
||||
resp = client.get("/api/users", headers={"Authorization": f"Bearer {analyst_token}"})
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_analyst_cannot_trigger_sync(self, seeded_client):
|
||||
client, _, analyst_token = seeded_client
|
||||
resp = client.post("/api/sync/trigger", headers={"Authorization": f"Bearer {analyst_token}"})
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
# ---- Sync Manifest ----
|
||||
|
||||
class TestSyncManifest:
|
||||
def test_manifest_returns_tables(self, seeded_client):
|
||||
client, admin_token, _ = seeded_client
|
||||
# Seed some sync state
|
||||
from src.db import get_system_db
|
||||
from src.repositories.sync_state import SyncStateRepository
|
||||
conn = get_system_db()
|
||||
repo = SyncStateRepository(conn)
|
||||
repo.update_sync(table_id="orders", rows=1000, file_size_bytes=5000, hash="abc")
|
||||
conn.close()
|
||||
|
||||
resp = client.get("/api/sync/manifest", headers={"Authorization": f"Bearer {admin_token}"})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "tables" in data
|
||||
assert "orders" in data["tables"]
|
||||
assert data["tables"]["orders"]["rows"] == 1000
|
||||
|
||||
|
||||
# ---- Users CRUD ----
|
||||
|
||||
class TestUsersCRUD:
|
||||
def test_create_user(self, seeded_client):
|
||||
client, admin_token, _ = seeded_client
|
||||
resp = client.post(
|
||||
"/api/users",
|
||||
json={"email": "new@acme.com", "name": "New User"},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
assert resp.json()["email"] == "new@acme.com"
|
||||
|
||||
def test_create_duplicate_user(self, seeded_client):
|
||||
client, admin_token, _ = seeded_client
|
||||
resp = client.post(
|
||||
"/api/users",
|
||||
json={"email": "admin@acme.com", "name": "Duplicate"},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
|
||||
def test_delete_user(self, seeded_client):
|
||||
client, admin_token, _ = seeded_client
|
||||
resp = client.delete(
|
||||
"/api/users/analyst1",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert resp.status_code == 204
|
||||
|
||||
|
||||
# ---- Knowledge / Memory ----
|
||||
|
||||
class TestMemory:
|
||||
def test_create_and_list(self, seeded_client):
|
||||
client, _, analyst_token = seeded_client
|
||||
headers = {"Authorization": f"Bearer {analyst_token}"}
|
||||
|
||||
# Create
|
||||
resp = client.post("/api/memory", json={
|
||||
"title": "MRR Definition",
|
||||
"content": "Monthly recurring revenue",
|
||||
"category": "metrics",
|
||||
}, headers=headers)
|
||||
assert resp.status_code == 201
|
||||
item_id = resp.json()["id"]
|
||||
|
||||
# List
|
||||
resp = client.get("/api/memory", headers=headers)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["count"] == 1
|
||||
|
||||
def test_vote(self, seeded_client):
|
||||
client, _, analyst_token = seeded_client
|
||||
headers = {"Authorization": f"Bearer {analyst_token}"}
|
||||
|
||||
resp = client.post("/api/memory", json={
|
||||
"title": "Test", "content": "test", "category": "test",
|
||||
}, headers=headers)
|
||||
item_id = resp.json()["id"]
|
||||
|
||||
resp = client.post(f"/api/memory/{item_id}/vote", json={"vote": 1}, headers=headers)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["upvotes"] == 1
|
||||
|
||||
def test_search(self, seeded_client):
|
||||
client, _, analyst_token = seeded_client
|
||||
headers = {"Authorization": f"Bearer {analyst_token}"}
|
||||
|
||||
client.post("/api/memory", json={
|
||||
"title": "Revenue report", "content": "MRR trends", "category": "finance",
|
||||
}, headers=headers)
|
||||
client.post("/api/memory", json={
|
||||
"title": "Support SLA", "content": "Response times", "category": "support",
|
||||
}, headers=headers)
|
||||
|
||||
resp = client.get("/api/memory?search=revenue", headers=headers)
|
||||
assert resp.json()["count"] == 1
|
||||
|
||||
|
||||
# ---- Upload ----
|
||||
|
||||
class TestUpload:
|
||||
def test_upload_session(self, seeded_client):
|
||||
client, _, analyst_token = seeded_client
|
||||
headers = {"Authorization": f"Bearer {analyst_token}"}
|
||||
resp = client.post(
|
||||
"/api/upload/sessions",
|
||||
files={"file": ("session.jsonl", b'{"role":"user","content":"hello"}', "application/jsonl")},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["size"] > 0
|
||||
|
||||
def test_upload_local_md(self, seeded_client):
|
||||
client, _, analyst_token = seeded_client
|
||||
headers = {"Authorization": f"Bearer {analyst_token}"}
|
||||
resp = client.post(
|
||||
"/api/upload/local-md",
|
||||
json={"content": "# My knowledge\n\nMRR = Monthly Recurring Revenue"},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["user"] == "analyst@acme.com"
|
||||
Loading…
Reference in a new issue