Feat(Fix): Multi-Tenant Zerotier Org Setups

Imports Network From Zerotier
Async Emails
Migration guardrails
Admin to see all approvals states
This commit is contained in:
2026-03-29 23:14:20 +05:45
parent 05eb092228
commit 2b6f7e15af
21 changed files with 974 additions and 239 deletions
-12
View File
@@ -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"
+1 -1
View File
@@ -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}")
+3 -3
View File
@@ -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=(
+3 -11
View File
@@ -37,7 +37,7 @@ def create_org_invite(org_id):
app_url = current_app.config.get("APP_URL", "http://localhost:8080") 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=(
+338 -17
View File
@@ -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.")
+4 -1
View File
@@ -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(
+1 -1
View File
@@ -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:
first = q.first() # Fall back to deriving from first active membership
org_id = first.organization_id if first else None first = q.first()
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}")
+78 -97
View File
@@ -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,29 +79,22 @@ 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,
) )
logger.info(
if success: f"Sent MFA deadline reminder to {user.email} "
logger.info( f"({days_until_deadline} days remaining)"
f"Sent MFA deadline reminder to {user.email} " )
f"({days_until_deadline} days remaining)" AuditService.log_action(
) action=AuditAction.MFA_POLICY_USER_COMPLIANT,
AuditService.log_action( user_id=user.id,
action=AuditAction.MFA_POLICY_USER_COMPLIANT, organization_id=compliance.organization_id,
user_id=user.id, description=f"MFA deadline reminder sent. Days remaining: {days_until_deadline}",
organization_id=compliance.organization_id, )
description=f"MFA deadline reminder sent. Days remaining: {days_until_deadline}", return True
)
else:
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,
) )
logger.info(f"Sent MFA suspension notification to {user.email}")
if success: AuditService.log_action(
logger.info(f"Sent MFA suspension notification to {user.email}") action=AuditAction.MFA_POLICY_USER_SUSPENDED,
# Audit log user_id=user.id,
AuditService.log_action( organization_id=compliance.organization_id,
action=AuditAction.MFA_POLICY_USER_SUSPENDED, description="MFA compliance suspension notification sent",
user_id=user.id, )
organization_id=compliance.organization_id, return True
description="MFA compliance suspension notification sent",
)
else:
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,89 +271,84 @@ 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
if not email_enabled: def _send():
logger.info( with app.app_context():
f"[EMAIL DISABLED] Would have sent to: {to_address} | Subject: {subject}\n" email_enabled = app.config.get(NotificationService.EMAIL_ENABLED_KEY, False)
f"Body: {body[:500]}" if not email_enabled:
) logger.info(
return False f"[EMAIL DISABLED] Would have sent to: {to_address} | Subject: {subject}\n"
f"Body: {body[:500]}"
)
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. if missing:
missing = [k for k, v in [ logger.error(
("SMTP_HOST", smtp_host), f"[EMAIL] Cannot send — missing config: {', '.join(missing)}. "
("FROM_ADDRESS", from_address), f"Would have sent to: {to_address} | Subject: {subject}"
] if not v] )
if missing: return
logger.error(
f"[EMAIL] Cannot send — missing config: {', '.join(missing)}. "
f"Would have sent to: {to_address} | Subject: {subject}"
)
return False
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),
) )
try: try:
msg = MIMEMultipart("alternative") msg = MIMEMultipart("alternative")
msg["Subject"] = subject msg["Subject"] = subject
msg["From"] = from_address msg["From"] = from_address
msg["To"] = to_address msg["To"] = to_address
msg.attach(MIMEText(body, "plain")) msg.attach(MIMEText(body, "plain"))
if html_body: if html_body:
msg.attach(MIMEText(html_body, "html")) msg.attach(MIMEText(html_body, "html"))
with smtplib.SMTP(smtp_host, smtp_port) as server: with smtplib.SMTP(smtp_host, smtp_port) as server:
server.ehlo() server.ehlo()
if smtp_use_tls: if smtp_use_tls:
server.starttls() server.starttls()
server.ehlo() server.ehlo()
if smtp_username and smtp_password: if smtp_username and smtp_password:
server.login(smtp_username, smtp_password) server.login(smtp_username, smtp_password)
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]:
+112 -10
View File
@@ -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,
+83 -29
View File
@@ -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).
Args:
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).
Raises:
ZeroTierAPIError: If organization_id is missing, the org is not found,
or the org has incomplete ZeroTier credentials.
"""
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
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_str = app.config.get("ZEROTIER_API_MODE", "controller")
mode = APIMode.CENTRAL if mode_str == "central" else APIMode.CONTROLLER mode = APIMode.CENTRAL if mode_str == "central" else APIMode.CONTROLLER
return ZeroTierClient( logger.debug(
api_token=app.config.get("ZEROTIER_API_TOKEN", ""), f"[ZT] Client for org:{organization_id} mode={mode_str} url={url}"
base_url=app.config.get("ZEROTIER_API_URL", "http://localhost:9993"),
mode=mode,
) )
return ZeroTierClient(api_token=token, base_url=url, mode=mode)
def get_status() -> dict:
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
logger.info(
f"[Reconciliation] {network_label}: Deauthorized node {node_id} (device={device.display_name!r})."
)
except Exception as exc: except Exception as exc:
logger.warning(f"[Reconciliation] Deauthorize failed for {node_id}: {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 # Unknown ZT members not in our portal — log only, do not touch
actions["unknown_members"] = list(zt_members.keys()) 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( logger.info(
f"[Reconciliation] Network {zerotier_network_id}: " 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,12 +434,24 @@ 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
AuditService.log_action( AuditService.log_action(
-1
View File
@@ -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")