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:
452
backend/app/routers/devices.py
Normal file
452
backend/app/routers/devices.py
Normal file
@@ -0,0 +1,452 @@
|
||||
"""
|
||||
Device management API endpoints.
|
||||
|
||||
All routes are tenant-scoped under /api/tenants/{tenant_id}/devices.
|
||||
RLS is enforced via PostgreSQL — the app_user engine automatically filters
|
||||
cross-tenant data based on the SET LOCAL app.current_tenant context set by
|
||||
get_current_user dependency.
|
||||
|
||||
RBAC:
|
||||
- viewer: GET (read-only)
|
||||
- operator: POST, PUT (write)
|
||||
- admin/tenant_admin: DELETE
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.middleware.rate_limit import limiter
|
||||
from app.services.audit_service import log_action
|
||||
from app.middleware.rbac import (
|
||||
require_min_role,
|
||||
require_operator_or_above,
|
||||
require_scope,
|
||||
require_tenant_admin_or_above,
|
||||
)
|
||||
from app.middleware.tenant_context import CurrentUser, get_current_user
|
||||
from app.schemas.device import (
|
||||
BulkAddRequest,
|
||||
BulkAddResult,
|
||||
DeviceCreate,
|
||||
DeviceListResponse,
|
||||
DeviceResponse,
|
||||
DeviceUpdate,
|
||||
SubnetScanRequest,
|
||||
SubnetScanResponse,
|
||||
)
|
||||
from app.services import device as device_service
|
||||
from app.services.scanner import scan_subnet
|
||||
|
||||
router = APIRouter(tags=["devices"])
|
||||
|
||||
|
||||
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.
|
||||
|
||||
- super_admin can access any tenant — re-sets DB tenant context to target tenant.
|
||||
- All other roles must match their own tenant_id.
|
||||
"""
|
||||
if current_user.is_super_admin:
|
||||
# Re-set tenant context to the target tenant so RLS allows the operation
|
||||
from app.database import set_tenant_context
|
||||
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.",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Device CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get(
|
||||
"/tenants/{tenant_id}/devices",
|
||||
response_model=DeviceListResponse,
|
||||
summary="List devices with pagination and filtering",
|
||||
dependencies=[require_scope("devices:read")],
|
||||
)
|
||||
async def list_devices(
|
||||
tenant_id: uuid.UUID,
|
||||
page: int = Query(1, ge=1, description="Page number (1-based)"),
|
||||
page_size: int = Query(25, ge=1, le=100, description="Items per page (1-100)"),
|
||||
status_filter: Optional[str] = Query(None, alias="status"),
|
||||
search: Optional[str] = Query(None, description="Text search on hostname or IP"),
|
||||
tag_id: Optional[uuid.UUID] = Query(None),
|
||||
group_id: Optional[uuid.UUID] = Query(None),
|
||||
sort_by: str = Query("created_at", description="Field to sort by"),
|
||||
sort_order: str = Query("desc", description="asc or desc"),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> DeviceListResponse:
|
||||
"""List devices for a tenant with optional pagination, filtering, and sorting."""
|
||||
await _check_tenant_access(current_user, tenant_id, db)
|
||||
|
||||
items, total = await device_service.get_devices(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
status=status_filter,
|
||||
search=search,
|
||||
tag_id=tag_id,
|
||||
group_id=group_id,
|
||||
sort_by=sort_by,
|
||||
sort_order=sort_order,
|
||||
)
|
||||
return DeviceListResponse(items=items, total=total, page=page, page_size=page_size)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/tenants/{tenant_id}/devices",
|
||||
response_model=DeviceResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Add a device (validates TCP connectivity first)",
|
||||
dependencies=[Depends(require_operator_or_above), require_scope("devices:write")],
|
||||
)
|
||||
@limiter.limit("20/minute")
|
||||
async def create_device(
|
||||
request: Request,
|
||||
tenant_id: uuid.UUID,
|
||||
data: DeviceCreate,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> DeviceResponse:
|
||||
"""
|
||||
Create a new device. Requires operator role or above.
|
||||
|
||||
The device IP/port is TCP-probed before the record is saved.
|
||||
Credentials are encrypted with AES-256-GCM before storage and never returned.
|
||||
"""
|
||||
await _check_tenant_access(current_user, tenant_id, db)
|
||||
result = await device_service.create_device(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
data=data,
|
||||
encryption_key=settings.get_encryption_key_bytes(),
|
||||
)
|
||||
try:
|
||||
await log_action(
|
||||
db, tenant_id, current_user.user_id, "device_create",
|
||||
resource_type="device", resource_id=str(result.id),
|
||||
details={"hostname": data.hostname, "ip_address": data.ip_address},
|
||||
ip_address=request.client.host if request.client else None,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
|
||||
|
||||
@router.get(
|
||||
"/tenants/{tenant_id}/devices/{device_id}",
|
||||
response_model=DeviceResponse,
|
||||
summary="Get a single device",
|
||||
dependencies=[require_scope("devices:read")],
|
||||
)
|
||||
async def get_device(
|
||||
tenant_id: uuid.UUID,
|
||||
device_id: uuid.UUID,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> DeviceResponse:
|
||||
"""Get device details. Viewer role and above."""
|
||||
await _check_tenant_access(current_user, tenant_id, db)
|
||||
return await device_service.get_device(db=db, tenant_id=tenant_id, device_id=device_id)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/tenants/{tenant_id}/devices/{device_id}",
|
||||
response_model=DeviceResponse,
|
||||
summary="Update a device",
|
||||
dependencies=[Depends(require_operator_or_above), require_scope("devices:write")],
|
||||
)
|
||||
@limiter.limit("20/minute")
|
||||
async def update_device(
|
||||
request: Request,
|
||||
tenant_id: uuid.UUID,
|
||||
device_id: uuid.UUID,
|
||||
data: DeviceUpdate,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> DeviceResponse:
|
||||
"""Update device fields. Requires operator role or above."""
|
||||
await _check_tenant_access(current_user, tenant_id, db)
|
||||
result = await device_service.update_device(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
device_id=device_id,
|
||||
data=data,
|
||||
encryption_key=settings.get_encryption_key_bytes(),
|
||||
)
|
||||
try:
|
||||
await log_action(
|
||||
db, tenant_id, current_user.user_id, "device_update",
|
||||
resource_type="device", resource_id=str(device_id),
|
||||
device_id=device_id,
|
||||
details={"changes": data.model_dump(exclude_unset=True)},
|
||||
ip_address=request.client.host if request.client else None,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/tenants/{tenant_id}/devices/{device_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
summary="Delete a device",
|
||||
dependencies=[Depends(require_tenant_admin_or_above), require_scope("devices:write")],
|
||||
)
|
||||
@limiter.limit("5/minute")
|
||||
async def delete_device(
|
||||
request: Request,
|
||||
tenant_id: uuid.UUID,
|
||||
device_id: uuid.UUID,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> None:
|
||||
"""Hard-delete a device. Requires tenant_admin or above."""
|
||||
await _check_tenant_access(current_user, tenant_id, db)
|
||||
try:
|
||||
await log_action(
|
||||
db, tenant_id, current_user.user_id, "device_delete",
|
||||
resource_type="device", resource_id=str(device_id),
|
||||
device_id=device_id,
|
||||
ip_address=request.client.host if request.client else None,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
await device_service.delete_device(db=db, tenant_id=tenant_id, device_id=device_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Subnet scan and bulk add
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post(
|
||||
"/tenants/{tenant_id}/devices/scan",
|
||||
response_model=SubnetScanResponse,
|
||||
summary="Scan a subnet for MikroTik devices",
|
||||
dependencies=[Depends(require_operator_or_above), require_scope("devices:write")],
|
||||
)
|
||||
@limiter.limit("5/minute")
|
||||
async def scan_devices(
|
||||
request: Request,
|
||||
tenant_id: uuid.UUID,
|
||||
data: SubnetScanRequest,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> SubnetScanResponse:
|
||||
"""
|
||||
Scan a CIDR subnet for hosts with open RouterOS API ports (8728/8729).
|
||||
|
||||
Returns a list of discovered IPs for the user to review and selectively
|
||||
import — does NOT automatically add devices.
|
||||
|
||||
Requires operator role or above.
|
||||
"""
|
||||
if not current_user.is_super_admin and current_user.tenant_id != tenant_id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
|
||||
|
||||
discovered = await scan_subnet(data.cidr)
|
||||
import ipaddress
|
||||
network = ipaddress.ip_network(data.cidr, strict=False)
|
||||
total_scanned = network.num_addresses - 2 if network.num_addresses > 2 else network.num_addresses
|
||||
|
||||
# Audit log the scan (fire-and-forget — never breaks the response)
|
||||
try:
|
||||
await log_action(
|
||||
db, tenant_id, current_user.user_id, "subnet_scan",
|
||||
resource_type="network", resource_id=data.cidr,
|
||||
details={
|
||||
"cidr": data.cidr,
|
||||
"devices_found": len(discovered),
|
||||
"ip": request.client.host if request.client else None,
|
||||
},
|
||||
ip_address=request.client.host if request.client else None,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return SubnetScanResponse(
|
||||
cidr=data.cidr,
|
||||
discovered=discovered,
|
||||
total_scanned=total_scanned,
|
||||
total_discovered=len(discovered),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/tenants/{tenant_id}/devices/bulk-add",
|
||||
response_model=BulkAddResult,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Bulk-add devices from scan results",
|
||||
dependencies=[Depends(require_operator_or_above), require_scope("devices:write")],
|
||||
)
|
||||
@limiter.limit("5/minute")
|
||||
async def bulk_add_devices(
|
||||
request: Request,
|
||||
tenant_id: uuid.UUID,
|
||||
data: BulkAddRequest,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> BulkAddResult:
|
||||
"""
|
||||
Add multiple devices at once from scan results.
|
||||
|
||||
Per-device credentials take precedence over shared credentials.
|
||||
Devices that fail connectivity checks or validation are reported in `failed`.
|
||||
Requires operator role or above.
|
||||
"""
|
||||
await _check_tenant_access(current_user, tenant_id, db)
|
||||
|
||||
added = []
|
||||
failed = []
|
||||
encryption_key = settings.get_encryption_key_bytes()
|
||||
|
||||
for dev_data in data.devices:
|
||||
# Resolve credentials: per-device first, then shared
|
||||
username = dev_data.username or data.shared_username
|
||||
password = dev_data.password or data.shared_password
|
||||
|
||||
if not username or not password:
|
||||
failed.append({
|
||||
"ip_address": dev_data.ip_address,
|
||||
"error": "No credentials provided (set per-device or shared credentials)",
|
||||
})
|
||||
continue
|
||||
|
||||
create_data = DeviceCreate(
|
||||
hostname=dev_data.hostname or dev_data.ip_address,
|
||||
ip_address=dev_data.ip_address,
|
||||
api_port=dev_data.api_port,
|
||||
api_ssl_port=dev_data.api_ssl_port,
|
||||
username=username,
|
||||
password=password,
|
||||
)
|
||||
|
||||
try:
|
||||
device = await device_service.create_device(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
data=create_data,
|
||||
encryption_key=encryption_key,
|
||||
)
|
||||
added.append(device)
|
||||
try:
|
||||
await log_action(
|
||||
db, tenant_id, current_user.user_id, "device_adopt",
|
||||
resource_type="device", resource_id=str(device.id),
|
||||
details={"hostname": create_data.hostname, "ip_address": create_data.ip_address},
|
||||
ip_address=request.client.host if request.client else None,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
except HTTPException as exc:
|
||||
failed.append({"ip_address": dev_data.ip_address, "error": exc.detail})
|
||||
except Exception as exc:
|
||||
failed.append({"ip_address": dev_data.ip_address, "error": str(exc)})
|
||||
|
||||
return BulkAddResult(added=added, failed=failed)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Group assignment
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post(
|
||||
"/tenants/{tenant_id}/devices/{device_id}/groups/{group_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
summary="Add device to a group",
|
||||
dependencies=[Depends(require_operator_or_above), require_scope("devices:write")],
|
||||
)
|
||||
@limiter.limit("20/minute")
|
||||
async def add_device_to_group(
|
||||
request: Request,
|
||||
tenant_id: uuid.UUID,
|
||||
device_id: uuid.UUID,
|
||||
group_id: uuid.UUID,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> None:
|
||||
"""Assign a device to a group. Requires operator or above."""
|
||||
await _check_tenant_access(current_user, tenant_id, db)
|
||||
await device_service.assign_device_to_group(db, tenant_id, device_id, group_id)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/tenants/{tenant_id}/devices/{device_id}/groups/{group_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
summary="Remove device from a group",
|
||||
dependencies=[Depends(require_operator_or_above), require_scope("devices:write")],
|
||||
)
|
||||
@limiter.limit("5/minute")
|
||||
async def remove_device_from_group(
|
||||
request: Request,
|
||||
tenant_id: uuid.UUID,
|
||||
device_id: uuid.UUID,
|
||||
group_id: uuid.UUID,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> None:
|
||||
"""Remove a device from a group. Requires operator or above."""
|
||||
await _check_tenant_access(current_user, tenant_id, db)
|
||||
await device_service.remove_device_from_group(db, tenant_id, device_id, group_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tag assignment
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post(
|
||||
"/tenants/{tenant_id}/devices/{device_id}/tags/{tag_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
summary="Add tag to a device",
|
||||
dependencies=[Depends(require_operator_or_above), require_scope("devices:write")],
|
||||
)
|
||||
@limiter.limit("20/minute")
|
||||
async def add_tag_to_device(
|
||||
request: Request,
|
||||
tenant_id: uuid.UUID,
|
||||
device_id: uuid.UUID,
|
||||
tag_id: uuid.UUID,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> None:
|
||||
"""Assign a tag to a device. Requires operator or above."""
|
||||
await _check_tenant_access(current_user, tenant_id, db)
|
||||
await device_service.assign_tag_to_device(db, tenant_id, device_id, tag_id)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/tenants/{tenant_id}/devices/{device_id}/tags/{tag_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
summary="Remove tag from a device",
|
||||
dependencies=[Depends(require_operator_or_above), require_scope("devices:write")],
|
||||
)
|
||||
@limiter.limit("5/minute")
|
||||
async def remove_tag_from_device(
|
||||
request: Request,
|
||||
tenant_id: uuid.UUID,
|
||||
device_id: uuid.UUID,
|
||||
tag_id: uuid.UUID,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> None:
|
||||
"""Remove a tag from a device. Requires operator or above."""
|
||||
await _check_tenant_access(current_user, tenant_id, db)
|
||||
await device_service.remove_tag_from_device(db, tenant_id, device_id, tag_id)
|
||||
Reference in New Issue
Block a user