feat(zerotier): add ZeroTier network governance module

Add comprehensive ZeroTier integration for managing network access:

- Portal networks: manager-created ZeroTier network bindings
- Device registration: user-owned ZeroTier node endpoints
- Approval workflows: request/approve/revoke network access
- Activation sessions: time-limited network authorization
- Kill switch: emergency access revocation
- Reconciliation job: sync portal state with ZeroTier controller

Includes ZeroTier client SDK supporting both Central and self-hosted
controller APIs, with full CRUD operations for networks and members.
This commit is contained in:
2026-03-20 21:50:20 +10:30
parent 49e724222f
commit 1789590167
27 changed files with 4862 additions and 4 deletions
+29 -1
View File
@@ -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",
]
+18
View File
@@ -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
@@ -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"<ActivationSession membership={self.device_network_membership_id} "
f"expires={self.expires_at}>"
)
@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
+79
View File
@@ -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"<Device {self.node_id} ({self.device_nickname or 'unnamed'})>"
@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
@@ -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"<DeviceNetworkMembership device={self.device_id} "
f"network={self.portal_network_id} state={self.state}>"
)
@property
def active_session(self):
"""Return the current active ActivationSession, if any."""
for s in self.activation_sessions:
if s.ended_at is None and s.expires_at is not None:
from datetime import datetime, timezone
now = datetime.now(timezone.utc)
exp = s.expires_at
if exp.tzinfo is None:
exp = exp.replace(tzinfo=timezone.utc)
if exp > now:
return s
return None
def to_dict(self, exclude=None):
exclude = exclude or []
data = super().to_dict(exclude=exclude)
data["active_session"] = (
self.active_session.to_dict() if self.active_session else None
)
return data
@@ -0,0 +1,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"<KillSwitchEvent target={self.target_user_id} "
f"scope={self.scope} by={self.triggered_by_user_id}>"
)
@@ -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"<PortalNetwork {self.name} ({self.zerotier_network_id})>"
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
@@ -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"<UserNetworkApproval user={self.user_id} "
f"network={self.portal_network_id} state={self.state}>"
)
def to_dict(self, exclude=None):
exclude = exclude or []
data = super().to_dict(exclude=exclude)
data["active_membership_count"] = sum(
1 for m in self.memberships if m.deleted_at is None
)
return data
@@ -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"<ZeroTierMembership network={self.zerotier_network_id} "
f"node={self.node_id} authorized={self.authorized}>"
)
def to_dict(self, exclude=None):
exclude = exclude or []
data = super().to_dict(exclude=exclude)
return data