feat: allow admins to bypass approval flow when joining networks

This commit is contained in:
Ubuntu
2026-05-07 20:04:08 +00:00
parent 32d517ea08
commit d100fdff3b
34 changed files with 2523 additions and 1637 deletions
+5 -5
View File
@@ -167,10 +167,10 @@ def remove_device(device_id: str, user_id: str) -> None:
raise DeviceNotFoundError("Device not found.")
# Soft-delete all memberships (deactivates active ones first)
for membership in device.memberships:
if membership.deleted_at is None:
from gatehouse_app.services.network_access_service import revoke_membership_soft
revoke_membership_soft(membership.id, revoked_by_user_id=user_id)
for request in device.network_access_requests:
if request.deleted_at is None:
from gatehouse_app.services.network_access_service import revoke_request_soft
revoke_request_soft(request.id, revoker_user_id=user_id)
device.delete(soft=True)
@@ -180,7 +180,7 @@ def remove_device(device_id: str, user_id: str) -> None:
organization_id=device.organization_id,
resource_type="device",
resource_id=device.id,
metadata={"node_id": device.node_id, "memberships_removed": len([m for m in device.memberships if m.deleted_at is None])},
metadata={"node_id": device.node_id, "memberships_removed": len([m for m in device.network_access_requests if m.deleted_at is None])},
description=f"Device {device.node_id} removed",
success=True,
)
File diff suppressed because it is too large Load Diff
@@ -262,47 +262,33 @@ def update_network(
def delete_network(network_id: str, user_id: str) -> None:
"""Soft-delete a portal network and deactivate/clean up all related records."""
from datetime import datetime, timezone
from gatehouse_app.models import UserNetworkApproval
from gatehouse_app.extensions import db
network = get_network(network_id)
# Deauthorize all active memberships in ZeroTier
for membership in network.memberships:
if membership.deleted_at is None and membership.state.value == "active_authorized":
from gatehouse_app.services.network_access_service import deactivate_membership
deactivate_membership(membership.id, reason="network_deleted")
for request in network.access_requests:
if request.deleted_at is None and request.active:
from gatehouse_app.services.network_access_service import deactivate_request
deactivate_request(request.id, reason="network_deleted")
network.delete(soft=True)
# Cascade soft-delete all active approvals and memberships for this network.
# Cascade soft-delete all active access requests for this network.
now = datetime.now(timezone.utc)
db.session.execute(
db.text(
"UPDATE user_network_approvals AS a "
"UPDATE network_access_requests AS a "
"SET deleted_at = :now + (s.rn * interval '1 microsecond') "
"FROM ("
" SELECT id, row_number() OVER () AS rn "
" FROM user_network_approvals "
" FROM network_access_requests "
" WHERE portal_network_id = :network_id AND deleted_at IS NULL"
") s "
"WHERE a.id = s.id"
),
{"now": now, "network_id": network_id},
)
db.session.execute(
db.text(
"UPDATE device_network_memberships AS m "
"SET deleted_at = :now + (s.rn * interval '1 microsecond') "
"FROM ("
" SELECT id, row_number() OVER () AS rn "
" FROM device_network_memberships "
" WHERE portal_network_id = :network_id AND deleted_at IS NULL"
") s "
"WHERE m.id = s.id"
),
{"now": now, "network_id": network_id},
)
db.session.commit()
AuditService.log_action(
@@ -318,22 +304,25 @@ def delete_network(network_id: str, user_id: str) -> None:
def get_network_members(network_id: str) -> list:
"""Return all DeviceNetworkMemberships for a network with user and device info."""
from gatehouse_app.models import DeviceNetworkMembership
"""Return all approved and active NetworkAccessRequests for a network."""
from gatehouse_app.models import NetworkAccessRequest
from gatehouse_app.utils.constants import ApprovalState
return DeviceNetworkMembership.query.filter(
DeviceNetworkMembership.portal_network_id == network_id,
DeviceNetworkMembership.deleted_at.is_(None),
return NetworkAccessRequest.query.filter(
NetworkAccessRequest.portal_network_id == network_id,
NetworkAccessRequest.status == ApprovalState.APPROVED,
NetworkAccessRequest.active == True,
NetworkAccessRequest.deleted_at.is_(None),
).all()
def get_network_pending_requests(network_id: str) -> list:
"""Return pending UserNetworkApprovals for a network."""
from gatehouse_app.models import UserNetworkApproval
"""Return pending NetworkAccessRequests for a network."""
from gatehouse_app.models import NetworkAccessRequest
from gatehouse_app.utils.constants import ApprovalState
return UserNetworkApproval.query.filter(
UserNetworkApproval.portal_network_id == network_id,
UserNetworkApproval.state == ApprovalState.PENDING,
UserNetworkApproval.deleted_at.is_(None),
return NetworkAccessRequest.query.filter(
NetworkAccessRequest.portal_network_id == network_id,
NetworkAccessRequest.status == ApprovalState.PENDING,
NetworkAccessRequest.deleted_at.is_(None),
).all()
@@ -7,16 +7,14 @@ from datetime import datetime, timezone
from gatehouse_app.extensions import db
from gatehouse_app.models import (
Device,
DeviceNetworkMembership,
NetworkAccessRequest,
ActivationSession,
ZeroTierMembership,
PortalNetwork,
UserNetworkApproval,
)
from gatehouse_app.services import zerotier_api_service as zt
from gatehouse_app.utils.constants import (
ActivationEndReason,
MembershipState,
ApprovalState,
)
@@ -45,7 +43,7 @@ def reconcile_expired_activations() -> int:
except Exception as exc:
logger.error(
f"[Reconciliation] Failed to expire session {session.id} "
f"(user={session.user_id} membership={session.device_network_membership_id}): {exc}",
f"(user={session.user_id} request={session.network_access_request_id}): {exc}",
exc_info=True,
)
@@ -104,9 +102,9 @@ def reconcile_network(portal_network_id: str) -> dict:
# Get our portal memberships for this network
our_memberships = {
m.device.node_id: m
for m in DeviceNetworkMembership.query.filter(
DeviceNetworkMembership.portal_network_id == portal_network_id,
DeviceNetworkMembership.deleted_at.is_(None),
for m in NetworkAccessRequest.query.filter(
NetworkAccessRequest.portal_network_id == portal_network_id,
NetworkAccessRequest.deleted_at.is_(None),
).all()
if m.device and m.device.deleted_at is None
}
@@ -124,7 +122,7 @@ def reconcile_network(portal_network_id: str) -> dict:
# Member not seen in ZT yet — could be freshly joined or never connected
logger.debug(
f"[Reconciliation] {network_label}: node {node_id} "
f"(device={device.display_name!r}, state={membership.state}) not yet seen in ZT controller."
f"(device={device.display_name!r}, active={membership.active}) not yet seen in ZT controller."
)
continue
@@ -134,11 +132,11 @@ def reconcile_network(portal_network_id: str) -> dict:
_sync_zt_membership(membership, zt_member)
# Sync authorization state
if membership.state == MembershipState.ACTIVE_AUTHORIZED:
if membership.active:
if not zt_member.is_authorized:
# Portal says active but ZT disagrees — drift, re-authorize
logger.warning(
f"[Reconciliation] {network_label}: DRIFT detected — portal=ACTIVE_AUTHORIZED "
f"[Reconciliation] {network_label}: DRIFT detected — portal=active "
f"but ZT says unauthorized for node {node_id} (device={device.display_name!r}). Re-authorizing."
)
try:
@@ -154,13 +152,13 @@ def reconcile_network(portal_network_id: str) -> dict:
)
else:
logger.debug(
f"[Reconciliation] {network_label}: node {node_id} — portal=ACTIVE_AUTHORIZED, ZT=authorized. OK."
f"[Reconciliation] {network_label}: node {node_id} — portal=active, ZT=authorized. OK."
)
else:
if zt_member.is_authorized:
# ZT says authorized but portal doesn't — could be manual override in ZT console
logger.warning(
f"[Reconciliation] {network_label}: DRIFT detected — portal state={membership.state} "
f"[Reconciliation] {network_label}: DRIFT detected — portal=inactive "
f"but ZT says authorized for node {node_id} (device={device.display_name!r}). Deauthorizing."
)
try:
@@ -177,7 +175,7 @@ def reconcile_network(portal_network_id: str) -> dict:
else:
logger.debug(
f"[Reconciliation] {network_label}: node {node_id}"
f"portal={membership.state}, ZT=unauthorized. OK."
f"portal=inactive, ZT=unauthorized. OK."
)
# Unknown ZT members not in our portal — log only, do not touch
@@ -261,11 +259,11 @@ def reconcile_deleted_memberships() -> dict:
"""Find soft-deleted memberships and hard-delete them after ZeroTier cleanup.
Only processes memberships whose ZeroTier members are already de-authorized
(the de-authorize step happened in revoke_membership_soft). This function
(the de-authorize step happened in revoke_request_soft). This function
removes the member from ZeroTier entirely and then hard-deletes the DB record.
"""
deleted = DeviceNetworkMembership.query.filter(
DeviceNetworkMembership.deleted_at.isnot(None),
deleted = NetworkAccessRequest.query.filter(
NetworkAccessRequest.deleted_at.isnot(None),
).all()
if not deleted:
@@ -328,7 +326,7 @@ def reconcile_deleted_memberships() -> dict:
return results
def _sync_zt_membership(membership: DeviceNetworkMembership, zt_member) -> None:
def _sync_zt_membership(membership: NetworkAccessRequest, zt_member) -> None:
"""Update the ZeroTierMembership cache record from a ZT API response."""
device = membership.device
network = membership.portal_network
@@ -347,7 +345,7 @@ def _sync_zt_membership(membership: DeviceNetworkMembership, zt_member) -> None:
)
zt_membership = ZeroTierMembership(
organization_id=membership.organization_id,
device_network_membership_id=membership.id,
network_access_request_id=membership.id,
zerotier_network_id=network.zerotier_network_id,
node_id=device.node_id,
)
@@ -377,10 +375,10 @@ def _sync_zt_membership(membership: DeviceNetworkMembership, zt_member) -> None:
logger.info(
f"[Reconciliation] First join seen for node {device.node_id} "
f"(device={device.display_name!r}, membership={membership.id}). "
f"State: {membership.state}{MembershipState.JOINED_DEAUTHORIZED}"
f"Setting join_seen=True, active=False"
)
membership.join_seen = True
membership.state = MembershipState.JOINED_DEAUTHORIZED
membership.active = False
membership.save()
else:
logger.debug(
@@ -397,23 +395,22 @@ def _expire_session(session: ActivationSession) -> None:
logger.info(
f"[Reconciliation] Expiring activation session {session.id} "
f"(user={session.user_id}, membership={session.device_network_membership_id}, "
f"(user={session.user_id}, request={session.network_access_request_id}, "
f"expired_at={session.expires_at.isoformat()})."
)
membership = DeviceNetworkMembership.query.get(session.device_network_membership_id)
if not membership:
request = NetworkAccessRequest.query.get(session.network_access_request_id)
if not request:
logger.warning(
f"[Reconciliation] Session {session.id}: membership "
f"{session.device_network_membership_id} not found — skipping ZT deauth."
f"[Reconciliation] Session {session.id}: request "
f"{session.network_access_request_id} not found — skipping ZT deauth."
)
else:
membership.state = MembershipState.ACTIVATION_EXPIRED
membership.currently_authorized = False
membership.save()
request.active = False
request.save()
device = Device.query.get(membership.device_id)
network = PortalNetwork.query.get(membership.portal_network_id)
device = Device.query.get(request.device_id)
network = PortalNetwork.query.get(request.portal_network_id)
if device and network:
network_label = f"{network.name} ({network.zerotier_network_id})"
try:
@@ -449,8 +446,8 @@ def _expire_session(session: ActivationSession) -> None:
else:
logger.warning(
f"[Reconciliation] Session {session.id}: missing "
f"{'device' if not device else 'network'} for membership "
f"{membership.id} — ZT deauth skipped."
f"{'device' if not device else 'network'} for request "
f"{request.id} — ZT deauth skipped."
)
from gatehouse_app.services.audit_service import AuditService
@@ -460,7 +457,7 @@ def _expire_session(session: ActivationSession) -> None:
organization_id=session.organization_id,
resource_type="activation_session",
resource_id=session.id,
metadata={"membership_id": session.device_network_membership_id},
metadata={"request_id": session.network_access_request_id},
description="Activation session expired",
success=True,
)