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
@@ -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