From 55f24ea9e5dd2f7d5a5af2d7d6937a05584a9ba6 Mon Sep 17 00:00:00 2001 From: cory Date: Sat, 30 May 2026 06:40:49 +0000 Subject: [PATCH] feat: hide invite-only networks from non-admin users in listing --- docs/zerotier-network-lifecycle.md | 139 ++++++++++++++++++ gatehouse_app/api/v1/zerotier.py | 6 +- .../services/portal_network_service.py | 18 ++- tests/integration/test_zerotier.py | 79 ++++++++++ 4 files changed, 240 insertions(+), 2 deletions(-) create mode 100644 docs/zerotier-network-lifecycle.md diff --git a/docs/zerotier-network-lifecycle.md b/docs/zerotier-network-lifecycle.md new file mode 100644 index 0000000..91847d5 --- /dev/null +++ b/docs/zerotier-network-lifecycle.md @@ -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.) | diff --git a/gatehouse_app/api/v1/zerotier.py b/gatehouse_app/api/v1/zerotier.py index ac84e0c..ebe65e6 100644 --- a/gatehouse_app/api/v1/zerotier.py +++ b/gatehouse_app/api/v1/zerotier.py @@ -134,7 +134,11 @@ def list_networks(org_id): return err 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( data={"networks": [n.to_dict() for n in networks], "count": len(networks)}, diff --git a/gatehouse_app/services/portal_network_service.py b/gatehouse_app/services/portal_network_service.py index db283a3..71cee69 100644 --- a/gatehouse_app/services/portal_network_service.py +++ b/gatehouse_app/services/portal_network_service.py @@ -6,6 +6,7 @@ import re from gatehouse_app.extensions import db from gatehouse_app.models import PortalNetwork 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.services.audit_service import AuditService from gatehouse_app.services import zerotier_api_service as zt @@ -178,14 +179,29 @@ def create_network( def list_networks( organization_id: str, include_inactive: bool = False, + user_id: str | None = None, ) -> 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( PortalNetwork.organization_id == organization_id, PortalNetwork.deleted_at.is_(None), ) if not include_inactive: 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() diff --git a/tests/integration/test_zerotier.py b/tests/integration/test_zerotier.py index 93f012b..279090c 100644 --- a/tests/integration/test_zerotier.py +++ b/tests/integration/test_zerotier.py @@ -68,6 +68,85 @@ class TestZeroTierNetworkCRUD: result = integration_client.get(f"/organizations/{org['id']}/networks") 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//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//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): """TEST: ZT-03 — Reject network creation as member.