Spaces:
Sleeping
Sleeping
feat: add confluence/slack search tools, chat history, cloud Qdrant support, sync trigger fixes
68af3c5 | """Admin REST endpoints — user management, channel management, and audit log.""" | |
| from __future__ import annotations | |
| import logging | |
| from functools import lru_cache | |
| from typing import Optional | |
| from fastapi import APIRouter, Depends, HTTPException, Query | |
| from pydantic import BaseModel | |
| from src.auth.deps import require_role | |
| logger = logging.getLogger(__name__) | |
| router = APIRouter(prefix="/api/workspace", tags=["admin-workspace"]) | |
| DEFAULT_WORKSPACE_ID = "00000000-0000-0000-0000-000000000001" | |
| def _client(): | |
| """Lazy singleton Supabase service-role client.""" | |
| from supabase import create_client | |
| from src.config import settings | |
| if not settings.supabase_url or not settings.supabase_key: | |
| raise RuntimeError("Supabase not configured (SUPABASE_URL / SUPABASE_KEY missing)") | |
| return create_client(settings.supabase_url, settings.supabase_key) | |
| # --------------------------------------------------------------------------- | |
| # Users | |
| # --------------------------------------------------------------------------- | |
| class CreateUserBody(BaseModel): | |
| email: str | |
| name: str | |
| role: str = "engineer" | |
| team_id: Optional[str] = None | |
| class PatchUserBody(BaseModel): | |
| role: Optional[str] = None | |
| is_active: Optional[bool] = None | |
| name: Optional[str] = None | |
| async def list_users(user=Depends(require_role("admin", "org_admin"))) -> dict: | |
| """List all users in the default workspace.""" | |
| try: | |
| result = ( | |
| _client() | |
| .table("users") | |
| .select("id, email, name, role, is_active") | |
| .eq("workspace_id", DEFAULT_WORKSPACE_ID) | |
| .order("name") | |
| .execute() | |
| ) | |
| return {"users": result.data} | |
| except Exception: | |
| logger.exception("admin: list_users failed") | |
| raise HTTPException(status_code=500, detail="Failed to fetch users") | |
| async def create_user(body: CreateUserBody, user=Depends(require_role("admin", "org_admin"))) -> dict: | |
| """Invite a new user — password_hash is left None; invite flow sets it later.""" | |
| payload: dict = { | |
| "workspace_id": DEFAULT_WORKSPACE_ID, | |
| "email": body.email.lower(), | |
| "name": body.name, | |
| "role": body.role, | |
| "is_active": True, | |
| "password_hash": None, | |
| } | |
| if body.team_id: | |
| payload["team_id"] = body.team_id | |
| try: | |
| result = _client().table("users").insert(payload).execute() | |
| return {"ok": True, "user": result.data[0] if result.data else None} | |
| except Exception: | |
| logger.exception("admin: create_user failed for %s", body.email) | |
| raise HTTPException(status_code=500, detail="Failed to create user") | |
| async def patch_user( | |
| user_id: str, | |
| body: PatchUserBody, | |
| user=Depends(require_role("admin", "org_admin")), | |
| ) -> dict: | |
| """Update role, is_active, or name for a user.""" | |
| updates = body.model_dump(exclude_none=True) | |
| if not updates: | |
| raise HTTPException(status_code=400, detail="No fields to update") | |
| try: | |
| result = ( | |
| _client() | |
| .table("users") | |
| .update(updates) | |
| .eq("id", user_id) | |
| .eq("workspace_id", DEFAULT_WORKSPACE_ID) | |
| .execute() | |
| ) | |
| except Exception: | |
| logger.exception("admin: patch_user failed for %s", user_id) | |
| raise HTTPException(status_code=500, detail="Failed to update user") | |
| if not result.data: | |
| raise HTTPException(status_code=404, detail="User not found") | |
| return {"ok": True} | |
| async def delete_user(user_id: str, user=Depends(require_role("admin", "org_admin"))) -> dict: | |
| """Soft-delete a user by setting is_active=False.""" | |
| try: | |
| result = ( | |
| _client() | |
| .table("users") | |
| .update({"is_active": False}) | |
| .eq("id", user_id) | |
| .eq("workspace_id", DEFAULT_WORKSPACE_ID) | |
| .execute() | |
| ) | |
| except Exception: | |
| logger.exception("admin: delete_user failed for %s", user_id) | |
| raise HTTPException(status_code=500, detail="Failed to deactivate user") | |
| if not result.data: | |
| raise HTTPException(status_code=404, detail="User not found") | |
| return {"ok": True} | |
| # --------------------------------------------------------------------------- | |
| # Channels | |
| # --------------------------------------------------------------------------- | |
| class CreateChannelBody(BaseModel): | |
| name: str | |
| team_id: Optional[str] = None | |
| source_type: Optional[str] = None | |
| sensitivity: Optional[str] = None | |
| class PatchChannelBody(BaseModel): | |
| name: Optional[str] = None | |
| sensitivity: Optional[str] = None | |
| async def list_channels(user=Depends(require_role("admin", "org_admin"))) -> dict: | |
| """List all channels in the default workspace.""" | |
| try: | |
| result = ( | |
| _client() | |
| .table("channels") | |
| .select("id, name, team_id, source_type, sensitivity, workspace_id") | |
| .eq("workspace_id", DEFAULT_WORKSPACE_ID) | |
| .order("name") | |
| .execute() | |
| ) | |
| return {"channels": result.data} | |
| except Exception: | |
| logger.exception("admin: list_channels failed") | |
| raise HTTPException(status_code=500, detail="Failed to fetch channels") | |
| async def create_channel(body: CreateChannelBody, user=Depends(require_role("admin", "org_admin"))) -> dict: | |
| """Create a new channel in the default workspace.""" | |
| payload: dict = { | |
| "workspace_id": DEFAULT_WORKSPACE_ID, | |
| "name": body.name, | |
| } | |
| if body.team_id: | |
| payload["team_id"] = body.team_id | |
| if body.source_type: | |
| payload["source_type"] = body.source_type | |
| if body.sensitivity: | |
| payload["sensitivity"] = body.sensitivity | |
| try: | |
| result = _client().table("channels").insert(payload).execute() | |
| return {"ok": True, "channel": result.data[0] if result.data else None} | |
| except Exception: | |
| logger.exception("admin: create_channel failed for '%s'", body.name) | |
| raise HTTPException(status_code=500, detail="Failed to create channel") | |
| async def patch_channel( | |
| channel_id: str, | |
| body: PatchChannelBody, | |
| user=Depends(require_role("admin", "org_admin")), | |
| ) -> dict: | |
| """Update name or sensitivity for a channel.""" | |
| updates = body.model_dump(exclude_none=True) | |
| if not updates: | |
| raise HTTPException(status_code=400, detail="No fields to update") | |
| try: | |
| result = ( | |
| _client() | |
| .table("channels") | |
| .update(updates) | |
| .eq("id", channel_id) | |
| .eq("workspace_id", DEFAULT_WORKSPACE_ID) | |
| .execute() | |
| ) | |
| except Exception: | |
| logger.exception("admin: patch_channel failed for %s", channel_id) | |
| raise HTTPException(status_code=500, detail="Failed to update channel") | |
| if not result.data: | |
| raise HTTPException(status_code=404, detail="Channel not found") | |
| return {"ok": True} | |
| async def delete_channel(channel_id: str, user=Depends(require_role("admin", "org_admin"))) -> dict: | |
| """Hard-delete a channel.""" | |
| try: | |
| result = ( | |
| _client() | |
| .table("channels") | |
| .delete() | |
| .eq("id", channel_id) | |
| .eq("workspace_id", DEFAULT_WORKSPACE_ID) | |
| .execute() | |
| ) | |
| except Exception: | |
| logger.exception("admin: delete_channel failed for %s", channel_id) | |
| raise HTTPException(status_code=500, detail="Failed to delete channel") | |
| if not result.data: | |
| raise HTTPException(status_code=404, detail="Channel not found") | |
| return {"ok": True} | |
| # --------------------------------------------------------------------------- | |
| # Audit log — separate prefix, same router | |
| # --------------------------------------------------------------------------- | |
| audit_router = APIRouter(prefix="/api", tags=["admin-audit"]) | |
| async def get_audit_log( | |
| page: int = Query(default=1, ge=1), | |
| limit: int = Query(default=50, ge=1, le=200), | |
| action: str = Query(default=""), | |
| target_type: str = Query(default=""), | |
| user=Depends(require_role("admin", "org_admin")), | |
| ) -> dict: | |
| """Paginated audit log from rbac_audit_log.""" | |
| offset = (page - 1) * limit | |
| try: | |
| q = ( | |
| _client() | |
| .table("rbac_audit_log") | |
| .select("id, actor_id, action, target_type, target_id, metadata, created_at") | |
| .order("created_at", desc=True) | |
| .range(offset, offset + limit - 1) | |
| ) | |
| if action: | |
| q = q.eq("action", action) | |
| if target_type: | |
| q = q.eq("target_type", target_type) | |
| result = q.execute() | |
| return {"logs": result.data, "page": page, "limit": limit} | |
| except Exception: | |
| logger.exception("admin: get_audit_log failed") | |
| raise HTTPException(status_code=500, detail="Failed to fetch audit log") | |