@@ -2,8 +2,10 @@
from flask import g , request
from marshmallow import Schema , fields , validate , ValidationError
from sqlalchemy . exc import IntegrityError
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
@@ -19,6 +21,8 @@ from gatehouse_app.models import (
ActivationSession ,
)
from gatehouse_app . models . organization import Organization
from gatehouse_app . models . organization . organization_member import OrganizationMember
from gatehouse_app . utils . constants import OrganizationRole
from gatehouse_app . exceptions import (
ValidationError as AppValidationError ,
ZeroTierAPIError ,
@@ -39,6 +43,17 @@ def _org_check(org_id):
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 ───────────────────────────────────────────────────────────────────
@@ -154,6 +169,63 @@ def create_network(org_id):
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 " ] )
@@ -346,6 +418,9 @@ def update_device(org_id, device_id):
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 " )
@@ -520,6 +595,25 @@ def assign_access(org_id):
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_approvals ( 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 ───────────────────────────────────────────────────────────────
@@ -548,7 +642,7 @@ def list_memberships(org_id):
@login_required
@full_access_required
def activate_membership ( org_id , membership_id ) :
""" Activate an approved device membership. """
""" 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
@@ -559,11 +653,14 @@ def activate_membership(org_id, membership_id):
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_device_membership (
membership_id = membership_id ,
user_id = g . current_user . id ,
lifetime_minutes = data . get ( " lifetime_minutes " ) ,
admin_override = is_admin ,
)
membership = DeviceNetworkMembership . query . get ( membership_id )
return api_response ( data = { " session " : session . to_dict ( ) , " membership " : membership . to_dict ( ) } , message = " Membership activated successfully " )
@@ -577,11 +674,21 @@ def activate_membership(org_id, membership_id):
@login_required
@full_access_required
def deactivate_membership ( org_id , membership_id ) :
""" Deactivate an active device membership. """
""" 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 = DeviceNetworkMembership . query . filter (
DeviceNetworkMembership . id == membership_id ,
DeviceNetworkMembership . user_id == g . current_user . id ,
DeviceNetworkMembership . 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 :
membership = network_access_service . deactivate_membership (
membership_id = membership_id ,
@@ -597,7 +704,7 @@ def deactivate_membership(org_id, membership_id):
@login_required
@full_access_required
def activate_all_memberships ( org_id ) :
""" Bulk-activate all approved inactive memberships. """
""" Bulk-activate all of the caller ' s approved inactive memberships in this org ."""
org , err = _org_check ( org_id )
if err :
return err
@@ -744,6 +851,7 @@ def trigger_kill_switch(org_id):
event = network_access_service . kill_switch (
target_user_id = data [ " target_user_id " ] ,
triggered_by_user_id = g . current_user . id ,
organization_id = org_id ,
scope = data . get ( " scope " , " organization " ) ,
reason = data . get ( " reason " ) ,
network_ids = data . get ( " network_ids " ) ,
@@ -794,12 +902,20 @@ def admin_delete_membership(org_id, membership_id):
@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). """
""" 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 ( )
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 )
@@ -807,12 +923,20 @@ def zerotier_status():
@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). """
""" 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 ( )
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 " ,
@@ -823,12 +947,20 @@ def zerotier_list_networks():
@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). """
""" 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 )
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 )
@@ -836,12 +968,20 @@ def zerotier_get_network(network_id):
@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). """
""" 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 )
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 " ,
@@ -852,9 +992,190 @@ def zerotier_list_members(network_id):
@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 ). """
""" 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 = " org.zerotier_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 = " org.zerotier_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. " )