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