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
+1 -1
View File
@@ -77,7 +77,7 @@ class BaseConfig:
SESSION_REDIS = None # Will be set at app initialization
# Rate Limiting
RATELIMIT_ENABLED = os.getenv("RATELIMIT_ENABLED", "True").lower() == "true"
RATELIMIT_ENABLED = os.getenv("RATELIMIT_ENABLED", "False").lower() == "true"
RATELIMIT_STORAGE_URL = os.getenv("RATELIMIT_STORAGE_URL", "redis://localhost:6379/1")
RATELIMIT_DEFAULT = "100/hour"
+169
View File
@@ -0,0 +1,169 @@
# ZeroTier Kill Switch
## Overview
The kill-switch mechanism provides emergency deactivation of ZeroTier network access at three granularities: a single device membership, all memberships for a user, or all memberships on a network. All kill operations are **reversible** — they set `active=False` and (in most cases) `status=SUSPENDED` but do not delete records, so affected users can re-activate or re-authenticate.
## Three Kill Operations
| Granularity | Endpoint | Admin-only | Behavior |
|---|---|---|---|
| **Device X on network Y** | `POST /orgs/<id>/memberships/<id>/deactivate` | No (owner can self-deactivate) | Sets `active=False`. Status stays APPROVED. |
| **All devices for a user** | `POST /orgs/<id>/kill-switch` | Yes | Sets `active=False`, `status=SUSPENDED` for every active membership in the org (optionally filtered to specific networks). |
| **All devices on a network** | `POST /orgs/<id>/networks/<id>/kill-switch` | Yes | Sets `active=False`, `status=SUSPENDED` for every active membership on the network, across all users. |
## Detailed Endpoint Reference
### 1. Kill a Single Membership (Device + Network)
```
POST /api/v1/organizations/<org_id>/memberships/<membership_id>/deactivate
```
**Auth:** `@login_required`, `@full_access_required`
**Admin override:** Admins can deactivate any membership; non-admins can only deactivate their own.
**Request body:** None
**Response (200):**
```json
{
"success": true,
"data": {
"request": {
"id": "...",
"active": false,
"status": "approved",
...
}
},
"message": "Request deactivated successfully"
}
```
**Behavior:**
- Ends the active `ActivationSession` with reason `manual_revoke`
- De-authorizes the device node in the ZeroTier controller
- Sets `request.active = False` (status **unchanged** — stays `approved`)
- Logs `zt.membership.deactivated` audit event
**Re-activation:** The user can re-activate via `POST /memberships/<id>/activate` or `POST /memberships/activate-all`.
---
### 2. Kill All Devices for a User
```
POST /api/v1/organizations/<org_id>/kill-switch
```
**Auth:** `@login_required`, `@require_admin`, `@full_access_required`
**Request body:**
```json
{
"target_user_id": "uuid-of-user-to-kill",
"scope": "organization",
"network_ids": ["uuid-of-network-1", "uuid-of-network-2"],
"reason": "Security incident — force deactivation"
}
```
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `target_user_id` | string (UUID) | yes | — | The user to deactivate |
| `scope` | string | no | `"organization"` | `"organization"` (all networks) or `"selected_networks"` |
| `network_ids` | array of UUIDs | no | `null` | Required when scope is `selected_networks` |
| `reason` | string | no | `null` | Max 500 chars |
**Response (200):**
```json
{
"success": true,
"data": {
"affected_count": 3
},
"message": "Kill switch triggered successfully"
}
```
**Behavior:**
- Queries all active, non-deleted `NetworkAccessRequest` rows for the target user
- For each: ends session (reason `kill_switch`), de-authorizes in ZT
- Sets `active = False`, **and** sets `status = SUSPENDED` if currently `APPROVED`
- Logs `zt.kill_switch.activated` audit event with `affected_count` and `scope`
**Re-activation:** The user's memberships are in `SUSPENDED` state. An admin must explicitly re-approve (change status back to `APPROVED`) before the user can re-activate.
---
### 3. Kill All Devices on a Network
```
POST /api/v1/organizations/<org_id>/networks/<network_id>/kill-switch
```
**Auth:** `@login_required`, `@require_admin`, `@full_access_required`
**Request body:**
```json
{
"reason": "Network compromised — emergency deactivation"
}
```
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `reason` | string | no | `null` | Max 500 chars |
**Response (200):**
```json
{
"success": true,
"data": {
"affected_count": 12
},
"message": "Network kill switch triggered successfully"
}
```
**Behavior:**
- Queries all active, non-deleted `NetworkAccessRequest` rows for the network, **regardless of user**
- For each: ends session (reason `kill_switch`), de-authorizes in ZT
- Sets `active = False`, and `status = SUSPENDED` if currently `APPROVED`
- Logs `zt.network_kill_switch.activated` audit event with `affected_count`
**Re-activation:** Same as user kill switch — each affected membership is `SUSPENDED` and needs admin re-approval.
## Comparison: Deactivation vs. Deletion
| Operation | active | status | deleted_at | DB row | Reversible? |
|---|---|---|---|---|---|
| `POST /memberships/<id>/deactivate` | `false` | unchanged | `null` | preserved | Yes — re-activate |
| `POST /kill-switch` (user) | `false` | `suspended` | `null` | preserved | Yes — admin re-approve |
| `POST /networks/<id>/kill-switch` | `false` | `suspended` | `null` | preserved | Yes — admin re-approve |
| `DELETE /memberships/<id>` (soft) | `false` | unchanged | set | preserved | Partial — depends on join logic |
| `DELETE /admin/memberships/<id>` | `false` | — | — | **hard-deleted** | No |
## Audit Events
| Event | Trigger |
|---|---|
| `zt.membership.deactivated` | Single membership deactivated (endpoint #1) |
| `zt.kill_switch.activated` | User kill switch triggered (endpoint #2) |
| `zt.network_kill_switch.activated` | Network kill switch triggered (endpoint #3) |
All audit entries include `organization_id`, `user_id` (the actor), `resource_type`, `resource_id`, and `metadata` (affected count, scope, network IDs).
## Key Source Files
| File | Purpose |
|---|---|
| `gatehouse_app/api/v1/zerotier.py` | Route handlers for all three endpoints |
| `gatehouse_app/services/network_access_service.py` | `deactivate_request()`, `kill_switch()`, `kill_switch_network()` |
| `gatehouse_app/services/zerotier_api_service.py` | `deauthorize_member()` — ZT controller call |
| `gatehouse_app/utils/constants.py` | `AuditAction` and `KillSwitchScope` enums |
| `gatehouse_app/models/zerotier/network_access_request.py` | `NetworkAccessRequest` model |
| `gatehouse_app/models/zerotier/activation_session.py` | `ActivationSession` model |
| `gatehouse_app/models/zerotier/kill_switch_event.py` | `KillSwitchEvent` model |
| `tests/integration/test_zerotier.py` | Integration tests in `TestZeroTierMembership` |
+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"
+112
View File
@@ -204,6 +204,118 @@ class TestZeroTierMembership:
# Accept errors when no active memberships to kill
assert exc.status_code in (400, 500)
@patch("gatehouse_app.services.network_access_service._end_active_session")
@patch("gatehouse_app.services.network_access_service.zt.deauthorize_member")
def test_network_kill_switch_positive(
self, mock_deauth, mock_end_session,
integration_client, create_test_user, create_test_org,
create_test_membership, integration_app,
):
"""TEST: ZT-10 — Trigger network kill switch.
WHAT: POST /organizations/<id>/networks/<id>/kill-switch.
WHY: Admin needs to kill all device access on a network.
EXPECTED: 200 OK, all active memberships on the network deactivated.
"""
from gatehouse_app.models.zerotier.network_access_request import NetworkAccessRequest
from gatehouse_app.models.zerotier.portal_network import PortalNetwork
from gatehouse_app.models.zerotier.device import Device
from gatehouse_app.extensions import db as _db
from gatehouse_app.utils.constants import ApprovalState
admin = create_test_user(password="AdminPass123!")
org = create_test_org()
create_test_membership(admin["id"], org["id"], OrganizationRole.ADMIN)
with integration_app.app_context():
network = PortalNetwork(
organization_id=org["id"],
name="Kill Test Net",
zerotier_network_id="zt_kill_test",
request_mode="open",
owner_user_id=admin["id"],
)
_db.session.add(network)
_db.session.flush()
network_id = network.id
device = Device(
user_id=admin["id"],
organization_id=org["id"],
node_id="9999999999",
device_nickname="Kill Test Device",
)
_db.session.add(device)
_db.session.flush()
device_id = device.id
req = NetworkAccessRequest(
organization_id=org["id"],
user_id=admin["id"],
device_id=device_id,
portal_network_id=network_id,
status=ApprovalState.APPROVED,
active=True,
)
_db.session.add(req)
_db.session.flush()
req_id = req.id
_db.session.commit()
integration_client.auth.login(email=admin["email"], password="AdminPass123!")
result = integration_client.post(
f"/organizations/{org['id']}/networks/{network_id}/kill-switch",
data={},
)
assert_success(result, "triggered")
with integration_app.app_context():
updated = NetworkAccessRequest.query.get(req_id)
assert updated.active is False
assert updated.status == ApprovalState.SUSPENDED
def test_network_kill_switch_non_admin_negative(
self, integration_client, create_test_user, create_test_org,
create_test_membership, integration_app,
):
"""TEST: ZT-11 — Non-admin cannot trigger network kill switch."""
from gatehouse_app.models.zerotier.portal_network import PortalNetwork
from gatehouse_app.extensions import db as _db
member = create_test_user(password="Pass1234!")
org = create_test_org()
create_test_membership(member["id"], org["id"], OrganizationRole.MEMBER)
with integration_app.app_context():
network = PortalNetwork(
organization_id=org["id"],
name="Locked Net",
zerotier_network_id="zt_locked",
request_mode="open",
owner_user_id=member["id"],
)
_db.session.add(network)
_db.session.commit()
network_id = network.id
integration_client.auth.login(email=member["email"], password="Pass1234!")
with pytest.raises(ApiError) as exc_info:
integration_client.post(
f"/organizations/{org['id']}/networks/{network_id}/kill-switch",
)
assert exc_info.value.status_code == 403
def test_network_kill_switch_unauth_negative(
self, integration_client, create_test_org,
):
"""TEST: ZT-12 — Unauthenticated user cannot trigger network kill switch."""
org = create_test_org()
with pytest.raises(ApiError) as exc_info:
integration_client.post(
f"/organizations/{org['id']}/networks/does-not-matter/kill-switch",
)
assert exc_info.value.status_code == 401
class TestZeroTierJoinNetwork:
"""Test joining a network with a registered device."""