"""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" @lru_cache(maxsize=1) 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 @router.get("/users") 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") @router.post("/users", status_code=201) 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") @router.patch("/users/{user_id}") 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} @router.delete("/users/{user_id}") 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 @router.get("/channels") 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") @router.post("/channels", status_code=201) 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") @router.patch("/channels/{channel_id}") 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} @router.delete("/channels/{channel_id}") 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"]) @audit_router.get("/audit-log") 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")