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
+2
View File
@@ -1,12 +1,14 @@
"""API package."""
from flask import Blueprint
from gatehouse_app.utils.response import api_response
from gatehouse_app.extensions import limiter
# Create main API blueprint
api_bp = Blueprint("api", __name__)
@api_bp.route("/health", methods=["GET"])
@limiter.exempt
def health_check():
"""Health check endpoint."""
return api_response(
+1 -1
View File
@@ -5,7 +5,7 @@ from flask import Blueprint
api_v1_bp = Blueprint("api_v1", __name__)
# Import route modules to register them
from gatehouse_app.api.v1 import auth, users, organizations, policies, external_auth, departments, principals, ssh, zerotier, sudo, oidc, contact
from gatehouse_app.api.v1 import auth, users, organizations, policies, external_auth, departments, principals, ssh, zerotier, oidc, contact
from gatehouse_app.api.v1 import superadmin
api_v1_bp.register_blueprint(ssh.ssh_bp)
-4
View File
@@ -16,15 +16,12 @@ class DepartmentCreateSchema(Schema):
"""Schema for creating a department."""
name = fields.Str(required=True, validate=validate.Length(min=1, max=255))
description = fields.Str(allow_none=True, validate=validate.Length(max=2000))
can_sudo = fields.Bool(allow_none=True, load_default=False)
class DepartmentUpdateSchema(Schema):
"""Schema for updating a department."""
name = fields.Str(validate=validate.Length(min=1, max=255))
description = fields.Str(allow_none=True, validate=validate.Length(max=2000))
can_sudo = fields.Bool(allow_none=True)
class AddDepartmentMemberSchema(Schema):
@@ -122,7 +119,6 @@ def create_department(org_id):
organization_id=org_id,
name=data["name"],
description=data.get("description"),
can_sudo=data.get("can_sudo", False),
)
db.session.add(dept)
db.session.commit()
@@ -1,4 +1,4 @@
"""Organization routes package."""
from gatehouse_app.api.v1.organizations import core, members, invites, clients, cas, audit, roles, api_keys
from gatehouse_app.api.v1.organizations import core, members, invites, clients, cas, audit, roles
__all__ = ["core", "members", "invites", "clients", "cas", "audit", "roles", "api_keys"]
__all__ = ["core", "members", "invites", "clients", "cas", "audit", "roles"]
@@ -1,299 +0,0 @@
"""Organization API Key management endpoints."""
from flask import g, request
from marshmallow import Schema, fields, validate, ValidationError
from gatehouse_app.api.v1 import api_v1_bp
from gatehouse_app.utils.response import api_response
from gatehouse_app.utils.decorators import login_required, require_admin, full_access_required
from gatehouse_app.models.organization import OrganizationApiKey
from gatehouse_app.services.organization_service import OrganizationService
from gatehouse_app.extensions import db
class ApiKeyCreateSchema(Schema):
"""Schema for creating an API key."""
name = fields.Str(required=True, validate=validate.Length(min=1, max=255))
description = fields.Str(allow_none=True, validate=validate.Length(max=2000))
class ApiKeyUpdateSchema(Schema):
"""Schema for updating an API key."""
name = fields.Str(validate=validate.Length(min=1, max=255))
description = fields.Str(allow_none=True, validate=validate.Length(max=2000))
@api_v1_bp.route("/organizations/<org_id>/api-keys", methods=["GET"])
@login_required
@require_admin
@full_access_required
def list_api_keys(org_id):
"""
List all API keys for an organization.
Only accessible by organization admins.
Args:
org_id: Organization ID
Returns:
200: List of API keys (without key values)
401: Not authenticated
403: Not an admin
404: Organization not found
"""
org = OrganizationService.get_organization_by_id(org_id)
# Check if user is an admin
from gatehouse_app.models.organization.organization_member import OrganizationMember
from gatehouse_app.utils.constants import OrganizationRole
membership = OrganizationMember.query.filter_by(
user_id=g.current_user.id,
organization_id=org_id,
deleted_at=None
).first()
if not membership or membership.role not in [OrganizationRole.OWNER, OrganizationRole.ADMIN]:
return api_response(
success=False,
message="You do not have permission to manage API keys",
status=403,
error_type="AUTHORIZATION_ERROR",
)
api_keys = OrganizationApiKey.query.filter_by(
organization_id=org_id,
deleted_at=None
).all()
return api_response(
data={
"api_keys": [k.to_dict() for k in api_keys],
"count": len(api_keys),
},
message="API keys retrieved successfully",
)
@api_v1_bp.route("/organizations/<org_id>/api-keys", methods=["POST"])
@login_required
@require_admin
@full_access_required
def create_api_key(org_id):
"""
Create a new API key for an organization.
Only accessible by organization admins.
The plain text key is returned only on creation and should be stored securely.
Args:
org_id: Organization ID
Request body:
name: API key name (required)
description: Optional description
Returns:
201: API key created successfully
400: Validation error
401: Not authenticated
403: Not an admin
404: Organization not found
"""
try:
org = OrganizationService.get_organization_by_id(org_id)
# Check if user is an admin
from gatehouse_app.models.organization.organization_member import OrganizationMember
from gatehouse_app.utils.constants import OrganizationRole
membership = OrganizationMember.query.filter_by(
user_id=g.current_user.id,
organization_id=org_id,
deleted_at=None
).first()
if not membership or membership.role not in [OrganizationRole.OWNER, OrganizationRole.ADMIN]:
return api_response(
success=False,
message="You do not have permission to create API keys",
status=403,
error_type="AUTHORIZATION_ERROR",
)
schema = ApiKeyCreateSchema()
data = schema.load(request.json or {})
# Create the API key
api_key, plain_key = OrganizationApiKey.create_key(
organization_id=org_id,
name=data["name"],
description=data.get("description"),
)
# Return the key data with the plain text key (only on creation)
key_dict = api_key.to_dict()
key_dict["key"] = plain_key # Include plain text only on creation
return api_response(
data={"api_key": key_dict},
message="API key created successfully. Store the key value securely - it cannot be retrieved later.",
status=201,
)
except ValidationError as e:
return api_response(
success=False,
message="Validation failed",
status=400,
error_type="VALIDATION_ERROR",
error_details=e.messages,
)
@api_v1_bp.route("/organizations/<org_id>/api-keys/<key_id>", methods=["PATCH"])
@login_required
@require_admin
@full_access_required
def update_api_key(org_id, key_id):
"""
Update an API key.
Only accessible by organization admins.
Args:
org_id: Organization ID
key_id: API Key ID
Request body:
name: New name (optional)
description: New description (optional)
Returns:
200: API key updated successfully
400: Validation error
401: Not authenticated
403: Not an admin
404: Organization or API key not found
"""
try:
org = OrganizationService.get_organization_by_id(org_id)
# Check if user is an admin
from gatehouse_app.models.organization.organization_member import OrganizationMember
from gatehouse_app.utils.constants import OrganizationRole
membership = OrganizationMember.query.filter_by(
user_id=g.current_user.id,
organization_id=org_id,
deleted_at=None
).first()
if not membership or membership.role not in [OrganizationRole.OWNER, OrganizationRole.ADMIN]:
return api_response(
success=False,
message="You do not have permission to update API keys",
status=403,
error_type="AUTHORIZATION_ERROR",
)
api_key = OrganizationApiKey.query.filter_by(
id=key_id,
organization_id=org_id,
deleted_at=None
).first()
if not api_key:
return api_response(
success=False,
message="API key not found",
status=404,
error_type="NOT_FOUND",
)
schema = ApiKeyUpdateSchema()
data = schema.load(request.json or {})
# Update fields
if "name" in data:
api_key.name = data["name"]
if "description" in data:
api_key.description = data["description"]
api_key.save()
return api_response(
data={"api_key": api_key.to_dict()},
message="API key updated successfully",
)
except ValidationError as e:
return api_response(
success=False,
message="Validation failed",
status=400,
error_type="VALIDATION_ERROR",
error_details=e.messages,
)
@api_v1_bp.route("/organizations/<org_id>/api-keys/<key_id>", methods=["DELETE"])
@login_required
@require_admin
@full_access_required
def delete_api_key(org_id, key_id):
"""
Delete/revoke an API key.
Only accessible by organization admins.
Args:
org_id: Organization ID
key_id: API Key ID
Returns:
200: API key deleted successfully
401: Not authenticated
403: Not an admin
404: Organization or API key not found
"""
org = OrganizationService.get_organization_by_id(org_id)
# Check if user is an admin
from gatehouse_app.models.organization.organization_member import OrganizationMember
from gatehouse_app.utils.constants import OrganizationRole
membership = OrganizationMember.query.filter_by(
user_id=g.current_user.id,
organization_id=org_id,
deleted_at=None
).first()
if not membership or membership.role not in [OrganizationRole.OWNER, OrganizationRole.ADMIN]:
return api_response(
success=False,
message="You do not have permission to delete API keys",
status=403,
error_type="AUTHORIZATION_ERROR",
)
api_key = OrganizationApiKey.query.filter_by(
id=key_id,
organization_id=org_id,
deleted_at=None
).first()
if not api_key:
return api_response(
success=False,
message="API key not found",
status=404,
error_type="NOT_FOUND",
)
# Soft delete the API key
api_key.delete(soft=True)
return api_response(
message="API key deleted successfully",
)
-137
View File
@@ -1,137 +0,0 @@
"""Sudoer check and sudo-related endpoints."""
from flask import request
from gatehouse_app.api.v1 import api_v1_bp
from gatehouse_app.utils.response import api_response
from gatehouse_app.models.organization import OrganizationApiKey
from gatehouse_app.models.ssh_ca.ssh_certificate import SSHCertificate
from gatehouse_app.models.organization import Department, DepartmentMembership
@api_v1_bp.route("/sudo/check", methods=["POST"])
def check_sudoer():
"""
Check if a user with a given certificate can sudo.
This endpoint validates an API key for an organization, retrieves the certificate
by serial ID, finds the user and their departments, and checks if any of their
departments have sudo capability.
Request body:
api_key: Organization API key (required)
certificate_serial: Certificate serial ID (required)
Returns:
200: Sudoer status returned
400: Invalid request body
401: Invalid API key
403: Certificate not found or user not found
404: Organization or certificate not found
"""
try:
data = request.get_json()
if not data:
return api_response(
success=False,
message="Request body is required",
status=400,
error_type="INVALID_REQUEST",
)
api_key = data.get("api_key")
certificate_serial = data.get("certificate_serial")
if not api_key or certificate_serial is None:
return api_response(
success=False,
message="api_key and certificate_serial are required",
status=400,
error_type="MISSING_REQUIRED_FIELDS",
)
# Find the certificate by serial
certificate = SSHCertificate.query.filter_by(
serial=certificate_serial,
deleted_at=None
).first()
if not certificate:
return api_response(
success=False,
message="Certificate not found",
status=404,
error_type="NOT_FOUND",
)
# Get the CA and organization
ca = certificate.ca
if not ca:
return api_response(
success=False,
message="Certificate CA not found",
status=404,
error_type="NOT_FOUND",
)
org_id = ca.organization_id
# Verify the API key for this organization
org_api_key = OrganizationApiKey.verify_key(org_id, api_key)
if not org_api_key:
return api_response(
success=False,
message="Invalid API key for organization",
status=401,
error_type="UNAUTHORIZED",
)
# Get the user from the certificate
user = certificate.user
if not user:
return api_response(
success=False,
message="Certificate user not found",
status=404,
error_type="NOT_FOUND",
)
# Get all departments the user belongs to
user_departments = DepartmentMembership.query.filter_by(
user_id=user.id,
deleted_at=None
).all()
# Check if any of the user's departments have sudo capability
can_sudo = False
sudoer_departments = []
for dept_membership in user_departments:
dept = dept_membership.department
if dept and dept.can_sudo and dept.deleted_at is None:
can_sudo = True
sudoer_departments.append({
"id": dept.id,
"name": dept.name,
})
return api_response(
data={
"can_sudo": can_sudo,
"user_id": user.id,
"user_email": user.email,
"certificate_serial": certificate.serial,
"sudoer_departments": sudoer_departments,
"all_departments_count": len(user_departments),
},
message="Sudoer status retrieved successfully",
status=200,
)
except Exception as e:
return api_response(
success=False,
message=f"An error occurred: {str(e)}",
status=500,
error_type="INTERNAL_ERROR",
)
+122
View File
@@ -710,6 +710,128 @@ def admin_set_user_password(user_id):
return api_response(data={"user": {"id": str(target.id), "email": target.email}}, message=f"Password updated for {target.email}")
@api_v1_bp.route("/admin/users/<user_id>/ssh-certificates", methods=["GET"])
@login_required
@full_access_required
def admin_get_user_ssh_certificates(user_id):
"""List all SSH certificates for a user (admin view).
Returns all certificates — active, expired, revoked — with relevant
metrics for admin visibility. Includes SSH key metadata (fingerprint,
type, description) via the ssh_key relationship.
Query parameters:
status: Filter by certificate status (issued, revoked, expired, superseded)
active: If "true", return only currently valid certificates
cert_type: Filter by certificate type (user, host)
"""
from gatehouse_app.models.ssh_ca.ssh_certificate import SSHCertificate, CertificateStatus
from gatehouse_app.models.ssh_ca.ca import CertType
caller = g.current_user
target = _find_user_for_admin(user_id)
if not target:
return api_response(success=False, message="User not found", status=404, error_type="NOT_FOUND")
if not _get_admin_access(caller, target):
return api_response(success=False, message="Access denied", status=403, error_type="AUTHORIZATION_ERROR")
query = SSHCertificate.query.filter_by(user_id=user_id, deleted_at=None)
# Filter by explicit status (e.g. ?status=revoked)
status_param = request.args.get("status", "").strip().lower()
if status_param:
try:
status_enum = CertificateStatus(status_param)
query = query.filter(SSHCertificate.status == status_enum)
except ValueError:
valid_statuses = [s.value for s in CertificateStatus]
return api_response(
success=False,
message=f"Invalid status '{status_param}'. Must be one of: {', '.join(valid_statuses)}",
status=400, error_type="VALIDATION_ERROR",
)
# Filter for only currently valid certs (?active=true)
active_param = request.args.get("active", "").strip().lower()
if active_param == "true":
now = datetime.now(timezone.utc)
query = query.filter(
SSHCertificate.revoked == False,
SSHCertificate.valid_after <= now,
SSHCertificate.valid_before >= now,
)
elif active_param == "false":
now = datetime.now(timezone.utc)
query = query.filter(
(SSHCertificate.revoked == True) |
(SSHCertificate.valid_before < now)
)
# Filter by certificate type (?cert_type=host)
cert_type_param = request.args.get("cert_type", "").strip().lower()
if cert_type_param:
try:
cert_type_enum = CertType(cert_type_param)
query = query.filter(SSHCertificate.cert_type == cert_type_enum)
except ValueError:
return api_response(
success=False,
message=f"Invalid cert_type '{cert_type_param}'. Must be one of: user, host",
status=400, error_type="VALIDATION_ERROR",
)
# Pagination
try:
page = max(1, int(request.args.get("page", 1)))
per_page = min(100, max(1, int(request.args.get("per_page", 50))))
except ValueError:
page, per_page = 1, 50
total = query.count()
certs = (
query.order_by(SSHCertificate.created_at.desc())
.offset((page - 1) * per_page)
.limit(per_page)
.all()
)
now = datetime.now(timezone.utc)
certs_data = []
for cert in certs:
d = cert.to_dict()
# Enrich with SSH key metadata
if cert.ssh_key:
d["ssh_key"] = {
"id": str(cert.ssh_key.id),
"fingerprint": cert.ssh_key.fingerprint,
"key_type": cert.ssh_key.key_type,
"key_bits": cert.ssh_key.key_bits,
"key_comment": cert.ssh_key.key_comment,
"description": cert.ssh_key.description,
"verified": cert.ssh_key.verified,
}
else:
d["ssh_key"] = None
certs_data.append(d)
return api_response(
data={
"user": {
"id": str(target.id),
"email": target.email,
"full_name": target.full_name,
},
"certificates": certs_data,
"count": total,
"page": page,
"per_page": per_page,
"pages": (total + per_page - 1) // per_page,
},
message="SSH certificates retrieved successfully",
)
@api_v1_bp.route("/admin/users/<user_id>/linked-accounts", methods=["GET"])
@login_required
@full_access_required
+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)
+4 -7
View File
@@ -17,9 +17,8 @@ models.ssh_ca — CA, KeyType, CertType, CaType, CAPermission,
CertificateAuditLog
models.security — OrganizationSecurityPolicy, UserSecurityPolicy,
MfaPolicyCompliance
models.zerotier — PortalNetwork, Device, UserNetworkApproval,
DeviceNetworkMembership, ActivationSession,
ZeroTierMembership, KillSwitchEvent
models.zerotier — PortalNetwork, Device, NetworkAccessRequest,
ActivationSession, ZeroTierMembership, KillSwitchEvent
All names are re-exported here so that existing code using the flat import
style (``from gatehouse_app.models import X``) or the old per-file style
@@ -107,8 +106,7 @@ from gatehouse_app.models.security.mfa_policy_compliance import (
from gatehouse_app.models.zerotier import ( # noqa: F401
PortalNetwork,
Device,
UserNetworkApproval,
DeviceNetworkMembership,
NetworkAccessRequest,
ActivationSession,
ZeroTierMembership,
KillSwitchEvent,
@@ -178,8 +176,7 @@ __all__ = [
# ZeroTier
"PortalNetwork",
"Device",
"UserNetworkApproval",
"DeviceNetworkMembership",
"NetworkAccessRequest",
"ActivationSession",
"ZeroTierMembership",
"KillSwitchEvent",
@@ -12,7 +12,6 @@ from gatehouse_app.models.organization.department_cert_policy import (
)
from gatehouse_app.models.organization.principal import Principal, PrincipalMembership
from gatehouse_app.models.organization.org_invite_token import OrgInviteToken
from gatehouse_app.models.organization.organization_api_key import OrganizationApiKey
__all__ = [
"Organization",
@@ -25,5 +24,4 @@ __all__ = [
"Principal",
"PrincipalMembership",
"OrgInviteToken",
"OrganizationApiKey",
]
@@ -27,7 +27,6 @@ class Department(BaseModel):
)
name = db.Column(db.String(255), nullable=False, index=True)
description = db.Column(db.Text, nullable=True)
can_sudo = db.Column(db.Boolean, default=False, nullable=False)
# Relationships
organization = db.relationship("Organization", back_populates="departments")
@@ -47,9 +47,6 @@ class Organization(BaseModel):
cas = db.relationship(
"CA", back_populates="organization", cascade="all, delete-orphan"
)
api_keys = db.relationship(
"OrganizationApiKey", back_populates="organization", cascade="all, delete-orphan"
)
def __repr__(self):
"""String representation of Organization."""
@@ -110,11 +107,3 @@ class Organization(BaseModel):
"""
return [ca for ca in self.cas if ca.deleted_at is None]
def get_active_api_keys(self):
"""Get active (non-deleted) API keys.
Returns:
List of OrganizationApiKey instances where deleted_at is None.
"""
return [k for k in self.api_keys if k.deleted_at is None]
@@ -1,158 +0,0 @@
"""Organization API Key model — API keys for organizations for external integrations."""
import secrets
from datetime import datetime, timezone
from gatehouse_app.extensions import db
from gatehouse_app.models.base import BaseModel
class OrganizationApiKey(BaseModel):
"""API Key model representing an API key for an organization.
API keys are used to authenticate external integrations or services
that need programmatic access to the organization's resources.
Each key is tied to an organization and can be revoked/deleted as needed.
"""
__tablename__ = "organization_api_keys"
organization_id = db.Column(
db.String(36),
db.ForeignKey("organizations.id"),
nullable=False,
index=True,
)
# Human-readable name for the API key
name = db.Column(db.String(255), nullable=False)
# Hashed key value (never store plain text)
key_hash = db.Column(db.String(255), nullable=False, unique=True, index=True)
# Last used timestamp for tracking activity
last_used_at = db.Column(db.DateTime, nullable=True)
# Revocation status
is_revoked = db.Column(db.Boolean, default=False, nullable=False, index=True)
revoked_at = db.Column(db.DateTime, nullable=True)
revoke_reason = db.Column(db.String(255), nullable=True)
# Description/purpose of the key
description = db.Column(db.Text, nullable=True)
# Relationships
organization = db.relationship("Organization", back_populates="api_keys")
__table_args__ = (
db.Index("idx_org_api_key_org_active", "organization_id", "is_revoked"),
db.Index("idx_api_key_last_used", "last_used_at"),
)
def __repr__(self):
"""String representation of OrganizationApiKey."""
return f"<OrganizationApiKey name={self.name} org_id={self.organization_id}>"
@staticmethod
def generate_key() -> str:
"""Generate a random API key.
Returns:
A random 32-byte hex string suitable for use as an API key
"""
return secrets.token_hex(32)
@classmethod
def create_key(
cls,
organization_id: str,
name: str,
description: str = None,
) -> tuple:
"""Create and store a new API key for an organization.
Args:
organization_id: ID of the organization
name: Human-readable name for the key
description: Optional description/purpose of the key
Returns:
Tuple of (OrganizationApiKey instance, plain_text_key_string)
The plain text key is only returned on creation and should be
stored securely by the user. It cannot be retrieved later.
"""
# Generate a plain text key
plain_key = cls.generate_key()
# Hash it using the key_hash method
key_hash = cls.hash_key(plain_key)
# Create the database record
api_key = cls(
organization_id=organization_id,
name=name,
key_hash=key_hash,
description=description,
)
api_key.save()
return api_key, plain_key
@staticmethod
def hash_key(plain_key: str) -> str:
"""Hash an API key for storage.
Args:
plain_key: The plain text API key
Returns:
Hashed version of the key
"""
import hashlib
return hashlib.sha256(plain_key.encode()).hexdigest()
@classmethod
def verify_key(cls, organization_id: str, plain_key: str) -> "OrganizationApiKey":
"""Verify an API key for an organization.
Args:
organization_id: ID of the organization
plain_key: The plain text API key to verify
Returns:
OrganizationApiKey instance if valid and active, None otherwise
"""
key_hash = cls.hash_key(plain_key)
api_key = cls.query.filter_by(
organization_id=organization_id,
key_hash=key_hash,
is_revoked=False,
deleted_at=None,
).first()
if api_key:
# Update last used timestamp
api_key.last_used_at = datetime.now(timezone.utc)
api_key.save()
return api_key
def revoke(self, reason: str = None) -> None:
"""Revoke this API key.
Args:
reason: Optional reason for revocation
"""
self.is_revoked = True
self.revoked_at = datetime.now(timezone.utc)
self.revoke_reason = reason
self.save()
def to_dict(self, exclude=None):
"""Convert API key to dictionary.
The key_hash is excluded by default for security.
"""
exclude = exclude or []
if "key_hash" not in exclude:
exclude.append("key_hash")
return super().to_dict(exclude=exclude)
+2 -4
View File
@@ -2,8 +2,7 @@
PortalNetwork manager-created network bound to a ZT network ID
Device user-registered ZeroTier node endpoint
UserNetworkApproval durable manager approval for network access
DeviceNetworkMembership per-device per-network workflow record
NetworkAccessRequest unified per-device, per-network access record
ActivationSession temporary activation window
ZeroTierMembership observed controller-side member state
KillSwitchEvent explicit rapid deactivation record
@@ -11,8 +10,7 @@ KillSwitchEvent — explicit rapid deactivation record
from gatehouse_app.models.zerotier.activation_session import ActivationSession # noqa: F401
from gatehouse_app.models.zerotier.device import Device # noqa: F401
from gatehouse_app.models.zerotier.device_network_membership import DeviceNetworkMembership # noqa: F401
from gatehouse_app.models.zerotier.kill_switch_event import KillSwitchEvent # noqa: F401
from gatehouse_app.models.zerotier.network_access_request import NetworkAccessRequest # noqa: F401
from gatehouse_app.models.zerotier.portal_network import PortalNetwork # noqa: F401
from gatehouse_app.models.zerotier.user_network_approval import UserNetworkApproval # noqa: F401
from gatehouse_app.models.zerotier.zerotier_membership import ZeroTierMembership # noqa: F401
@@ -16,7 +16,7 @@ class ActivationSession(BaseModel):
Attributes:
organization_id: FK to the organization
user_id: FK to the user who owns the session
device_network_membership_id: FK to the related membership
network_access_request_id: FK to the related network access request
authenticated_at: When the user re-authenticated to start this session
expires_at: When the activation window ends
ended_at: When the session was explicitly ended (null if still active)
@@ -38,9 +38,9 @@ class ActivationSession(BaseModel):
nullable=False,
index=True,
)
device_network_membership_id = db.Column(
network_access_request_id = db.Column(
db.String(36),
db.ForeignKey("device_network_memberships.id"),
db.ForeignKey("network_access_requests.id"),
nullable=False,
index=True,
)
@@ -75,14 +75,14 @@ class ActivationSession(BaseModel):
foreign_keys=[created_by],
backref="created_activation_sessions",
)
membership = db.relationship(
"DeviceNetworkMembership",
access_request = db.relationship(
"NetworkAccessRequest",
back_populates="activation_sessions",
)
def __repr__(self):
return (
f"<ActivationSession membership={self.device_network_membership_id} "
f"<ActivationSession request={self.network_access_request_id} "
f"expires={self.expires_at}>"
)
+5 -5
View File
@@ -2,7 +2,7 @@
from gatehouse_app.extensions import db
from gatehouse_app.models.base import BaseModel
from gatehouse_app.utils.constants import DeviceStatus
from gatehouse_app.utils.constants import ApprovalState, DeviceStatus
class Device(BaseModel):
@@ -55,8 +55,8 @@ class Device(BaseModel):
# Relationships
user = db.relationship("User", backref="devices")
organization = db.relationship("Organization", backref="devices")
memberships = db.relationship(
"DeviceNetworkMembership",
network_access_requests = db.relationship(
"NetworkAccessRequest",
back_populates="device",
cascade="all, delete-orphan",
)
@@ -73,7 +73,7 @@ class Device(BaseModel):
data = super().to_dict(exclude=exclude)
data["display_name"] = self.display_name
data["active_membership_count"] = sum(
1 for m in self.memberships
if m.state == "active_authorized" and m.deleted_at is None
1 for r in self.network_access_requests
if r.active and r.status == ApprovalState.APPROVED and r.deleted_at is None
)
return data
@@ -1,129 +0,0 @@
"""Device network membership — per-device, per-network workflow object."""
from gatehouse_app.extensions import db
from gatehouse_app.models.base import BaseModel
from gatehouse_app.utils.constants import MembershipState
class DeviceNetworkMembership(BaseModel):
"""The main per-device, per-network workflow record.
This binds a specific Device to a specific PortalNetwork through a
UserNetworkApproval. It tracks both the internal portal state and the
observed ZeroTier membership state.
States:
pending_device_registration approval exists but no device registered yet
pending_request user has requested access but not yet approved
pending_manager_approval approval pending manager sign-off
approved_inactive approved but not currently active
joined_deauthorized device has joined ZT network but not authorized
active_authorized authorized and actively connected
activation_expired activation window ended (member still in ZT, deauth'd)
suspended temporarily suspended
revoked permanently revoked
rejected request was rejected
"""
__tablename__ = "device_network_memberships"
organization_id = db.Column(
db.String(36),
db.ForeignKey("organizations.id"),
nullable=False,
index=True,
)
user_id = db.Column(
db.String(36),
db.ForeignKey("users.id"),
nullable=False,
index=True,
)
device_id = db.Column(
db.String(36),
db.ForeignKey("devices.id"),
nullable=False,
index=True,
)
portal_network_id = db.Column(
db.String(36),
db.ForeignKey("portal_networks.id"),
nullable=False,
index=True,
)
user_network_approval_id = db.Column(
db.String(36),
db.ForeignKey("user_network_approvals.id"),
nullable=True,
index=True,
)
state = db.Column(
db.Enum(MembershipState, name="membership_state", values_callable=lambda x: [e.value for e in x]),
default=MembershipState.PENDING_DEVICE_REGISTRATION,
nullable=False,
index=True,
)
join_seen = db.Column(db.Boolean, default=False, nullable=False)
currently_authorized = db.Column(db.Boolean, default=False, nullable=False)
approved_for_activation = db.Column(db.Boolean, default=True, nullable=False)
# Relationships
organization = db.relationship("Organization", backref="network_memberships")
user = db.relationship("User", backref="network_memberships")
device = db.relationship("Device", back_populates="memberships")
portal_network = db.relationship(
"PortalNetwork",
back_populates="memberships",
)
approval = db.relationship(
"UserNetworkApproval",
back_populates="memberships",
)
activation_sessions = db.relationship(
"ActivationSession",
back_populates="membership",
cascade="all, delete-orphan",
)
zerotier_membership = db.relationship(
"ZeroTierMembership",
back_populates="device_network_membership",
uselist=False,
cascade="all, delete-orphan",
)
__table_args__ = (
db.UniqueConstraint(
"device_id",
"portal_network_id",
"deleted_at",
name="uix_device_network",
),
)
def __repr__(self):
return (
f"<DeviceNetworkMembership device={self.device_id} "
f"network={self.portal_network_id} state={self.state}>"
)
@property
def active_session(self):
"""Return the current active ActivationSession, if any."""
for s in self.activation_sessions:
if s.ended_at is None and s.expires_at is not None:
from datetime import datetime, timezone
now = datetime.now(timezone.utc)
exp = s.expires_at
if exp.tzinfo is None:
exp = exp.replace(tzinfo=timezone.utc)
if exp > now:
return s
return None
def to_dict(self, exclude=None):
exclude = exclude or []
data = super().to_dict(exclude=exclude)
data["active_session"] = (
self.active_session.to_dict() if self.active_session else None
)
return data
@@ -0,0 +1,147 @@
"""Network access request model — unified per-device, per-network access record."""
from datetime import datetime, timezone
from gatehouse_app.extensions import db
from gatehouse_app.models.base import BaseModel
from gatehouse_app.utils.constants import ApprovalGrantType, ApprovalState
class NetworkAccessRequest(BaseModel):
"""A unified access record binding a user's device to a portal network.
Replaces the separate UserNetworkApproval and DeviceNetworkMembership
tables with a single per-device, per-network row. Each row tracks both
the business-level approval status and the device-level active/inactive
toggle.
Attributes:
organization_id: FK to the organization
user_id: FK to the requesting user
device_id: FK to the specific device
portal_network_id: FK to the portal network
granted_by_user_id: FK to the manager who approved (null for user-initiated)
grant_type: requested (user-initiated) or assigned (manager-initiated)
status: pending / approved / rejected / revoked / suspended
active: whether the device connection is currently live
justification: Business reason for the request
join_seen: Whether the device has been seen joining the ZeroTier network
"""
__tablename__ = "network_access_requests"
organization_id = db.Column(
db.String(36),
db.ForeignKey("organizations.id"),
nullable=False,
index=True,
)
user_id = db.Column(
db.String(36),
db.ForeignKey("users.id"),
nullable=False,
index=True,
)
device_id = db.Column(
db.String(36),
db.ForeignKey("devices.id"),
nullable=False,
index=True,
)
portal_network_id = db.Column(
db.String(36),
db.ForeignKey("portal_networks.id"),
nullable=False,
index=True,
)
granted_by_user_id = db.Column(
db.String(36),
db.ForeignKey("users.id"),
nullable=True,
)
grant_type = db.Column(
db.Enum(ApprovalGrantType, name="approval_grant_type", values_callable=lambda x: [e.value for e in x]),
default=ApprovalGrantType.REQUESTED,
nullable=False,
)
status = db.Column(
db.Enum(ApprovalState, name="approval_state", values_callable=lambda x: [e.value for e in x]),
default=ApprovalState.PENDING,
nullable=False,
index=True,
)
active = db.Column(
db.Boolean,
default=False,
nullable=False,
)
justification = db.Column(db.Text, nullable=True)
join_seen = db.Column(db.Boolean, default=False, nullable=False)
# Relationships
organization = db.relationship("Organization", backref="network_access_requests")
user = db.relationship(
"User",
foreign_keys=[user_id],
backref="network_access_requests",
)
granted_by = db.relationship(
"User",
foreign_keys=[granted_by_user_id],
backref="granted_network_requests",
)
device = db.relationship(
"Device",
back_populates="network_access_requests",
)
portal_network = db.relationship(
"PortalNetwork",
backref="access_requests",
)
activation_sessions = db.relationship(
"ActivationSession",
back_populates="access_request",
cascade="all, delete-orphan",
)
zerotier_membership = db.relationship(
"ZeroTierMembership",
back_populates="access_request",
uselist=False,
cascade="all, delete-orphan",
)
__table_args__ = (
db.UniqueConstraint(
"user_id",
"device_id",
"portal_network_id",
"deleted_at",
name="uix_user_device_network",
),
)
def __repr__(self):
return (
f"<NetworkAccessRequest user={self.user_id} "
f"device={self.device_id} network={self.portal_network_id} "
f"status={self.status}>"
)
@property
def active_session(self):
"""Return the current active ActivationSession, if any."""
for s in self.activation_sessions:
if s.ended_at is None and s.expires_at is not None:
now = datetime.now(timezone.utc)
exp = s.expires_at
if exp.tzinfo is None:
exp = exp.replace(tzinfo=timezone.utc)
if exp > now:
return s
return None
def to_dict(self, exclude=None):
exclude = exclude or []
data = super().to_dict(exclude=exclude)
session = self.active_session
data["active_session"] = session.to_dict() if session else None
return data
@@ -2,7 +2,7 @@
from gatehouse_app.extensions import db
from gatehouse_app.models.base import BaseModel
from gatehouse_app.utils.constants import NetworkEnvironment, NetworkRequestMode
from gatehouse_app.utils.constants import ApprovalState, NetworkEnvironment, NetworkRequestMode
class PortalNetwork(BaseModel):
@@ -65,16 +65,6 @@ class PortalNetwork(BaseModel):
# Relationships
organization = db.relationship("Organization", backref="portal_networks")
owner = db.relationship("User", backref="owned_networks")
approvals = db.relationship(
"UserNetworkApproval",
back_populates="portal_network",
cascade="all, delete-orphan",
)
memberships = db.relationship(
"DeviceNetworkMembership",
back_populates="portal_network",
cascade="all, delete-orphan",
)
__table_args__ = (
db.UniqueConstraint(
@@ -91,10 +81,11 @@ class PortalNetwork(BaseModel):
exclude = exclude or []
data = super().to_dict(exclude=exclude)
data["approved_user_count"] = sum(
1 for a in self.approvals if a.state == "approved" and a.deleted_at is None
1 for a in self.access_requests
if a.status == ApprovalState.APPROVED and a.deleted_at is None
)
data["active_membership_count"] = sum(
1 for m in self.memberships
if m.state == "active_authorized" and m.deleted_at is None
1 for r in self.access_requests
if r.active and r.status == ApprovalState.APPROVED and r.deleted_at is None
)
return data
@@ -1,106 +0,0 @@
"""User network approval model — durable manager approval for network access."""
from gatehouse_app.extensions import db
from gatehouse_app.models.base import BaseModel
from gatehouse_app.utils.constants import ApprovalGrantType, ApprovalState
class UserNetworkApproval(BaseModel):
"""A durable approval record binding a user to a portal network.
This is the business-level approval separate from any device and separate
from activation sessions. Manager approval survives across days and only
needs to be issued once unless explicitly revoked.
Attributes:
organization_id: FK to the organization
user_id: FK to the approved user
portal_network_id: FK to the portal network
granted_by_user_id: FK to the manager who approved (null for system-assigned)
grant_type: requested (user-initiated) or assigned (manager-initiated)
state: pending / approved / rejected / revoked / suspended
justification: Business reason for the approval
"""
__tablename__ = "user_network_approvals"
organization_id = db.Column(
db.String(36),
db.ForeignKey("organizations.id"),
nullable=False,
index=True,
)
user_id = db.Column(
db.String(36),
db.ForeignKey("users.id"),
nullable=False,
index=True,
)
portal_network_id = db.Column(
db.String(36),
db.ForeignKey("portal_networks.id"),
nullable=False,
index=True,
)
granted_by_user_id = db.Column(
db.String(36),
db.ForeignKey("users.id"),
nullable=True,
)
grant_type = db.Column(
db.Enum(ApprovalGrantType, name="approval_grant_type", values_callable=lambda x: [e.value for e in x]),
default=ApprovalGrantType.REQUESTED,
nullable=False,
)
state = db.Column(
db.Enum(ApprovalState, name="approval_state", values_callable=lambda x: [e.value for e in x]),
default=ApprovalState.PENDING,
nullable=False,
index=True,
)
justification = db.Column(db.Text, nullable=True)
# Relationships
organization = db.relationship("Organization", backref="network_approvals")
user = db.relationship(
"User",
foreign_keys=[user_id],
backref="network_approvals",
)
granted_by = db.relationship(
"User",
foreign_keys=[granted_by_user_id],
backref="granted_approvals",
)
portal_network = db.relationship(
"PortalNetwork",
back_populates="approvals",
)
memberships = db.relationship(
"DeviceNetworkMembership",
back_populates="approval",
cascade="all, delete-orphan",
)
__table_args__ = (
db.UniqueConstraint(
"user_id",
"portal_network_id",
"deleted_at",
name="uix_user_network_approval",
),
)
def __repr__(self):
return (
f"<UserNetworkApproval user={self.user_id} "
f"network={self.portal_network_id} state={self.state}>"
)
def to_dict(self, exclude=None):
exclude = exclude or []
data = super().to_dict(exclude=exclude)
data["active_membership_count"] = sum(
1 for m in self.memberships if m.deleted_at is None
)
return data
@@ -15,7 +15,7 @@ class ZeroTierMembership(BaseModel):
Attributes:
organization_id: FK to the organization
device_network_membership_id: FK to the portal's membership record (nullable)
network_access_request_id: FK to the portal's access request record (nullable)
zerotier_network_id: The 16-char hex ZeroTier network ID
node_id: The 10-char hex ZeroTier node ID
member_seen: Whether the controller has ever seen this member
@@ -33,9 +33,9 @@ class ZeroTierMembership(BaseModel):
nullable=False,
index=True,
)
device_network_membership_id = db.Column(
network_access_request_id = db.Column(
db.String(36),
db.ForeignKey("device_network_memberships.id"),
db.ForeignKey("network_access_requests.id"),
nullable=True,
index=True,
)
@@ -57,8 +57,8 @@ class ZeroTierMembership(BaseModel):
# Relationships
organization = db.relationship("Organization", backref="zerotier_memberships")
device_network_membership = db.relationship(
"DeviceNetworkMembership",
access_request = db.relationship(
"NetworkAccessRequest",
back_populates="zerotier_membership",
)
+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,
)
-15
View File
@@ -253,21 +253,6 @@ class ApprovalState(str, Enum):
SUSPENDED = "suspended"
class MembershipState(str, Enum):
"""State of a device network membership record."""
PENDING_DEVICE_REGISTRATION = "pending_device_registration"
PENDING_REQUEST = "pending_request"
PENDING_MANAGER_APPROVAL = "pending_manager_approval"
APPROVED_INACTIVE = "approved_inactive"
JOINED_DEAUTHORIZED = "joined_deauthorized"
ACTIVE_AUTHORIZED = "active_authorized"
ACTIVATION_EXPIRED = "activation_expired"
SUSPENDED = "suspended"
REVOKED = "revoked"
REJECTED = "rejected"
class ActivationEndReason(str, Enum):
"""Why an activation session ended."""