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>
This commit is contained in:
371
backend/app/routers/config_editor.py
Normal file
371
backend/app/routers/config_editor.py
Normal file
@@ -0,0 +1,371 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user