Feat(Fix): Multi-Tenant Zerotier Org Setups
Imports Network From Zerotier Async Emails Migration guardrails Admin to see all approvals states
This commit is contained in:
@@ -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.")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user