861 lines
33 KiB
Python
861 lines
33 KiB
Python
|
|
"""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")
|