187 lines
5.5 KiB
Python
187 lines
5.5 KiB
Python
"""Device management service."""
|
|
|
|
import logging
|
|
import re
|
|
|
|
from gatehouse_app.extensions import db
|
|
from gatehouse_app.models import Device
|
|
from gatehouse_app.models.user import User
|
|
from gatehouse_app.services.audit_service import AuditService
|
|
from gatehouse_app.exceptions import (
|
|
DeviceNotFoundError,
|
|
DeviceAlreadyExistsError,
|
|
InvalidNodeIdError,
|
|
ValidationError,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_NODE_ID_RE = re.compile(r"^[0-9a-fA-F]{10}$")
|
|
|
|
|
|
def _validate_node_id(node_id: str) -> str:
|
|
node_id = node_id.strip().lower()
|
|
if not _NODE_ID_RE.match(node_id):
|
|
raise InvalidNodeIdError(
|
|
f"Invalid ZeroTier node ID '{node_id}' — must be exactly 10 hex characters."
|
|
)
|
|
return node_id
|
|
|
|
|
|
def register_device(
|
|
user_id: str,
|
|
organization_id: str,
|
|
node_id: str,
|
|
nickname: str | None = None,
|
|
hostname: str | None = None,
|
|
asset_tag: str | None = None,
|
|
serial_number: str | None = None,
|
|
) -> Device:
|
|
"""Register a new device for a user in an organization.
|
|
|
|
Args:
|
|
user_id: ID of the owning user
|
|
organization_id: ID of the organization
|
|
node_id: 10-char hex ZeroTier node ID
|
|
nickname: User-assigned friendly name
|
|
hostname: Device hostname
|
|
asset_tag: Corporate asset tag
|
|
serial_number: Device serial number
|
|
|
|
Returns:
|
|
The created Device record
|
|
"""
|
|
node_id = _validate_node_id(node_id)
|
|
|
|
existing = Device.query.filter(
|
|
Device.node_id == node_id,
|
|
Device.deleted_at.is_(None),
|
|
).first()
|
|
if existing:
|
|
raise DeviceAlreadyExistsError(
|
|
f"A device with node ID {node_id} is already registered."
|
|
)
|
|
|
|
device = Device(
|
|
user_id=user_id,
|
|
organization_id=organization_id,
|
|
node_id=node_id,
|
|
device_nickname=nickname,
|
|
hostname=hostname,
|
|
asset_tag=asset_tag,
|
|
serial_number=serial_number,
|
|
)
|
|
device.save()
|
|
|
|
AuditService.log_action(
|
|
action="device.registered",
|
|
user_id=user_id,
|
|
organization_id=organization_id,
|
|
resource_type="device",
|
|
resource_id=device.id,
|
|
metadata={"node_id": node_id, "nickname": nickname},
|
|
description=f"Device {node_id} registered",
|
|
success=True,
|
|
)
|
|
|
|
return device
|
|
|
|
|
|
def list_user_devices(user_id: str, organization_id: str) -> list[Device]:
|
|
"""List all active devices for a user in an organization."""
|
|
return Device.query.filter(
|
|
Device.user_id == user_id,
|
|
Device.organization_id == organization_id,
|
|
Device.deleted_at.is_(None),
|
|
).all()
|
|
|
|
|
|
def get_device(device_id: str, organization_id: str | None = None) -> Device:
|
|
"""Fetch a device by ID, optionally scoped to an organization."""
|
|
q = Device.query.filter(
|
|
Device.id == device_id,
|
|
Device.deleted_at.is_(None),
|
|
)
|
|
if organization_id:
|
|
q = q.filter(Device.organization_id == organization_id)
|
|
|
|
device = q.first()
|
|
if not device:
|
|
raise DeviceNotFoundError(f"Device {device_id} not found.")
|
|
return device
|
|
|
|
|
|
def get_device_by_node_id(node_id: str, organization_id: str | None = None) -> Device | None:
|
|
"""Find a device by its ZeroTier node ID, optionally scoped to org."""
|
|
node_id = _validate_node_id(node_id)
|
|
q = Device.query.filter(
|
|
Device.node_id == node_id,
|
|
Device.deleted_at.is_(None),
|
|
)
|
|
if organization_id:
|
|
q = q.filter(Device.organization_id == organization_id)
|
|
return q.first()
|
|
|
|
|
|
def update_device(
|
|
device_id: str,
|
|
user_id: str,
|
|
**kwargs,
|
|
) -> Device:
|
|
"""Update device fields. Only allows nickname, hostname, asset_tag."""
|
|
device = get_device(device_id)
|
|
|
|
if device.user_id != user_id:
|
|
raise DeviceNotFoundError("Device not found.")
|
|
|
|
allowed = {"device_nickname", "hostname", "asset_tag", "serial_number"}
|
|
for key in kwargs:
|
|
if key not in allowed:
|
|
raise ValidationError(f"Cannot update field: {key}")
|
|
|
|
device.update(**kwargs)
|
|
|
|
AuditService.log_action(
|
|
action="device.updated",
|
|
user_id=user_id,
|
|
organization_id=device.organization_id,
|
|
resource_type="device",
|
|
resource_id=device.id,
|
|
metadata=kwargs,
|
|
description=f"Device {device.node_id} updated",
|
|
success=True,
|
|
)
|
|
|
|
return device
|
|
|
|
|
|
def remove_device(device_id: str, user_id: str) -> None:
|
|
"""Soft-delete a device, deactivate its active memberships, and soft-delete all memberships.
|
|
|
|
Since memberships are device-centric (tied to a specific device/node_id), removing a device
|
|
means all its memberships are orphaned and must be cleaned up.
|
|
"""
|
|
device = get_device(device_id)
|
|
|
|
if device.user_id != user_id:
|
|
raise DeviceNotFoundError("Device not found.")
|
|
|
|
# Soft-delete all memberships (deactivates active ones first)
|
|
for request in device.network_access_requests:
|
|
if request.deleted_at is None:
|
|
from gatehouse_app.services.network_access_service import revoke_request_soft
|
|
revoke_request_soft(request.id, revoker_user_id=user_id)
|
|
|
|
device.delete(soft=True)
|
|
|
|
AuditService.log_action(
|
|
action="device.removed",
|
|
user_id=user_id,
|
|
organization_id=device.organization_id,
|
|
resource_type="device",
|
|
resource_id=device.id,
|
|
metadata={"node_id": device.node_id, "memberships_removed": len([m for m in device.network_access_requests if m.deleted_at is None])},
|
|
description=f"Device {device.node_id} removed",
|
|
success=True,
|
|
)
|