feat: add network-level kill switch endpoint
This commit is contained in:
+1
-1
@@ -77,7 +77,7 @@ class BaseConfig:
|
|||||||
SESSION_REDIS = None # Will be set at app initialization
|
SESSION_REDIS = None # Will be set at app initialization
|
||||||
|
|
||||||
# Rate Limiting
|
# 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_STORAGE_URL = os.getenv("RATELIMIT_STORAGE_URL", "redis://localhost:6379/1")
|
||||||
RATELIMIT_DEFAULT = "100/hour"
|
RATELIMIT_DEFAULT = "100/hour"
|
||||||
|
|
||||||
|
|||||||
@@ -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` |
|
||||||
@@ -117,6 +117,10 @@ class KillSwitchSchema(Schema):
|
|||||||
network_ids = fields.List(fields.Str(), allow_none=True)
|
network_ids = fields.List(fields.Str(), allow_none=True)
|
||||||
|
|
||||||
|
|
||||||
|
class NetworkKillSwitchSchema(Schema):
|
||||||
|
reason = fields.Str(allow_none=True, validate=validate.Length(max=500))
|
||||||
|
|
||||||
|
|
||||||
# ── Networks ──────────────────────────────────────────────────────────────────
|
# ── 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 ───────────────────────────────────────────────────────────────
|
# ── 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)
|
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 ───────────────────────────────────────────────
|
# ── Admin / ZeroTier Controller ───────────────────────────────────────────────
|
||||||
|
|
||||||
@api_v1_bp.route("/organizations/<org_id>/admin/memberships", methods=["GET"])
|
@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,
|
MfaPolicyCompliance,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ── ZeroTier ──────────────────────────────────────────────────────────────
|
||||||
|
from gatehouse_app.models.zerotier import ( # noqa: F401
|
||||||
|
PortalNetwork,
|
||||||
|
Device,
|
||||||
|
NetworkAccessRequest,
|
||||||
|
ActivationSession,
|
||||||
|
ZeroTierMembership,
|
||||||
|
KillSwitchEvent,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# Base
|
# Base
|
||||||
"BaseModel",
|
"BaseModel",
|
||||||
|
|||||||
@@ -144,4 +144,6 @@ class NetworkAccessRequest(BaseModel):
|
|||||||
data = super().to_dict(exclude=exclude)
|
data = super().to_dict(exclude=exclude)
|
||||||
session = self.active_session
|
session = self.active_session
|
||||||
data["active_session"] = session.to_dict() if session else None
|
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
|
return data
|
||||||
|
|||||||
@@ -561,6 +561,52 @@ def kill_switch(
|
|||||||
return count
|
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 ────────────────────────────────────────────────────────────────────
|
# ── Helpers ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -799,6 +845,66 @@ def join_network_for_device(
|
|||||||
return request
|
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 ────────────────────────────────────────────────
|
# ── Admin membership management ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -204,7 +204,9 @@ class AuditAction(str, Enum):
|
|||||||
ZT_MEMBER_DEAUTHORIZED = "zt.member.deauthorized"
|
ZT_MEMBER_DEAUTHORIZED = "zt.member.deauthorized"
|
||||||
ZT_REQUEST_REVOKED = "zt.request.revoked"
|
ZT_REQUEST_REVOKED = "zt.request.revoked"
|
||||||
ZT_KILL_SWITCH_ACTIVATED = "zt.kill_switch.activated"
|
ZT_KILL_SWITCH_ACTIVATED = "zt.kill_switch.activated"
|
||||||
|
ZT_NETWORK_KILL_SWITCH = "zt.network_kill_switch.activated"
|
||||||
ZT_ACTIVATION_EXPIRED = "zt.activation.expired"
|
ZT_ACTIVATION_EXPIRED = "zt.activation.expired"
|
||||||
|
ZT_SESSION_ENDED = "zt.session.ended"
|
||||||
ZT_NETWORK_CREATED = "zt.network.created"
|
ZT_NETWORK_CREATED = "zt.network.created"
|
||||||
ZT_NETWORK_UPDATED = "zt.network.updated"
|
ZT_NETWORK_UPDATED = "zt.network.updated"
|
||||||
ZT_NETWORK_DELETED = "zt.network.deleted"
|
ZT_NETWORK_DELETED = "zt.network.deleted"
|
||||||
|
|||||||
@@ -204,6 +204,118 @@ class TestZeroTierMembership:
|
|||||||
# Accept errors when no active memberships to kill
|
# Accept errors when no active memberships to kill
|
||||||
assert exc.status_code in (400, 500)
|
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:
|
class TestZeroTierJoinNetwork:
|
||||||
"""Test joining a network with a registered device."""
|
"""Test joining a network with a registered device."""
|
||||||
|
|||||||
Reference in New Issue
Block a user