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=
|
SMTP_PASSWORD=
|
||||||
FROM_ADDRESS=
|
FROM_ADDRESS=
|
||||||
WEBAUTHN_ORIGIN=
|
WEBAUTHN_ORIGIN=
|
||||||
|
|
||||||
|
ZEROTIER_API_TOKEN=
|
||||||
|
ZEROTIER_API_URL=
|
||||||
@@ -136,3 +136,4 @@ Thumbs.db
|
|||||||
# Project specific
|
# Project specific
|
||||||
|
|
||||||
*.db
|
*.db
|
||||||
|
flask_session/
|
||||||
@@ -129,6 +129,20 @@ class BaseConfig:
|
|||||||
# Frontend URL (for OAuth callback redirects)
|
# Frontend URL (for OAuth callback redirects)
|
||||||
FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:8080")
|
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 / SMTP
|
||||||
EMAIL_ENABLED = os.getenv("EMAIL_ENABLED", "False").lower() == "true"
|
EMAIL_ENABLED = os.getenv("EMAIL_ENABLED", "False").lower() == "true"
|
||||||
SMTP_HOST = os.getenv("SMTP_HOST", "smtp.gmail.com")
|
SMTP_HOST = os.getenv("SMTP_HOST", "smtp.gmail.com")
|
||||||
|
|||||||
@@ -5,6 +5,6 @@ from flask import Blueprint
|
|||||||
api_v1_bp = Blueprint("api_v1", __name__)
|
api_v1_bp = Blueprint("api_v1", __name__)
|
||||||
|
|
||||||
# Import route modules to register them
|
# 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)
|
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,
|
DepartmentError,
|
||||||
DepartmentNotFoundError,
|
DepartmentNotFoundError,
|
||||||
)
|
)
|
||||||
|
from gatehouse_app.exceptions.zerotier_exceptions import (
|
||||||
|
ZeroTierAPIError,
|
||||||
|
ZeroTierAuthError,
|
||||||
|
ZeroTierNotFoundError,
|
||||||
|
NetworkNotFoundError,
|
||||||
|
DeviceNotFoundError,
|
||||||
|
ApprovalNotFoundError,
|
||||||
|
MembershipNotFoundError,
|
||||||
|
DeviceAlreadyExistsError,
|
||||||
|
ApprovalAlreadyExistsError,
|
||||||
|
InvalidNodeIdError,
|
||||||
|
InvalidNetworkIdError,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"BaseAPIException",
|
"BaseAPIException",
|
||||||
@@ -65,5 +78,16 @@ __all__ = [
|
|||||||
"PrincipalNotFoundError",
|
"PrincipalNotFoundError",
|
||||||
"DepartmentError",
|
"DepartmentError",
|
||||||
"DepartmentNotFoundError",
|
"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
|
CertificateAuditLog
|
||||||
models.security — OrganizationSecurityPolicy, UserSecurityPolicy,
|
models.security — OrganizationSecurityPolicy, UserSecurityPolicy,
|
||||||
MfaPolicyCompliance
|
MfaPolicyCompliance
|
||||||
|
models.zerotier — PortalNetwork, Device, UserNetworkApproval,
|
||||||
|
DeviceNetworkMembership, ActivationSession,
|
||||||
|
ZeroTierMembership, KillSwitchEvent
|
||||||
|
|
||||||
All names are re-exported here so that existing code using the flat import
|
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
|
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 ──────────────────────────────────────────────────────────────────
|
# ── Security ──────────────────────────────────────────────────────────────────
|
||||||
from gatehouse_app.models.security.organization_security_policy import ( # noqa: F401
|
from gatehouse_app.models.security.organization_security_policy import (
|
||||||
OrganizationSecurityPolicy,
|
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
|
from gatehouse_app.models.security.user_security_policy import ( # noqa: F401
|
||||||
UserSecurityPolicy,
|
UserSecurityPolicy,
|
||||||
)
|
)
|
||||||
@@ -147,4 +167,12 @@ __all__ = [
|
|||||||
"OrganizationSecurityPolicy",
|
"OrganizationSecurityPolicy",
|
||||||
"UserSecurityPolicy",
|
"UserSecurityPolicy",
|
||||||
"MfaPolicyCompliance",
|
"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"
|
INHERIT = "inherit"
|
||||||
REQUIRED = "required"
|
REQUIRED = "required"
|
||||||
EXEMPT = "exempt"
|
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)
|
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")
|
@cli.command("mfa_compliance_status")
|
||||||
def mfa_compliance_status():
|
def mfa_compliance_status():
|
||||||
"""Show current 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")
|
||||||
@@ -23,6 +23,10 @@ with app.app_context():
|
|||||||
for i in range(5, 0, -1):
|
for i in range(5, 0, -1):
|
||||||
print(f"{i}...")
|
print(f"{i}...")
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
|
|
||||||
|
if db_url.startswith("sqlite"):
|
||||||
|
db.drop_all()
|
||||||
|
else:
|
||||||
db.session.execute(text("DROP SCHEMA public CASCADE"))
|
db.session.execute(text("DROP SCHEMA public CASCADE"))
|
||||||
db.session.execute(text("CREATE SCHEMA public"))
|
db.session.execute(text("CREATE SCHEMA public"))
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|||||||
Reference in New Issue
Block a user