Files
the-other-dude/backend/app/routers/config_editor.py
Jason Staack b840047e19 feat: The Other Dude v9.0.1 — full-featured email system
ci: add GitHub Pages deployment workflow for docs site

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 19:30:44 -05:00

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