From eb3ea0def36f2e2a0bd7d67f5c368330d01d3ecd Mon Sep 17 00:00:00 2001 From: Jason Staack Date: Sat, 21 Mar 2026 18:52:58 -0500 Subject: [PATCH] feat(17-02): SNMP profile CRUD API and SNMP metrics query endpoint - Add Pydantic schemas for SNMP profile CRUD (list excludes profile_data JSONB) - Add 5-route SNMP profiles router with system profile protection (403) - Add device deletion protection for referenced profiles (409) - Add time-bucketed SNMP metrics query endpoint with metric_name/group filters - Add distinct metric names endpoint for frontend dropdowns - Register snmp_profiles_router in main.py Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/main.py | 2 + backend/app/routers/metrics.py | 98 +++++++++ backend/app/routers/snmp_profiles.py | 305 +++++++++++++++++++++++++++ backend/app/schemas/snmp_profile.py | 93 ++++++++ 4 files changed, 498 insertions(+) create mode 100644 backend/app/routers/snmp_profiles.py create mode 100644 backend/app/schemas/snmp_profile.py diff --git a/backend/app/main.py b/backend/app/main.py index c9c0ebf..94566df 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -462,6 +462,7 @@ def create_app() -> FastAPI: from app.routers.settings import router as settings_router from app.routers.remote_access import router as remote_access_router from app.routers.winbox_remote import router as winbox_remote_router + from app.routers.snmp_profiles import router as snmp_profiles_router from app.routers.sites import router as sites_router from app.routers.links import router as links_router from app.routers.sectors import router as sectors_router @@ -496,6 +497,7 @@ def create_app() -> FastAPI: app.include_router(settings_router, prefix="/api") app.include_router(remote_access_router, prefix="/api") app.include_router(winbox_remote_router, prefix="/api") + app.include_router(snmp_profiles_router, prefix="/api") app.include_router(sites_router, prefix="/api") app.include_router(links_router, prefix="/api") app.include_router(sectors_router, prefix="/api") diff --git a/backend/app/routers/metrics.py b/backend/app/routers/metrics.py index 6640876..f9e8073 100644 --- a/backend/app/routers/metrics.py +++ b/backend/app/routers/metrics.py @@ -312,6 +312,104 @@ async def device_wireless_latest( return [dict(row) for row in rows] +# --------------------------------------------------------------------------- +# SNMP custom metrics +# --------------------------------------------------------------------------- + + +@router.get( + "/tenants/{tenant_id}/devices/{device_id}/metrics/snmp", + summary="Time-bucketed custom SNMP metrics", +) +async def device_snmp_metrics( + tenant_id: uuid.UUID, + device_id: uuid.UUID, + start: datetime = Query(..., description="Start of time range (ISO format)"), + end: datetime = Query(..., description="End of time range (ISO format)"), + metric_name: Optional[str] = Query(None, description="Filter to specific metric name"), + metric_group: Optional[str] = Query(None, description="Filter to specific metric group"), + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> list[dict[str, Any]]: + """Return time-bucketed custom SNMP metrics for a device. + + Supports optional filtering by metric_name and metric_group. + Numeric values are aggregated (avg, min, max); text values return + the most recent value per bucket. + """ + await _check_tenant_access(current_user, tenant_id, db) + bucket = _bucket_for_range(start, end) + + # Build optional filters + filters = "" + if metric_name: + filters += " AND metric_name = :metric_name" + if metric_group: + filters += " AND metric_group = :metric_group" + + sql = f""" + SELECT + time_bucket(:bucket, time) AS bucket, + metric_name, + metric_group, + oid, + index_value, + avg(value_numeric) AS avg_value, + max(value_numeric) AS max_value, + min(value_numeric) AS min_value, + (array_agg(value_text ORDER BY time DESC))[1] AS last_text_value + FROM snmp_metrics + WHERE device_id = :device_id + AND time >= :start AND time < :end + {filters} + GROUP BY bucket, metric_name, metric_group, oid, index_value + ORDER BY metric_name, index_value, bucket ASC + """ + + params: dict[str, Any] = { + "bucket": bucket, + "device_id": str(device_id), + "start": start, + "end": end, + } + if metric_name: + params["metric_name"] = metric_name + if metric_group: + params["metric_group"] = metric_group + + result = await db.execute(text(sql), params) + rows = result.mappings().all() + return [dict(row) for row in rows] + + +@router.get( + "/tenants/{tenant_id}/devices/{device_id}/metrics/snmp/names", + summary="List distinct SNMP metric names for a device", +) +async def device_snmp_metric_names( + tenant_id: uuid.UUID, + device_id: uuid.UUID, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> list[dict[str, str]]: + """Return distinct (metric_name, metric_group) pairs for a device. + + Used by the frontend to populate metric selection dropdowns. + """ + await _check_tenant_access(current_user, tenant_id, db) + result = await db.execute( + text(""" + SELECT DISTINCT metric_name, metric_group + FROM snmp_metrics + WHERE device_id = :device_id + ORDER BY metric_group, metric_name + """), + {"device_id": str(device_id)}, + ) + rows = result.mappings().all() + return [dict(row) for row in rows] + + # --------------------------------------------------------------------------- # Sparkline # --------------------------------------------------------------------------- diff --git a/backend/app/routers/snmp_profiles.py b/backend/app/routers/snmp_profiles.py new file mode 100644 index 0000000..b81ed77 --- /dev/null +++ b/backend/app/routers/snmp_profiles.py @@ -0,0 +1,305 @@ +""" +SNMP Profile CRUD API endpoints. + +Routes: /api/tenants/{tenant_id}/snmp-profiles + +Provides listing, creation, update, and deletion of SNMP device profiles. +System-shipped profiles (is_system=True, tenant_id IS NULL) are visible to +all tenants but cannot be modified or deleted. + +RBAC: +- devices:read scope: GET (list, detail) +- operator+: POST, PUT (create, update tenant profiles) +- tenant_admin+: DELETE (delete tenant profiles) +""" + +import uuid +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.middleware.rbac import require_operator_or_above, require_scope, require_tenant_admin_or_above +from app.middleware.tenant_context import CurrentUser, get_current_user +from app.routers.devices import _check_tenant_access +from app.schemas.snmp_profile import ( + SNMPProfileCreate, + SNMPProfileDetailResponse, + SNMPProfileListResponse, + SNMPProfileResponse, + SNMPProfileUpdate, +) + +router = APIRouter(tags=["snmp-profiles"]) + + +# --------------------------------------------------------------------------- +# List profiles (system + tenant) +# --------------------------------------------------------------------------- + + +@router.get( + "/tenants/{tenant_id}/snmp-profiles", + response_model=SNMPProfileListResponse, + summary="List SNMP profiles (system + tenant)", + dependencies=[require_scope("devices:read")], +) +async def list_profiles( + tenant_id: uuid.UUID, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> SNMPProfileListResponse: + """List all SNMP profiles visible to a tenant. + + Returns both system-shipped profiles (tenant_id IS NULL) and + tenant-specific custom profiles. System profiles appear first. + """ + await _check_tenant_access(current_user, tenant_id, db) + + result = await db.execute( + text(""" + SELECT id, tenant_id, name, description, sys_object_id, vendor, + category, is_system, created_at, updated_at + FROM snmp_profiles + WHERE tenant_id = :tenant_id OR tenant_id IS NULL + ORDER BY is_system DESC, name ASC + """), + {"tenant_id": str(tenant_id)}, + ) + rows = result.mappings().all() + return SNMPProfileListResponse(profiles=[dict(row) for row in rows]) + + +# --------------------------------------------------------------------------- +# Get profile detail (includes profile_data) +# --------------------------------------------------------------------------- + + +@router.get( + "/tenants/{tenant_id}/snmp-profiles/{profile_id}", + response_model=SNMPProfileDetailResponse, + summary="Get SNMP profile detail", + dependencies=[require_scope("devices:read")], +) +async def get_profile( + tenant_id: uuid.UUID, + profile_id: uuid.UUID, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> dict[str, Any]: + """Get a single SNMP profile with full profile_data JSONB.""" + await _check_tenant_access(current_user, tenant_id, db) + + result = await db.execute( + text(""" + SELECT id, tenant_id, name, description, sys_object_id, vendor, + category, profile_data, is_system, created_at, updated_at + FROM snmp_profiles + WHERE id = :profile_id + AND (tenant_id = :tenant_id OR tenant_id IS NULL) + """), + {"profile_id": str(profile_id), "tenant_id": str(tenant_id)}, + ) + row = result.mappings().first() + if not row: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="SNMP profile not found") + return dict(row) + + +# --------------------------------------------------------------------------- +# Create profile +# --------------------------------------------------------------------------- + + +@router.post( + "/tenants/{tenant_id}/snmp-profiles", + response_model=SNMPProfileResponse, + status_code=status.HTTP_201_CREATED, + summary="Create a tenant SNMP profile", + dependencies=[Depends(require_operator_or_above)], +) +async def create_profile( + tenant_id: uuid.UUID, + data: SNMPProfileCreate, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> dict[str, Any]: + """Create a new tenant-scoped SNMP profile (is_system=False).""" + await _check_tenant_access(current_user, tenant_id, db) + + import json + + result = await db.execute( + text(""" + INSERT INTO snmp_profiles + (tenant_id, name, description, sys_object_id, vendor, + category, profile_data, is_system) + VALUES + (:tenant_id, :name, :description, :sys_object_id, :vendor, + :category, :profile_data::jsonb, FALSE) + RETURNING id, tenant_id, name, description, sys_object_id, vendor, + category, is_system, created_at, updated_at + """), + { + "tenant_id": str(tenant_id), + "name": data.name, + "description": data.description, + "sys_object_id": data.sys_object_id, + "vendor": data.vendor, + "category": data.category, + "profile_data": json.dumps(data.profile_data), + }, + ) + await db.commit() + row = result.mappings().first() + return dict(row) + + +# --------------------------------------------------------------------------- +# Update profile +# --------------------------------------------------------------------------- + + +@router.put( + "/tenants/{tenant_id}/snmp-profiles/{profile_id}", + response_model=SNMPProfileResponse, + summary="Update a tenant SNMP profile", + dependencies=[Depends(require_operator_or_above)], +) +async def update_profile( + tenant_id: uuid.UUID, + profile_id: uuid.UUID, + data: SNMPProfileUpdate, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> dict[str, Any]: + """Update an existing tenant-scoped SNMP profile. + + System profiles (is_system=True) cannot be modified -- returns 403. + """ + await _check_tenant_access(current_user, tenant_id, db) + + # Verify profile exists and is tenant-owned + existing = await db.execute( + text(""" + SELECT id, is_system + FROM snmp_profiles + WHERE id = :profile_id + AND (tenant_id = :tenant_id OR tenant_id IS NULL) + """), + {"profile_id": str(profile_id), "tenant_id": str(tenant_id)}, + ) + row = existing.mappings().first() + if not row: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="SNMP profile not found") + if row["is_system"]: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="System profiles cannot be modified", + ) + + # Build dynamic SET clause from provided fields + import json + + updates = {} + set_clauses = [] + fields = data.model_dump(exclude_unset=True) + + for field, value in fields.items(): + if field == "profile_data" and value is not None: + set_clauses.append(f"{field} = :{field}::jsonb") + updates[field] = json.dumps(value) + else: + set_clauses.append(f"{field} = :{field}") + updates[field] = value + + if not set_clauses: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="No fields to update", + ) + + set_clauses.append("updated_at = NOW()") + updates["profile_id"] = str(profile_id) + updates["tenant_id"] = str(tenant_id) + + sql = f""" + UPDATE snmp_profiles + SET {', '.join(set_clauses)} + WHERE id = :profile_id AND tenant_id = :tenant_id + RETURNING id, tenant_id, name, description, sys_object_id, vendor, + category, is_system, created_at, updated_at + """ + + result = await db.execute(text(sql), updates) + await db.commit() + row = result.mappings().first() + return dict(row) + + +# --------------------------------------------------------------------------- +# Delete profile +# --------------------------------------------------------------------------- + + +@router.delete( + "/tenants/{tenant_id}/snmp-profiles/{profile_id}", + status_code=status.HTTP_204_NO_CONTENT, + summary="Delete a tenant SNMP profile", + dependencies=[Depends(require_tenant_admin_or_above)], +) +async def delete_profile( + tenant_id: uuid.UUID, + profile_id: uuid.UUID, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> None: + """Delete a tenant-scoped SNMP profile. + + System profiles (is_system=True) cannot be deleted -- returns 403. + Profiles referenced by devices cannot be deleted -- returns 409. + """ + await _check_tenant_access(current_user, tenant_id, db) + + # Verify profile exists and is tenant-owned + existing = await db.execute( + text(""" + SELECT id, is_system + FROM snmp_profiles + WHERE id = :profile_id + AND (tenant_id = :tenant_id OR tenant_id IS NULL) + """), + {"profile_id": str(profile_id), "tenant_id": str(tenant_id)}, + ) + row = existing.mappings().first() + if not row: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="SNMP profile not found") + if row["is_system"]: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="System profiles cannot be deleted", + ) + + # Check if any devices reference this profile + ref_check = await db.execute( + text(""" + SELECT COUNT(*) AS cnt + FROM devices + WHERE snmp_profile_id = :profile_id + """), + {"profile_id": str(profile_id)}, + ) + count = ref_check.scalar() + if count and count > 0: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Cannot delete profile: {count} device(s) still reference it", + ) + + await db.execute( + text("DELETE FROM snmp_profiles WHERE id = :profile_id AND tenant_id = :tenant_id"), + {"profile_id": str(profile_id), "tenant_id": str(tenant_id)}, + ) + await db.commit() diff --git a/backend/app/schemas/snmp_profile.py b/backend/app/schemas/snmp_profile.py new file mode 100644 index 0000000..7abb2e7 --- /dev/null +++ b/backend/app/schemas/snmp_profile.py @@ -0,0 +1,93 @@ +"""Pydantic schemas for SNMP Profile CRUD endpoints.""" + +import uuid +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, field_validator + + +VALID_CATEGORIES = {"switch", "router", "access_point", "ups", "printer", "server", "generic"} + + +class SNMPProfileCreate(BaseModel): + """Schema for creating a tenant-scoped SNMP profile.""" + + name: str + description: Optional[str] = None + sys_object_id: Optional[str] = None + vendor: Optional[str] = None + category: Optional[str] = None + profile_data: dict + + @field_validator("name") + @classmethod + def validate_name(cls, v: str) -> str: + v = v.strip() + if len(v) < 1 or len(v) > 255: + raise ValueError("Profile name must be 1-255 characters") + return v + + @field_validator("category") + @classmethod + def validate_category(cls, v: Optional[str]) -> Optional[str]: + if v is not None and v not in VALID_CATEGORIES: + raise ValueError(f"Category must be one of: {', '.join(sorted(VALID_CATEGORIES))}") + return v + + +class SNMPProfileUpdate(BaseModel): + """Schema for updating an SNMP profile. All fields optional.""" + + name: Optional[str] = None + description: Optional[str] = None + sys_object_id: Optional[str] = None + vendor: Optional[str] = None + category: Optional[str] = None + profile_data: Optional[dict] = None + + @field_validator("name") + @classmethod + def validate_name(cls, v: Optional[str]) -> Optional[str]: + if v is None: + return v + v = v.strip() + if len(v) < 1 or len(v) > 255: + raise ValueError("Profile name must be 1-255 characters") + return v + + @field_validator("category") + @classmethod + def validate_category(cls, v: Optional[str]) -> Optional[str]: + if v is not None and v not in VALID_CATEGORIES: + raise ValueError(f"Category must be one of: {', '.join(sorted(VALID_CATEGORIES))}") + return v + + +class SNMPProfileResponse(BaseModel): + """SNMP profile response (list view -- excludes large profile_data JSONB).""" + + id: uuid.UUID + tenant_id: Optional[uuid.UUID] = None + name: str + description: Optional[str] = None + sys_object_id: Optional[str] = None + vendor: Optional[str] = None + category: Optional[str] = None + is_system: bool + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} + + +class SNMPProfileDetailResponse(SNMPProfileResponse): + """SNMP profile detail response (includes full profile_data).""" + + profile_data: dict + + +class SNMPProfileListResponse(BaseModel): + """List of SNMP profiles.""" + + profiles: list[SNMPProfileResponse]