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:
@@ -53,3 +53,6 @@ SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
FROM_ADDRESS=
|
||||
WEBAUTHN_ORIGIN=
|
||||
|
||||
ZEROTIER_API_TOKEN=
|
||||
ZEROTIER_API_URL=
|
||||
@@ -136,3 +136,4 @@ Thumbs.db
|
||||
# Project specific
|
||||
|
||||
*.db
|
||||
flask_session/
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
]
|
||||
@@ -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")
|
||||
+6
-2
@@ -23,8 +23,12 @@ with app.app_context():
|
||||
for i in range(5, 0, -1):
|
||||
print(f"{i}...")
|
||||
time.sleep(1)
|
||||
db.session.execute(text("DROP SCHEMA public CASCADE"))
|
||||
db.session.execute(text("CREATE SCHEMA public"))
|
||||
|
||||
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()
|
||||
|
||||
# Create all tables
|
||||
|
||||
Reference in New Issue
Block a user