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) <noreply@anthropic.com>
This commit is contained in:
Jason Staack
2026-03-21 18:59:24 -05:00
parent 998fff7d01
commit a231b18d69
3 changed files with 219 additions and 0 deletions

View File

@@ -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(

View File

@@ -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
# ---------------------------------------------------------------------------

View File

@@ -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
# ---------------------------------------------------------------------------