feat(zerotier): add ZeroTier network governance module
Add comprehensive ZeroTier integration for managing network access: - Portal networks: manager-created ZeroTier network bindings - Device registration: user-owned ZeroTier node endpoints - Approval workflows: request/approve/revoke network access - Activation sessions: time-limited network authorization - Kill switch: emergency access revocation - Reconciliation job: sync portal state with ZeroTier controller Includes ZeroTier client SDK supporting both Central and self-hosted controller APIs, with full CRUD operations for networks and members.
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
"""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 membership in device.memberships:
|
||||
if membership.deleted_at is None:
|
||||
from gatehouse_app.services.network_access_service import revoke_membership_soft
|
||||
revoke_membership_soft(membership.id, revoked_by_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.memberships if m.deleted_at is None])},
|
||||
description=f"Device {device.node_id} removed",
|
||||
success=True,
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,237 @@
|
||||
"""Portal network management service."""
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
from gatehouse_app.extensions import db
|
||||
from gatehouse_app.models import PortalNetwork
|
||||
from gatehouse_app.models.organization import Organization
|
||||
from gatehouse_app.models.user import User
|
||||
from gatehouse_app.services.audit_service import AuditService
|
||||
from gatehouse_app.services import zerotier_api_service as zt
|
||||
from gatehouse_app.utils.constants import NetworkRequestMode
|
||||
from gatehouse_app.exceptions import (
|
||||
NetworkNotFoundError,
|
||||
InvalidNetworkIdError,
|
||||
ValidationError,
|
||||
ZeroTierNotFoundError,
|
||||
ZeroTierAPIError,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_NETWORK_ID_RE = re.compile(r"^[0-9a-fA-F]{16}$")
|
||||
|
||||
|
||||
def _validate_network_id(network_id: str) -> str:
|
||||
network_id = network_id.strip().lower()
|
||||
if not _NETWORK_ID_RE.match(network_id):
|
||||
raise InvalidNetworkIdError(
|
||||
f"Invalid ZeroTier network ID '{network_id}' — "
|
||||
"must be exactly 16 hex characters."
|
||||
)
|
||||
return network_id
|
||||
|
||||
|
||||
def create_network(
|
||||
organization_id: str,
|
||||
name: str,
|
||||
owner_user_id: str,
|
||||
zerotier_network_id: str,
|
||||
description: str | None = None,
|
||||
environment: str | None = None,
|
||||
request_mode: str = "approval_required",
|
||||
default_activation_lifetime_minutes: int = 480,
|
||||
max_activation_lifetime_minutes: int | None = None,
|
||||
) -> PortalNetwork:
|
||||
"""Create a new portal network.
|
||||
|
||||
Args:
|
||||
organization_id: Owning organization
|
||||
name: Human-readable name
|
||||
owner_user_id: Manager who owns this network
|
||||
zerotier_network_id: 16-char hex ZT network ID
|
||||
description: Optional description
|
||||
environment: production | staging | development | lab
|
||||
request_mode: open | approval_required | invite_only
|
||||
default_activation_lifetime_minutes: Default session length
|
||||
max_activation_lifetime_minutes: Cap on activation lifetime
|
||||
"""
|
||||
from gatehouse_app.utils.constants import NetworkEnvironment
|
||||
|
||||
zerotier_network_id = _validate_network_id(zerotier_network_id)
|
||||
|
||||
existing = PortalNetwork.query.filter(
|
||||
PortalNetwork.organization_id == organization_id,
|
||||
PortalNetwork.zerotier_network_id == zerotier_network_id,
|
||||
PortalNetwork.deleted_at.is_(None),
|
||||
).first()
|
||||
if existing:
|
||||
raise ValidationError(
|
||||
f"A portal network already exists for ZT network {zerotier_network_id} "
|
||||
f"in this organization."
|
||||
)
|
||||
|
||||
env = NetworkEnvironment(environment) if environment else NetworkEnvironment.DEVELOPMENT
|
||||
mode = NetworkRequestMode(request_mode)
|
||||
|
||||
network = PortalNetwork(
|
||||
organization_id=organization_id,
|
||||
name=name,
|
||||
description=description,
|
||||
owner_user_id=owner_user_id,
|
||||
zerotier_network_id=zerotier_network_id,
|
||||
environment=env,
|
||||
request_mode=mode,
|
||||
default_activation_lifetime_minutes=default_activation_lifetime_minutes,
|
||||
max_activation_lifetime_minutes=max_activation_lifetime_minutes,
|
||||
)
|
||||
network.save()
|
||||
|
||||
# Try to verify the network exists in ZeroTier
|
||||
try:
|
||||
zt_network = zt.get_network(zerotier_network_id)
|
||||
logger.info(
|
||||
f"[PortalNetwork] Verified ZT network {zerotier_network_id} "
|
||||
f"exists in ZeroTier: {zt_network.name}"
|
||||
)
|
||||
except ZeroTierNotFoundError:
|
||||
logger.warning(
|
||||
f"[PortalNetwork] ZT network {zerotier_network_id} not found "
|
||||
"in ZeroTier — will be reconciled later."
|
||||
)
|
||||
except ZeroTierAPIError as exc:
|
||||
logger.warning(
|
||||
f"[PortalNetwork] Could not verify ZT network {zerotier_network_id}: {exc}"
|
||||
)
|
||||
|
||||
AuditService.log_action(
|
||||
action="zt.network.created",
|
||||
user_id=owner_user_id,
|
||||
organization_id=organization_id,
|
||||
resource_type="portal_network",
|
||||
resource_id=network.id,
|
||||
metadata={
|
||||
"zerotier_network_id": zerotier_network_id,
|
||||
"name": name,
|
||||
"environment": env.value,
|
||||
"request_mode": mode.value,
|
||||
},
|
||||
description=f"Portal network '{name}' created (ZT: {zerotier_network_id})",
|
||||
success=True,
|
||||
)
|
||||
|
||||
return network
|
||||
|
||||
|
||||
def list_networks(
|
||||
organization_id: str,
|
||||
include_inactive: bool = False,
|
||||
) -> list[PortalNetwork]:
|
||||
"""List portal networks for an organization."""
|
||||
q = PortalNetwork.query.filter(
|
||||
PortalNetwork.organization_id == organization_id,
|
||||
PortalNetwork.deleted_at.is_(None),
|
||||
)
|
||||
if not include_inactive:
|
||||
q = q.filter(PortalNetwork.is_active.is_(True))
|
||||
return q.all()
|
||||
|
||||
|
||||
def get_network(network_id: str, organization_id: str | None = None) -> PortalNetwork:
|
||||
"""Fetch a portal network by ID."""
|
||||
q = PortalNetwork.query.filter(
|
||||
PortalNetwork.id == network_id,
|
||||
PortalNetwork.deleted_at.is_(None),
|
||||
)
|
||||
if organization_id:
|
||||
q = q.filter(PortalNetwork.organization_id == organization_id)
|
||||
|
||||
network = q.first()
|
||||
if not network:
|
||||
raise NetworkNotFoundError(f"Portal network {network_id} not found.")
|
||||
return network
|
||||
|
||||
|
||||
def update_network(
|
||||
network_id: str,
|
||||
user_id: str,
|
||||
**kwargs,
|
||||
) -> PortalNetwork:
|
||||
"""Update network metadata. Allowed: name, description, environment,
|
||||
request_mode, default_activation_lifetime_minutes, max_activation_lifetime_minutes, is_active."""
|
||||
network = get_network(network_id)
|
||||
|
||||
allowed = {
|
||||
"name",
|
||||
"description",
|
||||
"environment",
|
||||
"request_mode",
|
||||
"default_activation_lifetime_minutes",
|
||||
"max_activation_lifetime_minutes",
|
||||
"is_active",
|
||||
}
|
||||
for key in kwargs:
|
||||
if key not in allowed:
|
||||
raise ValidationError(f"Cannot update field: {key}")
|
||||
|
||||
network.update(**kwargs)
|
||||
|
||||
AuditService.log_action(
|
||||
action="zt.network.updated",
|
||||
user_id=user_id,
|
||||
organization_id=network.organization_id,
|
||||
resource_type="portal_network",
|
||||
resource_id=network.id,
|
||||
metadata=kwargs,
|
||||
description=f"Portal network '{network.name}' updated",
|
||||
success=True,
|
||||
)
|
||||
|
||||
return network
|
||||
|
||||
|
||||
def delete_network(network_id: str, user_id: str) -> None:
|
||||
"""Soft-delete a portal network and deactivate all memberships."""
|
||||
network = get_network(network_id)
|
||||
|
||||
# Deauthorize all active memberships in ZeroTier
|
||||
for membership in network.memberships:
|
||||
if membership.deleted_at is None and membership.state.value == "active_authorized":
|
||||
from gatehouse_app.services.network_access_service import deactivate_membership
|
||||
deactivate_membership(membership.id, reason="network_deleted")
|
||||
|
||||
network.delete(soft=True)
|
||||
|
||||
AuditService.log_action(
|
||||
action="zt.network.deleted",
|
||||
user_id=user_id,
|
||||
organization_id=network.organization_id,
|
||||
resource_type="portal_network",
|
||||
resource_id=network.id,
|
||||
metadata={"zerotier_network_id": network.zerotier_network_id, "name": network.name},
|
||||
description=f"Portal network '{network.name}' deleted",
|
||||
success=True,
|
||||
)
|
||||
|
||||
|
||||
def get_network_members(network_id: str) -> list:
|
||||
"""Return all DeviceNetworkMemberships for a network with user and device info."""
|
||||
from gatehouse_app.models import DeviceNetworkMembership
|
||||
|
||||
return DeviceNetworkMembership.query.filter(
|
||||
DeviceNetworkMembership.portal_network_id == network_id,
|
||||
DeviceNetworkMembership.deleted_at.is_(None),
|
||||
).all()
|
||||
|
||||
|
||||
def get_network_pending_requests(network_id: str) -> list:
|
||||
"""Return pending UserNetworkApprovals for a network."""
|
||||
from gatehouse_app.models import UserNetworkApproval
|
||||
from gatehouse_app.utils.constants import ApprovalState
|
||||
|
||||
return UserNetworkApproval.query.filter(
|
||||
UserNetworkApproval.portal_network_id == network_id,
|
||||
UserNetworkApproval.state == ApprovalState.PENDING,
|
||||
UserNetworkApproval.deleted_at.is_(None),
|
||||
).all()
|
||||
@@ -0,0 +1,116 @@
|
||||
"""ZeroTier API service — thin Flask adapter around the ZeroTierClient SDK.
|
||||
|
||||
Reads configuration from app config and translates SDK exceptions to
|
||||
Secuird typed exceptions.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from gatehouse_app.exceptions import ZeroTierAPIError
|
||||
from gatehouse_app.utils.zerotier_client import (
|
||||
APIMode,
|
||||
ZeroTierAPIError as SDKZeroTierAPIError,
|
||||
ZeroTierAuthError,
|
||||
ZeroTierClient,
|
||||
ZeroTierNotFoundError,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_client(app=None) -> ZeroTierClient:
|
||||
"""Build a ZeroTierClient from current app config."""
|
||||
from flask import current_app
|
||||
|
||||
app = app or current_app
|
||||
|
||||
mode_str = app.config.get("ZEROTIER_API_MODE", "controller")
|
||||
mode = APIMode.CENTRAL if mode_str == "central" else APIMode.CONTROLLER
|
||||
|
||||
return ZeroTierClient(
|
||||
api_token=app.config.get("ZEROTIER_API_TOKEN", ""),
|
||||
base_url=app.config.get("ZEROTIER_API_URL", "http://localhost:9993"),
|
||||
mode=mode,
|
||||
)
|
||||
|
||||
|
||||
def get_status() -> dict:
|
||||
"""Verify connectivity to the ZeroTier controller."""
|
||||
client = _get_client()
|
||||
try:
|
||||
return client.get_status()
|
||||
except SDKZeroTierAPIError as exc:
|
||||
raise ZeroTierAPIError(str(exc), status_code=exc.status_code) from exc
|
||||
|
||||
|
||||
def list_networks():
|
||||
"""List all networks accessible to the configured token."""
|
||||
client = _get_client()
|
||||
try:
|
||||
return client.list_networks()
|
||||
except SDKZeroTierAPIError as exc:
|
||||
raise ZeroTierAPIError(str(exc), status_code=exc.status_code) from exc
|
||||
|
||||
|
||||
def get_network(network_id: str):
|
||||
"""Fetch a single network by ID."""
|
||||
client = _get_client()
|
||||
try:
|
||||
return client.get_network(network_id)
|
||||
except SDKZeroTierAPIError as exc:
|
||||
raise ZeroTierAPIError(str(exc), status_code=exc.status_code) from exc
|
||||
|
||||
|
||||
def list_members(network_id: str):
|
||||
"""List all members on a network."""
|
||||
client = _get_client()
|
||||
try:
|
||||
return client.list_members(network_id)
|
||||
except SDKZeroTierAPIError as exc:
|
||||
raise ZeroTierAPIError(str(exc), status_code=exc.status_code) from exc
|
||||
|
||||
|
||||
def get_member(network_id: str, node_id: str):
|
||||
"""Fetch a single member on a network."""
|
||||
client = _get_client()
|
||||
try:
|
||||
return client.get_member(network_id, node_id)
|
||||
except SDKZeroTierAPIError as exc:
|
||||
raise ZeroTierAPIError(str(exc), status_code=exc.status_code) from exc
|
||||
|
||||
|
||||
def authorize_member(network_id: str, node_id: str):
|
||||
"""Authorize a member on a network. Returns updated member."""
|
||||
client = _get_client()
|
||||
try:
|
||||
return client.authorize_member(network_id, node_id)
|
||||
except SDKZeroTierAPIError as exc:
|
||||
raise ZeroTierAPIError(str(exc), status_code=exc.status_code) from exc
|
||||
|
||||
|
||||
def deauthorize_member(network_id: str, node_id: str):
|
||||
"""De-authorize a member on a network. Returns updated member."""
|
||||
client = _get_client()
|
||||
try:
|
||||
return client.deauthorize_member(network_id, node_id)
|
||||
except SDKZeroTierAPIError as exc:
|
||||
raise ZeroTierAPIError(str(exc), status_code=exc.status_code) from exc
|
||||
|
||||
|
||||
def add_member(network_id: str, node_id: str, authorized: bool = False):
|
||||
"""Manually add/pre-provision a member on a network."""
|
||||
client = _get_client()
|
||||
try:
|
||||
return client.add_member(network_id, node_id, authorized=authorized)
|
||||
except SDKZeroTierAPIError as exc:
|
||||
raise ZeroTierAPIError(str(exc), status_code=exc.status_code) from exc
|
||||
|
||||
|
||||
def delete_network_member(network_id: str, node_id: str):
|
||||
"""Remove a member entirely from a ZeroTier network."""
|
||||
client = _get_client()
|
||||
try:
|
||||
return client.delete_member(network_id, node_id)
|
||||
except SDKZeroTierAPIError as exc:
|
||||
raise ZeroTierAPIError(str(exc), status_code=exc.status_code) from exc
|
||||
@@ -0,0 +1,303 @@
|
||||
"""ZeroTier reconciliation service — polling loop to sync state with the controller."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from gatehouse_app.extensions import db
|
||||
from gatehouse_app.models import (
|
||||
Device,
|
||||
DeviceNetworkMembership,
|
||||
ActivationSession,
|
||||
ZeroTierMembership,
|
||||
PortalNetwork,
|
||||
UserNetworkApproval,
|
||||
)
|
||||
from gatehouse_app.services import zerotier_api_service as zt
|
||||
from gatehouse_app.utils.constants import (
|
||||
ActivationEndReason,
|
||||
MembershipState,
|
||||
ApprovalState,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def reconcile_expired_activations() -> int:
|
||||
"""Find expired activation sessions and deactivate their memberships.
|
||||
|
||||
Returns the number of sessions expired.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
expired = ActivationSession.query.filter(
|
||||
ActivationSession.expires_at < now,
|
||||
ActivationSession.ended_at.is_(None),
|
||||
ActivationSession.deleted_at.is_(None),
|
||||
).all()
|
||||
|
||||
count = 0
|
||||
for session in expired:
|
||||
try:
|
||||
_expire_session(session)
|
||||
count += 1
|
||||
except Exception as exc:
|
||||
logger.error(f"[Reconciliation] Failed to expire session {session.id}: {exc}")
|
||||
|
||||
if count > 0:
|
||||
logger.info(f"[Reconciliation] Expired {count} activation sessions.")
|
||||
|
||||
return count
|
||||
|
||||
|
||||
def reconcile_network(portal_network_id: str) -> dict:
|
||||
"""Full reconciliation for one portal network.
|
||||
|
||||
Returns a dict with counts of actions taken.
|
||||
"""
|
||||
network = PortalNetwork.query.get(portal_network_id)
|
||||
if not network or not network.is_active:
|
||||
return {"skipped": True, "reason": "network_inactive_or_deleted"}
|
||||
|
||||
zerotier_network_id = network.zerotier_network_id
|
||||
actions = {
|
||||
"zt_members_checked": 0,
|
||||
"zt_members_added": 0,
|
||||
"authorized": 0,
|
||||
"deauthorized": 0,
|
||||
"join_seen_updated": 0,
|
||||
"unknown_members": [],
|
||||
}
|
||||
|
||||
# Get current ZT members
|
||||
try:
|
||||
zt_members = {m.node_id: m for m in zt.list_members(zerotier_network_id)}
|
||||
except Exception as exc:
|
||||
logger.error(f"[Reconciliation] Failed to list ZT members for {zerotier_network_id}: {exc}")
|
||||
actions["error"] = str(exc)
|
||||
return actions
|
||||
|
||||
actions["zt_members_checked"] = len(zt_members)
|
||||
|
||||
# Get our portal memberships for this network
|
||||
our_memberships = {
|
||||
m.device.node_id: m
|
||||
for m in DeviceNetworkMembership.query.filter(
|
||||
DeviceNetworkMembership.portal_network_id == portal_network_id,
|
||||
DeviceNetworkMembership.deleted_at.is_(None),
|
||||
).all()
|
||||
if m.device and m.device.deleted_at is None
|
||||
}
|
||||
|
||||
# Reconcile each portal membership
|
||||
for node_id, membership in our_memberships.items():
|
||||
zt_member = zt_members.pop(node_id, None)
|
||||
device = membership.device
|
||||
|
||||
if not zt_member:
|
||||
# Member not seen in ZT yet
|
||||
continue
|
||||
|
||||
actions["join_seen_updated"] += 1
|
||||
|
||||
# Update observed ZT membership
|
||||
_sync_zt_membership(membership, zt_member)
|
||||
|
||||
# Sync authorization state
|
||||
if membership.state == MembershipState.ACTIVE_AUTHORIZED:
|
||||
if not zt_member.is_authorized:
|
||||
# We think it's active but ZT says it's not — re-authorize
|
||||
try:
|
||||
zt.authorize_member(zerotier_network_id, node_id)
|
||||
actions["authorized"] += 1
|
||||
except Exception as exc:
|
||||
logger.warning(f"[Reconciliation] Re-authorize failed for {node_id}: {exc}")
|
||||
else:
|
||||
if zt_member.is_authorized:
|
||||
# We think it's not authorized but ZT says it is — deauthorize
|
||||
# (could be manual override in ZT console)
|
||||
try:
|
||||
zt.deauthorize_member(zerotier_network_id, node_id)
|
||||
actions["deauthorized"] += 1
|
||||
except Exception as exc:
|
||||
logger.warning(f"[Reconciliation] Deauthorize failed for {node_id}: {exc}")
|
||||
|
||||
# Unknown ZT members not in our portal
|
||||
actions["unknown_members"] = list(zt_members.keys())
|
||||
|
||||
logger.info(
|
||||
f"[Reconciliation] Network {zerotier_network_id}: "
|
||||
f"checked={actions['zt_members_checked']} "
|
||||
f"authorized={actions['authorized']} "
|
||||
f"deauthorized={actions['deauthorized']} "
|
||||
f"unknown={len(actions['unknown_members'])}"
|
||||
)
|
||||
|
||||
return actions
|
||||
|
||||
|
||||
def reconcile_all() -> dict:
|
||||
"""Run reconciliation on all active portal networks.
|
||||
|
||||
Returns a summary dict.
|
||||
"""
|
||||
networks = PortalNetwork.query.filter(
|
||||
PortalNetwork.is_active.is_(True),
|
||||
PortalNetwork.deleted_at.is_(None),
|
||||
).all()
|
||||
|
||||
results = {"networks_processed": 0, "errors": 0}
|
||||
for network in networks:
|
||||
try:
|
||||
result = reconcile_network(network.id)
|
||||
if "error" in result:
|
||||
results["errors"] += 1
|
||||
else:
|
||||
results["networks_processed"] += 1
|
||||
except Exception as exc:
|
||||
logger.error(f"[Reconciliation] Failed to reconcile network {network.id}: {exc}")
|
||||
results["errors"] += 1
|
||||
|
||||
deleted_result = reconcile_deleted_memberships()
|
||||
results["deleted_memberships"] = deleted_result.get("deleted", 0)
|
||||
results["delete_errors"] = deleted_result.get("errors", 0)
|
||||
|
||||
logger.info(
|
||||
f"[Reconciliation] Complete: {results['networks_processed']} networks processed, "
|
||||
f"{results['errors']} errors, {results.get('deleted_memberships', 0)} memberships purged."
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def reconcile_deleted_memberships() -> dict:
|
||||
"""Find soft-deleted memberships and hard-delete them after ZeroTier cleanup.
|
||||
|
||||
Only processes memberships whose ZeroTier members are already de-authorized
|
||||
(the de-authorize step happened in revoke_membership_soft). This function
|
||||
removes the member from ZeroTier entirely and then hard-deletes the DB record.
|
||||
"""
|
||||
deleted = DeviceNetworkMembership.query.filter(
|
||||
DeviceNetworkMembership.deleted_at.isnot(None),
|
||||
).all()
|
||||
|
||||
if not deleted:
|
||||
return {"deleted": 0, "errors": 0}
|
||||
|
||||
results = {"deleted": 0, "errors": 0}
|
||||
for membership in deleted:
|
||||
try:
|
||||
device = Device.query.get(membership.device_id)
|
||||
network = PortalNetwork.query.get(membership.portal_network_id)
|
||||
|
||||
if not device or not network:
|
||||
db.session.delete(membership)
|
||||
db.session.commit()
|
||||
results["deleted"] += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
zt.delete_network_member(network.zerotier_network_id, device.node_id)
|
||||
logger.info(f"[Reconciliation] Deleted {device.node_id} from ZT network {network.zerotier_network_id}")
|
||||
except Exception as zt_exc:
|
||||
logger.warning(
|
||||
f"[Reconciliation] ZT delete failed for {device.node_id} "
|
||||
f"on {network.zerotier_network_id}: {zt_exc}"
|
||||
)
|
||||
|
||||
db.session.delete(membership)
|
||||
db.session.commit()
|
||||
results["deleted"] += 1
|
||||
|
||||
except Exception as exc:
|
||||
logger.error(f"[Reconciliation] Failed to hard-delete membership {membership.id}: {exc}")
|
||||
results["errors"] += 1
|
||||
|
||||
if results["deleted"] > 0:
|
||||
logger.info(f"[Reconciliation] Purged {results['deleted']} memberships.")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def _sync_zt_membership(membership: DeviceNetworkMembership, zt_member) -> None:
|
||||
"""Update the ZeroTierMembership cache record from a ZT API response."""
|
||||
device = membership.device
|
||||
network = membership.portal_network
|
||||
|
||||
zt_membership = ZeroTierMembership.query.filter(
|
||||
ZeroTierMembership.zerotier_network_id == network.zerotier_network_id,
|
||||
ZeroTierMembership.node_id == device.node_id,
|
||||
ZeroTierMembership.deleted_at.is_(None),
|
||||
).first()
|
||||
|
||||
if not zt_membership:
|
||||
zt_membership = ZeroTierMembership(
|
||||
organization_id=membership.organization_id,
|
||||
device_network_membership_id=membership.id,
|
||||
zerotier_network_id=network.zerotier_network_id,
|
||||
node_id=device.node_id,
|
||||
)
|
||||
|
||||
zt_membership.member_seen = True
|
||||
zt_membership.authorized = zt_member.is_authorized
|
||||
zt_membership.last_synced_at = datetime.now(timezone.utc)
|
||||
zt_membership.raw_controller_payload = zt_member.raw
|
||||
|
||||
if zt_member.last_seen and zt_member.last_seen > 0:
|
||||
zt_membership.join_seen_at = datetime.fromtimestamp(
|
||||
zt_member.last_seen / 1000, tz=timezone.utc
|
||||
)
|
||||
|
||||
zt_membership.save()
|
||||
|
||||
# Update membership join_seen flag
|
||||
if not membership.join_seen:
|
||||
membership.join_seen = True
|
||||
membership.state = MembershipState.JOINED_DEAUTHORIZED
|
||||
membership.save()
|
||||
|
||||
|
||||
def _expire_session(session: ActivationSession) -> None:
|
||||
"""Expire an activation session and deauthorize the membership in ZT."""
|
||||
session.ended_at = datetime.now(timezone.utc)
|
||||
session.end_reason = ActivationEndReason.EXPIRED
|
||||
session.save()
|
||||
|
||||
membership = DeviceNetworkMembership.query.get(session.device_network_membership_id)
|
||||
if membership:
|
||||
membership.state = MembershipState.ACTIVATION_EXPIRED
|
||||
membership.currently_authorized = False
|
||||
membership.save()
|
||||
|
||||
device = Device.query.get(membership.device_id)
|
||||
network = PortalNetwork.query.get(membership.portal_network_id)
|
||||
if device and network:
|
||||
try:
|
||||
zt.deauthorize_member(network.zerotier_network_id, device.node_id)
|
||||
|
||||
# Update ZT membership cache
|
||||
zt_membership = ZeroTierMembership.query.filter(
|
||||
ZeroTierMembership.zerotier_network_id == network.zerotier_network_id,
|
||||
ZeroTierMembership.node_id == device.node_id,
|
||||
ZeroTierMembership.deleted_at.is_(None),
|
||||
).first()
|
||||
if zt_membership:
|
||||
zt_membership.authorized = False
|
||||
zt_membership.last_synced_at = datetime.now(timezone.utc)
|
||||
zt_membership.save()
|
||||
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
f"[_expire_session] Failed to deauthorize {device.node_id} "
|
||||
f"on {network.zerotier_network_id}: {exc}"
|
||||
)
|
||||
|
||||
from gatehouse_app.services.audit_service import AuditService
|
||||
AuditService.log_action(
|
||||
action="zt.activation.expired",
|
||||
user_id=session.user_id,
|
||||
organization_id=session.organization_id,
|
||||
resource_type="activation_session",
|
||||
resource_id=session.id,
|
||||
metadata={"membership_id": session.device_network_membership_id},
|
||||
description="Activation session expired",
|
||||
success=True,
|
||||
)
|
||||
Reference in New Issue
Block a user