Files
gatehouse-api/gatehouse_app/api/v1/zerotier.py
T

1393 lines
52 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""ZeroTier network governance API endpoints."""
from datetime import datetime, timezone
from flask import g, request
from marshmallow import Schema, fields, validate, ValidationError
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import joinedload
from gatehouse_app.api.v1 import api_v1_bp
from gatehouse_app.extensions import db
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.services.user_service import UserService
from gatehouse_app.models import (
PortalNetwork,
Device,
ActivationSession,
NetworkAccessRequest,
)
from gatehouse_app.models.organization import Organization
from gatehouse_app.models.organization.organization_member import OrganizationMember
from gatehouse_app.utils.constants import OrganizationRole, AuditAction
from gatehouse_app.exceptions import (
ValidationError as AppValidationError,
ZeroTierAPIError,
NetworkNotFoundError,
DeviceNotFoundError,
DeviceAlreadyExistsError,
ApprovalNotFoundError,
)
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
def _is_org_admin(org_id: str, user_id: str) -> bool:
"""Return True if the user is an admin or owner of the org."""
return OrganizationMember.query.filter(
OrganizationMember.organization_id == org_id,
OrganizationMember.user_id == user_id,
OrganizationMember.role.in_([OrganizationRole.ADMIN, OrganizationRole.OWNER]),
OrganizationMember.deleted_at.is_(None),
).first() is not 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)
class NetworkKillSwitchSchema(Schema):
reason = fields.Str(allow_none=True, validate=validate.Length(max=500))
# ── 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)
except IntegrityError:
db.session.rollback()
return api_response(
success=False,
message="A portal network with this ZeroTier ID already exists in this organization.",
status=409,
error_type="DUPLICATE_NETWORK",
)
@api_v1_bp.route("/organizations/<org_id>/zerotier/available-networks", methods=["GET"])
@login_required
@require_admin
@full_access_required
def list_zerotier_available_networks(org_id):
"""List all ZeroTier networks from the org's ZT controller/account.
Cross-references against managed portal networks so the UI can show
which ones are already imported and which can be imported.
"""
org, err = _org_check(org_id)
if err:
return err
# Fetch all active portal networks for this org, keyed by ZT network ID
managed = {
pn.zerotier_network_id: pn
for pn in PortalNetwork.query.filter(
PortalNetwork.organization_id == org_id,
PortalNetwork.deleted_at.is_(None),
).all()
}
try:
zt_networks = zt.list_networks(organization_id=org_id)
except ZeroTierAPIError as e:
# Return an empty list with a flag so the UI can show a helpful message
# rather than an error page (e.g. "ZeroTier not configured yet").
return api_response(
data={"networks": [], "count": 0, "zt_error": str(e)},
message="ZeroTier unavailable — no networks returned",
)
result = []
for zt_net in zt_networks:
portal = managed.get(zt_net.id)
result.append({
**zt_net.to_dict(),
"already_managed": portal is not None,
"portal_network_id": portal.id if portal else None,
"portal_network_name": portal.name if portal else None,
})
return api_response(
data={"networks": result, "count": len(result)},
message="Available ZeroTier networks retrieved",
)
@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": 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>/users/<user_id>/devices", methods=["GET"])
@login_required
@require_admin
@full_access_required
def list_user_devices(org_id, user_id):
"""List all ZeroTier devices for a specific user in the organization (admin only)."""
org, err = _org_check(org_id)
if err:
return err
# Verify target user exists
from gatehouse_app.exceptions.validation_exceptions import UserNotFoundError
try:
target_user = UserService.get_user_by_id(user_id)
except UserNotFoundError:
return api_response(success=False, message="User not found", status=404, error_type="NOT_FOUND")
# Verify target user is a member of the org
is_member = OrganizationMember.query.filter(
OrganizationMember.organization_id == org_id,
OrganizationMember.user_id == user_id,
OrganizationMember.deleted_at.is_(None),
).first() is not None
if not is_member:
return api_response(success=False, message="User is not a member of this organization", status=404, error_type="NOT_FOUND")
# Get devices for the user in this org
devices = device_service.list_user_devices(user_id, org_id)
return api_response(
data={
"devices": [d.to_dict() for d in devices],
"count": len(devices),
"user_id": user_id,
"organization_id": org_id,
},
message="User 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"),
)
return api_response(
data={"device": device.to_dict()},
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)
if "nickname" in data:
data["device_nickname"] = data.pop("nickname")
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_requests(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/<request_id>/revoke", methods=["POST"])
@login_required
@require_admin
@full_access_required
def revoke_approval(org_id, request_id):
"""Revoke an approved access record (admin only)."""
org, err = _org_check(org_id)
if err:
return err
try:
approval = network_access_service.revoke_access(request_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)
@api_v1_bp.route("/organizations/<org_id>/admin/approvals", methods=["GET"])
@login_required
@require_admin
@full_access_required
def admin_list_all_approvals(org_id):
"""List ALL approval records across all users in the org (admin only)."""
org, err = _org_check(org_id)
if err:
return err
network_id = request.args.get("network_id")
state = request.args.get("state")
approvals = network_access_service.list_all_org_requests(org_id, network_id=network_id, state=state)
return api_response(
data={"approvals": [a.to_dict() for a in approvals], "count": len(approvals)},
message="Approvals retrieved successfully",
)
# ── 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 = NetworkAccessRequest.query.filter(
NetworkAccessRequest.user_id == g.current_user.id,
NetworkAccessRequest.organization_id == org_id,
NetworkAccessRequest.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. Admins can activate any membership; regular members can only activate their own."""
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)
is_admin = _is_org_admin(org_id, g.current_user.id)
try:
session = network_access_service.activate_request(
request_id=membership_id,
user_id=g.current_user.id,
lifetime_minutes=data.get("lifetime_minutes"),
admin_override=is_admin,
)
return api_response(data={"session": session.to_dict()}, message="Request activated 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>/memberships/<membership_id>/deactivate", methods=["POST"])
@login_required
@full_access_required
def deactivate_membership(org_id, membership_id):
"""Deactivate an active device membership. Admins can deactivate any; regular members can only deactivate their own."""
org, err = _org_check(org_id)
if err:
return err
# Verify ownership for non-admins
if not _is_org_admin(org_id, g.current_user.id):
membership_check = NetworkAccessRequest.query.filter(
NetworkAccessRequest.id == membership_id,
NetworkAccessRequest.user_id == g.current_user.id,
NetworkAccessRequest.deleted_at.is_(None),
).first()
if not membership_check:
return api_response(success=False, message="Membership not found", status=404, error_type="NOT_FOUND")
try:
req = network_access_service.deactivate_request(
request_id=membership_id,
reason="manual_revoke",
deactivated_by_user_id=g.current_user.id,
)
return api_response(data={"request": req.to_dict()}, message="Request deactivated 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>/memberships/activate-all", methods=["POST"])
@login_required
@full_access_required
def activate_all_memberships(org_id):
"""Bulk-activate all of the caller's approved inactive memberships in this org."""
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. Admins can override for any network."""
org, err = _org_check(org_id)
if err:
return err
is_admin = _is_org_admin(org_id, g.current_user.id)
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,
admin_override=is_admin,
granted_by_user_id=g.current_user.id if is_admin else None,
)
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_request_soft(
request_id=membership_id,
revoker_user_id=g.current_user.id,
)
return api_response(message="Request revoked successfully")
except ApprovalNotFoundError as e:
return api_response(success=False, message=str(e), status=404, error_type=e.error_type)
# ── Session helpers ──────────────────────────────────────────────────────────
def _session_to_dict(session, include_user=False):
"""Build a rich session dict with device, network, and timing details."""
now = datetime.now(timezone.utc)
exp = session.expires_at
if exp.tzinfo is None:
exp = exp.replace(tzinfo=timezone.utc)
remaining = (exp - now).total_seconds() if exp > now else 0
duration = (session.expires_at - session.authenticated_at).total_seconds()
auth_at = session.authenticated_at
if auth_at.tzinfo is None:
auth_at = auth_at.replace(tzinfo=timezone.utc)
exp_at = session.expires_at
if exp_at.tzinfo is None:
exp_at = exp_at.replace(tzinfo=timezone.utc)
d = {
"id": session.id,
"authenticated_at": auth_at.isoformat(),
"expires_at": exp_at.isoformat(),
"duration_seconds": int(duration),
"remaining_seconds": max(0, int(remaining)),
"is_active": session.is_active,
"is_expired": session.is_expired,
"ended_at": session.ended_at.isoformat() if session.ended_at else None,
"end_reason": session.end_reason.value if session.end_reason else None,
}
if session.access_request:
if session.access_request.device:
dev = session.access_request.device
d["device"] = {
"id": dev.id,
"node_id": dev.node_id,
"name": dev.display_name,
}
if session.access_request.portal_network:
net = session.access_request.portal_network
d["network"] = {
"id": net.id,
"name": net.name,
}
if include_user:
d["user"] = {
"id": session.user.id,
"full_name": session.user.full_name,
"email": session.user.email,
}
return d
# ── 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.options(
joinedload(ActivationSession.access_request)
.joinedload(NetworkAccessRequest.device),
joinedload(ActivationSession.access_request)
.joinedload(NetworkAccessRequest.portal_network),
)
.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": [_session_to_dict(s) 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
_end_session(session, ActivationEndReason.LOGOUT)
if session.network_access_request_id:
network_access_service.deactivate_request(session.network_access_request_id, reason="logout")
return api_response(message="Session ended successfully")
@api_v1_bp.route("/organizations/<org_id>/admin/sessions", methods=["GET"])
@login_required
@require_admin
@full_access_required
def admin_list_sessions(org_id):
"""List all active activation sessions across all users (admin only)."""
org, err = _org_check(org_id)
if err:
return err
sessions = (
ActivationSession.query.options(
joinedload(ActivationSession.user),
joinedload(ActivationSession.access_request)
.joinedload(NetworkAccessRequest.device),
joinedload(ActivationSession.access_request)
.joinedload(NetworkAccessRequest.portal_network),
)
.filter(
ActivationSession.organization_id == org_id,
ActivationSession.ended_at.is_(None),
ActivationSession.deleted_at.is_(None),
)
.all()
)
return api_response(
data={
"sessions": [_session_to_dict(s, include_user=True) for s in sessions],
"count": len(sessions),
},
message="Admin sessions retrieved successfully",
)
@api_v1_bp.route("/organizations/<org_id>/admin/sessions/<session_id>/end", methods=["POST"])
@login_required
@require_admin
@full_access_required
def admin_end_session(org_id, session_id):
"""End a specific activation session (admin only).
Terminates the active session for any user, deauthorizes the device
in ZeroTier, and marks the membership as inactive. The user retains
their approval and can re-authenticate without re-approval.
"""
org, err = _org_check(org_id)
if err:
return err
try:
session = network_access_service.admin_end_session(
session_id=session_id,
admin_user_id=g.current_user.id,
)
return api_response(
data={"session": _session_to_dict(session, include_user=True)},
message="Session ended successfully by admin",
)
except ApprovalNotFoundError as e:
return api_response(success=False, message=str(e), status=404, error_type="NOT_FOUND")
except AppValidationError as e:
return api_response(success=False, message=str(e.message), status=400, error_type=e.error_type)
# ── 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:
from gatehouse_app.utils.constants import KillSwitchScope
scope = data.get("scope", "organization")
scope_enum = KillSwitchScope(scope) if scope in KillSwitchScope._value2member_map_ else KillSwitchScope.ORGANIZATION
count = network_access_service.kill_switch(
user_id=data["target_user_id"],
org_id=org_id,
scope=scope_enum,
network_ids=data.get("network_ids"),
)
return api_response(data={"affected_count": count}, 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)
@api_v1_bp.route("/organizations/<org_id>/networks/<network_id>/kill-switch", methods=["POST"])
@login_required
@require_admin
@full_access_required
def trigger_network_kill_switch(org_id, network_id):
"""Deactivate all active memberships on a network (admin only)."""
org, err = _org_check(org_id)
if err:
return err
schema = NetworkKillSwitchSchema()
data = schema.load(request.json or {})
count = network_access_service.kill_switch_network(
portal_network_id=network_id,
organization_id=org_id,
admin_user_id=g.current_user.id,
)
return api_response(
data={"affected_count": count},
message="Network kill switch triggered successfully",
)
# ── 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
requests = network_access_service.get_all_requests_with_details(org_id)
return api_response(
data={"requests": requests, "count": len(requests)},
message="All requests 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):
"""Force-delete a membership and remove it from ZeroTier (admin only).
Handles the full lifecycle: deactivates if active, removes the member
from the ZeroTier controller, and hard-deletes the DB record.
"""
org, err = _org_check(org_id)
if err:
return err
try:
network_access_service.admin_force_delete_request(
membership_id,
admin_user_id=g.current_user.id,
)
return api_response(message="Request permanently deleted")
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)
# ── ZeroTier Controller ───────────────────────────────────────────────────────
@api_v1_bp.route("/admin/zerotier/status", methods=["GET"])
@login_required
@full_access_required
def zerotier_status():
"""Check ZeroTier controller connectivity and status.
Requires ?org_id=<uuid> — credentials are looked up from that org.
Caller must be an admin/owner of that specific org.
"""
org_id = request.args.get("org_id")
if not org_id:
return api_response(success=False, message="org_id query parameter is required", status=400, error_type="VALIDATION_ERROR")
if not _is_org_admin(org_id, g.current_user.id):
return api_response(success=False, message="Admin or owner role required for this organization", status=403, error_type="AUTHORIZATION_ERROR")
try:
status = zt.get_status(organization_id=org_id)
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
@full_access_required
def zerotier_list_networks():
"""List networks from the ZeroTier controller.
Requires ?org_id=<uuid> — credentials are looked up from that org.
Caller must be an admin/owner of that specific org.
"""
org_id = request.args.get("org_id")
if not org_id:
return api_response(success=False, message="org_id query parameter is required", status=400, error_type="VALIDATION_ERROR")
if not _is_org_admin(org_id, g.current_user.id):
return api_response(success=False, message="Admin or owner role required for this organization", status=403, error_type="AUTHORIZATION_ERROR")
try:
networks = zt.list_networks(organization_id=org_id)
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
@full_access_required
def zerotier_get_network(network_id):
"""Get a ZeroTier network from the controller.
Requires ?org_id=<uuid> — credentials are looked up from that org.
Caller must be an admin/owner of that specific org.
"""
org_id = request.args.get("org_id")
if not org_id:
return api_response(success=False, message="org_id query parameter is required", status=400, error_type="VALIDATION_ERROR")
if not _is_org_admin(org_id, g.current_user.id):
return api_response(success=False, message="Admin or owner role required for this organization", status=403, error_type="AUTHORIZATION_ERROR")
try:
network = zt.get_network(network_id, organization_id=org_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
@full_access_required
def zerotier_list_members(network_id):
"""List members on a ZeroTier network from the controller.
Requires ?org_id=<uuid> — credentials are looked up from that org.
Caller must be an admin/owner of that specific org.
"""
org_id = request.args.get("org_id")
if not org_id:
return api_response(success=False, message="org_id query parameter is required", status=400, error_type="VALIDATION_ERROR")
if not _is_org_admin(org_id, g.current_user.id):
return api_response(success=False, message="Admin or owner role required for this organization", status=403, error_type="AUTHORIZATION_ERROR")
try:
members = zt.list_members(network_id, organization_id=org_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
@full_access_required
def trigger_reconciliation():
"""Trigger full reconciliation across all networks (requires org admin in at least one org)."""
from gatehouse_app.models.organization.organization_member import OrganizationMember
is_any_admin = OrganizationMember.query.filter(
OrganizationMember.user_id == g.current_user.id,
OrganizationMember.role.in_([OrganizationRole.ADMIN, OrganizationRole.OWNER]),
OrganizationMember.deleted_at.is_(None),
).first() is not None
if not is_any_admin:
return api_response(success=False, message="Admin or owner role required", status=403, error_type="AUTHORIZATION_ERROR")
result = zerotier_reconciliation_service.reconcile_all()
return api_response(data=result, message="Reconciliation complete")
# ── Per-org ZeroTier configuration ───────────────────────────────────────────
class ZeroTierConfigSchema(Schema):
zt_api_token = fields.Str(required=True, validate=validate.Length(min=1, max=512))
zt_api_url = fields.Str(required=True, validate=validate.Length(min=1, max=512))
zt_api_mode = fields.Str(
required=True,
validate=validate.OneOf(["central", "controller"]),
)
@api_v1_bp.route("/organizations/<org_id>/zerotier-config", methods=["GET"])
@login_required
@require_admin
@full_access_required
def get_zerotier_config(org_id):
"""Return the current ZeroTier configuration for an organization (admin only).
The token is masked — only its presence is indicated, not the value.
"""
org, err = _org_check(org_id)
if err:
return err
return api_response(
data={
"zerotier_config": {
"zt_api_token_set": bool(org.zt_api_token),
"zt_api_url": org.zt_api_url,
"zt_api_mode": org.zt_api_mode,
}
},
message="ZeroTier configuration retrieved successfully",
)
@api_v1_bp.route("/organizations/<org_id>/zerotier-config", methods=["PUT"])
@login_required
@require_admin
@full_access_required
def set_zerotier_config(org_id):
"""Set (or replace) the ZeroTier credentials for an organization (admin only).
All three fields are required — there are no server-level defaults.
Body:
zt_api_token (required) API token for ZeroTier Central or authtoken.secret
zt_api_url (required) full base URL, e.g. http://host:9993 or
https://api.zerotier.com/api/v1
zt_api_mode (required) "central" | "controller"
"""
org, err = _org_check(org_id)
if err:
return err
try:
schema = ZeroTierConfigSchema()
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,
)
# Test connectivity BEFORE saving — reject bad credentials early
connectivity_ok = False
connectivity_error = None
# Temporarily set the credentials so _get_client() can build a client
old_token, old_url, old_mode = org.zt_api_token, org.zt_api_url, org.zt_api_mode
org.zt_api_token = data["zt_api_token"]
org.zt_api_url = data["zt_api_url"]
org.zt_api_mode = data["zt_api_mode"]
db.session.flush() # make visible to _get_client query without committing
try:
zt.get_status(organization_id=org_id)
connectivity_ok = True
except ZeroTierAPIError as exc:
connectivity_error = str(exc)
except Exception as exc:
connectivity_error = str(exc)
if not connectivity_ok:
# Roll back — don't persist bad credentials
org.zt_api_token = old_token
org.zt_api_url = old_url
org.zt_api_mode = old_mode
db.session.commit()
return api_response(
success=False,
message="Controller Connectivity test failed",
status=400,
error_type="ZEROTIER_CONNECTIVITY_FAILED",
error_details={
"connectivity_test": {
"ok": False,
"error": connectivity_error,
},
},
)
# Connectivity verified — commit the new credentials
org.save()
from gatehouse_app.services.audit_service import AuditService
AuditService.log_action(
action=AuditAction.ZT_CONFIG_UPDATED,
user_id=g.current_user.id,
organization_id=org_id,
resource_type="organization",
resource_id=org_id,
metadata={
"zt_api_url": org.zt_api_url,
"zt_api_mode": org.zt_api_mode,
"connectivity_ok": connectivity_ok,
},
description="Organization ZeroTier config updated",
success=True,
)
return api_response(
data={
"zerotier_config": {
"zt_api_token_set": True,
"zt_api_url": org.zt_api_url,
"zt_api_mode": org.zt_api_mode,
},
"connectivity_test": {
"ok": True,
"error": None,
},
},
message="ZeroTier configuration saved successfully",
)
@api_v1_bp.route("/organizations/<org_id>/zerotier-config", methods=["DELETE"])
@login_required
@require_admin
@full_access_required
def delete_zerotier_config(org_id):
"""Remove the org-level ZeroTier credentials (admin only).
After removal, all ZeroTier operations for this organization will fail
until new credentials
are configured via the ZeroTier Config page.
"""
org, err = _org_check(org_id)
if err:
return err
org.zt_api_token = None
org.zt_api_url = None
org.zt_api_mode = None
org.save()
from gatehouse_app.services.audit_service import AuditService
AuditService.log_action(
action=AuditAction.ZT_CONFIG_DELETED,
user_id=g.current_user.id,
organization_id=org_id,
resource_type="organization",
resource_id=org_id,
metadata={},
description="Organization ZeroTier config removed — ZeroTier operations disabled until reconfigured",
success=True,
)
return api_response(message="ZeroTier configuration removed. Configure new credentials to re-enable ZeroTier features.")