feat: add network-level kill switch endpoint
This commit is contained in:
@@ -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"])
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user