ci: add GitHub Pages deployment workflow for docs site Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
372 lines
12 KiB
Python
372 lines
12 KiB
Python
"""
|
|
Dynamic RouterOS config editor API endpoints.
|
|
|
|
All routes are tenant-scoped under:
|
|
/api/tenants/{tenant_id}/devices/{device_id}/config-editor/
|
|
|
|
Proxies commands to the Go poller's CmdResponder via the RouterOS proxy service.
|
|
|
|
Provides:
|
|
- GET /browse -- browse a RouterOS menu path
|
|
- POST /add -- add a new entry
|
|
- POST /set -- edit an existing entry
|
|
- POST /remove -- delete an entry
|
|
- POST /execute -- execute an arbitrary CLI command
|
|
|
|
RLS is enforced via get_db() (app_user engine with tenant context).
|
|
RBAC: viewer = read-only (GET browse); operator and above = write (POST).
|
|
"""
|
|
|
|
import uuid
|
|
|
|
import structlog
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
|
from pydantic import BaseModel, ConfigDict
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.database import get_db
|
|
from app.middleware.rate_limit import limiter
|
|
from app.middleware.rbac import require_min_role, require_scope
|
|
from app.middleware.tenant_context import CurrentUser, get_current_user
|
|
from app.models.device import Device
|
|
from app.security.command_blocklist import check_command_safety, check_path_safety
|
|
from app.services import routeros_proxy
|
|
from app.services.audit_service import log_action
|
|
|
|
logger = structlog.get_logger(__name__)
|
|
audit_logger = structlog.get_logger("audit")
|
|
|
|
router = APIRouter(tags=["config-editor"])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def _check_tenant_access(
|
|
current_user: CurrentUser, tenant_id: uuid.UUID, db: AsyncSession
|
|
) -> None:
|
|
"""Verify the current user is allowed to access the given tenant."""
|
|
from app.database import set_tenant_context
|
|
|
|
if current_user.is_super_admin:
|
|
await set_tenant_context(db, str(tenant_id))
|
|
return
|
|
if current_user.tenant_id != tenant_id:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Access denied: you do not belong to this tenant.",
|
|
)
|
|
# Set RLS context for regular users too
|
|
await set_tenant_context(db, str(tenant_id))
|
|
|
|
|
|
async def _check_device_online(
|
|
db: AsyncSession, device_id: uuid.UUID
|
|
) -> Device:
|
|
"""Verify the device exists and is online. Returns the Device object."""
|
|
result = await db.execute(
|
|
select(Device).where(Device.id == device_id) # type: ignore[arg-type]
|
|
)
|
|
device = result.scalar_one_or_none()
|
|
if device is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Device {device_id} not found",
|
|
)
|
|
if device.status != "online":
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail="Device is offline \u2014 config editor requires a live connection.",
|
|
)
|
|
return device
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Request schemas
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class AddEntryRequest(BaseModel):
|
|
model_config = ConfigDict(extra="forbid")
|
|
path: str
|
|
properties: dict[str, str]
|
|
|
|
|
|
class SetEntryRequest(BaseModel):
|
|
model_config = ConfigDict(extra="forbid")
|
|
path: str
|
|
entry_id: str | None = None # Optional for singleton paths (e.g. /ip/dns)
|
|
properties: dict[str, str]
|
|
|
|
|
|
class RemoveEntryRequest(BaseModel):
|
|
model_config = ConfigDict(extra="forbid")
|
|
path: str
|
|
entry_id: str
|
|
|
|
|
|
class ExecuteRequest(BaseModel):
|
|
model_config = ConfigDict(extra="forbid")
|
|
command: str
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Endpoints
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.get(
|
|
"/tenants/{tenant_id}/devices/{device_id}/config-editor/browse",
|
|
summary="Browse a RouterOS menu path",
|
|
dependencies=[require_scope("config:read")],
|
|
)
|
|
async def browse_menu(
|
|
tenant_id: uuid.UUID,
|
|
device_id: uuid.UUID,
|
|
path: str = Query("/interface", description="RouterOS menu path to browse"),
|
|
current_user: CurrentUser = Depends(get_current_user),
|
|
_role: CurrentUser = Depends(require_min_role("viewer")),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> dict:
|
|
"""Browse a RouterOS menu path and return all entries at that path."""
|
|
await _check_tenant_access(current_user, tenant_id, db)
|
|
await _check_device_online(db, device_id)
|
|
check_path_safety(path)
|
|
|
|
result = await routeros_proxy.browse_menu(str(device_id), path)
|
|
|
|
if not result.get("success"):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
detail=result.get("error", "Failed to browse menu path"),
|
|
)
|
|
|
|
audit_logger.info(
|
|
"routeros_config_browsed",
|
|
device_id=str(device_id),
|
|
tenant_id=str(tenant_id),
|
|
user_id=str(current_user.user_id),
|
|
path=path,
|
|
)
|
|
|
|
return {
|
|
"success": True,
|
|
"entries": result.get("data", []),
|
|
"error": None,
|
|
"path": path,
|
|
}
|
|
|
|
|
|
@router.post(
|
|
"/tenants/{tenant_id}/devices/{device_id}/config-editor/add",
|
|
summary="Add a new entry to a RouterOS menu path",
|
|
dependencies=[require_scope("config:write")],
|
|
)
|
|
@limiter.limit("20/minute")
|
|
async def add_entry(
|
|
request: Request,
|
|
tenant_id: uuid.UUID,
|
|
device_id: uuid.UUID,
|
|
body: AddEntryRequest,
|
|
current_user: CurrentUser = Depends(get_current_user),
|
|
_role: CurrentUser = Depends(require_min_role("operator")),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> dict:
|
|
"""Add a new entry to a RouterOS menu path with the given properties."""
|
|
await _check_tenant_access(current_user, tenant_id, db)
|
|
await _check_device_online(db, device_id)
|
|
check_path_safety(body.path, write=True)
|
|
|
|
result = await routeros_proxy.add_entry(str(device_id), body.path, body.properties)
|
|
|
|
if not result.get("success"):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
detail=result.get("error", "Failed to add entry"),
|
|
)
|
|
|
|
audit_logger.info(
|
|
"routeros_config_added",
|
|
device_id=str(device_id),
|
|
tenant_id=str(tenant_id),
|
|
user_id=str(current_user.user_id),
|
|
user_role=current_user.role,
|
|
path=body.path,
|
|
success=result.get("success", False),
|
|
)
|
|
|
|
try:
|
|
await log_action(
|
|
db, tenant_id, current_user.user_id, "config_add",
|
|
resource_type="config", resource_id=str(device_id),
|
|
device_id=device_id,
|
|
details={"path": body.path, "properties": body.properties},
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
return result
|
|
|
|
|
|
@router.post(
|
|
"/tenants/{tenant_id}/devices/{device_id}/config-editor/set",
|
|
summary="Edit an existing entry in a RouterOS menu path",
|
|
dependencies=[require_scope("config:write")],
|
|
)
|
|
@limiter.limit("20/minute")
|
|
async def set_entry(
|
|
request: Request,
|
|
tenant_id: uuid.UUID,
|
|
device_id: uuid.UUID,
|
|
body: SetEntryRequest,
|
|
current_user: CurrentUser = Depends(get_current_user),
|
|
_role: CurrentUser = Depends(require_min_role("operator")),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> dict:
|
|
"""Update an existing entry's properties on the device."""
|
|
await _check_tenant_access(current_user, tenant_id, db)
|
|
await _check_device_online(db, device_id)
|
|
check_path_safety(body.path, write=True)
|
|
|
|
result = await routeros_proxy.update_entry(
|
|
str(device_id), body.path, body.entry_id, body.properties
|
|
)
|
|
|
|
if not result.get("success"):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
detail=result.get("error", "Failed to update entry"),
|
|
)
|
|
|
|
audit_logger.info(
|
|
"routeros_config_modified",
|
|
device_id=str(device_id),
|
|
tenant_id=str(tenant_id),
|
|
user_id=str(current_user.user_id),
|
|
user_role=current_user.role,
|
|
path=body.path,
|
|
entry_id=body.entry_id,
|
|
success=result.get("success", False),
|
|
)
|
|
|
|
try:
|
|
await log_action(
|
|
db, tenant_id, current_user.user_id, "config_set",
|
|
resource_type="config", resource_id=str(device_id),
|
|
device_id=device_id,
|
|
details={"path": body.path, "entry_id": body.entry_id, "properties": body.properties},
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
return result
|
|
|
|
|
|
@router.post(
|
|
"/tenants/{tenant_id}/devices/{device_id}/config-editor/remove",
|
|
summary="Delete an entry from a RouterOS menu path",
|
|
dependencies=[require_scope("config:write")],
|
|
)
|
|
@limiter.limit("5/minute")
|
|
async def remove_entry(
|
|
request: Request,
|
|
tenant_id: uuid.UUID,
|
|
device_id: uuid.UUID,
|
|
body: RemoveEntryRequest,
|
|
current_user: CurrentUser = Depends(get_current_user),
|
|
_role: CurrentUser = Depends(require_min_role("operator")),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> dict:
|
|
"""Remove an entry from a RouterOS menu path."""
|
|
await _check_tenant_access(current_user, tenant_id, db)
|
|
await _check_device_online(db, device_id)
|
|
check_path_safety(body.path, write=True)
|
|
|
|
result = await routeros_proxy.remove_entry(
|
|
str(device_id), body.path, body.entry_id
|
|
)
|
|
|
|
if not result.get("success"):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
detail=result.get("error", "Failed to remove entry"),
|
|
)
|
|
|
|
audit_logger.info(
|
|
"routeros_config_removed",
|
|
device_id=str(device_id),
|
|
tenant_id=str(tenant_id),
|
|
user_id=str(current_user.user_id),
|
|
user_role=current_user.role,
|
|
path=body.path,
|
|
entry_id=body.entry_id,
|
|
success=result.get("success", False),
|
|
)
|
|
|
|
try:
|
|
await log_action(
|
|
db, tenant_id, current_user.user_id, "config_remove",
|
|
resource_type="config", resource_id=str(device_id),
|
|
device_id=device_id,
|
|
details={"path": body.path, "entry_id": body.entry_id},
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
return result
|
|
|
|
|
|
@router.post(
|
|
"/tenants/{tenant_id}/devices/{device_id}/config-editor/execute",
|
|
summary="Execute an arbitrary RouterOS CLI command",
|
|
dependencies=[require_scope("config:write")],
|
|
)
|
|
@limiter.limit("20/minute")
|
|
async def execute_command(
|
|
request: Request,
|
|
tenant_id: uuid.UUID,
|
|
device_id: uuid.UUID,
|
|
body: ExecuteRequest,
|
|
current_user: CurrentUser = Depends(get_current_user),
|
|
_role: CurrentUser = Depends(require_min_role("operator")),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> dict:
|
|
"""Execute an arbitrary RouterOS CLI command on the device."""
|
|
await _check_tenant_access(current_user, tenant_id, db)
|
|
await _check_device_online(db, device_id)
|
|
check_command_safety(body.command)
|
|
|
|
result = await routeros_proxy.execute_cli(str(device_id), body.command)
|
|
|
|
if not result.get("success"):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
detail=result.get("error", "Failed to execute command"),
|
|
)
|
|
|
|
audit_logger.info(
|
|
"routeros_command_executed",
|
|
device_id=str(device_id),
|
|
tenant_id=str(tenant_id),
|
|
user_id=str(current_user.user_id),
|
|
user_role=current_user.role,
|
|
command=body.command,
|
|
success=result.get("success", False),
|
|
)
|
|
|
|
try:
|
|
await log_action(
|
|
db, tenant_id, current_user.user_id, "config_execute",
|
|
resource_type="config", resource_id=str(device_id),
|
|
device_id=device_id,
|
|
details={"command": body.command},
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
return result
|