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:
@@ -132,18 +132,6 @@ class BaseConfig:
|
|||||||
OIDC_UI_URL = os.getenv("OIDC_UI_URL", os.getenv("FRONTEND_URL", "http://localhost:8080"))
|
OIDC_UI_URL = os.getenv("OIDC_UI_URL", os.getenv("FRONTEND_URL", "http://localhost:8080"))
|
||||||
|
|
||||||
# ZeroTier Configuration
|
# ZeroTier Configuration
|
||||||
ZEROTIER_API_TOKEN = os.getenv("ZEROTIER_API_TOKEN", "")
|
|
||||||
ZEROTIER_API_URL = os.getenv(
|
|
||||||
"ZEROTIER_API_URL",
|
|
||||||
"http://localhost:9993",
|
|
||||||
)
|
|
||||||
ZEROTIER_API_MODE = os.getenv("ZEROTIER_API_MODE", "controller").lower()
|
|
||||||
ZEROTIER_DEFAULT_ACTIVATION_LIFETIME_MINUTES = int(
|
|
||||||
os.getenv("ZEROTIER_DEFAULT_ACTIVATION_LIFETIME_MINUTES", "480")
|
|
||||||
)
|
|
||||||
ZEROTIER_RECONCILIATION_INTERVAL_SECONDS = int(
|
|
||||||
os.getenv("ZEROTIER_RECONCILIATION_INTERVAL_SECONDS", "120")
|
|
||||||
)
|
|
||||||
|
|
||||||
# Email / SMTP
|
# Email / SMTP
|
||||||
EMAIL_ENABLED = os.getenv("EMAIL_ENABLED", "False").lower() == "true"
|
EMAIL_ENABLED = os.getenv("EMAIL_ENABLED", "False").lower() == "true"
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ def register():
|
|||||||
f"{verify_link}\n\n"
|
f"{verify_link}\n\n"
|
||||||
f"Gatehouse Security Team"
|
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:
|
except Exception as exc:
|
||||||
logging.getLogger(__name__).warning(f"Failed to send verification email on register: {exc}")
|
logging.getLogger(__name__).warning(f"Failed to send verification email on register: {exc}")
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ def forgot_password():
|
|||||||
reset_token = PasswordResetToken.generate(user_id=user.id)
|
reset_token = PasswordResetToken.generate(user_id=user.id)
|
||||||
app_url = current_app.config.get("APP_URL", "http://localhost:8080")
|
app_url = current_app.config.get("APP_URL", "http://localhost:8080")
|
||||||
reset_link = f"{app_url}/reset-password?token={reset_token.token}"
|
reset_link = f"{app_url}/reset-password?token={reset_token.token}"
|
||||||
NotificationService._send_email(
|
NotificationService._send_email_async(
|
||||||
to_address=user.email,
|
to_address=user.email,
|
||||||
subject="Reset your Gatehouse password",
|
subject="Reset your Gatehouse password",
|
||||||
body=(
|
body=(
|
||||||
@@ -129,7 +129,7 @@ def resend_verification():
|
|||||||
verify_token = EmailVerificationToken.generate(user_id=user.id)
|
verify_token = EmailVerificationToken.generate(user_id=user.id)
|
||||||
app_url = current_app.config.get("APP_URL", "http://localhost:8080")
|
app_url = current_app.config.get("APP_URL", "http://localhost:8080")
|
||||||
verify_link = f"{app_url}/verify-email?token={verify_token.token}"
|
verify_link = f"{app_url}/verify-email?token={verify_token.token}"
|
||||||
NotificationService._send_email(
|
NotificationService._send_email_async(
|
||||||
to_address=user.email,
|
to_address=user.email,
|
||||||
subject="Verify your Gatehouse email address",
|
subject="Verify your Gatehouse email address",
|
||||||
body=(
|
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"))
|
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}"
|
activate_link = f"{app_url}/activate?code={code}"
|
||||||
NotificationService._send_email(
|
NotificationService._send_email_async(
|
||||||
to_address=user.email,
|
to_address=user.email,
|
||||||
subject="Activate your Gatehouse account",
|
subject="Activate your Gatehouse account",
|
||||||
body=(
|
body=(
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ def create_org_invite(org_id):
|
|||||||
app_url = current_app.config.get("APP_URL", "http://localhost:8080")
|
app_url = current_app.config.get("APP_URL", "http://localhost:8080")
|
||||||
invite_link = f"{app_url}/invite?token={invite.token}"
|
invite_link = f"{app_url}/invite?token={invite.token}"
|
||||||
|
|
||||||
email_sent = NotificationService._send_email(
|
NotificationService._send_email_async(
|
||||||
to_address=email,
|
to_address=email,
|
||||||
subject=f"You're invited to join {org.name} on Gatehouse",
|
subject=f"You're invited to join {org.name} on Gatehouse",
|
||||||
body=(
|
body=(
|
||||||
@@ -47,16 +47,8 @@ def create_org_invite(org_id):
|
|||||||
f"Gatehouse Security Team"
|
f"Gatehouse Security Team"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
logging.getLogger(__name__).info(f"[INVITE] Email queued for {email}")
|
||||||
if not email_sent:
|
email_sent = True # async — assume queued successfully
|
||||||
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}"
|
|
||||||
)
|
|
||||||
|
|
||||||
response_data = {
|
response_data = {
|
||||||
"invite": {
|
"invite": {
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ def send_mfa_reminder(org_id, user_id):
|
|||||||
if compliance and policy and compliance.deadline_at:
|
if compliance and policy and compliance.deadline_at:
|
||||||
NotificationService.send_mfa_deadline_reminder(user, compliance, policy)
|
NotificationService.send_mfa_deadline_reminder(user, compliance, policy)
|
||||||
else:
|
else:
|
||||||
NotificationService._send_email(
|
NotificationService._send_email_async(
|
||||||
to_address=user.email,
|
to_address=user.email,
|
||||||
subject="Reminder: Set up multi-factor authentication",
|
subject="Reminder: Set up multi-factor authentication",
|
||||||
body=(
|
body=(
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
from flask import g, request
|
from flask import g, request
|
||||||
from marshmallow import Schema, fields, validate, ValidationError
|
from marshmallow import Schema, fields, validate, ValidationError
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
from gatehouse_app.api.v1 import api_v1_bp
|
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.response import api_response
|
||||||
from gatehouse_app.utils.decorators import login_required, require_admin, full_access_required
|
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 portal_network_service
|
||||||
@@ -19,6 +21,8 @@ from gatehouse_app.models import (
|
|||||||
ActivationSession,
|
ActivationSession,
|
||||||
)
|
)
|
||||||
from gatehouse_app.models.organization import Organization
|
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 (
|
from gatehouse_app.exceptions import (
|
||||||
ValidationError as AppValidationError,
|
ValidationError as AppValidationError,
|
||||||
ZeroTierAPIError,
|
ZeroTierAPIError,
|
||||||
@@ -39,6 +43,17 @@ def _org_check(org_id):
|
|||||||
return org, None
|
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 ───────────────────────────────────────────────────────────────────
|
# ── 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)
|
return api_response(success=False, message=str(e.message), status=400, error_type=e.error_type)
|
||||||
except ZeroTierAPIError as e:
|
except ZeroTierAPIError as e:
|
||||||
return api_response(success=False, message=str(e), status=502, error_type=e.error_type)
|
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"])
|
@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:
|
except ValidationError as e:
|
||||||
return api_response(success=False, message="Validation failed", status=400, error_type="VALIDATION_ERROR", error_details=e.messages)
|
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:
|
try:
|
||||||
device = device_service.update_device(device_id, g.current_user.id, **data)
|
device = device_service.update_device(device_id, g.current_user.id, **data)
|
||||||
return api_response(data={"device": device.to_dict()}, message="Device updated successfully")
|
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)
|
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 ───────────────────────────────────────────────────────────────
|
# ── Memberships ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -548,7 +642,7 @@ def list_memberships(org_id):
|
|||||||
@login_required
|
@login_required
|
||||||
@full_access_required
|
@full_access_required
|
||||||
def activate_membership(org_id, membership_id):
|
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)
|
org, err = _org_check(org_id)
|
||||||
if err:
|
if err:
|
||||||
return err
|
return err
|
||||||
@@ -559,11 +653,14 @@ def activate_membership(org_id, membership_id):
|
|||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
return api_response(success=False, message="Validation failed", status=400, error_type="VALIDATION_ERROR", error_details=e.messages)
|
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:
|
try:
|
||||||
session = network_access_service.activate_device_membership(
|
session = network_access_service.activate_device_membership(
|
||||||
membership_id=membership_id,
|
membership_id=membership_id,
|
||||||
user_id=g.current_user.id,
|
user_id=g.current_user.id,
|
||||||
lifetime_minutes=data.get("lifetime_minutes"),
|
lifetime_minutes=data.get("lifetime_minutes"),
|
||||||
|
admin_override=is_admin,
|
||||||
)
|
)
|
||||||
membership = DeviceNetworkMembership.query.get(membership_id)
|
membership = DeviceNetworkMembership.query.get(membership_id)
|
||||||
return api_response(data={"session": session.to_dict(), "membership": membership.to_dict()}, message="Membership activated successfully")
|
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
|
@login_required
|
||||||
@full_access_required
|
@full_access_required
|
||||||
def deactivate_membership(org_id, membership_id):
|
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)
|
org, err = _org_check(org_id)
|
||||||
if err:
|
if err:
|
||||||
return 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:
|
try:
|
||||||
membership = network_access_service.deactivate_membership(
|
membership = network_access_service.deactivate_membership(
|
||||||
membership_id=membership_id,
|
membership_id=membership_id,
|
||||||
@@ -597,7 +704,7 @@ def deactivate_membership(org_id, membership_id):
|
|||||||
@login_required
|
@login_required
|
||||||
@full_access_required
|
@full_access_required
|
||||||
def activate_all_memberships(org_id):
|
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)
|
org, err = _org_check(org_id)
|
||||||
if err:
|
if err:
|
||||||
return err
|
return err
|
||||||
@@ -744,6 +851,7 @@ def trigger_kill_switch(org_id):
|
|||||||
event = network_access_service.kill_switch(
|
event = network_access_service.kill_switch(
|
||||||
target_user_id=data["target_user_id"],
|
target_user_id=data["target_user_id"],
|
||||||
triggered_by_user_id=g.current_user.id,
|
triggered_by_user_id=g.current_user.id,
|
||||||
|
organization_id=org_id,
|
||||||
scope=data.get("scope", "organization"),
|
scope=data.get("scope", "organization"),
|
||||||
reason=data.get("reason"),
|
reason=data.get("reason"),
|
||||||
network_ids=data.get("network_ids"),
|
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"])
|
@api_v1_bp.route("/admin/zerotier/status", methods=["GET"])
|
||||||
@login_required
|
@login_required
|
||||||
@require_admin
|
|
||||||
@full_access_required
|
@full_access_required
|
||||||
def zerotier_status():
|
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:
|
try:
|
||||||
status = zt.get_status()
|
status = zt.get_status(organization_id=org_id)
|
||||||
return api_response(data={"status": status}, message="ZeroTier controller is reachable")
|
return api_response(data={"status": status}, message="ZeroTier controller is reachable")
|
||||||
except ZeroTierAPIError as e:
|
except ZeroTierAPIError as e:
|
||||||
return api_response(success=False, message=str(e), status=502, error_type=e.error_type)
|
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"])
|
@api_v1_bp.route("/admin/zerotier/networks", methods=["GET"])
|
||||||
@login_required
|
@login_required
|
||||||
@require_admin
|
|
||||||
@full_access_required
|
@full_access_required
|
||||||
def zerotier_list_networks():
|
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:
|
try:
|
||||||
networks = zt.list_networks()
|
networks = zt.list_networks(organization_id=org_id)
|
||||||
return api_response(
|
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)},
|
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",
|
message="Networks retrieved successfully",
|
||||||
@@ -823,12 +947,20 @@ def zerotier_list_networks():
|
|||||||
|
|
||||||
@api_v1_bp.route("/admin/zerotier/networks/<network_id>", methods=["GET"])
|
@api_v1_bp.route("/admin/zerotier/networks/<network_id>", methods=["GET"])
|
||||||
@login_required
|
@login_required
|
||||||
@require_admin
|
|
||||||
@full_access_required
|
@full_access_required
|
||||||
def zerotier_get_network(network_id):
|
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:
|
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")
|
return api_response(data={"network": network.to_dict()}, message="Network retrieved successfully")
|
||||||
except ZeroTierAPIError as e:
|
except ZeroTierAPIError as e:
|
||||||
return api_response(success=False, message=str(e), status=502, error_type=e.error_type)
|
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"])
|
@api_v1_bp.route("/admin/zerotier/networks/<network_id>/members", methods=["GET"])
|
||||||
@login_required
|
@login_required
|
||||||
@require_admin
|
|
||||||
@full_access_required
|
@full_access_required
|
||||||
def zerotier_list_members(network_id):
|
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:
|
try:
|
||||||
members = zt.list_members(network_id)
|
members = zt.list_members(network_id, organization_id=org_id)
|
||||||
return api_response(
|
return api_response(
|
||||||
data={"members": [m.to_dict() for m in members], "count": len(members)},
|
data={"members": [m.to_dict() for m in members], "count": len(members)},
|
||||||
message="Members retrieved successfully",
|
message="Members retrieved successfully",
|
||||||
@@ -852,9 +992,190 @@ def zerotier_list_members(network_id):
|
|||||||
|
|
||||||
@api_v1_bp.route("/admin/zerotier/reconcile", methods=["POST"])
|
@api_v1_bp.route("/admin/zerotier/reconcile", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
@require_admin
|
|
||||||
@full_access_required
|
@full_access_required
|
||||||
def trigger_reconciliation():
|
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()
|
result = zerotier_reconciliation_service.reconcile_all()
|
||||||
return api_response(data=result, message="Reconciliation complete")
|
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.")
|
||||||
|
|
||||||
|
|||||||
@@ -8,19 +8,22 @@ class BaseAPIException(Exception):
|
|||||||
error_type = "INTERNAL_ERROR"
|
error_type = "INTERNAL_ERROR"
|
||||||
message = "An unexpected error occurred"
|
message = "An unexpected error occurred"
|
||||||
|
|
||||||
def __init__(self, message=None, error_details=None):
|
def __init__(self, message=None, error_details=None, status_code=None):
|
||||||
"""
|
"""
|
||||||
Initialize exception.
|
Initialize exception.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
message: Custom error message
|
message: Custom error message
|
||||||
error_details: Additional error details dictionary
|
error_details: Additional error details dictionary
|
||||||
|
status_code: Override the class-level HTTP status code
|
||||||
"""
|
"""
|
||||||
super().__init__(self.message)
|
super().__init__(self.message)
|
||||||
if message:
|
if message:
|
||||||
self.message = message
|
self.message = message
|
||||||
super().__init__(message) # update args so str(e) works
|
super().__init__(message) # update args so str(e) works
|
||||||
self.error_details = error_details or {}
|
self.error_details = error_details or {}
|
||||||
|
if status_code is not None:
|
||||||
|
self.status_code = status_code
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
"""Convert exception to dictionary for API response."""
|
"""Convert exception to dictionary for API response."""
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ class Organization(BaseModel):
|
|||||||
# Settings (stored as JSON)
|
# Settings (stored as JSON)
|
||||||
settings = db.Column(db.JSON, nullable=True, default=dict)
|
settings = db.Column(db.JSON, nullable=True, default=dict)
|
||||||
|
|
||||||
|
zt_api_token = db.Column(db.String(512), nullable=True)
|
||||||
|
zt_api_url = db.Column(db.String(512), nullable=True)
|
||||||
|
zt_api_mode = db.Column(db.String(32), nullable=True) # "central" | "controller"
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
members = db.relationship(
|
members = db.relationship(
|
||||||
"OrganizationMember", back_populates="organization", cascade="all, delete-orphan"
|
"OrganizationMember", back_populates="organization", cascade="all, delete-orphan"
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ class ActivationSession(BaseModel):
|
|||||||
)
|
)
|
||||||
ended_at = db.Column(db.DateTime, nullable=True)
|
ended_at = db.Column(db.DateTime, nullable=True)
|
||||||
end_reason = db.Column(
|
end_reason = db.Column(
|
||||||
db.Enum(ActivationEndReason, name="activation_end_reason"),
|
db.Enum(ActivationEndReason, name="activation_end_reason", values_callable=lambda x: [e.value for e in x]),
|
||||||
nullable=True,
|
nullable=True,
|
||||||
)
|
)
|
||||||
created_by = db.Column(
|
created_by = db.Column(
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ class Device(BaseModel):
|
|||||||
asset_tag = db.Column(db.String(255), nullable=True)
|
asset_tag = db.Column(db.String(255), nullable=True)
|
||||||
serial_number = db.Column(db.String(255), nullable=True)
|
serial_number = db.Column(db.String(255), nullable=True)
|
||||||
status = db.Column(
|
status = db.Column(
|
||||||
db.Enum(DeviceStatus, name="device_status"),
|
db.Enum(DeviceStatus, name="device_status", values_callable=lambda x: [e.value for e in x]),
|
||||||
default=DeviceStatus.ACTIVE,
|
default=DeviceStatus.ACTIVE,
|
||||||
nullable=False,
|
nullable=False,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ class DeviceNetworkMembership(BaseModel):
|
|||||||
index=True,
|
index=True,
|
||||||
)
|
)
|
||||||
state = db.Column(
|
state = db.Column(
|
||||||
db.Enum(MembershipState, name="membership_state"),
|
db.Enum(MembershipState, name="membership_state", values_callable=lambda x: [e.value for e in x]),
|
||||||
default=MembershipState.PENDING_DEVICE_REGISTRATION,
|
default=MembershipState.PENDING_DEVICE_REGISTRATION,
|
||||||
nullable=False,
|
nullable=False,
|
||||||
index=True,
|
index=True,
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ class KillSwitchEvent(BaseModel):
|
|||||||
index=True,
|
index=True,
|
||||||
)
|
)
|
||||||
scope = db.Column(
|
scope = db.Column(
|
||||||
db.Enum(KillSwitchScope, name="kill_switch_scope"),
|
db.Enum(KillSwitchScope, name="kill_switch_scope", values_callable=lambda x: [e.value for e in x]),
|
||||||
default=KillSwitchScope.ORGANIZATION,
|
default=KillSwitchScope.ORGANIZATION,
|
||||||
nullable=False,
|
nullable=False,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -45,12 +45,12 @@ class PortalNetwork(BaseModel):
|
|||||||
index=True,
|
index=True,
|
||||||
)
|
)
|
||||||
environment = db.Column(
|
environment = db.Column(
|
||||||
db.Enum(NetworkEnvironment, name="network_environment"),
|
db.Enum(NetworkEnvironment, name="network_environment", values_callable=lambda x: [e.value for e in x]),
|
||||||
default=NetworkEnvironment.DEVELOPMENT,
|
default=NetworkEnvironment.DEVELOPMENT,
|
||||||
nullable=False,
|
nullable=False,
|
||||||
)
|
)
|
||||||
request_mode = db.Column(
|
request_mode = db.Column(
|
||||||
db.Enum(NetworkRequestMode, name="network_request_mode"),
|
db.Enum(NetworkRequestMode, name="network_request_mode", values_callable=lambda x: [e.value for e in x]),
|
||||||
default=NetworkRequestMode.APPROVAL_REQUIRED,
|
default=NetworkRequestMode.APPROVAL_REQUIRED,
|
||||||
nullable=False,
|
nullable=False,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -48,12 +48,12 @@ class UserNetworkApproval(BaseModel):
|
|||||||
nullable=True,
|
nullable=True,
|
||||||
)
|
)
|
||||||
grant_type = db.Column(
|
grant_type = db.Column(
|
||||||
db.Enum(ApprovalGrantType, name="approval_grant_type"),
|
db.Enum(ApprovalGrantType, name="approval_grant_type", values_callable=lambda x: [e.value for e in x]),
|
||||||
default=ApprovalGrantType.REQUESTED,
|
default=ApprovalGrantType.REQUESTED,
|
||||||
nullable=False,
|
nullable=False,
|
||||||
)
|
)
|
||||||
state = db.Column(
|
state = db.Column(
|
||||||
db.Enum(ApprovalState, name="approval_state"),
|
db.Enum(ApprovalState, name="approval_state", values_callable=lambda x: [e.value for e in x]),
|
||||||
default=ApprovalState.PENDING,
|
default=ApprovalState.PENDING,
|
||||||
nullable=False,
|
nullable=False,
|
||||||
index=True,
|
index=True,
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ from gatehouse_app.exceptions import (
|
|||||||
DeviceNotFoundError,
|
DeviceNotFoundError,
|
||||||
ApprovalAlreadyExistsError,
|
ApprovalAlreadyExistsError,
|
||||||
ValidationError,
|
ValidationError,
|
||||||
|
ZeroTierAPIError,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -74,9 +75,30 @@ def request_access(
|
|||||||
raise ApprovalAlreadyExistsError(
|
raise ApprovalAlreadyExistsError(
|
||||||
"An access request or approval already exists for this user and network."
|
"An access request or approval already exists for this user and network."
|
||||||
)
|
)
|
||||||
existing.state = ApprovalState.PENDING
|
is_open = network.request_mode.value == "open"
|
||||||
|
existing.state = ApprovalState.APPROVED if is_open else ApprovalState.PENDING
|
||||||
existing.justification = justification
|
existing.justification = justification
|
||||||
existing.save()
|
existing.save()
|
||||||
|
|
||||||
|
existing_membership = DeviceNetworkMembership.query.filter(
|
||||||
|
DeviceNetworkMembership.user_network_approval_id == existing.id,
|
||||||
|
DeviceNetworkMembership.device_id == device_id,
|
||||||
|
DeviceNetworkMembership.deleted_at.is_(None),
|
||||||
|
).first()
|
||||||
|
if not existing_membership:
|
||||||
|
membership_state = MembershipState.APPROVED_INACTIVE if is_open else MembershipState.PENDING_DEVICE_REGISTRATION
|
||||||
|
membership = DeviceNetworkMembership(
|
||||||
|
organization_id=organization_id,
|
||||||
|
user_id=user_id,
|
||||||
|
device_id=device_id,
|
||||||
|
portal_network_id=portal_network_id,
|
||||||
|
user_network_approval_id=existing.id,
|
||||||
|
state=membership_state,
|
||||||
|
approved_for_activation=is_open,
|
||||||
|
)
|
||||||
|
membership.save()
|
||||||
|
_ensure_zerotier_member(device.node_id, portal_network_id, authorized=False)
|
||||||
|
|
||||||
return existing
|
return existing
|
||||||
|
|
||||||
is_open = network.request_mode.value == "open"
|
is_open = network.request_mode.value == "open"
|
||||||
@@ -329,6 +351,23 @@ def list_user_approvals(user_id: str, organization_id: str) -> list[UserNetworkA
|
|||||||
).all()
|
).all()
|
||||||
|
|
||||||
|
|
||||||
|
def list_all_org_approvals(
|
||||||
|
organization_id: str,
|
||||||
|
network_id: str | None = None,
|
||||||
|
state: str | None = None,
|
||||||
|
) -> list[UserNetworkApproval]:
|
||||||
|
"""List all approval records across all users in an org (admin use)."""
|
||||||
|
q = UserNetworkApproval.query.filter(
|
||||||
|
UserNetworkApproval.organization_id == organization_id,
|
||||||
|
UserNetworkApproval.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
if network_id:
|
||||||
|
q = q.filter(UserNetworkApproval.portal_network_id == network_id)
|
||||||
|
if state:
|
||||||
|
q = q.filter(UserNetworkApproval.state == state)
|
||||||
|
return q.order_by(UserNetworkApproval.created_at.desc()).all()
|
||||||
|
|
||||||
|
|
||||||
# ── Membership materialisation ───────────────────────────────────────────────
|
# ── Membership materialisation ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -428,11 +467,12 @@ def activate_device_membership(
|
|||||||
membership_id: str,
|
membership_id: str,
|
||||||
user_id: str,
|
user_id: str,
|
||||||
lifetime_minutes: int | None = None,
|
lifetime_minutes: int | None = None,
|
||||||
|
admin_override: bool = False,
|
||||||
) -> ActivationSession:
|
) -> ActivationSession:
|
||||||
"""Activate an approved device on a network. Creates an activation session and authorizes in ZT."""
|
"""Activate an approved device on a network. Creates an activation session and authorizes in ZT."""
|
||||||
membership = _get_membership(membership_id)
|
membership = _get_membership(membership_id)
|
||||||
|
|
||||||
if membership.user_id != user_id:
|
if not admin_override and membership.user_id != user_id:
|
||||||
raise MembershipNotFoundError("Membership not found.")
|
raise MembershipNotFoundError("Membership not found.")
|
||||||
|
|
||||||
# Check approval is still active
|
# Check approval is still active
|
||||||
@@ -536,7 +576,8 @@ def deactivate_membership(
|
|||||||
# Deauthorize in ZeroTier
|
# Deauthorize in ZeroTier
|
||||||
device = Device.query.get(membership.device_id)
|
device = Device.query.get(membership.device_id)
|
||||||
network = PortalNetwork.query.get(membership.portal_network_id)
|
network = PortalNetwork.query.get(membership.portal_network_id)
|
||||||
_deauthorize_in_zerotier(device.node_id, network.zerotier_network_id)
|
_deauthorize_in_zerotier(device.node_id, network.zerotier_network_id,
|
||||||
|
organization_id=membership.organization_id)
|
||||||
|
|
||||||
membership.state = MembershipState.APPROVED_INACTIVE
|
membership.state = MembershipState.APPROVED_INACTIVE
|
||||||
membership.currently_authorized = False
|
membership.currently_authorized = False
|
||||||
@@ -567,6 +608,7 @@ def kill_switch(
|
|||||||
target_user_id: str,
|
target_user_id: str,
|
||||||
triggered_by_user_id: str,
|
triggered_by_user_id: str,
|
||||||
scope: str,
|
scope: str,
|
||||||
|
organization_id: str | None = None,
|
||||||
reason: str | None = None,
|
reason: str | None = None,
|
||||||
network_ids: list[str] | None = None,
|
network_ids: list[str] | None = None,
|
||||||
) -> KillSwitchEvent:
|
) -> KillSwitchEvent:
|
||||||
@@ -579,14 +621,18 @@ def kill_switch(
|
|||||||
DeviceNetworkMembership.deleted_at.is_(None),
|
DeviceNetworkMembership.deleted_at.is_(None),
|
||||||
)
|
)
|
||||||
|
|
||||||
org_id = None
|
org_id = organization_id # Use caller-supplied org_id as the primary source
|
||||||
if scope_enum == KillSwitchScope.ORGANIZATION:
|
if scope_enum == KillSwitchScope.ORGANIZATION:
|
||||||
# Use the first membership's org
|
if not org_id:
|
||||||
|
# Fall back to deriving from first active membership
|
||||||
first = q.first()
|
first = q.first()
|
||||||
org_id = first.organization_id if first else None
|
org_id = first.organization_id if first else None
|
||||||
|
else:
|
||||||
|
# Scope query to the specified org
|
||||||
|
q = q.filter(DeviceNetworkMembership.organization_id == org_id)
|
||||||
elif scope_enum == KillSwitchScope.SELECTED_NETWORKS and network_ids:
|
elif scope_enum == KillSwitchScope.SELECTED_NETWORKS and network_ids:
|
||||||
q = q.filter(DeviceNetworkMembership.portal_network_id.in_(network_ids))
|
q = q.filter(DeviceNetworkMembership.portal_network_id.in_(network_ids))
|
||||||
if network_ids:
|
if not org_id:
|
||||||
first_network = PortalNetwork.query.filter(
|
first_network = PortalNetwork.query.filter(
|
||||||
PortalNetwork.id.in_(network_ids),
|
PortalNetwork.id.in_(network_ids),
|
||||||
PortalNetwork.deleted_at.is_(None),
|
PortalNetwork.deleted_at.is_(None),
|
||||||
@@ -594,7 +640,7 @@ def kill_switch(
|
|||||||
org_id = first_network.organization_id if first_network else None
|
org_id = first_network.organization_id if first_network else None
|
||||||
|
|
||||||
if not org_id:
|
if not org_id:
|
||||||
org_id = network_ids[0] if network_ids else None
|
raise ValidationError("Cannot determine organization for kill switch event.")
|
||||||
|
|
||||||
# Create kill switch event
|
# Create kill switch event
|
||||||
event = KillSwitchEvent(
|
event = KillSwitchEvent(
|
||||||
@@ -608,14 +654,16 @@ def kill_switch(
|
|||||||
event.save()
|
event.save()
|
||||||
|
|
||||||
# Suspend all approvals
|
# Suspend all approvals
|
||||||
ApprovalState._value2member_map_ # just reference
|
|
||||||
approvals = UserNetworkApproval.query.filter(
|
approvals = UserNetworkApproval.query.filter(
|
||||||
UserNetworkApproval.user_id == target_user_id,
|
UserNetworkApproval.user_id == target_user_id,
|
||||||
UserNetworkApproval.state == ApprovalState.APPROVED,
|
UserNetworkApproval.state == ApprovalState.APPROVED,
|
||||||
UserNetworkApproval.deleted_at.is_(None),
|
UserNetworkApproval.deleted_at.is_(None),
|
||||||
).all()
|
).all()
|
||||||
for approval in approvals:
|
for approval in approvals:
|
||||||
if scope_enum == KillSwitchScope.SELECTED_NETWORKS and network_ids:
|
if scope_enum == KillSwitchScope.ORGANIZATION and org_id:
|
||||||
|
if approval.organization_id != org_id:
|
||||||
|
continue
|
||||||
|
elif scope_enum == KillSwitchScope.SELECTED_NETWORKS and network_ids:
|
||||||
if approval.portal_network_id not in network_ids:
|
if approval.portal_network_id not in network_ids:
|
||||||
continue
|
continue
|
||||||
approval.state = ApprovalState.SUSPENDED
|
approval.state = ApprovalState.SUSPENDED
|
||||||
@@ -691,7 +739,8 @@ def _ensure_zerotier_member(
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
zt.add_member(network.zerotier_network_id, node_id, authorized=authorized)
|
zt.add_member(network.zerotier_network_id, node_id, authorized=authorized,
|
||||||
|
organization_id=network.organization_id)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"[_ensure_zerotier_member] Could not add member {node_id} "
|
f"[_ensure_zerotier_member] Could not add member {node_id} "
|
||||||
@@ -705,7 +754,8 @@ def _authorize_in_zerotier(
|
|||||||
membership: DeviceNetworkMembership,
|
membership: DeviceNetworkMembership,
|
||||||
) -> None:
|
) -> None:
|
||||||
try:
|
try:
|
||||||
zt.authorize_member(zerotier_network_id, node_id)
|
zt.authorize_member(zerotier_network_id, node_id,
|
||||||
|
organization_id=membership.organization_id)
|
||||||
|
|
||||||
# Update zerotier_membership cache
|
# Update zerotier_membership cache
|
||||||
zt_membership = ZeroTierMembership.query.filter(
|
zt_membership = ZeroTierMembership.query.filter(
|
||||||
@@ -740,6 +790,11 @@ def _authorize_in_zerotier(
|
|||||||
success=True,
|
success=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
except ZeroTierAPIError as exc:
|
||||||
|
logger.warning(
|
||||||
|
f"[_authorize_in_zerotier] ZeroTier unavailable — skipping authorization "
|
||||||
|
f"for {node_id} on {zerotier_network_id}: {exc}"
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"[_authorize_in_zerotier] Failed to authorize {node_id} "
|
f"[_authorize_in_zerotier] Failed to authorize {node_id} "
|
||||||
@@ -748,9 +803,11 @@ def _authorize_in_zerotier(
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
def _deauthorize_in_zerotier(node_id: str, zerotier_network_id: str) -> None:
|
def _deauthorize_in_zerotier(node_id: str, zerotier_network_id: str,
|
||||||
|
organization_id: str | None = None) -> None:
|
||||||
try:
|
try:
|
||||||
zt.deauthorize_member(zerotier_network_id, node_id)
|
zt.deauthorize_member(zerotier_network_id, node_id,
|
||||||
|
organization_id=organization_id)
|
||||||
|
|
||||||
zt_membership = ZeroTierMembership.query.filter(
|
zt_membership = ZeroTierMembership.query.filter(
|
||||||
ZeroTierMembership.zerotier_network_id == zerotier_network_id,
|
ZeroTierMembership.zerotier_network_id == zerotier_network_id,
|
||||||
@@ -940,7 +997,8 @@ def revoke_membership_soft(
|
|||||||
|
|
||||||
if device and network:
|
if device and network:
|
||||||
try:
|
try:
|
||||||
zt.deauthorize_member(network.zerotier_network_id, device.node_id)
|
zt.deauthorize_member(network.zerotier_network_id, device.node_id,
|
||||||
|
organization_id=membership.organization_id)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning(f"[revoke_membership_soft] ZT deauthorize failed for {device.node_id}: {exc}")
|
logger.warning(f"[revoke_membership_soft] ZT deauthorize failed for {device.node_id}: {exc}")
|
||||||
|
|
||||||
@@ -984,7 +1042,8 @@ def hard_delete_membership(membership_id: str) -> None:
|
|||||||
|
|
||||||
if device and network:
|
if device and network:
|
||||||
try:
|
try:
|
||||||
zt.delete_network_member(network.zerotier_network_id, device.node_id)
|
zt.delete_network_member(network.zerotier_network_id, device.node_id,
|
||||||
|
organization_id=membership.organization_id)
|
||||||
logger.info(f"[hard_delete_membership] Deleted {device.node_id} from ZT network {network.zerotier_network_id}")
|
logger.info(f"[hard_delete_membership] Deleted {device.node_id} from ZT network {network.zerotier_network_id}")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning(f"[hard_delete_membership] ZT delete failed for {device.node_id}: {exc}")
|
logger.warning(f"[hard_delete_membership] ZT delete failed for {device.node_id}: {exc}")
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from datetime import datetime, timezone
|
|||||||
from typing import Optional, Dict, Any
|
from typing import Optional, Dict, Any
|
||||||
import logging
|
import logging
|
||||||
import json
|
import json
|
||||||
|
import threading
|
||||||
|
|
||||||
from gatehouse_app.extensions import db
|
from gatehouse_app.extensions import db
|
||||||
from gatehouse_app.models.security.mfa_policy_compliance import MfaPolicyCompliance
|
from gatehouse_app.models.security.mfa_policy_compliance import MfaPolicyCompliance
|
||||||
@@ -78,13 +79,11 @@ class NotificationService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Send the notification
|
# Send the notification
|
||||||
success = NotificationService._send_email(
|
NotificationService._send_email_async(
|
||||||
to_address=user.email,
|
to_address=user.email,
|
||||||
subject=subject,
|
subject=subject,
|
||||||
body=body,
|
body=body,
|
||||||
)
|
)
|
||||||
|
|
||||||
if success:
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Sent MFA deadline reminder to {user.email} "
|
f"Sent MFA deadline reminder to {user.email} "
|
||||||
f"({days_until_deadline} days remaining)"
|
f"({days_until_deadline} days remaining)"
|
||||||
@@ -95,12 +94,7 @@ class NotificationService:
|
|||||||
organization_id=compliance.organization_id,
|
organization_id=compliance.organization_id,
|
||||||
description=f"MFA deadline reminder sent. Days remaining: {days_until_deadline}",
|
description=f"MFA deadline reminder sent. Days remaining: {days_until_deadline}",
|
||||||
)
|
)
|
||||||
else:
|
return True
|
||||||
logger.warning(
|
|
||||||
f"Failed to send MFA deadline reminder to {user.email}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return success
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"Error sending MFA deadline reminder to {user.email}: {e}")
|
logger.exception(f"Error sending MFA deadline reminder to {user.email}: {e}")
|
||||||
@@ -136,27 +130,19 @@ class NotificationService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Send the notification
|
# Send the notification
|
||||||
success = NotificationService._send_email(
|
NotificationService._send_email_async(
|
||||||
to_address=user.email,
|
to_address=user.email,
|
||||||
subject=subject,
|
subject=subject,
|
||||||
body=body,
|
body=body,
|
||||||
)
|
)
|
||||||
|
|
||||||
if success:
|
|
||||||
logger.info(f"Sent MFA suspension notification to {user.email}")
|
logger.info(f"Sent MFA suspension notification to {user.email}")
|
||||||
# Audit log
|
|
||||||
AuditService.log_action(
|
AuditService.log_action(
|
||||||
action=AuditAction.MFA_POLICY_USER_SUSPENDED,
|
action=AuditAction.MFA_POLICY_USER_SUSPENDED,
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
organization_id=compliance.organization_id,
|
organization_id=compliance.organization_id,
|
||||||
description="MFA compliance suspension notification sent",
|
description="MFA compliance suspension notification sent",
|
||||||
)
|
)
|
||||||
else:
|
return True
|
||||||
logger.warning(
|
|
||||||
f"Failed to send MFA suspension notification to {user.email}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return success
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(
|
logger.exception(
|
||||||
@@ -285,60 +271,56 @@ Gatehouse Security Team
|
|||||||
return body
|
return body
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _send_email(
|
def _send_email_async(
|
||||||
to_address: str,
|
to_address: str,
|
||||||
subject: str,
|
subject: str,
|
||||||
body: str,
|
body: str,
|
||||||
html_body: Optional[str] = None,
|
html_body: Optional[str] = None,
|
||||||
) -> bool:
|
) -> None:
|
||||||
"""Send an email via SMTP.
|
"""Send an email on a daemon thread so the calling request returns immediately.
|
||||||
|
|
||||||
Returns True if the email was sent successfully, False otherwise.
|
If EMAIL_ENABLED is False, logs instead of sending.
|
||||||
If EMAIL_ENABLED is False, logs the email body instead (simulation mode).
|
|
||||||
All SMTP exceptions are caught and logged — this method never raises.
|
All SMTP exceptions are caught and logged — this method never raises.
|
||||||
|
The Flask app context is pushed inside the thread so current_app works correctly.
|
||||||
"""
|
"""
|
||||||
import smtplib
|
import smtplib
|
||||||
from email.mime.multipart import MIMEMultipart
|
from email.mime.multipart import MIMEMultipart
|
||||||
from email.mime.text import MIMEText
|
from email.mime.text import MIMEText
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
|
|
||||||
email_enabled = current_app.config.get(NotificationService.EMAIL_ENABLED_KEY, False)
|
app = current_app._get_current_object() # capture real app before leaving request context
|
||||||
|
|
||||||
|
def _send():
|
||||||
|
with app.app_context():
|
||||||
|
email_enabled = app.config.get(NotificationService.EMAIL_ENABLED_KEY, False)
|
||||||
if not email_enabled:
|
if not email_enabled:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[EMAIL DISABLED] Would have sent to: {to_address} | Subject: {subject}\n"
|
f"[EMAIL DISABLED] Would have sent to: {to_address} | Subject: {subject}\n"
|
||||||
f"Body: {body[:500]}"
|
f"Body: {body[:500]}"
|
||||||
)
|
)
|
||||||
return False
|
return
|
||||||
|
|
||||||
smtp_host = current_app.config.get(NotificationService.SMTP_HOST_KEY, "")
|
smtp_host = app.config.get(NotificationService.SMTP_HOST_KEY, "")
|
||||||
smtp_port_raw = current_app.config.get(NotificationService.SMTP_PORT_KEY, 587)
|
smtp_port_raw = app.config.get(NotificationService.SMTP_PORT_KEY, 587)
|
||||||
smtp_username = current_app.config.get(NotificationService.SMTP_USERNAME_KEY)
|
smtp_username = app.config.get(NotificationService.SMTP_USERNAME_KEY)
|
||||||
smtp_password = current_app.config.get(NotificationService.SMTP_PASSWORD_KEY)
|
smtp_password = app.config.get(NotificationService.SMTP_PASSWORD_KEY)
|
||||||
from_address = current_app.config.get(
|
from_address = app.config.get(NotificationService.FROM_ADDRESS_KEY, "")
|
||||||
NotificationService.FROM_ADDRESS_KEY, ""
|
|
||||||
)
|
|
||||||
|
|
||||||
# Guard: refuse to attempt a connection when critical config is missing.
|
missing = [k for k, v in [("SMTP_HOST", smtp_host), ("FROM_ADDRESS", from_address)] if not v]
|
||||||
# This surfaces a clear log message instead of a confusing socket error.
|
|
||||||
missing = [k for k, v in [
|
|
||||||
("SMTP_HOST", smtp_host),
|
|
||||||
("FROM_ADDRESS", from_address),
|
|
||||||
] if not v]
|
|
||||||
if missing:
|
if missing:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"[EMAIL] Cannot send — missing config: {', '.join(missing)}. "
|
f"[EMAIL] Cannot send — missing config: {', '.join(missing)}. "
|
||||||
f"Would have sent to: {to_address} | Subject: {subject}"
|
f"Would have sent to: {to_address} | Subject: {subject}"
|
||||||
)
|
)
|
||||||
return False
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
smtp_port = int(smtp_port_raw)
|
smtp_port = int(smtp_port_raw)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
logger.error(f"[EMAIL] Invalid SMTP_PORT value: {smtp_port_raw!r}")
|
logger.error(f"[EMAIL] Invalid SMTP_PORT value: {smtp_port_raw!r}")
|
||||||
return False
|
return
|
||||||
|
|
||||||
smtp_use_tls = current_app.config.get(
|
smtp_use_tls = app.config.get(
|
||||||
NotificationService.SMTP_USE_TLS_KEY,
|
NotificationService.SMTP_USE_TLS_KEY,
|
||||||
smtp_port not in (25, 1025),
|
smtp_port not in (25, 1025),
|
||||||
)
|
)
|
||||||
@@ -362,12 +344,11 @@ Gatehouse Security Team
|
|||||||
server.send_message(msg)
|
server.send_message(msg)
|
||||||
|
|
||||||
logger.info(f"[EMAIL] Sent to {to_address} | Subject: {subject}")
|
logger.info(f"[EMAIL] Sent to {to_address} | Subject: {subject}")
|
||||||
return True
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[EMAIL] Failed to send to {to_address}: {e}")
|
logger.error(f"[EMAIL] Failed to send to {to_address}: {e}")
|
||||||
return False
|
|
||||||
|
|
||||||
|
threading.Thread(target=_send, daemon=True).start()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_notification_stats(user_id: str) -> Dict[str, Any]:
|
def get_notification_stats(user_id: str) -> Dict[str, Any]:
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from gatehouse_app.models.organization import Organization
|
|||||||
from gatehouse_app.models.user import User
|
from gatehouse_app.models.user import User
|
||||||
from gatehouse_app.services.audit_service import AuditService
|
from gatehouse_app.services.audit_service import AuditService
|
||||||
from gatehouse_app.services import zerotier_api_service as zt
|
from gatehouse_app.services import zerotier_api_service as zt
|
||||||
from gatehouse_app.utils.constants import NetworkRequestMode
|
from gatehouse_app.utils.constants import NetworkRequestMode, NetworkEnvironment
|
||||||
from gatehouse_app.exceptions import (
|
from gatehouse_app.exceptions import (
|
||||||
NetworkNotFoundError,
|
NetworkNotFoundError,
|
||||||
InvalidNetworkIdError,
|
InvalidNetworkIdError,
|
||||||
@@ -57,23 +57,74 @@ def create_network(
|
|||||||
default_activation_lifetime_minutes: Default session length
|
default_activation_lifetime_minutes: Default session length
|
||||||
max_activation_lifetime_minutes: Cap on activation lifetime
|
max_activation_lifetime_minutes: Cap on activation lifetime
|
||||||
"""
|
"""
|
||||||
from gatehouse_app.utils.constants import NetworkEnvironment
|
|
||||||
|
|
||||||
zerotier_network_id = _validate_network_id(zerotier_network_id)
|
zerotier_network_id = _validate_network_id(zerotier_network_id)
|
||||||
|
|
||||||
existing = PortalNetwork.query.filter(
|
existing_active = PortalNetwork.query.filter(
|
||||||
PortalNetwork.organization_id == organization_id,
|
PortalNetwork.organization_id == organization_id,
|
||||||
PortalNetwork.zerotier_network_id == zerotier_network_id,
|
PortalNetwork.zerotier_network_id == zerotier_network_id,
|
||||||
PortalNetwork.deleted_at.is_(None),
|
PortalNetwork.deleted_at.is_(None),
|
||||||
).first()
|
).first()
|
||||||
if existing:
|
if existing_active:
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
f"A portal network already exists for ZT network {zerotier_network_id} "
|
f"A portal network already exists for ZT network {zerotier_network_id} "
|
||||||
f"in this organization."
|
f"in this organization."
|
||||||
)
|
)
|
||||||
|
|
||||||
env = NetworkEnvironment(environment) if environment else NetworkEnvironment.DEVELOPMENT
|
# Normalize to lowercase so callers may pass "PRODUCTION" or "production" interchangeably
|
||||||
mode = NetworkRequestMode(request_mode)
|
env_str = environment.lower() if environment else None
|
||||||
|
mode_str = request_mode.lower() if request_mode else "approval_required"
|
||||||
|
|
||||||
|
try:
|
||||||
|
env = NetworkEnvironment(env_str) if env_str else NetworkEnvironment.DEVELOPMENT
|
||||||
|
except ValueError:
|
||||||
|
valid = [e.value for e in NetworkEnvironment]
|
||||||
|
raise ValidationError(f"Invalid environment '{environment}'. Must be one of: {valid}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
mode = NetworkRequestMode(mode_str)
|
||||||
|
except ValueError:
|
||||||
|
valid = [e.value for e in NetworkRequestMode]
|
||||||
|
raise ValidationError(f"Invalid request_mode '{request_mode}'. Must be one of: {valid}")
|
||||||
|
|
||||||
|
# If a soft-deleted record for the same (org, zt_network_id) pair exists, restore it
|
||||||
|
# rather than inserting a new row (which would violate the unique constraint).
|
||||||
|
deleted = PortalNetwork.query.filter(
|
||||||
|
PortalNetwork.organization_id == organization_id,
|
||||||
|
PortalNetwork.zerotier_network_id == zerotier_network_id,
|
||||||
|
PortalNetwork.deleted_at.isnot(None),
|
||||||
|
).first()
|
||||||
|
if deleted:
|
||||||
|
logger.info(
|
||||||
|
f"[PortalNetwork] Restoring soft-deleted portal network {deleted.id} "
|
||||||
|
f"for ZT network {zerotier_network_id}"
|
||||||
|
)
|
||||||
|
deleted.deleted_at = None
|
||||||
|
deleted.name = name
|
||||||
|
deleted.description = description
|
||||||
|
deleted.owner_user_id = owner_user_id
|
||||||
|
deleted.environment = env
|
||||||
|
deleted.request_mode = mode
|
||||||
|
deleted.default_activation_lifetime_minutes = default_activation_lifetime_minutes
|
||||||
|
deleted.max_activation_lifetime_minutes = max_activation_lifetime_minutes
|
||||||
|
deleted.is_active = True
|
||||||
|
deleted.save()
|
||||||
|
|
||||||
|
AuditService.log_action(
|
||||||
|
action="zt.network.restored",
|
||||||
|
user_id=owner_user_id,
|
||||||
|
organization_id=organization_id,
|
||||||
|
resource_type="portal_network",
|
||||||
|
resource_id=deleted.id,
|
||||||
|
metadata={
|
||||||
|
"zerotier_network_id": zerotier_network_id,
|
||||||
|
"name": name,
|
||||||
|
"environment": env.value,
|
||||||
|
"request_mode": mode.value,
|
||||||
|
},
|
||||||
|
description=f"Portal network '{name}' restored (ZT: {zerotier_network_id})",
|
||||||
|
success=True,
|
||||||
|
)
|
||||||
|
return deleted
|
||||||
|
|
||||||
network = PortalNetwork(
|
network = PortalNetwork(
|
||||||
organization_id=organization_id,
|
organization_id=organization_id,
|
||||||
@@ -90,7 +141,7 @@ def create_network(
|
|||||||
|
|
||||||
# Try to verify the network exists in ZeroTier
|
# Try to verify the network exists in ZeroTier
|
||||||
try:
|
try:
|
||||||
zt_network = zt.get_network(zerotier_network_id)
|
zt_network = zt.get_network(zerotier_network_id, organization_id=organization_id)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[PortalNetwork] Verified ZT network {zerotier_network_id} "
|
f"[PortalNetwork] Verified ZT network {zerotier_network_id} "
|
||||||
f"exists in ZeroTier: {zt_network.name}"
|
f"exists in ZeroTier: {zt_network.name}"
|
||||||
@@ -100,7 +151,7 @@ def create_network(
|
|||||||
f"[PortalNetwork] ZT network {zerotier_network_id} not found "
|
f"[PortalNetwork] ZT network {zerotier_network_id} not found "
|
||||||
"in ZeroTier — will be reconciled later."
|
"in ZeroTier — will be reconciled later."
|
||||||
)
|
)
|
||||||
except ZeroTierAPIError as exc:
|
except (ZeroTierAPIError, Exception) as exc:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"[PortalNetwork] Could not verify ZT network {zerotier_network_id}: {exc}"
|
f"[PortalNetwork] Could not verify ZT network {zerotier_network_id}: {exc}"
|
||||||
)
|
)
|
||||||
@@ -175,6 +226,23 @@ def update_network(
|
|||||||
if key not in allowed:
|
if key not in allowed:
|
||||||
raise ValidationError(f"Cannot update field: {key}")
|
raise ValidationError(f"Cannot update field: {key}")
|
||||||
|
|
||||||
|
# Normalize environment / request_mode strings to lowercase enum values
|
||||||
|
if "environment" in kwargs and isinstance(kwargs["environment"], str):
|
||||||
|
env_str = kwargs["environment"].lower()
|
||||||
|
try:
|
||||||
|
kwargs["environment"] = NetworkEnvironment(env_str)
|
||||||
|
except ValueError:
|
||||||
|
valid = [e.value for e in NetworkEnvironment]
|
||||||
|
raise ValidationError(f"Invalid environment '{kwargs['environment']}'. Must be one of: {valid}")
|
||||||
|
|
||||||
|
if "request_mode" in kwargs and isinstance(kwargs["request_mode"], str):
|
||||||
|
mode_str = kwargs["request_mode"].lower()
|
||||||
|
try:
|
||||||
|
kwargs["request_mode"] = NetworkRequestMode(mode_str)
|
||||||
|
except ValueError:
|
||||||
|
valid = [e.value for e in NetworkRequestMode]
|
||||||
|
raise ValidationError(f"Invalid request_mode '{kwargs['request_mode']}'. Must be one of: {valid}")
|
||||||
|
|
||||||
network.update(**kwargs)
|
network.update(**kwargs)
|
||||||
|
|
||||||
AuditService.log_action(
|
AuditService.log_action(
|
||||||
@@ -192,7 +260,11 @@ def update_network(
|
|||||||
|
|
||||||
|
|
||||||
def delete_network(network_id: str, user_id: str) -> None:
|
def delete_network(network_id: str, user_id: str) -> None:
|
||||||
"""Soft-delete a portal network and deactivate all memberships."""
|
"""Soft-delete a portal network and deactivate/clean up all related records."""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from gatehouse_app.models import UserNetworkApproval
|
||||||
|
from gatehouse_app.extensions import db
|
||||||
|
|
||||||
network = get_network(network_id)
|
network = get_network(network_id)
|
||||||
|
|
||||||
# Deauthorize all active memberships in ZeroTier
|
# Deauthorize all active memberships in ZeroTier
|
||||||
@@ -203,6 +275,36 @@ def delete_network(network_id: str, user_id: str) -> None:
|
|||||||
|
|
||||||
network.delete(soft=True)
|
network.delete(soft=True)
|
||||||
|
|
||||||
|
# Cascade soft-delete all active approvals and memberships for this network.
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
db.session.execute(
|
||||||
|
db.text(
|
||||||
|
"UPDATE user_network_approvals AS a "
|
||||||
|
"SET deleted_at = :now + (s.rn * interval '1 microsecond') "
|
||||||
|
"FROM ("
|
||||||
|
" SELECT id, row_number() OVER () AS rn "
|
||||||
|
" FROM user_network_approvals "
|
||||||
|
" WHERE portal_network_id = :network_id AND deleted_at IS NULL"
|
||||||
|
") s "
|
||||||
|
"WHERE a.id = s.id"
|
||||||
|
),
|
||||||
|
{"now": now, "network_id": network_id},
|
||||||
|
)
|
||||||
|
db.session.execute(
|
||||||
|
db.text(
|
||||||
|
"UPDATE device_network_memberships AS m "
|
||||||
|
"SET deleted_at = :now + (s.rn * interval '1 microsecond') "
|
||||||
|
"FROM ("
|
||||||
|
" SELECT id, row_number() OVER () AS rn "
|
||||||
|
" FROM device_network_memberships "
|
||||||
|
" WHERE portal_network_id = :network_id AND deleted_at IS NULL"
|
||||||
|
") s "
|
||||||
|
"WHERE m.id = s.id"
|
||||||
|
),
|
||||||
|
{"now": now, "network_id": network_id},
|
||||||
|
)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
AuditService.log_action(
|
AuditService.log_action(
|
||||||
action="zt.network.deleted",
|
action="zt.network.deleted",
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
"""ZeroTier API service — thin Flask adapter around the ZeroTierClient SDK.
|
"""ZeroTier API service — thin Flask adapter around the ZeroTierClient SDK.
|
||||||
|
|
||||||
Reads configuration from app config and translates SDK exceptions to
|
ZeroTier is managed exclusively at the organization level. Each organization
|
||||||
Secuird typed exceptions.
|
configures its own ZeroTier credentials (token, URL, mode) via the web UI
|
||||||
|
(ZeroTier Config page → stored in the organizations table).
|
||||||
|
|
||||||
|
Every call that interacts with ZeroTier must supply an organization_id so the correct org credentials
|
||||||
|
can be loaded from the database.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -19,97 +23,147 @@ from gatehouse_app.utils.zerotier_client import (
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _get_client(app=None) -> ZeroTierClient:
|
def _get_client(organization_id: Optional[str] = None, app=None) -> ZeroTierClient:
|
||||||
"""Build a ZeroTierClient from current app config."""
|
"""Build a ZeroTierClient using the organization's stored ZeroTier credentials.
|
||||||
from flask import current_app
|
|
||||||
|
|
||||||
app = app or current_app
|
Credentials are read exclusively from the organization record
|
||||||
|
(org.zt_api_token / org.zt_api_url / org.zt_api_mode).
|
||||||
|
|
||||||
mode_str = app.config.get("ZEROTIER_API_MODE", "controller")
|
Args:
|
||||||
mode = APIMode.CENTRAL if mode_str == "central" else APIMode.CONTROLLER
|
organization_id: The org whose credentials should be used.
|
||||||
|
Required for any ZeroTier operation.
|
||||||
|
app: Flask app instance (defaults to current_app, only needed for
|
||||||
|
background tasks that run outside a request context).
|
||||||
|
|
||||||
return ZeroTierClient(
|
Raises:
|
||||||
api_token=app.config.get("ZEROTIER_API_TOKEN", ""),
|
ZeroTierAPIError: If organization_id is missing, the org is not found,
|
||||||
base_url=app.config.get("ZEROTIER_API_URL", "http://localhost:9993"),
|
or the org has incomplete ZeroTier credentials.
|
||||||
mode=mode,
|
"""
|
||||||
|
if not organization_id:
|
||||||
|
raise ZeroTierAPIError(
|
||||||
|
"organization_id is required — ZeroTier credentials are managed "
|
||||||
|
"per-organization. Configure them via the ZeroTier Config page."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from gatehouse_app.models.organization.organization import Organization
|
||||||
|
from gatehouse_app.extensions import db
|
||||||
|
org = db.session.get(Organization, organization_id)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f"[ZT] Failed to load org {organization_id} from DB: {exc}")
|
||||||
|
raise ZeroTierAPIError(
|
||||||
|
f"Could not load organization {organization_id}: {exc}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
def get_status() -> dict:
|
if not org:
|
||||||
|
raise ZeroTierAPIError(f"Organization {organization_id} not found.")
|
||||||
|
|
||||||
|
token: Optional[str] = org.zt_api_token or None
|
||||||
|
if not token:
|
||||||
|
raise ZeroTierAPIError(
|
||||||
|
f"Organization '{org.name}' has no ZeroTier credentials configured. "
|
||||||
|
"Go to Settings → ZeroTier Config to add a token, mode, and controller URL."
|
||||||
|
)
|
||||||
|
|
||||||
|
mode_str = (org.zt_api_mode or "").strip().lower()
|
||||||
|
if mode_str not in ("central", "controller"):
|
||||||
|
raise ZeroTierAPIError(
|
||||||
|
f"Organization '{org.name}' has no ZeroTier mode set. "
|
||||||
|
"Go to Settings → ZeroTier Config and select 'Central' or 'Controller'."
|
||||||
|
)
|
||||||
|
|
||||||
|
url: str = (org.zt_api_url or "").strip()
|
||||||
|
if not url:
|
||||||
|
raise ZeroTierAPIError(
|
||||||
|
f"Organization '{org.name}' has no ZeroTier controller/API URL set. "
|
||||||
|
"Go to Settings → ZeroTier Config and enter the URL for your ZeroTier "
|
||||||
|
"controller (e.g. http://host:9993) or Central API."
|
||||||
|
)
|
||||||
|
|
||||||
|
mode = APIMode.CENTRAL if mode_str == "central" else APIMode.CONTROLLER
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"[ZT] Client for org:{organization_id} mode={mode_str} url={url}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return ZeroTierClient(api_token=token, base_url=url, mode=mode)
|
||||||
|
|
||||||
|
|
||||||
|
def get_status(organization_id: Optional[str] = None) -> dict:
|
||||||
"""Verify connectivity to the ZeroTier controller."""
|
"""Verify connectivity to the ZeroTier controller."""
|
||||||
client = _get_client()
|
client = _get_client(organization_id)
|
||||||
try:
|
try:
|
||||||
return client.get_status()
|
return client.get_status()
|
||||||
except SDKZeroTierAPIError as exc:
|
except SDKZeroTierAPIError as exc:
|
||||||
raise ZeroTierAPIError(str(exc), status_code=exc.status_code) from exc
|
raise ZeroTierAPIError(str(exc), status_code=exc.status_code) from exc
|
||||||
|
|
||||||
|
|
||||||
def list_networks():
|
def list_networks(organization_id: Optional[str] = None):
|
||||||
"""List all networks accessible to the configured token."""
|
"""List all networks accessible to the configured token."""
|
||||||
client = _get_client()
|
client = _get_client(organization_id)
|
||||||
try:
|
try:
|
||||||
return client.list_networks()
|
return client.list_networks()
|
||||||
except SDKZeroTierAPIError as exc:
|
except SDKZeroTierAPIError as exc:
|
||||||
raise ZeroTierAPIError(str(exc), status_code=exc.status_code) from exc
|
raise ZeroTierAPIError(str(exc), status_code=exc.status_code) from exc
|
||||||
|
|
||||||
|
|
||||||
def get_network(network_id: str):
|
def get_network(network_id: str, organization_id: Optional[str] = None):
|
||||||
"""Fetch a single network by ID."""
|
"""Fetch a single network by ID."""
|
||||||
client = _get_client()
|
client = _get_client(organization_id)
|
||||||
try:
|
try:
|
||||||
return client.get_network(network_id)
|
return client.get_network(network_id)
|
||||||
except SDKZeroTierAPIError as exc:
|
except SDKZeroTierAPIError as exc:
|
||||||
raise ZeroTierAPIError(str(exc), status_code=exc.status_code) from exc
|
raise ZeroTierAPIError(str(exc), status_code=exc.status_code) from exc
|
||||||
|
|
||||||
|
|
||||||
def list_members(network_id: str):
|
def list_members(network_id: str, organization_id: Optional[str] = None):
|
||||||
"""List all members on a network."""
|
"""List all members on a network."""
|
||||||
client = _get_client()
|
client = _get_client(organization_id)
|
||||||
try:
|
try:
|
||||||
return client.list_members(network_id)
|
return client.list_members(network_id)
|
||||||
except SDKZeroTierAPIError as exc:
|
except SDKZeroTierAPIError as exc:
|
||||||
raise ZeroTierAPIError(str(exc), status_code=exc.status_code) from exc
|
raise ZeroTierAPIError(str(exc), status_code=exc.status_code) from exc
|
||||||
|
|
||||||
|
|
||||||
def get_member(network_id: str, node_id: str):
|
def get_member(network_id: str, node_id: str, organization_id: Optional[str] = None):
|
||||||
"""Fetch a single member on a network."""
|
"""Fetch a single member on a network."""
|
||||||
client = _get_client()
|
client = _get_client(organization_id)
|
||||||
try:
|
try:
|
||||||
return client.get_member(network_id, node_id)
|
return client.get_member(network_id, node_id)
|
||||||
except SDKZeroTierAPIError as exc:
|
except SDKZeroTierAPIError as exc:
|
||||||
raise ZeroTierAPIError(str(exc), status_code=exc.status_code) from exc
|
raise ZeroTierAPIError(str(exc), status_code=exc.status_code) from exc
|
||||||
|
|
||||||
|
|
||||||
def authorize_member(network_id: str, node_id: str):
|
def authorize_member(network_id: str, node_id: str, organization_id: Optional[str] = None):
|
||||||
"""Authorize a member on a network. Returns updated member."""
|
"""Authorize a member on a network. Returns updated member."""
|
||||||
client = _get_client()
|
client = _get_client(organization_id)
|
||||||
try:
|
try:
|
||||||
return client.authorize_member(network_id, node_id)
|
return client.authorize_member(network_id, node_id)
|
||||||
except SDKZeroTierAPIError as exc:
|
except SDKZeroTierAPIError as exc:
|
||||||
raise ZeroTierAPIError(str(exc), status_code=exc.status_code) from exc
|
raise ZeroTierAPIError(str(exc), status_code=exc.status_code) from exc
|
||||||
|
|
||||||
|
|
||||||
def deauthorize_member(network_id: str, node_id: str):
|
def deauthorize_member(network_id: str, node_id: str, organization_id: Optional[str] = None):
|
||||||
"""De-authorize a member on a network. Returns updated member."""
|
"""De-authorize a member on a network. Returns updated member."""
|
||||||
client = _get_client()
|
client = _get_client(organization_id)
|
||||||
try:
|
try:
|
||||||
return client.deauthorize_member(network_id, node_id)
|
return client.deauthorize_member(network_id, node_id)
|
||||||
except SDKZeroTierAPIError as exc:
|
except SDKZeroTierAPIError as exc:
|
||||||
raise ZeroTierAPIError(str(exc), status_code=exc.status_code) from exc
|
raise ZeroTierAPIError(str(exc), status_code=exc.status_code) from exc
|
||||||
|
|
||||||
|
|
||||||
def add_member(network_id: str, node_id: str, authorized: bool = False):
|
def add_member(network_id: str, node_id: str, authorized: bool = False, organization_id: Optional[str] = None):
|
||||||
"""Manually add/pre-provision a member on a network."""
|
"""Manually add/pre-provision a member on a network."""
|
||||||
client = _get_client()
|
client = _get_client(organization_id)
|
||||||
try:
|
try:
|
||||||
return client.add_member(network_id, node_id, authorized=authorized)
|
return client.add_member(network_id, node_id, authorized=authorized)
|
||||||
except SDKZeroTierAPIError as exc:
|
except SDKZeroTierAPIError as exc:
|
||||||
raise ZeroTierAPIError(str(exc), status_code=exc.status_code) from exc
|
raise ZeroTierAPIError(str(exc), status_code=exc.status_code) from exc
|
||||||
|
|
||||||
|
|
||||||
def delete_network_member(network_id: str, node_id: str):
|
def delete_network_member(network_id: str, node_id: str, organization_id: Optional[str] = None):
|
||||||
"""Remove a member entirely from a ZeroTier network."""
|
"""Remove a member entirely from a ZeroTier network."""
|
||||||
client = _get_client()
|
client = _get_client(organization_id)
|
||||||
try:
|
try:
|
||||||
return client.delete_member(network_id, node_id)
|
return client.delete_member(network_id, node_id)
|
||||||
except SDKZeroTierAPIError as exc:
|
except SDKZeroTierAPIError as exc:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""ZeroTier reconciliation service — polling loop to sync state with the controller."""
|
"""ZeroTier reconciliation service — polling loop to sync state with the controller."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from gatehouse_app.extensions import db
|
from gatehouse_app.extensions import db
|
||||||
@@ -34,16 +35,24 @@ def reconcile_expired_activations() -> int:
|
|||||||
ActivationSession.deleted_at.is_(None),
|
ActivationSession.deleted_at.is_(None),
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
|
logger.debug(f"[Reconciliation] Expiry check: {len(expired)} overdue session(s) found.")
|
||||||
|
|
||||||
count = 0
|
count = 0
|
||||||
for session in expired:
|
for session in expired:
|
||||||
try:
|
try:
|
||||||
_expire_session(session)
|
_expire_session(session)
|
||||||
count += 1
|
count += 1
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error(f"[Reconciliation] Failed to expire session {session.id}: {exc}")
|
logger.error(
|
||||||
|
f"[Reconciliation] Failed to expire session {session.id} "
|
||||||
|
f"(user={session.user_id} membership={session.device_network_membership_id}): {exc}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
if count > 0:
|
if count > 0:
|
||||||
logger.info(f"[Reconciliation] Expired {count} activation sessions.")
|
logger.info(f"[Reconciliation] Expired {count} activation session(s).")
|
||||||
|
else:
|
||||||
|
logger.debug("[Reconciliation] No activation sessions to expire.")
|
||||||
|
|
||||||
return count
|
return count
|
||||||
|
|
||||||
@@ -55,9 +64,14 @@ def reconcile_network(portal_network_id: str) -> dict:
|
|||||||
"""
|
"""
|
||||||
network = PortalNetwork.query.get(portal_network_id)
|
network = PortalNetwork.query.get(portal_network_id)
|
||||||
if not network or not network.is_active:
|
if not network or not network.is_active:
|
||||||
|
logger.debug(
|
||||||
|
f"[Reconciliation] Skipping portal_network_id={portal_network_id}: "
|
||||||
|
f"{'not found' if not network else 'inactive or deleted'}."
|
||||||
|
)
|
||||||
return {"skipped": True, "reason": "network_inactive_or_deleted"}
|
return {"skipped": True, "reason": "network_inactive_or_deleted"}
|
||||||
|
|
||||||
zerotier_network_id = network.zerotier_network_id
|
zerotier_network_id = network.zerotier_network_id
|
||||||
|
network_label = f"{network.name} ({zerotier_network_id})"
|
||||||
actions = {
|
actions = {
|
||||||
"zt_members_checked": 0,
|
"zt_members_checked": 0,
|
||||||
"zt_members_added": 0,
|
"zt_members_added": 0,
|
||||||
@@ -67,15 +81,25 @@ def reconcile_network(portal_network_id: str) -> dict:
|
|||||||
"unknown_members": [],
|
"unknown_members": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
t_start = time.monotonic()
|
||||||
|
logger.debug(f"[Reconciliation] Starting network reconciliation for {network_label}.")
|
||||||
|
|
||||||
# Get current ZT members
|
# Get current ZT members
|
||||||
try:
|
try:
|
||||||
zt_members = {m.node_id: m for m in zt.list_members(zerotier_network_id)}
|
zt_members = {m.node_id: m for m in zt.list_members(zerotier_network_id,
|
||||||
|
organization_id=network.organization_id)}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error(f"[Reconciliation] Failed to list ZT members for {zerotier_network_id}: {exc}")
|
logger.error(
|
||||||
|
f"[Reconciliation] Failed to list ZT members for {network_label}: {exc}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
actions["error"] = str(exc)
|
actions["error"] = str(exc)
|
||||||
return actions
|
return actions
|
||||||
|
|
||||||
actions["zt_members_checked"] = len(zt_members)
|
actions["zt_members_checked"] = len(zt_members)
|
||||||
|
logger.debug(
|
||||||
|
f"[Reconciliation] {network_label}: {len(zt_members)} member(s) fetched from ZT controller."
|
||||||
|
)
|
||||||
|
|
||||||
# Get our portal memberships for this network
|
# Get our portal memberships for this network
|
||||||
our_memberships = {
|
our_memberships = {
|
||||||
@@ -87,13 +111,21 @@ def reconcile_network(portal_network_id: str) -> dict:
|
|||||||
if m.device and m.device.deleted_at is None
|
if m.device and m.device.deleted_at is None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"[Reconciliation] {network_label}: {len(our_memberships)} portal membership(s) to reconcile."
|
||||||
|
)
|
||||||
|
|
||||||
# Reconcile each portal membership
|
# Reconcile each portal membership
|
||||||
for node_id, membership in our_memberships.items():
|
for node_id, membership in our_memberships.items():
|
||||||
zt_member = zt_members.pop(node_id, None)
|
zt_member = zt_members.pop(node_id, None)
|
||||||
device = membership.device
|
device = membership.device
|
||||||
|
|
||||||
if not zt_member:
|
if not zt_member:
|
||||||
# Member not seen in ZT yet
|
# Member not seen in ZT yet — could be freshly joined or never connected
|
||||||
|
logger.debug(
|
||||||
|
f"[Reconciliation] {network_label}: node {node_id} "
|
||||||
|
f"(device={device.display_name!r}, state={membership.state}) not yet seen in ZT controller."
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
actions["join_seen_updated"] += 1
|
actions["join_seen_updated"] += 1
|
||||||
@@ -104,31 +136,67 @@ def reconcile_network(portal_network_id: str) -> dict:
|
|||||||
# Sync authorization state
|
# Sync authorization state
|
||||||
if membership.state == MembershipState.ACTIVE_AUTHORIZED:
|
if membership.state == MembershipState.ACTIVE_AUTHORIZED:
|
||||||
if not zt_member.is_authorized:
|
if not zt_member.is_authorized:
|
||||||
# We think it's active but ZT says it's not — re-authorize
|
# Portal says active but ZT disagrees — drift, re-authorize
|
||||||
|
logger.warning(
|
||||||
|
f"[Reconciliation] {network_label}: DRIFT detected — portal=ACTIVE_AUTHORIZED "
|
||||||
|
f"but ZT says unauthorized for node {node_id} (device={device.display_name!r}). Re-authorizing."
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
zt.authorize_member(zerotier_network_id, node_id)
|
zt.authorize_member(zerotier_network_id, node_id,
|
||||||
|
organization_id=network.organization_id)
|
||||||
actions["authorized"] += 1
|
actions["authorized"] += 1
|
||||||
|
logger.info(
|
||||||
|
f"[Reconciliation] {network_label}: Re-authorized node {node_id} (device={device.display_name!r})."
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning(f"[Reconciliation] Re-authorize failed for {node_id}: {exc}")
|
logger.warning(
|
||||||
|
f"[Reconciliation] {network_label}: Re-authorize failed for node {node_id}: {exc}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.debug(
|
||||||
|
f"[Reconciliation] {network_label}: node {node_id} — portal=ACTIVE_AUTHORIZED, ZT=authorized. OK."
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
if zt_member.is_authorized:
|
if zt_member.is_authorized:
|
||||||
# We think it's not authorized but ZT says it is — deauthorize
|
# ZT says authorized but portal doesn't — could be manual override in ZT console
|
||||||
# (could be manual override in ZT console)
|
logger.warning(
|
||||||
|
f"[Reconciliation] {network_label}: DRIFT detected — portal state={membership.state} "
|
||||||
|
f"but ZT says authorized for node {node_id} (device={device.display_name!r}). Deauthorizing."
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
zt.deauthorize_member(zerotier_network_id, node_id)
|
zt.deauthorize_member(zerotier_network_id, node_id,
|
||||||
|
organization_id=network.organization_id)
|
||||||
actions["deauthorized"] += 1
|
actions["deauthorized"] += 1
|
||||||
except Exception as exc:
|
|
||||||
logger.warning(f"[Reconciliation] Deauthorize failed for {node_id}: {exc}")
|
|
||||||
|
|
||||||
# Unknown ZT members not in our portal
|
|
||||||
actions["unknown_members"] = list(zt_members.keys())
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[Reconciliation] Network {zerotier_network_id}: "
|
f"[Reconciliation] {network_label}: Deauthorized node {node_id} (device={device.display_name!r})."
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
f"[Reconciliation] {network_label}: Deauthorize failed for node {node_id}: {exc}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.debug(
|
||||||
|
f"[Reconciliation] {network_label}: node {node_id} — "
|
||||||
|
f"portal={membership.state}, ZT=unauthorized. OK."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Unknown ZT members not in our portal — log only, do not touch
|
||||||
|
unknown = list(zt_members.keys())
|
||||||
|
actions["unknown_members"] = unknown
|
||||||
|
if unknown:
|
||||||
|
logger.warning(
|
||||||
|
f"[Reconciliation] {network_label}: {len(unknown)} ZT member(s) not in portal — "
|
||||||
|
f"node IDs: {', '.join(unknown)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
elapsed_ms = int((time.monotonic() - t_start) * 1000)
|
||||||
|
logger.info(
|
||||||
|
f"[Reconciliation] Network {network_label}: "
|
||||||
f"checked={actions['zt_members_checked']} "
|
f"checked={actions['zt_members_checked']} "
|
||||||
f"authorized={actions['authorized']} "
|
f"authorized={actions['authorized']} "
|
||||||
f"deauthorized={actions['deauthorized']} "
|
f"deauthorized={actions['deauthorized']} "
|
||||||
f"unknown={len(actions['unknown_members'])}"
|
f"unknown={len(actions['unknown_members'])} "
|
||||||
|
f"elapsed={elapsed_ms}ms"
|
||||||
)
|
)
|
||||||
|
|
||||||
return actions
|
return actions
|
||||||
@@ -144,16 +212,34 @@ def reconcile_all() -> dict:
|
|||||||
PortalNetwork.deleted_at.is_(None),
|
PortalNetwork.deleted_at.is_(None),
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
results = {"networks_processed": 0, "errors": 0}
|
logger.info(f"[Reconciliation] reconcile_all: {len(networks)} active network(s) to process.")
|
||||||
|
|
||||||
|
results = {"networks_processed": 0, "errors": 0, "authorized": 0, "deauthorized": 0, "unknown_members": []}
|
||||||
for network in networks:
|
for network in networks:
|
||||||
try:
|
try:
|
||||||
result = reconcile_network(network.id)
|
result = reconcile_network(network.id)
|
||||||
if "error" in result:
|
if "error" in result:
|
||||||
|
logger.error(
|
||||||
|
f"[Reconciliation] Network {network.name} ({network.zerotier_network_id}) "
|
||||||
|
f"failed: {result['error']}"
|
||||||
|
)
|
||||||
results["errors"] += 1
|
results["errors"] += 1
|
||||||
|
elif result.get("skipped"):
|
||||||
|
logger.debug(
|
||||||
|
f"[Reconciliation] Network {network.name} ({network.zerotier_network_id}) "
|
||||||
|
f"skipped: {result.get('reason')}"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
results["networks_processed"] += 1
|
results["networks_processed"] += 1
|
||||||
|
results["authorized"] += result.get("authorized", 0)
|
||||||
|
results["deauthorized"] += result.get("deauthorized", 0)
|
||||||
|
results["unknown_members"].extend(result.get("unknown_members", []))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error(f"[Reconciliation] Failed to reconcile network {network.id}: {exc}")
|
logger.error(
|
||||||
|
f"[Reconciliation] Unhandled error reconciling network "
|
||||||
|
f"{network.name} ({network.id}): {exc}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
results["errors"] += 1
|
results["errors"] += 1
|
||||||
|
|
||||||
deleted_result = reconcile_deleted_memberships()
|
deleted_result = reconcile_deleted_memberships()
|
||||||
@@ -161,8 +247,11 @@ def reconcile_all() -> dict:
|
|||||||
results["delete_errors"] = deleted_result.get("errors", 0)
|
results["delete_errors"] = deleted_result.get("errors", 0)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[Reconciliation] Complete: {results['networks_processed']} networks processed, "
|
f"[Reconciliation] Complete: "
|
||||||
f"{results['errors']} errors, {results.get('deleted_memberships', 0)} memberships purged."
|
f"networks={results['networks_processed']} "
|
||||||
|
f"errors={results['errors']} "
|
||||||
|
f"purged={results.get('deleted_memberships', 0)} "
|
||||||
|
f"purge_errors={results.get('delete_errors', 0)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
return results
|
return results
|
||||||
@@ -180,8 +269,11 @@ def reconcile_deleted_memberships() -> dict:
|
|||||||
).all()
|
).all()
|
||||||
|
|
||||||
if not deleted:
|
if not deleted:
|
||||||
|
logger.debug("[Reconciliation] No soft-deleted memberships to purge.")
|
||||||
return {"deleted": 0, "errors": 0}
|
return {"deleted": 0, "errors": 0}
|
||||||
|
|
||||||
|
logger.info(f"[Reconciliation] Purging {len(deleted)} soft-deleted membership(s) from ZT and DB.")
|
||||||
|
|
||||||
results = {"deleted": 0, "errors": 0}
|
results = {"deleted": 0, "errors": 0}
|
||||||
for membership in deleted:
|
for membership in deleted:
|
||||||
try:
|
try:
|
||||||
@@ -189,30 +281,49 @@ def reconcile_deleted_memberships() -> dict:
|
|||||||
network = PortalNetwork.query.get(membership.portal_network_id)
|
network = PortalNetwork.query.get(membership.portal_network_id)
|
||||||
|
|
||||||
if not device or not network:
|
if not device or not network:
|
||||||
|
logger.warning(
|
||||||
|
f"[Reconciliation] Membership {membership.id}: missing "
|
||||||
|
f"{'device' if not device else 'network'} — hard-deleting record only."
|
||||||
|
)
|
||||||
db.session.delete(membership)
|
db.session.delete(membership)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
results["deleted"] += 1
|
results["deleted"] += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
node_id = device.node_id
|
||||||
|
zt_network_id = network.zerotier_network_id
|
||||||
|
network_label = f"{network.name} ({zt_network_id})"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
zt.delete_network_member(network.zerotier_network_id, device.node_id)
|
zt.delete_network_member(zt_network_id, node_id,
|
||||||
logger.info(f"[Reconciliation] Deleted {device.node_id} from ZT network {network.zerotier_network_id}")
|
organization_id=network.organization_id)
|
||||||
|
logger.info(
|
||||||
|
f"[Reconciliation] Removed node {node_id} (device={device.display_name!r}) "
|
||||||
|
f"from ZT network {network_label}."
|
||||||
|
)
|
||||||
except Exception as zt_exc:
|
except Exception as zt_exc:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"[Reconciliation] ZT delete failed for {device.node_id} "
|
f"[Reconciliation] ZT delete failed for node {node_id} "
|
||||||
f"on {network.zerotier_network_id}: {zt_exc}"
|
f"on {network_label}: {zt_exc} — proceeding with DB hard-delete."
|
||||||
)
|
)
|
||||||
|
|
||||||
db.session.delete(membership)
|
db.session.delete(membership)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
results["deleted"] += 1
|
results["deleted"] += 1
|
||||||
|
logger.debug(
|
||||||
|
f"[Reconciliation] Hard-deleted membership {membership.id} "
|
||||||
|
f"(node={node_id}, network={network_label})."
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error(f"[Reconciliation] Failed to hard-delete membership {membership.id}: {exc}")
|
logger.error(
|
||||||
|
f"[Reconciliation] Failed to hard-delete membership {membership.id}: {exc}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
results["errors"] += 1
|
results["errors"] += 1
|
||||||
|
|
||||||
if results["deleted"] > 0:
|
if results["deleted"] > 0:
|
||||||
logger.info(f"[Reconciliation] Purged {results['deleted']} memberships.")
|
logger.info(f"[Reconciliation] Purged {results['deleted']} membership(s).")
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
@@ -228,7 +339,12 @@ def _sync_zt_membership(membership: DeviceNetworkMembership, zt_member) -> None:
|
|||||||
ZeroTierMembership.deleted_at.is_(None),
|
ZeroTierMembership.deleted_at.is_(None),
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
if not zt_membership:
|
is_new = zt_membership is None
|
||||||
|
if is_new:
|
||||||
|
logger.debug(
|
||||||
|
f"[Reconciliation] Creating new ZeroTierMembership cache record for "
|
||||||
|
f"node {device.node_id} on network {network.zerotier_network_id}."
|
||||||
|
)
|
||||||
zt_membership = ZeroTierMembership(
|
zt_membership = ZeroTierMembership(
|
||||||
organization_id=membership.organization_id,
|
organization_id=membership.organization_id,
|
||||||
device_network_membership_id=membership.id,
|
device_network_membership_id=membership.id,
|
||||||
@@ -236,6 +352,8 @@ def _sync_zt_membership(membership: DeviceNetworkMembership, zt_member) -> None:
|
|||||||
node_id=device.node_id,
|
node_id=device.node_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
prev_authorized = zt_membership.authorized if not is_new else None
|
||||||
|
|
||||||
zt_membership.member_seen = True
|
zt_membership.member_seen = True
|
||||||
zt_membership.authorized = zt_member.is_authorized
|
zt_membership.authorized = zt_member.is_authorized
|
||||||
zt_membership.last_synced_at = datetime.now(timezone.utc)
|
zt_membership.last_synced_at = datetime.now(timezone.utc)
|
||||||
@@ -248,11 +366,27 @@ def _sync_zt_membership(membership: DeviceNetworkMembership, zt_member) -> None:
|
|||||||
|
|
||||||
zt_membership.save()
|
zt_membership.save()
|
||||||
|
|
||||||
|
if not is_new and prev_authorized != zt_member.is_authorized:
|
||||||
|
logger.info(
|
||||||
|
f"[Reconciliation] ZT auth state changed for node {device.node_id} "
|
||||||
|
f"(device={device.display_name!r}): {prev_authorized} → {zt_member.is_authorized}"
|
||||||
|
)
|
||||||
|
|
||||||
# Update membership join_seen flag
|
# Update membership join_seen flag
|
||||||
if not membership.join_seen:
|
if not membership.join_seen:
|
||||||
|
logger.info(
|
||||||
|
f"[Reconciliation] First join seen for node {device.node_id} "
|
||||||
|
f"(device={device.display_name!r}, membership={membership.id}). "
|
||||||
|
f"State: {membership.state} → {MembershipState.JOINED_DEAUTHORIZED}"
|
||||||
|
)
|
||||||
membership.join_seen = True
|
membership.join_seen = True
|
||||||
membership.state = MembershipState.JOINED_DEAUTHORIZED
|
membership.state = MembershipState.JOINED_DEAUTHORIZED
|
||||||
membership.save()
|
membership.save()
|
||||||
|
else:
|
||||||
|
logger.debug(
|
||||||
|
f"[Reconciliation] Synced ZT membership for node {device.node_id} "
|
||||||
|
f"(device={device.display_name!r}, authorized={zt_member.is_authorized})."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _expire_session(session: ActivationSession) -> None:
|
def _expire_session(session: ActivationSession) -> None:
|
||||||
@@ -261,8 +395,19 @@ def _expire_session(session: ActivationSession) -> None:
|
|||||||
session.end_reason = ActivationEndReason.EXPIRED
|
session.end_reason = ActivationEndReason.EXPIRED
|
||||||
session.save()
|
session.save()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[Reconciliation] Expiring activation session {session.id} "
|
||||||
|
f"(user={session.user_id}, membership={session.device_network_membership_id}, "
|
||||||
|
f"expired_at={session.expires_at.isoformat()})."
|
||||||
|
)
|
||||||
|
|
||||||
membership = DeviceNetworkMembership.query.get(session.device_network_membership_id)
|
membership = DeviceNetworkMembership.query.get(session.device_network_membership_id)
|
||||||
if membership:
|
if not membership:
|
||||||
|
logger.warning(
|
||||||
|
f"[Reconciliation] Session {session.id}: membership "
|
||||||
|
f"{session.device_network_membership_id} not found — skipping ZT deauth."
|
||||||
|
)
|
||||||
|
else:
|
||||||
membership.state = MembershipState.ACTIVATION_EXPIRED
|
membership.state = MembershipState.ACTIVATION_EXPIRED
|
||||||
membership.currently_authorized = False
|
membership.currently_authorized = False
|
||||||
membership.save()
|
membership.save()
|
||||||
@@ -270,8 +415,14 @@ def _expire_session(session: ActivationSession) -> None:
|
|||||||
device = Device.query.get(membership.device_id)
|
device = Device.query.get(membership.device_id)
|
||||||
network = PortalNetwork.query.get(membership.portal_network_id)
|
network = PortalNetwork.query.get(membership.portal_network_id)
|
||||||
if device and network:
|
if device and network:
|
||||||
|
network_label = f"{network.name} ({network.zerotier_network_id})"
|
||||||
try:
|
try:
|
||||||
zt.deauthorize_member(network.zerotier_network_id, device.node_id)
|
zt.deauthorize_member(network.zerotier_network_id, device.node_id,
|
||||||
|
organization_id=network.organization_id)
|
||||||
|
logger.info(
|
||||||
|
f"[Reconciliation] Deauthorized expired node {device.node_id} "
|
||||||
|
f"(device={device.display_name!r}) on {network_label}."
|
||||||
|
)
|
||||||
|
|
||||||
# Update ZT membership cache
|
# Update ZT membership cache
|
||||||
zt_membership = ZeroTierMembership.query.filter(
|
zt_membership = ZeroTierMembership.query.filter(
|
||||||
@@ -283,11 +434,23 @@ def _expire_session(session: ActivationSession) -> None:
|
|||||||
zt_membership.authorized = False
|
zt_membership.authorized = False
|
||||||
zt_membership.last_synced_at = datetime.now(timezone.utc)
|
zt_membership.last_synced_at = datetime.now(timezone.utc)
|
||||||
zt_membership.save()
|
zt_membership.save()
|
||||||
|
else:
|
||||||
|
logger.debug(
|
||||||
|
f"[Reconciliation] No ZeroTierMembership cache record found for "
|
||||||
|
f"node {device.node_id} on {network_label} — nothing to update."
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"[_expire_session] Failed to deauthorize {device.node_id} "
|
f"[_expire_session] Failed to deauthorize node {device.node_id} "
|
||||||
f"on {network.zerotier_network_id}: {exc}"
|
f"on {network_label}: {exc}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
f"[Reconciliation] Session {session.id}: missing "
|
||||||
|
f"{'device' if not device else 'network'} for membership "
|
||||||
|
f"{membership.id} — ZT deauth skipped."
|
||||||
)
|
)
|
||||||
|
|
||||||
from gatehouse_app.services.audit_service import AuditService
|
from gatehouse_app.services.audit_service import AuditService
|
||||||
|
|||||||
@@ -282,7 +282,6 @@ class KillSwitchScope(str, Enum):
|
|||||||
"""Scope of a kill switch event."""
|
"""Scope of a kill switch event."""
|
||||||
|
|
||||||
ORGANIZATION = "organization"
|
ORGANIZATION = "organization"
|
||||||
GLOBAL = "global"
|
|
||||||
SELECTED_NETWORKS = "selected_networks"
|
SELECTED_NETWORKS = "selected_networks"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""Add per-org ZeroTier credentials to organizations table.
|
||||||
|
|
||||||
|
Revision ID: 028_org_zerotier_config
|
||||||
|
Revises: 026_schema_cleanup
|
||||||
|
Create Date: 2026-03-25
|
||||||
|
|
||||||
|
Adds three nullable columns to `organizations`:
|
||||||
|
- zt_api_token VARCHAR(512) — API token (Central) or authtoken.secret (controller)
|
||||||
|
- zt_api_url VARCHAR(512) — base URL of the controller / Central API
|
||||||
|
- zt_api_mode VARCHAR(32) — "central" | "controller"
|
||||||
|
|
||||||
|
When these are NULL the server-level ZEROTIER_API_* env vars are used instead,
|
||||||
|
so existing deployments are fully backwards-compatible with no data migration needed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "028_org_zerotier_config"
|
||||||
|
down_revision = "027_fix_cert_serial_uniqueness"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _col_exists(conn, table: str, column: str) -> bool:
|
||||||
|
row = conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT 1 FROM information_schema.columns "
|
||||||
|
"WHERE table_name = :t AND column_name = :c"
|
||||||
|
),
|
||||||
|
{"t": table, "c": column},
|
||||||
|
).first()
|
||||||
|
return row is not None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
if not _col_exists(conn, "organizations", "zt_api_token"):
|
||||||
|
op.add_column(
|
||||||
|
"organizations",
|
||||||
|
sa.Column("zt_api_token", sa.String(512), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not _col_exists(conn, "organizations", "zt_api_url"):
|
||||||
|
op.add_column(
|
||||||
|
"organizations",
|
||||||
|
sa.Column("zt_api_url", sa.String(512), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not _col_exists(conn, "organizations", "zt_api_mode"):
|
||||||
|
op.add_column(
|
||||||
|
"organizations",
|
||||||
|
sa.Column("zt_api_mode", sa.String(32), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
if _col_exists(conn, "organizations", "zt_api_mode"):
|
||||||
|
op.drop_column("organizations", "zt_api_mode")
|
||||||
|
|
||||||
|
if _col_exists(conn, "organizations", "zt_api_url"):
|
||||||
|
op.drop_column("organizations", "zt_api_url")
|
||||||
|
|
||||||
|
if _col_exists(conn, "organizations", "zt_api_token"):
|
||||||
|
op.drop_column("organizations", "zt_api_token")
|
||||||
Reference in New Issue
Block a user