feat: allow admins to bypass approval flow when joining networks
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user