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
+17
View File
@@ -0,0 +1,17 @@
version: '3.8'
services:
api:
environment:
- FLASK_ENV=development
- FLASK_DEBUG=1
volumes:
- .:/app
command: >
flask run --host=0.0.0.0 --port=5000 --reload
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5000/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
+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."""
@@ -0,0 +1,71 @@
"""Remove sudo: drop can_sudo column and organization_api_keys table.
Revision ID: d1e2f3g4h5i6
Revises: c0a1b2c3d4e5
Create Date: 2026-05-03 10:20:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'd1e2f3g4h5i6'
down_revision = 'c0a1b2c3d4e5'
branch_labels = None
depends_on = None
def upgrade():
# ------------------------------------------------------------------
# Step 1: Drop organization_api_keys table and all its indexes
# ------------------------------------------------------------------
op.drop_index('idx_api_key_last_used', table_name='organization_api_keys')
op.drop_index('idx_org_api_key_org_active', table_name='organization_api_keys')
op.drop_index(op.f('ix_organization_api_keys_is_revoked'), table_name='organization_api_keys')
op.drop_index(op.f('ix_organization_api_keys_key_hash'), table_name='organization_api_keys')
op.drop_index(op.f('ix_organization_api_keys_organization_id'), table_name='organization_api_keys')
op.drop_table('organization_api_keys')
# ------------------------------------------------------------------
# Step 2: Drop can_sudo column from departments table
# ------------------------------------------------------------------
op.drop_column('departments', 'can_sudo')
def downgrade():
# ------------------------------------------------------------------
# Step 1: Recreate can_sudo column in departments table
# ------------------------------------------------------------------
op.add_column(
'departments',
sa.Column('can_sudo', sa.Boolean(), nullable=False, server_default='false')
)
# ------------------------------------------------------------------
# Step 2: Recreate organization_api_keys table
# ------------------------------------------------------------------
op.create_table(
'organization_api_keys',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('organization_id', sa.String(length=36), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('key_hash', sa.String(length=255), nullable=False),
sa.Column('last_used_at', sa.DateTime(), nullable=True),
sa.Column('is_revoked', sa.Boolean(), nullable=False),
sa.Column('revoked_at', sa.DateTime(), nullable=True),
sa.Column('revoke_reason', sa.String(length=255), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], name='fk_organization_api_keys_organization'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('key_hash', name='uq_organization_api_keys_key_hash'),
)
# Recreate indexes on organization_api_keys
op.create_index('idx_org_api_key_org_active', 'organization_api_keys', ['organization_id', 'is_revoked'])
op.create_index('idx_api_key_last_used', 'organization_api_keys', ['last_used_at'])
op.create_index(op.f('ix_organization_api_keys_is_revoked'), 'organization_api_keys', ['is_revoked'])
op.create_index(op.f('ix_organization_api_keys_key_hash'), 'organization_api_keys', ['key_hash'], unique=True)
op.create_index(op.f('ix_organization_api_keys_organization_id'), 'organization_api_keys', ['organization_id'])
@@ -0,0 +1,691 @@
"""Merge user_network_approvals and device_network_memberships into network_access_requests.
Revision ID: c0a1b2c3d4e5
Revises: a1b2c3d4e5f6
Create Date: 2026-05-02 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'c0a1b2c3d4e5'
down_revision = 'a1b2c3d4e5f6'
branch_labels = None
depends_on = None
# ---------------------------------------------------------------------------
# UPGRADE
# ---------------------------------------------------------------------------
def upgrade():
# ------------------------------------------------------------------
# Step 1: Create the new network_access_requests table
# ------------------------------------------------------------------
op.create_table(
'network_access_requests',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('organization_id', sa.String(length=36), nullable=False),
sa.Column('user_id', sa.String(length=36), nullable=False),
sa.Column('device_id', sa.String(length=36), nullable=False),
sa.Column('portal_network_id', sa.String(length=36), nullable=False),
sa.Column('granted_by_user_id', sa.String(length=36), nullable=True),
sa.Column(
'grant_type',
sa.Enum('requested', 'assigned', name='approval_grant_type', create_type=False),
nullable=False,
),
sa.Column(
'status',
sa.Enum(
'pending', 'approved', 'rejected', 'revoked', 'suspended',
name='approval_state', create_type=False,
),
nullable=False,
),
sa.Column('active', sa.Boolean(), nullable=False, server_default='false'),
sa.Column('justification', sa.Text(), nullable=True),
sa.Column('join_seen', sa.Boolean(), nullable=False, server_default='false'),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(
['device_id'], ['devices.id'],
name='fk_network_access_requests_device',
),
sa.ForeignKeyConstraint(
['granted_by_user_id'], ['users.id'],
name='fk_network_access_requests_granted_by_user',
),
sa.ForeignKeyConstraint(
['organization_id'], ['organizations.id'],
name='fk_network_access_requests_organization',
),
sa.ForeignKeyConstraint(
['portal_network_id'], ['portal_networks.id'],
name='fk_network_access_requests_portal_network',
),
sa.ForeignKeyConstraint(
['user_id'], ['users.id'],
name='fk_network_access_requests_user',
),
sa.PrimaryKeyConstraint('id', name='pk_network_access_requests'),
sa.UniqueConstraint(
'user_id', 'device_id', 'portal_network_id', 'deleted_at',
name='uix_user_device_network',
),
)
# Indexes on network_access_requests
op.create_index(
'ix_network_access_requests_device_id',
'network_access_requests',
['device_id'],
unique=False,
)
op.create_index(
'ix_network_access_requests_organization_id',
'network_access_requests',
['organization_id'],
unique=False,
)
op.create_index(
'ix_network_access_requests_portal_network_id',
'network_access_requests',
['portal_network_id'],
unique=False,
)
op.create_index(
'ix_network_access_requests_status',
'network_access_requests',
['status'],
unique=False,
)
op.create_index(
'ix_network_access_requests_user_id',
'network_access_requests',
['user_id'],
unique=False,
)
# ------------------------------------------------------------------
# Step 2: Migrate data from old tables into the new table
# ------------------------------------------------------------------
op.execute(
"""
INSERT INTO network_access_requests (
id, organization_id, user_id, device_id, portal_network_id,
granted_by_user_id, grant_type, status, active, justification,
join_seen, created_at, updated_at, deleted_at
)
SELECT
dnm.id,
dnm.organization_id,
dnm.user_id,
dnm.device_id,
dnm.portal_network_id,
COALESCE(una.granted_by_user_id, NULL),
COALESCE(una.grant_type, 'requested'),
COALESCE(una.state, 'pending'),
CASE
WHEN dnm.currently_authorized = true AND una.state = 'approved'
THEN true
ELSE false
END,
una.justification,
dnm.join_seen,
COALESCE(dnm.created_at, una.created_at),
COALESCE(dnm.updated_at, una.updated_at),
dnm.deleted_at
FROM device_network_memberships dnm
LEFT JOIN user_network_approvals una
ON una.id = dnm.user_network_approval_id;
"""
)
# ------------------------------------------------------------------
# Step 3: Update activation_sessions FK
# ------------------------------------------------------------------
# 3a. Add the new nullable column
op.add_column(
'activation_sessions',
sa.Column('network_access_request_id', sa.String(length=36), nullable=True),
)
# 3b. Populate the new column from the old column
op.execute(
"""
UPDATE activation_sessions
SET network_access_request_id = device_network_membership_id;
"""
)
# 3c. Drop the old foreign-key constraint
op.drop_constraint(
'activation_sessions_device_network_membership_id_fkey',
'activation_sessions',
type_='foreignkey',
)
# 3d. Drop the old column
op.drop_column('activation_sessions', 'device_network_membership_id')
# 3d-alt. Enforce NOT NULL on the new column before FK creation
op.alter_column('activation_sessions', 'network_access_request_id', nullable=False)
# 3e. Create the new foreign-key constraint
op.create_foreign_key(
'fk_activation_sessions_network_access_request',
'activation_sessions',
'network_access_requests',
['network_access_request_id'],
['id'],
)
# 3f. Create the new index
op.create_index(
'ix_activation_sessions_network_access_request_id',
'activation_sessions',
['network_access_request_id'],
unique=False,
)
# ------------------------------------------------------------------
# Step 4: Update zerotier_memberships FK
# ------------------------------------------------------------------
# 4a. Add the new nullable column
op.add_column(
'zerotier_memberships',
sa.Column('network_access_request_id', sa.String(length=36), nullable=True),
)
# 4b. Populate the new column from the old column
op.execute(
"""
UPDATE zerotier_memberships
SET network_access_request_id = device_network_membership_id;
"""
)
# 4c. Drop the old foreign-key constraint
op.drop_constraint(
'zerotier_memberships_device_network_membership_id_fkey',
'zerotier_memberships',
type_='foreignkey',
)
# 4d. Drop the old column
op.drop_column('zerotier_memberships', 'device_network_membership_id')
# 4e. Create the new foreign-key constraint
op.create_foreign_key(
'fk_zerotier_memberships_network_access_request',
'zerotier_memberships',
'network_access_requests',
['network_access_request_id'],
['id'],
)
# 4f. Create the new index
op.create_index(
'ix_zerotier_memberships_network_access_request_id',
'zerotier_memberships',
['network_access_request_id'],
unique=False,
)
# ------------------------------------------------------------------
# Step 5: Drop old tables and the membership_state enum
# ------------------------------------------------------------------
# 5a. Drop device_network_memberships and all its indexes
op.drop_index(
'ix_device_network_memberships_user_network_approval_id',
table_name='device_network_memberships',
)
op.drop_index(
'ix_device_network_memberships_user_id',
table_name='device_network_memberships',
)
op.drop_index(
'ix_device_network_memberships_state',
table_name='device_network_memberships',
)
op.drop_index(
'ix_device_network_memberships_portal_network_id',
table_name='device_network_memberships',
)
op.drop_index(
'ix_device_network_memberships_organization_id',
table_name='device_network_memberships',
)
op.drop_index(
'ix_device_network_memberships_device_id',
table_name='device_network_memberships',
)
op.drop_table('device_network_memberships')
# 5b. Drop user_network_approvals and all its indexes
op.drop_index(
'ix_user_network_approvals_user_id',
table_name='user_network_approvals',
)
op.drop_index(
'ix_user_network_approvals_state',
table_name='user_network_approvals',
)
op.drop_index(
'ix_user_network_approvals_portal_network_id',
table_name='user_network_approvals',
)
op.drop_index(
'ix_user_network_approvals_organization_id',
table_name='user_network_approvals',
)
op.drop_table('user_network_approvals')
# 5c. Drop the membership_state enum type if it exists
op.execute(
"""
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM pg_type WHERE typname = 'membership_state'
) THEN
DROP TYPE membership_state;
END IF;
END$$;
"""
)
# ---------------------------------------------------------------------------
# DOWNGRADE
# ---------------------------------------------------------------------------
def downgrade():
# ------------------------------------------------------------------
# Step 1: Recreate the membership_state enum (used by old tables)
# ------------------------------------------------------------------
membership_state = sa.Enum(
'pending_device_registration',
'pending_request',
'pending_manager_approval',
'approved_inactive',
'joined_deauthorized',
'active_authorized',
'activation_expired',
'suspended',
'revoked',
'rejected',
name='membership_state',
)
membership_state.create(op.get_bind(), checkfirst=True)
# ------------------------------------------------------------------
# Step 2: Recreate user_network_approvals table
# ------------------------------------------------------------------
op.create_table(
'user_network_approvals',
sa.Column('organization_id', sa.String(length=36), nullable=False),
sa.Column('user_id', sa.String(length=36), nullable=False),
sa.Column('portal_network_id', sa.String(length=36), nullable=False),
sa.Column('granted_by_user_id', sa.String(length=36), nullable=True),
sa.Column(
'grant_type',
sa.Enum('requested', 'assigned', name='approval_grant_type', create_type=False),
nullable=False,
),
sa.Column(
'state',
sa.Enum(
'pending', 'approved', 'rejected', 'revoked', 'suspended',
name='approval_state', create_type=False,
),
nullable=False,
),
sa.Column('justification', sa.Text(), nullable=True),
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(
['granted_by_user_id'], ['users.id'],
),
sa.ForeignKeyConstraint(
['organization_id'], ['organizations.id'],
),
sa.ForeignKeyConstraint(
['portal_network_id'], ['portal_networks.id'],
),
sa.ForeignKeyConstraint(
['user_id'], ['users.id'],
),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint(
'user_id', 'portal_network_id', 'deleted_at',
name='uix_user_network_approval',
),
)
# Recreate indexes on user_network_approvals
op.create_index(
'ix_user_network_approvals_organization_id',
'user_network_approvals',
['organization_id'],
unique=False,
)
op.create_index(
'ix_user_network_approvals_portal_network_id',
'user_network_approvals',
['portal_network_id'],
unique=False,
)
op.create_index(
'ix_user_network_approvals_state',
'user_network_approvals',
['state'],
unique=False,
)
op.create_index(
'ix_user_network_approvals_user_id',
'user_network_approvals',
['user_id'],
unique=False,
)
# ------------------------------------------------------------------
# Step 3: Migrate data back into user_network_approvals
# ------------------------------------------------------------------
# Derive one approval row per (user_id, portal_network_id, deleted_at).
# We use gen_random_uuid() to generate new approval IDs because the
# original approval IDs were lost during the upgrade.
op.execute(
"""
INSERT INTO user_network_approvals (
id, organization_id, user_id, portal_network_id,
granted_by_user_id, grant_type, state, justification,
created_at, updated_at, deleted_at
)
SELECT
gen_random_uuid()::text,
(array_agg(organization_id ORDER BY created_at))[1],
user_id,
portal_network_id,
(array_agg(granted_by_user_id ORDER BY created_at))[1],
(array_agg(grant_type ORDER BY created_at))[1],
(array_agg(status ORDER BY created_at))[1],
(array_agg(justification ORDER BY created_at))[1],
MIN(created_at),
MAX(updated_at),
deleted_at
FROM network_access_requests
GROUP BY user_id, portal_network_id, deleted_at;
"""
)
# ------------------------------------------------------------------
# Step 4: Recreate device_network_memberships table
# ------------------------------------------------------------------
op.create_table(
'device_network_memberships',
sa.Column('organization_id', sa.String(length=36), nullable=False),
sa.Column('user_id', sa.String(length=36), nullable=False),
sa.Column('device_id', sa.String(length=36), nullable=False),
sa.Column('portal_network_id', sa.String(length=36), nullable=False),
sa.Column('user_network_approval_id', sa.String(length=36), nullable=True),
sa.Column(
'state',
sa.Enum(
'pending_device_registration',
'pending_request',
'pending_manager_approval',
'approved_inactive',
'joined_deauthorized',
'active_authorized',
'activation_expired',
'suspended',
'revoked',
'rejected',
name='membership_state', create_type=False,
),
nullable=False,
),
sa.Column('join_seen', sa.Boolean(), nullable=False),
sa.Column('currently_authorized', sa.Boolean(), nullable=False),
sa.Column('approved_for_activation', sa.Boolean(), nullable=False),
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(
['device_id'], ['devices.id'],
),
sa.ForeignKeyConstraint(
['organization_id'], ['organizations.id'],
),
sa.ForeignKeyConstraint(
['portal_network_id'], ['portal_networks.id'],
),
sa.ForeignKeyConstraint(
['user_id'], ['users.id'],
),
sa.ForeignKeyConstraint(
['user_network_approval_id'], ['user_network_approvals.id'],
),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint(
'device_id', 'portal_network_id', 'deleted_at',
name='uix_device_network',
),
)
# Recreate indexes on device_network_memberships
op.create_index(
'ix_device_network_memberships_device_id',
'device_network_memberships',
['device_id'],
unique=False,
)
op.create_index(
'ix_device_network_memberships_organization_id',
'device_network_memberships',
['organization_id'],
unique=False,
)
op.create_index(
'ix_device_network_memberships_portal_network_id',
'device_network_memberships',
['portal_network_id'],
unique=False,
)
op.create_index(
'ix_device_network_memberships_state',
'device_network_memberships',
['state'],
unique=False,
)
op.create_index(
'ix_device_network_memberships_user_id',
'device_network_memberships',
['user_id'],
unique=False,
)
op.create_index(
'ix_device_network_memberships_user_network_approval_id',
'device_network_memberships',
['user_network_approval_id'],
unique=False,
)
# ------------------------------------------------------------------
# Step 5: Migrate data back into device_network_memberships
# ------------------------------------------------------------------
# Map network_access_requests rows back to device_network_memberships.
# Reverse the status/active mapping using a best-effort approach.
op.execute(
"""
INSERT INTO device_network_memberships (
id, organization_id, user_id, device_id, portal_network_id,
user_network_approval_id, state, join_seen, currently_authorized,
approved_for_activation, created_at, updated_at, deleted_at
)
SELECT
nar.id,
nar.organization_id,
nar.user_id,
nar.device_id,
nar.portal_network_id,
una.id AS user_network_approval_id,
CASE nar.status
WHEN 'approved' THEN
CASE WHEN nar.active = true
THEN 'active_authorized'
ELSE 'approved_inactive'
END
WHEN 'pending' THEN 'pending_request'
ELSE nar.status
END AS state,
nar.join_seen,
nar.active AS currently_authorized,
CASE
WHEN nar.status = 'approved' THEN true
ELSE false
END AS approved_for_activation,
nar.created_at,
nar.updated_at,
nar.deleted_at
FROM network_access_requests nar
JOIN user_network_approvals una
ON una.user_id = nar.user_id
AND una.portal_network_id = nar.portal_network_id
AND (una.deleted_at IS NOT DISTINCT FROM nar.deleted_at);
"""
)
# ------------------------------------------------------------------
# Step 6: Restore activation_sessions FK
# ------------------------------------------------------------------
# 6a. Add the old column (nullable first so we can populate)
op.add_column(
'activation_sessions',
sa.Column('device_network_membership_id', sa.String(length=36), nullable=True),
)
# 6b. Populate the old column from the new column before it disappears
op.execute(
"""
UPDATE activation_sessions
SET device_network_membership_id = network_access_request_id
WHERE network_access_request_id IS NOT NULL;
"""
)
# 6c. Drop the new column, FK, and index
op.drop_constraint(
'fk_activation_sessions_network_access_request',
'activation_sessions',
type_='foreignkey',
)
op.drop_index(
'ix_activation_sessions_network_access_request_id',
table_name='activation_sessions',
)
op.drop_column('activation_sessions', 'network_access_request_id')
# 6d. Alter the old column to NOT NULL
op.alter_column(
'activation_sessions',
'device_network_membership_id',
nullable=False,
)
# 6d. Recreate the old foreign key
op.create_foreign_key(
None,
'activation_sessions',
'device_network_memberships',
['device_network_membership_id'],
['id'],
)
# 6e. Recreate the old index
op.create_index(
'ix_activation_sessions_device_network_membership_id',
'activation_sessions',
['device_network_membership_id'],
unique=False,
)
# ------------------------------------------------------------------
# Step 7: Restore zerotier_memberships FK
# ------------------------------------------------------------------
# 7a. Add the old column (nullable first so we can populate)
op.add_column(
'zerotier_memberships',
sa.Column('device_network_membership_id', sa.String(length=36), nullable=True),
)
# 7b. Populate the old column from the new column before it disappears
op.execute(
"""
UPDATE zerotier_memberships
SET device_network_membership_id = network_access_request_id
WHERE network_access_request_id IS NOT NULL;
"""
)
# 7c. Drop the new column, FK, and index
op.drop_constraint(
'fk_zerotier_memberships_network_access_request',
'zerotier_memberships',
type_='foreignkey',
)
op.drop_index(
'ix_zerotier_memberships_network_access_request_id',
table_name='zerotier_memberships',
)
op.drop_column('zerotier_memberships', 'network_access_request_id')
# 7d. Recreate the old foreign key
op.create_foreign_key(
None,
'zerotier_memberships',
'device_network_memberships',
['device_network_membership_id'],
['id'],
)
# 7e. Recreate the old index
op.create_index(
'ix_zerotier_memberships_device_network_membership_id',
'zerotier_memberships',
['device_network_membership_id'],
unique=False,
)
# ------------------------------------------------------------------
# Step 8: Drop the new network_access_requests table and indexes
# ------------------------------------------------------------------
op.drop_index(
'ix_network_access_requests_user_id',
table_name='network_access_requests',
)
op.drop_index(
'ix_network_access_requests_status',
table_name='network_access_requests',
)
op.drop_index(
'ix_network_access_requests_portal_network_id',
table_name='network_access_requests',
)
op.drop_index(
'ix_network_access_requests_organization_id',
table_name='network_access_requests',
)
op.drop_index(
'ix_network_access_requests_device_id',
table_name='network_access_requests',
)
op.drop_table('network_access_requests')
+15
View File
@@ -48,6 +48,21 @@ class AdminClient:
data={"confirm": confirm},
)
def get_user_ssh_certificates(self, user_id: str, **params) -> dict:
"""List all SSH certificates for a user (admin view).
Args:
user_id: Target user ID
**params: Optional query parameters status, active, cert_type, page, per_page
"""
path = f"/admin/users/{user_id}/ssh-certificates"
if params:
from urllib.parse import urlencode
query = urlencode({k: v for k, v in params.items() if v is not None})
if query:
path = f"{path}?{query}"
return self._client.get(path)
def list_audit_logs(self) -> dict:
"""List system-wide audit logs."""
return self._client.get("/audit-logs")
+306
View File
@@ -211,3 +211,309 @@ class TestAdminUserManagement:
with pytest.raises(ApiError) as exc_info:
integration_client.auth.login(email=victim["email"], password="VictimPass123!")
assert exc_info.value.status_code in (400, 401)
class TestAdminSSHCertificates:
"""Test admin SSH certificate listing endpoints."""
def _create_test_cert(
self, integration_app, user_id: str, ca_id: str, *, ssh_key_id=None,
status="issued", revoked=False, valid_after=None, valid_before=None,
cert_type="user", principals=None,
):
"""Create a test SSH certificate record."""
from datetime import datetime, timezone, timedelta
from gatehouse_app.models.ssh_ca.ssh_certificate import SSHCertificate, CertificateStatus
from gatehouse_app.models.ssh_ca.ca import CertType
now = datetime.now(timezone.utc)
valid_after = valid_after or (now - timedelta(hours=1))
valid_before = valid_before or (now + timedelta(hours=23))
principals = principals or ["prod-servers"]
with integration_app.app_context():
cert = SSHCertificate(
ca_id=ca_id,
user_id=user_id,
ssh_key_id=ssh_key_id,
certificate=f"ssh-ed25519-cert-v01@openssh.com AAAA...test_serial_{uuid.uuid4().hex[:8]}",
serial=str(uuid.uuid4().int)[:20],
key_id=f"test@example.com-{uuid.uuid4().hex[:8]}",
cert_type=CertType(cert_type),
principals=principals,
valid_after=valid_after,
valid_before=valid_before,
revoked=revoked,
status=CertificateStatus(status),
request_ip="192.168.1.100",
request_user_agent="OpenSSH_9.0",
)
if revoked:
cert.revoked_at = now
cert.revoke_reason = "test revocation"
db.session.add(cert)
db.session.commit()
return str(cert.id)
def _create_test_ssh_key(self, integration_app, user_id: str, fingerprint: str = None):
"""Create a test SSH key record."""
from gatehouse_app.models.ssh_ca.ssh_key import SSHKey
fingerprint = fingerprint or f"SHA256:{uuid.uuid4().hex[:43]}"
with integration_app.app_context():
key = SSHKey(
user_id=user_id,
payload=f"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI...test",
fingerprint=fingerprint,
description="Test laptop key",
verified=True,
key_type="ssh-ed25519",
key_bits=256,
key_comment="test@laptop",
)
db.session.add(key)
db.session.commit()
return str(key.id)
def test_list_user_ssh_certs_positive(self, integration_app, integration_client, create_test_user, create_test_org, create_test_membership, create_test_ca):
"""TEST: ADMIN-SSH-01 — List all SSH certificates for a user as admin.
WHAT: Create a user with two certs (one active, one expired),
admin lists all certs via the new endpoint.
WHY: Admin needs full visibility of user SSH certificate history.
EXPECTED: 200 OK with certificates array containing both certs.
"""
admin = create_test_user(password="AdminPass123!")
victim = create_test_user(password="VictimPass123!")
org = create_test_org()
create_test_membership(admin["id"], org["id"], OrganizationRole.OWNER)
create_test_membership(victim["id"], org["id"], OrganizationRole.MEMBER)
ca = create_test_ca(org_id=org["id"])
from datetime import datetime, timezone, timedelta
now = datetime.now(timezone.utc)
# Create an active cert
self._create_test_cert(
integration_app, victim["id"], ca["id"],
status="issued", valid_after=now - timedelta(hours=1),
valid_before=now + timedelta(hours=23),
)
# Create an expired cert
self._create_test_cert(
integration_app, victim["id"], ca["id"],
status="expired", valid_after=now - timedelta(days=7),
valid_before=now - timedelta(days=1),
)
integration_client.auth.login(email=admin["email"], password="AdminPass123!")
result = integration_client.admin.get_user_ssh_certificates(victim["id"])
data = assert_success(result)
assert "certificates" in data
assert data["count"] == 2
assert len(data["certificates"]) == 2
def test_list_user_ssh_certs_with_key_metadata(self, integration_app, integration_client, create_test_user, create_test_org, create_test_membership, create_test_ca):
"""TEST: ADMIN-SSH-02 — Certificate includes SSH key metadata.
WHAT: Create a cert linked to an SSH key, verify key details
appear in the response.
WHY: Admin needs to see which key was used to request the cert.
EXPECTED: ssh_key object with fingerprint, key_type, key_bits.
"""
admin = create_test_user(password="AdminPass123!")
victim = create_test_user(password="VictimPass123!")
org = create_test_org()
create_test_membership(admin["id"], org["id"], OrganizationRole.OWNER)
create_test_membership(victim["id"], org["id"], OrganizationRole.MEMBER)
ca = create_test_ca(org_id=org["id"])
key_id = self._create_test_ssh_key(integration_app, victim["id"])
self._create_test_cert(integration_app, victim["id"], ca["id"], ssh_key_id=key_id)
integration_client.auth.login(email=admin["email"], password="AdminPass123!")
result = integration_client.admin.get_user_ssh_certificates(victim["id"])
data = assert_success(result)
cert = data["certificates"][0]
assert cert["ssh_key"] is not None
assert cert["ssh_key"]["key_type"] == "ssh-ed25519"
assert cert["ssh_key"]["fingerprint"] is not None
assert cert["ssh_key"]["description"] == "Test laptop key"
def test_list_user_ssh_certs_non_admin_negative(self, integration_app, integration_client, create_test_user, create_test_org, create_test_membership, create_test_ca):
"""TEST: ADMIN-SSH-03 — Non-admin cannot list another user's certs.
WHAT: Regular member tries to list admin's certs.
WHY: Certificate data is sensitive and admin-only.
EXPECTED: 403 Forbidden.
"""
member = create_test_user(password="MemberPass123!")
admin_user = create_test_user(password="AdminPass123!")
org = create_test_org()
create_test_membership(member["id"], org["id"], OrganizationRole.MEMBER)
create_test_membership(admin_user["id"], org["id"], OrganizationRole.OWNER)
integration_client.auth.login(email=member["email"], password="MemberPass123!")
with pytest.raises(ApiError) as exc_info:
integration_client.admin.get_user_ssh_certificates(admin_user["id"])
assert exc_info.value.status_code == 403
def test_list_user_ssh_certs_filter_by_status(self, integration_app, integration_client, create_test_user, create_test_org, create_test_membership, create_test_ca):
"""TEST: ADMIN-SSH-04 — Filter certificates by status.
WHAT: Create certs with different statuses, filter by status=revoked.
WHY: Admin may want to see only revoked certs to audit access.
EXPECTED: Only revoked certs returned.
"""
admin = create_test_user(password="AdminPass123!")
victim = create_test_user(password="VictimPass123!")
org = create_test_org()
create_test_membership(admin["id"], org["id"], OrganizationRole.OWNER)
create_test_membership(victim["id"], org["id"], OrganizationRole.MEMBER)
ca = create_test_ca(org_id=org["id"])
from datetime import datetime, timezone, timedelta
now = datetime.now(timezone.utc)
self._create_test_cert(integration_app, victim["id"], ca["id"], status="issued")
self._create_test_cert(integration_app, victim["id"], ca["id"], status="revoked", revoked=True)
self._create_test_cert(integration_app, victim["id"], ca["id"], status="expired")
integration_client.auth.login(email=admin["email"], password="AdminPass123!")
result = integration_client.admin.get_user_ssh_certificates(victim["id"], status="revoked")
data = assert_success(result)
assert data["count"] == 1
assert data["certificates"][0]["status"] == "revoked"
assert data["certificates"][0]["revoked"] is True
def test_list_user_ssh_certs_filter_active_only(self, integration_app, integration_client, create_test_user, create_test_org, create_test_membership, create_test_ca):
"""TEST: ADMIN-SSH-05 — Filter for only currently valid certificates.
WHAT: Create active and expired certs, filter by active=true.
WHY: Admin needs quick view of currently active certs.
EXPECTED: Only valid (non-revoked, non-expired) certs returned.
"""
admin = create_test_user(password="AdminPass123!")
victim = create_test_user(password="VictimPass123!")
org = create_test_org()
create_test_membership(admin["id"], org["id"], OrganizationRole.OWNER)
create_test_membership(victim["id"], org["id"], OrganizationRole.MEMBER)
ca = create_test_ca(org_id=org["id"])
from datetime import datetime, timezone, timedelta
now = datetime.now(timezone.utc)
self._create_test_cert(
integration_app, victim["id"], ca["id"], status="issued",
valid_after=now - timedelta(hours=1), valid_before=now + timedelta(hours=23),
)
self._create_test_cert(
integration_app, victim["id"], ca["id"], status="expired",
valid_after=now - timedelta(days=7), valid_before=now - timedelta(days=1),
)
self._create_test_cert(
integration_app, victim["id"], ca["id"], status="revoked", revoked=True,
valid_after=now - timedelta(hours=1), valid_before=now + timedelta(hours=23),
)
integration_client.auth.login(email=admin["email"], password="AdminPass123!")
result = integration_client.admin.get_user_ssh_certificates(victim["id"], active="true")
data = assert_success(result)
assert data["count"] == 1
cert = data["certificates"][0]
assert cert["is_valid"] is True
assert cert["revoked"] is False
def test_list_user_ssh_certs_user_not_found(self, integration_app, integration_client, create_test_user, create_test_org, create_test_membership):
"""TEST: ADMIN-SSH-06 — Return 404 for non-existent user.
WHAT: Admin requests certs for a user ID that doesn't exist.
WHY: Clear error for missing resources.
EXPECTED: 404 NOT_FOUND.
"""
admin = create_test_user(password="AdminPass123!")
org = create_test_org()
create_test_membership(admin["id"], org["id"], OrganizationRole.OWNER)
integration_client.auth.login(email=admin["email"], password="AdminPass123!")
with pytest.raises(ApiError) as exc_info:
integration_client.admin.get_user_ssh_certificates("non-existent-user-id")
assert exc_info.value.status_code == 404
assert exc_info.value.error_type == "NOT_FOUND"
def test_list_user_ssh_certs_empty_result(self, integration_app, integration_client, create_test_user, create_test_org, create_test_membership):
"""TEST: ADMIN-SSH-07 — Empty result when user has no certs.
WHAT: Admin lists certs for a user who has never requested one.
WHY: Endpoint should handle gracefully, not error.
EXPECTED: 200 OK with empty certificates array and count=0.
"""
admin = create_test_user(password="AdminPass123!")
victim = create_test_user(password="VictimPass123!")
org = create_test_org()
create_test_membership(admin["id"], org["id"], OrganizationRole.OWNER)
create_test_membership(victim["id"], org["id"], OrganizationRole.MEMBER)
integration_client.auth.login(email=admin["email"], password="AdminPass123!")
result = integration_client.admin.get_user_ssh_certificates(victim["id"])
data = assert_success(result)
assert data["certificates"] == []
assert data["count"] == 0
def test_list_user_ssh_certs_revoked_cert_details(self, integration_app, integration_client, create_test_user, create_test_org, create_test_membership, create_test_ca):
"""TEST: ADMIN-SSH-08 — Revoked certificate shows revocation details.
WHAT: Create a revoked cert, verify revoke metadata is present.
WHY: Admin needs to know when and why a cert was revoked.
EXPECTED: revoked=True, revoked_at populated, revoke_reason present.
"""
admin = create_test_user(password="AdminPass123!")
victim = create_test_user(password="VictimPass123!")
org = create_test_org()
create_test_membership(admin["id"], org["id"], OrganizationRole.OWNER)
create_test_membership(victim["id"], org["id"], OrganizationRole.MEMBER)
ca = create_test_ca(org_id=org["id"])
self._create_test_cert(
integration_app, victim["id"], ca["id"],
status="revoked", revoked=True,
)
integration_client.auth.login(email=admin["email"], password="AdminPass123!")
result = integration_client.admin.get_user_ssh_certificates(victim["id"])
data = assert_success(result)
cert = data["certificates"][0]
assert cert["revoked"] is True
assert cert["revoked_at"] is not None
assert cert["revoke_reason"] == "test revocation"
assert cert["status"] == "revoked"
def test_list_user_ssh_certs_invalid_status_filter(self, integration_app, integration_client, create_test_user, create_test_org, create_test_membership):
"""TEST: ADMIN-SSH-09 — Invalid status filter returns 400.
WHAT: Admin passes an invalid status value.
WHY: Input validation prevents confusing queries.
EXPECTED: 400 VALIDATION_ERROR.
"""
admin = create_test_user(password="AdminPass123!")
victim = create_test_user(password="VictimPass123!")
org = create_test_org()
create_test_membership(admin["id"], org["id"], OrganizationRole.OWNER)
create_test_membership(victim["id"], org["id"], OrganizationRole.MEMBER)
integration_client.auth.login(email=admin["email"], password="AdminPass123!")
with pytest.raises(ApiError) as exc_info:
integration_client.admin.get_user_ssh_certificates(victim["id"], status="bogus")
assert exc_info.value.status_code == 400
assert exc_info.value.error_type == "VALIDATION_ERROR"
+142
View File
@@ -201,3 +201,145 @@ class TestZeroTierMembership:
except ApiError as exc:
# Accept errors when no active memberships to kill
assert exc.status_code in (400, 500)
class TestAdminUserDevices:
"""Test admin endpoint to list devices for a specific user."""
def test_list_user_devices_positive(
self, integration_client, create_test_user, create_test_org, create_test_membership, integration_app
):
"""TEST: ZT-10 — Admin lists devices for a user with devices.
WHAT: Admin GET /organizations/<id>/users/<user_id>/devices.
WHY: Admins need to see what devices a user has registered.
EXPECTED: 200 OK with devices array.
"""
from gatehouse_app.models.zerotier.device import Device
admin = create_test_user(password="AdminPass123!")
member = create_test_user(password="MemberPass123!")
org = create_test_org()
create_test_membership(admin["id"], org["id"], OrganizationRole.ADMIN)
create_test_membership(member["id"], org["id"], OrganizationRole.MEMBER)
# Create test devices for the member
from gatehouse_app.extensions import db as _db
with integration_app.app_context():
device1 = Device(
user_id=member["id"],
organization_id=org["id"],
node_id="1234567890",
device_nickname="Member Laptop",
hostname="member-laptop",
)
device2 = Device(
user_id=member["id"],
organization_id=org["id"],
node_id="0987654321",
device_nickname="Member Phone",
hostname="member-phone",
)
_db.session.add_all([device1, device2])
_db.session.commit()
integration_client.auth.login(email=admin["email"], password="AdminPass123!")
result = integration_client.get(f"/organizations/{org['id']}/users/{member['id']}/devices")
data = assert_success(result, "devices retrieved")
assert "devices" in data
assert data["count"] == 2
assert data["user_id"] == member["id"]
assert data["organization_id"] == org["id"]
device_node_ids = [d["node_id"] for d in data["devices"]]
assert "1234567890" in device_node_ids
assert "0987654321" in device_node_ids
def test_list_user_devices_no_devices(
self, integration_client, create_test_user, create_test_org, create_test_membership
):
"""TEST: ZT-11 — Admin lists devices for a user with no devices.
WHAT: Admin GET /organizations/<id>/users/<user_id>/devices for user with no devices.
WHY: Endpoint should return empty list, not error.
EXPECTED: 200 OK with empty devices array.
"""
admin = create_test_user(password="AdminPass123!")
member = create_test_user(password="MemberPass123!")
org = create_test_org()
create_test_membership(admin["id"], org["id"], OrganizationRole.ADMIN)
create_test_membership(member["id"], org["id"], OrganizationRole.MEMBER)
integration_client.auth.login(email=admin["email"], password="AdminPass123!")
result = integration_client.get(f"/organizations/{org['id']}/users/{member['id']}/devices")
data = assert_success(result)
assert data["count"] == 0
assert data["devices"] == []
def test_list_user_devices_non_admin_negative(
self, integration_client, create_test_user, create_test_org, create_test_membership
):
"""TEST: ZT-12 — Non-admin cannot list another user's devices.
WHAT: Member attempts GET /organizations/<id>/users/<user_id>/devices.
WHY: This endpoint is admin-only.
EXPECTED: 403 Forbidden.
"""
member1 = create_test_user(password="Member1Pass123!")
member2 = create_test_user(password="Member2Pass123!")
org = create_test_org()
create_test_membership(member1["id"], org["id"], OrganizationRole.MEMBER)
create_test_membership(member2["id"], org["id"], OrganizationRole.MEMBER)
integration_client.auth.login(email=member1["email"], password="Member1Pass123!")
with pytest.raises(ApiError) as exc_info:
integration_client.get(f"/organizations/{org['id']}/users/{member2['id']}/devices")
assert exc_info.value.status_code == 403
def test_list_user_devices_user_not_in_org_negative(
self, integration_client, create_test_user, create_test_org, create_test_membership
):
"""TEST: ZT-13 — Cannot list devices for user not in organization.
WHAT: Admin GET /organizations/<id>/users/<user_id>/devices for user not in org.
WHY: User must be a member of the organization.
EXPECTED: 404 Not Found.
"""
admin = create_test_user(password="AdminPass123!")
outside_user = create_test_user(password="OutsidePass123!")
org = create_test_org()
create_test_membership(admin["id"], org["id"], OrganizationRole.ADMIN)
# outside_user is NOT added to the org
integration_client.auth.login(email=admin["email"], password="AdminPass123!")
with pytest.raises(ApiError) as exc_info:
integration_client.get(f"/organizations/{org['id']}/users/{outside_user['id']}/devices")
assert exc_info.value.status_code == 404
def test_list_user_devices_user_not_found_negative(
self, integration_client, create_test_user, create_test_org, create_test_membership
):
"""TEST: ZT-14 — Cannot list devices for non-existent user.
WHAT: Admin GET /organizations/<id>/users/<non_existent_id>/devices.
WHY: User must exist.
EXPECTED: 404 Not Found.
"""
import uuid
admin = create_test_user(password="AdminPass123!")
org = create_test_org()
create_test_membership(admin["id"], org["id"], OrganizationRole.ADMIN)
non_existent_id = str(uuid.uuid4())
integration_client.auth.login(email=admin["email"], password="AdminPass123!")
with pytest.raises(ApiError) as exc_info:
integration_client.get(f"/organizations/{org['id']}/users/{non_existent_id}/devices")
assert exc_info.value.status_code == 404
@@ -0,0 +1,88 @@
"""Verify the structure of the Alembic migration that merges
user_network_approvals and device_network_memberships into network_access_requests.
These are STRUCTURAL tests only no database connection is required.
"""
import importlib
import importlib.util
import os
import sys
# ── helpers ────────────────────────────────────────────────────────────────
def _load_migration_module():
"""Load the migration module by file path without executing Alembic."""
migration_path = os.path.join(
os.path.dirname(__file__),
'..', '..', 'migrations', 'versions',
'merge_approval_membership_tables.py',
)
migration_path = os.path.abspath(migration_path)
spec = importlib.util.spec_from_file_location(
'merge_approval_membership_tables', migration_path,
)
assert spec is not None, f'Could not create module spec for {migration_path}'
assert spec.loader is not None, f'Module spec has no loader for {migration_path}'
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
# ── structural tests ───────────────────────────────────────────────────────
def test_migration_file_can_be_imported():
"""The migration module MUST import without raising any exception."""
mod = _load_migration_module()
assert mod is not None
def test_upgrade_function_exists():
"""upgrade() must be a callable in the module."""
mod = _load_migration_module()
assert hasattr(mod, 'upgrade'), 'module is missing upgrade()'
assert callable(mod.upgrade), 'upgrade is not callable'
def test_downgrade_function_exists():
"""downgrade() must be a callable in the module."""
mod = _load_migration_module()
assert hasattr(mod, 'downgrade'), 'module is missing downgrade()'
assert callable(mod.downgrade), 'downgrade is not callable'
def test_revision_is_set_correctly():
"""revision must equal the documented value 'c0a1b2c3d4e5'."""
mod = _load_migration_module()
assert hasattr(mod, 'revision'), 'module is missing revision'
assert mod.revision == 'c0a1b2c3d4e5', (
f"Expected revision 'c0a1b2c3d4e5', got '{mod.revision}'"
)
def test_down_revision_is_set_correctly():
"""down_revision must equal the documented value 'a1b2c3d4e5f6'."""
mod = _load_migration_module()
assert hasattr(mod, 'down_revision'), 'module is missing down_revision'
assert mod.down_revision == 'a1b2c3d4e5f6', (
f"Expected down_revision 'a1b2c3d4e5f6', got '{mod.down_revision}'"
)
def test_branch_labels_is_none():
"""branch_labels should be None for a standard linear migration."""
mod = _load_migration_module()
assert mod.branch_labels is None, (
f"Expected branch_labels None, got {mod.branch_labels!r}"
)
def test_depends_on_is_none():
"""depends_on should be None — this migration has no cross-dependencies."""
mod = _load_migration_module()
assert mod.depends_on is None, (
f"Expected depends_on None, got {mod.depends_on!r}"
)
@@ -0,0 +1,340 @@
"""Unit tests for NetworkAccessRequest model structure.
WHAT: Verifies the model class can be imported, has the expected columns,
constraints, and enum types.
WHY: Structural correctness of the model is a prerequisite for Phase 2+
work; catching missing columns or constraints early prevents
migration/runtime failures.
APPROACH: gatehouse_app/__init__.py calls create_app() at module level which
requires psycopg2 (PostgreSQL driver). We prevent this by pre-loading
gatehouse_app as a bare namespace package, then selectively providing
the real submodules (utils.constants) and fakes (extensions, models.base).
We do NOT call db.create_all() the table metadata is fully populated
during class definition. FK target tables don't exist in our test
metadata, so we check FK presence without table resolution.
"""
import sys
import importlib.util
import pytest
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
# ═══════════════════════════════════════════════════════════════════════════════
# Step 1: Pre-load gatehouse_app as a bare namespace (prevents __init__.py)
# ═══════════════════════════════════════════════════════════════════════════════
_gatehouse = type(sys)("gatehouse_app")
_gatehouse.__path__ = []
sys.modules["gatehouse_app"] = _gatehouse
# ═══════════════════════════════════════════════════════════════════════════════
# Step 2: Load the real gatehouse_app.utils.constants (self-contained, no deps)
# ═══════════════════════════════════════════════════════════════════════════════
_constants_spec = importlib.util.spec_from_file_location(
"gatehouse_app.utils.constants",
"/home/ubuntu/securid/gatehouse-api/gatehouse_app/utils/constants.py",
submodule_search_locations=[],
)
_constants_mod = importlib.util.module_from_spec(_constants_spec)
sys.modules["gatehouse_app.utils"] = type(sys)("gatehouse_app.utils")
sys.modules["gatehouse_app.utils.constants"] = _constants_mod
_constants_spec.loader.exec_module(_constants_mod)
ApprovalGrantType = _constants_mod.ApprovalGrantType
ApprovalState = _constants_mod.ApprovalState
# ═══════════════════════════════════════════════════════════════════════════════
# Step 3: Build fake extensions.db and models.base
# ═══════════════════════════════════════════════════════════════════════════════
_fake_db = SQLAlchemy()
class FakeBaseModel(_fake_db.Model):
"""Minimal BaseModel matching the real one's column definitions."""
__abstract__ = True
id = _fake_db.Column(_fake_db.String(36), primary_key=True, default=lambda: "test-uuid", nullable=False)
created_at = _fake_db.Column(_fake_db.DateTime, nullable=False)
updated_at = _fake_db.Column(_fake_db.DateTime, nullable=False)
deleted_at = _fake_db.Column(_fake_db.DateTime, nullable=True)
def to_dict(self, exclude=None):
"""Mimic the real BaseModel.to_dict — iterates __table__.columns."""
from datetime import datetime, timezone
exclude = exclude or []
result = {}
for column in self.__table__.columns:
if column.name not in exclude:
value = getattr(self, column.name)
if isinstance(value, datetime):
result[column.name] = value.isoformat()
else:
result[column.name] = value
return result
_fake_extensions = type(sys)("gatehouse_app.extensions")
_fake_extensions.db = _fake_db
_fake_models_base = type(sys)("gatehouse_app.models.base")
_fake_models_base.BaseModel = FakeBaseModel
sys.modules["gatehouse_app.extensions"] = _fake_extensions
sys.modules["gatehouse_app.models"] = type(sys)("gatehouse_app.models")
sys.modules["gatehouse_app.models.base"] = _fake_models_base
# ═══════════════════════════════════════════════════════════════════════════════
# Step 3b: Create stub models for relationship targets so ORM mapper
# can resolve 'Organization', 'User', 'Device', 'PortalNetwork'
# ═══════════════════════════════════════════════════════════════════════════════
class Organization(_fake_db.Model):
__tablename__ = "organizations"
id = _fake_db.Column(_fake_db.String(36), primary_key=True)
class User(_fake_db.Model):
__tablename__ = "users"
id = _fake_db.Column(_fake_db.String(36), primary_key=True)
class Device(_fake_db.Model):
__tablename__ = "devices"
id = _fake_db.Column(_fake_db.String(36), primary_key=True)
class PortalNetwork(_fake_db.Model):
__tablename__ = "portal_networks"
id = _fake_db.Column(_fake_db.String(36), primary_key=True)
# ═══════════════════════════════════════════════════════════════════════════════
# Step 4: Load the real network_access_request module from file
# ═══════════════════════════════════════════════════════════════════════════════
_model_spec = importlib.util.spec_from_file_location(
"gatehouse_app.models.zerotier.network_access_request",
"/home/ubuntu/securid/gatehouse-api/gatehouse_app/models/zerotier/network_access_request.py",
submodule_search_locations=[],
)
_model_mod = importlib.util.module_from_spec(_model_spec)
sys.modules["gatehouse_app.models.zerotier"] = type(sys)("gatehouse_app.models.zerotier")
sys.modules["gatehouse_app.models.zerotier.network_access_request"] = _model_mod
_model_spec.loader.exec_module(_model_mod)
NetworkAccessRequest = _model_mod.NetworkAccessRequest
# ═══════════════════════════════════════════════════════════════════════════════
# Fixture
# ═══════════════════════════════════════════════════════════════════════════════
@pytest.fixture(scope="module")
def model_class():
"""Return the model class — table metadata is already built at definition time."""
return NetworkAccessRequest
@pytest.fixture(scope="module")
def app():
"""Minimal Flask app for to_dict (BaseModel.to_dict iterates __table__.columns)."""
app = Flask(__name__)
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:"
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
_fake_db.init_app(app)
return app
# ═══════════════════════════════════════════════════════════════════════════════
# Test data
# ═══════════════════════════════════════════════════════════════════════════════
EXPECTED_LOCAL_COLUMNS = {
"organization_id", "user_id", "device_id", "portal_network_id",
"granted_by_user_id", "grant_type", "status", "active",
"justification", "join_seen",
}
EXPECTED_INHERITED_COLUMNS = {"id", "created_at", "updated_at", "deleted_at"}
ALL_EXPECTED = EXPECTED_LOCAL_COLUMNS | EXPECTED_INHERITED_COLUMNS
# FK columns that should have foreign keys (table name, FK target)
EXPECTED_FKS = {
"organization_id": "organizations.id",
"user_id": "users.id",
"device_id": "devices.id",
"portal_network_id": "portal_networks.id",
"granted_by_user_id": "users.id",
}
# ═══════════════════════════════════════════════════════════════════════════════
# Test: Module importability
# ═══════════════════════════════════════════════════════════════════════════════
class TestImport:
def test_model_importable(self, model_class):
assert model_class is not None
assert isinstance(model_class, type)
def test_model_tablename(self, model_class):
assert model_class.__tablename__ == "network_access_requests"
def test_model_inherits_base(self, model_class):
assert issubclass(model_class, FakeBaseModel)
# ═══════════════════════════════════════════════════════════════════════════════
# Test: Columns
# ═══════════════════════════════════════════════════════════════════════════════
class TestColumns:
def test_all_expected_columns_present(self, model_class):
actual = {c.name for c in model_class.__table__.columns}
missing = ALL_EXPECTED - actual
assert missing == set(), f"Missing columns: {missing}"
def test_no_extra_columns(self, model_class):
actual = {c.name for c in model_class.__table__.columns}
extra = actual - ALL_EXPECTED
assert extra == set(), f"Unexpected columns: {extra}"
def test_exact_column_count(self, model_class):
assert len(model_class.__table__.columns) == 14, (
f"Expected 14 columns, got {len(model_class.__table__.columns)}: "
f"{sorted(c.name for c in model_class.__table__.columns)}"
)
def test_organization_id_is_fk_string_not_null(self, model_class):
col = model_class.__table__.columns["organization_id"]
assert not col.nullable
assert _has_foreign_key(col)
def test_user_id_is_fk_string_not_null(self, model_class):
col = model_class.__table__.columns["user_id"]
assert not col.nullable
assert _has_foreign_key(col)
def test_device_id_is_fk_string_not_null(self, model_class):
col = model_class.__table__.columns["device_id"]
assert not col.nullable
assert _has_foreign_key(col)
def test_portal_network_id_is_fk_string_not_null(self, model_class):
col = model_class.__table__.columns["portal_network_id"]
assert not col.nullable
assert _has_foreign_key(col)
def test_granted_by_user_id_nullable_fk(self, model_class):
col = model_class.__table__.columns["granted_by_user_id"]
assert col.nullable
assert _has_foreign_key(col)
def test_justification_is_text_nullable(self, model_class):
col = model_class.__table__.columns["justification"]
assert col.nullable
assert "TEXT" in str(col.type).upper()
def test_active_is_boolean_not_null(self, model_class):
col = model_class.__table__.columns["active"]
assert str(col.type) in ("BOOLEAN", "INTEGER")
assert not col.nullable
def test_join_seen_is_boolean_not_null(self, model_class):
col = model_class.__table__.columns["join_seen"]
assert str(col.type) in ("BOOLEAN", "INTEGER")
assert not col.nullable
def test_fk_count(self, model_class):
"""Verify exactly the expected FK columns have foreign keys."""
fk_cols = {c.name for c in model_class.__table__.columns if _has_foreign_key(c)}
assert fk_cols == set(EXPECTED_FKS.keys()), (
f"FK columns {sorted(fk_cols)} != expected {sorted(EXPECTED_FKS.keys())}"
)
def _has_foreign_key(column):
"""Check if column has at least one ForeignKey, without resolving target table."""
return bool(column.foreign_keys)
# ═══════════════════════════════════════════════════════════════════════════════
# Test: UniqueConstraint
# ═══════════════════════════════════════════════════════════════════════════════
class TestConstraints:
def test_unique_constraint_exists(self, model_class):
from sqlalchemy import UniqueConstraint
ucs = [c for c in model_class.__table__.constraints if isinstance(c, UniqueConstraint)]
assert len(ucs) >= 1, "No UniqueConstraint found"
def test_unique_constraint_columns(self, model_class):
from sqlalchemy import UniqueConstraint
ucs = [c for c in model_class.__table__.constraints if isinstance(c, UniqueConstraint)]
assert len(ucs) == 1, f"Expected 1, found {len(ucs)}"
cols = {col.name for col in ucs[0].columns}
expected = {"user_id", "device_id", "portal_network_id", "deleted_at"}
assert cols == expected, f"UniqueConstraint columns {cols} != {expected}"
def test_unique_constraint_name(self, model_class):
from sqlalchemy import UniqueConstraint
ucs = [c for c in model_class.__table__.constraints if isinstance(c, UniqueConstraint)]
assert len(ucs) == 1
assert ucs[0].name == "uix_user_device_network", (
f"Expected 'uix_user_device_network', got '{ucs[0].name}'"
)
# ═══════════════════════════════════════════════════════════════════════════════
# Test: Enum types
# ═══════════════════════════════════════════════════════════════════════════════
class TestEnumTypes:
def test_status_column_uses_approval_state_enum(self, model_class):
col = model_class.__table__.columns["status"]
assert hasattr(col.type, "enum_class"), (
f"status column type {type(col.type)} has no enum_class"
)
assert col.type.enum_class is ApprovalState, (
f"status enum is {col.type.enum_class}, expected ApprovalState"
)
def test_grant_type_column_uses_approval_grant_type_enum(self, model_class):
col = model_class.__table__.columns["grant_type"]
assert hasattr(col.type, "enum_class"), (
f"grant_type column type {type(col.type)} has no enum_class"
)
assert col.type.enum_class is ApprovalGrantType, (
f"grant_type enum is {col.type.enum_class}, expected ApprovalGrantType"
)
def test_status_column_not_nullable(self, model_class):
assert not model_class.__table__.columns["status"].nullable
def test_grant_type_column_not_nullable(self, model_class):
assert not model_class.__table__.columns["grant_type"].nullable
# ═══════════════════════════════════════════════════════════════════════════════
# Test: Properties and methods
# ═══════════════════════════════════════════════════════════════════════════════
class TestMethods:
def test_repr_returns_string(self, model_class):
instance = model_class()
result = repr(instance)
assert isinstance(result, str)
assert "NetworkAccessRequest" in result
def test_active_session_property_returns_none(self, model_class):
instance = model_class()
assert instance.active_session is None
def test_to_dict_returns_dict(self, model_class, app):
with app.app_context():
instance = model_class()
result = instance.to_dict()
assert isinstance(result, dict)
for col_name in EXPECTED_LOCAL_COLUMNS:
assert col_name in result, f"Missing '{col_name}' in to_dict output"