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
+91 -52
View File
@@ -13,12 +13,12 @@ from gatehouse_app.services import device_service
from gatehouse_app.services import network_access_service
from gatehouse_app.services import zerotier_api_service as zt
from gatehouse_app.services import zerotier_reconciliation_service
from gatehouse_app.services.user_service import UserService
from gatehouse_app.models import (
PortalNetwork,
Device,
DeviceNetworkMembership,
UserNetworkApproval,
ActivationSession,
NetworkAccessRequest,
)
from gatehouse_app.models.organization import Organization
from gatehouse_app.models.organization.organization_member import OrganizationMember
@@ -30,7 +30,6 @@ from gatehouse_app.exceptions import (
DeviceNotFoundError,
DeviceAlreadyExistsError,
ApprovalNotFoundError,
MembershipNotFoundError,
)
@@ -347,6 +346,47 @@ def list_devices(org_id):
)
@api_v1_bp.route("/organizations/<org_id>/users/<user_id>/devices", methods=["GET"])
@login_required
@require_admin
@full_access_required
def list_user_devices(org_id, user_id):
"""List all ZeroTier devices for a specific user in the organization (admin only)."""
org, err = _org_check(org_id)
if err:
return err
# Verify target user exists
from gatehouse_app.exceptions.validation_exceptions import UserNotFoundError
try:
target_user = UserService.get_user_by_id(user_id)
except UserNotFoundError:
return api_response(success=False, message="User not found", status=404, error_type="NOT_FOUND")
# Verify target user is a member of the org
is_member = OrganizationMember.query.filter(
OrganizationMember.organization_id == org_id,
OrganizationMember.user_id == user_id,
OrganizationMember.deleted_at.is_(None),
).first() is not None
if not is_member:
return api_response(success=False, message="User is not a member of this organization", status=404, error_type="NOT_FOUND")
# Get devices for the user in this org
devices = device_service.list_user_devices(user_id, org_id)
return api_response(
data={
"devices": [d.to_dict() for d in devices],
"count": len(devices),
"user_id": user_id,
"organization_id": org_id,
},
message="User devices retrieved successfully",
)
@api_v1_bp.route("/organizations/<org_id>/devices", methods=["POST"])
@login_required
@full_access_required
@@ -373,11 +413,8 @@ def register_device(org_id):
serial_number=data.get("serial_number"),
)
from gatehouse_app.services.network_access_service import materialize_device_memberships
memberships = materialize_device_memberships(device.id)
return api_response(
data={"device": device.to_dict(), "memberships_created": len(memberships)},
data={"device": device.to_dict()},
message="Device registered successfully",
status=201,
)
@@ -486,7 +523,7 @@ def list_my_approvals(org_id):
if err:
return err
approvals = network_access_service.list_user_approvals(g.current_user.id, org_id)
approvals = network_access_service.list_user_requests(g.current_user.id, org_id)
return api_response(
data={"approvals": [a.to_dict() for a in approvals], "count": len(approvals)},
message="Approvals retrieved successfully",
@@ -549,18 +586,18 @@ def reject_request(org_id, approval_id):
return api_response(success=False, message=str(e.message), status=400, error_type=e.error_type)
@api_v1_bp.route("/organizations/<org_id>/approvals/<approval_id>/revoke", methods=["POST"])
@api_v1_bp.route("/organizations/<org_id>/approvals/<request_id>/revoke", methods=["POST"])
@login_required
@require_admin
@full_access_required
def revoke_approval(org_id, approval_id):
def revoke_approval(org_id, request_id):
"""Revoke an approved access record (admin only)."""
org, err = _org_check(org_id)
if err:
return err
try:
approval = network_access_service.revoke_approval(approval_id, g.current_user.id)
approval = network_access_service.revoke_access(request_id, g.current_user.id)
return api_response(data={"approval": approval.to_dict()}, message="Approval revoked successfully")
except ApprovalNotFoundError as e:
return api_response(success=False, message=str(e), status=404, error_type=e.error_type)
@@ -607,7 +644,7 @@ def admin_list_all_approvals(org_id):
network_id = request.args.get("network_id")
state = request.args.get("state")
approvals = network_access_service.list_all_org_approvals(org_id, network_id=network_id, state=state)
approvals = network_access_service.list_all_org_requests(org_id, network_id=network_id, state=state)
return api_response(
data={"approvals": [a.to_dict() for a in approvals], "count": len(approvals)},
message="Approvals retrieved successfully",
@@ -626,10 +663,10 @@ def list_memberships(org_id):
if err:
return err
memberships = DeviceNetworkMembership.query.filter(
DeviceNetworkMembership.user_id == g.current_user.id,
DeviceNetworkMembership.organization_id == org_id,
DeviceNetworkMembership.deleted_at.is_(None),
memberships = NetworkAccessRequest.query.filter(
NetworkAccessRequest.user_id == g.current_user.id,
NetworkAccessRequest.organization_id == org_id,
NetworkAccessRequest.deleted_at.is_(None),
).all()
return api_response(
@@ -656,15 +693,14 @@ def activate_membership(org_id, membership_id):
is_admin = _is_org_admin(org_id, g.current_user.id)
try:
session = network_access_service.activate_device_membership(
membership_id=membership_id,
session = network_access_service.activate_request(
request_id=membership_id,
user_id=g.current_user.id,
lifetime_minutes=data.get("lifetime_minutes"),
admin_override=is_admin,
)
membership = DeviceNetworkMembership.query.get(membership_id)
return api_response(data={"session": session.to_dict(), "membership": membership.to_dict()}, message="Membership activated successfully")
except MembershipNotFoundError as e:
return api_response(data={"session": session.to_dict()}, message="Request activated successfully")
except ApprovalNotFoundError as e:
return api_response(success=False, message=str(e), status=404, error_type=e.error_type)
except AppValidationError as e:
return api_response(success=False, message=str(e.message), status=400, error_type=e.error_type)
@@ -681,22 +717,22 @@ def deactivate_membership(org_id, membership_id):
# Verify ownership for non-admins
if not _is_org_admin(org_id, g.current_user.id):
membership_check = DeviceNetworkMembership.query.filter(
DeviceNetworkMembership.id == membership_id,
DeviceNetworkMembership.user_id == g.current_user.id,
DeviceNetworkMembership.deleted_at.is_(None),
membership_check = NetworkAccessRequest.query.filter(
NetworkAccessRequest.id == membership_id,
NetworkAccessRequest.user_id == g.current_user.id,
NetworkAccessRequest.deleted_at.is_(None),
).first()
if not membership_check:
return api_response(success=False, message="Membership not found", status=404, error_type="NOT_FOUND")
try:
membership = network_access_service.deactivate_membership(
membership_id=membership_id,
req = network_access_service.deactivate_request(
request_id=membership_id,
reason="manual_revoke",
deactivated_by_user_id=g.current_user.id,
)
return api_response(data={"membership": membership.to_dict()}, message="Membership deactivated successfully")
except MembershipNotFoundError as e:
return api_response(data={"request": req.to_dict()}, message="Request deactivated successfully")
except ApprovalNotFoundError as e:
return api_response(success=False, message=str(e), status=404, error_type=e.error_type)
@@ -730,17 +766,21 @@ def activate_all_memberships(org_id):
@login_required
@full_access_required
def join_network(org_id, device_id, portal_network_id):
"""Join an open network directly with a registered device."""
"""Join an open network directly with a registered device. Admins can override for any network."""
org, err = _org_check(org_id)
if err:
return err
is_admin = _is_org_admin(org_id, g.current_user.id)
try:
membership = network_access_service.join_network_for_device(
user_id=g.current_user.id,
organization_id=org_id,
device_id=device_id,
portal_network_id=portal_network_id,
admin_override=is_admin,
granted_by_user_id=g.current_user.id if is_admin else None,
)
return api_response(data={"membership": membership.to_dict()}, message="Joined network successfully", status=201)
except AppValidationError as e:
@@ -759,12 +799,12 @@ def delete_membership(org_id, membership_id):
return err
try:
network_access_service.revoke_membership_soft(
membership_id=membership_id,
revoked_by_user_id=g.current_user.id,
network_access_service.revoke_request_soft(
request_id=membership_id,
revoker_user_id=g.current_user.id,
)
return api_response(message="Membership removed successfully")
except MembershipNotFoundError as e:
return api_response(message="Request revoked successfully")
except ApprovalNotFoundError as e:
return api_response(success=False, message=str(e), status=404, error_type=e.error_type)
@@ -820,10 +860,8 @@ def end_session(org_id, session_id):
_end_session(session, ActivationEndReason.LOGOUT)
membership = DeviceNetworkMembership.query.get(session.device_network_membership_id)
if membership:
from gatehouse_app.services.network_access_service import deactivate_membership
deactivate_membership(membership.id, reason="logout")
if session.network_access_request_id:
network_access_service.deactivate_request(session.network_access_request_id, reason="logout")
return api_response(message="Session ended successfully")
@@ -848,15 +886,16 @@ def trigger_kill_switch(org_id):
return api_response(success=False, message="Validation failed", status=400, error_type="VALIDATION_ERROR", error_details=e.messages)
try:
event = network_access_service.kill_switch(
target_user_id=data["target_user_id"],
triggered_by_user_id=g.current_user.id,
organization_id=org_id,
scope=data.get("scope", "organization"),
reason=data.get("reason"),
from gatehouse_app.utils.constants import KillSwitchScope
scope = data.get("scope", "organization")
scope_enum = KillSwitchScope(scope) if scope in KillSwitchScope._value2member_map_ else KillSwitchScope.ORGANIZATION
count = network_access_service.kill_switch(
user_id=data["target_user_id"],
org_id=org_id,
scope=scope_enum,
network_ids=data.get("network_ids"),
)
return api_response(data={"event": event.to_dict()}, message="Kill switch triggered successfully")
return api_response(data={"affected_count": count}, message="Kill switch triggered successfully")
except AppValidationError as e:
return api_response(success=False, message=str(e.message), status=400, error_type=e.error_type)
@@ -873,10 +912,10 @@ def admin_list_memberships(org_id):
if err:
return err
memberships = network_access_service.get_all_memberships_with_details(org_id)
requests = network_access_service.get_all_requests_with_details(org_id)
return api_response(
data={"memberships": memberships, "count": len(memberships)},
message="All memberships retrieved successfully",
data={"requests": requests, "count": len(requests)},
message="All requests retrieved successfully",
)
@@ -891,9 +930,9 @@ def admin_delete_membership(org_id, membership_id):
return err
try:
network_access_service.hard_delete_membership(membership_id)
return api_response(message="Membership permanently deleted")
except MembershipNotFoundError as e:
network_access_service.hard_delete_request(membership_id)
return api_response(message="Request permanently deleted")
except ApprovalNotFoundError as e:
return api_response(success=False, message=str(e), status=404, error_type=e.error_type)