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
+3
View File
@@ -53,3 +53,6 @@ SMTP_USERNAME=
SMTP_PASSWORD=
FROM_ADDRESS=
WEBAUTHN_ORIGIN=
ZEROTIER_API_TOKEN=
ZEROTIER_API_URL=
+1
View File
@@ -136,3 +136,4 @@ Thumbs.db
# Project specific
*.db
flask_session/
+14
View File
@@ -129,6 +129,20 @@ class BaseConfig:
# Frontend URL (for OAuth callback redirects)
FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:8080")
# ZeroTier Configuration
ZEROTIER_API_TOKEN = os.getenv("ZEROTIER_API_TOKEN", "")
ZEROTIER_API_URL = os.getenv(
"ZEROTIER_API_URL",
"http://localhost:9993",
)
ZEROTIER_API_MODE = os.getenv("ZEROTIER_API_MODE", "controller").lower()
ZEROTIER_DEFAULT_ACTIVATION_LIFETIME_MINUTES = int(
os.getenv("ZEROTIER_DEFAULT_ACTIVATION_LIFETIME_MINUTES", "480")
)
ZEROTIER_RECONCILIATION_INTERVAL_SECONDS = int(
os.getenv("ZEROTIER_RECONCILIATION_INTERVAL_SECONDS", "120")
)
# Email / SMTP
EMAIL_ENABLED = os.getenv("EMAIL_ENABLED", "False").lower() == "true"
SMTP_HOST = os.getenv("SMTP_HOST", "smtp.gmail.com")
+1 -1
View File
@@ -5,6 +5,6 @@ from flask import Blueprint
api_v1_bp = Blueprint("api_v1", __name__)
# Import route modules to register them
from gatehouse_app.api.v1 import auth, users, organizations, policies, external_auth, departments, principals, ssh
from gatehouse_app.api.v1 import auth, users, organizations, policies, external_auth, departments, principals, ssh, zerotier
api_v1_bp.register_blueprint(ssh.ssh_bp)
+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")
+24
View File
@@ -34,6 +34,19 @@ from gatehouse_app.exceptions.ssh_exceptions import (
DepartmentError,
DepartmentNotFoundError,
)
from gatehouse_app.exceptions.zerotier_exceptions import (
ZeroTierAPIError,
ZeroTierAuthError,
ZeroTierNotFoundError,
NetworkNotFoundError,
DeviceNotFoundError,
ApprovalNotFoundError,
MembershipNotFoundError,
DeviceAlreadyExistsError,
ApprovalAlreadyExistsError,
InvalidNodeIdError,
InvalidNetworkIdError,
)
__all__ = [
"BaseAPIException",
@@ -65,5 +78,16 @@ __all__ = [
"PrincipalNotFoundError",
"DepartmentError",
"DepartmentNotFoundError",
"ZeroTierAPIError",
"ZeroTierAuthError",
"ZeroTierNotFoundError",
"NetworkNotFoundError",
"DeviceNotFoundError",
"ApprovalNotFoundError",
"MembershipNotFoundError",
"DeviceAlreadyExistsError",
"ApprovalAlreadyExistsError",
"InvalidNodeIdError",
"InvalidNetworkIdError",
]
@@ -0,0 +1,69 @@
"""ZeroTier-specific exceptions."""
from gatehouse_app.exceptions.base import BaseAPIException
class ZeroTierAPIError(BaseAPIException):
status_code = 502
error_type = "ZEROTIER_API_ERROR"
message = "ZeroTier API error"
class ZeroTierAuthError(ZeroTierAPIError):
status_code = 401
error_type = "ZEROTIER_AUTH_ERROR"
message = "ZeroTier API authentication failed"
class ZeroTierNotFoundError(ZeroTierAPIError):
status_code = 404
error_type = "ZEROTIER_NOT_FOUND"
message = "ZeroTier network or member not found"
class NetworkNotFoundError(ZeroTierAPIError):
status_code = 404
error_type = "NETWORK_NOT_FOUND"
message = "Portal network not found"
class DeviceNotFoundError(ZeroTierAPIError):
status_code = 404
error_type = "DEVICE_NOT_FOUND"
message = "Device not found"
class ApprovalNotFoundError(ZeroTierAPIError):
status_code = 404
error_type = "APPROVAL_NOT_FOUND"
message = "Network approval not found"
class MembershipNotFoundError(ZeroTierAPIError):
status_code = 404
error_type = "MEMBERSHIP_NOT_FOUND"
message = "Device network membership not found"
class DeviceAlreadyExistsError(ZeroTierAPIError):
status_code = 409
error_type = "DEVICE_ALREADY_EXISTS"
message = "A device with this node ID already exists"
class ApprovalAlreadyExistsError(ZeroTierAPIError):
status_code = 409
error_type = "APPROVAL_ALREADY_EXISTS"
message = "An approval already exists for this user and network"
class InvalidNodeIdError(ZeroTierAPIError):
status_code = 400
error_type = "INVALID_NODE_ID"
message = "Invalid ZeroTier node ID"
class InvalidNetworkIdError(ZeroTierAPIError):
status_code = 400
error_type = "INVALID_NETWORK_ID"
message = "Invalid ZeroTier network ID"
@@ -0,0 +1,71 @@
"""ZeroTier reconciliation scheduled job.
This module implements the scheduled job for reconciling portal network state
with the ZeroTier controller. It is designed to be run periodically (every
1-2 minutes via cron) to:
1. Expire activation sessions past their TTL and deauthorize the corresponding ZT members
2. Sync observed ZeroTier membership state into zerotier_memberships cache
3. Reconcile portal membership state against ZT controller state
4. Flag unknown ZT members (not in portal)
5. Detect and repair drift (ZT says authorized but portal says inactive, and vice versa)
Usage:
python manage.py run_zerotier_reconciliation
Or call directly:
from gatehouse_app.jobs.zerotier_reconciliation_job import run_reconciliation
run_reconciliation()
Cron example (every 2 minutes):
*/2 * * * * cd /path/to/app && python manage.py run_zerotier_reconciliation
"""
import logging
from datetime import datetime, timezone
from typing import Optional
from gatehouse_app.services import zerotier_reconciliation_service
logger = logging.getLogger(__name__)
def run_reconciliation() -> dict:
"""Run full ZeroTier reconciliation across all networks and activations.
Returns:
Dictionary with reconciliation results:
- expired_activations: number of activation sessions expired
- networks_processed: number of portal networks reconciled
- errors: number of networks that had errors
"""
logger.info(f"[ZT Reconcile] Starting reconciliation at {datetime.now(timezone.utc).isoformat()}")
results = {
"expired_activations": 0,
"networks_processed": 0,
"errors": 0,
}
try:
expired = zerotier_reconciliation_service.reconcile_expired_activations()
results["expired_activations"] = expired
except Exception as exc:
logger.error(f"[ZT Reconcile] Error expiring activations: {exc}")
try:
summary = zerotier_reconciliation_service.reconcile_all()
results["networks_processed"] = summary.get("networks_processed", 0)
results["errors"] = summary.get("errors", 0)
except Exception as exc:
logger.error(f"[ZT Reconcile] Error during network reconciliation: {exc}")
results["errors"] += 1
logger.info(
f"[ZT Reconcile] Complete at {datetime.now(timezone.utc).isoformat()}: "
f"expired={results['expired_activations']} "
f"networks={results['networks_processed']} "
f"errors={results['errors']}"
)
return results
+29 -1
View File
@@ -17,6 +17,9 @@ models.ssh_ca — CA, KeyType, CertType, CaType, CAPermission,
CertificateAuditLog
models.security OrganizationSecurityPolicy, UserSecurityPolicy,
MfaPolicyCompliance
models.zerotier PortalNetwork, Device, UserNetworkApproval,
DeviceNetworkMembership, ActivationSession,
ZeroTierMembership, KillSwitchEvent
All names are re-exported here so that existing code using the flat import
style (``from gatehouse_app.models import X``) or the old per-file style
@@ -90,9 +93,26 @@ from gatehouse_app.models.ssh_ca.certificate_audit_log import ( # noqa: F401
)
# ── Security ──────────────────────────────────────────────────────────────────
from gatehouse_app.models.security.organization_security_policy import ( # noqa: F401
from gatehouse_app.models.security.organization_security_policy import (
OrganizationSecurityPolicy,
)
from gatehouse_app.models.security.user_security_policy import (
UserSecurityPolicy,
)
from gatehouse_app.models.security.mfa_policy_compliance import (
MfaPolicyCompliance,
)
# ── ZeroTier / Portal Network ─────────────────────────────────────────────────
from gatehouse_app.models.zerotier import ( # noqa: F401
PortalNetwork,
Device,
UserNetworkApproval,
DeviceNetworkMembership,
ActivationSession,
ZeroTierMembership,
KillSwitchEvent,
)
from gatehouse_app.models.security.user_security_policy import ( # noqa: F401
UserSecurityPolicy,
)
@@ -147,4 +167,12 @@ __all__ = [
"OrganizationSecurityPolicy",
"UserSecurityPolicy",
"MfaPolicyCompliance",
# ZeroTier
"PortalNetwork",
"Device",
"UserNetworkApproval",
"DeviceNetworkMembership",
"ActivationSession",
"ZeroTierMembership",
"KillSwitchEvent",
]
+18
View File
@@ -0,0 +1,18 @@
"""ZeroTier / Portal Network models.
PortalNetwork manager-created network bound to a ZT network ID
Device user-registered ZeroTier node endpoint
UserNetworkApproval durable manager approval for network access
DeviceNetworkMembership per-device per-network workflow record
ActivationSession temporary activation window
ZeroTierMembership observed controller-side member state
KillSwitchEvent explicit rapid deactivation record
"""
from gatehouse_app.models.zerotier.activation_session import ActivationSession # noqa: F401
from gatehouse_app.models.zerotier.device import Device # noqa: F401
from gatehouse_app.models.zerotier.device_network_membership import DeviceNetworkMembership # noqa: F401
from gatehouse_app.models.zerotier.kill_switch_event import KillSwitchEvent # noqa: F401
from gatehouse_app.models.zerotier.portal_network import PortalNetwork # noqa: F401
from gatehouse_app.models.zerotier.user_network_approval import UserNetworkApproval # noqa: F401
from gatehouse_app.models.zerotier.zerotier_membership import ZeroTierMembership # noqa: F401
@@ -0,0 +1,107 @@
"""Activation session model — temporary activation window for a device membership."""
from gatehouse_app.extensions import db
from gatehouse_app.models.base import BaseModel
from gatehouse_app.utils.constants import ActivationEndReason
class ActivationSession(BaseModel):
"""A temporary activation window for an already-approved device membership.
Created when a user re-authenticates in the portal and wants to activate
an approved device on a network. Has a fixed lifetime (e.g. 8 hours).
When it expires the membership is de-authorized in ZeroTier but the
underlying approval record is untouched.
Attributes:
organization_id: FK to the organization
user_id: FK to the user who owns the session
device_network_membership_id: FK to the related membership
authenticated_at: When the user re-authenticated to start this session
expires_at: When the activation window ends
ended_at: When the session was explicitly ended (null if still active)
end_reason: Why the session ended (expired, logout, kill_switch, etc.)
created_by: FK to the user who triggered activation (usually same as user_id)
"""
__tablename__ = "activation_sessions"
organization_id = db.Column(
db.String(36),
db.ForeignKey("organizations.id"),
nullable=False,
index=True,
)
user_id = db.Column(
db.String(36),
db.ForeignKey("users.id"),
nullable=False,
index=True,
)
device_network_membership_id = db.Column(
db.String(36),
db.ForeignKey("device_network_memberships.id"),
nullable=False,
index=True,
)
authenticated_at = db.Column(
db.DateTime(timezone=True),
nullable=False,
)
expires_at = db.Column(
db.DateTime(timezone=True),
nullable=False,
)
ended_at = db.Column(db.DateTime(timezone=True), nullable=True)
end_reason = db.Column(
db.Enum(ActivationEndReason, name="activation_end_reason"),
nullable=True,
)
created_by = db.Column(
db.String(36),
db.ForeignKey("users.id"),
nullable=False,
)
# Relationships
organization = db.relationship("Organization", backref="activation_sessions")
user = db.relationship(
"User",
foreign_keys=[user_id],
backref="activation_sessions",
)
created_by_user = db.relationship(
"User",
foreign_keys=[created_by],
backref="created_activation_sessions",
)
membership = db.relationship(
"DeviceNetworkMembership",
back_populates="activation_sessions",
)
def __repr__(self):
return (
f"<ActivationSession membership={self.device_network_membership_id} "
f"expires={self.expires_at}>"
)
@property
def is_expired(self) -> bool:
from datetime import datetime, timezone
now = datetime.now(timezone.utc)
exp = self.expires_at
if exp.tzinfo is None:
exp = exp.replace(tzinfo=timezone.utc)
return now > exp
@property
def is_active(self) -> bool:
return self.ended_at is None and not self.is_expired
def to_dict(self, exclude=None):
exclude = exclude or []
data = super().to_dict(exclude=exclude)
data["is_expired"] = self.is_expired
data["is_active"] = self.is_active
return data
+79
View File
@@ -0,0 +1,79 @@
"""Device model — a user-registered ZeroTier node endpoint."""
from gatehouse_app.extensions import db
from gatehouse_app.models.base import BaseModel
from gatehouse_app.utils.constants import DeviceStatus
class Device(BaseModel):
"""A user-owned endpoint identified by a ZeroTier Node ID.
A user registers their device in the portal using the 10-character ZeroTier
Node ID visible in the ZeroTier client. One active device record per
node_id at a time (unique constraint excludes soft-deleted duplicates).
Attributes:
user_id: FK to the owning user
organization_id: FK to the organization this device is registered in
node_id: 10-char hex ZeroTier node / device address
device_nickname: User-assigned friendly name
hostname: Device hostname reported by the client
asset_tag: Corporate asset tag if available
serial_number: Device serial number if available
status: active / inactive
"""
__tablename__ = "devices"
user_id = db.Column(
db.String(36),
db.ForeignKey("users.id"),
nullable=False,
index=True,
)
organization_id = db.Column(
db.String(36),
db.ForeignKey("organizations.id"),
nullable=False,
index=True,
)
node_id = db.Column(
db.String(10),
nullable=False,
index=True,
)
device_nickname = db.Column(db.String(255), nullable=True)
hostname = db.Column(db.String(255), nullable=True)
asset_tag = db.Column(db.String(255), nullable=True)
serial_number = db.Column(db.String(255), nullable=True)
status = db.Column(
db.Enum(DeviceStatus, name="device_status"),
default=DeviceStatus.ACTIVE,
nullable=False,
)
# Relationships
user = db.relationship("User", backref="devices")
organization = db.relationship("Organization", backref="devices")
memberships = db.relationship(
"DeviceNetworkMembership",
back_populates="device",
cascade="all, delete-orphan",
)
def __repr__(self):
return f"<Device {self.node_id} ({self.device_nickname or 'unnamed'})>"
@property
def display_name(self) -> str:
return self.device_nickname or self.hostname or self.node_id
def to_dict(self, exclude=None):
exclude = exclude or []
data = super().to_dict(exclude=exclude)
data["display_name"] = self.display_name
data["active_membership_count"] = sum(
1 for m in self.memberships
if m.state == "active_authorized" and m.deleted_at is None
)
return data
@@ -0,0 +1,129 @@
"""Device network membership — per-device, per-network workflow object."""
from gatehouse_app.extensions import db
from gatehouse_app.models.base import BaseModel
from gatehouse_app.utils.constants import MembershipState
class DeviceNetworkMembership(BaseModel):
"""The main per-device, per-network workflow record.
This binds a specific Device to a specific PortalNetwork through a
UserNetworkApproval. It tracks both the internal portal state and the
observed ZeroTier membership state.
States:
pending_device_registration approval exists but no device registered yet
pending_request user has requested access but not yet approved
pending_manager_approval approval pending manager sign-off
approved_inactive approved but not currently active
joined_deauthorized device has joined ZT network but not authorized
active_authorized authorized and actively connected
activation_expired activation window ended (member still in ZT, deauth'd)
suspended temporarily suspended
revoked permanently revoked
rejected request was rejected
"""
__tablename__ = "device_network_memberships"
organization_id = db.Column(
db.String(36),
db.ForeignKey("organizations.id"),
nullable=False,
index=True,
)
user_id = db.Column(
db.String(36),
db.ForeignKey("users.id"),
nullable=False,
index=True,
)
device_id = db.Column(
db.String(36),
db.ForeignKey("devices.id"),
nullable=False,
index=True,
)
portal_network_id = db.Column(
db.String(36),
db.ForeignKey("portal_networks.id"),
nullable=False,
index=True,
)
user_network_approval_id = db.Column(
db.String(36),
db.ForeignKey("user_network_approvals.id"),
nullable=True,
index=True,
)
state = db.Column(
db.Enum(MembershipState, name="membership_state"),
default=MembershipState.PENDING_DEVICE_REGISTRATION,
nullable=False,
index=True,
)
join_seen = db.Column(db.Boolean, default=False, nullable=False)
currently_authorized = db.Column(db.Boolean, default=False, nullable=False)
approved_for_activation = db.Column(db.Boolean, default=True, nullable=False)
# Relationships
organization = db.relationship("Organization", backref="network_memberships")
user = db.relationship("User", backref="network_memberships")
device = db.relationship("Device", back_populates="memberships")
portal_network = db.relationship(
"PortalNetwork",
back_populates="memberships",
)
approval = db.relationship(
"UserNetworkApproval",
back_populates="memberships",
)
activation_sessions = db.relationship(
"ActivationSession",
back_populates="membership",
cascade="all, delete-orphan",
)
zerotier_membership = db.relationship(
"ZeroTierMembership",
back_populates="device_network_membership",
uselist=False,
cascade="all, delete-orphan",
)
__table_args__ = (
db.UniqueConstraint(
"device_id",
"portal_network_id",
"deleted_at",
name="uix_device_network",
),
)
def __repr__(self):
return (
f"<DeviceNetworkMembership device={self.device_id} "
f"network={self.portal_network_id} state={self.state}>"
)
@property
def active_session(self):
"""Return the current active ActivationSession, if any."""
for s in self.activation_sessions:
if s.ended_at is None and s.expires_at is not None:
from datetime import datetime, timezone
now = datetime.now(timezone.utc)
exp = s.expires_at
if exp.tzinfo is None:
exp = exp.replace(tzinfo=timezone.utc)
if exp > now:
return s
return None
def to_dict(self, exclude=None):
exclude = exclude or []
data = super().to_dict(exclude=exclude)
data["active_session"] = (
self.active_session.to_dict() if self.active_session else None
)
return data
@@ -0,0 +1,70 @@
"""Kill switch event model — explicit record of rapid deactivation actions."""
from gatehouse_app.extensions import db
from gatehouse_app.models.base import BaseModel
from gatehouse_app.utils.constants import KillSwitchScope
class KillSwitchEvent(BaseModel):
"""An explicit record of a kill switch action.
Created whenever an administrator or manager triggers a kill switch on a user.
Append-only kill switch events are never modified or deleted.
Attributes:
organization_id: FK to the organization where the kill switch was triggered
target_user_id: FK to the user whose access was revoked
scope: organization | global | selected_networks
triggered_by_user_id: FK to the admin/manager who pulled the kill switch
reason: Free-text reason for the action
network_ids: JSON list of network IDs if scope is SELECTED_NETWORKS
"""
__tablename__ = "kill_switch_events"
organization_id = db.Column(
db.String(36),
db.ForeignKey("organizations.id"),
nullable=False,
index=True,
)
target_user_id = db.Column(
db.String(36),
db.ForeignKey("users.id"),
nullable=False,
index=True,
)
scope = db.Column(
db.Enum(KillSwitchScope, name="kill_switch_scope"),
default=KillSwitchScope.ORGANIZATION,
nullable=False,
)
triggered_by_user_id = db.Column(
db.String(36),
db.ForeignKey("users.id"),
nullable=False,
)
reason = db.Column(db.Text, nullable=True)
network_ids = db.Column(db.JSON, nullable=True) # list of network IDs if scope=selected
# Relationships
organization = db.relationship(
"Organization",
backref="kill_switch_events",
)
target_user = db.relationship(
"User",
foreign_keys=[target_user_id],
backref="kill_switch_events",
)
triggered_by = db.relationship(
"User",
foreign_keys=[triggered_by_user_id],
backref="triggered_kill_switches",
)
def __repr__(self):
return (
f"<KillSwitchEvent target={self.target_user_id} "
f"scope={self.scope} by={self.triggered_by_user_id}>"
)
@@ -0,0 +1,100 @@
"""Portal network model — a manager-created ZeroTier network binding."""
from gatehouse_app.extensions import db
from gatehouse_app.models.base import BaseModel
from gatehouse_app.utils.constants import NetworkEnvironment, NetworkRequestMode
class PortalNetwork(BaseModel):
"""A business/admin object representing a ZeroTier network under management.
Each PortalNetwork maps one-to-one to exactly one ZeroTier network ID.
Networks are scoped to an organization and owned by a manager.
Attributes:
organization_id: FK to the owning organization
name: Human-readable display name
description: Free-text description
owner_user_id: FK to the user who manages this network
zerotier_network_id: The 16-char hex ZeroTier network ID
environment: Environment tag (production, staging, etc.)
request_mode: How users request access (open, approval_required, invite_only)
default_activation_lifetime_minutes: Default session length when user activates
max_activation_lifetime_minutes: Cap on activation lifetime
is_active: Whether this network is live
"""
__tablename__ = "portal_networks"
organization_id = db.Column(
db.String(36),
db.ForeignKey("organizations.id"),
nullable=False,
index=True,
)
name = db.Column(db.String(255), nullable=False)
description = db.Column(db.Text, nullable=True)
owner_user_id = db.Column(
db.String(36),
db.ForeignKey("users.id"),
nullable=False,
)
zerotier_network_id = db.Column(
db.String(16),
nullable=False,
index=True,
)
environment = db.Column(
db.Enum(NetworkEnvironment, name="network_environment"),
default=NetworkEnvironment.DEVELOPMENT,
nullable=False,
)
request_mode = db.Column(
db.Enum(NetworkRequestMode, name="network_request_mode"),
default=NetworkRequestMode.APPROVAL_REQUIRED,
nullable=False,
)
default_activation_lifetime_minutes = db.Column(
db.Integer,
default=480, # 8 hours
nullable=False,
)
max_activation_lifetime_minutes = db.Column(db.Integer, nullable=True)
is_active = db.Column(db.Boolean, default=True, nullable=False)
# Relationships
organization = db.relationship("Organization", backref="portal_networks")
owner = db.relationship("User", backref="owned_networks")
approvals = db.relationship(
"UserNetworkApproval",
back_populates="portal_network",
cascade="all, delete-orphan",
)
memberships = db.relationship(
"DeviceNetworkMembership",
back_populates="portal_network",
cascade="all, delete-orphan",
)
__table_args__ = (
db.UniqueConstraint(
"organization_id",
"zerotier_network_id",
name="uix_org_zt_network_id",
),
)
def __repr__(self):
return f"<PortalNetwork {self.name} ({self.zerotier_network_id})>"
def to_dict(self, exclude=None):
exclude = exclude or []
data = super().to_dict(exclude=exclude)
data["approved_user_count"] = sum(
1 for a in self.approvals if a.state == "approved" and a.deleted_at is None
)
data["active_membership_count"] = sum(
1 for m in self.memberships
if m.state == "active_authorized" and m.deleted_at is None
)
return data
@@ -0,0 +1,106 @@
"""User network approval model — durable manager approval for network access."""
from gatehouse_app.extensions import db
from gatehouse_app.models.base import BaseModel
from gatehouse_app.utils.constants import ApprovalGrantType, ApprovalState
class UserNetworkApproval(BaseModel):
"""A durable approval record binding a user to a portal network.
This is the business-level approval separate from any device and separate
from activation sessions. Manager approval survives across days and only
needs to be issued once unless explicitly revoked.
Attributes:
organization_id: FK to the organization
user_id: FK to the approved user
portal_network_id: FK to the portal network
granted_by_user_id: FK to the manager who approved (null for system-assigned)
grant_type: requested (user-initiated) or assigned (manager-initiated)
state: pending / approved / rejected / revoked / suspended
justification: Business reason for the approval
"""
__tablename__ = "user_network_approvals"
organization_id = db.Column(
db.String(36),
db.ForeignKey("organizations.id"),
nullable=False,
index=True,
)
user_id = db.Column(
db.String(36),
db.ForeignKey("users.id"),
nullable=False,
index=True,
)
portal_network_id = db.Column(
db.String(36),
db.ForeignKey("portal_networks.id"),
nullable=False,
index=True,
)
granted_by_user_id = db.Column(
db.String(36),
db.ForeignKey("users.id"),
nullable=True,
)
grant_type = db.Column(
db.Enum(ApprovalGrantType, name="approval_grant_type"),
default=ApprovalGrantType.REQUESTED,
nullable=False,
)
state = db.Column(
db.Enum(ApprovalState, name="approval_state"),
default=ApprovalState.PENDING,
nullable=False,
index=True,
)
justification = db.Column(db.Text, nullable=True)
# Relationships
organization = db.relationship("Organization", backref="network_approvals")
user = db.relationship(
"User",
foreign_keys=[user_id],
backref="network_approvals",
)
granted_by = db.relationship(
"User",
foreign_keys=[granted_by_user_id],
backref="granted_approvals",
)
portal_network = db.relationship(
"PortalNetwork",
back_populates="approvals",
)
memberships = db.relationship(
"DeviceNetworkMembership",
back_populates="approval",
cascade="all, delete-orphan",
)
__table_args__ = (
db.UniqueConstraint(
"user_id",
"portal_network_id",
"deleted_at",
name="uix_user_network_approval",
),
)
def __repr__(self):
return (
f"<UserNetworkApproval user={self.user_id} "
f"network={self.portal_network_id} state={self.state}>"
)
def to_dict(self, exclude=None):
exclude = exclude or []
data = super().to_dict(exclude=exclude)
data["active_membership_count"] = sum(
1 for m in self.memberships if m.deleted_at is None
)
return data
@@ -0,0 +1,82 @@
"""ZeroTier membership model — observed controller-side member state."""
from gatehouse_app.extensions import db
from gatehouse_app.models.base import BaseModel
class ZeroTierMembership(BaseModel):
"""Observed state of a node in a ZeroTier network from the controller API.
This is maintained as a cache of controller-side state, updated by the
reconciliation loop and by direct API calls. The raw_controller_payload
column stores the full API response for debugging and audit.
Keyed by zerotier_network_id + node_id (unique constraint).
Attributes:
organization_id: FK to the organization
device_network_membership_id: FK to the portal's membership record (nullable)
zerotier_network_id: The 16-char hex ZeroTier network ID
node_id: The 10-char hex ZeroTier node ID
member_seen: Whether the controller has ever seen this member
authorized: Current authorization state from ZeroTier
join_seen_at: When the member was first seen joining
last_synced_at: When we last polled ZeroTier for this member
raw_controller_payload: Full API response for debugging
"""
__tablename__ = "zerotier_memberships"
organization_id = db.Column(
db.String(36),
db.ForeignKey("organizations.id"),
nullable=False,
index=True,
)
device_network_membership_id = db.Column(
db.String(36),
db.ForeignKey("device_network_memberships.id"),
nullable=True,
index=True,
)
zerotier_network_id = db.Column(
db.String(16),
nullable=False,
index=True,
)
node_id = db.Column(
db.String(10),
nullable=False,
index=True,
)
member_seen = db.Column(db.Boolean, default=False, nullable=False)
authorized = db.Column(db.Boolean, default=False, nullable=False)
join_seen_at = db.Column(db.DateTime(timezone=True), nullable=True)
last_synced_at = db.Column(db.DateTime(timezone=True), nullable=True)
raw_controller_payload = db.Column(db.JSON, nullable=True)
# Relationships
organization = db.relationship("Organization", backref="zerotier_memberships")
device_network_membership = db.relationship(
"DeviceNetworkMembership",
back_populates="zerotier_membership",
)
__table_args__ = (
db.UniqueConstraint(
"zerotier_network_id",
"node_id",
name="uix_zt_network_node",
),
)
def __repr__(self):
return (
f"<ZeroTierMembership network={self.zerotier_network_id} "
f"node={self.node_id} authorized={self.authorized}>"
)
def to_dict(self, exclude=None):
exclude = exclude or []
data = super().to_dict(exclude=exclude)
return data
+186
View File
@@ -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,
)
+78
View File
@@ -213,3 +213,81 @@ class MfaRequirementOverride(str, Enum):
INHERIT = "inherit"
REQUIRED = "required"
EXEMPT = "exempt"
# ── ZeroTier / Portal Network ────────────────────────────────────────────────
class NetworkEnvironment(str, Enum):
"""Environment tag for a portal network."""
PRODUCTION = "production"
STAGING = "staging"
DEVELOPMENT = "development"
LAB = "lab"
class NetworkRequestMode(str, Enum):
"""How users request access to a portal network."""
OPEN = "open" # anyone in the org can request
APPROVAL_REQUIRED = "approval_required" # manager must approve
INVITE_ONLY = "invite_only" # only managers can assign
class ApprovalGrantType(str, Enum):
"""How a user was granted network access."""
REQUESTED = "requested" # user initiated
ASSIGNED = "assigned" # manager initiated
class ApprovalState(str, Enum):
"""State of a user network approval record."""
PENDING = "pending"
APPROVED = "approved"
REJECTED = "rejected"
REVOKED = "revoked"
SUSPENDED = "suspended"
class MembershipState(str, Enum):
"""State of a device network membership record."""
PENDING_DEVICE_REGISTRATION = "pending_device_registration"
PENDING_REQUEST = "pending_request"
PENDING_MANAGER_APPROVAL = "pending_manager_approval"
APPROVED_INACTIVE = "approved_inactive"
JOINED_DEAUTHORIZED = "joined_deauthorized"
ACTIVE_AUTHORIZED = "active_authorized"
ACTIVATION_EXPIRED = "activation_expired"
SUSPENDED = "suspended"
REVOKED = "revoked"
REJECTED = "rejected"
class ActivationEndReason(str, Enum):
"""Why an activation session ended."""
EXPIRED = "expired"
LOGOUT = "logout"
KILL_SWITCH = "kill_switch"
MANUAL_REVOKE = "manual_revoke"
APPROVAL_REVOKED = "approval_revoked"
ADMIN_ACTION = "admin_action"
class KillSwitchScope(str, Enum):
"""Scope of a kill switch event."""
ORGANIZATION = "organization"
GLOBAL = "global"
SELECTED_NETWORKS = "selected_networks"
class DeviceStatus(str, Enum):
"""Status of a registered device."""
ACTIVE = "active"
INACTIVE = "inactive"
+808
View File
@@ -0,0 +1,808 @@
"""ZeroTier API client — reusable SDK for Secuird integration.
Supports both ZeroTier Central (hosted) and self-hosted controllers.
Central API (default):
Base URL: https://api.zerotier.com/api/v1
Auth: Authorization: token <api_token>
Docs: https://docs.zerotier.com/api/central/v1/
Self-hosted Controller API:
Base URL: http://<host>:9993
Auth: X-ZT1-Auth: <authtoken.secret>
Docs: https://docs.zerotier.com/api/service/v1/
"""
import logging
import re
import time
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum
from typing import Any, Optional
import requests
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Validation helpers
# ---------------------------------------------------------------------------
_NETWORK_ID_RE = re.compile(r"^[0-9a-fA-F]{16}$")
_NODE_ID_RE = re.compile(r"^[0-9a-fA-F]{10}$")
def validate_network_id(network_id: str) -> str:
"""Validate and normalise a 16-hex-char ZeroTier network ID."""
network_id = network_id.strip().lower()
if not _NETWORK_ID_RE.match(network_id):
raise ValueError(
f"Invalid ZeroTier network ID '{network_id}'"
"must be exactly 16 hexadecimal characters."
)
return network_id
def validate_node_id(node_id: str) -> str:
"""Validate and normalise a 10-hex-char ZeroTier node ID."""
node_id = node_id.strip().lower()
if not _NODE_ID_RE.match(node_id):
raise ValueError(
f"Invalid ZeroTier node ID '{node_id}'"
"must be exactly 10 hexadecimal characters."
)
return node_id
# ---------------------------------------------------------------------------
# API mode
# ---------------------------------------------------------------------------
class APIMode(Enum):
"""Which ZeroTier API flavour the client targets."""
CENTRAL = "central" # Hosted at api.zerotier.com
CONTROLLER = "controller" # Self-hosted zerotier-one service
# ---------------------------------------------------------------------------
# Data classes — typed representations of ZeroTier API objects
# ---------------------------------------------------------------------------
@dataclass
class ZTMemberConfig:
"""Subset of the member config block we care about."""
authorized: bool = False
active_bridge: bool = False
ip_assignments: list[str] = field(default_factory=list)
creation_time: Optional[int] = None
last_authorized_time: Optional[int] = None
last_deauthorized_time: Optional[int] = None
version_major: Optional[int] = None
version_minor: Optional[int] = None
version_rev: Optional[int] = None
@classmethod
def from_central(cls, data: dict) -> "ZTMemberConfig":
"""Parse config from Central API member.config block."""
return cls(
authorized=data.get("authorized", False),
active_bridge=data.get("activeBridge", False),
ip_assignments=data.get("ipAssignments", []),
creation_time=data.get("creationTime"),
last_authorized_time=data.get("lastAuthorizedTime"),
last_deauthorized_time=data.get("lastDeauthorizedTime"),
version_major=data.get("vMajor"),
version_minor=data.get("vMinor"),
version_rev=data.get("vRev"),
)
@classmethod
def from_controller(cls, data: dict) -> "ZTMemberConfig":
"""Parse config from self-hosted controller — fields are top-level."""
return cls(
authorized=data.get("authorized", False),
active_bridge=data.get("activeBridge", False),
ip_assignments=data.get("ipAssignments", []),
creation_time=data.get("creationTime"),
# Controller API does not return lastAuthorizedTime/lastDeauthorizedTime
version_major=data.get("vMajor"),
version_minor=data.get("vMinor"),
version_rev=data.get("vRev"),
)
@property
def version_string(self) -> str:
if self.version_major is not None:
return f"{self.version_major}.{self.version_minor}.{self.version_rev}"
return "unknown"
def to_dict(self) -> dict:
return {
"authorized": self.authorized,
"active_bridge": self.active_bridge,
"ip_assignments": self.ip_assignments,
"creation_time": self.creation_time,
"last_authorized_time": self.last_authorized_time,
"last_deauthorized_time": self.last_deauthorized_time,
"version_string": self.version_string,
}
@dataclass
class ZTMember:
"""A member (device) on a ZeroTier network."""
id: str # composite "{networkId}-{nodeId}" on Central, just nodeId on controller
network_id: str
node_id: str
name: Optional[str] = None
description: Optional[str] = None
hidden: bool = False
config: ZTMemberConfig = field(default_factory=ZTMemberConfig)
last_online: Optional[int] = None
last_seen: Optional[int] = None
physical_address: Optional[str] = None
client_version: Optional[str] = None
controller_id: Optional[str] = None
raw: dict = field(default_factory=dict, repr=False)
@classmethod
def from_central(cls, data: dict) -> "ZTMember":
"""Parse member from Central API response."""
config_data = data.get("config", {})
return cls(
id=data.get("id", ""),
network_id=data.get("networkId", ""),
node_id=data.get("nodeId", ""),
name=data.get("name"),
description=data.get("description"),
hidden=data.get("hidden", False),
config=ZTMemberConfig.from_central(config_data),
last_online=data.get("lastOnline"),
last_seen=data.get("lastSeen"),
physical_address=data.get("physicalAddress"),
client_version=data.get("clientVersion"),
controller_id=data.get("controllerId"),
raw=data,
)
@classmethod
def from_controller(cls, data: dict, network_id: str = "") -> "ZTMember":
"""Parse member from self-hosted controller API response.
Controller responses are flat authorized, ipAssignments etc. are
top-level keys, not nested under a config block.
"""
node_id = data.get("id", data.get("address", ""))
nwid = data.get("nwid", network_id)
return cls(
id=f"{nwid}-{node_id}",
network_id=nwid,
node_id=node_id,
name=None, # controller API doesn't have name/description
description=None,
hidden=False,
config=ZTMemberConfig.from_controller(data),
last_online=None, # not available in controller API
last_seen=None,
physical_address=None,
client_version=None,
controller_id=None,
raw=data,
)
@property
def is_authorized(self) -> bool:
return self.config.authorized
@property
def display_name(self) -> str:
return self.name or self.node_id
@property
def ip_list(self) -> str:
return ", ".join(self.config.ip_assignments) if self.config.ip_assignments else "none"
@property
def last_seen_str(self) -> str:
if not self.last_seen or self.last_seen == 0:
return "never"
dt = datetime.fromtimestamp(self.last_seen / 1000, tz=timezone.utc)
delta = datetime.now(tz=timezone.utc) - dt
if delta.total_seconds() < 120:
return "just now"
if delta.total_seconds() < 3600:
return f"{int(delta.total_seconds() / 60)}m ago"
if delta.total_seconds() < 86400:
return f"{int(delta.total_seconds() / 3600)}h ago"
return f"{int(delta.days)}d ago"
def to_dict(self) -> dict:
return {
"id": self.id,
"network_id": self.network_id,
"node_id": self.node_id,
"name": self.name,
"description": self.description,
"hidden": self.hidden,
"is_authorized": self.is_authorized,
"display_name": self.display_name,
"ip_list": self.ip_list,
"last_online": self.last_online,
"last_seen": self.last_seen,
"last_seen_str": self.last_seen_str,
"client_version": self.client_version,
"controller_id": self.controller_id,
"config": self.config.to_dict(),
}
@dataclass
class ZTNetworkConfig:
"""Subset of the network config block."""
name: str = ""
private: bool = True
creation_time: Optional[int] = None
ip_assignment_pools: list[dict] = field(default_factory=list)
routes: list[dict] = field(default_factory=list)
@classmethod
def from_central(cls, data: dict) -> "ZTNetworkConfig":
"""Parse from Central API network.config block."""
return cls(
name=data.get("name", ""),
private=data.get("private", True),
creation_time=data.get("creationTime"),
ip_assignment_pools=data.get("ipAssignmentPools", []),
routes=data.get("routes", []),
)
@classmethod
def from_controller(cls, data: dict) -> "ZTNetworkConfig":
"""Parse from self-hosted controller — fields are top-level."""
return cls(
name=data.get("name", ""),
private=data.get("private", True),
creation_time=data.get("creationTime"),
ip_assignment_pools=data.get("ipAssignmentPools", []),
routes=data.get("routes", []),
)
def to_dict(self) -> dict:
return {
"name": self.name,
"private": self.private,
"creation_time": self.creation_time,
"ip_assignment_pools": self.ip_assignment_pools,
"routes": self.routes,
}
@dataclass
class ZTNetwork:
"""A ZeroTier virtual network."""
id: str
config: ZTNetworkConfig = field(default_factory=ZTNetworkConfig)
description: Optional[str] = None
owner_id: Optional[str] = None
online_member_count: int = 0
authorized_member_count: int = 0
total_member_count: int = 0
raw: dict = field(default_factory=dict, repr=False)
@classmethod
def from_central(cls, data: dict) -> "ZTNetwork":
"""Parse from Central API response."""
config_data = data.get("config", {})
return cls(
id=data.get("id", ""),
config=ZTNetworkConfig.from_central(config_data),
description=data.get("description"),
owner_id=data.get("ownerId"),
online_member_count=data.get("onlineMemberCount", 0),
authorized_member_count=data.get("authorizedMemberCount", 0),
total_member_count=data.get("totalMemberCount", 0),
raw=data,
)
@classmethod
def from_controller(cls, data: dict) -> "ZTNetwork":
"""Parse from self-hosted controller response — flat structure."""
return cls(
id=data.get("id", data.get("nwid", "")),
config=ZTNetworkConfig.from_controller(data),
description=None, # controller API doesn't have description
owner_id=None,
online_member_count=0, # not available from controller
authorized_member_count=0,
total_member_count=0,
raw=data,
)
@property
def name(self) -> str:
return self.config.name or self.id
def to_dict(self) -> dict:
return {
"id": self.id,
"name": self.name,
"description": self.description,
"owner_id": self.owner_id,
"online_member_count": self.online_member_count,
"authorized_member_count": self.authorized_member_count,
"total_member_count": self.total_member_count,
"config": self.config.to_dict(),
}
# ---------------------------------------------------------------------------
# Exceptions
# ---------------------------------------------------------------------------
class ZeroTierAPIError(Exception):
"""Base exception for ZeroTier API errors."""
def __init__(self, message: str, status_code: Optional[int] = None, response: Optional[dict] = None):
super().__init__(message)
self.status_code = status_code
self.response = response
class ZeroTierAuthError(ZeroTierAPIError):
"""401/403 from ZeroTier — bad or missing token."""
pass
class ZeroTierNotFoundError(ZeroTierAPIError):
"""404 — network or member not found."""
pass
class ZeroTierRateLimitError(ZeroTierAPIError):
"""429 — rate limited."""
pass
# ---------------------------------------------------------------------------
# Client
# ---------------------------------------------------------------------------
class ZeroTierClient:
"""HTTP client for ZeroTier — supports both Central and self-hosted controllers.
Designed to be lifted into Secuird's backend as-is. All methods are
instance methods so the client can be instantiated with different tokens
(e.g. per-organization tokens in a multi-tenant setup).
Usage Central (default):
client = ZeroTierClient(api_token="your-central-token")
Usage Self-hosted controller:
client = ZeroTierClient(
api_token="contents-of-authtoken.secret",
base_url="http://my-controller:9993",
mode=APIMode.CONTROLLER,
)
Then:
network = client.get_network("8056c2e21c000001")
members = client.list_members("8056c2e21c000001")
client.authorize_member("8056c2e21c000001", "abcdef0123")
client.deauthorize_member("8056c2e21c000001", "abcdef0123")
"""
DEFAULT_CENTRAL_URL = "https://api.zerotier.com/api/v1"
DEFAULT_CONTROLLER_URL = "http://localhost:9993"
DEFAULT_TIMEOUT = 15 # seconds
def __init__(
self,
api_token: str,
base_url: Optional[str] = None,
mode: APIMode = APIMode.CENTRAL,
timeout: int = DEFAULT_TIMEOUT,
max_retries: int = 2,
):
if not api_token:
raise ValueError("ZeroTier API token is required.")
self._api_token = api_token
self._mode = mode
self._timeout = timeout
self._max_retries = max_retries
# Default base URL depends on mode
if base_url:
self._base_url = base_url.rstrip("/")
elif mode == APIMode.CENTRAL:
self._base_url = self.DEFAULT_CENTRAL_URL
else:
self._base_url = self.DEFAULT_CONTROLLER_URL
# Build session with correct auth header
self._session = requests.Session()
self._session.headers.update(
{
"Content-Type": "application/json",
"Accept": "application/json",
}
)
if mode == APIMode.CENTRAL:
self._session.headers["Authorization"] = f"token {self._api_token}"
else:
self._session.headers["X-ZT1-Auth"] = self._api_token
@property
def mode(self) -> APIMode:
return self._mode
@property
def base_url(self) -> str:
return self._base_url
# ------------------------------------------------------------------
# Low-level HTTP
# ------------------------------------------------------------------
def _request(
self,
method: str,
path: str,
json_body: Optional[dict] = None,
params: Optional[dict] = None,
) -> Any:
"""Execute an HTTP request with retry and error mapping."""
url = f"{self._base_url}{path}"
last_error: Optional[Exception] = None
for attempt in range(1, self._max_retries + 1):
try:
logger.debug(f"[ZT API] {method} {url} (attempt {attempt})")
resp = self._session.request(
method=method,
url=url,
json=json_body,
params=params,
timeout=self._timeout,
)
return self._handle_response(resp)
except ZeroTierRateLimitError:
if attempt < self._max_retries:
wait = 2 ** attempt
logger.warning(f"[ZT API] Rate limited, retrying in {wait}s...")
time.sleep(wait)
else:
raise
except requests.exceptions.Timeout as exc:
last_error = exc
if attempt < self._max_retries:
wait = 2 ** attempt
logger.warning(f"[ZT API] Timeout, retrying in {wait}s...")
time.sleep(wait)
else:
raise ZeroTierAPIError(f"Request timed out after {self._timeout}s") from exc
except requests.exceptions.ConnectionError as exc:
last_error = exc
if attempt < self._max_retries:
wait = 2 ** attempt
logger.warning(f"[ZT API] Connection error, retrying in {wait}s...")
time.sleep(wait)
else:
raise ZeroTierAPIError(
f"Connection to ZeroTier API failed at {self._base_url}"
) from exc
# Should not reach here, but just in case
raise ZeroTierAPIError("Request failed after retries") from last_error
def _handle_response(self, resp: requests.Response) -> Any:
"""Map HTTP status codes to typed exceptions."""
if resp.status_code == 200:
if resp.content:
return resp.json()
return None
# Try to parse error body
error_body = None
try:
error_body = resp.json()
except (ValueError, requests.exceptions.JSONDecodeError):
pass
msg = f"ZeroTier API error {resp.status_code}"
if error_body and isinstance(error_body, dict):
msg = error_body.get("message", msg)
if resp.status_code in (401, 403):
raise ZeroTierAuthError(msg, status_code=resp.status_code, response=error_body)
if resp.status_code == 404:
raise ZeroTierNotFoundError(msg, status_code=resp.status_code, response=error_body)
if resp.status_code == 429:
raise ZeroTierRateLimitError(msg, status_code=resp.status_code, response=error_body)
raise ZeroTierAPIError(msg, status_code=resp.status_code, response=error_body)
# ------------------------------------------------------------------
# Path builders — abstract away Central vs Controller URL differences
# ------------------------------------------------------------------
def _network_path(self, network_id: str = "") -> str:
"""Build the path prefix for network endpoints."""
if self._mode == APIMode.CENTRAL:
return f"/network/{network_id}" if network_id else "/network"
else:
return f"/controller/network/{network_id}" if network_id else "/controller/network"
def _member_path(self, network_id: str, node_id: str = "") -> str:
"""Build the path for member endpoints."""
base = self._network_path(network_id) + "/member"
if node_id:
base += f"/{node_id}"
return base
# ------------------------------------------------------------------
# Status / connectivity check
# ------------------------------------------------------------------
def get_status(self) -> dict:
"""Verify token and connectivity.
Central: GET /status returns account/user info
Controller: GET /controller returns controller status
"""
if self._mode == APIMode.CENTRAL:
return self._request("GET", "/status")
else:
# Combine /status (node info) and /controller (controller flag)
node_status = self._request("GET", "/status")
try:
ctrl_status = self._request("GET", "/controller")
node_status["_controller"] = ctrl_status
except ZeroTierAPIError:
node_status["_controller"] = None
return node_status
# ------------------------------------------------------------------
# Network operations
# ------------------------------------------------------------------
def list_networks(self) -> list[ZTNetwork]:
"""List all networks the token has access to."""
data = self._request("GET", self._network_path())
if self._mode == APIMode.CENTRAL:
# Central returns an array of full network objects
return [ZTNetwork.from_central(n) for n in data]
else:
# Controller returns an array of network ID strings
# e.g. ["3e245e31af000001", "3e245e31af000002"]
# Fetch each one for full details
networks = []
for nwid in data:
try:
net = self.get_network(nwid)
networks.append(net)
except ZeroTierAPIError:
logger.warning(f"[ZT API] Failed to fetch network {nwid}, skipping")
return networks
def get_network(self, network_id: str) -> ZTNetwork:
"""Fetch a single network by ID."""
network_id = validate_network_id(network_id)
data = self._request("GET", self._network_path(network_id))
if self._mode == APIMode.CENTRAL:
return ZTNetwork.from_central(data)
else:
return ZTNetwork.from_controller(data)
# ------------------------------------------------------------------
# Member operations — the core of what Secuird needs
# ------------------------------------------------------------------
def list_members(self, network_id: str) -> list[ZTMember]:
"""List all members on a network."""
network_id = validate_network_id(network_id)
data = self._request("GET", self._member_path(network_id))
if self._mode == APIMode.CENTRAL:
# Central returns an array of full member objects
return [ZTMember.from_central(m) for m in data]
else:
# Controller returns {"nodeId": revisionCounter, ...}
# We must fetch each member individually for full details
members = []
for node_id in data:
try:
member = self.get_member(network_id, node_id)
members.append(member)
except ZeroTierAPIError:
logger.warning(
f"[ZT API] Failed to fetch member {node_id} on "
f"network {network_id}, skipping"
)
return members
def get_member(self, network_id: str, node_id: str) -> ZTMember:
"""Fetch a single member on a network."""
network_id = validate_network_id(network_id)
node_id = validate_node_id(node_id)
data = self._request("GET", self._member_path(network_id, node_id))
if self._mode == APIMode.CENTRAL:
return ZTMember.from_central(data)
else:
return ZTMember.from_controller(data, network_id=network_id)
def _build_auth_body(self, authorized: bool) -> dict:
"""Build the JSON body for authorize/deauthorize calls.
Central nests under config: {"config": {"authorized": true}}
Controller is flat: {"authorized": true}
"""
if self._mode == APIMode.CENTRAL:
return {"config": {"authorized": authorized}}
else:
return {"authorized": authorized}
def authorize_member(self, network_id: str, node_id: str) -> ZTMember:
"""Authorize a member on a network.
This is the enforcement action: the device can now communicate
on the network.
"""
network_id = validate_network_id(network_id)
node_id = validate_node_id(node_id)
logger.info(f"[ZT API] Authorizing member {node_id} on network {network_id}")
data = self._request(
"POST",
self._member_path(network_id, node_id),
json_body=self._build_auth_body(True),
)
if self._mode == APIMode.CENTRAL:
return ZTMember.from_central(data)
return ZTMember.from_controller(data, network_id=network_id)
def deauthorize_member(self, network_id: str, node_id: str) -> ZTMember:
"""De-authorize a member on a network.
The member remains in the member list but cannot communicate.
This is the standard ZeroTier pattern for revoking access without
deleting the member record.
"""
network_id = validate_network_id(network_id)
node_id = validate_node_id(node_id)
logger.info(f"[ZT API] De-authorizing member {node_id} on network {network_id}")
data = self._request(
"POST",
self._member_path(network_id, node_id),
json_body=self._build_auth_body(False),
)
if self._mode == APIMode.CENTRAL:
return ZTMember.from_central(data)
return ZTMember.from_controller(data, network_id=network_id)
def add_member(self, network_id: str, node_id: str, authorized: bool = False) -> ZTMember:
"""Manually add a member to a network.
Creates the member record on the controller side. By default the
member is de-authorized (matching the Secuird workflow where
activation is a separate step).
"""
network_id = validate_network_id(network_id)
node_id = validate_node_id(node_id)
logger.info(
f"[ZT API] Adding member {node_id} to network {network_id} "
f"(authorized={authorized})"
)
data = self._request(
"POST",
self._member_path(network_id, node_id),
json_body=self._build_auth_body(authorized),
)
if self._mode == APIMode.CENTRAL:
return ZTMember.from_central(data)
return ZTMember.from_controller(data, network_id=network_id)
def delete_member(self, network_id: str, node_id: str) -> None:
"""Remove a member entirely.
Use with caution. In most Secuird workflows we de-authorize rather
than delete, so the member record persists for audit purposes.
Note: the self-hosted controller API does not document a DELETE
member endpoint; this may only work on Central.
"""
network_id = validate_network_id(network_id)
node_id = validate_node_id(node_id)
logger.warning(f"[ZT API] Deleting member {node_id} from network {network_id}")
self._request("DELETE", self._member_path(network_id, node_id))
def update_member(
self,
network_id: str,
node_id: str,
*,
name: Optional[str] = None,
description: Optional[str] = None,
authorized: Optional[bool] = None,
ip_assignments: Optional[list[str]] = None,
) -> ZTMember:
"""Update member metadata or config fields."""
network_id = validate_network_id(network_id)
node_id = validate_node_id(node_id)
if self._mode == APIMode.CENTRAL:
body: dict[str, Any] = {}
if name is not None:
body["name"] = name
if description is not None:
body["description"] = description
config: dict[str, Any] = {}
if authorized is not None:
config["authorized"] = authorized
if ip_assignments is not None:
config["ipAssignments"] = ip_assignments
if config:
body["config"] = config
else:
# Controller API is flat
body = {}
if authorized is not None:
body["authorized"] = authorized
if ip_assignments is not None:
body["ipAssignments"] = ip_assignments
# name/description not supported on controller API
data = self._request(
"POST",
self._member_path(network_id, node_id),
json_body=body,
)
if self._mode == APIMode.CENTRAL:
return ZTMember.from_central(data)
return ZTMember.from_controller(data, network_id=network_id)
# ------------------------------------------------------------------
# Bulk operations — useful for kill-switch and reconciliation
# ------------------------------------------------------------------
def deauthorize_all_members(self, network_id: str) -> list[ZTMember]:
"""De-authorize every member on a network. Returns updated members."""
members = self.list_members(network_id)
results = []
for m in members:
if m.is_authorized:
updated = self.deauthorize_member(network_id, m.node_id)
results.append(updated)
return results
def get_authorized_members(self, network_id: str) -> list[ZTMember]:
"""Return only currently authorized members."""
return [m for m in self.list_members(network_id) if m.is_authorized]
def get_online_members(self, network_id: str) -> list[ZTMember]:
"""Return members seen in the last 5 minutes (Central API only).
The self-hosted controller API does not expose lastOnline, so this
will return an empty list in controller mode.
"""
cutoff = (int(time.time()) - 300) * 1000 # ms
return [
m for m in self.list_members(network_id)
if m.last_online and m.last_online > cutoff
]
+41
View File
@@ -72,6 +72,47 @@ def run_mfa_compliance_job():
print("=" * 60)
@cli.command("run_zerotier_reconciliation")
def run_zerotier_reconciliation():
"""Run the ZeroTier network reconciliation scheduled job.
This command:
- Expires activation sessions past their TTL and deauthorizes ZT members
- Syncs observed ZeroTier membership state into the portal cache
- Reconciles portal membership state against ZT controller state
- Detects and repairs drift between portal and ZT
Usage:
python manage.py run_zerotier_reconciliation
Cron example (every 2 minutes):
*/2 * * * * cd /path/to/app && python manage.py run_zerotier_reconciliation
"""
from datetime import datetime, timezone
from gatehouse_app.jobs.zerotier_reconciliation_job import run_reconciliation
print("=" * 60)
print("ZeroTier Reconciliation Job")
print("=" * 60)
now = datetime.now(timezone.utc)
print(f"Start time: {now.isoformat()}")
print()
result = run_reconciliation()
print()
print("Job Results:")
print(f" Expired activations: {result['expired_activations']}")
print(f" Networks processed: {result['networks_processed']}")
print(f" Errors: {result['errors']}")
print()
print("=" * 60)
print("Job completed successfully")
print("=" * 60)
@cli.command("mfa_compliance_status")
def mfa_compliance_status():
"""Show current MFA compliance status.
@@ -0,0 +1,317 @@
"""Add ZeroTier / Portal Network models.
Revision ID: 020_zerotier
Revises: 019_audit_varchar
Create Date: 2026-03-19
Tables created:
- portal_networks manager-created ZeroTier network bindings
- devices user-registered ZeroTier node endpoints
- user_network_approvals durable manager approval records
- device_network_memberships per-device per-network workflow records
- activation_sessions temporary activation windows
- zerotier_memberships observed controller-side member state
- kill_switch_events explicit rapid deactivation records
"""
from alembic import op
import sqlalchemy as sa
revision = "020_zerotier"
down_revision = "019_audit_varchar"
branch_labels = None
depends_on = None
def _pg_enum(enum_name: str, values: list[str]) -> sa.Enum:
return sa.Enum(*values, name=enum_name, create_type=False)
def upgrade():
bind = op.get_bind()
dialect = bind.dialect.name
# ── 1. Enum types ─────────────────────────────────────────────────────────
if dialect == "postgresql":
op.execute("CREATE TYPE network_environment AS ENUM (%s)" % ", ".join(
f"'{v}'" for v in ["production", "staging", "development", "lab"]
))
op.execute("CREATE TYPE network_request_mode AS ENUM (%s)" % ", ".join(
f"'{v}'" for v in ["open", "approval_required", "invite_only"]
))
op.execute("CREATE TYPE approval_grant_type AS ENUM (%s)" % ", ".join(
f"'{v}'" for v in ["requested", "assigned"]
))
op.execute("CREATE TYPE approval_state AS ENUM (%s)" % ", ".join(
f"'{v}'" for v in ["pending", "approved", "rejected", "revoked", "suspended"]
))
op.execute("CREATE TYPE membership_state AS ENUM (%s)" % ", ".join(
f"'{v}'" for v in [
"pending_device_registration",
"pending_request",
"pending_manager_approval",
"approved_inactive",
"joined_deauthorized",
"active_authorized",
"activation_expired",
"suspended",
"revoked",
"rejected",
]
))
op.execute("CREATE TYPE activation_end_reason AS ENUM (%s)" % ", ".join(
f"'{v}'" for v in [
"expired", "logout", "kill_switch",
"manual_revoke", "approval_revoked", "admin_action",
]
))
op.execute("CREATE TYPE kill_switch_scope AS ENUM (%s)" % ", ".join(
f"'{v}'" for v in ["organization", "global", "selected_networks"]
))
op.execute("CREATE TYPE device_status AS ENUM (%s)" % ", ".join(
f"'{v}'" for v in ["active", "inactive"]
))
# ── 2. portal_networks ────────────────────────────────────────────────────
op.create_table(
"portal_networks",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("organization_id", sa.String(36), sa.ForeignKey("organizations.id"), nullable=False, index=True),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("description", sa.Text, nullable=True),
sa.Column("owner_user_id", sa.String(36), sa.ForeignKey("users.id"), nullable=False),
sa.Column("zerotier_network_id", sa.String(16), nullable=False, index=True),
sa.Column(
"environment",
_pg_enum("network_environment", ["production", "staging", "development", "lab"]) if dialect == "postgresql"
else sa.String(20),
nullable=False,
),
sa.Column(
"request_mode",
_pg_enum("network_request_mode", ["open", "approval_required", "invite_only"]) if dialect == "postgresql"
else sa.String(20),
nullable=False,
),
sa.Column("default_activation_lifetime_minutes", sa.Integer, nullable=False, default=480),
sa.Column("max_activation_lifetime_minutes", sa.Integer, nullable=True),
sa.Column("is_active", sa.Boolean, nullable=False, default=True),
)
op.create_index(
"ix_portal_networks_org_zt",
"portal_networks",
["organization_id", "zerotier_network_id"],
unique=True,
postgresql_where=sa.text("deleted_at IS NULL"),
)
# ── 3. devices ───────────────────────────────────────────────────────────
op.create_table(
"devices",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("user_id", sa.String(36), sa.ForeignKey("users.id"), nullable=False, index=True),
sa.Column("organization_id", sa.String(36), sa.ForeignKey("organizations.id"), nullable=False, index=True),
sa.Column("node_id", sa.String(10), nullable=False, index=True),
sa.Column("device_nickname", sa.String(255), nullable=True),
sa.Column("hostname", sa.String(255), nullable=True),
sa.Column("asset_tag", sa.String(255), nullable=True),
sa.Column("serial_number", sa.String(255), nullable=True),
sa.Column(
"status",
_pg_enum("device_status", ["active", "inactive"]) if dialect == "postgresql"
else sa.String(20),
nullable=False,
default="active",
),
)
if dialect == "postgresql":
op.create_index(
"ix_devices_node_id_active",
"devices",
["node_id"],
unique=True,
postgresql_where=sa.text("deleted_at IS NULL"),
)
else:
op.create_index("ix_devices_node_id", "devices", ["node_id"], unique=False)
# ── 4. user_network_approvals ─────────────────────────────────────────────
op.create_table(
"user_network_approvals",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("organization_id", sa.String(36), sa.ForeignKey("organizations.id"), nullable=False, index=True),
sa.Column("user_id", sa.String(36), sa.ForeignKey("users.id"), nullable=False, index=True),
sa.Column("portal_network_id", sa.String(36), sa.ForeignKey("portal_networks.id"), nullable=False, index=True),
sa.Column("granted_by_user_id", sa.String(36), sa.ForeignKey("users.id"), nullable=True),
sa.Column(
"grant_type",
_pg_enum("approval_grant_type", ["requested", "assigned"]) if dialect == "postgresql"
else sa.String(20),
nullable=False,
default="requested",
),
sa.Column(
"state",
_pg_enum("approval_state", ["pending", "approved", "rejected", "revoked", "suspended"]) if dialect == "postgresql"
else sa.String(20),
nullable=False,
default="pending",
index=True,
),
sa.Column("justification", sa.Text, nullable=True),
)
op.create_index(
"ix_user_network_approvals_user_network",
"user_network_approvals",
["user_id", "portal_network_id"],
unique=True,
postgresql_where=sa.text("deleted_at IS NULL"),
)
# ── 5. device_network_memberships ────────────────────────────────────────
op.create_table(
"device_network_memberships",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("organization_id", sa.String(36), sa.ForeignKey("organizations.id"), nullable=False, index=True),
sa.Column("user_id", sa.String(36), sa.ForeignKey("users.id"), nullable=False, index=True),
sa.Column("device_id", sa.String(36), sa.ForeignKey("devices.id"), nullable=False, index=True),
sa.Column("portal_network_id", sa.String(36), sa.ForeignKey("portal_networks.id"), nullable=False, index=True),
sa.Column("user_network_approval_id", sa.String(36), sa.ForeignKey("user_network_approvals.id"), nullable=True, index=True),
sa.Column(
"state",
_pg_enum(
"membership_state",
[
"pending_device_registration", "pending_request",
"pending_manager_approval", "approved_inactive",
"joined_deauthorized", "active_authorized",
"activation_expired", "suspended", "revoked", "rejected",
],
) if dialect == "postgresql" else sa.String(30),
nullable=False,
default="pending_device_registration",
index=True,
),
sa.Column("join_seen", sa.Boolean, nullable=False, default=False),
sa.Column("currently_authorized", sa.Boolean, nullable=False, default=False),
sa.Column("approved_for_activation", sa.Boolean, nullable=False, default=True),
)
op.create_index(
"ix_device_network_memberships_device_network",
"device_network_memberships",
["device_id", "portal_network_id"],
unique=True,
postgresql_where=sa.text("deleted_at IS NULL"),
)
# ── 6. activation_sessions ────────────────────────────────────────────────
op.create_table(
"activation_sessions",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("organization_id", sa.String(36), sa.ForeignKey("organizations.id"), nullable=False, index=True),
sa.Column("user_id", sa.String(36), sa.ForeignKey("users.id"), nullable=False, index=True),
sa.Column("device_network_membership_id", sa.String(36), sa.ForeignKey("device_network_memberships.id"), nullable=False, index=True),
sa.Column("authenticated_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("ended_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"end_reason",
_pg_enum(
"activation_end_reason",
["expired", "logout", "kill_switch", "manual_revoke", "approval_revoked", "admin_action"],
) if dialect == "postgresql" else sa.String(20),
nullable=True,
),
sa.Column("created_by", sa.String(36), sa.ForeignKey("users.id"), nullable=False),
)
# ── 7. zerotier_memberships ───────────────────────────────────────────────
op.create_table(
"zerotier_memberships",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("organization_id", sa.String(36), sa.ForeignKey("organizations.id"), nullable=False, index=True),
sa.Column("device_network_membership_id", sa.String(36), sa.ForeignKey("device_network_memberships.id"), nullable=True, index=True),
sa.Column("zerotier_network_id", sa.String(16), nullable=False, index=True),
sa.Column("node_id", sa.String(10), nullable=False, index=True),
sa.Column("member_seen", sa.Boolean, nullable=False, default=False),
sa.Column("authorized", sa.Boolean, nullable=False, default=False),
sa.Column("join_seen_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_synced_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("raw_controller_payload", sa.JSON, nullable=True),
)
op.create_index(
"ix_zerotier_memberships_network_node",
"zerotier_memberships",
["zerotier_network_id", "node_id"],
unique=True,
)
# ── 8. kill_switch_events ────────────────────────────────────────────────
op.create_table(
"kill_switch_events",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("organization_id", sa.String(36), sa.ForeignKey("organizations.id"), nullable=False, index=True),
sa.Column("target_user_id", sa.String(36), sa.ForeignKey("users.id"), nullable=False, index=True),
sa.Column(
"scope",
_pg_enum("kill_switch_scope", ["organization", "global", "selected_networks"]) if dialect == "postgresql"
else sa.String(20),
nullable=False,
default="organization",
),
sa.Column("triggered_by_user_id", sa.String(36), sa.ForeignKey("users.id"), nullable=False),
sa.Column("reason", sa.Text, nullable=True),
sa.Column("network_ids", sa.JSON, nullable=True),
)
def downgrade():
bind = op.get_bind()
dialect = bind.dialect.name
op.drop_table("kill_switch_events")
op.drop_table("zerotier_memberships")
op.drop_table("activation_sessions")
op.drop_table("device_network_memberships")
op.drop_table("user_network_approvals")
op.drop_table("devices")
op.drop_table("portal_networks")
if dialect == "postgresql":
op.execute("DROP TYPE IF EXISTS kill_switch_scope")
op.execute("DROP TYPE IF EXISTS device_status")
op.execute("DROP TYPE IF EXISTS activation_end_reason")
op.execute("DROP TYPE IF EXISTS membership_state")
op.execute("DROP TYPE IF EXISTS approval_state")
op.execute("DROP TYPE IF EXISTS approval_grant_type")
op.execute("DROP TYPE IF EXISTS network_request_mode")
op.execute("DROP TYPE IF EXISTS network_environment")
+4
View File
@@ -23,6 +23,10 @@ with app.app_context():
for i in range(5, 0, -1):
print(f"{i}...")
time.sleep(1)
if db_url.startswith("sqlite"):
db.drop_all()
else:
db.session.execute(text("DROP SCHEMA public CASCADE"))
db.session.execute(text("CREATE SCHEMA public"))
db.session.commit()