feat: hide invite-only networks from non-admin users in listing
This commit is contained in:
@@ -0,0 +1,139 @@
|
|||||||
|
# ZeroTier Network Lifecycle
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This document covers the full lifecycle of ZeroTier networks in Gatehouse — how networks are created, who can see them, how members request access, and how devices are activated and deactivated.
|
||||||
|
|
||||||
|
## Organization Membership Roles
|
||||||
|
|
||||||
|
Every user belongs to an organization via an `OrganizationMember` record. Roles determine what a user can see and do:
|
||||||
|
|
||||||
|
| Role | Can list networks? | Can see invite-only networks? | Can create/update/delete networks? | Can approve access requests? |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `owner` | Yes | Yes | Yes | Yes |
|
||||||
|
| `admin` | Yes | Yes | Yes | Yes |
|
||||||
|
| `member` | Yes | **No** | No | No |
|
||||||
|
| `guest` | Yes | **No** | No | No |
|
||||||
|
|
||||||
|
Role checks happen via the `OrganizationMember.is_admin()` method, which returns `True` for `owner` and `admin`.
|
||||||
|
|
||||||
|
## Network Request Modes
|
||||||
|
|
||||||
|
Every `PortalNetwork` has a `request_mode` field that controls how users gain access:
|
||||||
|
|
||||||
|
| Mode | Value | Behavior |
|
||||||
|
|---|---|---|
|
||||||
|
| `open` | `"open"` | Any org member can join directly without approval |
|
||||||
|
| `approval_required` | `"approval_required"` | User requests access; a manager must approve |
|
||||||
|
| `invite_only` | `"invite_only"` | Only managers can assign access; invisible to non-admins |
|
||||||
|
|
||||||
|
## Network Listing Visibility
|
||||||
|
|
||||||
|
`GET /organizations/{org_id}/networks`
|
||||||
|
|
||||||
|
The listing endpoint applies two visibility filters:
|
||||||
|
|
||||||
|
1. **Soft-delete filter** — networks with a non-null `deleted_at` are always excluded.
|
||||||
|
2. **Active filter** — by default, only networks where `is_active = True` are returned. Pass `?include_inactive=true` to include disabled networks.
|
||||||
|
3. **Invite-only filter** — networks with `request_mode = "invite_only"` are hidden from non-admin users (`member` and `guest` roles). Admins and owners see all networks.
|
||||||
|
|
||||||
|
### Filtering logic
|
||||||
|
|
||||||
|
The filtering happens in `portal_network_service.list_networks()`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Non-admin users cannot see invite-only networks
|
||||||
|
if user_id is not None:
|
||||||
|
membership = OrganizationMember.query.filter(...).first()
|
||||||
|
is_admin = membership.is_admin() if membership else False
|
||||||
|
if not is_admin:
|
||||||
|
q = q.filter(PortalNetwork.request_mode != NetworkRequestMode.INVITE_ONLY)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Network CRUD
|
||||||
|
|
||||||
|
| Action | Endpoint | Required Role |
|
||||||
|
|---|---|---|
|
||||||
|
| List networks | `GET /organizations/{id}/networks` | Any org member (visibility restricted as above) |
|
||||||
|
| Create network | `POST /organizations/{id}/networks` | `admin` or `owner` |
|
||||||
|
| Update network | `PUT /organizations/{id}/networks/{id}` | `admin` or `owner` |
|
||||||
|
| Delete network | `DELETE /organizations/{id}/networks/{id}` | `admin` or `owner` |
|
||||||
|
|
||||||
|
## Device Registration
|
||||||
|
|
||||||
|
Before a user can access a network, they must register a device:
|
||||||
|
|
||||||
|
`POST /organizations/{org_id}/devices`
|
||||||
|
|
||||||
|
A `Device` record ties a ZeroTier node (10-char `node_id`) to a user within an org.
|
||||||
|
|
||||||
|
| Field | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `node_id` | ZeroTier 10-char node identifier |
|
||||||
|
| `device_nickname` | Human-friendly label |
|
||||||
|
| `hostname` | Optional hostname for identification |
|
||||||
|
|
||||||
|
## Network Access Request Lifecycle
|
||||||
|
|
||||||
|
The core model is `NetworkAccessRequest` (table: `network_access_requests`). Each row represents one user + one device + one network. See [zerotier-device-membership.md](zerotier-device-membership.md) for the full schema.
|
||||||
|
|
||||||
|
### Flow by request mode
|
||||||
|
|
||||||
|
**Open networks** — user calls `join_network_for_device()` directly:
|
||||||
|
1. Creates `NetworkAccessRequest` with `status=APPROVED`, `active=False`
|
||||||
|
2. Returns the request
|
||||||
|
|
||||||
|
**Approval-required networks** — user calls `request_access()`:
|
||||||
|
1. Creates `NetworkAccessRequest` with `status=PENDING`
|
||||||
|
2. Admin calls `approve_request()` → sets `status=APPROVED`
|
||||||
|
3. User calls `activate_membership()` → sets `active=True`, creates `ActivationSession`
|
||||||
|
|
||||||
|
**Invite-only networks** — only an admin can call `assign_access()`:
|
||||||
|
1. Admin creates `NetworkAccessRequest` with `status=APPROVED`, `grant_type=ASSIGNED`
|
||||||
|
2. User calls `activate_membership()` → sets `active=True`, creates `ActivationSession`
|
||||||
|
|
||||||
|
### The `active` flag
|
||||||
|
|
||||||
|
| `status` | `active` | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `approved` | `false` | Has permission but not currently connected |
|
||||||
|
| `approved` | `true` | Has permission and device is authorized on the controller |
|
||||||
|
| `pending` | `false` | Awaiting approval |
|
||||||
|
| `rejected` / `revoked` / `suspended` | `false` | Access denied or removed |
|
||||||
|
|
||||||
|
## Activation and Deactivation
|
||||||
|
|
||||||
|
Activation creates an `ActivationSession` with a configurable TTL (default 8 hours). The session is tied to the `active=True` state.
|
||||||
|
|
||||||
|
- `activate_membership()` — sets `active=True`, creates session, authorizes on ZeroTier controller
|
||||||
|
- `deactivate_membership()` — sets `active=False`, ends session, de-authorizes on controller
|
||||||
|
- Activation sessions expire automatically via the reconciliation worker, which sets `active=False`
|
||||||
|
|
||||||
|
### Kill switch
|
||||||
|
|
||||||
|
Admins can trigger a kill switch to deactivate all active memberships on an organization or network:
|
||||||
|
|
||||||
|
- `POST /organizations/{id}/kill-switch` — deactivates all memberships in the org
|
||||||
|
- `POST /organizations/{id}/networks/{id}/kill-switch` — deactivates all memberships on a specific network
|
||||||
|
|
||||||
|
## Reconciliation Worker
|
||||||
|
|
||||||
|
A scheduled job (runs every 2 minutes) performs:
|
||||||
|
|
||||||
|
1. **Expired activation cleanup** — finds expired `ActivationSession` records, de-authorizes in ZeroTier, sets `active=False`
|
||||||
|
2. **Drift detection** — compares portal state against ZeroTier controller state, repairs mismatches
|
||||||
|
|
||||||
|
## Key Source Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `gatehouse_app/models/zerotier/portal_network.py` | `PortalNetwork` model (network definition + request_mode) |
|
||||||
|
| `gatehouse_app/models/zerotier/network_access_request.py` | `NetworkAccessRequest` model (per-device membership) |
|
||||||
|
| `gatehouse_app/models/zerotier/activation_session.py` | `ActivationSession` model (TTL-based sessions) |
|
||||||
|
| `gatehouse_app/models/zerotier/device.py` | `Device` model |
|
||||||
|
| `gatehouse_app/models/organization/organization_member.py` | `OrganizationMember` model (roles) |
|
||||||
|
| `gatehouse_app/services/portal_network_service.py` | Network CRUD + listing logic |
|
||||||
|
| `gatehouse_app/services/network_access_service.py` | Access request + activation logic |
|
||||||
|
| `gatehouse_app/services/zerotier_reconciliation_service.py` | Expired session + drift reconciliation |
|
||||||
|
| `gatehouse_app/api/v1/zerotier.py` | All ZeroTier API endpoints |
|
||||||
|
| `gatehouse_app/utils/constants.py` | Enums (`OrganizationRole`, `NetworkRequestMode`, etc.) |
|
||||||
@@ -134,7 +134,11 @@ def list_networks(org_id):
|
|||||||
return err
|
return err
|
||||||
|
|
||||||
include_inactive = request.args.get("include_inactive", "false").lower() == "true"
|
include_inactive = request.args.get("include_inactive", "false").lower() == "true"
|
||||||
networks = portal_network_service.list_networks(org_id, include_inactive=include_inactive)
|
networks = portal_network_service.list_networks(
|
||||||
|
org_id,
|
||||||
|
include_inactive=include_inactive,
|
||||||
|
user_id=g.current_user.id,
|
||||||
|
)
|
||||||
|
|
||||||
return api_response(
|
return api_response(
|
||||||
data={"networks": [n.to_dict() for n in networks], "count": len(networks)},
|
data={"networks": [n.to_dict() for n in networks], "count": len(networks)},
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import re
|
|||||||
from gatehouse_app.extensions import db
|
from gatehouse_app.extensions import db
|
||||||
from gatehouse_app.models import PortalNetwork
|
from gatehouse_app.models import PortalNetwork
|
||||||
from gatehouse_app.models.organization import Organization
|
from gatehouse_app.models.organization import Organization
|
||||||
|
from gatehouse_app.models.organization.organization_member import OrganizationMember
|
||||||
from gatehouse_app.models.user import User
|
from gatehouse_app.models.user import User
|
||||||
from gatehouse_app.services.audit_service import AuditService
|
from gatehouse_app.services.audit_service import AuditService
|
||||||
from gatehouse_app.services import zerotier_api_service as zt
|
from gatehouse_app.services import zerotier_api_service as zt
|
||||||
@@ -178,14 +179,29 @@ def create_network(
|
|||||||
def list_networks(
|
def list_networks(
|
||||||
organization_id: str,
|
organization_id: str,
|
||||||
include_inactive: bool = False,
|
include_inactive: bool = False,
|
||||||
|
user_id: str | None = None,
|
||||||
) -> list[PortalNetwork]:
|
) -> list[PortalNetwork]:
|
||||||
"""List portal networks for an organization."""
|
"""List portal networks for an organization.
|
||||||
|
|
||||||
|
Invite-only networks are hidden from non-admin users.
|
||||||
|
"""
|
||||||
q = PortalNetwork.query.filter(
|
q = PortalNetwork.query.filter(
|
||||||
PortalNetwork.organization_id == organization_id,
|
PortalNetwork.organization_id == organization_id,
|
||||||
PortalNetwork.deleted_at.is_(None),
|
PortalNetwork.deleted_at.is_(None),
|
||||||
)
|
)
|
||||||
if not include_inactive:
|
if not include_inactive:
|
||||||
q = q.filter(PortalNetwork.is_active.is_(True))
|
q = q.filter(PortalNetwork.is_active.is_(True))
|
||||||
|
|
||||||
|
if user_id is not None:
|
||||||
|
membership = OrganizationMember.query.filter(
|
||||||
|
OrganizationMember.organization_id == organization_id,
|
||||||
|
OrganizationMember.user_id == user_id,
|
||||||
|
OrganizationMember.deleted_at.is_(None),
|
||||||
|
).first()
|
||||||
|
is_admin = membership.is_admin() if membership else False
|
||||||
|
if not is_admin:
|
||||||
|
q = q.filter(PortalNetwork.request_mode != NetworkRequestMode.INVITE_ONLY)
|
||||||
|
|
||||||
return q.all()
|
return q.all()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,85 @@ class TestZeroTierNetworkCRUD:
|
|||||||
result = integration_client.get(f"/organizations/{org['id']}/networks")
|
result = integration_client.get(f"/organizations/{org['id']}/networks")
|
||||||
assert_success(result)
|
assert_success(result)
|
||||||
|
|
||||||
|
def test_list_networks_member_hides_invite_only(
|
||||||
|
self, integration_client, create_test_user, create_test_org,
|
||||||
|
create_test_membership, integration_app,
|
||||||
|
):
|
||||||
|
"""TEST: ZT-02a — Member cannot see invite-only networks.
|
||||||
|
|
||||||
|
WHAT: GET /organizations/<id>/networks as a MEMBER.
|
||||||
|
WHY: Invite-only networks must be hidden from non-admin users.
|
||||||
|
EXPECTED: 200 OK, invite-only network excluded from results.
|
||||||
|
"""
|
||||||
|
from gatehouse_app.models.zerotier.portal_network import PortalNetwork
|
||||||
|
from gatehouse_app.extensions import db as _db
|
||||||
|
|
||||||
|
member = create_test_user(password="MemberPass123!")
|
||||||
|
admin = create_test_user(password="AdminPass123!")
|
||||||
|
org = create_test_org()
|
||||||
|
create_test_membership(member["id"], org["id"], OrganizationRole.MEMBER)
|
||||||
|
create_test_membership(admin["id"], org["id"], OrganizationRole.ADMIN)
|
||||||
|
|
||||||
|
with integration_app.app_context():
|
||||||
|
open_net = PortalNetwork(
|
||||||
|
organization_id=org["id"],
|
||||||
|
name="Open Network",
|
||||||
|
zerotier_network_id="aaaa000000000001",
|
||||||
|
request_mode="open",
|
||||||
|
owner_user_id=admin["id"],
|
||||||
|
)
|
||||||
|
_db.session.add(open_net)
|
||||||
|
invite_net = PortalNetwork(
|
||||||
|
organization_id=org["id"],
|
||||||
|
name="Invite Only Network",
|
||||||
|
zerotier_network_id="aaaa000000000002",
|
||||||
|
request_mode="invite_only",
|
||||||
|
owner_user_id=admin["id"],
|
||||||
|
)
|
||||||
|
_db.session.add(invite_net)
|
||||||
|
_db.session.commit()
|
||||||
|
|
||||||
|
integration_client.auth.login(email=member["email"], password="MemberPass123!")
|
||||||
|
result = integration_client.get(f"/organizations/{org['id']}/networks")
|
||||||
|
data = assert_success(result)
|
||||||
|
network_names = [n["name"] for n in data["networks"]]
|
||||||
|
assert "Open Network" in network_names
|
||||||
|
assert "Invite Only Network" not in network_names
|
||||||
|
|
||||||
|
def test_list_networks_admin_sees_invite_only(
|
||||||
|
self, integration_client, create_test_user, create_test_org,
|
||||||
|
create_test_membership, integration_app,
|
||||||
|
):
|
||||||
|
"""TEST: ZT-02b — Admin can see invite-only networks.
|
||||||
|
|
||||||
|
WHAT: GET /organizations/<id>/networks as an ADMIN.
|
||||||
|
WHY: Admins need visibility into all networks.
|
||||||
|
EXPECTED: 200 OK, invite-only network included in results.
|
||||||
|
"""
|
||||||
|
from gatehouse_app.models.zerotier.portal_network import PortalNetwork
|
||||||
|
from gatehouse_app.extensions import db as _db
|
||||||
|
|
||||||
|
admin = create_test_user(password="AdminPass123!")
|
||||||
|
org = create_test_org()
|
||||||
|
create_test_membership(admin["id"], org["id"], OrganizationRole.ADMIN)
|
||||||
|
|
||||||
|
with integration_app.app_context():
|
||||||
|
invite_net = PortalNetwork(
|
||||||
|
organization_id=org["id"],
|
||||||
|
name="Hidden Network",
|
||||||
|
zerotier_network_id="bbbb000000000001",
|
||||||
|
request_mode="invite_only",
|
||||||
|
owner_user_id=admin["id"],
|
||||||
|
)
|
||||||
|
_db.session.add(invite_net)
|
||||||
|
_db.session.commit()
|
||||||
|
|
||||||
|
integration_client.auth.login(email=admin["email"], password="AdminPass123!")
|
||||||
|
result = integration_client.get(f"/organizations/{org['id']}/networks")
|
||||||
|
data = assert_success(result)
|
||||||
|
network_names = [n["name"] for n in data["networks"]]
|
||||||
|
assert "Hidden Network" in network_names
|
||||||
|
|
||||||
def test_create_network_non_admin_negative(self, integration_client, create_test_user, create_test_org, create_test_membership):
|
def test_create_network_non_admin_negative(self, integration_client, create_test_user, create_test_org, create_test_membership):
|
||||||
"""TEST: ZT-03 — Reject network creation as member.
|
"""TEST: ZT-03 — Reject network creation as member.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user