From a231b18d69816351749bc0455c79dbe8add47a75 Mon Sep 17 00:00:00 2001 From: Jason Staack Date: Sat, 21 Mar 2026 18:59:24 -0500 Subject: [PATCH] feat(17-03): bulk add endpoint and service with credential profile support - POST /tenants/{tenant_id}/devices/bulk endpoint with rate limiting - bulk_add_with_profile service validates profile ownership and type compatibility - Duplicate IP check prevents adding same IP twice in one tenant - TCP reachability check for RouterOS devices, skipped for SNMP (UDP) - Per-device result reporting with partial success support - Device model updated with device_type, snmp_port, snmp_version, snmp_profile_id columns - Audit logging for bulk add operations Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/models/device.py | 13 +++ backend/app/routers/devices.py | 50 +++++++++++ backend/app/services/device.py | 156 +++++++++++++++++++++++++++++++++ 3 files changed, 219 insertions(+) diff --git a/backend/app/models/device.py b/backend/app/models/device.py index 3549a49..cc5f06b 100644 --- a/backend/app/models/device.py +++ b/backend/app/models/device.py @@ -93,6 +93,19 @@ class Device(Base): nullable=False, ) + # SNMP / device-type columns (migration 039) + device_type: Mapped[str] = mapped_column( + Text, default="routeros", server_default="routeros", nullable=False + ) + snmp_port: Mapped[int | None] = mapped_column(Integer, default=161, nullable=True) + snmp_version: Mapped[str | None] = mapped_column(Text, nullable=True) + snmp_profile_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("snmp_profiles.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) + # Relationships tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="devices") # type: ignore[name-defined] group_memberships: Mapped[list["DeviceGroupMembership"]] = relationship( diff --git a/backend/app/routers/devices.py b/backend/app/routers/devices.py index e4531cd..741a417 100644 --- a/backend/app/routers/devices.py +++ b/backend/app/routers/devices.py @@ -31,6 +31,8 @@ from app.middleware.tenant_context import CurrentUser, get_current_user from app.schemas.device import ( BulkAddRequest, BulkAddResult, + BulkAddWithProfileRequest, + BulkAddWithProfileResult, DeviceCreate, DeviceListResponse, DeviceResponse, @@ -394,6 +396,54 @@ async def bulk_add_devices( return BulkAddResult(added=added, failed=failed) +@router.post( + "/tenants/{tenant_id}/devices/bulk", + response_model=BulkAddWithProfileResult, + summary="Bulk add devices using a credential profile", + dependencies=[Depends(require_operator_or_above), require_scope("devices:write")], +) +@limiter.limit("5/minute") +async def bulk_add_with_profile( + request: Request, + tenant_id: uuid.UUID, + data: BulkAddWithProfileRequest, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> BulkAddWithProfileResult: + """Add multiple devices using a credential profile. + + Supports partial success -- individual devices can fail without blocking others. + Returns per-device results with success/failure reasons. + """ + await _check_tenant_access(current_user, tenant_id, db) + result = await device_service.bulk_add_with_profile( + db=db, + tenant_id=tenant_id, + data=data, + user_id=current_user.user_id, + ) + # Audit log the bulk add (fire-and-forget) + try: + await log_action( + db, + tenant_id, + current_user.user_id, + "device_bulk_add", + resource_type="device", + details={ + "total": result.total, + "succeeded": result.succeeded, + "failed": result.failed, + "device_type": data.device_type, + "credential_profile_id": str(data.credential_profile_id), + }, + ip_address=request.client.host if request.client else None, + ) + except Exception: + pass + return result + + # --------------------------------------------------------------------------- # Group assignment # --------------------------------------------------------------------------- diff --git a/backend/app/services/device.py b/backend/app/services/device.py index d5d7cb3..1a67f02 100644 --- a/backend/app/services/device.py +++ b/backend/app/services/device.py @@ -29,6 +29,10 @@ from app.models.device import ( DeviceTagAssignment, ) from app.schemas.device import ( + BulkAddDefaults, + BulkAddDeviceResult, + BulkAddWithProfileRequest, + BulkAddWithProfileResult, DeviceCreate, DeviceGroupCreate, DeviceGroupResponse, @@ -425,6 +429,158 @@ async def delete_device( await db.flush() +# --------------------------------------------------------------------------- +# Bulk add with credential profile +# --------------------------------------------------------------------------- + + +async def bulk_add_with_profile( + db: AsyncSession, + tenant_id: uuid.UUID, + data: BulkAddWithProfileRequest, + user_id: uuid.UUID, +) -> BulkAddWithProfileResult: + """Add multiple devices using a credential profile. Partial success allowed.""" + from fastapi import HTTPException, status + from sqlalchemy import text + + from app.models.credential_profile import CredentialProfile + + # 1. Validate credential profile exists and belongs to tenant + profile_row = await db.execute( + select(CredentialProfile).where( + CredentialProfile.id == data.credential_profile_id, + CredentialProfile.tenant_id == tenant_id, + ) + ) + profile = profile_row.scalar_one_or_none() + if not profile: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Credential profile not found or does not belong to this tenant", + ) + + # 2. Validate credential_type matches device_type + valid_types_for_device = { + "routeros": ["routeros"], + "snmp": ["snmp_v1", "snmp_v2c", "snmp_v3"], + } + if profile.credential_type not in valid_types_for_device.get(data.device_type, []): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=( + f"Credential profile type '{profile.credential_type}' " + f"is not compatible with device_type '{data.device_type}'" + ), + ) + + # 3. If SNMP and snmp_profile_id provided in defaults, validate it exists + snmp_profile_id = None + if data.device_type == "snmp" and data.defaults and data.defaults.snmp_profile_id: + snmp_check = await db.execute( + text( + "SELECT id FROM snmp_profiles" + " WHERE id = :id AND (tenant_id = :tenant_id OR tenant_id IS NULL)" + ), + {"id": str(data.defaults.snmp_profile_id), "tenant_id": str(tenant_id)}, + ) + if not snmp_check.scalar_one_or_none(): + raise HTTPException(status_code=404, detail="SNMP profile not found") + snmp_profile_id = data.defaults.snmp_profile_id + + # 4. Process each device + results: list[BulkAddDeviceResult] = [] + defaults = data.defaults or BulkAddDefaults() + + for entry in data.devices: + try: + hostname = entry.hostname or entry.ip_address + + # Check for duplicate IP in tenant + dup_check = await db.execute( + text("SELECT id FROM devices WHERE ip_address = :ip AND tenant_id = :tid"), + {"ip": entry.ip_address, "tid": str(tenant_id)}, + ) + if dup_check.scalar_one_or_none(): + results.append( + BulkAddDeviceResult( + ip_address=entry.ip_address, + hostname=hostname, + success=False, + error="Device with this IP already exists", + ) + ) + continue + + # TCP reachability check for RouterOS devices + if data.device_type == "routeros": + reachable = await _tcp_reachable(entry.ip_address, defaults.api_ssl_port) + if not reachable: + reachable = await _tcp_reachable(entry.ip_address, defaults.api_port) + if not reachable: + results.append( + BulkAddDeviceResult( + ip_address=entry.ip_address, + hostname=hostname, + success=False, + error=( + f"Device unreachable on ports " + f"{defaults.api_port}/{defaults.api_ssl_port}" + ), + ) + ) + continue + + # Create device with credential profile reference + device = Device( + tenant_id=tenant_id, + hostname=hostname, + ip_address=entry.ip_address, + device_type=data.device_type, + credential_profile_id=data.credential_profile_id, + # RouterOS fields + api_port=defaults.api_port if data.device_type == "routeros" else 8728, + api_ssl_port=defaults.api_ssl_port if data.device_type == "routeros" else 8729, + tls_mode=defaults.tls_mode if data.device_type == "routeros" else "auto", + # SNMP fields + snmp_port=defaults.snmp_port if data.device_type == "snmp" else 161, + snmp_version=defaults.snmp_version if data.device_type == "snmp" else None, + snmp_profile_id=snmp_profile_id, + status="unknown", + ) + db.add(device) + await db.flush() + + results.append( + BulkAddDeviceResult( + ip_address=entry.ip_address, + hostname=hostname, + success=True, + device_id=device.id, + ) + ) + + except Exception as exc: + results.append( + BulkAddDeviceResult( + ip_address=entry.ip_address, + hostname=entry.hostname or entry.ip_address, + success=False, + error=str(exc), + ) + ) + + await db.commit() + + succeeded = sum(1 for r in results if r.success) + return BulkAddWithProfileResult( + total=len(results), + succeeded=succeeded, + failed=len(results) - succeeded, + results=results, + ) + + # --------------------------------------------------------------------------- # Group / Tag assignment # ---------------------------------------------------------------------------