diff --git a/.env.example b/.env.example index 4d1bf81..204b2bf 100644 --- a/.env.example +++ b/.env.example @@ -53,3 +53,6 @@ SMTP_USERNAME= SMTP_PASSWORD= FROM_ADDRESS= WEBAUTHN_ORIGIN= + +ZEROTIER_API_TOKEN= +ZEROTIER_API_URL= \ No newline at end of file diff --git a/.gitignore b/.gitignore index 747bd85..306cd4a 100644 --- a/.gitignore +++ b/.gitignore @@ -136,3 +136,4 @@ Thumbs.db # Project specific *.db +flask_session/ \ No newline at end of file diff --git a/config/base.py b/config/base.py index 73a4d7f..b940767 100644 --- a/config/base.py +++ b/config/base.py @@ -129,6 +129,20 @@ class BaseConfig: # Frontend URL (for OAuth callback redirects) FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:8080") + # ZeroTier Configuration + ZEROTIER_API_TOKEN = os.getenv("ZEROTIER_API_TOKEN", "") + ZEROTIER_API_URL = os.getenv( + "ZEROTIER_API_URL", + "http://localhost:9993", + ) + ZEROTIER_API_MODE = os.getenv("ZEROTIER_API_MODE", "controller").lower() + ZEROTIER_DEFAULT_ACTIVATION_LIFETIME_MINUTES = int( + os.getenv("ZEROTIER_DEFAULT_ACTIVATION_LIFETIME_MINUTES", "480") + ) + ZEROTIER_RECONCILIATION_INTERVAL_SECONDS = int( + os.getenv("ZEROTIER_RECONCILIATION_INTERVAL_SECONDS", "120") + ) + # Email / SMTP EMAIL_ENABLED = os.getenv("EMAIL_ENABLED", "False").lower() == "true" SMTP_HOST = os.getenv("SMTP_HOST", "smtp.gmail.com") diff --git a/gatehouse_app/api/v1/__init__.py b/gatehouse_app/api/v1/__init__.py index 14534ed..a5f676c 100644 --- a/gatehouse_app/api/v1/__init__.py +++ b/gatehouse_app/api/v1/__init__.py @@ -5,6 +5,6 @@ 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 +from gatehouse_app.api.v1 import auth, users, organizations, policies, external_auth, departments, principals, ssh, zerotier api_v1_bp.register_blueprint(ssh.ssh_bp) diff --git a/gatehouse_app/api/v1/zerotier.py b/gatehouse_app/api/v1/zerotier.py new file mode 100644 index 0000000..1468ce1 --- /dev/null +++ b/gatehouse_app/api/v1/zerotier.py @@ -0,0 +1,860 @@ +"""ZeroTier network governance API 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.services import portal_network_service +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.models import ( + PortalNetwork, + Device, + DeviceNetworkMembership, + UserNetworkApproval, + ActivationSession, +) +from gatehouse_app.models.organization import Organization +from gatehouse_app.exceptions import ( + ValidationError as AppValidationError, + ZeroTierAPIError, + NetworkNotFoundError, + DeviceNotFoundError, + DeviceAlreadyExistsError, + ApprovalNotFoundError, + MembershipNotFoundError, +) + + +def _org_check(org_id): + org = Organization.query.filter_by(id=org_id, deleted_at=None).first() + if not org: + return None, api_response(success=False, message="Organization not found", status=404, error_type="NOT_FOUND") + if not org.is_member(g.current_user.id): + return None, api_response(success=False, message="Not a member of this organization", status=403, error_type="AUTHORIZATION_ERROR") + return org, None + + +# ── Schemas ─────────────────────────────────────────────────────────────────── + + +class CreateNetworkSchema(Schema): + name = fields.Str(required=True, validate=validate.Length(min=1, max=255)) + description = fields.Str(allow_none=True) + zerotier_network_id = fields.Str(required=True, validate=validate.Length(equal=16)) + environment = fields.Str(load_default="development") + request_mode = fields.Str(load_default="approval_required") + default_activation_lifetime_minutes = fields.Int(load_default=480) + max_activation_lifetime_minutes = fields.Int(allow_none=True, load_default=None) + + +class UpdateNetworkSchema(Schema): + name = fields.Str(validate=validate.Length(min=1, max=255)) + description = fields.Str(allow_none=True) + environment = fields.Str() + request_mode = fields.Str() + default_activation_lifetime_minutes = fields.Int() + max_activation_lifetime_minutes = fields.Int(allow_none=True) + is_active = fields.Bool() + + +class RegisterDeviceSchema(Schema): + node_id = fields.Str(required=True, validate=validate.Length(equal=10)) + nickname = fields.Str(allow_none=True, validate=validate.Length(max=255)) + hostname = fields.Str(allow_none=True, validate=validate.Length(max=255)) + asset_tag = fields.Str(allow_none=True, validate=validate.Length(max=255)) + serial_number = fields.Str(allow_none=True, validate=validate.Length(max=255)) + + +class UpdateDeviceSchema(Schema): + nickname = fields.Str(allow_none=True, validate=validate.Length(max=255)) + hostname = fields.Str(allow_none=True, validate=validate.Length(max=255)) + asset_tag = fields.Str(allow_none=True, validate=validate.Length(max=255)) + serial_number = fields.Str(allow_none=True, validate=validate.Length(max=255)) + + +class RequestAccessSchema(Schema): + portal_network_id = fields.Str(required=True) + device_id = fields.Str(required=True) + justification = fields.Str(allow_none=True, validate=validate.Length(max=1000)) + + +class AssignAccessSchema(Schema): + target_user_id = fields.Str(required=True) + portal_network_id = fields.Str(required=True) + justification = fields.Str(allow_none=True, validate=validate.Length(max=1000)) + + +class ActivateSchema(Schema): + lifetime_minutes = fields.Int(allow_none=True) + + +class KillSwitchSchema(Schema): + target_user_id = fields.Str(required=True) + scope = fields.Str(load_default="organization") + reason = fields.Str(allow_none=True, validate=validate.Length(max=500)) + network_ids = fields.List(fields.Str(), allow_none=True) + + +# ── Networks ────────────────────────────────────────────────────────────────── + + +@api_v1_bp.route("/organizations//networks", methods=["GET"]) +@login_required +@full_access_required +def list_networks(org_id): + """List portal networks for an organization.""" + org, err = _org_check(org_id) + if err: + return err + + include_inactive = request.args.get("include_inactive", "false").lower() == "true" + networks = portal_network_service.list_networks(org_id, include_inactive=include_inactive) + + return api_response( + data={"networks": [n.to_dict() for n in networks], "count": len(networks)}, + message="Networks retrieved successfully", + ) + + +@api_v1_bp.route("/organizations//networks", methods=["POST"]) +@login_required +@require_admin +@full_access_required +def create_network(org_id): + """Create a new portal network (admin only).""" + org, err = _org_check(org_id) + if err: + return err + + try: + schema = CreateNetworkSchema() + data = schema.load(request.json or {}) + except ValidationError as e: + return api_response(success=False, message="Validation failed", status=400, error_type="VALIDATION_ERROR", error_details=e.messages) + + try: + network = portal_network_service.create_network( + organization_id=org_id, + name=data["name"], + owner_user_id=g.current_user.id, + zerotier_network_id=data["zerotier_network_id"], + description=data.get("description"), + environment=data.get("environment"), + request_mode=data.get("request_mode"), + default_activation_lifetime_minutes=data.get("default_activation_lifetime_minutes", 480), + max_activation_lifetime_minutes=data.get("max_activation_lifetime_minutes"), + ) + return api_response(data={"network": network.to_dict()}, message="Network created successfully", status=201) + except AppValidationError as e: + return api_response(success=False, message=str(e.message), status=400, error_type=e.error_type) + except ZeroTierAPIError as e: + return api_response(success=False, message=str(e), status=502, error_type=e.error_type) + + +@api_v1_bp.route("/organizations//networks/", methods=["GET"]) +@login_required +@full_access_required +def get_network(org_id, network_id): + """Get a portal network by ID.""" + org, err = _org_check(org_id) + if err: + return err + + try: + network = portal_network_service.get_network(network_id, organization_id=org_id) + return api_response(data={"network": network.to_dict()}, message="Network retrieved successfully") + except NetworkNotFoundError as e: + return api_response(success=False, message=str(e), status=404, error_type=e.error_type) + + +@api_v1_bp.route("/organizations//networks/", methods=["PATCH"]) +@login_required +@require_admin +@full_access_required +def update_network(org_id, network_id): + """Update network metadata (admin only).""" + org, err = _org_check(org_id) + if err: + return err + + try: + schema = UpdateNetworkSchema() + data = schema.load(request.json or {}) + except ValidationError as e: + return api_response(success=False, message="Validation failed", status=400, error_type="VALIDATION_ERROR", error_details=e.messages) + + try: + network = portal_network_service.update_network(network_id, g.current_user.id, **data) + return api_response(data={"network": network.to_dict()}, message="Network updated successfully") + except NetworkNotFoundError 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) + + +@api_v1_bp.route("/organizations//networks/", methods=["DELETE"]) +@login_required +@require_admin +@full_access_required +def delete_network(org_id, network_id): + """Delete a portal network (admin only).""" + org, err = _org_check(org_id) + if err: + return err + + try: + portal_network_service.delete_network(network_id, g.current_user.id) + return api_response(message="Network deleted successfully") + except NetworkNotFoundError as e: + return api_response(success=False, message=str(e), status=404, error_type=e.error_type) + + +@api_v1_bp.route("/organizations//networks//members", methods=["GET"]) +@login_required +@full_access_required +def get_network_members(org_id, network_id): + """List all device memberships for a network.""" + org, err = _org_check(org_id) + if err: + return err + + try: + portal_network_service.get_network(network_id, organization_id=org_id) + except NetworkNotFoundError as e: + return api_response(success=False, message=str(e), status=404, error_type=e.error_type) + + memberships = portal_network_service.get_network_members(network_id) + return api_response( + data={"memberships": [m.to_dict() for m in memberships], "count": len(memberships)}, + message="Network members retrieved successfully", + ) + + +@api_v1_bp.route("/organizations//networks//requests", methods=["GET"]) +@login_required +@full_access_required +def get_network_pending_requests(org_id, network_id): + """List pending access requests for a network (manager view).""" + org, err = _org_check(org_id) + if err: + return err + + try: + portal_network_service.get_network(network_id, organization_id=org_id) + except NetworkNotFoundError as e: + return api_response(success=False, message=str(e), status=404, error_type=e.error_type) + + requests = portal_network_service.get_network_pending_requests(network_id) + return api_response( + data={"requests": [r.to_dict() for r in requests], "count": len(requests)}, + message="Pending requests retrieved successfully", + ) + + +# ── Devices ─────────────────────────────────────────────────────────────────── + + +@api_v1_bp.route("/organizations//devices", methods=["GET"]) +@login_required +@full_access_required +def list_devices(org_id): + """List the current user's registered devices.""" + org, err = _org_check(org_id) + if err: + return err + + devices = device_service.list_user_devices(g.current_user.id, org_id) + return api_response( + data={"devices": [d.to_dict() for d in devices], "count": len(devices)}, + message="Devices retrieved successfully", + ) + + +@api_v1_bp.route("/organizations//devices", methods=["POST"]) +@login_required +@full_access_required +def register_device(org_id): + """Register a new device for the current user.""" + org, err = _org_check(org_id) + if err: + return err + + try: + schema = RegisterDeviceSchema() + data = schema.load(request.json or {}) + except ValidationError as e: + return api_response(success=False, message="Validation failed", status=400, error_type="VALIDATION_ERROR", error_details=e.messages) + + try: + device = device_service.register_device( + user_id=g.current_user.id, + organization_id=org_id, + node_id=data["node_id"], + nickname=data.get("nickname"), + hostname=data.get("hostname"), + asset_tag=data.get("asset_tag"), + 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)}, + message="Device registered successfully", + status=201, + ) + except AppValidationError as e: + return api_response(success=False, message=str(e.message), status=400, error_type=e.error_type) + except DeviceAlreadyExistsError as e: + return api_response(success=False, message=str(e), status=409, error_type=e.error_type) + + +@api_v1_bp.route("/organizations//devices/", methods=["GET"]) +@login_required +@full_access_required +def get_device(org_id, device_id): + """Get a device by ID.""" + org, err = _org_check(org_id) + if err: + return err + + try: + device = device_service.get_device(device_id, organization_id=org_id) + return api_response(data={"device": device.to_dict()}, message="Device retrieved successfully") + except DeviceNotFoundError as e: + return api_response(success=False, message=str(e), status=404, error_type=e.error_type) + + +@api_v1_bp.route("/organizations//devices/", methods=["PATCH"]) +@login_required +@full_access_required +def update_device(org_id, device_id): + """Update device fields (owner only).""" + org, err = _org_check(org_id) + if err: + return err + + try: + schema = UpdateDeviceSchema() + data = schema.load(request.json or {}) + except ValidationError as e: + return api_response(success=False, message="Validation failed", status=400, error_type="VALIDATION_ERROR", error_details=e.messages) + + try: + device = device_service.update_device(device_id, g.current_user.id, **data) + return api_response(data={"device": device.to_dict()}, message="Device updated successfully") + except DeviceNotFoundError as e: + return api_response(success=False, message=str(e), status=404, error_type=e.error_type) + + +@api_v1_bp.route("/organizations//devices/", methods=["DELETE"]) +@login_required +@full_access_required +def remove_device(org_id, device_id): + """Remove a registered device (owner only).""" + org, err = _org_check(org_id) + if err: + return err + + try: + device_service.remove_device(device_id, g.current_user.id) + return api_response(message="Device removed successfully") + except DeviceNotFoundError as e: + return api_response(success=False, message=str(e), status=404, error_type=e.error_type) + + +# ── Approvals ───────────────────────────────────────────────────────────────── + + +@api_v1_bp.route("/organizations//approvals", methods=["POST"]) +@login_required +@full_access_required +def request_access(org_id): + """Request access to a network for a specific registered device.""" + org, err = _org_check(org_id) + if err: + return err + + try: + schema = RequestAccessSchema() + data = schema.load(request.json or {}) + except ValidationError as e: + return api_response(success=False, message="Validation failed", status=400, error_type="VALIDATION_ERROR", error_details=e.messages) + + try: + approval = network_access_service.request_access( + user_id=g.current_user.id, + organization_id=org_id, + portal_network_id=data["portal_network_id"], + device_id=data["device_id"], + justification=data.get("justification"), + ) + return api_response(data={"approval": approval.to_dict()}, message="Access requested successfully", status=201) + except AppValidationError as e: + return api_response(success=False, message=str(e.message), status=400, error_type=e.error_type) + except DeviceNotFoundError as e: + return api_response(success=False, message=str(e), status=400, error_type=e.error_type) + + +@api_v1_bp.route("/organizations//approvals", methods=["GET"]) +@login_required +@full_access_required +def list_my_approvals(org_id): + """List the current user's approval records.""" + org, err = _org_check(org_id) + if err: + return err + + approvals = network_access_service.list_user_approvals(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", + ) + + +@api_v1_bp.route("/organizations//approvals/pending", methods=["GET"]) +@login_required +@require_admin +@full_access_required +def list_pending_approvals(org_id): + """List all pending approval requests (admin only).""" + org, err = _org_check(org_id) + if err: + return err + + network_id = request.args.get("network_id") + approvals = network_access_service.list_pending_approvals(org_id, network_id=network_id) + return api_response( + data={"approvals": [a.to_dict() for a in approvals], "count": len(approvals)}, + message="Pending approvals retrieved successfully", + ) + + +@api_v1_bp.route("/organizations//approvals//approve", methods=["POST"]) +@login_required +@require_admin +@full_access_required +def approve_request(org_id, approval_id): + """Approve a pending access request (admin only).""" + org, err = _org_check(org_id) + if err: + return err + + try: + approval = network_access_service.approve_request(approval_id, g.current_user.id) + return api_response(data={"approval": approval.to_dict()}, message="Request approved 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) + + +@api_v1_bp.route("/organizations//approvals//reject", methods=["POST"]) +@login_required +@require_admin +@full_access_required +def reject_request(org_id, approval_id): + """Reject a pending access request (admin only).""" + org, err = _org_check(org_id) + if err: + return err + + try: + approval = network_access_service.reject_request(approval_id, g.current_user.id) + return api_response(data={"approval": approval.to_dict()}, message="Request rejected 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) + + +@api_v1_bp.route("/organizations//approvals//revoke", methods=["POST"]) +@login_required +@require_admin +@full_access_required +def revoke_approval(org_id, approval_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) + 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) + + +@api_v1_bp.route("/organizations//approvals/assign", methods=["POST"]) +@login_required +@require_admin +@full_access_required +def assign_access(org_id): + """Directly assign network access to a user (admin only).""" + org, err = _org_check(org_id) + if err: + return err + + try: + schema = AssignAccessSchema() + data = schema.load(request.json or {}) + except ValidationError as e: + return api_response(success=False, message="Validation failed", status=400, error_type="VALIDATION_ERROR", error_details=e.messages) + + try: + approval = network_access_service.assign_access( + portal_network_id=data["portal_network_id"], + target_user_id=data["target_user_id"], + granted_by_user_id=g.current_user.id, + organization_id=org_id, + justification=data.get("justification"), + ) + return api_response(data={"approval": approval.to_dict()}, message="Access assigned successfully", status=201) + except AppValidationError as e: + return api_response(success=False, message=str(e.message), status=400, error_type=e.error_type) + + +# ── Memberships ─────────────────────────────────────────────────────────────── + + +@api_v1_bp.route("/organizations//memberships", methods=["GET"]) +@login_required +@full_access_required +def list_memberships(org_id): + """List the current user's device network memberships.""" + org, err = _org_check(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), + ).all() + + return api_response( + data={"memberships": [m.to_dict() for m in memberships], "count": len(memberships)}, + message="Memberships retrieved successfully", + ) + + +@api_v1_bp.route("/organizations//memberships//activate", methods=["POST"]) +@login_required +@full_access_required +def activate_membership(org_id, membership_id): + """Activate an approved device membership.""" + org, err = _org_check(org_id) + if err: + return err + + try: + schema = ActivateSchema() + data = schema.load(request.json or {}) + except ValidationError as e: + return api_response(success=False, message="Validation failed", status=400, error_type="VALIDATION_ERROR", error_details=e.messages) + + try: + session = network_access_service.activate_device_membership( + membership_id=membership_id, + user_id=g.current_user.id, + lifetime_minutes=data.get("lifetime_minutes"), + ) + 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(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) + + +@api_v1_bp.route("/organizations//memberships//deactivate", methods=["POST"]) +@login_required +@full_access_required +def deactivate_membership(org_id, membership_id): + """Deactivate an active device membership.""" + org, err = _org_check(org_id) + if err: + return err + + try: + membership = network_access_service.deactivate_membership( + membership_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(success=False, message=str(e), status=404, error_type=e.error_type) + + +@api_v1_bp.route("/organizations//memberships/activate-all", methods=["POST"]) +@login_required +@full_access_required +def activate_all_memberships(org_id): + """Bulk-activate all approved inactive memberships.""" + org, err = _org_check(org_id) + if err: + return err + + try: + schema = ActivateSchema() + data = schema.load(request.json or {}) + except ValidationError as e: + return api_response(success=False, message="Validation failed", status=400, error_type="VALIDATION_ERROR", error_details=e.messages) + + sessions = network_access_service.activate_all_approved( + user_id=g.current_user.id, + organization_id=org_id, + lifetime_minutes=data.get("lifetime_minutes"), + ) + return api_response( + data={"sessions": [s.to_dict() for s in sessions], "count": len(sessions)}, + message=f"{len(sessions)} memberships activated", + ) + + +@api_v1_bp.route("/organizations//devices//join-network/", methods=["POST"]) +@login_required +@full_access_required +def join_network(org_id, device_id, portal_network_id): + """Join an open network directly with a registered device.""" + org, err = _org_check(org_id) + if err: + return err + + 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, + ) + return api_response(data={"membership": membership.to_dict()}, message="Joined network successfully", status=201) + except AppValidationError as e: + return api_response(success=False, message=str(e.message), status=400, error_type=e.error_type) + except DeviceNotFoundError as e: + return api_response(success=False, message=str(e), status=404, error_type=e.error_type) + + +@api_v1_bp.route("/organizations//memberships/", methods=["DELETE"]) +@login_required +@full_access_required +def delete_membership(org_id, membership_id): + """Soft-delete the current user's own membership.""" + org, err = _org_check(org_id) + if err: + return err + + try: + network_access_service.revoke_membership_soft( + membership_id=membership_id, + revoked_by_user_id=g.current_user.id, + ) + return api_response(message="Membership removed successfully") + except MembershipNotFoundError as e: + return api_response(success=False, message=str(e), status=404, error_type=e.error_type) + + +# ── Sessions ───────────────────────────────────────────────────────────────── + + +@api_v1_bp.route("/organizations//sessions", methods=["GET"]) +@login_required +@full_access_required +def list_sessions(org_id): + """List the current user's active activation sessions.""" + org, err = _org_check(org_id) + if err: + return err + + sessions = ActivationSession.query.filter( + ActivationSession.user_id == g.current_user.id, + ActivationSession.organization_id == org_id, + ActivationSession.ended_at.is_(None), + ActivationSession.deleted_at.is_(None), + ).all() + + return api_response( + data={"sessions": [s.to_dict() for s in sessions], "count": len(sessions)}, + message="Sessions retrieved successfully", + ) + + +@api_v1_bp.route("/organizations//sessions/", methods=["DELETE"]) +@login_required +@full_access_required +def end_session(org_id, session_id): + """End an active activation session.""" + org, err = _org_check(org_id) + if err: + return err + + session = ActivationSession.query.filter( + ActivationSession.id == session_id, + ActivationSession.user_id == g.current_user.id, + ActivationSession.deleted_at.is_(None), + ).first() + + if not session: + return api_response(success=False, message="Session not found", status=404, error_type="NOT_FOUND") + + if session.ended_at: + return api_response(success=False, message="Session already ended", status=400, error_type="VALIDATION_ERROR") + + from gatehouse_app.services.network_access_service import _end_session + from gatehouse_app.utils.constants import ActivationEndReason + from datetime import datetime, timezone + + _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") + + return api_response(message="Session ended successfully") + + +# ── Kill Switch ─────────────────────────────────────────────────────────────── + + +@api_v1_bp.route("/organizations//kill-switch", methods=["POST"]) +@login_required +@require_admin +@full_access_required +def trigger_kill_switch(org_id): + """Trigger a kill switch on a user (admin only).""" + org, err = _org_check(org_id) + if err: + return err + + try: + schema = KillSwitchSchema() + data = schema.load(request.json or {}) + except ValidationError as e: + 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, + scope=data.get("scope", "organization"), + reason=data.get("reason"), + network_ids=data.get("network_ids"), + ) + return api_response(data={"event": event.to_dict()}, 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) + + +# ── Admin / ZeroTier Controller ─────────────────────────────────────────────── + +@api_v1_bp.route("/organizations//admin/memberships", methods=["GET"]) +@login_required +@require_admin +@full_access_required +def admin_list_memberships(org_id): + """List all memberships across all users and networks (admin only).""" + org, err = _org_check(org_id) + if err: + return err + + memberships = network_access_service.get_all_memberships_with_details(org_id) + return api_response( + data={"memberships": memberships, "count": len(memberships)}, + message="All memberships retrieved successfully", + ) + + +@api_v1_bp.route("/organizations//admin/memberships/", methods=["DELETE"]) +@login_required +@require_admin +@full_access_required +def admin_delete_membership(org_id, membership_id): + """Hard-delete a membership and remove it from ZeroTier (admin only).""" + org, err = _org_check(org_id) + if err: + return err + + try: + network_access_service.hard_delete_membership(membership_id) + return api_response(message="Membership permanently deleted") + except MembershipNotFoundError as e: + return api_response(success=False, message=str(e), status=404, error_type=e.error_type) + + +# ── ZeroTier Controller ─────────────────────────────────────────────────────── + + +@api_v1_bp.route("/admin/zerotier/status", methods=["GET"]) +@login_required +@require_admin +@full_access_required +def zerotier_status(): + """Check ZeroTier controller connectivity and status (admin only).""" + try: + status = zt.get_status() + return api_response(data={"status": status}, message="ZeroTier controller is reachable") + except ZeroTierAPIError as e: + return api_response(success=False, message=str(e), status=502, error_type=e.error_type) + + +@api_v1_bp.route("/admin/zerotier/networks", methods=["GET"]) +@login_required +@require_admin +@full_access_required +def zerotier_list_networks(): + """List networks from the ZeroTier controller (admin only).""" + try: + networks = zt.list_networks() + return api_response( + data={"networks": [n.to_dict() if hasattr(n, 'to_dict') else {"id": getattr(n, "id", str(n))} for n in networks], "count": len(networks)}, + message="Networks retrieved successfully", + ) + except ZeroTierAPIError as e: + return api_response(success=False, message=str(e), status=502, error_type=e.error_type) + + +@api_v1_bp.route("/admin/zerotier/networks/", methods=["GET"]) +@login_required +@require_admin +@full_access_required +def zerotier_get_network(network_id): + """Get a ZeroTier network from the controller (admin only).""" + try: + network = zt.get_network(network_id) + return api_response(data={"network": network.to_dict()}, message="Network retrieved successfully") + except ZeroTierAPIError as e: + return api_response(success=False, message=str(e), status=502, error_type=e.error_type) + + +@api_v1_bp.route("/admin/zerotier/networks//members", methods=["GET"]) +@login_required +@require_admin +@full_access_required +def zerotier_list_members(network_id): + """List members on a ZeroTier network from the controller (admin only).""" + try: + members = zt.list_members(network_id) + return api_response( + data={"members": [m.to_dict() for m in members], "count": len(members)}, + message="Members retrieved successfully", + ) + except ZeroTierAPIError as e: + return api_response(success=False, message=str(e), status=502, error_type=e.error_type) + + +@api_v1_bp.route("/admin/zerotier/reconcile", methods=["POST"]) +@login_required +@require_admin +@full_access_required +def trigger_reconciliation(): + """Trigger full reconciliation across all networks (admin only).""" + result = zerotier_reconciliation_service.reconcile_all() + return api_response(data=result, message="Reconciliation complete") diff --git a/gatehouse_app/exceptions/__init__.py b/gatehouse_app/exceptions/__init__.py index 06c3f34..aab2cca 100644 --- a/gatehouse_app/exceptions/__init__.py +++ b/gatehouse_app/exceptions/__init__.py @@ -34,6 +34,19 @@ from gatehouse_app.exceptions.ssh_exceptions import ( DepartmentError, DepartmentNotFoundError, ) +from gatehouse_app.exceptions.zerotier_exceptions import ( + ZeroTierAPIError, + ZeroTierAuthError, + ZeroTierNotFoundError, + NetworkNotFoundError, + DeviceNotFoundError, + ApprovalNotFoundError, + MembershipNotFoundError, + DeviceAlreadyExistsError, + ApprovalAlreadyExistsError, + InvalidNodeIdError, + InvalidNetworkIdError, +) __all__ = [ "BaseAPIException", @@ -65,5 +78,16 @@ __all__ = [ "PrincipalNotFoundError", "DepartmentError", "DepartmentNotFoundError", + "ZeroTierAPIError", + "ZeroTierAuthError", + "ZeroTierNotFoundError", + "NetworkNotFoundError", + "DeviceNotFoundError", + "ApprovalNotFoundError", + "MembershipNotFoundError", + "DeviceAlreadyExistsError", + "ApprovalAlreadyExistsError", + "InvalidNodeIdError", + "InvalidNetworkIdError", ] diff --git a/gatehouse_app/exceptions/zerotier_exceptions.py b/gatehouse_app/exceptions/zerotier_exceptions.py new file mode 100644 index 0000000..2ba0668 --- /dev/null +++ b/gatehouse_app/exceptions/zerotier_exceptions.py @@ -0,0 +1,69 @@ +"""ZeroTier-specific exceptions.""" + +from gatehouse_app.exceptions.base import BaseAPIException + + +class ZeroTierAPIError(BaseAPIException): + status_code = 502 + error_type = "ZEROTIER_API_ERROR" + message = "ZeroTier API error" + + +class ZeroTierAuthError(ZeroTierAPIError): + status_code = 401 + error_type = "ZEROTIER_AUTH_ERROR" + message = "ZeroTier API authentication failed" + + +class ZeroTierNotFoundError(ZeroTierAPIError): + status_code = 404 + error_type = "ZEROTIER_NOT_FOUND" + message = "ZeroTier network or member not found" + + +class NetworkNotFoundError(ZeroTierAPIError): + status_code = 404 + error_type = "NETWORK_NOT_FOUND" + message = "Portal network not found" + + +class DeviceNotFoundError(ZeroTierAPIError): + status_code = 404 + error_type = "DEVICE_NOT_FOUND" + message = "Device not found" + + +class ApprovalNotFoundError(ZeroTierAPIError): + status_code = 404 + error_type = "APPROVAL_NOT_FOUND" + message = "Network approval not found" + + +class MembershipNotFoundError(ZeroTierAPIError): + status_code = 404 + error_type = "MEMBERSHIP_NOT_FOUND" + message = "Device network membership not found" + + +class DeviceAlreadyExistsError(ZeroTierAPIError): + status_code = 409 + error_type = "DEVICE_ALREADY_EXISTS" + message = "A device with this node ID already exists" + + +class ApprovalAlreadyExistsError(ZeroTierAPIError): + status_code = 409 + error_type = "APPROVAL_ALREADY_EXISTS" + message = "An approval already exists for this user and network" + + +class InvalidNodeIdError(ZeroTierAPIError): + status_code = 400 + error_type = "INVALID_NODE_ID" + message = "Invalid ZeroTier node ID" + + +class InvalidNetworkIdError(ZeroTierAPIError): + status_code = 400 + error_type = "INVALID_NETWORK_ID" + message = "Invalid ZeroTier network ID" diff --git a/gatehouse_app/jobs/zerotier_reconciliation_job.py b/gatehouse_app/jobs/zerotier_reconciliation_job.py new file mode 100644 index 0000000..80b4df8 --- /dev/null +++ b/gatehouse_app/jobs/zerotier_reconciliation_job.py @@ -0,0 +1,71 @@ +"""ZeroTier reconciliation scheduled job. + +This module implements the scheduled job for reconciling portal network state +with the ZeroTier controller. It is designed to be run periodically (every +1-2 minutes via cron) to: + +1. Expire activation sessions past their TTL and deauthorize the corresponding ZT members +2. Sync observed ZeroTier membership state into zerotier_memberships cache +3. Reconcile portal membership state against ZT controller state +4. Flag unknown ZT members (not in portal) +5. Detect and repair drift (ZT says authorized but portal says inactive, and vice versa) + +Usage: + python manage.py run_zerotier_reconciliation + +Or call directly: + from gatehouse_app.jobs.zerotier_reconciliation_job import run_reconciliation + run_reconciliation() + +Cron example (every 2 minutes): + */2 * * * * cd /path/to/app && python manage.py run_zerotier_reconciliation +""" + +import logging +from datetime import datetime, timezone +from typing import Optional + +from gatehouse_app.services import zerotier_reconciliation_service + +logger = logging.getLogger(__name__) + + +def run_reconciliation() -> dict: + """Run full ZeroTier reconciliation across all networks and activations. + + Returns: + Dictionary with reconciliation results: + - expired_activations: number of activation sessions expired + - networks_processed: number of portal networks reconciled + - errors: number of networks that had errors + """ + logger.info(f"[ZT Reconcile] Starting reconciliation at {datetime.now(timezone.utc).isoformat()}") + + results = { + "expired_activations": 0, + "networks_processed": 0, + "errors": 0, + } + + try: + expired = zerotier_reconciliation_service.reconcile_expired_activations() + results["expired_activations"] = expired + except Exception as exc: + logger.error(f"[ZT Reconcile] Error expiring activations: {exc}") + + try: + summary = zerotier_reconciliation_service.reconcile_all() + results["networks_processed"] = summary.get("networks_processed", 0) + results["errors"] = summary.get("errors", 0) + except Exception as exc: + logger.error(f"[ZT Reconcile] Error during network reconciliation: {exc}") + results["errors"] += 1 + + logger.info( + f"[ZT Reconcile] Complete at {datetime.now(timezone.utc).isoformat()}: " + f"expired={results['expired_activations']} " + f"networks={results['networks_processed']} " + f"errors={results['errors']}" + ) + + return results diff --git a/gatehouse_app/models/__init__.py b/gatehouse_app/models/__init__.py index f15764c..fc6b060 100644 --- a/gatehouse_app/models/__init__.py +++ b/gatehouse_app/models/__init__.py @@ -17,6 +17,9 @@ models.ssh_ca — CA, KeyType, CertType, CaType, CAPermission, CertificateAuditLog models.security — OrganizationSecurityPolicy, UserSecurityPolicy, MfaPolicyCompliance +models.zerotier — PortalNetwork, Device, UserNetworkApproval, + DeviceNetworkMembership, 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 @@ -90,9 +93,26 @@ from gatehouse_app.models.ssh_ca.certificate_audit_log import ( # noqa: F401 ) # ── Security ────────────────────────────────────────────────────────────────── -from gatehouse_app.models.security.organization_security_policy import ( # noqa: F401 +from gatehouse_app.models.security.organization_security_policy import ( OrganizationSecurityPolicy, ) +from gatehouse_app.models.security.user_security_policy import ( + UserSecurityPolicy, +) +from gatehouse_app.models.security.mfa_policy_compliance import ( + MfaPolicyCompliance, +) + +# ── ZeroTier / Portal Network ───────────────────────────────────────────────── +from gatehouse_app.models.zerotier import ( # noqa: F401 + PortalNetwork, + Device, + UserNetworkApproval, + DeviceNetworkMembership, + ActivationSession, + ZeroTierMembership, + KillSwitchEvent, +) from gatehouse_app.models.security.user_security_policy import ( # noqa: F401 UserSecurityPolicy, ) @@ -147,4 +167,12 @@ __all__ = [ "OrganizationSecurityPolicy", "UserSecurityPolicy", "MfaPolicyCompliance", + # ZeroTier + "PortalNetwork", + "Device", + "UserNetworkApproval", + "DeviceNetworkMembership", + "ActivationSession", + "ZeroTierMembership", + "KillSwitchEvent", ] diff --git a/gatehouse_app/models/zerotier/__init__.py b/gatehouse_app/models/zerotier/__init__.py new file mode 100644 index 0000000..d6b3f5f --- /dev/null +++ b/gatehouse_app/models/zerotier/__init__.py @@ -0,0 +1,18 @@ +"""ZeroTier / Portal Network models. + +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 +ActivationSession — temporary activation window +ZeroTierMembership — observed controller-side member state +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.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 diff --git a/gatehouse_app/models/zerotier/activation_session.py b/gatehouse_app/models/zerotier/activation_session.py new file mode 100644 index 0000000..8ee06cb --- /dev/null +++ b/gatehouse_app/models/zerotier/activation_session.py @@ -0,0 +1,107 @@ +"""Activation session model — temporary activation window for a device membership.""" + +from gatehouse_app.extensions import db +from gatehouse_app.models.base import BaseModel +from gatehouse_app.utils.constants import ActivationEndReason + + +class ActivationSession(BaseModel): + """A temporary activation window for an already-approved device membership. + + Created when a user re-authenticates in the portal and wants to activate + an approved device on a network. Has a fixed lifetime (e.g. 8 hours). + When it expires the membership is de-authorized in ZeroTier but the + underlying approval record is untouched. + + 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 + 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) + end_reason: Why the session ended (expired, logout, kill_switch, etc.) + created_by: FK to the user who triggered activation (usually same as user_id) + """ + + __tablename__ = "activation_sessions" + + 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_network_membership_id = db.Column( + db.String(36), + db.ForeignKey("device_network_memberships.id"), + nullable=False, + index=True, + ) + authenticated_at = db.Column( + db.DateTime(timezone=True), + nullable=False, + ) + expires_at = db.Column( + db.DateTime(timezone=True), + nullable=False, + ) + ended_at = db.Column(db.DateTime(timezone=True), nullable=True) + end_reason = db.Column( + db.Enum(ActivationEndReason, name="activation_end_reason"), + nullable=True, + ) + created_by = db.Column( + db.String(36), + db.ForeignKey("users.id"), + nullable=False, + ) + + # Relationships + organization = db.relationship("Organization", backref="activation_sessions") + user = db.relationship( + "User", + foreign_keys=[user_id], + backref="activation_sessions", + ) + created_by_user = db.relationship( + "User", + foreign_keys=[created_by], + backref="created_activation_sessions", + ) + membership = db.relationship( + "DeviceNetworkMembership", + back_populates="activation_sessions", + ) + + def __repr__(self): + return ( + f"" + ) + + @property + def is_expired(self) -> bool: + from datetime import datetime, timezone + now = datetime.now(timezone.utc) + exp = self.expires_at + if exp.tzinfo is None: + exp = exp.replace(tzinfo=timezone.utc) + return now > exp + + @property + def is_active(self) -> bool: + return self.ended_at is None and not self.is_expired + + def to_dict(self, exclude=None): + exclude = exclude or [] + data = super().to_dict(exclude=exclude) + data["is_expired"] = self.is_expired + data["is_active"] = self.is_active + return data diff --git a/gatehouse_app/models/zerotier/device.py b/gatehouse_app/models/zerotier/device.py new file mode 100644 index 0000000..695b5f3 --- /dev/null +++ b/gatehouse_app/models/zerotier/device.py @@ -0,0 +1,79 @@ +"""Device model — a user-registered ZeroTier node endpoint.""" + +from gatehouse_app.extensions import db +from gatehouse_app.models.base import BaseModel +from gatehouse_app.utils.constants import DeviceStatus + + +class Device(BaseModel): + """A user-owned endpoint identified by a ZeroTier Node ID. + + A user registers their device in the portal using the 10-character ZeroTier + Node ID visible in the ZeroTier client. One active device record per + node_id at a time (unique constraint excludes soft-deleted duplicates). + + Attributes: + user_id: FK to the owning user + organization_id: FK to the organization this device is registered in + node_id: 10-char hex ZeroTier node / device address + device_nickname: User-assigned friendly name + hostname: Device hostname reported by the client + asset_tag: Corporate asset tag if available + serial_number: Device serial number if available + status: active / inactive + """ + + __tablename__ = "devices" + + user_id = db.Column( + db.String(36), + db.ForeignKey("users.id"), + nullable=False, + index=True, + ) + organization_id = db.Column( + db.String(36), + db.ForeignKey("organizations.id"), + nullable=False, + index=True, + ) + node_id = db.Column( + db.String(10), + nullable=False, + index=True, + ) + device_nickname = db.Column(db.String(255), nullable=True) + hostname = db.Column(db.String(255), nullable=True) + asset_tag = db.Column(db.String(255), nullable=True) + serial_number = db.Column(db.String(255), nullable=True) + status = db.Column( + db.Enum(DeviceStatus, name="device_status"), + default=DeviceStatus.ACTIVE, + nullable=False, + ) + + # Relationships + user = db.relationship("User", backref="devices") + organization = db.relationship("Organization", backref="devices") + memberships = db.relationship( + "DeviceNetworkMembership", + back_populates="device", + cascade="all, delete-orphan", + ) + + def __repr__(self): + return f"" + + @property + def display_name(self) -> str: + return self.device_nickname or self.hostname or self.node_id + + def to_dict(self, exclude=None): + exclude = exclude or [] + 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 + ) + return data diff --git a/gatehouse_app/models/zerotier/device_network_membership.py b/gatehouse_app/models/zerotier/device_network_membership.py new file mode 100644 index 0000000..8e1118c --- /dev/null +++ b/gatehouse_app/models/zerotier/device_network_membership.py @@ -0,0 +1,129 @@ +"""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"), + 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"" + ) + + @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 diff --git a/gatehouse_app/models/zerotier/kill_switch_event.py b/gatehouse_app/models/zerotier/kill_switch_event.py new file mode 100644 index 0000000..6571582 --- /dev/null +++ b/gatehouse_app/models/zerotier/kill_switch_event.py @@ -0,0 +1,70 @@ +"""Kill switch event model — explicit record of rapid deactivation actions.""" + +from gatehouse_app.extensions import db +from gatehouse_app.models.base import BaseModel +from gatehouse_app.utils.constants import KillSwitchScope + + +class KillSwitchEvent(BaseModel): + """An explicit record of a kill switch action. + + Created whenever an administrator or manager triggers a kill switch on a user. + Append-only — kill switch events are never modified or deleted. + + Attributes: + organization_id: FK to the organization where the kill switch was triggered + target_user_id: FK to the user whose access was revoked + scope: organization | global | selected_networks + triggered_by_user_id: FK to the admin/manager who pulled the kill switch + reason: Free-text reason for the action + network_ids: JSON list of network IDs if scope is SELECTED_NETWORKS + """ + + __tablename__ = "kill_switch_events" + + organization_id = db.Column( + db.String(36), + db.ForeignKey("organizations.id"), + nullable=False, + index=True, + ) + target_user_id = db.Column( + db.String(36), + db.ForeignKey("users.id"), + nullable=False, + index=True, + ) + scope = db.Column( + db.Enum(KillSwitchScope, name="kill_switch_scope"), + default=KillSwitchScope.ORGANIZATION, + nullable=False, + ) + triggered_by_user_id = db.Column( + db.String(36), + db.ForeignKey("users.id"), + nullable=False, + ) + reason = db.Column(db.Text, nullable=True) + network_ids = db.Column(db.JSON, nullable=True) # list of network IDs if scope=selected + + # Relationships + organization = db.relationship( + "Organization", + backref="kill_switch_events", + ) + target_user = db.relationship( + "User", + foreign_keys=[target_user_id], + backref="kill_switch_events", + ) + triggered_by = db.relationship( + "User", + foreign_keys=[triggered_by_user_id], + backref="triggered_kill_switches", + ) + + def __repr__(self): + return ( + f"" + ) diff --git a/gatehouse_app/models/zerotier/portal_network.py b/gatehouse_app/models/zerotier/portal_network.py new file mode 100644 index 0000000..b321832 --- /dev/null +++ b/gatehouse_app/models/zerotier/portal_network.py @@ -0,0 +1,100 @@ +"""Portal network model — a manager-created ZeroTier network binding.""" + +from gatehouse_app.extensions import db +from gatehouse_app.models.base import BaseModel +from gatehouse_app.utils.constants import NetworkEnvironment, NetworkRequestMode + + +class PortalNetwork(BaseModel): + """A business/admin object representing a ZeroTier network under management. + + Each PortalNetwork maps one-to-one to exactly one ZeroTier network ID. + Networks are scoped to an organization and owned by a manager. + + Attributes: + organization_id: FK to the owning organization + name: Human-readable display name + description: Free-text description + owner_user_id: FK to the user who manages this network + zerotier_network_id: The 16-char hex ZeroTier network ID + environment: Environment tag (production, staging, etc.) + request_mode: How users request access (open, approval_required, invite_only) + default_activation_lifetime_minutes: Default session length when user activates + max_activation_lifetime_minutes: Cap on activation lifetime + is_active: Whether this network is live + """ + + __tablename__ = "portal_networks" + + organization_id = db.Column( + db.String(36), + db.ForeignKey("organizations.id"), + nullable=False, + index=True, + ) + name = db.Column(db.String(255), nullable=False) + description = db.Column(db.Text, nullable=True) + owner_user_id = db.Column( + db.String(36), + db.ForeignKey("users.id"), + nullable=False, + ) + zerotier_network_id = db.Column( + db.String(16), + nullable=False, + index=True, + ) + environment = db.Column( + db.Enum(NetworkEnvironment, name="network_environment"), + default=NetworkEnvironment.DEVELOPMENT, + nullable=False, + ) + request_mode = db.Column( + db.Enum(NetworkRequestMode, name="network_request_mode"), + default=NetworkRequestMode.APPROVAL_REQUIRED, + nullable=False, + ) + default_activation_lifetime_minutes = db.Column( + db.Integer, + default=480, # 8 hours + nullable=False, + ) + max_activation_lifetime_minutes = db.Column(db.Integer, nullable=True) + is_active = db.Column(db.Boolean, default=True, nullable=False) + + # 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( + "organization_id", + "zerotier_network_id", + name="uix_org_zt_network_id", + ), + ) + + def __repr__(self): + return f"" + + def to_dict(self, exclude=None): + 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 + ) + data["active_membership_count"] = sum( + 1 for m in self.memberships + if m.state == "active_authorized" and m.deleted_at is None + ) + return data diff --git a/gatehouse_app/models/zerotier/user_network_approval.py b/gatehouse_app/models/zerotier/user_network_approval.py new file mode 100644 index 0000000..44e57ae --- /dev/null +++ b/gatehouse_app/models/zerotier/user_network_approval.py @@ -0,0 +1,106 @@ +"""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"), + default=ApprovalGrantType.REQUESTED, + nullable=False, + ) + state = db.Column( + db.Enum(ApprovalState, name="approval_state"), + 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"" + ) + + 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 diff --git a/gatehouse_app/models/zerotier/zerotier_membership.py b/gatehouse_app/models/zerotier/zerotier_membership.py new file mode 100644 index 0000000..4897056 --- /dev/null +++ b/gatehouse_app/models/zerotier/zerotier_membership.py @@ -0,0 +1,82 @@ +"""ZeroTier membership model — observed controller-side member state.""" + +from gatehouse_app.extensions import db +from gatehouse_app.models.base import BaseModel + + +class ZeroTierMembership(BaseModel): + """Observed state of a node in a ZeroTier network from the controller API. + + This is maintained as a cache of controller-side state, updated by the + reconciliation loop and by direct API calls. The raw_controller_payload + column stores the full API response for debugging and audit. + + Keyed by zerotier_network_id + node_id (unique constraint). + + Attributes: + organization_id: FK to the organization + device_network_membership_id: FK to the portal's membership 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 + authorized: Current authorization state from ZeroTier + join_seen_at: When the member was first seen joining + last_synced_at: When we last polled ZeroTier for this member + raw_controller_payload: Full API response for debugging + """ + + __tablename__ = "zerotier_memberships" + + organization_id = db.Column( + db.String(36), + db.ForeignKey("organizations.id"), + nullable=False, + index=True, + ) + device_network_membership_id = db.Column( + db.String(36), + db.ForeignKey("device_network_memberships.id"), + nullable=True, + index=True, + ) + zerotier_network_id = db.Column( + db.String(16), + nullable=False, + index=True, + ) + node_id = db.Column( + db.String(10), + nullable=False, + index=True, + ) + member_seen = db.Column(db.Boolean, default=False, nullable=False) + authorized = db.Column(db.Boolean, default=False, nullable=False) + join_seen_at = db.Column(db.DateTime(timezone=True), nullable=True) + last_synced_at = db.Column(db.DateTime(timezone=True), nullable=True) + raw_controller_payload = db.Column(db.JSON, nullable=True) + + # Relationships + organization = db.relationship("Organization", backref="zerotier_memberships") + device_network_membership = db.relationship( + "DeviceNetworkMembership", + back_populates="zerotier_membership", + ) + + __table_args__ = ( + db.UniqueConstraint( + "zerotier_network_id", + "node_id", + name="uix_zt_network_node", + ), + ) + + def __repr__(self): + return ( + f"" + ) + + def to_dict(self, exclude=None): + exclude = exclude or [] + data = super().to_dict(exclude=exclude) + return data diff --git a/gatehouse_app/services/device_service.py b/gatehouse_app/services/device_service.py new file mode 100644 index 0000000..573c8bf --- /dev/null +++ b/gatehouse_app/services/device_service.py @@ -0,0 +1,186 @@ +"""Device management service.""" + +import logging +import re + +from gatehouse_app.extensions import db +from gatehouse_app.models import Device +from gatehouse_app.models.user import User +from gatehouse_app.services.audit_service import AuditService +from gatehouse_app.exceptions import ( + DeviceNotFoundError, + DeviceAlreadyExistsError, + InvalidNodeIdError, + ValidationError, +) + +logger = logging.getLogger(__name__) + +_NODE_ID_RE = re.compile(r"^[0-9a-fA-F]{10}$") + + +def _validate_node_id(node_id: str) -> str: + node_id = node_id.strip().lower() + if not _NODE_ID_RE.match(node_id): + raise InvalidNodeIdError( + f"Invalid ZeroTier node ID '{node_id}' — must be exactly 10 hex characters." + ) + return node_id + + +def register_device( + user_id: str, + organization_id: str, + node_id: str, + nickname: str | None = None, + hostname: str | None = None, + asset_tag: str | None = None, + serial_number: str | None = None, +) -> Device: + """Register a new device for a user in an organization. + + Args: + user_id: ID of the owning user + organization_id: ID of the organization + node_id: 10-char hex ZeroTier node ID + nickname: User-assigned friendly name + hostname: Device hostname + asset_tag: Corporate asset tag + serial_number: Device serial number + + Returns: + The created Device record + """ + node_id = _validate_node_id(node_id) + + existing = Device.query.filter( + Device.node_id == node_id, + Device.deleted_at.is_(None), + ).first() + if existing: + raise DeviceAlreadyExistsError( + f"A device with node ID {node_id} is already registered." + ) + + device = Device( + user_id=user_id, + organization_id=organization_id, + node_id=node_id, + device_nickname=nickname, + hostname=hostname, + asset_tag=asset_tag, + serial_number=serial_number, + ) + device.save() + + AuditService.log_action( + action="device.registered", + user_id=user_id, + organization_id=organization_id, + resource_type="device", + resource_id=device.id, + metadata={"node_id": node_id, "nickname": nickname}, + description=f"Device {node_id} registered", + success=True, + ) + + return device + + +def list_user_devices(user_id: str, organization_id: str) -> list[Device]: + """List all active devices for a user in an organization.""" + return Device.query.filter( + Device.user_id == user_id, + Device.organization_id == organization_id, + Device.deleted_at.is_(None), + ).all() + + +def get_device(device_id: str, organization_id: str | None = None) -> Device: + """Fetch a device by ID, optionally scoped to an organization.""" + q = Device.query.filter( + Device.id == device_id, + Device.deleted_at.is_(None), + ) + if organization_id: + q = q.filter(Device.organization_id == organization_id) + + device = q.first() + if not device: + raise DeviceNotFoundError(f"Device {device_id} not found.") + return device + + +def get_device_by_node_id(node_id: str, organization_id: str | None = None) -> Device | None: + """Find a device by its ZeroTier node ID, optionally scoped to org.""" + node_id = _validate_node_id(node_id) + q = Device.query.filter( + Device.node_id == node_id, + Device.deleted_at.is_(None), + ) + if organization_id: + q = q.filter(Device.organization_id == organization_id) + return q.first() + + +def update_device( + device_id: str, + user_id: str, + **kwargs, +) -> Device: + """Update device fields. Only allows nickname, hostname, asset_tag.""" + device = get_device(device_id) + + if device.user_id != user_id: + raise DeviceNotFoundError("Device not found.") + + allowed = {"device_nickname", "hostname", "asset_tag", "serial_number"} + for key in kwargs: + if key not in allowed: + raise ValidationError(f"Cannot update field: {key}") + + device.update(**kwargs) + + AuditService.log_action( + action="device.updated", + user_id=user_id, + organization_id=device.organization_id, + resource_type="device", + resource_id=device.id, + metadata=kwargs, + description=f"Device {device.node_id} updated", + success=True, + ) + + return device + + +def remove_device(device_id: str, user_id: str) -> None: + """Soft-delete a device, deactivate its active memberships, and soft-delete all memberships. + + Since memberships are device-centric (tied to a specific device/node_id), removing a device + means all its memberships are orphaned and must be cleaned up. + """ + device = get_device(device_id) + + if device.user_id != user_id: + 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) + + device.delete(soft=True) + + AuditService.log_action( + action="device.removed", + user_id=user_id, + 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])}, + description=f"Device {device.node_id} removed", + success=True, + ) diff --git a/gatehouse_app/services/network_access_service.py b/gatehouse_app/services/network_access_service.py new file mode 100644 index 0000000..6d43ebd --- /dev/null +++ b/gatehouse_app/services/network_access_service.py @@ -0,0 +1,1007 @@ +"""Network access service — core business logic for ZeroTier access governance. + +Handles: approval workflows, activation sessions, kill-switch, and all +ZeroTier API calls for authorization state. +""" + +import logging +from datetime import datetime, timedelta, timezone + +from gatehouse_app.extensions import db +from gatehouse_app.models import ( + Device, + DeviceNetworkMembership, + UserNetworkApproval, + ActivationSession, + ZeroTierMembership, + KillSwitchEvent, + PortalNetwork, +) +from gatehouse_app.services import zerotier_api_service as zt +from gatehouse_app.services.audit_service import AuditService +from gatehouse_app.utils.constants import ( + ApprovalGrantType, + ApprovalState, + ActivationEndReason, + MembershipState, + KillSwitchScope, +) +from gatehouse_app.exceptions import ( + ApprovalNotFoundError, + MembershipNotFoundError, + NetworkNotFoundError, + DeviceNotFoundError, + ApprovalAlreadyExistsError, + ValidationError, +) + +logger = logging.getLogger(__name__) + +# ── Approvals ──────────────────────────────────────────────────────────────── + + +def request_access( + user_id: str, + organization_id: str, + portal_network_id: str, + device_id: str, + justification: str | None = None, +) -> UserNetworkApproval: + """Create a pending access request for a user's specific device to a network. + + Creates a UserNetworkApproval and a DeviceNetworkMembership pinned to the device + in one transaction. For open-mode networks the approval is immediate; + for approval_required networks it starts pending. + """ + network = _validate_org_network(org_id=organization_id, network_id=portal_network_id) + + device = Device.query.filter( + Device.id == device_id, + Device.user_id == user_id, + Device.organization_id == organization_id, + Device.deleted_at.is_(None), + ).first() + if not device: + raise DeviceNotFoundError(f"Device {device_id} not found or does not belong to this user.") + + existing = UserNetworkApproval.query.filter( + UserNetworkApproval.user_id == user_id, + UserNetworkApproval.portal_network_id == portal_network_id, + UserNetworkApproval.deleted_at.is_(None), + ).first() + if existing: + if existing.state in (ApprovalState.APPROVED, ApprovalState.PENDING): + raise ApprovalAlreadyExistsError( + "An access request or approval already exists for this user and network." + ) + existing.state = ApprovalState.PENDING + existing.justification = justification + existing.save() + return existing + + is_open = network.request_mode.value == "open" + approval_state = ApprovalState.APPROVED if is_open else ApprovalState.PENDING + + approval = UserNetworkApproval( + organization_id=organization_id, + user_id=user_id, + portal_network_id=portal_network_id, + grant_type=ApprovalGrantType.REQUESTED, + state=approval_state, + justification=justification, + ) + approval.save() + + membership_state = MembershipState.APPROVED_INACTIVE if is_open else MembershipState.PENDING_DEVICE_REGISTRATION + + membership = DeviceNetworkMembership( + organization_id=organization_id, + user_id=user_id, + device_id=device_id, + portal_network_id=portal_network_id, + user_network_approval_id=approval.id, + state=membership_state, + approved_for_activation=is_open, + ) + membership.save() + + _ensure_zerotier_member(device.node_id, portal_network_id, authorized=False) + + AuditService.log_action( + action="zt.approval.requested", + user_id=user_id, + organization_id=organization_id, + resource_type="user_network_approval", + resource_id=approval.id, + metadata={ + "portal_network_id": portal_network_id, + "device_id": device_id, + "device_node_id": device.node_id, + "justification": justification, + "is_open_network": is_open, + }, + description=f"Network access requested for device {device.node_id}", + success=True, + ) + + if is_open: + AuditService.log_action( + action="zt.membership.created", + user_id=user_id, + organization_id=organization_id, + resource_type="device_network_membership", + resource_id=membership.id, + metadata={ + "device_id": device_id, + "device_node_id": device.node_id, + "portal_network_id": portal_network_id, + "source": "open_network_join", + }, + description=f"Device membership created (open network) for {device.node_id}", + success=True, + ) + + return approval + + +def assign_access( + portal_network_id: str, + target_user_id: str, + granted_by_user_id: str, + organization_id: str, + justification: str | None = None, +) -> UserNetworkApproval: + """Manager directly assigns access to a user (no approval needed).""" + network = _validate_org_network(org_id=organization_id, network_id=portal_network_id) + + existing = UserNetworkApproval.query.filter( + UserNetworkApproval.user_id == target_user_id, + UserNetworkApproval.portal_network_id == portal_network_id, + UserNetworkApproval.deleted_at.is_(None), + ).first() + if existing: + if existing.state == ApprovalState.APPROVED: + return existing + existing.state = ApprovalState.APPROVED + existing.granted_by_user_id = granted_by_user_id + existing.justification = justification + existing.save() + _materialize_memberships_for_approval(existing) + return existing + + approval = UserNetworkApproval( + organization_id=organization_id, + user_id=target_user_id, + portal_network_id=portal_network_id, + granted_by_user_id=granted_by_user_id, + grant_type=ApprovalGrantType.ASSIGNED, + state=ApprovalState.APPROVED, + justification=justification, + ) + approval.save() + _materialize_memberships_for_approval(approval) + + AuditService.log_action( + action="zt.approval.granted", + user_id=granted_by_user_id, + organization_id=organization_id, + resource_type="user_network_approval", + resource_id=approval.id, + metadata={ + "target_user_id": target_user_id, + "portal_network_id": portal_network_id, + "grant_type": "assigned", + }, + description=f"Network access assigned to user {target_user_id}", + success=True, + ) + + return approval + + +def approve_request( + approval_id: str, + approver_user_id: str, +) -> UserNetworkApproval: + """Approve a pending access request. Updates the pre-created device membership to approved_inactive.""" + approval = _get_approval(approval_id) + + if approval.state != ApprovalState.PENDING: + raise ValidationError(f"Approval is not pending (current state: {approval.state.value}).") + + approval.state = ApprovalState.APPROVED + approval.granted_by_user_id = approver_user_id + approval.save() + + membership = DeviceNetworkMembership.query.filter( + DeviceNetworkMembership.user_network_approval_id == approval_id, + DeviceNetworkMembership.deleted_at.is_(None), + ).first() + + if membership: + membership.state = MembershipState.APPROVED_INACTIVE + membership.approved_for_activation = True + membership.save() + else: + logger.warning(f"[approve_request] No pre-created membership found for approval {approval_id}") + + AuditService.log_action( + action="zt.approval.granted", + user_id=approver_user_id, + organization_id=approval.organization_id, + resource_type="user_network_approval", + resource_id=approval.id, + metadata={"target_user_id": approval.user_id, "grant_type": "requested"}, + description=f"Network access approved for user {approval.user_id}", + success=True, + ) + + return approval + + +def reject_request( + approval_id: str, + rejecter_user_id: str, +) -> UserNetworkApproval: + """Reject a pending access request and remove the pre-created device membership.""" + approval = _get_approval(approval_id) + + if approval.state != ApprovalState.PENDING: + raise ValidationError(f"Approval is not pending (current state: {approval.state.value}).") + + membership = DeviceNetworkMembership.query.filter( + DeviceNetworkMembership.user_network_approval_id == approval_id, + DeviceNetworkMembership.deleted_at.is_(None), + ).first() + + if membership: + from datetime import datetime, timezone + membership.deleted_at = datetime.now(timezone.utc) + membership.save() + + approval.state = ApprovalState.REJECTED + approval.save() + + AuditService.log_action( + action="zt.approval.rejected", + user_id=rejecter_user_id, + organization_id=approval.organization_id, + resource_type="user_network_approval", + resource_id=approval.id, + metadata={"target_user_id": approval.user_id}, + description=f"Network access rejected for user {approval.user_id}", + success=True, + ) + + return approval + + +def revoke_approval( + approval_id: str, + revoker_user_id: str, +) -> UserNetworkApproval: + """Revoke an approved access record and deactivate all related memberships.""" + approval = _get_approval(approval_id) + + approval.state = ApprovalState.REVOKED + approval.save() + + # Deactivate all memberships + for membership in approval.memberships: + if membership.deleted_at is None: + deactivate_membership(membership.id, reason="approval_revoked") + + AuditService.log_action( + action="zt.approval.revoked", + user_id=revoker_user_id, + organization_id=approval.organization_id, + resource_type="user_network_approval", + resource_id=approval.id, + metadata={"target_user_id": approval.user_id}, + description=f"Network access revoked for user {approval.user_id}", + success=True, + ) + + return approval + + +def list_pending_approvals( + organization_id: str, + network_id: str | None = None, +) -> list[UserNetworkApproval]: + """List pending approval requests for managers.""" + q = UserNetworkApproval.query.filter( + UserNetworkApproval.organization_id == organization_id, + UserNetworkApproval.state == ApprovalState.PENDING, + UserNetworkApproval.deleted_at.is_(None), + ) + if network_id: + q = q.filter(UserNetworkApproval.portal_network_id == network_id) + return q.all() + + +def list_user_approvals(user_id: str, organization_id: str) -> list[UserNetworkApproval]: + """List all approval records for a user in an org.""" + return UserNetworkApproval.query.filter( + UserNetworkApproval.user_id == user_id, + UserNetworkApproval.organization_id == organization_id, + UserNetworkApproval.deleted_at.is_(None), + ).all() + + +# ── Membership materialisation ─────────────────────────────────────────────── + + +def _materialize_memberships_for_approval(approval: UserNetworkApproval) -> None: + """Create DeviceNetworkMembership records for all of a user's devices on a network.""" + devices = Device.query.filter( + Device.user_id == approval.user_id, + Device.organization_id == approval.organization_id, + Device.deleted_at.is_(None), + ).all() + + for device in devices: + existing = DeviceNetworkMembership.query.filter( + DeviceNetworkMembership.device_id == device.id, + DeviceNetworkMembership.portal_network_id == approval.portal_network_id, + DeviceNetworkMembership.deleted_at.is_(None), + ).first() + + if not existing: + membership = DeviceNetworkMembership( + organization_id=approval.organization_id, + user_id=approval.user_id, + device_id=device.id, + portal_network_id=approval.portal_network_id, + user_network_approval_id=approval.id, + state=MembershipState.APPROVED_INACTIVE, + approved_for_activation=True, + ) + membership.save() + + # Pre-provision the member in ZeroTier (de-authorized) + _ensure_zerotier_member(device.node_id, approval.portal_network_id, authorized=False) + + AuditService.log_action( + action="zt.membership.created", + user_id=approval.user_id, + organization_id=approval.organization_id, + resource_type="device_network_membership", + resource_id=membership.id, + metadata={ + "device_id": device.id, + "device_node_id": device.node_id, + "portal_network_id": approval.portal_network_id, + }, + description=f"Device membership created for network", + success=True, + ) + + +def materialize_device_memberships(device_id: str) -> list[DeviceNetworkMembership]: + """When a device is newly registered, create memberships for all approved networks.""" + device = Device.query.filter(Device.id == device_id, Device.deleted_at.is_(None)).first() + if not device: + raise DeviceNotFoundError(f"Device {device_id} not found.") + + created = [] + approvals = UserNetworkApproval.query.filter( + UserNetworkApproval.user_id == device.user_id, + UserNetworkApproval.organization_id == device.organization_id, + UserNetworkApproval.state == ApprovalState.APPROVED, + UserNetworkApproval.deleted_at.is_(None), + ).all() + + for approval in approvals: + existing = DeviceNetworkMembership.query.filter( + DeviceNetworkMembership.device_id == device_id, + DeviceNetworkMembership.portal_network_id == approval.portal_network_id, + DeviceNetworkMembership.deleted_at.is_(None), + ).first() + if existing: + continue + + membership = DeviceNetworkMembership( + organization_id=device.organization_id, + user_id=device.user_id, + device_id=device_id, + portal_network_id=approval.portal_network_id, + user_network_approval_id=approval.id, + state=MembershipState.APPROVED_INACTIVE, + approved_for_activation=True, + ) + membership.save() + _ensure_zerotier_member( + device.node_id, + approval.portal_network_id, + authorized=False, + ) + created.append(membership) + + return created + + +# ── Activation ─────────────────────────────────────────────────────────────── + + +def activate_device_membership( + membership_id: str, + user_id: str, + lifetime_minutes: int | None = None, +) -> ActivationSession: + """Activate an approved device on a network. Creates an activation session and authorizes in ZT.""" + membership = _get_membership(membership_id) + + if membership.user_id != user_id: + raise MembershipNotFoundError("Membership not found.") + + # Check approval is still active + if membership.user_network_approval_id: + approval = UserNetworkApproval.query.get(membership.user_network_approval_id) + if not approval or approval.state != ApprovalState.APPROVED: + raise ValidationError("Network access approval is not active.") + + # Determine lifetime + network = PortalNetwork.query.get(membership.portal_network_id) + if lifetime_minutes is None: + lifetime_minutes = network.default_activation_lifetime_minutes + if network.max_activation_lifetime_minutes and lifetime_minutes > network.max_activation_lifetime_minutes: + lifetime_minutes = network.max_activation_lifetime_minutes + + # End any existing active session + for session in membership.activation_sessions: + if session.ended_at is None: + _end_session(session, ActivationEndReason.MANUAL_REVOKE) + + # Create session + now = datetime.now(timezone.utc) + expires = now + timedelta(minutes=lifetime_minutes) + session = ActivationSession( + organization_id=membership.organization_id, + user_id=membership.user_id, + device_network_membership_id=membership.id, + authenticated_at=now, + expires_at=expires, + created_by=user_id, + ) + session.save() + + # Update membership state + membership.state = MembershipState.ACTIVE_AUTHORIZED + membership.currently_authorized = True + membership.save() + + # Authorize in ZeroTier + device = Device.query.get(membership.device_id) + _authorize_in_zerotier(device.node_id, network.zerotier_network_id, membership) + + AuditService.log_action( + action="zt.membership.activated", + user_id=user_id, + organization_id=membership.organization_id, + resource_type="activation_session", + resource_id=session.id, + metadata={ + "membership_id": membership.id, + "device_node_id": device.node_id, + "network_id": network.zerotier_network_id, + "expires_at": expires.isoformat(), + }, + description="Device membership activated", + success=True, + ) + + return session + + +def activate_all_approved( + user_id: str, + organization_id: str, + lifetime_minutes: int | None = None, +) -> list[ActivationSession]: + """Bulk-activate all approved inactive memberships for a user.""" + memberships = DeviceNetworkMembership.query.filter( + DeviceNetworkMembership.user_id == user_id, + DeviceNetworkMembership.organization_id == organization_id, + DeviceNetworkMembership.state == MembershipState.APPROVED_INACTIVE, + DeviceNetworkMembership.approved_for_activation.is_(True), + DeviceNetworkMembership.deleted_at.is_(None), + ).all() + + sessions = [] + for m in memberships: + try: + s = activate_device_membership(m.id, user_id, lifetime_minutes=lifetime_minutes) + sessions.append(s) + except Exception as exc: + logger.warning(f"[Activation] Failed to activate membership {m.id}: {exc}") + + return sessions + + +def deactivate_membership( + membership_id: str, + reason: str, + deactivated_by_user_id: str | None = None, +) -> DeviceNetworkMembership: + """Deactivate a device membership: end session, deauthorize in ZT, update state.""" + membership = _get_membership(membership_id) + + # End any active session + for session in membership.activation_sessions: + if session.ended_at is None: + end_reason = ActivationEndReason(reason) if reason in ActivationEndReason._value2member_map_ else ActivationEndReason.MANUAL_REVOKE + _end_session(session, end_reason) + + # Deauthorize in ZeroTier + device = Device.query.get(membership.device_id) + network = PortalNetwork.query.get(membership.portal_network_id) + _deauthorize_in_zerotier(device.node_id, network.zerotier_network_id) + + membership.state = MembershipState.APPROVED_INACTIVE + membership.currently_authorized = False + membership.save() + + AuditService.log_action( + action="zt.membership.deactivated", + user_id=deactivated_by_user_id, + organization_id=membership.organization_id, + resource_type="device_network_membership", + resource_id=membership.id, + metadata={ + "reason": reason, + "device_node_id": device.node_id, + "network_id": network.zerotier_network_id, + }, + description=f"Device membership deactivated: {reason}", + success=True, + ) + + return membership + + +# ── Kill switch ─────────────────────────────────────────────────────────────── + + +def kill_switch( + target_user_id: str, + triggered_by_user_id: str, + scope: str, + reason: str | None = None, + network_ids: list[str] | None = None, +) -> KillSwitchEvent: + """Immediately deauthorize all active memberships for a user.""" + scope_enum = KillSwitchScope(scope) if scope in KillSwitchScope._value2member_map_ else KillSwitchScope.ORGANIZATION + + q = DeviceNetworkMembership.query.filter( + DeviceNetworkMembership.user_id == target_user_id, + DeviceNetworkMembership.state == MembershipState.ACTIVE_AUTHORIZED, + DeviceNetworkMembership.deleted_at.is_(None), + ) + + org_id = None + if scope_enum == KillSwitchScope.ORGANIZATION: + # Use the first membership's org + first = q.first() + org_id = first.organization_id if first else None + elif scope_enum == KillSwitchScope.SELECTED_NETWORKS and network_ids: + q = q.filter(DeviceNetworkMembership.portal_network_id.in_(network_ids)) + if network_ids: + first_network = PortalNetwork.query.filter( + PortalNetwork.id.in_(network_ids), + PortalNetwork.deleted_at.is_(None), + ).first() + org_id = first_network.organization_id if first_network else None + + if not org_id: + org_id = network_ids[0] if network_ids else None + + # Create kill switch event + event = KillSwitchEvent( + organization_id=org_id or "", + target_user_id=target_user_id, + scope=scope_enum, + triggered_by_user_id=triggered_by_user_id, + reason=reason, + network_ids=network_ids, + ) + event.save() + + # Suspend all approvals + ApprovalState._value2member_map_ # just reference + approvals = UserNetworkApproval.query.filter( + UserNetworkApproval.user_id == target_user_id, + UserNetworkApproval.state == ApprovalState.APPROVED, + UserNetworkApproval.deleted_at.is_(None), + ).all() + for approval in approvals: + if scope_enum == KillSwitchScope.SELECTED_NETWORKS and network_ids: + if approval.portal_network_id not in network_ids: + continue + approval.state = ApprovalState.SUSPENDED + approval.save() + + # Deactivate memberships + memberships = q.all() + for membership in memberships: + deactivate_membership(membership.id, reason="kill_switch") + + AuditService.log_action( + action="zt.kill_switch.triggered", + user_id=triggered_by_user_id, + organization_id=org_id, + resource_type="kill_switch_event", + resource_id=event.id, + metadata={ + "target_user_id": target_user_id, + "scope": scope, + "reason": reason, + "network_ids": network_ids, + "memberships_deactivated": len(memberships), + }, + description=f"Kill switch triggered for user {target_user_id}: {len(memberships)} memberships deactivated", + success=True, + ) + + return event + + +# ── Helpers ──────────────────────────────────────────────────────────────────── + + +def _get_approval(approval_id: str) -> UserNetworkApproval: + approval = UserNetworkApproval.query.filter( + UserNetworkApproval.id == approval_id, + UserNetworkApproval.deleted_at.is_(None), + ).first() + if not approval: + raise ApprovalNotFoundError(f"Approval {approval_id} not found.") + return approval + + +def _get_membership(membership_id: str) -> DeviceNetworkMembership: + membership = DeviceNetworkMembership.query.filter( + DeviceNetworkMembership.id == membership_id, + DeviceNetworkMembership.deleted_at.is_(None), + ).first() + if not membership: + raise MembershipNotFoundError(f"Membership {membership_id} not found.") + return membership + + +def _validate_org_network(org_id: str, network_id: str) -> PortalNetwork: + network = PortalNetwork.query.filter( + PortalNetwork.id == network_id, + PortalNetwork.organization_id == org_id, + PortalNetwork.deleted_at.is_(None), + ).first() + if not network: + raise NetworkNotFoundError(f"Portal network {network_id} not found.") + return network + + +def _ensure_zerotier_member( + node_id: str, + portal_network_id: str, + authorized: bool = False, +) -> None: + """Ensure a ZeroTier member record exists (de-authorized by default).""" + network = PortalNetwork.query.get(portal_network_id) + if not network: + return + + try: + zt.add_member(network.zerotier_network_id, node_id, authorized=authorized) + except Exception as exc: + logger.warning( + f"[_ensure_zerotier_member] Could not add member {node_id} " + f"to ZT network {network.zerotier_network_id}: {exc}" + ) + + +def _authorize_in_zerotier( + node_id: str, + zerotier_network_id: str, + membership: DeviceNetworkMembership, +) -> None: + try: + zt.authorize_member(zerotier_network_id, node_id) + + # Update zerotier_membership cache + zt_membership = ZeroTierMembership.query.filter( + ZeroTierMembership.zerotier_network_id == zerotier_network_id, + ZeroTierMembership.node_id == node_id, + ZeroTierMembership.deleted_at.is_(None), + ).first() + if zt_membership: + zt_membership.authorized = True + zt_membership.last_synced_at = datetime.now(timezone.utc) + zt_membership.save() + else: + zt_membership = ZeroTierMembership( + organization_id=membership.organization_id, + device_network_membership_id=membership.id, + zerotier_network_id=zerotier_network_id, + node_id=node_id, + authorized=True, + member_seen=True, + last_synced_at=datetime.now(timezone.utc), + ) + zt_membership.save() + + AuditService.log_action( + action="zt.member.authorized", + user_id=membership.user_id, + organization_id=membership.organization_id, + resource_type="zerotier_membership", + resource_id=zt_membership.id, + metadata={"node_id": node_id, "network_id": zerotier_network_id}, + description=f"ZeroTier member {node_id} authorized on {zerotier_network_id}", + success=True, + ) + + except Exception as exc: + logger.error( + f"[_authorize_in_zerotier] Failed to authorize {node_id} " + f"on {zerotier_network_id}: {exc}" + ) + raise + + +def _deauthorize_in_zerotier(node_id: str, zerotier_network_id: str) -> None: + try: + zt.deauthorize_member(zerotier_network_id, node_id) + + zt_membership = ZeroTierMembership.query.filter( + ZeroTierMembership.zerotier_network_id == zerotier_network_id, + ZeroTierMembership.node_id == node_id, + ZeroTierMembership.deleted_at.is_(None), + ).first() + if zt_membership: + zt_membership.authorized = False + zt_membership.last_synced_at = datetime.now(timezone.utc) + zt_membership.save() + + AuditService.log_action( + action="zt.member.deauthorized", + user_id=None, + organization_id=zt_membership.organization_id, + resource_type="zerotier_membership", + resource_id=zt_membership.id, + metadata={"node_id": node_id, "network_id": zerotier_network_id}, + description=f"ZeroTier member {node_id} deauthorized on {zerotier_network_id}", + success=True, + ) + + except Exception as exc: + logger.warning( + f"[_deauthorize_in_zerotier] Failed to deauthorize {node_id} " + f"on {zerotier_network_id}: {exc}" + ) + + +def _end_session(session: ActivationSession, reason: ActivationEndReason) -> None: + session.ended_at = datetime.now(timezone.utc) + session.end_reason = reason + session.save() + + +# ── Open network join ────────────────────────────────────────────────────────── + + +def join_network_for_device( + user_id: str, + organization_id: str, + device_id: str, + portal_network_id: str, +) -> DeviceNetworkMembership: + """Join an open network with a specific registered device. + + Creates an immediately-approved UserNetworkApproval and DeviceNetworkMembership + in approved_inactive state. User can then activate. + """ + network = _validate_org_network(org_id=organization_id, network_id=portal_network_id) + + if network.request_mode.value != "open": + raise ValidationError("Network does not support direct join. Use request_access instead.") + + device = Device.query.filter( + Device.id == device_id, + Device.user_id == user_id, + Device.organization_id == organization_id, + Device.deleted_at.is_(None), + ).first() + if not device: + raise DeviceNotFoundError(f"Device {device_id} not found or does not belong to this user.") + + existing = DeviceNetworkMembership.query.filter( + DeviceNetworkMembership.device_id == device_id, + DeviceNetworkMembership.portal_network_id == portal_network_id, + DeviceNetworkMembership.deleted_at.is_(None), + ).first() + if existing: + raise ValidationError("Device already has a membership for this network.") + + approval = UserNetworkApproval( + organization_id=organization_id, + user_id=user_id, + portal_network_id=portal_network_id, + grant_type=ApprovalGrantType.REQUESTED, + state=ApprovalState.APPROVED, + justification="Direct join (open network)", + ) + approval.save() + + membership = DeviceNetworkMembership( + organization_id=organization_id, + user_id=user_id, + device_id=device_id, + portal_network_id=portal_network_id, + user_network_approval_id=approval.id, + state=MembershipState.APPROVED_INACTIVE, + approved_for_activation=True, + ) + membership.save() + + _ensure_zerotier_member(device.node_id, portal_network_id, authorized=False) + + AuditService.log_action( + action="zt.membership.created", + user_id=user_id, + organization_id=organization_id, + resource_type="device_network_membership", + resource_id=membership.id, + metadata={ + "device_id": device_id, + "device_node_id": device.node_id, + "portal_network_id": portal_network_id, + "source": "open_network_join", + }, + description=f"Device membership created (direct join) for {device.node_id}", + success=True, + ) + + return membership + + +# ── Admin membership management ──────────────────────────────────────────────── + + +def get_all_memberships_with_details(organization_id: str) -> list[dict]: + """Return all memberships for an org with enriched user/device/network info. + + Used by managers to see every device membership across all users and networks. + Returns a list of plain dicts (not model objects) for easy serialisation. + """ + memberships = DeviceNetworkMembership.query.filter( + DeviceNetworkMembership.organization_id == organization_id, + DeviceNetworkMembership.deleted_at.is_(None), + ).all() + + result = [] + for m in memberships: + device = Device.query.get(m.device_id) + network = PortalNetwork.query.get(m.portal_network_id) + approval = UserNetworkApproval.query.get(m.user_network_approval_id) if m.user_network_approval_id else None + + active_session = None + for sess in m.activation_sessions: + if sess.ended_at is None and sess.deleted_at is None: + active_session = sess.to_dict() + break + + from gatehouse_app.models.user.user import User + user = User.query.get(m.user_id) + + result.append({ + "id": m.id, + "user_id": m.user_id, + "user_email": user.email if user else m.user_id, + "user_full_name": user.full_name if user else None, + "device_id": m.device_id, + "device_nickname": device.device_nickname if device else None, + "device_hostname": device.hostname if device else None, + "device_node_id": device.node_id if device else None, + "device_status": device.status.value if device and device.status else None, + "portal_network_id": m.portal_network_id, + "network_name": network.name if network else m.portal_network_id, + "network_environment": network.environment.value if network and network.environment else None, + "state": m.state.value if m.state else None, + "join_seen": m.join_seen, + "currently_authorized": m.currently_authorized, + "approved_for_activation": m.approved_for_activation, + "user_network_approval_id": m.user_network_approval_id, + "approval_state": approval.state.value if approval and approval.state else None, + "active_session": active_session, + "created_at": m.created_at.isoformat() if m.created_at else None, + "updated_at": m.updated_at.isoformat() if m.updated_at else None, + }) + + return result + + +def revoke_membership_soft( + membership_id: str, + revoked_by_user_id: str | None = None, +) -> DeviceNetworkMembership: + """Soft-delete a membership (user or admin initiated). Sets deleted_at. + + The membership is marked deleted and the ZeroTier member will be removed + by the reconciliation job. + """ + membership = _get_membership(membership_id) + + for session in membership.activation_sessions: + if session.ended_at is None: + _end_session(session, ActivationEndReason.MANUAL_REVOKE) + + device = Device.query.get(membership.device_id) + network = PortalNetwork.query.get(membership.portal_network_id) + + if device and network: + try: + zt.deauthorize_member(network.zerotier_network_id, device.node_id) + except Exception as exc: + logger.warning(f"[revoke_membership_soft] ZT deauthorize failed for {device.node_id}: {exc}") + + membership.currently_authorized = False + membership.deleted_at = datetime.now(timezone.utc) + membership.save() + + AuditService.log_action( + action="zt.membership.revoked", + user_id=revoked_by_user_id, + organization_id=membership.organization_id, + resource_type="device_network_membership", + resource_id=membership.id, + metadata={ + "device_node_id": device.node_id if device else None, + "network_id": network.zerotier_network_id if network else None, + }, + description=f"Membership revoked for device {device.node_id if device else membership.device_id}", + success=True, + ) + + return membership + + +def hard_delete_membership(membership_id: str) -> None: + """Hard delete a membership after ZeroTier has been cleaned up. + + Called by the reconciliation job after successfully removing the member + from the ZeroTier controller. This is the final DB cleanup step. + """ + membership = DeviceNetworkMembership.query.filter( + DeviceNetworkMembership.id == membership_id, + ).first() + + if not membership: + logger.warning(f"[hard_delete_membership] Membership {membership_id} not found, skipping.") + return + + device = Device.query.get(membership.device_id) + network = PortalNetwork.query.get(membership.portal_network_id) + + if device and network: + try: + zt.delete_network_member(network.zerotier_network_id, device.node_id) + logger.info(f"[hard_delete_membership] Deleted {device.node_id} from ZT network {network.zerotier_network_id}") + except Exception as exc: + logger.warning(f"[hard_delete_membership] ZT delete failed for {device.node_id}: {exc}") + + db.session.delete(membership) + db.session.commit() + + AuditService.log_action( + action="zt.membership.deleted", + user_id=None, + organization_id=membership.organization_id, + resource_type="device_network_membership", + resource_id=membership_id, + metadata={ + "device_node_id": device.node_id if device else None, + "network_id": network.zerotier_network_id if network else None, + }, + description=f"Membership hard-deleted: device {device.node_id if device else 'unknown'} from network", + success=True, + ) diff --git a/gatehouse_app/services/portal_network_service.py b/gatehouse_app/services/portal_network_service.py new file mode 100644 index 0000000..fcff9ea --- /dev/null +++ b/gatehouse_app/services/portal_network_service.py @@ -0,0 +1,237 @@ +"""Portal network management service.""" + +import logging +import re + +from gatehouse_app.extensions import db +from gatehouse_app.models import PortalNetwork +from gatehouse_app.models.organization import Organization +from gatehouse_app.models.user import User +from gatehouse_app.services.audit_service import AuditService +from gatehouse_app.services import zerotier_api_service as zt +from gatehouse_app.utils.constants import NetworkRequestMode +from gatehouse_app.exceptions import ( + NetworkNotFoundError, + InvalidNetworkIdError, + ValidationError, + ZeroTierNotFoundError, + ZeroTierAPIError, +) + +logger = logging.getLogger(__name__) + +_NETWORK_ID_RE = re.compile(r"^[0-9a-fA-F]{16}$") + + +def _validate_network_id(network_id: str) -> str: + network_id = network_id.strip().lower() + if not _NETWORK_ID_RE.match(network_id): + raise InvalidNetworkIdError( + f"Invalid ZeroTier network ID '{network_id}' — " + "must be exactly 16 hex characters." + ) + return network_id + + +def create_network( + organization_id: str, + name: str, + owner_user_id: str, + zerotier_network_id: str, + description: str | None = None, + environment: str | None = None, + request_mode: str = "approval_required", + default_activation_lifetime_minutes: int = 480, + max_activation_lifetime_minutes: int | None = None, +) -> PortalNetwork: + """Create a new portal network. + + Args: + organization_id: Owning organization + name: Human-readable name + owner_user_id: Manager who owns this network + zerotier_network_id: 16-char hex ZT network ID + description: Optional description + environment: production | staging | development | lab + request_mode: open | approval_required | invite_only + default_activation_lifetime_minutes: Default session length + max_activation_lifetime_minutes: Cap on activation lifetime + """ + from gatehouse_app.utils.constants import NetworkEnvironment + + zerotier_network_id = _validate_network_id(zerotier_network_id) + + existing = PortalNetwork.query.filter( + PortalNetwork.organization_id == organization_id, + PortalNetwork.zerotier_network_id == zerotier_network_id, + PortalNetwork.deleted_at.is_(None), + ).first() + if existing: + raise ValidationError( + f"A portal network already exists for ZT network {zerotier_network_id} " + f"in this organization." + ) + + env = NetworkEnvironment(environment) if environment else NetworkEnvironment.DEVELOPMENT + mode = NetworkRequestMode(request_mode) + + network = PortalNetwork( + organization_id=organization_id, + name=name, + description=description, + owner_user_id=owner_user_id, + zerotier_network_id=zerotier_network_id, + environment=env, + request_mode=mode, + default_activation_lifetime_minutes=default_activation_lifetime_minutes, + max_activation_lifetime_minutes=max_activation_lifetime_minutes, + ) + network.save() + + # Try to verify the network exists in ZeroTier + try: + zt_network = zt.get_network(zerotier_network_id) + logger.info( + f"[PortalNetwork] Verified ZT network {zerotier_network_id} " + f"exists in ZeroTier: {zt_network.name}" + ) + except ZeroTierNotFoundError: + logger.warning( + f"[PortalNetwork] ZT network {zerotier_network_id} not found " + "in ZeroTier — will be reconciled later." + ) + except ZeroTierAPIError as exc: + logger.warning( + f"[PortalNetwork] Could not verify ZT network {zerotier_network_id}: {exc}" + ) + + AuditService.log_action( + action="zt.network.created", + user_id=owner_user_id, + organization_id=organization_id, + resource_type="portal_network", + resource_id=network.id, + metadata={ + "zerotier_network_id": zerotier_network_id, + "name": name, + "environment": env.value, + "request_mode": mode.value, + }, + description=f"Portal network '{name}' created (ZT: {zerotier_network_id})", + success=True, + ) + + return network + + +def list_networks( + organization_id: str, + include_inactive: bool = False, +) -> list[PortalNetwork]: + """List portal networks for an organization.""" + q = PortalNetwork.query.filter( + PortalNetwork.organization_id == organization_id, + PortalNetwork.deleted_at.is_(None), + ) + if not include_inactive: + q = q.filter(PortalNetwork.is_active.is_(True)) + return q.all() + + +def get_network(network_id: str, organization_id: str | None = None) -> PortalNetwork: + """Fetch a portal network by ID.""" + q = PortalNetwork.query.filter( + PortalNetwork.id == network_id, + PortalNetwork.deleted_at.is_(None), + ) + if organization_id: + q = q.filter(PortalNetwork.organization_id == organization_id) + + network = q.first() + if not network: + raise NetworkNotFoundError(f"Portal network {network_id} not found.") + return network + + +def update_network( + network_id: str, + user_id: str, + **kwargs, +) -> PortalNetwork: + """Update network metadata. Allowed: name, description, environment, + request_mode, default_activation_lifetime_minutes, max_activation_lifetime_minutes, is_active.""" + network = get_network(network_id) + + allowed = { + "name", + "description", + "environment", + "request_mode", + "default_activation_lifetime_minutes", + "max_activation_lifetime_minutes", + "is_active", + } + for key in kwargs: + if key not in allowed: + raise ValidationError(f"Cannot update field: {key}") + + network.update(**kwargs) + + AuditService.log_action( + action="zt.network.updated", + user_id=user_id, + organization_id=network.organization_id, + resource_type="portal_network", + resource_id=network.id, + metadata=kwargs, + description=f"Portal network '{network.name}' updated", + success=True, + ) + + return network + + +def delete_network(network_id: str, user_id: str) -> None: + """Soft-delete a portal network and deactivate all memberships.""" + 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") + + network.delete(soft=True) + + AuditService.log_action( + action="zt.network.deleted", + user_id=user_id, + organization_id=network.organization_id, + resource_type="portal_network", + resource_id=network.id, + metadata={"zerotier_network_id": network.zerotier_network_id, "name": network.name}, + description=f"Portal network '{network.name}' deleted", + success=True, + ) + + +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 DeviceNetworkMembership.query.filter( + DeviceNetworkMembership.portal_network_id == network_id, + DeviceNetworkMembership.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 + 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), + ).all() diff --git a/gatehouse_app/services/zerotier_api_service.py b/gatehouse_app/services/zerotier_api_service.py new file mode 100644 index 0000000..a501755 --- /dev/null +++ b/gatehouse_app/services/zerotier_api_service.py @@ -0,0 +1,116 @@ +"""ZeroTier API service — thin Flask adapter around the ZeroTierClient SDK. + +Reads configuration from app config and translates SDK exceptions to +Secuird typed exceptions. +""" + +import logging +from typing import Optional + +from gatehouse_app.exceptions import ZeroTierAPIError +from gatehouse_app.utils.zerotier_client import ( + APIMode, + ZeroTierAPIError as SDKZeroTierAPIError, + ZeroTierAuthError, + ZeroTierClient, + ZeroTierNotFoundError, +) + +logger = logging.getLogger(__name__) + + +def _get_client(app=None) -> ZeroTierClient: + """Build a ZeroTierClient from current app config.""" + from flask import current_app + + app = app or current_app + + mode_str = app.config.get("ZEROTIER_API_MODE", "controller") + mode = APIMode.CENTRAL if mode_str == "central" else APIMode.CONTROLLER + + return ZeroTierClient( + api_token=app.config.get("ZEROTIER_API_TOKEN", ""), + base_url=app.config.get("ZEROTIER_API_URL", "http://localhost:9993"), + mode=mode, + ) + + +def get_status() -> dict: + """Verify connectivity to the ZeroTier controller.""" + client = _get_client() + try: + return client.get_status() + except SDKZeroTierAPIError as exc: + raise ZeroTierAPIError(str(exc), status_code=exc.status_code) from exc + + +def list_networks(): + """List all networks accessible to the configured token.""" + client = _get_client() + try: + return client.list_networks() + except SDKZeroTierAPIError as exc: + raise ZeroTierAPIError(str(exc), status_code=exc.status_code) from exc + + +def get_network(network_id: str): + """Fetch a single network by ID.""" + client = _get_client() + try: + return client.get_network(network_id) + except SDKZeroTierAPIError as exc: + raise ZeroTierAPIError(str(exc), status_code=exc.status_code) from exc + + +def list_members(network_id: str): + """List all members on a network.""" + client = _get_client() + try: + return client.list_members(network_id) + except SDKZeroTierAPIError as exc: + raise ZeroTierAPIError(str(exc), status_code=exc.status_code) from exc + + +def get_member(network_id: str, node_id: str): + """Fetch a single member on a network.""" + client = _get_client() + try: + return client.get_member(network_id, node_id) + except SDKZeroTierAPIError as exc: + raise ZeroTierAPIError(str(exc), status_code=exc.status_code) from exc + + +def authorize_member(network_id: str, node_id: str): + """Authorize a member on a network. Returns updated member.""" + client = _get_client() + try: + return client.authorize_member(network_id, node_id) + except SDKZeroTierAPIError as exc: + raise ZeroTierAPIError(str(exc), status_code=exc.status_code) from exc + + +def deauthorize_member(network_id: str, node_id: str): + """De-authorize a member on a network. Returns updated member.""" + client = _get_client() + try: + return client.deauthorize_member(network_id, node_id) + except SDKZeroTierAPIError as exc: + raise ZeroTierAPIError(str(exc), status_code=exc.status_code) from exc + + +def add_member(network_id: str, node_id: str, authorized: bool = False): + """Manually add/pre-provision a member on a network.""" + client = _get_client() + try: + return client.add_member(network_id, node_id, authorized=authorized) + except SDKZeroTierAPIError as exc: + raise ZeroTierAPIError(str(exc), status_code=exc.status_code) from exc + + +def delete_network_member(network_id: str, node_id: str): + """Remove a member entirely from a ZeroTier network.""" + client = _get_client() + try: + return client.delete_member(network_id, node_id) + except SDKZeroTierAPIError as exc: + raise ZeroTierAPIError(str(exc), status_code=exc.status_code) from exc diff --git a/gatehouse_app/services/zerotier_reconciliation_service.py b/gatehouse_app/services/zerotier_reconciliation_service.py new file mode 100644 index 0000000..c43e941 --- /dev/null +++ b/gatehouse_app/services/zerotier_reconciliation_service.py @@ -0,0 +1,303 @@ +"""ZeroTier reconciliation service — polling loop to sync state with the controller.""" + +import logging +from datetime import datetime, timezone + +from gatehouse_app.extensions import db +from gatehouse_app.models import ( + Device, + DeviceNetworkMembership, + ActivationSession, + ZeroTierMembership, + PortalNetwork, + UserNetworkApproval, +) +from gatehouse_app.services import zerotier_api_service as zt +from gatehouse_app.utils.constants import ( + ActivationEndReason, + MembershipState, + ApprovalState, +) + +logger = logging.getLogger(__name__) + + +def reconcile_expired_activations() -> int: + """Find expired activation sessions and deactivate their memberships. + + Returns the number of sessions expired. + """ + now = datetime.now(timezone.utc) + expired = ActivationSession.query.filter( + ActivationSession.expires_at < now, + ActivationSession.ended_at.is_(None), + ActivationSession.deleted_at.is_(None), + ).all() + + count = 0 + for session in expired: + try: + _expire_session(session) + count += 1 + except Exception as exc: + logger.error(f"[Reconciliation] Failed to expire session {session.id}: {exc}") + + if count > 0: + logger.info(f"[Reconciliation] Expired {count} activation sessions.") + + return count + + +def reconcile_network(portal_network_id: str) -> dict: + """Full reconciliation for one portal network. + + Returns a dict with counts of actions taken. + """ + network = PortalNetwork.query.get(portal_network_id) + if not network or not network.is_active: + return {"skipped": True, "reason": "network_inactive_or_deleted"} + + zerotier_network_id = network.zerotier_network_id + actions = { + "zt_members_checked": 0, + "zt_members_added": 0, + "authorized": 0, + "deauthorized": 0, + "join_seen_updated": 0, + "unknown_members": [], + } + + # Get current ZT members + try: + zt_members = {m.node_id: m for m in zt.list_members(zerotier_network_id)} + except Exception as exc: + logger.error(f"[Reconciliation] Failed to list ZT members for {zerotier_network_id}: {exc}") + actions["error"] = str(exc) + return actions + + actions["zt_members_checked"] = len(zt_members) + + # 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), + ).all() + if m.device and m.device.deleted_at is None + } + + # Reconcile each portal membership + for node_id, membership in our_memberships.items(): + zt_member = zt_members.pop(node_id, None) + device = membership.device + + if not zt_member: + # Member not seen in ZT yet + continue + + actions["join_seen_updated"] += 1 + + # Update observed ZT membership + _sync_zt_membership(membership, zt_member) + + # Sync authorization state + if membership.state == MembershipState.ACTIVE_AUTHORIZED: + if not zt_member.is_authorized: + # We think it's active but ZT says it's not — re-authorize + try: + zt.authorize_member(zerotier_network_id, node_id) + actions["authorized"] += 1 + except Exception as exc: + logger.warning(f"[Reconciliation] Re-authorize failed for {node_id}: {exc}") + else: + if zt_member.is_authorized: + # We think it's not authorized but ZT says it is — deauthorize + # (could be manual override in ZT console) + try: + zt.deauthorize_member(zerotier_network_id, node_id) + actions["deauthorized"] += 1 + except Exception as exc: + logger.warning(f"[Reconciliation] Deauthorize failed for {node_id}: {exc}") + + # Unknown ZT members not in our portal + actions["unknown_members"] = list(zt_members.keys()) + + logger.info( + f"[Reconciliation] Network {zerotier_network_id}: " + f"checked={actions['zt_members_checked']} " + f"authorized={actions['authorized']} " + f"deauthorized={actions['deauthorized']} " + f"unknown={len(actions['unknown_members'])}" + ) + + return actions + + +def reconcile_all() -> dict: + """Run reconciliation on all active portal networks. + + Returns a summary dict. + """ + networks = PortalNetwork.query.filter( + PortalNetwork.is_active.is_(True), + PortalNetwork.deleted_at.is_(None), + ).all() + + results = {"networks_processed": 0, "errors": 0} + for network in networks: + try: + result = reconcile_network(network.id) + if "error" in result: + results["errors"] += 1 + else: + results["networks_processed"] += 1 + except Exception as exc: + logger.error(f"[Reconciliation] Failed to reconcile network {network.id}: {exc}") + results["errors"] += 1 + + deleted_result = reconcile_deleted_memberships() + results["deleted_memberships"] = deleted_result.get("deleted", 0) + results["delete_errors"] = deleted_result.get("errors", 0) + + logger.info( + f"[Reconciliation] Complete: {results['networks_processed']} networks processed, " + f"{results['errors']} errors, {results.get('deleted_memberships', 0)} memberships purged." + ) + + return results + + +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 + removes the member from ZeroTier entirely and then hard-deletes the DB record. + """ + deleted = DeviceNetworkMembership.query.filter( + DeviceNetworkMembership.deleted_at.isnot(None), + ).all() + + if not deleted: + return {"deleted": 0, "errors": 0} + + results = {"deleted": 0, "errors": 0} + for membership in deleted: + try: + device = Device.query.get(membership.device_id) + network = PortalNetwork.query.get(membership.portal_network_id) + + if not device or not network: + db.session.delete(membership) + db.session.commit() + results["deleted"] += 1 + continue + + try: + zt.delete_network_member(network.zerotier_network_id, device.node_id) + logger.info(f"[Reconciliation] Deleted {device.node_id} from ZT network {network.zerotier_network_id}") + except Exception as zt_exc: + logger.warning( + f"[Reconciliation] ZT delete failed for {device.node_id} " + f"on {network.zerotier_network_id}: {zt_exc}" + ) + + db.session.delete(membership) + db.session.commit() + results["deleted"] += 1 + + except Exception as exc: + logger.error(f"[Reconciliation] Failed to hard-delete membership {membership.id}: {exc}") + results["errors"] += 1 + + if results["deleted"] > 0: + logger.info(f"[Reconciliation] Purged {results['deleted']} memberships.") + + return results + + +def _sync_zt_membership(membership: DeviceNetworkMembership, zt_member) -> None: + """Update the ZeroTierMembership cache record from a ZT API response.""" + device = membership.device + network = membership.portal_network + + zt_membership = ZeroTierMembership.query.filter( + ZeroTierMembership.zerotier_network_id == network.zerotier_network_id, + ZeroTierMembership.node_id == device.node_id, + ZeroTierMembership.deleted_at.is_(None), + ).first() + + if not zt_membership: + zt_membership = ZeroTierMembership( + organization_id=membership.organization_id, + device_network_membership_id=membership.id, + zerotier_network_id=network.zerotier_network_id, + node_id=device.node_id, + ) + + zt_membership.member_seen = True + zt_membership.authorized = zt_member.is_authorized + zt_membership.last_synced_at = datetime.now(timezone.utc) + zt_membership.raw_controller_payload = zt_member.raw + + if zt_member.last_seen and zt_member.last_seen > 0: + zt_membership.join_seen_at = datetime.fromtimestamp( + zt_member.last_seen / 1000, tz=timezone.utc + ) + + zt_membership.save() + + # Update membership join_seen flag + if not membership.join_seen: + membership.join_seen = True + membership.state = MembershipState.JOINED_DEAUTHORIZED + membership.save() + + +def _expire_session(session: ActivationSession) -> None: + """Expire an activation session and deauthorize the membership in ZT.""" + session.ended_at = datetime.now(timezone.utc) + session.end_reason = ActivationEndReason.EXPIRED + session.save() + + membership = DeviceNetworkMembership.query.get(session.device_network_membership_id) + if membership: + membership.state = MembershipState.ACTIVATION_EXPIRED + membership.currently_authorized = False + membership.save() + + device = Device.query.get(membership.device_id) + network = PortalNetwork.query.get(membership.portal_network_id) + if device and network: + try: + zt.deauthorize_member(network.zerotier_network_id, device.node_id) + + # Update ZT membership cache + zt_membership = ZeroTierMembership.query.filter( + ZeroTierMembership.zerotier_network_id == network.zerotier_network_id, + ZeroTierMembership.node_id == device.node_id, + ZeroTierMembership.deleted_at.is_(None), + ).first() + if zt_membership: + zt_membership.authorized = False + zt_membership.last_synced_at = datetime.now(timezone.utc) + zt_membership.save() + + except Exception as exc: + logger.warning( + f"[_expire_session] Failed to deauthorize {device.node_id} " + f"on {network.zerotier_network_id}: {exc}" + ) + + from gatehouse_app.services.audit_service import AuditService + AuditService.log_action( + action="zt.activation.expired", + user_id=session.user_id, + organization_id=session.organization_id, + resource_type="activation_session", + resource_id=session.id, + metadata={"membership_id": session.device_network_membership_id}, + description="Activation session expired", + success=True, + ) diff --git a/gatehouse_app/utils/constants.py b/gatehouse_app/utils/constants.py index 201803b..1fd9285 100644 --- a/gatehouse_app/utils/constants.py +++ b/gatehouse_app/utils/constants.py @@ -213,3 +213,81 @@ class MfaRequirementOverride(str, Enum): INHERIT = "inherit" REQUIRED = "required" EXEMPT = "exempt" + + +# ── ZeroTier / Portal Network ──────────────────────────────────────────────── + + +class NetworkEnvironment(str, Enum): + """Environment tag for a portal network.""" + + PRODUCTION = "production" + STAGING = "staging" + DEVELOPMENT = "development" + LAB = "lab" + + +class NetworkRequestMode(str, Enum): + """How users request access to a portal network.""" + + OPEN = "open" # anyone in the org can request + APPROVAL_REQUIRED = "approval_required" # manager must approve + INVITE_ONLY = "invite_only" # only managers can assign + + +class ApprovalGrantType(str, Enum): + """How a user was granted network access.""" + + REQUESTED = "requested" # user initiated + ASSIGNED = "assigned" # manager initiated + + +class ApprovalState(str, Enum): + """State of a user network approval record.""" + + PENDING = "pending" + APPROVED = "approved" + REJECTED = "rejected" + REVOKED = "revoked" + 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.""" + + EXPIRED = "expired" + LOGOUT = "logout" + KILL_SWITCH = "kill_switch" + MANUAL_REVOKE = "manual_revoke" + APPROVAL_REVOKED = "approval_revoked" + ADMIN_ACTION = "admin_action" + + +class KillSwitchScope(str, Enum): + """Scope of a kill switch event.""" + + ORGANIZATION = "organization" + GLOBAL = "global" + SELECTED_NETWORKS = "selected_networks" + + +class DeviceStatus(str, Enum): + """Status of a registered device.""" + + ACTIVE = "active" + INACTIVE = "inactive" diff --git a/gatehouse_app/utils/zerotier_client.py b/gatehouse_app/utils/zerotier_client.py new file mode 100644 index 0000000..e3780c5 --- /dev/null +++ b/gatehouse_app/utils/zerotier_client.py @@ -0,0 +1,808 @@ +"""ZeroTier API client — reusable SDK for Secuird integration. + +Supports both ZeroTier Central (hosted) and self-hosted controllers. + +Central API (default): + Base URL: https://api.zerotier.com/api/v1 + Auth: Authorization: token + Docs: https://docs.zerotier.com/api/central/v1/ + +Self-hosted Controller API: + Base URL: http://:9993 + Auth: X-ZT1-Auth: + Docs: https://docs.zerotier.com/api/service/v1/ +""" + +import logging +import re +import time +from dataclasses import dataclass, field +from datetime import datetime, timezone +from enum import Enum +from typing import Any, Optional + +import requests + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Validation helpers +# --------------------------------------------------------------------------- + +_NETWORK_ID_RE = re.compile(r"^[0-9a-fA-F]{16}$") +_NODE_ID_RE = re.compile(r"^[0-9a-fA-F]{10}$") + + +def validate_network_id(network_id: str) -> str: + """Validate and normalise a 16-hex-char ZeroTier network ID.""" + network_id = network_id.strip().lower() + if not _NETWORK_ID_RE.match(network_id): + raise ValueError( + f"Invalid ZeroTier network ID '{network_id}' — " + "must be exactly 16 hexadecimal characters." + ) + return network_id + + +def validate_node_id(node_id: str) -> str: + """Validate and normalise a 10-hex-char ZeroTier node ID.""" + node_id = node_id.strip().lower() + if not _NODE_ID_RE.match(node_id): + raise ValueError( + f"Invalid ZeroTier node ID '{node_id}' — " + "must be exactly 10 hexadecimal characters." + ) + return node_id + + +# --------------------------------------------------------------------------- +# API mode +# --------------------------------------------------------------------------- + + +class APIMode(Enum): + """Which ZeroTier API flavour the client targets.""" + + CENTRAL = "central" # Hosted at api.zerotier.com + CONTROLLER = "controller" # Self-hosted zerotier-one service + + +# --------------------------------------------------------------------------- +# Data classes — typed representations of ZeroTier API objects +# --------------------------------------------------------------------------- + + +@dataclass +class ZTMemberConfig: + """Subset of the member config block we care about.""" + + authorized: bool = False + active_bridge: bool = False + ip_assignments: list[str] = field(default_factory=list) + creation_time: Optional[int] = None + last_authorized_time: Optional[int] = None + last_deauthorized_time: Optional[int] = None + version_major: Optional[int] = None + version_minor: Optional[int] = None + version_rev: Optional[int] = None + + @classmethod + def from_central(cls, data: dict) -> "ZTMemberConfig": + """Parse config from Central API member.config block.""" + return cls( + authorized=data.get("authorized", False), + active_bridge=data.get("activeBridge", False), + ip_assignments=data.get("ipAssignments", []), + creation_time=data.get("creationTime"), + last_authorized_time=data.get("lastAuthorizedTime"), + last_deauthorized_time=data.get("lastDeauthorizedTime"), + version_major=data.get("vMajor"), + version_minor=data.get("vMinor"), + version_rev=data.get("vRev"), + ) + + @classmethod + def from_controller(cls, data: dict) -> "ZTMemberConfig": + """Parse config from self-hosted controller — fields are top-level.""" + return cls( + authorized=data.get("authorized", False), + active_bridge=data.get("activeBridge", False), + ip_assignments=data.get("ipAssignments", []), + creation_time=data.get("creationTime"), + # Controller API does not return lastAuthorizedTime/lastDeauthorizedTime + version_major=data.get("vMajor"), + version_minor=data.get("vMinor"), + version_rev=data.get("vRev"), + ) + + @property + def version_string(self) -> str: + if self.version_major is not None: + return f"{self.version_major}.{self.version_minor}.{self.version_rev}" + return "unknown" + + def to_dict(self) -> dict: + return { + "authorized": self.authorized, + "active_bridge": self.active_bridge, + "ip_assignments": self.ip_assignments, + "creation_time": self.creation_time, + "last_authorized_time": self.last_authorized_time, + "last_deauthorized_time": self.last_deauthorized_time, + "version_string": self.version_string, + } + + +@dataclass +class ZTMember: + """A member (device) on a ZeroTier network.""" + + id: str # composite "{networkId}-{nodeId}" on Central, just nodeId on controller + network_id: str + node_id: str + name: Optional[str] = None + description: Optional[str] = None + hidden: bool = False + config: ZTMemberConfig = field(default_factory=ZTMemberConfig) + last_online: Optional[int] = None + last_seen: Optional[int] = None + physical_address: Optional[str] = None + client_version: Optional[str] = None + controller_id: Optional[str] = None + raw: dict = field(default_factory=dict, repr=False) + + @classmethod + def from_central(cls, data: dict) -> "ZTMember": + """Parse member from Central API response.""" + config_data = data.get("config", {}) + return cls( + id=data.get("id", ""), + network_id=data.get("networkId", ""), + node_id=data.get("nodeId", ""), + name=data.get("name"), + description=data.get("description"), + hidden=data.get("hidden", False), + config=ZTMemberConfig.from_central(config_data), + last_online=data.get("lastOnline"), + last_seen=data.get("lastSeen"), + physical_address=data.get("physicalAddress"), + client_version=data.get("clientVersion"), + controller_id=data.get("controllerId"), + raw=data, + ) + + @classmethod + def from_controller(cls, data: dict, network_id: str = "") -> "ZTMember": + """Parse member from self-hosted controller API response. + + Controller responses are flat — authorized, ipAssignments etc. are + top-level keys, not nested under a config block. + """ + node_id = data.get("id", data.get("address", "")) + nwid = data.get("nwid", network_id) + return cls( + id=f"{nwid}-{node_id}", + network_id=nwid, + node_id=node_id, + name=None, # controller API doesn't have name/description + description=None, + hidden=False, + config=ZTMemberConfig.from_controller(data), + last_online=None, # not available in controller API + last_seen=None, + physical_address=None, + client_version=None, + controller_id=None, + raw=data, + ) + + @property + def is_authorized(self) -> bool: + return self.config.authorized + + @property + def display_name(self) -> str: + return self.name or self.node_id + + @property + def ip_list(self) -> str: + return ", ".join(self.config.ip_assignments) if self.config.ip_assignments else "none" + + @property + def last_seen_str(self) -> str: + if not self.last_seen or self.last_seen == 0: + return "never" + dt = datetime.fromtimestamp(self.last_seen / 1000, tz=timezone.utc) + delta = datetime.now(tz=timezone.utc) - dt + if delta.total_seconds() < 120: + return "just now" + if delta.total_seconds() < 3600: + return f"{int(delta.total_seconds() / 60)}m ago" + if delta.total_seconds() < 86400: + return f"{int(delta.total_seconds() / 3600)}h ago" + return f"{int(delta.days)}d ago" + + def to_dict(self) -> dict: + return { + "id": self.id, + "network_id": self.network_id, + "node_id": self.node_id, + "name": self.name, + "description": self.description, + "hidden": self.hidden, + "is_authorized": self.is_authorized, + "display_name": self.display_name, + "ip_list": self.ip_list, + "last_online": self.last_online, + "last_seen": self.last_seen, + "last_seen_str": self.last_seen_str, + "client_version": self.client_version, + "controller_id": self.controller_id, + "config": self.config.to_dict(), + } + + +@dataclass +class ZTNetworkConfig: + """Subset of the network config block.""" + + name: str = "" + private: bool = True + creation_time: Optional[int] = None + ip_assignment_pools: list[dict] = field(default_factory=list) + routes: list[dict] = field(default_factory=list) + + @classmethod + def from_central(cls, data: dict) -> "ZTNetworkConfig": + """Parse from Central API network.config block.""" + return cls( + name=data.get("name", ""), + private=data.get("private", True), + creation_time=data.get("creationTime"), + ip_assignment_pools=data.get("ipAssignmentPools", []), + routes=data.get("routes", []), + ) + + @classmethod + def from_controller(cls, data: dict) -> "ZTNetworkConfig": + """Parse from self-hosted controller — fields are top-level.""" + return cls( + name=data.get("name", ""), + private=data.get("private", True), + creation_time=data.get("creationTime"), + ip_assignment_pools=data.get("ipAssignmentPools", []), + routes=data.get("routes", []), + ) + + def to_dict(self) -> dict: + return { + "name": self.name, + "private": self.private, + "creation_time": self.creation_time, + "ip_assignment_pools": self.ip_assignment_pools, + "routes": self.routes, + } + + +@dataclass +class ZTNetwork: + """A ZeroTier virtual network.""" + + id: str + config: ZTNetworkConfig = field(default_factory=ZTNetworkConfig) + description: Optional[str] = None + owner_id: Optional[str] = None + online_member_count: int = 0 + authorized_member_count: int = 0 + total_member_count: int = 0 + raw: dict = field(default_factory=dict, repr=False) + + @classmethod + def from_central(cls, data: dict) -> "ZTNetwork": + """Parse from Central API response.""" + config_data = data.get("config", {}) + return cls( + id=data.get("id", ""), + config=ZTNetworkConfig.from_central(config_data), + description=data.get("description"), + owner_id=data.get("ownerId"), + online_member_count=data.get("onlineMemberCount", 0), + authorized_member_count=data.get("authorizedMemberCount", 0), + total_member_count=data.get("totalMemberCount", 0), + raw=data, + ) + + @classmethod + def from_controller(cls, data: dict) -> "ZTNetwork": + """Parse from self-hosted controller response — flat structure.""" + return cls( + id=data.get("id", data.get("nwid", "")), + config=ZTNetworkConfig.from_controller(data), + description=None, # controller API doesn't have description + owner_id=None, + online_member_count=0, # not available from controller + authorized_member_count=0, + total_member_count=0, + raw=data, + ) + + @property + def name(self) -> str: + return self.config.name or self.id + + def to_dict(self) -> dict: + return { + "id": self.id, + "name": self.name, + "description": self.description, + "owner_id": self.owner_id, + "online_member_count": self.online_member_count, + "authorized_member_count": self.authorized_member_count, + "total_member_count": self.total_member_count, + "config": self.config.to_dict(), + } + + +# --------------------------------------------------------------------------- +# Exceptions +# --------------------------------------------------------------------------- + + +class ZeroTierAPIError(Exception): + """Base exception for ZeroTier API errors.""" + + def __init__(self, message: str, status_code: Optional[int] = None, response: Optional[dict] = None): + super().__init__(message) + self.status_code = status_code + self.response = response + + +class ZeroTierAuthError(ZeroTierAPIError): + """401/403 from ZeroTier — bad or missing token.""" + pass + + +class ZeroTierNotFoundError(ZeroTierAPIError): + """404 — network or member not found.""" + pass + + +class ZeroTierRateLimitError(ZeroTierAPIError): + """429 — rate limited.""" + pass + + +# --------------------------------------------------------------------------- +# Client +# --------------------------------------------------------------------------- + + +class ZeroTierClient: + """HTTP client for ZeroTier — supports both Central and self-hosted controllers. + + Designed to be lifted into Secuird's backend as-is. All methods are + instance methods so the client can be instantiated with different tokens + (e.g. per-organization tokens in a multi-tenant setup). + + Usage — Central (default): + client = ZeroTierClient(api_token="your-central-token") + + Usage — Self-hosted controller: + client = ZeroTierClient( + api_token="contents-of-authtoken.secret", + base_url="http://my-controller:9993", + mode=APIMode.CONTROLLER, + ) + + Then: + network = client.get_network("8056c2e21c000001") + members = client.list_members("8056c2e21c000001") + client.authorize_member("8056c2e21c000001", "abcdef0123") + client.deauthorize_member("8056c2e21c000001", "abcdef0123") + """ + + DEFAULT_CENTRAL_URL = "https://api.zerotier.com/api/v1" + DEFAULT_CONTROLLER_URL = "http://localhost:9993" + DEFAULT_TIMEOUT = 15 # seconds + + def __init__( + self, + api_token: str, + base_url: Optional[str] = None, + mode: APIMode = APIMode.CENTRAL, + timeout: int = DEFAULT_TIMEOUT, + max_retries: int = 2, + ): + if not api_token: + raise ValueError("ZeroTier API token is required.") + + self._api_token = api_token + self._mode = mode + self._timeout = timeout + self._max_retries = max_retries + + # Default base URL depends on mode + if base_url: + self._base_url = base_url.rstrip("/") + elif mode == APIMode.CENTRAL: + self._base_url = self.DEFAULT_CENTRAL_URL + else: + self._base_url = self.DEFAULT_CONTROLLER_URL + + # Build session with correct auth header + self._session = requests.Session() + self._session.headers.update( + { + "Content-Type": "application/json", + "Accept": "application/json", + } + ) + + if mode == APIMode.CENTRAL: + self._session.headers["Authorization"] = f"token {self._api_token}" + else: + self._session.headers["X-ZT1-Auth"] = self._api_token + + @property + def mode(self) -> APIMode: + return self._mode + + @property + def base_url(self) -> str: + return self._base_url + + # ------------------------------------------------------------------ + # Low-level HTTP + # ------------------------------------------------------------------ + + def _request( + self, + method: str, + path: str, + json_body: Optional[dict] = None, + params: Optional[dict] = None, + ) -> Any: + """Execute an HTTP request with retry and error mapping.""" + url = f"{self._base_url}{path}" + + last_error: Optional[Exception] = None + for attempt in range(1, self._max_retries + 1): + try: + logger.debug(f"[ZT API] {method} {url} (attempt {attempt})") + resp = self._session.request( + method=method, + url=url, + json=json_body, + params=params, + timeout=self._timeout, + ) + return self._handle_response(resp) + + except ZeroTierRateLimitError: + if attempt < self._max_retries: + wait = 2 ** attempt + logger.warning(f"[ZT API] Rate limited, retrying in {wait}s...") + time.sleep(wait) + else: + raise + + except requests.exceptions.Timeout as exc: + last_error = exc + if attempt < self._max_retries: + wait = 2 ** attempt + logger.warning(f"[ZT API] Timeout, retrying in {wait}s...") + time.sleep(wait) + else: + raise ZeroTierAPIError(f"Request timed out after {self._timeout}s") from exc + + except requests.exceptions.ConnectionError as exc: + last_error = exc + if attempt < self._max_retries: + wait = 2 ** attempt + logger.warning(f"[ZT API] Connection error, retrying in {wait}s...") + time.sleep(wait) + else: + raise ZeroTierAPIError( + f"Connection to ZeroTier API failed at {self._base_url}" + ) from exc + + # Should not reach here, but just in case + raise ZeroTierAPIError("Request failed after retries") from last_error + + def _handle_response(self, resp: requests.Response) -> Any: + """Map HTTP status codes to typed exceptions.""" + if resp.status_code == 200: + if resp.content: + return resp.json() + return None + + # Try to parse error body + error_body = None + try: + error_body = resp.json() + except (ValueError, requests.exceptions.JSONDecodeError): + pass + + msg = f"ZeroTier API error {resp.status_code}" + if error_body and isinstance(error_body, dict): + msg = error_body.get("message", msg) + + if resp.status_code in (401, 403): + raise ZeroTierAuthError(msg, status_code=resp.status_code, response=error_body) + if resp.status_code == 404: + raise ZeroTierNotFoundError(msg, status_code=resp.status_code, response=error_body) + if resp.status_code == 429: + raise ZeroTierRateLimitError(msg, status_code=resp.status_code, response=error_body) + + raise ZeroTierAPIError(msg, status_code=resp.status_code, response=error_body) + + # ------------------------------------------------------------------ + # Path builders — abstract away Central vs Controller URL differences + # ------------------------------------------------------------------ + + def _network_path(self, network_id: str = "") -> str: + """Build the path prefix for network endpoints.""" + if self._mode == APIMode.CENTRAL: + return f"/network/{network_id}" if network_id else "/network" + else: + return f"/controller/network/{network_id}" if network_id else "/controller/network" + + def _member_path(self, network_id: str, node_id: str = "") -> str: + """Build the path for member endpoints.""" + base = self._network_path(network_id) + "/member" + if node_id: + base += f"/{node_id}" + return base + + # ------------------------------------------------------------------ + # Status / connectivity check + # ------------------------------------------------------------------ + + def get_status(self) -> dict: + """Verify token and connectivity. + + Central: GET /status — returns account/user info + Controller: GET /controller — returns controller status + """ + if self._mode == APIMode.CENTRAL: + return self._request("GET", "/status") + else: + # Combine /status (node info) and /controller (controller flag) + node_status = self._request("GET", "/status") + try: + ctrl_status = self._request("GET", "/controller") + node_status["_controller"] = ctrl_status + except ZeroTierAPIError: + node_status["_controller"] = None + return node_status + + # ------------------------------------------------------------------ + # Network operations + # ------------------------------------------------------------------ + + def list_networks(self) -> list[ZTNetwork]: + """List all networks the token has access to.""" + data = self._request("GET", self._network_path()) + + if self._mode == APIMode.CENTRAL: + # Central returns an array of full network objects + return [ZTNetwork.from_central(n) for n in data] + else: + # Controller returns an array of network ID strings + # e.g. ["3e245e31af000001", "3e245e31af000002"] + # Fetch each one for full details + networks = [] + for nwid in data: + try: + net = self.get_network(nwid) + networks.append(net) + except ZeroTierAPIError: + logger.warning(f"[ZT API] Failed to fetch network {nwid}, skipping") + return networks + + def get_network(self, network_id: str) -> ZTNetwork: + """Fetch a single network by ID.""" + network_id = validate_network_id(network_id) + data = self._request("GET", self._network_path(network_id)) + + if self._mode == APIMode.CENTRAL: + return ZTNetwork.from_central(data) + else: + return ZTNetwork.from_controller(data) + + # ------------------------------------------------------------------ + # Member operations — the core of what Secuird needs + # ------------------------------------------------------------------ + + def list_members(self, network_id: str) -> list[ZTMember]: + """List all members on a network.""" + network_id = validate_network_id(network_id) + data = self._request("GET", self._member_path(network_id)) + + if self._mode == APIMode.CENTRAL: + # Central returns an array of full member objects + return [ZTMember.from_central(m) for m in data] + else: + # Controller returns {"nodeId": revisionCounter, ...} + # We must fetch each member individually for full details + members = [] + for node_id in data: + try: + member = self.get_member(network_id, node_id) + members.append(member) + except ZeroTierAPIError: + logger.warning( + f"[ZT API] Failed to fetch member {node_id} on " + f"network {network_id}, skipping" + ) + return members + + def get_member(self, network_id: str, node_id: str) -> ZTMember: + """Fetch a single member on a network.""" + network_id = validate_network_id(network_id) + node_id = validate_node_id(node_id) + data = self._request("GET", self._member_path(network_id, node_id)) + + if self._mode == APIMode.CENTRAL: + return ZTMember.from_central(data) + else: + return ZTMember.from_controller(data, network_id=network_id) + + def _build_auth_body(self, authorized: bool) -> dict: + """Build the JSON body for authorize/deauthorize calls. + + Central nests under config: {"config": {"authorized": true}} + Controller is flat: {"authorized": true} + """ + if self._mode == APIMode.CENTRAL: + return {"config": {"authorized": authorized}} + else: + return {"authorized": authorized} + + def authorize_member(self, network_id: str, node_id: str) -> ZTMember: + """Authorize a member on a network. + + This is the enforcement action: the device can now communicate + on the network. + """ + network_id = validate_network_id(network_id) + node_id = validate_node_id(node_id) + logger.info(f"[ZT API] Authorizing member {node_id} on network {network_id}") + data = self._request( + "POST", + self._member_path(network_id, node_id), + json_body=self._build_auth_body(True), + ) + if self._mode == APIMode.CENTRAL: + return ZTMember.from_central(data) + return ZTMember.from_controller(data, network_id=network_id) + + def deauthorize_member(self, network_id: str, node_id: str) -> ZTMember: + """De-authorize a member on a network. + + The member remains in the member list but cannot communicate. + This is the standard ZeroTier pattern for revoking access without + deleting the member record. + """ + network_id = validate_network_id(network_id) + node_id = validate_node_id(node_id) + logger.info(f"[ZT API] De-authorizing member {node_id} on network {network_id}") + data = self._request( + "POST", + self._member_path(network_id, node_id), + json_body=self._build_auth_body(False), + ) + if self._mode == APIMode.CENTRAL: + return ZTMember.from_central(data) + return ZTMember.from_controller(data, network_id=network_id) + + def add_member(self, network_id: str, node_id: str, authorized: bool = False) -> ZTMember: + """Manually add a member to a network. + + Creates the member record on the controller side. By default the + member is de-authorized (matching the Secuird workflow where + activation is a separate step). + """ + network_id = validate_network_id(network_id) + node_id = validate_node_id(node_id) + logger.info( + f"[ZT API] Adding member {node_id} to network {network_id} " + f"(authorized={authorized})" + ) + data = self._request( + "POST", + self._member_path(network_id, node_id), + json_body=self._build_auth_body(authorized), + ) + if self._mode == APIMode.CENTRAL: + return ZTMember.from_central(data) + return ZTMember.from_controller(data, network_id=network_id) + + def delete_member(self, network_id: str, node_id: str) -> None: + """Remove a member entirely. + + Use with caution. In most Secuird workflows we de-authorize rather + than delete, so the member record persists for audit purposes. + + Note: the self-hosted controller API does not document a DELETE + member endpoint; this may only work on Central. + """ + network_id = validate_network_id(network_id) + node_id = validate_node_id(node_id) + logger.warning(f"[ZT API] Deleting member {node_id} from network {network_id}") + self._request("DELETE", self._member_path(network_id, node_id)) + + def update_member( + self, + network_id: str, + node_id: str, + *, + name: Optional[str] = None, + description: Optional[str] = None, + authorized: Optional[bool] = None, + ip_assignments: Optional[list[str]] = None, + ) -> ZTMember: + """Update member metadata or config fields.""" + network_id = validate_network_id(network_id) + node_id = validate_node_id(node_id) + + if self._mode == APIMode.CENTRAL: + body: dict[str, Any] = {} + if name is not None: + body["name"] = name + if description is not None: + body["description"] = description + config: dict[str, Any] = {} + if authorized is not None: + config["authorized"] = authorized + if ip_assignments is not None: + config["ipAssignments"] = ip_assignments + if config: + body["config"] = config + else: + # Controller API is flat + body = {} + if authorized is not None: + body["authorized"] = authorized + if ip_assignments is not None: + body["ipAssignments"] = ip_assignments + # name/description not supported on controller API + + data = self._request( + "POST", + self._member_path(network_id, node_id), + json_body=body, + ) + if self._mode == APIMode.CENTRAL: + return ZTMember.from_central(data) + return ZTMember.from_controller(data, network_id=network_id) + + # ------------------------------------------------------------------ + # Bulk operations — useful for kill-switch and reconciliation + # ------------------------------------------------------------------ + + def deauthorize_all_members(self, network_id: str) -> list[ZTMember]: + """De-authorize every member on a network. Returns updated members.""" + members = self.list_members(network_id) + results = [] + for m in members: + if m.is_authorized: + updated = self.deauthorize_member(network_id, m.node_id) + results.append(updated) + return results + + def get_authorized_members(self, network_id: str) -> list[ZTMember]: + """Return only currently authorized members.""" + return [m for m in self.list_members(network_id) if m.is_authorized] + + def get_online_members(self, network_id: str) -> list[ZTMember]: + """Return members seen in the last 5 minutes (Central API only). + + The self-hosted controller API does not expose lastOnline, so this + will return an empty list in controller mode. + """ + cutoff = (int(time.time()) - 300) * 1000 # ms + return [ + m for m in self.list_members(network_id) + if m.last_online and m.last_online > cutoff + ] diff --git a/manage.py b/manage.py index a89d335..3f694fc 100644 --- a/manage.py +++ b/manage.py @@ -72,6 +72,47 @@ def run_mfa_compliance_job(): print("=" * 60) +@cli.command("run_zerotier_reconciliation") +def run_zerotier_reconciliation(): + """Run the ZeroTier network reconciliation scheduled job. + + This command: + - Expires activation sessions past their TTL and deauthorizes ZT members + - Syncs observed ZeroTier membership state into the portal cache + - Reconciles portal membership state against ZT controller state + - Detects and repairs drift between portal and ZT + + Usage: + python manage.py run_zerotier_reconciliation + + Cron example (every 2 minutes): + */2 * * * * cd /path/to/app && python manage.py run_zerotier_reconciliation + """ + from datetime import datetime, timezone + from gatehouse_app.jobs.zerotier_reconciliation_job import run_reconciliation + + print("=" * 60) + print("ZeroTier Reconciliation Job") + print("=" * 60) + + now = datetime.now(timezone.utc) + print(f"Start time: {now.isoformat()}") + print() + + result = run_reconciliation() + + print() + print("Job Results:") + print(f" Expired activations: {result['expired_activations']}") + print(f" Networks processed: {result['networks_processed']}") + print(f" Errors: {result['errors']}") + + print() + print("=" * 60) + print("Job completed successfully") + print("=" * 60) + + @cli.command("mfa_compliance_status") def mfa_compliance_status(): """Show current MFA compliance status. diff --git a/migrations/versions/020_add_zerotier_models.py b/migrations/versions/020_add_zerotier_models.py new file mode 100644 index 0000000..5b15439 --- /dev/null +++ b/migrations/versions/020_add_zerotier_models.py @@ -0,0 +1,317 @@ +"""Add ZeroTier / Portal Network models. + +Revision ID: 020_zerotier +Revises: 019_audit_varchar +Create Date: 2026-03-19 + +Tables created: + - portal_networks — manager-created ZeroTier network bindings + - devices — user-registered ZeroTier node endpoints + - user_network_approvals — durable manager approval records + - device_network_memberships — per-device per-network workflow records + - activation_sessions — temporary activation windows + - zerotier_memberships — observed controller-side member state + - kill_switch_events — explicit rapid deactivation records +""" + +from alembic import op +import sqlalchemy as sa + +revision = "020_zerotier" +down_revision = "019_audit_varchar" +branch_labels = None +depends_on = None + + +def _pg_enum(enum_name: str, values: list[str]) -> sa.Enum: + return sa.Enum(*values, name=enum_name, create_type=False) + + +def upgrade(): + bind = op.get_bind() + dialect = bind.dialect.name + + # ── 1. Enum types ───────────────────────────────────────────────────────── + + if dialect == "postgresql": + op.execute("CREATE TYPE network_environment AS ENUM (%s)" % ", ".join( + f"'{v}'" for v in ["production", "staging", "development", "lab"] + )) + op.execute("CREATE TYPE network_request_mode AS ENUM (%s)" % ", ".join( + f"'{v}'" for v in ["open", "approval_required", "invite_only"] + )) + op.execute("CREATE TYPE approval_grant_type AS ENUM (%s)" % ", ".join( + f"'{v}'" for v in ["requested", "assigned"] + )) + op.execute("CREATE TYPE approval_state AS ENUM (%s)" % ", ".join( + f"'{v}'" for v in ["pending", "approved", "rejected", "revoked", "suspended"] + )) + op.execute("CREATE TYPE membership_state AS ENUM (%s)" % ", ".join( + f"'{v}'" for v in [ + "pending_device_registration", + "pending_request", + "pending_manager_approval", + "approved_inactive", + "joined_deauthorized", + "active_authorized", + "activation_expired", + "suspended", + "revoked", + "rejected", + ] + )) + op.execute("CREATE TYPE activation_end_reason AS ENUM (%s)" % ", ".join( + f"'{v}'" for v in [ + "expired", "logout", "kill_switch", + "manual_revoke", "approval_revoked", "admin_action", + ] + )) + op.execute("CREATE TYPE kill_switch_scope AS ENUM (%s)" % ", ".join( + f"'{v}'" for v in ["organization", "global", "selected_networks"] + )) + op.execute("CREATE TYPE device_status AS ENUM (%s)" % ", ".join( + f"'{v}'" for v in ["active", "inactive"] + )) + + # ── 2. portal_networks ──────────────────────────────────────────────────── + + op.create_table( + "portal_networks", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("organization_id", sa.String(36), sa.ForeignKey("organizations.id"), nullable=False, index=True), + sa.Column("name", sa.String(255), nullable=False), + sa.Column("description", sa.Text, nullable=True), + sa.Column("owner_user_id", sa.String(36), sa.ForeignKey("users.id"), nullable=False), + sa.Column("zerotier_network_id", sa.String(16), nullable=False, index=True), + sa.Column( + "environment", + _pg_enum("network_environment", ["production", "staging", "development", "lab"]) if dialect == "postgresql" + else sa.String(20), + nullable=False, + ), + sa.Column( + "request_mode", + _pg_enum("network_request_mode", ["open", "approval_required", "invite_only"]) if dialect == "postgresql" + else sa.String(20), + nullable=False, + ), + sa.Column("default_activation_lifetime_minutes", sa.Integer, nullable=False, default=480), + sa.Column("max_activation_lifetime_minutes", sa.Integer, nullable=True), + sa.Column("is_active", sa.Boolean, nullable=False, default=True), + ) + op.create_index( + "ix_portal_networks_org_zt", + "portal_networks", + ["organization_id", "zerotier_network_id"], + unique=True, + postgresql_where=sa.text("deleted_at IS NULL"), + ) + + # ── 3. devices ─────────────────────────────────────────────────────────── + + op.create_table( + "devices", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("user_id", sa.String(36), sa.ForeignKey("users.id"), nullable=False, index=True), + sa.Column("organization_id", sa.String(36), sa.ForeignKey("organizations.id"), nullable=False, index=True), + sa.Column("node_id", sa.String(10), nullable=False, index=True), + sa.Column("device_nickname", sa.String(255), nullable=True), + sa.Column("hostname", sa.String(255), nullable=True), + sa.Column("asset_tag", sa.String(255), nullable=True), + sa.Column("serial_number", sa.String(255), nullable=True), + sa.Column( + "status", + _pg_enum("device_status", ["active", "inactive"]) if dialect == "postgresql" + else sa.String(20), + nullable=False, + default="active", + ), + ) + if dialect == "postgresql": + op.create_index( + "ix_devices_node_id_active", + "devices", + ["node_id"], + unique=True, + postgresql_where=sa.text("deleted_at IS NULL"), + ) + else: + op.create_index("ix_devices_node_id", "devices", ["node_id"], unique=False) + + # ── 4. user_network_approvals ───────────────────────────────────────────── + + op.create_table( + "user_network_approvals", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("organization_id", sa.String(36), sa.ForeignKey("organizations.id"), nullable=False, index=True), + sa.Column("user_id", sa.String(36), sa.ForeignKey("users.id"), nullable=False, index=True), + sa.Column("portal_network_id", sa.String(36), sa.ForeignKey("portal_networks.id"), nullable=False, index=True), + sa.Column("granted_by_user_id", sa.String(36), sa.ForeignKey("users.id"), nullable=True), + sa.Column( + "grant_type", + _pg_enum("approval_grant_type", ["requested", "assigned"]) if dialect == "postgresql" + else sa.String(20), + nullable=False, + default="requested", + ), + sa.Column( + "state", + _pg_enum("approval_state", ["pending", "approved", "rejected", "revoked", "suspended"]) if dialect == "postgresql" + else sa.String(20), + nullable=False, + default="pending", + index=True, + ), + sa.Column("justification", sa.Text, nullable=True), + ) + op.create_index( + "ix_user_network_approvals_user_network", + "user_network_approvals", + ["user_id", "portal_network_id"], + unique=True, + postgresql_where=sa.text("deleted_at IS NULL"), + ) + + # ── 5. device_network_memberships ──────────────────────────────────────── + + op.create_table( + "device_network_memberships", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("organization_id", sa.String(36), sa.ForeignKey("organizations.id"), nullable=False, index=True), + sa.Column("user_id", sa.String(36), sa.ForeignKey("users.id"), nullable=False, index=True), + sa.Column("device_id", sa.String(36), sa.ForeignKey("devices.id"), nullable=False, index=True), + sa.Column("portal_network_id", sa.String(36), sa.ForeignKey("portal_networks.id"), nullable=False, index=True), + sa.Column("user_network_approval_id", sa.String(36), sa.ForeignKey("user_network_approvals.id"), nullable=True, index=True), + sa.Column( + "state", + _pg_enum( + "membership_state", + [ + "pending_device_registration", "pending_request", + "pending_manager_approval", "approved_inactive", + "joined_deauthorized", "active_authorized", + "activation_expired", "suspended", "revoked", "rejected", + ], + ) if dialect == "postgresql" else sa.String(30), + nullable=False, + default="pending_device_registration", + index=True, + ), + sa.Column("join_seen", sa.Boolean, nullable=False, default=False), + sa.Column("currently_authorized", sa.Boolean, nullable=False, default=False), + sa.Column("approved_for_activation", sa.Boolean, nullable=False, default=True), + ) + op.create_index( + "ix_device_network_memberships_device_network", + "device_network_memberships", + ["device_id", "portal_network_id"], + unique=True, + postgresql_where=sa.text("deleted_at IS NULL"), + ) + + # ── 6. activation_sessions ──────────────────────────────────────────────── + + op.create_table( + "activation_sessions", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("organization_id", sa.String(36), sa.ForeignKey("organizations.id"), nullable=False, index=True), + sa.Column("user_id", sa.String(36), sa.ForeignKey("users.id"), nullable=False, index=True), + sa.Column("device_network_membership_id", sa.String(36), sa.ForeignKey("device_network_memberships.id"), nullable=False, index=True), + sa.Column("authenticated_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("ended_at", sa.DateTime(timezone=True), nullable=True), + sa.Column( + "end_reason", + _pg_enum( + "activation_end_reason", + ["expired", "logout", "kill_switch", "manual_revoke", "approval_revoked", "admin_action"], + ) if dialect == "postgresql" else sa.String(20), + nullable=True, + ), + sa.Column("created_by", sa.String(36), sa.ForeignKey("users.id"), nullable=False), + ) + + # ── 7. zerotier_memberships ─────────────────────────────────────────────── + + op.create_table( + "zerotier_memberships", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("organization_id", sa.String(36), sa.ForeignKey("organizations.id"), nullable=False, index=True), + sa.Column("device_network_membership_id", sa.String(36), sa.ForeignKey("device_network_memberships.id"), nullable=True, index=True), + sa.Column("zerotier_network_id", sa.String(16), nullable=False, index=True), + sa.Column("node_id", sa.String(10), nullable=False, index=True), + sa.Column("member_seen", sa.Boolean, nullable=False, default=False), + sa.Column("authorized", sa.Boolean, nullable=False, default=False), + sa.Column("join_seen_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("last_synced_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("raw_controller_payload", sa.JSON, nullable=True), + ) + op.create_index( + "ix_zerotier_memberships_network_node", + "zerotier_memberships", + ["zerotier_network_id", "node_id"], + unique=True, + ) + + # ── 8. kill_switch_events ──────────────────────────────────────────────── + + op.create_table( + "kill_switch_events", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("organization_id", sa.String(36), sa.ForeignKey("organizations.id"), nullable=False, index=True), + sa.Column("target_user_id", sa.String(36), sa.ForeignKey("users.id"), nullable=False, index=True), + sa.Column( + "scope", + _pg_enum("kill_switch_scope", ["organization", "global", "selected_networks"]) if dialect == "postgresql" + else sa.String(20), + nullable=False, + default="organization", + ), + sa.Column("triggered_by_user_id", sa.String(36), sa.ForeignKey("users.id"), nullable=False), + sa.Column("reason", sa.Text, nullable=True), + sa.Column("network_ids", sa.JSON, nullable=True), + ) + + +def downgrade(): + bind = op.get_bind() + dialect = bind.dialect.name + + op.drop_table("kill_switch_events") + op.drop_table("zerotier_memberships") + op.drop_table("activation_sessions") + op.drop_table("device_network_memberships") + op.drop_table("user_network_approvals") + op.drop_table("devices") + op.drop_table("portal_networks") + + if dialect == "postgresql": + op.execute("DROP TYPE IF EXISTS kill_switch_scope") + op.execute("DROP TYPE IF EXISTS device_status") + op.execute("DROP TYPE IF EXISTS activation_end_reason") + op.execute("DROP TYPE IF EXISTS membership_state") + op.execute("DROP TYPE IF EXISTS approval_state") + op.execute("DROP TYPE IF EXISTS approval_grant_type") + op.execute("DROP TYPE IF EXISTS network_request_mode") + op.execute("DROP TYPE IF EXISTS network_environment") diff --git a/scripts/init_db.py b/scripts/init_db.py index c6ecda9..6f620bd 100644 --- a/scripts/init_db.py +++ b/scripts/init_db.py @@ -23,8 +23,12 @@ with app.app_context(): for i in range(5, 0, -1): print(f"{i}...") time.sleep(1) - db.session.execute(text("DROP SCHEMA public CASCADE")) - db.session.execute(text("CREATE SCHEMA public")) + + if db_url.startswith("sqlite"): + db.drop_all() + else: + db.session.execute(text("DROP SCHEMA public CASCADE")) + db.session.execute(text("CREATE SCHEMA public")) db.session.commit() # Create all tables