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:
2026-03-20 21:50:20 +10:30
parent 49e724222f
commit 1789590167
27 changed files with 4862 additions and 4 deletions
+860
View File
@@ -0,0 +1,860 @@
"""ZeroTier network governance API endpoints."""
from flask import g, request
from marshmallow import Schema, fields, validate, ValidationError
from gatehouse_app.api.v1 import api_v1_bp
from gatehouse_app.utils.response import api_response
from gatehouse_app.utils.decorators import login_required, require_admin, full_access_required
from gatehouse_app.services import portal_network_service
from gatehouse_app.services import device_service
from gatehouse_app.services import network_access_service
from gatehouse_app.services import zerotier_api_service as zt
from gatehouse_app.services import zerotier_reconciliation_service
from gatehouse_app.models import (
PortalNetwork,
Device,
DeviceNetworkMembership,
UserNetworkApproval,
ActivationSession,
)
from gatehouse_app.models.organization import Organization
from gatehouse_app.exceptions import (
ValidationError as AppValidationError,
ZeroTierAPIError,
NetworkNotFoundError,
DeviceNotFoundError,
DeviceAlreadyExistsError,
ApprovalNotFoundError,
MembershipNotFoundError,
)
def _org_check(org_id):
org = Organization.query.filter_by(id=org_id, deleted_at=None).first()
if not org:
return None, api_response(success=False, message="Organization not found", status=404, error_type="NOT_FOUND")
if not org.is_member(g.current_user.id):
return None, api_response(success=False, message="Not a member of this organization", status=403, error_type="AUTHORIZATION_ERROR")
return org, None
# ── Schemas ───────────────────────────────────────────────────────────────────
class CreateNetworkSchema(Schema):
name = fields.Str(required=True, validate=validate.Length(min=1, max=255))
description = fields.Str(allow_none=True)
zerotier_network_id = fields.Str(required=True, validate=validate.Length(equal=16))
environment = fields.Str(load_default="development")
request_mode = fields.Str(load_default="approval_required")
default_activation_lifetime_minutes = fields.Int(load_default=480)
max_activation_lifetime_minutes = fields.Int(allow_none=True, load_default=None)
class UpdateNetworkSchema(Schema):
name = fields.Str(validate=validate.Length(min=1, max=255))
description = fields.Str(allow_none=True)
environment = fields.Str()
request_mode = fields.Str()
default_activation_lifetime_minutes = fields.Int()
max_activation_lifetime_minutes = fields.Int(allow_none=True)
is_active = fields.Bool()
class RegisterDeviceSchema(Schema):
node_id = fields.Str(required=True, validate=validate.Length(equal=10))
nickname = fields.Str(allow_none=True, validate=validate.Length(max=255))
hostname = fields.Str(allow_none=True, validate=validate.Length(max=255))
asset_tag = fields.Str(allow_none=True, validate=validate.Length(max=255))
serial_number = fields.Str(allow_none=True, validate=validate.Length(max=255))
class UpdateDeviceSchema(Schema):
nickname = fields.Str(allow_none=True, validate=validate.Length(max=255))
hostname = fields.Str(allow_none=True, validate=validate.Length(max=255))
asset_tag = fields.Str(allow_none=True, validate=validate.Length(max=255))
serial_number = fields.Str(allow_none=True, validate=validate.Length(max=255))
class RequestAccessSchema(Schema):
portal_network_id = fields.Str(required=True)
device_id = fields.Str(required=True)
justification = fields.Str(allow_none=True, validate=validate.Length(max=1000))
class AssignAccessSchema(Schema):
target_user_id = fields.Str(required=True)
portal_network_id = fields.Str(required=True)
justification = fields.Str(allow_none=True, validate=validate.Length(max=1000))
class ActivateSchema(Schema):
lifetime_minutes = fields.Int(allow_none=True)
class KillSwitchSchema(Schema):
target_user_id = fields.Str(required=True)
scope = fields.Str(load_default="organization")
reason = fields.Str(allow_none=True, validate=validate.Length(max=500))
network_ids = fields.List(fields.Str(), allow_none=True)
# ── Networks ──────────────────────────────────────────────────────────────────
@api_v1_bp.route("/organizations/<org_id>/networks", methods=["GET"])
@login_required
@full_access_required
def list_networks(org_id):
"""List portal networks for an organization."""
org, err = _org_check(org_id)
if err:
return err
include_inactive = request.args.get("include_inactive", "false").lower() == "true"
networks = portal_network_service.list_networks(org_id, include_inactive=include_inactive)
return api_response(
data={"networks": [n.to_dict() for n in networks], "count": len(networks)},
message="Networks retrieved successfully",
)
@api_v1_bp.route("/organizations/<org_id>/networks", methods=["POST"])
@login_required
@require_admin
@full_access_required
def create_network(org_id):
"""Create a new portal network (admin only)."""
org, err = _org_check(org_id)
if err:
return err
try:
schema = CreateNetworkSchema()
data = schema.load(request.json or {})
except ValidationError as e:
return api_response(success=False, message="Validation failed", status=400, error_type="VALIDATION_ERROR", error_details=e.messages)
try:
network = portal_network_service.create_network(
organization_id=org_id,
name=data["name"],
owner_user_id=g.current_user.id,
zerotier_network_id=data["zerotier_network_id"],
description=data.get("description"),
environment=data.get("environment"),
request_mode=data.get("request_mode"),
default_activation_lifetime_minutes=data.get("default_activation_lifetime_minutes", 480),
max_activation_lifetime_minutes=data.get("max_activation_lifetime_minutes"),
)
return api_response(data={"network": network.to_dict()}, message="Network created successfully", status=201)
except AppValidationError as e:
return api_response(success=False, message=str(e.message), status=400, error_type=e.error_type)
except ZeroTierAPIError as e:
return api_response(success=False, message=str(e), status=502, error_type=e.error_type)
@api_v1_bp.route("/organizations/<org_id>/networks/<network_id>", methods=["GET"])
@login_required
@full_access_required
def get_network(org_id, network_id):
"""Get a portal network by ID."""
org, err = _org_check(org_id)
if err:
return err
try:
network = portal_network_service.get_network(network_id, organization_id=org_id)
return api_response(data={"network": network.to_dict()}, message="Network retrieved successfully")
except NetworkNotFoundError as e:
return api_response(success=False, message=str(e), status=404, error_type=e.error_type)
@api_v1_bp.route("/organizations/<org_id>/networks/<network_id>", methods=["PATCH"])
@login_required
@require_admin
@full_access_required
def update_network(org_id, network_id):
"""Update network metadata (admin only)."""
org, err = _org_check(org_id)
if err:
return err
try:
schema = UpdateNetworkSchema()
data = schema.load(request.json or {})
except ValidationError as e:
return api_response(success=False, message="Validation failed", status=400, error_type="VALIDATION_ERROR", error_details=e.messages)
try:
network = portal_network_service.update_network(network_id, g.current_user.id, **data)
return api_response(data={"network": network.to_dict()}, message="Network updated successfully")
except NetworkNotFoundError as e:
return api_response(success=False, message=str(e), status=404, error_type=e.error_type)
except AppValidationError as e:
return api_response(success=False, message=str(e.message), status=400, error_type=e.error_type)
@api_v1_bp.route("/organizations/<org_id>/networks/<network_id>", methods=["DELETE"])
@login_required
@require_admin
@full_access_required
def delete_network(org_id, network_id):
"""Delete a portal network (admin only)."""
org, err = _org_check(org_id)
if err:
return err
try:
portal_network_service.delete_network(network_id, g.current_user.id)
return api_response(message="Network deleted successfully")
except NetworkNotFoundError as e:
return api_response(success=False, message=str(e), status=404, error_type=e.error_type)
@api_v1_bp.route("/organizations/<org_id>/networks/<network_id>/members", methods=["GET"])
@login_required
@full_access_required
def get_network_members(org_id, network_id):
"""List all device memberships for a network."""
org, err = _org_check(org_id)
if err:
return err
try:
portal_network_service.get_network(network_id, organization_id=org_id)
except NetworkNotFoundError as e:
return api_response(success=False, message=str(e), status=404, error_type=e.error_type)
memberships = portal_network_service.get_network_members(network_id)
return api_response(
data={"memberships": [m.to_dict() for m in memberships], "count": len(memberships)},
message="Network members retrieved successfully",
)
@api_v1_bp.route("/organizations/<org_id>/networks/<network_id>/requests", methods=["GET"])
@login_required
@full_access_required
def get_network_pending_requests(org_id, network_id):
"""List pending access requests for a network (manager view)."""
org, err = _org_check(org_id)
if err:
return err
try:
portal_network_service.get_network(network_id, organization_id=org_id)
except NetworkNotFoundError as e:
return api_response(success=False, message=str(e), status=404, error_type=e.error_type)
requests = portal_network_service.get_network_pending_requests(network_id)
return api_response(
data={"requests": [r.to_dict() for r in requests], "count": len(requests)},
message="Pending requests retrieved successfully",
)
# ── Devices ───────────────────────────────────────────────────────────────────
@api_v1_bp.route("/organizations/<org_id>/devices", methods=["GET"])
@login_required
@full_access_required
def list_devices(org_id):
"""List the current user's registered devices."""
org, err = _org_check(org_id)
if err:
return err
devices = device_service.list_user_devices(g.current_user.id, org_id)
return api_response(
data={"devices": [d.to_dict() for d in devices], "count": len(devices)},
message="Devices retrieved successfully",
)
@api_v1_bp.route("/organizations/<org_id>/devices", methods=["POST"])
@login_required
@full_access_required
def register_device(org_id):
"""Register a new device for the current user."""
org, err = _org_check(org_id)
if err:
return err
try:
schema = RegisterDeviceSchema()
data = schema.load(request.json or {})
except ValidationError as e:
return api_response(success=False, message="Validation failed", status=400, error_type="VALIDATION_ERROR", error_details=e.messages)
try:
device = device_service.register_device(
user_id=g.current_user.id,
organization_id=org_id,
node_id=data["node_id"],
nickname=data.get("nickname"),
hostname=data.get("hostname"),
asset_tag=data.get("asset_tag"),
serial_number=data.get("serial_number"),
)
from gatehouse_app.services.network_access_service import materialize_device_memberships
memberships = materialize_device_memberships(device.id)
return api_response(
data={"device": device.to_dict(), "memberships_created": len(memberships)},
message="Device registered successfully",
status=201,
)
except AppValidationError as e:
return api_response(success=False, message=str(e.message), status=400, error_type=e.error_type)
except DeviceAlreadyExistsError as e:
return api_response(success=False, message=str(e), status=409, error_type=e.error_type)
@api_v1_bp.route("/organizations/<org_id>/devices/<device_id>", methods=["GET"])
@login_required
@full_access_required
def get_device(org_id, device_id):
"""Get a device by ID."""
org, err = _org_check(org_id)
if err:
return err
try:
device = device_service.get_device(device_id, organization_id=org_id)
return api_response(data={"device": device.to_dict()}, message="Device retrieved successfully")
except DeviceNotFoundError as e:
return api_response(success=False, message=str(e), status=404, error_type=e.error_type)
@api_v1_bp.route("/organizations/<org_id>/devices/<device_id>", methods=["PATCH"])
@login_required
@full_access_required
def update_device(org_id, device_id):
"""Update device fields (owner only)."""
org, err = _org_check(org_id)
if err:
return err
try:
schema = UpdateDeviceSchema()
data = schema.load(request.json or {})
except ValidationError as e:
return api_response(success=False, message="Validation failed", status=400, error_type="VALIDATION_ERROR", error_details=e.messages)
try:
device = device_service.update_device(device_id, g.current_user.id, **data)
return api_response(data={"device": device.to_dict()}, message="Device updated successfully")
except DeviceNotFoundError as e:
return api_response(success=False, message=str(e), status=404, error_type=e.error_type)
@api_v1_bp.route("/organizations/<org_id>/devices/<device_id>", methods=["DELETE"])
@login_required
@full_access_required
def remove_device(org_id, device_id):
"""Remove a registered device (owner only)."""
org, err = _org_check(org_id)
if err:
return err
try:
device_service.remove_device(device_id, g.current_user.id)
return api_response(message="Device removed successfully")
except DeviceNotFoundError as e:
return api_response(success=False, message=str(e), status=404, error_type=e.error_type)
# ── Approvals ─────────────────────────────────────────────────────────────────
@api_v1_bp.route("/organizations/<org_id>/approvals", methods=["POST"])
@login_required
@full_access_required
def request_access(org_id):
"""Request access to a network for a specific registered device."""
org, err = _org_check(org_id)
if err:
return err
try:
schema = RequestAccessSchema()
data = schema.load(request.json or {})
except ValidationError as e:
return api_response(success=False, message="Validation failed", status=400, error_type="VALIDATION_ERROR", error_details=e.messages)
try:
approval = network_access_service.request_access(
user_id=g.current_user.id,
organization_id=org_id,
portal_network_id=data["portal_network_id"],
device_id=data["device_id"],
justification=data.get("justification"),
)
return api_response(data={"approval": approval.to_dict()}, message="Access requested successfully", status=201)
except AppValidationError as e:
return api_response(success=False, message=str(e.message), status=400, error_type=e.error_type)
except DeviceNotFoundError as e:
return api_response(success=False, message=str(e), status=400, error_type=e.error_type)
@api_v1_bp.route("/organizations/<org_id>/approvals", methods=["GET"])
@login_required
@full_access_required
def list_my_approvals(org_id):
"""List the current user's approval records."""
org, err = _org_check(org_id)
if err:
return err
approvals = network_access_service.list_user_approvals(g.current_user.id, org_id)
return api_response(
data={"approvals": [a.to_dict() for a in approvals], "count": len(approvals)},
message="Approvals retrieved successfully",
)
@api_v1_bp.route("/organizations/<org_id>/approvals/pending", methods=["GET"])
@login_required
@require_admin
@full_access_required
def list_pending_approvals(org_id):
"""List all pending approval requests (admin only)."""
org, err = _org_check(org_id)
if err:
return err
network_id = request.args.get("network_id")
approvals = network_access_service.list_pending_approvals(org_id, network_id=network_id)
return api_response(
data={"approvals": [a.to_dict() for a in approvals], "count": len(approvals)},
message="Pending approvals retrieved successfully",
)
@api_v1_bp.route("/organizations/<org_id>/approvals/<approval_id>/approve", methods=["POST"])
@login_required
@require_admin
@full_access_required
def approve_request(org_id, approval_id):
"""Approve a pending access request (admin only)."""
org, err = _org_check(org_id)
if err:
return err
try:
approval = network_access_service.approve_request(approval_id, g.current_user.id)
return api_response(data={"approval": approval.to_dict()}, message="Request approved successfully")
except ApprovalNotFoundError as e:
return api_response(success=False, message=str(e), status=404, error_type=e.error_type)
except AppValidationError as e:
return api_response(success=False, message=str(e.message), status=400, error_type=e.error_type)
@api_v1_bp.route("/organizations/<org_id>/approvals/<approval_id>/reject", methods=["POST"])
@login_required
@require_admin
@full_access_required
def reject_request(org_id, approval_id):
"""Reject a pending access request (admin only)."""
org, err = _org_check(org_id)
if err:
return err
try:
approval = network_access_service.reject_request(approval_id, g.current_user.id)
return api_response(data={"approval": approval.to_dict()}, message="Request rejected successfully")
except ApprovalNotFoundError as e:
return api_response(success=False, message=str(e), status=404, error_type=e.error_type)
except AppValidationError as e:
return api_response(success=False, message=str(e.message), status=400, error_type=e.error_type)
@api_v1_bp.route("/organizations/<org_id>/approvals/<approval_id>/revoke", methods=["POST"])
@login_required
@require_admin
@full_access_required
def revoke_approval(org_id, approval_id):
"""Revoke an approved access record (admin only)."""
org, err = _org_check(org_id)
if err:
return err
try:
approval = network_access_service.revoke_approval(approval_id, g.current_user.id)
return api_response(data={"approval": approval.to_dict()}, message="Approval revoked successfully")
except ApprovalNotFoundError as e:
return api_response(success=False, message=str(e), status=404, error_type=e.error_type)
@api_v1_bp.route("/organizations/<org_id>/approvals/assign", methods=["POST"])
@login_required
@require_admin
@full_access_required
def assign_access(org_id):
"""Directly assign network access to a user (admin only)."""
org, err = _org_check(org_id)
if err:
return err
try:
schema = AssignAccessSchema()
data = schema.load(request.json or {})
except ValidationError as e:
return api_response(success=False, message="Validation failed", status=400, error_type="VALIDATION_ERROR", error_details=e.messages)
try:
approval = network_access_service.assign_access(
portal_network_id=data["portal_network_id"],
target_user_id=data["target_user_id"],
granted_by_user_id=g.current_user.id,
organization_id=org_id,
justification=data.get("justification"),
)
return api_response(data={"approval": approval.to_dict()}, message="Access assigned successfully", status=201)
except AppValidationError as e:
return api_response(success=False, message=str(e.message), status=400, error_type=e.error_type)
# ── Memberships ───────────────────────────────────────────────────────────────
@api_v1_bp.route("/organizations/<org_id>/memberships", methods=["GET"])
@login_required
@full_access_required
def list_memberships(org_id):
"""List the current user's device network memberships."""
org, err = _org_check(org_id)
if err:
return err
memberships = DeviceNetworkMembership.query.filter(
DeviceNetworkMembership.user_id == g.current_user.id,
DeviceNetworkMembership.organization_id == org_id,
DeviceNetworkMembership.deleted_at.is_(None),
).all()
return api_response(
data={"memberships": [m.to_dict() for m in memberships], "count": len(memberships)},
message="Memberships retrieved successfully",
)
@api_v1_bp.route("/organizations/<org_id>/memberships/<membership_id>/activate", methods=["POST"])
@login_required
@full_access_required
def activate_membership(org_id, membership_id):
"""Activate an approved device membership."""
org, err = _org_check(org_id)
if err:
return err
try:
schema = ActivateSchema()
data = schema.load(request.json or {})
except ValidationError as e:
return api_response(success=False, message="Validation failed", status=400, error_type="VALIDATION_ERROR", error_details=e.messages)
try:
session = network_access_service.activate_device_membership(
membership_id=membership_id,
user_id=g.current_user.id,
lifetime_minutes=data.get("lifetime_minutes"),
)
membership = DeviceNetworkMembership.query.get(membership_id)
return api_response(data={"session": session.to_dict(), "membership": membership.to_dict()}, message="Membership activated successfully")
except MembershipNotFoundError as e:
return api_response(success=False, message=str(e), status=404, error_type=e.error_type)
except AppValidationError as e:
return api_response(success=False, message=str(e.message), status=400, error_type=e.error_type)
@api_v1_bp.route("/organizations/<org_id>/memberships/<membership_id>/deactivate", methods=["POST"])
@login_required
@full_access_required
def deactivate_membership(org_id, membership_id):
"""Deactivate an active device membership."""
org, err = _org_check(org_id)
if err:
return err
try:
membership = network_access_service.deactivate_membership(
membership_id=membership_id,
reason="manual_revoke",
deactivated_by_user_id=g.current_user.id,
)
return api_response(data={"membership": membership.to_dict()}, message="Membership deactivated successfully")
except MembershipNotFoundError as e:
return api_response(success=False, message=str(e), status=404, error_type=e.error_type)
@api_v1_bp.route("/organizations/<org_id>/memberships/activate-all", methods=["POST"])
@login_required
@full_access_required
def activate_all_memberships(org_id):
"""Bulk-activate all approved inactive memberships."""
org, err = _org_check(org_id)
if err:
return err
try:
schema = ActivateSchema()
data = schema.load(request.json or {})
except ValidationError as e:
return api_response(success=False, message="Validation failed", status=400, error_type="VALIDATION_ERROR", error_details=e.messages)
sessions = network_access_service.activate_all_approved(
user_id=g.current_user.id,
organization_id=org_id,
lifetime_minutes=data.get("lifetime_minutes"),
)
return api_response(
data={"sessions": [s.to_dict() for s in sessions], "count": len(sessions)},
message=f"{len(sessions)} memberships activated",
)
@api_v1_bp.route("/organizations/<org_id>/devices/<device_id>/join-network/<portal_network_id>", methods=["POST"])
@login_required
@full_access_required
def join_network(org_id, device_id, portal_network_id):
"""Join an open network directly with a registered device."""
org, err = _org_check(org_id)
if err:
return err
try:
membership = network_access_service.join_network_for_device(
user_id=g.current_user.id,
organization_id=org_id,
device_id=device_id,
portal_network_id=portal_network_id,
)
return api_response(data={"membership": membership.to_dict()}, message="Joined network successfully", status=201)
except AppValidationError as e:
return api_response(success=False, message=str(e.message), status=400, error_type=e.error_type)
except DeviceNotFoundError as e:
return api_response(success=False, message=str(e), status=404, error_type=e.error_type)
@api_v1_bp.route("/organizations/<org_id>/memberships/<membership_id>", methods=["DELETE"])
@login_required
@full_access_required
def delete_membership(org_id, membership_id):
"""Soft-delete the current user's own membership."""
org, err = _org_check(org_id)
if err:
return err
try:
network_access_service.revoke_membership_soft(
membership_id=membership_id,
revoked_by_user_id=g.current_user.id,
)
return api_response(message="Membership removed successfully")
except MembershipNotFoundError as e:
return api_response(success=False, message=str(e), status=404, error_type=e.error_type)
# ── Sessions ─────────────────────────────────────────────────────────────────
@api_v1_bp.route("/organizations/<org_id>/sessions", methods=["GET"])
@login_required
@full_access_required
def list_sessions(org_id):
"""List the current user's active activation sessions."""
org, err = _org_check(org_id)
if err:
return err
sessions = ActivationSession.query.filter(
ActivationSession.user_id == g.current_user.id,
ActivationSession.organization_id == org_id,
ActivationSession.ended_at.is_(None),
ActivationSession.deleted_at.is_(None),
).all()
return api_response(
data={"sessions": [s.to_dict() for s in sessions], "count": len(sessions)},
message="Sessions retrieved successfully",
)
@api_v1_bp.route("/organizations/<org_id>/sessions/<session_id>", methods=["DELETE"])
@login_required
@full_access_required
def end_session(org_id, session_id):
"""End an active activation session."""
org, err = _org_check(org_id)
if err:
return err
session = ActivationSession.query.filter(
ActivationSession.id == session_id,
ActivationSession.user_id == g.current_user.id,
ActivationSession.deleted_at.is_(None),
).first()
if not session:
return api_response(success=False, message="Session not found", status=404, error_type="NOT_FOUND")
if session.ended_at:
return api_response(success=False, message="Session already ended", status=400, error_type="VALIDATION_ERROR")
from gatehouse_app.services.network_access_service import _end_session
from gatehouse_app.utils.constants import ActivationEndReason
from datetime import datetime, timezone
_end_session(session, ActivationEndReason.LOGOUT)
membership = DeviceNetworkMembership.query.get(session.device_network_membership_id)
if membership:
from gatehouse_app.services.network_access_service import deactivate_membership
deactivate_membership(membership.id, reason="logout")
return api_response(message="Session ended successfully")
# ── Kill Switch ───────────────────────────────────────────────────────────────
@api_v1_bp.route("/organizations/<org_id>/kill-switch", methods=["POST"])
@login_required
@require_admin
@full_access_required
def trigger_kill_switch(org_id):
"""Trigger a kill switch on a user (admin only)."""
org, err = _org_check(org_id)
if err:
return err
try:
schema = KillSwitchSchema()
data = schema.load(request.json or {})
except ValidationError as e:
return api_response(success=False, message="Validation failed", status=400, error_type="VALIDATION_ERROR", error_details=e.messages)
try:
event = network_access_service.kill_switch(
target_user_id=data["target_user_id"],
triggered_by_user_id=g.current_user.id,
scope=data.get("scope", "organization"),
reason=data.get("reason"),
network_ids=data.get("network_ids"),
)
return api_response(data={"event": event.to_dict()}, message="Kill switch triggered successfully")
except AppValidationError as e:
return api_response(success=False, message=str(e.message), status=400, error_type=e.error_type)
# ── Admin / ZeroTier Controller ───────────────────────────────────────────────
@api_v1_bp.route("/organizations/<org_id>/admin/memberships", methods=["GET"])
@login_required
@require_admin
@full_access_required
def admin_list_memberships(org_id):
"""List all memberships across all users and networks (admin only)."""
org, err = _org_check(org_id)
if err:
return err
memberships = network_access_service.get_all_memberships_with_details(org_id)
return api_response(
data={"memberships": memberships, "count": len(memberships)},
message="All memberships retrieved successfully",
)
@api_v1_bp.route("/organizations/<org_id>/admin/memberships/<membership_id>", methods=["DELETE"])
@login_required
@require_admin
@full_access_required
def admin_delete_membership(org_id, membership_id):
"""Hard-delete a membership and remove it from ZeroTier (admin only)."""
org, err = _org_check(org_id)
if err:
return err
try:
network_access_service.hard_delete_membership(membership_id)
return api_response(message="Membership permanently deleted")
except MembershipNotFoundError as e:
return api_response(success=False, message=str(e), status=404, error_type=e.error_type)
# ── ZeroTier Controller ───────────────────────────────────────────────────────
@api_v1_bp.route("/admin/zerotier/status", methods=["GET"])
@login_required
@require_admin
@full_access_required
def zerotier_status():
"""Check ZeroTier controller connectivity and status (admin only)."""
try:
status = zt.get_status()
return api_response(data={"status": status}, message="ZeroTier controller is reachable")
except ZeroTierAPIError as e:
return api_response(success=False, message=str(e), status=502, error_type=e.error_type)
@api_v1_bp.route("/admin/zerotier/networks", methods=["GET"])
@login_required
@require_admin
@full_access_required
def zerotier_list_networks():
"""List networks from the ZeroTier controller (admin only)."""
try:
networks = zt.list_networks()
return api_response(
data={"networks": [n.to_dict() if hasattr(n, 'to_dict') else {"id": getattr(n, "id", str(n))} for n in networks], "count": len(networks)},
message="Networks retrieved successfully",
)
except ZeroTierAPIError as e:
return api_response(success=False, message=str(e), status=502, error_type=e.error_type)
@api_v1_bp.route("/admin/zerotier/networks/<network_id>", methods=["GET"])
@login_required
@require_admin
@full_access_required
def zerotier_get_network(network_id):
"""Get a ZeroTier network from the controller (admin only)."""
try:
network = zt.get_network(network_id)
return api_response(data={"network": network.to_dict()}, message="Network retrieved successfully")
except ZeroTierAPIError as e:
return api_response(success=False, message=str(e), status=502, error_type=e.error_type)
@api_v1_bp.route("/admin/zerotier/networks/<network_id>/members", methods=["GET"])
@login_required
@require_admin
@full_access_required
def zerotier_list_members(network_id):
"""List members on a ZeroTier network from the controller (admin only)."""
try:
members = zt.list_members(network_id)
return api_response(
data={"members": [m.to_dict() for m in members], "count": len(members)},
message="Members retrieved successfully",
)
except ZeroTierAPIError as e:
return api_response(success=False, message=str(e), status=502, error_type=e.error_type)
@api_v1_bp.route("/admin/zerotier/reconcile", methods=["POST"])
@login_required
@require_admin
@full_access_required
def trigger_reconciliation():
"""Trigger full reconciliation across all networks (admin only)."""
result = zerotier_reconciliation_service.reconcile_all()
return api_response(data=result, message="Reconciliation complete")