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"))
# 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_ENABLED = os.getenv("EMAIL_ENABLED", "False").lower() == "true"
+1 -1
View File
@@ -39,7 +39,7 @@ def register():
f"{verify_link}\n\n"
f"Gatehouse Security Team"
)
NotificationService._send_email(to_address=user.email, subject=subject, body=body)
NotificationService._send_email_async(to_address=user.email, subject=subject, body=body)
except Exception as exc:
logging.getLogger(__name__).warning(f"Failed to send verification email on register: {exc}")
+3 -3
View File
@@ -27,7 +27,7 @@ def forgot_password():
reset_token = PasswordResetToken.generate(user_id=user.id)
app_url = current_app.config.get("APP_URL", "http://localhost:8080")
reset_link = f"{app_url}/reset-password?token={reset_token.token}"
NotificationService._send_email(
NotificationService._send_email_async(
to_address=user.email,
subject="Reset your Gatehouse password",
body=(
@@ -129,7 +129,7 @@ def resend_verification():
verify_token = EmailVerificationToken.generate(user_id=user.id)
app_url = current_app.config.get("APP_URL", "http://localhost:8080")
verify_link = f"{app_url}/verify-email?token={verify_token.token}"
NotificationService._send_email(
NotificationService._send_email_async(
to_address=user.email,
subject="Verify your Gatehouse email address",
body=(
@@ -200,7 +200,7 @@ def resend_activation():
app_url = current_app.config.get("APP_URL", current_app.config.get("FRONTEND_URL", "http://localhost:8080"))
activate_link = f"{app_url}/activate?code={code}"
NotificationService._send_email(
NotificationService._send_email_async(
to_address=user.email,
subject="Activate your Gatehouse account",
body=(
+3 -11
View File
@@ -37,7 +37,7 @@ def create_org_invite(org_id):
app_url = current_app.config.get("APP_URL", "http://localhost:8080")
invite_link = f"{app_url}/invite?token={invite.token}"
email_sent = NotificationService._send_email(
NotificationService._send_email_async(
to_address=email,
subject=f"You're invited to join {org.name} on Gatehouse",
body=(
@@ -47,16 +47,8 @@ def create_org_invite(org_id):
f"Gatehouse Security Team"
),
)
if not email_sent:
logging.getLogger(__name__).warning(
f"[INVITE LINK] Email not sent (EMAIL_ENABLED=False or SMTP down). "
f"Invite for {email}{invite_link}"
)
else:
logging.getLogger(__name__).info(
f"[INVITE] Email sent successfully to {email}"
)
logging.getLogger(__name__).info(f"[INVITE] Email queued for {email}")
email_sent = True # async — assume queued successfully
response_data = {
"invite": {
@@ -161,7 +161,7 @@ def send_mfa_reminder(org_id, user_id):
if compliance and policy and compliance.deadline_at:
NotificationService.send_mfa_deadline_reminder(user, compliance, policy)
else:
NotificationService._send_email(
NotificationService._send_email_async(
to_address=user.email,
subject="Reminder: Set up multi-factor authentication",
body=(
+338 -17
View File
@@ -2,8 +2,10 @@
from flask import g, request
from marshmallow import Schema, fields, validate, ValidationError
from sqlalchemy.exc import IntegrityError
from gatehouse_app.api.v1 import api_v1_bp
from gatehouse_app.extensions import db
from gatehouse_app.utils.response import api_response
from gatehouse_app.utils.decorators import login_required, require_admin, full_access_required
from gatehouse_app.services import portal_network_service
@@ -19,6 +21,8 @@ from gatehouse_app.models import (
ActivationSession,
)
from gatehouse_app.models.organization import Organization
from gatehouse_app.models.organization.organization_member import OrganizationMember
from gatehouse_app.utils.constants import OrganizationRole
from gatehouse_app.exceptions import (
ValidationError as AppValidationError,
ZeroTierAPIError,
@@ -39,6 +43,17 @@ def _org_check(org_id):
return org, None
def _is_org_admin(org_id: str, user_id: str) -> bool:
"""Return True if the user is an admin or owner of the org."""
return OrganizationMember.query.filter(
OrganizationMember.organization_id == org_id,
OrganizationMember.user_id == user_id,
OrganizationMember.role.in_([OrganizationRole.ADMIN, OrganizationRole.OWNER]),
OrganizationMember.deleted_at.is_(None),
).first() is not None
# ── Schemas ───────────────────────────────────────────────────────────────────
@@ -154,6 +169,63 @@ def create_network(org_id):
return api_response(success=False, message=str(e.message), status=400, error_type=e.error_type)
except ZeroTierAPIError as e:
return api_response(success=False, message=str(e), status=502, error_type=e.error_type)
except IntegrityError:
db.session.rollback()
return api_response(
success=False,
message="A portal network with this ZeroTier ID already exists in this organization.",
status=409,
error_type="DUPLICATE_NETWORK",
)
@api_v1_bp.route("/organizations/<org_id>/zerotier/available-networks", methods=["GET"])
@login_required
@require_admin
@full_access_required
def list_zerotier_available_networks(org_id):
"""List all ZeroTier networks from the org's ZT controller/account.
Cross-references against managed portal networks so the UI can show
which ones are already imported and which can be imported.
"""
org, err = _org_check(org_id)
if err:
return err
# Fetch all active portal networks for this org, keyed by ZT network ID
managed = {
pn.zerotier_network_id: pn
for pn in PortalNetwork.query.filter(
PortalNetwork.organization_id == org_id,
PortalNetwork.deleted_at.is_(None),
).all()
}
try:
zt_networks = zt.list_networks(organization_id=org_id)
except ZeroTierAPIError as e:
# Return an empty list with a flag so the UI can show a helpful message
# rather than an error page (e.g. "ZeroTier not configured yet").
return api_response(
data={"networks": [], "count": 0, "zt_error": str(e)},
message="ZeroTier unavailable — no networks returned",
)
result = []
for zt_net in zt_networks:
portal = managed.get(zt_net.id)
result.append({
**zt_net.to_dict(),
"already_managed": portal is not None,
"portal_network_id": portal.id if portal else None,
"portal_network_name": portal.name if portal else None,
})
return api_response(
data={"networks": result, "count": len(result)},
message="Available ZeroTier networks retrieved",
)
@api_v1_bp.route("/organizations/<org_id>/networks/<network_id>", methods=["GET"])
@@ -346,6 +418,9 @@ def update_device(org_id, device_id):
except ValidationError as e:
return api_response(success=False, message="Validation failed", status=400, error_type="VALIDATION_ERROR", error_details=e.messages)
if "nickname" in data:
data["device_nickname"] = data.pop("nickname")
try:
device = device_service.update_device(device_id, g.current_user.id, **data)
return api_response(data={"device": device.to_dict()}, message="Device updated successfully")
@@ -520,6 +595,25 @@ def assign_access(org_id):
return api_response(success=False, message=str(e.message), status=400, error_type=e.error_type)
@api_v1_bp.route("/organizations/<org_id>/admin/approvals", methods=["GET"])
@login_required
@require_admin
@full_access_required
def admin_list_all_approvals(org_id):
"""List ALL approval records across all users in the org (admin only)."""
org, err = _org_check(org_id)
if err:
return err
network_id = request.args.get("network_id")
state = request.args.get("state")
approvals = network_access_service.list_all_org_approvals(org_id, network_id=network_id, state=state)
return api_response(
data={"approvals": [a.to_dict() for a in approvals], "count": len(approvals)},
message="Approvals retrieved successfully",
)
# ── Memberships ───────────────────────────────────────────────────────────────
@@ -548,7 +642,7 @@ def list_memberships(org_id):
@login_required
@full_access_required
def activate_membership(org_id, membership_id):
"""Activate an approved device membership."""
"""Activate an approved device membership. Admins can activate any membership; regular members can only activate their own."""
org, err = _org_check(org_id)
if err:
return err
@@ -559,11 +653,14 @@ def activate_membership(org_id, membership_id):
except ValidationError as e:
return api_response(success=False, message="Validation failed", status=400, error_type="VALIDATION_ERROR", error_details=e.messages)
is_admin = _is_org_admin(org_id, g.current_user.id)
try:
session = network_access_service.activate_device_membership(
membership_id=membership_id,
user_id=g.current_user.id,
lifetime_minutes=data.get("lifetime_minutes"),
admin_override=is_admin,
)
membership = DeviceNetworkMembership.query.get(membership_id)
return api_response(data={"session": session.to_dict(), "membership": membership.to_dict()}, message="Membership activated successfully")
@@ -577,11 +674,21 @@ def activate_membership(org_id, membership_id):
@login_required
@full_access_required
def deactivate_membership(org_id, membership_id):
"""Deactivate an active device membership."""
"""Deactivate an active device membership. Admins can deactivate any; regular members can only deactivate their own."""
org, err = _org_check(org_id)
if err:
return err
# Verify ownership for non-admins
if not _is_org_admin(org_id, g.current_user.id):
membership_check = DeviceNetworkMembership.query.filter(
DeviceNetworkMembership.id == membership_id,
DeviceNetworkMembership.user_id == g.current_user.id,
DeviceNetworkMembership.deleted_at.is_(None),
).first()
if not membership_check:
return api_response(success=False, message="Membership not found", status=404, error_type="NOT_FOUND")
try:
membership = network_access_service.deactivate_membership(
membership_id=membership_id,
@@ -597,7 +704,7 @@ def deactivate_membership(org_id, membership_id):
@login_required
@full_access_required
def activate_all_memberships(org_id):
"""Bulk-activate all approved inactive memberships."""
"""Bulk-activate all of the caller's approved inactive memberships in this org."""
org, err = _org_check(org_id)
if err:
return err
@@ -744,6 +851,7 @@ def trigger_kill_switch(org_id):
event = network_access_service.kill_switch(
target_user_id=data["target_user_id"],
triggered_by_user_id=g.current_user.id,
organization_id=org_id,
scope=data.get("scope", "organization"),
reason=data.get("reason"),
network_ids=data.get("network_ids"),
@@ -794,12 +902,20 @@ def admin_delete_membership(org_id, membership_id):
@api_v1_bp.route("/admin/zerotier/status", methods=["GET"])
@login_required
@require_admin
@full_access_required
def zerotier_status():
"""Check ZeroTier controller connectivity and status (admin only)."""
"""Check ZeroTier controller connectivity and status.
Requires ?org_id=<uuid> — credentials are looked up from that org.
Caller must be an admin/owner of that specific org.
"""
org_id = request.args.get("org_id")
if not org_id:
return api_response(success=False, message="org_id query parameter is required", status=400, error_type="VALIDATION_ERROR")
if not _is_org_admin(org_id, g.current_user.id):
return api_response(success=False, message="Admin or owner role required for this organization", status=403, error_type="AUTHORIZATION_ERROR")
try:
status = zt.get_status()
status = zt.get_status(organization_id=org_id)
return api_response(data={"status": status}, message="ZeroTier controller is reachable")
except ZeroTierAPIError as e:
return api_response(success=False, message=str(e), status=502, error_type=e.error_type)
@@ -807,12 +923,20 @@ def zerotier_status():
@api_v1_bp.route("/admin/zerotier/networks", methods=["GET"])
@login_required
@require_admin
@full_access_required
def zerotier_list_networks():
"""List networks from the ZeroTier controller (admin only)."""
"""List networks from the ZeroTier controller.
Requires ?org_id=<uuid> — credentials are looked up from that org.
Caller must be an admin/owner of that specific org.
"""
org_id = request.args.get("org_id")
if not org_id:
return api_response(success=False, message="org_id query parameter is required", status=400, error_type="VALIDATION_ERROR")
if not _is_org_admin(org_id, g.current_user.id):
return api_response(success=False, message="Admin or owner role required for this organization", status=403, error_type="AUTHORIZATION_ERROR")
try:
networks = zt.list_networks()
networks = zt.list_networks(organization_id=org_id)
return api_response(
data={"networks": [n.to_dict() if hasattr(n, 'to_dict') else {"id": getattr(n, "id", str(n))} for n in networks], "count": len(networks)},
message="Networks retrieved successfully",
@@ -823,12 +947,20 @@ def zerotier_list_networks():
@api_v1_bp.route("/admin/zerotier/networks/<network_id>", methods=["GET"])
@login_required
@require_admin
@full_access_required
def zerotier_get_network(network_id):
"""Get a ZeroTier network from the controller (admin only)."""
"""Get a ZeroTier network from the controller.
Requires ?org_id=<uuid> — credentials are looked up from that org.
Caller must be an admin/owner of that specific org.
"""
org_id = request.args.get("org_id")
if not org_id:
return api_response(success=False, message="org_id query parameter is required", status=400, error_type="VALIDATION_ERROR")
if not _is_org_admin(org_id, g.current_user.id):
return api_response(success=False, message="Admin or owner role required for this organization", status=403, error_type="AUTHORIZATION_ERROR")
try:
network = zt.get_network(network_id)
network = zt.get_network(network_id, organization_id=org_id)
return api_response(data={"network": network.to_dict()}, message="Network retrieved successfully")
except ZeroTierAPIError as e:
return api_response(success=False, message=str(e), status=502, error_type=e.error_type)
@@ -836,12 +968,20 @@ def zerotier_get_network(network_id):
@api_v1_bp.route("/admin/zerotier/networks/<network_id>/members", methods=["GET"])
@login_required
@require_admin
@full_access_required
def zerotier_list_members(network_id):
"""List members on a ZeroTier network from the controller (admin only)."""
"""List members on a ZeroTier network from the controller.
Requires ?org_id=<uuid> — credentials are looked up from that org.
Caller must be an admin/owner of that specific org.
"""
org_id = request.args.get("org_id")
if not org_id:
return api_response(success=False, message="org_id query parameter is required", status=400, error_type="VALIDATION_ERROR")
if not _is_org_admin(org_id, g.current_user.id):
return api_response(success=False, message="Admin or owner role required for this organization", status=403, error_type="AUTHORIZATION_ERROR")
try:
members = zt.list_members(network_id)
members = zt.list_members(network_id, organization_id=org_id)
return api_response(
data={"members": [m.to_dict() for m in members], "count": len(members)},
message="Members retrieved successfully",
@@ -852,9 +992,190 @@ def zerotier_list_members(network_id):
@api_v1_bp.route("/admin/zerotier/reconcile", methods=["POST"])
@login_required
@require_admin
@full_access_required
def trigger_reconciliation():
"""Trigger full reconciliation across all networks (admin only)."""
"""Trigger full reconciliation across all networks (requires org admin in at least one org)."""
from gatehouse_app.models.organization.organization_member import OrganizationMember
is_any_admin = OrganizationMember.query.filter(
OrganizationMember.user_id == g.current_user.id,
OrganizationMember.role.in_([OrganizationRole.ADMIN, OrganizationRole.OWNER]),
OrganizationMember.deleted_at.is_(None),
).first() is not None
if not is_any_admin:
return api_response(success=False, message="Admin or owner role required", status=403, error_type="AUTHORIZATION_ERROR")
result = zerotier_reconciliation_service.reconcile_all()
return api_response(data=result, message="Reconciliation complete")
# ── Per-org ZeroTier configuration ───────────────────────────────────────────
class ZeroTierConfigSchema(Schema):
zt_api_token = fields.Str(required=True, validate=validate.Length(min=1, max=512))
zt_api_url = fields.Str(required=True, validate=validate.Length(min=1, max=512))
zt_api_mode = fields.Str(
required=True,
validate=validate.OneOf(["central", "controller"]),
)
@api_v1_bp.route("/organizations/<org_id>/zerotier-config", methods=["GET"])
@login_required
@require_admin
@full_access_required
def get_zerotier_config(org_id):
"""Return the current ZeroTier configuration for an organization (admin only).
The token is masked — only its presence is indicated, not the value.
"""
org, err = _org_check(org_id)
if err:
return err
return api_response(
data={
"zerotier_config": {
"zt_api_token_set": bool(org.zt_api_token),
"zt_api_url": org.zt_api_url,
"zt_api_mode": org.zt_api_mode,
}
},
message="ZeroTier configuration retrieved successfully",
)
@api_v1_bp.route("/organizations/<org_id>/zerotier-config", methods=["PUT"])
@login_required
@require_admin
@full_access_required
def set_zerotier_config(org_id):
"""Set (or replace) the ZeroTier credentials for an organization (admin only).
All three fields are required — there are no server-level defaults.
Body:
zt_api_token (required) API token for ZeroTier Central or authtoken.secret
zt_api_url (required) full base URL, e.g. http://host:9993 or
https://api.zerotier.com/api/v1
zt_api_mode (required) "central" | "controller"
"""
org, err = _org_check(org_id)
if err:
return err
try:
schema = ZeroTierConfigSchema()
data = schema.load(request.json or {})
except ValidationError as e:
return api_response(
success=False, message="Validation failed",
status=400, error_type="VALIDATION_ERROR", error_details=e.messages,
)
# Test connectivity BEFORE saving — reject bad credentials early
connectivity_ok = False
connectivity_error = None
# Temporarily set the credentials so _get_client() can build a client
old_token, old_url, old_mode = org.zt_api_token, org.zt_api_url, org.zt_api_mode
org.zt_api_token = data["zt_api_token"]
org.zt_api_url = data["zt_api_url"]
org.zt_api_mode = data["zt_api_mode"]
db.session.flush() # make visible to _get_client query without committing
try:
zt.get_status(organization_id=org_id)
connectivity_ok = True
except ZeroTierAPIError as exc:
connectivity_error = str(exc)
except Exception as exc:
connectivity_error = str(exc)
if not connectivity_ok:
# Roll back — don't persist bad credentials
org.zt_api_token = old_token
org.zt_api_url = old_url
org.zt_api_mode = old_mode
db.session.commit()
return api_response(
success=False,
message="Controller Connectivity test failed",
status=400,
error_type="ZEROTIER_CONNECTIVITY_FAILED",
error_details={
"connectivity_test": {
"ok": False,
"error": connectivity_error,
},
},
)
# Connectivity verified — commit the new credentials
org.save()
from gatehouse_app.services.audit_service import AuditService
AuditService.log_action(
action="org.zerotier_config.updated",
user_id=g.current_user.id,
organization_id=org_id,
resource_type="organization",
resource_id=org_id,
metadata={
"zt_api_url": org.zt_api_url,
"zt_api_mode": org.zt_api_mode,
"connectivity_ok": connectivity_ok,
},
description="Organization ZeroTier config updated",
success=True,
)
return api_response(
data={
"zerotier_config": {
"zt_api_token_set": True,
"zt_api_url": org.zt_api_url,
"zt_api_mode": org.zt_api_mode,
},
"connectivity_test": {
"ok": True,
"error": None,
},
},
message="ZeroTier configuration saved successfully",
)
@api_v1_bp.route("/organizations/<org_id>/zerotier-config", methods=["DELETE"])
@login_required
@require_admin
@full_access_required
def delete_zerotier_config(org_id):
"""Remove the org-level ZeroTier credentials (admin only).
After removal, all ZeroTier operations for this organization will fail
until new credentials
are configured via the ZeroTier Config page.
"""
org, err = _org_check(org_id)
if err:
return err
org.zt_api_token = None
org.zt_api_url = None
org.zt_api_mode = None
org.save()
from gatehouse_app.services.audit_service import AuditService
AuditService.log_action(
action="org.zerotier_config.deleted",
user_id=g.current_user.id,
organization_id=org_id,
resource_type="organization",
resource_id=org_id,
metadata={},
description="Organization ZeroTier config removed — ZeroTier operations disabled until reconfigured",
success=True,
)
return api_response(message="ZeroTier configuration removed. Configure new credentials to re-enable ZeroTier features.")
+4 -1
View File
@@ -8,19 +8,22 @@ class BaseAPIException(Exception):
error_type = "INTERNAL_ERROR"
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.
Args:
message: Custom error message
error_details: Additional error details dictionary
status_code: Override the class-level HTTP status code
"""
super().__init__(self.message)
if message:
self.message = message
super().__init__(message) # update args so str(e) works
self.error_details = error_details or {}
if status_code is not None:
self.status_code = status_code
def to_dict(self):
"""Convert exception to dictionary for API response."""
@@ -17,6 +17,10 @@ class Organization(BaseModel):
# Settings (stored as JSON)
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
members = db.relationship(
"OrganizationMember", back_populates="organization", cascade="all, delete-orphan"
@@ -54,7 +54,7 @@ class ActivationSession(BaseModel):
)
ended_at = db.Column(db.DateTime, nullable=True)
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,
)
created_by = db.Column(
+1 -1
View File
@@ -47,7 +47,7 @@ class Device(BaseModel):
asset_tag = db.Column(db.String(255), nullable=True)
serial_number = db.Column(db.String(255), nullable=True)
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,
nullable=False,
)
@@ -58,7 +58,7 @@ class DeviceNetworkMembership(BaseModel):
index=True,
)
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,
nullable=False,
index=True,
@@ -35,7 +35,7 @@ class KillSwitchEvent(BaseModel):
index=True,
)
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,
nullable=False,
)
@@ -45,12 +45,12 @@ class PortalNetwork(BaseModel):
index=True,
)
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,
nullable=False,
)
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,
nullable=False,
)
@@ -48,12 +48,12 @@ class UserNetworkApproval(BaseModel):
nullable=True,
)
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,
nullable=False,
)
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,
nullable=False,
index=True,
@@ -33,6 +33,7 @@ from gatehouse_app.exceptions import (
DeviceNotFoundError,
ApprovalAlreadyExistsError,
ValidationError,
ZeroTierAPIError,
)
logger = logging.getLogger(__name__)
@@ -74,9 +75,30 @@ def request_access(
raise ApprovalAlreadyExistsError(
"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.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
is_open = network.request_mode.value == "open"
@@ -329,6 +351,23 @@ def list_user_approvals(user_id: str, organization_id: str) -> list[UserNetworkA
).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 ───────────────────────────────────────────────
@@ -428,11 +467,12 @@ def activate_device_membership(
membership_id: str,
user_id: str,
lifetime_minutes: int | None = None,
admin_override: bool = False,
) -> ActivationSession:
"""Activate an approved device on a network. Creates an activation session and authorizes in ZT."""
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.")
# Check approval is still active
@@ -536,7 +576,8 @@ def deactivate_membership(
# Deauthorize in ZeroTier
device = Device.query.get(membership.device_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.currently_authorized = False
@@ -567,6 +608,7 @@ def kill_switch(
target_user_id: str,
triggered_by_user_id: str,
scope: str,
organization_id: str | None = None,
reason: str | None = None,
network_ids: list[str] | None = None,
) -> KillSwitchEvent:
@@ -579,14 +621,18 @@ def kill_switch(
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:
# Use the first membership's org
first = q.first()
org_id = first.organization_id if first else None
if not org_id:
# Fall back to deriving from first active membership
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:
q = q.filter(DeviceNetworkMembership.portal_network_id.in_(network_ids))
if network_ids:
if not org_id:
first_network = PortalNetwork.query.filter(
PortalNetwork.id.in_(network_ids),
PortalNetwork.deleted_at.is_(None),
@@ -594,7 +640,7 @@ def kill_switch(
org_id = first_network.organization_id if first_network else None
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
event = KillSwitchEvent(
@@ -608,14 +654,16 @@ def kill_switch(
event.save()
# Suspend all approvals
ApprovalState._value2member_map_ # just reference
approvals = UserNetworkApproval.query.filter(
UserNetworkApproval.user_id == target_user_id,
UserNetworkApproval.state == ApprovalState.APPROVED,
UserNetworkApproval.deleted_at.is_(None),
).all()
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:
continue
approval.state = ApprovalState.SUSPENDED
@@ -691,7 +739,8 @@ def _ensure_zerotier_member(
return
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:
logger.warning(
f"[_ensure_zerotier_member] Could not add member {node_id} "
@@ -705,7 +754,8 @@ def _authorize_in_zerotier(
membership: DeviceNetworkMembership,
) -> None:
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
zt_membership = ZeroTierMembership.query.filter(
@@ -740,6 +790,11 @@ def _authorize_in_zerotier(
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:
logger.error(
f"[_authorize_in_zerotier] Failed to authorize {node_id} "
@@ -748,9 +803,11 @@ def _authorize_in_zerotier(
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:
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(
ZeroTierMembership.zerotier_network_id == zerotier_network_id,
@@ -940,7 +997,8 @@ def revoke_membership_soft(
if device and network:
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:
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:
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}")
except Exception as 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
import logging
import json
import threading
from gatehouse_app.extensions import db
from gatehouse_app.models.security.mfa_policy_compliance import MfaPolicyCompliance
@@ -78,29 +79,22 @@ class NotificationService:
)
# Send the notification
success = NotificationService._send_email(
NotificationService._send_email_async(
to_address=user.email,
subject=subject,
body=body,
)
if success:
logger.info(
f"Sent MFA deadline reminder to {user.email} "
f"({days_until_deadline} days remaining)"
)
AuditService.log_action(
action=AuditAction.MFA_POLICY_USER_COMPLIANT,
user_id=user.id,
organization_id=compliance.organization_id,
description=f"MFA deadline reminder sent. Days remaining: {days_until_deadline}",
)
else:
logger.warning(
f"Failed to send MFA deadline reminder to {user.email}"
)
return success
logger.info(
f"Sent MFA deadline reminder to {user.email} "
f"({days_until_deadline} days remaining)"
)
AuditService.log_action(
action=AuditAction.MFA_POLICY_USER_COMPLIANT,
user_id=user.id,
organization_id=compliance.organization_id,
description=f"MFA deadline reminder sent. Days remaining: {days_until_deadline}",
)
return True
except Exception as e:
logger.exception(f"Error sending MFA deadline reminder to {user.email}: {e}")
@@ -136,27 +130,19 @@ class NotificationService:
)
# Send the notification
success = NotificationService._send_email(
NotificationService._send_email_async(
to_address=user.email,
subject=subject,
body=body,
)
if success:
logger.info(f"Sent MFA suspension notification to {user.email}")
# Audit log
AuditService.log_action(
action=AuditAction.MFA_POLICY_USER_SUSPENDED,
user_id=user.id,
organization_id=compliance.organization_id,
description="MFA compliance suspension notification sent",
)
else:
logger.warning(
f"Failed to send MFA suspension notification to {user.email}"
)
return success
logger.info(f"Sent MFA suspension notification to {user.email}")
AuditService.log_action(
action=AuditAction.MFA_POLICY_USER_SUSPENDED,
user_id=user.id,
organization_id=compliance.organization_id,
description="MFA compliance suspension notification sent",
)
return True
except Exception as e:
logger.exception(
@@ -285,89 +271,84 @@ Gatehouse Security Team
return body
@staticmethod
def _send_email(
def _send_email_async(
to_address: str,
subject: str,
body: str,
html_body: Optional[str] = None,
) -> bool:
"""Send an email via SMTP.
) -> None:
"""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 the email body instead (simulation mode).
If EMAIL_ENABLED is False, logs instead of sending.
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
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
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:
logger.info(
f"[EMAIL DISABLED] Would have sent to: {to_address} | Subject: {subject}\n"
f"Body: {body[:500]}"
)
return False
def _send():
with app.app_context():
email_enabled = app.config.get(NotificationService.EMAIL_ENABLED_KEY, False)
if not email_enabled:
logger.info(
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_port_raw = current_app.config.get(NotificationService.SMTP_PORT_KEY, 587)
smtp_username = current_app.config.get(NotificationService.SMTP_USERNAME_KEY)
smtp_password = current_app.config.get(NotificationService.SMTP_PASSWORD_KEY)
from_address = current_app.config.get(
NotificationService.FROM_ADDRESS_KEY, ""
)
smtp_host = app.config.get(NotificationService.SMTP_HOST_KEY, "")
smtp_port_raw = app.config.get(NotificationService.SMTP_PORT_KEY, 587)
smtp_username = app.config.get(NotificationService.SMTP_USERNAME_KEY)
smtp_password = app.config.get(NotificationService.SMTP_PASSWORD_KEY)
from_address = app.config.get(NotificationService.FROM_ADDRESS_KEY, "")
# Guard: refuse to attempt a connection when critical config is missing.
# 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:
logger.error(
f"[EMAIL] Cannot send — missing config: {', '.join(missing)}. "
f"Would have sent to: {to_address} | Subject: {subject}"
)
return False
missing = [k for k, v in [("SMTP_HOST", smtp_host), ("FROM_ADDRESS", from_address)] if not v]
if missing:
logger.error(
f"[EMAIL] Cannot send — missing config: {', '.join(missing)}. "
f"Would have sent to: {to_address} | Subject: {subject}"
)
return
try:
smtp_port = int(smtp_port_raw)
except (TypeError, ValueError):
logger.error(f"[EMAIL] Invalid SMTP_PORT value: {smtp_port_raw!r}")
return False
try:
smtp_port = int(smtp_port_raw)
except (TypeError, ValueError):
logger.error(f"[EMAIL] Invalid SMTP_PORT value: {smtp_port_raw!r}")
return
smtp_use_tls = current_app.config.get(
NotificationService.SMTP_USE_TLS_KEY,
smtp_port not in (25, 1025),
)
smtp_use_tls = app.config.get(
NotificationService.SMTP_USE_TLS_KEY,
smtp_port not in (25, 1025),
)
try:
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = from_address
msg["To"] = to_address
msg.attach(MIMEText(body, "plain"))
if html_body:
msg.attach(MIMEText(html_body, "html"))
try:
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = from_address
msg["To"] = to_address
msg.attach(MIMEText(body, "plain"))
if html_body:
msg.attach(MIMEText(html_body, "html"))
with smtplib.SMTP(smtp_host, smtp_port) as server:
server.ehlo()
if smtp_use_tls:
server.starttls()
server.ehlo()
if smtp_username and smtp_password:
server.login(smtp_username, smtp_password)
server.send_message(msg)
with smtplib.SMTP(smtp_host, smtp_port) as server:
server.ehlo()
if smtp_use_tls:
server.starttls()
server.ehlo()
if smtp_username and smtp_password:
server.login(smtp_username, smtp_password)
server.send_message(msg)
logger.info(f"[EMAIL] Sent to {to_address} | Subject: {subject}")
return True
logger.info(f"[EMAIL] Sent to {to_address} | Subject: {subject}")
except Exception as e:
logger.error(f"[EMAIL] Failed to send to {to_address}: {e}")
return False
except Exception as e:
logger.error(f"[EMAIL] Failed to send to {to_address}: {e}")
threading.Thread(target=_send, daemon=True).start()
@staticmethod
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.services.audit_service import AuditService
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 (
NetworkNotFoundError,
InvalidNetworkIdError,
@@ -57,23 +57,74 @@ def create_network(
default_activation_lifetime_minutes: Default session length
max_activation_lifetime_minutes: Cap on activation lifetime
"""
from gatehouse_app.utils.constants import NetworkEnvironment
zerotier_network_id = _validate_network_id(zerotier_network_id)
existing = PortalNetwork.query.filter(
existing_active = PortalNetwork.query.filter(
PortalNetwork.organization_id == organization_id,
PortalNetwork.zerotier_network_id == zerotier_network_id,
PortalNetwork.deleted_at.is_(None),
).first()
if existing:
if existing_active:
raise ValidationError(
f"A portal network already exists for ZT network {zerotier_network_id} "
f"in this organization."
)
env = NetworkEnvironment(environment) if environment else NetworkEnvironment.DEVELOPMENT
mode = NetworkRequestMode(request_mode)
# Normalize to lowercase so callers may pass "PRODUCTION" or "production" interchangeably
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(
organization_id=organization_id,
@@ -90,7 +141,7 @@ def create_network(
# Try to verify the network exists in ZeroTier
try:
zt_network = zt.get_network(zerotier_network_id)
zt_network = zt.get_network(zerotier_network_id, organization_id=organization_id)
logger.info(
f"[PortalNetwork] Verified ZT network {zerotier_network_id} "
f"exists in ZeroTier: {zt_network.name}"
@@ -100,7 +151,7 @@ def create_network(
f"[PortalNetwork] ZT network {zerotier_network_id} not found "
"in ZeroTier — will be reconciled later."
)
except ZeroTierAPIError as exc:
except (ZeroTierAPIError, Exception) as exc:
logger.warning(
f"[PortalNetwork] Could not verify ZT network {zerotier_network_id}: {exc}"
)
@@ -175,6 +226,23 @@ def update_network(
if key not in allowed:
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)
AuditService.log_action(
@@ -192,7 +260,11 @@ def update_network(
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)
# Deauthorize all active memberships in ZeroTier
@@ -203,6 +275,36 @@ def delete_network(network_id: str, user_id: str) -> None:
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(
action="zt.network.deleted",
user_id=user_id,
+83 -29
View File
@@ -1,7 +1,11 @@
"""ZeroTier API service — thin Flask adapter around the ZeroTierClient SDK.
Reads configuration from app config and translates SDK exceptions to
Secuird typed exceptions.
ZeroTier is managed exclusively at the organization level. Each organization
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
@@ -19,97 +23,147 @@ from gatehouse_app.utils.zerotier_client import (
logger = logging.getLogger(__name__)
def _get_client(app=None) -> ZeroTierClient:
"""Build a ZeroTierClient from current app config."""
from flask import current_app
def _get_client(organization_id: Optional[str] = None, app=None) -> ZeroTierClient:
"""Build a ZeroTierClient using the organization's stored ZeroTier credentials.
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
return ZeroTierClient(
api_token=app.config.get("ZEROTIER_API_TOKEN", ""),
base_url=app.config.get("ZEROTIER_API_URL", "http://localhost:9993"),
mode=mode,
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() -> dict:
def get_status(organization_id: Optional[str] = None) -> dict:
"""Verify connectivity to the ZeroTier controller."""
client = _get_client()
client = _get_client(organization_id)
try:
return client.get_status()
except SDKZeroTierAPIError as 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."""
client = _get_client()
client = _get_client(organization_id)
try:
return client.list_networks()
except SDKZeroTierAPIError as 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."""
client = _get_client()
client = _get_client(organization_id)
try:
return client.get_network(network_id)
except SDKZeroTierAPIError as 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."""
client = _get_client()
client = _get_client(organization_id)
try:
return client.list_members(network_id)
except SDKZeroTierAPIError as 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."""
client = _get_client()
client = _get_client(organization_id)
try:
return client.get_member(network_id, node_id)
except SDKZeroTierAPIError as 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."""
client = _get_client()
client = _get_client(organization_id)
try:
return client.authorize_member(network_id, node_id)
except SDKZeroTierAPIError as 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."""
client = _get_client()
client = _get_client(organization_id)
try:
return client.deauthorize_member(network_id, node_id)
except SDKZeroTierAPIError as 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."""
client = _get_client()
client = _get_client(organization_id)
try:
return client.add_member(network_id, node_id, authorized=authorized)
except SDKZeroTierAPIError as 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."""
client = _get_client()
client = _get_client(organization_id)
try:
return client.delete_member(network_id, node_id)
except SDKZeroTierAPIError as exc:
@@ -1,6 +1,7 @@
"""ZeroTier reconciliation service — polling loop to sync state with the controller."""
import logging
import time
from datetime import datetime, timezone
from gatehouse_app.extensions import db
@@ -34,16 +35,24 @@ def reconcile_expired_activations() -> int:
ActivationSession.deleted_at.is_(None),
).all()
logger.debug(f"[Reconciliation] Expiry check: {len(expired)} overdue session(s) found.")
count = 0
for session in expired:
try:
_expire_session(session)
count += 1
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:
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
@@ -55,9 +64,14 @@ def reconcile_network(portal_network_id: str) -> dict:
"""
network = PortalNetwork.query.get(portal_network_id)
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"}
zerotier_network_id = network.zerotier_network_id
network_label = f"{network.name} ({zerotier_network_id})"
actions = {
"zt_members_checked": 0,
"zt_members_added": 0,
@@ -67,15 +81,25 @@ def reconcile_network(portal_network_id: str) -> dict:
"unknown_members": [],
}
t_start = time.monotonic()
logger.debug(f"[Reconciliation] Starting network reconciliation for {network_label}.")
# Get current ZT members
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:
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)
return actions
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
our_memberships = {
@@ -87,13 +111,21 @@ def reconcile_network(portal_network_id: str) -> dict:
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
for node_id, membership in our_memberships.items():
zt_member = zt_members.pop(node_id, None)
device = membership.device
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
actions["join_seen_updated"] += 1
@@ -104,31 +136,67 @@ def reconcile_network(portal_network_id: str) -> dict:
# Sync authorization state
if membership.state == MembershipState.ACTIVE_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:
zt.authorize_member(zerotier_network_id, node_id)
zt.authorize_member(zerotier_network_id, node_id,
organization_id=network.organization_id)
actions["authorized"] += 1
logger.info(
f"[Reconciliation] {network_label}: Re-authorized node {node_id} (device={device.display_name!r})."
)
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:
if zt_member.is_authorized:
# We think it's not authorized but ZT says it is — deauthorize
# (could be manual override in ZT console)
# ZT says authorized but portal doesn't — 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:
zt.deauthorize_member(zerotier_network_id, node_id)
zt.deauthorize_member(zerotier_network_id, node_id,
organization_id=network.organization_id)
actions["deauthorized"] += 1
logger.info(
f"[Reconciliation] {network_label}: Deauthorized node {node_id} (device={device.display_name!r})."
)
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
actions["unknown_members"] = list(zt_members.keys())
# 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 {zerotier_network_id}: "
f"[Reconciliation] Network {network_label}: "
f"checked={actions['zt_members_checked']} "
f"authorized={actions['authorized']} "
f"deauthorized={actions['deauthorized']} "
f"unknown={len(actions['unknown_members'])}"
f"unknown={len(actions['unknown_members'])} "
f"elapsed={elapsed_ms}ms"
)
return actions
@@ -144,16 +212,34 @@ def reconcile_all() -> dict:
PortalNetwork.deleted_at.is_(None),
).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:
try:
result = reconcile_network(network.id)
if "error" in result:
logger.error(
f"[Reconciliation] Network {network.name} ({network.zerotier_network_id}) "
f"failed: {result['error']}"
)
results["errors"] += 1
elif result.get("skipped"):
logger.debug(
f"[Reconciliation] Network {network.name} ({network.zerotier_network_id}) "
f"skipped: {result.get('reason')}"
)
else:
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:
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
deleted_result = reconcile_deleted_memberships()
@@ -161,8 +247,11 @@ def reconcile_all() -> dict:
results["delete_errors"] = deleted_result.get("errors", 0)
logger.info(
f"[Reconciliation] Complete: {results['networks_processed']} networks processed, "
f"{results['errors']} errors, {results.get('deleted_memberships', 0)} memberships purged."
f"[Reconciliation] Complete: "
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
@@ -180,8 +269,11 @@ def reconcile_deleted_memberships() -> dict:
).all()
if not deleted:
logger.debug("[Reconciliation] No soft-deleted memberships to purge.")
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}
for membership in deleted:
try:
@@ -189,30 +281,49 @@ def reconcile_deleted_memberships() -> dict:
network = PortalNetwork.query.get(membership.portal_network_id)
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.commit()
results["deleted"] += 1
continue
node_id = device.node_id
zt_network_id = network.zerotier_network_id
network_label = f"{network.name} ({zt_network_id})"
try:
zt.delete_network_member(network.zerotier_network_id, device.node_id)
logger.info(f"[Reconciliation] Deleted {device.node_id} from ZT network {network.zerotier_network_id}")
zt.delete_network_member(zt_network_id, node_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:
logger.warning(
f"[Reconciliation] ZT delete failed for {device.node_id} "
f"on {network.zerotier_network_id}: {zt_exc}"
f"[Reconciliation] ZT delete failed for node {node_id} "
f"on {network_label}: {zt_exc} — proceeding with DB hard-delete."
)
db.session.delete(membership)
db.session.commit()
results["deleted"] += 1
logger.debug(
f"[Reconciliation] Hard-deleted membership {membership.id} "
f"(node={node_id}, network={network_label})."
)
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
if results["deleted"] > 0:
logger.info(f"[Reconciliation] Purged {results['deleted']} memberships.")
logger.info(f"[Reconciliation] Purged {results['deleted']} membership(s).")
return results
@@ -228,7 +339,12 @@ def _sync_zt_membership(membership: DeviceNetworkMembership, zt_member) -> None:
ZeroTierMembership.deleted_at.is_(None),
).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(
organization_id=membership.organization_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,
)
prev_authorized = zt_membership.authorized if not is_new else None
zt_membership.member_seen = True
zt_membership.authorized = zt_member.is_authorized
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()
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
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.state = MembershipState.JOINED_DEAUTHORIZED
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:
@@ -261,8 +395,19 @@ def _expire_session(session: ActivationSession) -> None:
session.end_reason = ActivationEndReason.EXPIRED
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)
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.currently_authorized = False
membership.save()
@@ -270,8 +415,14 @@ def _expire_session(session: ActivationSession) -> None:
device = Device.query.get(membership.device_id)
network = PortalNetwork.query.get(membership.portal_network_id)
if device and network:
network_label = f"{network.name} ({network.zerotier_network_id})"
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
zt_membership = ZeroTierMembership.query.filter(
@@ -283,12 +434,24 @@ def _expire_session(session: ActivationSession) -> None:
zt_membership.authorized = False
zt_membership.last_synced_at = datetime.now(timezone.utc)
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:
logger.warning(
f"[_expire_session] Failed to deauthorize {device.node_id} "
f"on {network.zerotier_network_id}: {exc}"
f"[_expire_session] Failed to deauthorize node {device.node_id} "
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
AuditService.log_action(
-1
View File
@@ -282,7 +282,6 @@ class KillSwitchScope(str, Enum):
"""Scope of a kill switch event."""
ORGANIZATION = "organization"
GLOBAL = "global"
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")