feat: add network-level kill switch endpoint

This commit is contained in:
2026-05-30 06:32:26 +00:00
parent fed72f8bcd
commit 2aad17f5e0
8 changed files with 460 additions and 1 deletions
+58
View File
@@ -117,6 +117,10 @@ class KillSwitchSchema(Schema):
network_ids = fields.List(fields.Str(), allow_none=True)
class NetworkKillSwitchSchema(Schema):
reason = fields.Str(allow_none=True, validate=validate.Length(max=500))
# ── Networks ──────────────────────────────────────────────────────────────────
@@ -971,6 +975,36 @@ def admin_list_sessions(org_id):
)
@api_v1_bp.route("/organizations/<org_id>/admin/sessions/<session_id>/end", methods=["POST"])
@login_required
@require_admin
@full_access_required
def admin_end_session(org_id, session_id):
"""End a specific activation session (admin only).
Terminates the active session for any user, deauthorizes the device
in ZeroTier, and marks the membership as inactive. The user retains
their approval and can re-authenticate without re-approval.
"""
org, err = _org_check(org_id)
if err:
return err
try:
session = network_access_service.admin_end_session(
session_id=session_id,
admin_user_id=g.current_user.id,
)
return api_response(
data={"session": _session_to_dict(session, include_user=True)},
message="Session ended successfully by admin",
)
except ApprovalNotFoundError as e:
return api_response(success=False, message=str(e), status=404, error_type="NOT_FOUND")
except AppValidationError as e:
return api_response(success=False, message=str(e.message), status=400, error_type=e.error_type)
# ── Kill Switch ───────────────────────────────────────────────────────────────
@@ -1005,6 +1039,30 @@ def trigger_kill_switch(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>/networks/<network_id>/kill-switch", methods=["POST"])
@login_required
@require_admin
@full_access_required
def trigger_network_kill_switch(org_id, network_id):
"""Deactivate all active memberships on a network (admin only)."""
org, err = _org_check(org_id)
if err:
return err
schema = NetworkKillSwitchSchema()
data = schema.load(request.json or {})
count = network_access_service.kill_switch_network(
portal_network_id=network_id,
organization_id=org_id,
admin_user_id=g.current_user.id,
)
return api_response(
data={"affected_count": count},
message="Network kill switch triggered successfully",
)
# ── Admin / ZeroTier Controller ───────────────────────────────────────────────
@api_v1_bp.route("/organizations/<org_id>/admin/memberships", methods=["GET"])
+10
View File
@@ -120,6 +120,16 @@ from gatehouse_app.models.security.mfa_policy_compliance import ( # noqa: F401
MfaPolicyCompliance,
)
# ── ZeroTier ──────────────────────────────────────────────────────────────
from gatehouse_app.models.zerotier import ( # noqa: F401
PortalNetwork,
Device,
NetworkAccessRequest,
ActivationSession,
ZeroTierMembership,
KillSwitchEvent,
)
__all__ = [
# Base
"BaseModel",
@@ -144,4 +144,6 @@ class NetworkAccessRequest(BaseModel):
data = super().to_dict(exclude=exclude)
session = self.active_session
data["active_session"] = session.to_dict() if session else None
data["device_name"] = self.device.display_name if self.device else None
data["device_nickname"] = self.device.device_nickname if self.device else None
return data
@@ -561,6 +561,52 @@ def kill_switch(
return count
def kill_switch_network(
portal_network_id: str,
organization_id: str,
admin_user_id: str,
) -> int:
"""Deactivate all active memberships on a network across all users."""
requests = NetworkAccessRequest.query.filter(
NetworkAccessRequest.portal_network_id == portal_network_id,
NetworkAccessRequest.organization_id == organization_id,
NetworkAccessRequest.active == True,
NetworkAccessRequest.deleted_at.is_(None),
).all()
count = 0
for r in requests:
_end_active_session(r, reason=ActivationEndReason.KILL_SWITCH)
device = Device.query.get(r.device_id)
network = PortalNetwork.query.get(r.portal_network_id)
if device and network:
try:
zt.deauthorize_member(network.zerotier_network_id, device.node_id,
organization_id=r.organization_id)
except Exception as exc:
logger.warning(f"[kill_switch_network] Could not deauthorize {device.node_id}: {exc}")
r.active = False
if r.status == ApprovalState.APPROVED:
r.status = ApprovalState.SUSPENDED
r.save()
count += 1
AuditService.log_action(
action=AuditAction.ZT_NETWORK_KILL_SWITCH,
user_id=admin_user_id,
organization_id=organization_id,
resource_type="portal_network",
resource_id=portal_network_id,
metadata={"affected_count": count},
description=f"Network kill switch activated: {count} requests deactivated on network {portal_network_id}",
success=True,
)
return count
# ── Helpers ────────────────────────────────────────────────────────────────────
@@ -799,6 +845,66 @@ def join_network_for_device(
return request
def admin_end_session(
session_id: str,
admin_user_id: str,
) -> ActivationSession:
"""End a specific activation session (admin only).
Ends the session, deauthorizes the device in ZeroTier, and marks the
associated network access request as inactive. Does NOT change the
request's approval status — the user keeps their approval and can
re-authenticate without needing re-approval.
"""
session = ActivationSession.query.filter(
ActivationSession.id == session_id,
ActivationSession.deleted_at.is_(None),
).first()
if not session:
raise ApprovalNotFoundError(f"Session {session_id} not found.")
if session.ended_at:
raise ValidationError("Session already ended.")
# End the session with ADMIN_ACTION reason
_end_session(session, ActivationEndReason.ADMIN_ACTION)
# Deactivate the associated request
if session.network_access_request_id:
request = NetworkAccessRequest.query.get(session.network_access_request_id)
if request and request.active:
request.active = False
request.save()
# Deauthorize in ZeroTier
device = Device.query.get(request.device_id)
network = PortalNetwork.query.get(request.portal_network_id)
if device and network:
_deauthorize_in_zerotier(
device.node_id,
network.zerotier_network_id,
organization_id=request.organization_id,
)
AuditService.log_action(
action=AuditAction.ZT_SESSION_ENDED,
user_id=admin_user_id,
organization_id=session.organization_id,
resource_type="activation_session",
resource_id=session.id,
metadata={
"target_user_id": session.user_id,
"end_reason": ActivationEndReason.ADMIN_ACTION.value,
"network_access_request_id": session.network_access_request_id,
},
description=f"Admin terminated session for user {session.user_id}",
success=True,
)
return session
# ── Admin membership management ────────────────────────────────────────────────
+2
View File
@@ -204,7 +204,9 @@ class AuditAction(str, Enum):
ZT_MEMBER_DEAUTHORIZED = "zt.member.deauthorized"
ZT_REQUEST_REVOKED = "zt.request.revoked"
ZT_KILL_SWITCH_ACTIVATED = "zt.kill_switch.activated"
ZT_NETWORK_KILL_SWITCH = "zt.network_kill_switch.activated"
ZT_ACTIVATION_EXPIRED = "zt.activation.expired"
ZT_SESSION_ENDED = "zt.session.ended"
ZT_NETWORK_CREATED = "zt.network.created"
ZT_NETWORK_UPDATED = "zt.network.updated"
ZT_NETWORK_DELETED = "zt.network.deleted"