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