Spaces:
Sleeping
Sleeping
File size: 9,242 Bytes
e8b3591 68af3c5 e8b3591 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 | """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")
|