292 lines
11 KiB
Python
292 lines
11 KiB
Python
from fastapi import APIRouter, HTTPException, Depends, status, Request, Body
|
|
from fastapi.responses import FileResponse, JSONResponse
|
|
from typing import Optional, Dict, Any, List
|
|
import os
|
|
import logging
|
|
|
|
from modules.security.auth import getCurrentUser, limiter
|
|
from modules.interfaces.interfaceAppObjects import getInterface, getRootInterface
|
|
from modules.interfaces.interfaceAppModel import User, UserInDB, AuthAuthority, Token
|
|
from modules.shared.configuration import APP_CONFIG
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(
|
|
prefix="/api/admin",
|
|
tags=["Admin"],
|
|
responses={
|
|
404: {"description": "Not found"},
|
|
400: {"description": "Bad request"},
|
|
401: {"description": "Unauthorized"},
|
|
403: {"description": "Forbidden"},
|
|
500: {"description": "Internal server error"}
|
|
}
|
|
)
|
|
|
|
def _ensure_admin_scope(current_user: User, target_mandate_id: Optional[str] = None) -> None:
|
|
if current_user.privilege not in ("admin", "sysadmin"):
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin privileges required")
|
|
if current_user.privilege == "admin":
|
|
if target_mandate_id and str(target_mandate_id) != str(current_user.mandateId):
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden for target mandate")
|
|
|
|
|
|
# ----------------------
|
|
# Token listing and revocation
|
|
# ----------------------
|
|
|
|
@router.get("/tokens")
|
|
@limiter.limit("30/minute")
|
|
async def list_tokens(
|
|
request: Request,
|
|
currentUser: User = Depends(getCurrentUser),
|
|
userId: Optional[str] = None,
|
|
authority: Optional[str] = None,
|
|
sessionId: Optional[str] = None,
|
|
statusFilter: Optional[str] = None,
|
|
connectionId: Optional[str] = None,
|
|
) -> List[Dict[str, Any]]:
|
|
try:
|
|
appInterface = getRootInterface()
|
|
target_mandate = currentUser.mandateId
|
|
_ensure_admin_scope(currentUser, target_mandate)
|
|
|
|
recordFilter: Dict[str, Any] = {}
|
|
if userId:
|
|
recordFilter["userId"] = userId
|
|
if authority:
|
|
recordFilter["authority"] = authority
|
|
if sessionId:
|
|
recordFilter["sessionId"] = sessionId
|
|
if connectionId:
|
|
recordFilter["connectionId"] = connectionId
|
|
if statusFilter:
|
|
recordFilter["status"] = statusFilter
|
|
if currentUser.privilege == "admin":
|
|
recordFilter["mandateId"] = str(currentUser.mandateId)
|
|
|
|
tokens = appInterface.db.getRecordset(Token, recordFilter=recordFilter)
|
|
return tokens
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error listing tokens: {str(e)}")
|
|
raise HTTPException(status_code=500, detail="Failed to list tokens")
|
|
|
|
|
|
@router.post("/tokens/revoke/user")
|
|
@limiter.limit("30/minute")
|
|
async def revoke_tokens_by_user(
|
|
request: Request,
|
|
currentUser: User = Depends(getCurrentUser),
|
|
payload: Dict[str, Any] = Body(...)
|
|
) -> Dict[str, Any]:
|
|
try:
|
|
userId = payload.get("userId")
|
|
authority = payload.get("authority")
|
|
reason = payload.get("reason", "admin revoke")
|
|
if not userId:
|
|
raise HTTPException(status_code=400, detail="userId is required")
|
|
|
|
appInterface = getRootInterface()
|
|
# Tenant scope check
|
|
target_user = appInterface.db.getRecordset(User, recordFilter={"id": userId})
|
|
target_mandate = target_user[0].get("mandateId") if target_user else None
|
|
_ensure_admin_scope(currentUser, target_mandate)
|
|
|
|
count = appInterface.revokeTokensByUser(
|
|
userId=userId,
|
|
authority=AuthAuthority(authority) if authority else None,
|
|
mandateId=None if currentUser.privilege == "sysadmin" else str(currentUser.mandateId),
|
|
revokedBy=currentUser.id,
|
|
reason=reason
|
|
)
|
|
return {"revoked": count}
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error revoking tokens by user: {str(e)}")
|
|
raise HTTPException(status_code=500, detail="Failed to revoke tokens")
|
|
|
|
|
|
@router.post("/tokens/revoke/session")
|
|
@limiter.limit("30/minute")
|
|
async def revoke_tokens_by_session(
|
|
request: Request,
|
|
currentUser: User = Depends(getCurrentUser),
|
|
payload: Dict[str, Any] = Body(...)
|
|
) -> Dict[str, Any]:
|
|
try:
|
|
userId = payload.get("userId")
|
|
sessionId = payload.get("sessionId")
|
|
authority = payload.get("authority", "local")
|
|
reason = payload.get("reason", "admin session revoke")
|
|
if not userId or not sessionId:
|
|
raise HTTPException(status_code=400, detail="userId and sessionId are required")
|
|
|
|
appInterface = getRootInterface()
|
|
target_user = appInterface.db.getRecordset(User, recordFilter={"id": userId})
|
|
target_mandate = target_user[0].get("mandateId") if target_user else None
|
|
_ensure_admin_scope(currentUser, target_mandate)
|
|
|
|
count = appInterface.revokeTokensBySessionId(
|
|
sessionId=sessionId,
|
|
userId=userId,
|
|
authority=AuthAuthority(authority),
|
|
revokedBy=currentUser.id,
|
|
reason=reason
|
|
)
|
|
return {"revoked": count}
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error revoking tokens by session: {str(e)}")
|
|
raise HTTPException(status_code=500, detail="Failed to revoke session tokens")
|
|
|
|
|
|
@router.post("/tokens/revoke/id")
|
|
@limiter.limit("30/minute")
|
|
async def revoke_token_by_id(
|
|
request: Request,
|
|
currentUser: User = Depends(getCurrentUser),
|
|
payload: Dict[str, Any] = Body(...)
|
|
) -> Dict[str, Any]:
|
|
try:
|
|
tokenId = payload.get("tokenId")
|
|
reason = payload.get("reason", "admin revoke")
|
|
if not tokenId:
|
|
raise HTTPException(status_code=400, detail="tokenId is required")
|
|
appInterface = getRootInterface()
|
|
# Load token to check tenant scope for admins
|
|
tokens = appInterface.db.getRecordset(Token, recordFilter={"id": tokenId})
|
|
if not tokens:
|
|
return {"revoked": 0}
|
|
target_mandate = tokens[0].get("mandateId")
|
|
_ensure_admin_scope(currentUser, target_mandate)
|
|
|
|
ok = appInterface.revokeTokenById(tokenId, revokedBy=currentUser.id, reason=reason)
|
|
return {"revoked": 1 if ok else 0}
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error revoking token by id: {str(e)}")
|
|
raise HTTPException(status_code=500, detail="Failed to revoke token")
|
|
|
|
|
|
@router.post("/tokens/revoke/mandate")
|
|
@limiter.limit("10/minute")
|
|
async def revoke_tokens_by_mandate(
|
|
request: Request,
|
|
currentUser: User = Depends(getCurrentUser),
|
|
payload: Dict[str, Any] = Body(...)
|
|
) -> Dict[str, Any]:
|
|
try:
|
|
mandateId = payload.get("mandateId")
|
|
authority = payload.get("authority", "local")
|
|
reason = payload.get("reason", "admin mandate revoke")
|
|
if not mandateId:
|
|
raise HTTPException(status_code=400, detail="mandateId is required")
|
|
|
|
_ensure_admin_scope(currentUser, mandateId)
|
|
|
|
# Revoke for all users in mandate
|
|
appInterface = getRootInterface()
|
|
# IMPORTANT: user rows are stored as UserInDB in the database
|
|
users = appInterface.db.getRecordset(UserInDB, recordFilter={"mandateId": mandateId})
|
|
total = 0
|
|
for u in users:
|
|
# Revoke regardless of token.mandateId to also catch legacy tokens without mandateId
|
|
total += appInterface.revokeTokensByUser(
|
|
userId=u["id"],
|
|
authority=AuthAuthority(authority),
|
|
mandateId=None,
|
|
revokedBy=currentUser.id,
|
|
reason=reason
|
|
)
|
|
return {"revoked": total}
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error revoking tokens by mandate: {str(e)}")
|
|
raise HTTPException(status_code=500, detail="Failed to revoke mandate tokens")
|
|
|
|
|
|
# ----------------------
|
|
# Logs download
|
|
# ----------------------
|
|
|
|
@router.get("/logs/{log_name}")
|
|
@limiter.limit("60/minute")
|
|
async def download_log(
|
|
request: Request,
|
|
currentUser: User = Depends(getCurrentUser),
|
|
log_name: str = "poweron"
|
|
):
|
|
_ensure_admin_scope(currentUser)
|
|
base_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
# base_dir -> gateway
|
|
if log_name == "poweron":
|
|
file_path = os.path.join(base_dir, "poweron.log")
|
|
elif log_name == "audit":
|
|
file_path = os.path.join(base_dir, "audit.log")
|
|
else:
|
|
raise HTTPException(status_code=400, detail="Unsupported log name")
|
|
|
|
if not os.path.exists(file_path):
|
|
raise HTTPException(status_code=404, detail=f"{log_name}.log not found")
|
|
return FileResponse(path=file_path, filename=f"{log_name}.log")
|
|
|
|
|
|
# ----------------------
|
|
# Database admin
|
|
# ----------------------
|
|
|
|
@router.get("/databases")
|
|
@limiter.limit("10/minute")
|
|
async def list_databases(
|
|
request: Request,
|
|
currentUser: User = Depends(getCurrentUser)
|
|
) -> Dict[str, Any]:
|
|
_ensure_admin_scope(currentUser)
|
|
# For safety, expose only configured database name
|
|
db_name = APP_CONFIG.get("DB_DATABASE") or APP_CONFIG.get("DB_NAME") or "poweron"
|
|
return {"databases": [db_name]}
|
|
|
|
|
|
@router.post("/databases/drop")
|
|
@limiter.limit("5/minute")
|
|
async def drop_database(
|
|
request: Request,
|
|
currentUser: User = Depends(getCurrentUser),
|
|
payload: Dict[str, Any] = Body(...)
|
|
) -> Dict[str, Any]:
|
|
_ensure_admin_scope(currentUser)
|
|
db_name = payload.get("database")
|
|
configured_db = APP_CONFIG.get("DB_DATABASE") or APP_CONFIG.get("DB_NAME") or "poweron"
|
|
if not db_name or db_name != configured_db:
|
|
raise HTTPException(status_code=400, detail="Invalid database name")
|
|
|
|
try:
|
|
appInterface = getRootInterface()
|
|
conn = appInterface.db.connection
|
|
with conn.cursor() as cursor:
|
|
# Drop all user tables (public schema) except system table
|
|
cursor.execute("""
|
|
SELECT table_name FROM information_schema.tables
|
|
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
|
|
""")
|
|
tables = [row['table_name'] for row in cursor.fetchall()]
|
|
dropped = []
|
|
for tbl in tables:
|
|
cursor.execute(f'DROP TABLE IF EXISTS "{tbl}" CASCADE')
|
|
dropped.append(tbl)
|
|
conn.commit()
|
|
logger.warning(f"Admin drop_database executed by {currentUser.id}: dropped tables: {dropped}")
|
|
return {"droppedTables": dropped}
|
|
except Exception as e:
|
|
logger.error(f"Error dropping database tables: {str(e)}")
|
|
if appInterface and appInterface.db and appInterface.db.connection:
|
|
appInterface.db.connection.rollback()
|
|
raise HTTPException(status_code=500, detail="Failed to drop database tables")
|
|
|
|
|