Updated ZeroTier network membership flow and logic
This commit is contained in:
@@ -0,0 +1,165 @@
|
|||||||
|
# ZeroTier Device Membership
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This document covers the ZeroTier device membership model — how devices are registered, approved, activated, and deactivated on ZeroTier networks. It explains the schema design, the distinction between `active` and `approved`, the user activation flow, and audit coverage.
|
||||||
|
|
||||||
|
## Schema Design
|
||||||
|
|
||||||
|
The core model is `NetworkAccessRequest` (table: `network_access_requests`). It replaces the legacy two-table approach (`UserNetworkApproval` + `DeviceNetworkMembership`) with a single per-device, per-network row.
|
||||||
|
|
||||||
|
### `network_access_requests` table
|
||||||
|
|
||||||
|
| Column | Type | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | UUID | Primary key |
|
||||||
|
| `organization_id` | FK -> organizations | Org scope |
|
||||||
|
| `user_id` | FK -> users | Requesting user |
|
||||||
|
| `device_id` | FK -> devices | Target device |
|
||||||
|
| `portal_network_id` | FK -> portal_networks | Target network |
|
||||||
|
| `granted_by_user_id` | FK -> users (nullable) | Approving manager |
|
||||||
|
| `grant_type` | enum | `requested` or `assigned` |
|
||||||
|
| `status` | enum | `pending`, `approved`, `rejected`, `revoked`, `suspended` |
|
||||||
|
| `active` | boolean | Currently authorized on controller? |
|
||||||
|
| `justification` | text | User's reason for requesting |
|
||||||
|
| `join_seen` | boolean | Controller observed the device join |
|
||||||
|
| `deleted_at` | timestamp (nullable) | Soft delete support |
|
||||||
|
|
||||||
|
**Unique constraint:** `(user_id, device_id, portal_network_id, deleted_at)` — ensures exactly one active record per device per network.
|
||||||
|
|
||||||
|
### Supporting models
|
||||||
|
|
||||||
|
| Model | Table | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `Device` | `devices` | A user-registered ZeroTier node (10-char node ID) |
|
||||||
|
| `PortalNetwork` | `portal_networks` | A managed ZeroTier network (scoped to org) |
|
||||||
|
| `ActivationSession` | `activation_sessions` | Temporary authorization window (TTL-based) |
|
||||||
|
| `ZeroTierMembership` | `zerotier_memberships` | Cache of controller-side state |
|
||||||
|
| `KillSwitchEvent` | `kill_switch_events` | Append-only audit of kill switch actions |
|
||||||
|
|
||||||
|
### Entity relationships
|
||||||
|
|
||||||
|
```
|
||||||
|
Device (1) ──> (N) NetworkAccessRequest (N) <── (1) PortalNetwork
|
||||||
|
│
|
||||||
|
(1 or 0)
|
||||||
|
│
|
||||||
|
ActivationSession
|
||||||
|
│
|
||||||
|
(1 or 0)
|
||||||
|
│
|
||||||
|
ZeroTierMembership
|
||||||
|
```
|
||||||
|
|
||||||
|
## `active` vs `approved` — the key distinction
|
||||||
|
|
||||||
|
These are orthogonal concepts:
|
||||||
|
|
||||||
|
| Concept | Represents | Set by | Persists across sessions? |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **`status = approved`** | Administrative permission to use the network | Manager approval | Yes — once approved, stays approved until revoked |
|
||||||
|
| **`active = True`** | Device is currently authorized on the ZT controller | User activation | No — toggled on/off per session |
|
||||||
|
|
||||||
|
A request can be in any of these states:
|
||||||
|
|
||||||
|
| `status` | `active` | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `approved` | `false` | Manager said yes, but user hasn't activated yet |
|
||||||
|
| `approved` | `true` | Manager said yes, device is actively connected |
|
||||||
|
| `pending` | `false` | Awaiting manager decision |
|
||||||
|
| `rejected` / `revoked` / `suspended` | `false` | Access denied or removed |
|
||||||
|
|
||||||
|
The `active` flag is **not** a persistent grant — it's a run-time operational state tied to an `ActivationSession` with a finite TTL (default 8 hours).
|
||||||
|
|
||||||
|
## User activation flow — "turning on" ZeroTier
|
||||||
|
|
||||||
|
```
|
||||||
|
User API Service ZT Controller
|
||||||
|
│ │ │ │
|
||||||
|
│ POST /orgs/<id>/approvals │ │ │
|
||||||
|
├───────────────────────────>│ request_access() │ │
|
||||||
|
│ ├──> creates NetworkAccessRequest (status=PENDING) │
|
||||||
|
│ ├──> _ensure_zerotier_member() │
|
||||||
|
│ │ └──> provisions member (de-authorized) ────────────────>│
|
||||||
|
│ │ │ │
|
||||||
|
│ <── 201 { status: pending } │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ [Admin approves] │ │ │
|
||||||
|
│ POST /orgs/<id>/approvals │ │ │
|
||||||
|
│ /<id>/approve │ │ │
|
||||||
|
├───────────────────────────>│ approve_request() │ │
|
||||||
|
│ ├──> sets status=APPROVED │ │
|
||||||
|
│ <── 200 { status: approved } │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ POST /orgs/<id>/memberships/<id>/activate │ │
|
||||||
|
│ ─────────────────────────>│ activate_request() │ │
|
||||||
|
│ "turn on ZeroTier" ├──> creates ActivationSession (TTL=8h) │
|
||||||
|
│ ├──> sets request.active=True │ │
|
||||||
|
│ ├──> _authorize_in_zerotier() │ │
|
||||||
|
│ │ └──> authorizes member ───────────────────────────────>│
|
||||||
|
│ │ │ │
|
||||||
|
│ <── 200 { active: true, session: {...} } │ │
|
||||||
|
```
|
||||||
|
|
||||||
|
### Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Action |
|
||||||
|
|---|---|---|
|
||||||
|
| `POST` | `/organizations/<id>/devices` | Register a device (prerequisite) |
|
||||||
|
| `POST` | `/organizations/<id>/approvals` | Request network access |
|
||||||
|
| `POST` | `/organizations/<id>/approvals/<id>/approve` | Admin approves |
|
||||||
|
| `POST` | `/organizations/<id>/memberships/<id>/activate` | **Turn on ZeroTier** |
|
||||||
|
| `POST` | `/organizations/<id>/memberships/activate-all` | Bulk-activate all approved |
|
||||||
|
| `POST` | `/organizations/<id>/memberships/<id>/deactivate` | Turn off ZeroTier |
|
||||||
|
| `POST` | `/organizations/<id>/devices/<id>/join-network/<id>` | Direct join (open networks) |
|
||||||
|
| `POST` | `/organizations/<id>/kill-switch` | Emergency deactivation |
|
||||||
|
|
||||||
|
## Session expiry and audit
|
||||||
|
|
||||||
|
When the `ActivationSession` TTL expires, the reconciliation worker handles it:
|
||||||
|
|
||||||
|
```
|
||||||
|
Reconciliation worker (runs every 2 min)
|
||||||
|
│
|
||||||
|
├── reconcile_expired_activations()
|
||||||
|
│ └── for each expired ActivationSession:
|
||||||
|
│ ├── _deauthorize_in_zerotier() ───> ZT controller de-authorizes member
|
||||||
|
│ ├── sets request.active = False
|
||||||
|
│ └── logs audit event
|
||||||
|
│
|
||||||
|
└── reconcile_all()
|
||||||
|
└── for each network:
|
||||||
|
├── sync ZeroTierMembership cache
|
||||||
|
└── detect/repair drift (portal vs controller state mismatch)
|
||||||
|
```
|
||||||
|
|
||||||
|
Every authorization state change is audited:
|
||||||
|
|
||||||
|
| Event | When |
|
||||||
|
|---|---|
|
||||||
|
| `zt.approval.requested` | User requests access |
|
||||||
|
| `zt.approval.granted` | Manager approves/assigns |
|
||||||
|
| `zt.approval.rejected` | Manager rejects |
|
||||||
|
| `zt.approval.revoked` | Manager revokes |
|
||||||
|
| `zt.membership.activated` | User activates (session created) |
|
||||||
|
| `zt.membership.deactivated` | User deactivates (session ended) |
|
||||||
|
| `zt.member.authorized` | ZT controller authorize call succeeds |
|
||||||
|
| `zt.member.deauthorized` | ZT controller de-authorize call succeeds |
|
||||||
|
| `zt.activation.expired` | Session expired by reconciliation worker |
|
||||||
|
| `zt.kill_switch.activated` | Admin triggers kill switch |
|
||||||
|
|
||||||
|
All audit entries are stored in the `audit_logs` table with `organization_id`, `user_id`, `resource_type`, `resource_id`, `ip_address`, and `extra_data` (JSON) for full traceability.
|
||||||
|
|
||||||
|
## Key source files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `gatehouse_app/models/zerotier/network_access_request.py` | `NetworkAccessRequest` model |
|
||||||
|
| `gatehouse_app/models/zerotier/activation_session.py` | `ActivationSession` model |
|
||||||
|
| `gatehouse_app/models/zerotier/zerotier_membership.py` | `ZeroTierMembership` model |
|
||||||
|
| `gatehouse_app/services/network_access_service.py` | Core business logic |
|
||||||
|
| `gatehouse_app/services/zerotier_reconciliation_service.py` | Reconciliation worker logic |
|
||||||
|
| `gatehouse_app/api/v1/zerotier.py` | API endpoints |
|
||||||
|
| `gatehouse_app/utils/constants.py` | Audit action enums |
|
||||||
|
| `gatehouse_app/jobs/zerotier_reconciliation_job.py` | Scheduled job entry point |
|
||||||
|
| `migrations/versions/merge_approval_membership_tables.py` | Schema migration |
|
||||||
@@ -924,16 +924,25 @@ def admin_list_memberships(org_id):
|
|||||||
@require_admin
|
@require_admin
|
||||||
@full_access_required
|
@full_access_required
|
||||||
def admin_delete_membership(org_id, membership_id):
|
def admin_delete_membership(org_id, membership_id):
|
||||||
"""Hard-delete a membership and remove it from ZeroTier (admin only)."""
|
"""Force-delete a membership and remove it from ZeroTier (admin only).
|
||||||
|
|
||||||
|
Handles the full lifecycle: deactivates if active, removes the member
|
||||||
|
from the ZeroTier controller, and hard-deletes the DB record.
|
||||||
|
"""
|
||||||
org, err = _org_check(org_id)
|
org, err = _org_check(org_id)
|
||||||
if err:
|
if err:
|
||||||
return err
|
return err
|
||||||
|
|
||||||
try:
|
try:
|
||||||
network_access_service.hard_delete_request(membership_id)
|
network_access_service.admin_force_delete_request(
|
||||||
|
membership_id,
|
||||||
|
admin_user_id=g.current_user.id,
|
||||||
|
)
|
||||||
return api_response(message="Request permanently deleted")
|
return api_response(message="Request permanently deleted")
|
||||||
except ApprovalNotFoundError as e:
|
except ApprovalNotFoundError as e:
|
||||||
return api_response(success=False, message=str(e), status=404, error_type=e.error_type)
|
return api_response(success=False, message=str(e), status=404, error_type=e.error_type)
|
||||||
|
except AppValidationError as e:
|
||||||
|
return api_response(success=False, message=str(e.message), status=400, error_type=e.error_type)
|
||||||
|
|
||||||
|
|
||||||
# ── ZeroTier Controller ───────────────────────────────────────────────────────
|
# ── ZeroTier Controller ───────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -760,7 +760,7 @@ def join_network_for_device(
|
|||||||
).first()
|
).first()
|
||||||
|
|
||||||
if existing:
|
if existing:
|
||||||
if existing.status in (ApprovalState.APPROVED, ApprovalState.PENDING):
|
if existing.status == ApprovalState.PENDING or (existing.status == ApprovalState.APPROVED and existing.active):
|
||||||
raise ApprovalAlreadyExistsError("Already have access or pending request.")
|
raise ApprovalAlreadyExistsError("Already have access or pending request.")
|
||||||
# Re-open
|
# Re-open
|
||||||
existing.status = ApprovalState.APPROVED
|
existing.status = ApprovalState.APPROVED
|
||||||
@@ -861,3 +861,62 @@ def hard_delete_request(
|
|||||||
|
|
||||||
db.session.delete(request)
|
db.session.delete(request)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def admin_force_delete_request(
|
||||||
|
request_id: str,
|
||||||
|
admin_user_id: str,
|
||||||
|
) -> None:
|
||||||
|
"""Force-delete a network access request (admin only).
|
||||||
|
|
||||||
|
Handles the full lifecycle: deactivates if active, removes the member
|
||||||
|
from the ZeroTier controller entirely, then hard-deletes the DB record.
|
||||||
|
Does NOT require the request to be soft-deleted first.
|
||||||
|
"""
|
||||||
|
request = NetworkAccessRequest.query.filter(
|
||||||
|
NetworkAccessRequest.id == request_id,
|
||||||
|
).first()
|
||||||
|
if not request:
|
||||||
|
raise ApprovalNotFoundError(f"Request {request_id} not found.")
|
||||||
|
|
||||||
|
# Deactivate if active
|
||||||
|
if request.active:
|
||||||
|
deactivate_request(
|
||||||
|
request_id,
|
||||||
|
reason="manual_revoke",
|
||||||
|
deactivated_by_user_id=admin_user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Remove from ZeroTier controller entirely
|
||||||
|
device = Device.query.get(request.device_id)
|
||||||
|
network = PortalNetwork.query.get(request.portal_network_id)
|
||||||
|
if device and network:
|
||||||
|
try:
|
||||||
|
zt.delete_network_member(
|
||||||
|
network.zerotier_network_id,
|
||||||
|
device.node_id,
|
||||||
|
organization_id=request.organization_id,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
f"[admin_force_delete] Could not remove {device.node_id} "
|
||||||
|
f"from ZT network {network.zerotier_network_id}: {exc}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Hard-delete from DB
|
||||||
|
db.session.delete(request)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
AuditService.log_action(
|
||||||
|
action=AuditAction.ZT_REQUEST_REVOKED,
|
||||||
|
user_id=admin_user_id,
|
||||||
|
organization_id=request.organization_id,
|
||||||
|
resource_type="network_access_request",
|
||||||
|
resource_id=request.id,
|
||||||
|
metadata={
|
||||||
|
"target_user_id": request.user_id,
|
||||||
|
"force_delete": True,
|
||||||
|
},
|
||||||
|
description=f"Network access request force-deleted by admin for user {request.user_id}",
|
||||||
|
success=True,
|
||||||
|
)
|
||||||
|
|||||||
@@ -304,16 +304,15 @@ def delete_network(network_id: str, user_id: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def get_network_members(network_id: str) -> list:
|
def get_network_members(network_id: str) -> list:
|
||||||
"""Return all approved and active NetworkAccessRequests for a network."""
|
"""Return all approved NetworkAccessRequests for a network (active or inactive)."""
|
||||||
from gatehouse_app.models import NetworkAccessRequest
|
from gatehouse_app.models import NetworkAccessRequest
|
||||||
from gatehouse_app.utils.constants import ApprovalState
|
from gatehouse_app.utils.constants import ApprovalState
|
||||||
|
|
||||||
return NetworkAccessRequest.query.filter(
|
return NetworkAccessRequest.query.filter(
|
||||||
NetworkAccessRequest.portal_network_id == network_id,
|
NetworkAccessRequest.portal_network_id == network_id,
|
||||||
NetworkAccessRequest.status == ApprovalState.APPROVED,
|
NetworkAccessRequest.status == ApprovalState.APPROVED,
|
||||||
NetworkAccessRequest.active == True,
|
|
||||||
NetworkAccessRequest.deleted_at.is_(None),
|
NetworkAccessRequest.deleted_at.is_(None),
|
||||||
).all()
|
).order_by(NetworkAccessRequest.created_at.desc()).all()
|
||||||
|
|
||||||
|
|
||||||
def get_network_pending_requests(network_id: str) -> list:
|
def get_network_pending_requests(network_id: str) -> list:
|
||||||
|
|||||||
@@ -203,6 +203,80 @@ class TestZeroTierMembership:
|
|||||||
assert exc.status_code in (400, 500)
|
assert exc.status_code in (400, 500)
|
||||||
|
|
||||||
|
|
||||||
|
class TestZeroTierJoinNetwork:
|
||||||
|
"""Test joining a network with a registered device."""
|
||||||
|
|
||||||
|
@patch("gatehouse_app.services.network_access_service._ensure_zerotier_member")
|
||||||
|
def test_rejoin_after_deactivation_positive(
|
||||||
|
self,
|
||||||
|
mock_ensure_member,
|
||||||
|
integration_client,
|
||||||
|
create_test_user,
|
||||||
|
create_test_org,
|
||||||
|
create_test_membership,
|
||||||
|
integration_app,
|
||||||
|
):
|
||||||
|
"""TEST: ZT-15 — Re-join network after deactivation.
|
||||||
|
|
||||||
|
WHAT: User with deactivated membership (APPROVED + active=False)
|
||||||
|
POSTs to join-network for the same device and network.
|
||||||
|
WHY: Deactivated memberships should not block re-join attempts.
|
||||||
|
EXPECTED: 201 Created (re-opens existing request), not 409 Conflict.
|
||||||
|
"""
|
||||||
|
from gatehouse_app.models.zerotier.device import Device
|
||||||
|
from gatehouse_app.models.zerotier.portal_network import PortalNetwork
|
||||||
|
from gatehouse_app.models.zerotier.network_access_request import NetworkAccessRequest
|
||||||
|
from gatehouse_app.extensions import db as _db
|
||||||
|
from gatehouse_app.utils.constants import ApprovalState, NetworkRequestMode
|
||||||
|
|
||||||
|
user = create_test_user(password="MyPassword123!")
|
||||||
|
org = create_test_org()
|
||||||
|
create_test_membership(user["id"], org["id"], OrganizationRole.MEMBER)
|
||||||
|
|
||||||
|
with integration_app.app_context():
|
||||||
|
device = Device(
|
||||||
|
user_id=user["id"],
|
||||||
|
organization_id=org["id"],
|
||||||
|
node_id="1234567890",
|
||||||
|
device_nickname="Test Device",
|
||||||
|
hostname="test-device",
|
||||||
|
)
|
||||||
|
_db.session.add(device)
|
||||||
|
_db.session.flush()
|
||||||
|
device_id = device.id
|
||||||
|
|
||||||
|
portal_network = PortalNetwork(
|
||||||
|
organization_id=org["id"],
|
||||||
|
name="Test Network",
|
||||||
|
owner_user_id=user["id"],
|
||||||
|
zerotier_network_id="a84ac5c10a6e4c7e",
|
||||||
|
request_mode=NetworkRequestMode.OPEN,
|
||||||
|
)
|
||||||
|
_db.session.add(portal_network)
|
||||||
|
_db.session.flush()
|
||||||
|
portal_network_id = portal_network.id
|
||||||
|
|
||||||
|
deactivated_request = NetworkAccessRequest(
|
||||||
|
organization_id=org["id"],
|
||||||
|
user_id=user["id"],
|
||||||
|
device_id=device_id,
|
||||||
|
portal_network_id=portal_network_id,
|
||||||
|
status=ApprovalState.APPROVED,
|
||||||
|
active=False,
|
||||||
|
)
|
||||||
|
_db.session.add(deactivated_request)
|
||||||
|
_db.session.commit()
|
||||||
|
|
||||||
|
integration_client.auth.login(email=user["email"], password="MyPassword123!")
|
||||||
|
result = integration_client.post(
|
||||||
|
f"/organizations/{org['id']}/devices/{device_id}/join-network/{portal_network_id}",
|
||||||
|
)
|
||||||
|
data = assert_success(result, "joined network successfully")
|
||||||
|
assert result.get("code") == 201
|
||||||
|
assert "membership" in data
|
||||||
|
mock_ensure_member.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
class TestAdminUserDevices:
|
class TestAdminUserDevices:
|
||||||
"""Test admin endpoint to list devices for a specific user."""
|
"""Test admin endpoint to list devices for a specific user."""
|
||||||
|
|
||||||
@@ -343,3 +417,215 @@ class TestAdminUserDevices:
|
|||||||
with pytest.raises(ApiError) as exc_info:
|
with pytest.raises(ApiError) as exc_info:
|
||||||
integration_client.get(f"/organizations/{org['id']}/users/{non_existent_id}/devices")
|
integration_client.get(f"/organizations/{org['id']}/users/{non_existent_id}/devices")
|
||||||
assert exc_info.value.status_code == 404
|
assert exc_info.value.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
class TestAdminForceDeleteMembership:
|
||||||
|
"""Test admin force-delete of network access requests."""
|
||||||
|
|
||||||
|
@patch("gatehouse_app.services.zerotier_api_service.deauthorize_member")
|
||||||
|
@patch("gatehouse_app.services.zerotier_api_service.delete_network_member")
|
||||||
|
def test_force_delete_active_membership_positive(
|
||||||
|
self,
|
||||||
|
mock_delete_member,
|
||||||
|
mock_deauthorize_member,
|
||||||
|
integration_client,
|
||||||
|
create_test_user,
|
||||||
|
create_test_org,
|
||||||
|
create_test_membership,
|
||||||
|
integration_app,
|
||||||
|
):
|
||||||
|
"""TEST: ZT-16 — Admin force-deletes an active membership.
|
||||||
|
|
||||||
|
WHAT: Admin calls DELETE admin/memberships/<id> on an active,
|
||||||
|
non-soft-deleted request. Should deactivate, remove from ZT,
|
||||||
|
and hard-delete the DB record in one step.
|
||||||
|
EXPECTED: 200 OK, request no longer exists in DB.
|
||||||
|
"""
|
||||||
|
from gatehouse_app.models.zerotier.device import Device
|
||||||
|
from gatehouse_app.models.zerotier.portal_network import PortalNetwork
|
||||||
|
from gatehouse_app.models.zerotier.network_access_request import NetworkAccessRequest
|
||||||
|
from gatehouse_app.extensions import db as _db
|
||||||
|
from gatehouse_app.utils.constants import ApprovalState, NetworkRequestMode
|
||||||
|
|
||||||
|
admin = create_test_user(password="AdminPass123!")
|
||||||
|
user = create_test_user(password="UserPass123!")
|
||||||
|
org = create_test_org()
|
||||||
|
create_test_membership(admin["id"], org["id"], OrganizationRole.ADMIN)
|
||||||
|
create_test_membership(user["id"], org["id"], OrganizationRole.MEMBER)
|
||||||
|
|
||||||
|
with integration_app.app_context():
|
||||||
|
device = Device(
|
||||||
|
user_id=user["id"],
|
||||||
|
organization_id=org["id"],
|
||||||
|
node_id="1234567890",
|
||||||
|
device_nickname="Test Device",
|
||||||
|
hostname="test-device",
|
||||||
|
)
|
||||||
|
_db.session.add(device)
|
||||||
|
_db.session.flush()
|
||||||
|
device_id = device.id
|
||||||
|
|
||||||
|
portal_network = PortalNetwork(
|
||||||
|
organization_id=org["id"],
|
||||||
|
name="Test Network",
|
||||||
|
owner_user_id=admin["id"],
|
||||||
|
zerotier_network_id="a84ac5c10a6e4c7e",
|
||||||
|
request_mode=NetworkRequestMode.OPEN,
|
||||||
|
)
|
||||||
|
_db.session.add(portal_network)
|
||||||
|
_db.session.flush()
|
||||||
|
portal_network_id = portal_network.id
|
||||||
|
|
||||||
|
request = NetworkAccessRequest(
|
||||||
|
organization_id=org["id"],
|
||||||
|
user_id=user["id"],
|
||||||
|
device_id=device_id,
|
||||||
|
portal_network_id=portal_network_id,
|
||||||
|
status=ApprovalState.APPROVED,
|
||||||
|
active=True,
|
||||||
|
)
|
||||||
|
_db.session.add(request)
|
||||||
|
_db.session.commit()
|
||||||
|
request_id = request.id
|
||||||
|
|
||||||
|
integration_client.auth.login(email=admin["email"], password="AdminPass123!")
|
||||||
|
result = integration_client.delete(
|
||||||
|
f"/organizations/{org['id']}/admin/memberships/{request_id}",
|
||||||
|
)
|
||||||
|
assert_success(result, "permanently deleted")
|
||||||
|
|
||||||
|
# Verify request is gone from DB
|
||||||
|
with integration_app.app_context():
|
||||||
|
deleted = NetworkAccessRequest.query.get(request_id)
|
||||||
|
assert deleted is None
|
||||||
|
|
||||||
|
mock_deauthorize_member.assert_called_once()
|
||||||
|
mock_delete_member.assert_called_once()
|
||||||
|
|
||||||
|
@patch("gatehouse_app.services.zerotier_api_service.delete_network_member")
|
||||||
|
def test_force_delete_soft_deleted_membership_positive(
|
||||||
|
self,
|
||||||
|
mock_delete_member,
|
||||||
|
integration_client,
|
||||||
|
create_test_user,
|
||||||
|
create_test_org,
|
||||||
|
create_test_membership,
|
||||||
|
integration_app,
|
||||||
|
):
|
||||||
|
"""TEST: ZT-17 — Admin force-deletes an already-soft-deleted membership.
|
||||||
|
|
||||||
|
WHAT: Admin calls DELETE admin/memberships/<id> on a soft-deleted
|
||||||
|
request. Should still work (remove from ZT, hard-delete).
|
||||||
|
EXPECTED: 200 OK.
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from gatehouse_app.models.zerotier.device import Device
|
||||||
|
from gatehouse_app.models.zerotier.portal_network import PortalNetwork
|
||||||
|
from gatehouse_app.models.zerotier.network_access_request import NetworkAccessRequest
|
||||||
|
from gatehouse_app.extensions import db as _db
|
||||||
|
from gatehouse_app.utils.constants import ApprovalState, NetworkRequestMode
|
||||||
|
|
||||||
|
admin = create_test_user(password="AdminPass123!")
|
||||||
|
user = create_test_user(password="UserPass123!")
|
||||||
|
org = create_test_org()
|
||||||
|
create_test_membership(admin["id"], org["id"], OrganizationRole.ADMIN)
|
||||||
|
create_test_membership(user["id"], org["id"], OrganizationRole.MEMBER)
|
||||||
|
|
||||||
|
with integration_app.app_context():
|
||||||
|
device = Device(
|
||||||
|
user_id=user["id"],
|
||||||
|
organization_id=org["id"],
|
||||||
|
node_id="1234567890",
|
||||||
|
device_nickname="Test Device",
|
||||||
|
hostname="test-device",
|
||||||
|
)
|
||||||
|
_db.session.add(device)
|
||||||
|
_db.session.flush()
|
||||||
|
device_id = device.id
|
||||||
|
|
||||||
|
portal_network = PortalNetwork(
|
||||||
|
organization_id=org["id"],
|
||||||
|
name="Test Network",
|
||||||
|
owner_user_id=admin["id"],
|
||||||
|
zerotier_network_id="a84ac5c10a6e4c7e",
|
||||||
|
request_mode=NetworkRequestMode.OPEN,
|
||||||
|
)
|
||||||
|
_db.session.add(portal_network)
|
||||||
|
_db.session.flush()
|
||||||
|
portal_network_id = portal_network.id
|
||||||
|
|
||||||
|
request = NetworkAccessRequest(
|
||||||
|
organization_id=org["id"],
|
||||||
|
user_id=user["id"],
|
||||||
|
device_id=device_id,
|
||||||
|
portal_network_id=portal_network_id,
|
||||||
|
status=ApprovalState.APPROVED,
|
||||||
|
active=False,
|
||||||
|
deleted_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
_db.session.add(request)
|
||||||
|
_db.session.commit()
|
||||||
|
request_id = request.id
|
||||||
|
|
||||||
|
integration_client.auth.login(email=admin["email"], password="AdminPass123!")
|
||||||
|
result = integration_client.delete(
|
||||||
|
f"/organizations/{org['id']}/admin/memberships/{request_id}",
|
||||||
|
)
|
||||||
|
assert_success(result, "permanently deleted")
|
||||||
|
|
||||||
|
# Verify request is gone from DB
|
||||||
|
with integration_app.app_context():
|
||||||
|
deleted = NetworkAccessRequest.query.get(request_id)
|
||||||
|
assert deleted is None
|
||||||
|
|
||||||
|
mock_delete_member.assert_called_once()
|
||||||
|
|
||||||
|
def test_force_delete_non_existent_membership_negative(
|
||||||
|
self,
|
||||||
|
integration_client,
|
||||||
|
create_test_user,
|
||||||
|
create_test_org,
|
||||||
|
create_test_membership,
|
||||||
|
):
|
||||||
|
"""TEST: ZT-18 — Admin force-deletes a non-existent membership.
|
||||||
|
|
||||||
|
WHAT: Admin calls DELETE admin/memberships/<non_existent_id>.
|
||||||
|
EXPECTED: 404 Not Found.
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
admin = create_test_user(password="AdminPass123!")
|
||||||
|
org = create_test_org()
|
||||||
|
create_test_membership(admin["id"], org["id"], OrganizationRole.ADMIN)
|
||||||
|
|
||||||
|
integration_client.auth.login(email=admin["email"], password="AdminPass123!")
|
||||||
|
with pytest.raises(ApiError) as exc_info:
|
||||||
|
integration_client.delete(
|
||||||
|
f"/organizations/{org['id']}/admin/memberships/{uuid.uuid4()}",
|
||||||
|
)
|
||||||
|
assert exc_info.value.status_code == 404
|
||||||
|
|
||||||
|
def test_force_delete_non_admin_negative(
|
||||||
|
self,
|
||||||
|
integration_client,
|
||||||
|
create_test_user,
|
||||||
|
create_test_org,
|
||||||
|
create_test_membership,
|
||||||
|
):
|
||||||
|
"""TEST: ZT-19 — Non-admin cannot force-delete a membership.
|
||||||
|
|
||||||
|
WHAT: A MEMBER calls DELETE admin/memberships/<id>.
|
||||||
|
EXPECTED: 403 Forbidden.
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
member = create_test_user(password="MemberPass123!")
|
||||||
|
org = create_test_org()
|
||||||
|
create_test_membership(member["id"], org["id"], OrganizationRole.MEMBER)
|
||||||
|
|
||||||
|
integration_client.auth.login(email=member["email"], password="MemberPass123!")
|
||||||
|
with pytest.raises(ApiError) as exc_info:
|
||||||
|
integration_client.delete(
|
||||||
|
f"/organizations/{org['id']}/admin/memberships/{uuid.uuid4()}",
|
||||||
|
)
|
||||||
|
assert exc_info.value.status_code == 403
|
||||||
|
|||||||
Reference in New Issue
Block a user