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:
2026-03-29 23:14:20 +05:45
parent 05eb092228
commit 2b6f7e15af
21 changed files with 974 additions and 239 deletions
+1 -1
View File
@@ -39,7 +39,7 @@ def register():
f"{verify_link}\n\n"
f"Gatehouse Security Team"
)
NotificationService._send_email(to_address=user.email, subject=subject, body=body)
NotificationService._send_email_async(to_address=user.email, subject=subject, body=body)
except Exception as exc:
logging.getLogger(__name__).warning(f"Failed to send verification email on register: {exc}")
+3 -3
View File
@@ -27,7 +27,7 @@ def forgot_password():
reset_token = PasswordResetToken.generate(user_id=user.id)
app_url = current_app.config.get("APP_URL", "http://localhost:8080")
reset_link = f"{app_url}/reset-password?token={reset_token.token}"
NotificationService._send_email(
NotificationService._send_email_async(
to_address=user.email,
subject="Reset your Gatehouse password",
body=(
@@ -129,7 +129,7 @@ def resend_verification():
verify_token = EmailVerificationToken.generate(user_id=user.id)
app_url = current_app.config.get("APP_URL", "http://localhost:8080")
verify_link = f"{app_url}/verify-email?token={verify_token.token}"
NotificationService._send_email(
NotificationService._send_email_async(
to_address=user.email,
subject="Verify your Gatehouse email address",
body=(
@@ -200,7 +200,7 @@ def resend_activation():
app_url = current_app.config.get("APP_URL", current_app.config.get("FRONTEND_URL", "http://localhost:8080"))
activate_link = f"{app_url}/activate?code={code}"
NotificationService._send_email(
NotificationService._send_email_async(
to_address=user.email,
subject="Activate your Gatehouse account",
body=(
+3 -11
View File
@@ -37,7 +37,7 @@ def create_org_invite(org_id):
app_url = current_app.config.get("APP_URL", "http://localhost:8080")
invite_link = f"{app_url}/invite?token={invite.token}"
email_sent = NotificationService._send_email(
NotificationService._send_email_async(
to_address=email,
subject=f"You're invited to join {org.name} on Gatehouse",
body=(
@@ -47,16 +47,8 @@ def create_org_invite(org_id):
f"Gatehouse Security Team"
),
)
if not email_sent:
logging.getLogger(__name__).warning(
f"[INVITE LINK] Email not sent (EMAIL_ENABLED=False or SMTP down). "
f"Invite for {email}{invite_link}"
)
else:
logging.getLogger(__name__).info(
f"[INVITE] Email sent successfully to {email}"
)
logging.getLogger(__name__).info(f"[INVITE] Email queued for {email}")
email_sent = True # async — assume queued successfully
response_data = {
"invite": {
@@ -161,7 +161,7 @@ def send_mfa_reminder(org_id, user_id):
if compliance and policy and compliance.deadline_at:
NotificationService.send_mfa_deadline_reminder(user, compliance, policy)
else:
NotificationService._send_email(
NotificationService._send_email_async(
to_address=user.email,
subject="Reminder: Set up multi-factor authentication",
body=(
+338 -17
View File
@@ -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.")