feat: allow admins to bypass approval flow when joining networks

This commit is contained in:
Ubuntu
2026-05-07 20:04:08 +00:00
parent 32d517ea08
commit d100fdff3b
34 changed files with 2523 additions and 1637 deletions
+2 -4
View File
@@ -2,8 +2,7 @@
PortalNetwork — manager-created network bound to a ZT network ID
Device — user-registered ZeroTier node endpoint
UserNetworkApproval — durable manager approval for network access
DeviceNetworkMembership — per-device per-network workflow record
NetworkAccessRequest — unified per-device, per-network access record
ActivationSession — temporary activation window
ZeroTierMembership — observed controller-side member state
KillSwitchEvent — explicit rapid deactivation record
@@ -11,8 +10,7 @@ KillSwitchEvent — explicit rapid deactivation record
from gatehouse_app.models.zerotier.activation_session import ActivationSession # noqa: F401
from gatehouse_app.models.zerotier.device import Device # noqa: F401
from gatehouse_app.models.zerotier.device_network_membership import DeviceNetworkMembership # noqa: F401
from gatehouse_app.models.zerotier.kill_switch_event import KillSwitchEvent # noqa: F401
from gatehouse_app.models.zerotier.network_access_request import NetworkAccessRequest # noqa: F401
from gatehouse_app.models.zerotier.portal_network import PortalNetwork # noqa: F401
from gatehouse_app.models.zerotier.user_network_approval import UserNetworkApproval # noqa: F401
from gatehouse_app.models.zerotier.zerotier_membership import ZeroTierMembership # noqa: F401
@@ -16,7 +16,7 @@ class ActivationSession(BaseModel):
Attributes:
organization_id: FK to the organization
user_id: FK to the user who owns the session
device_network_membership_id: FK to the related membership
network_access_request_id: FK to the related network access request
authenticated_at: When the user re-authenticated to start this session
expires_at: When the activation window ends
ended_at: When the session was explicitly ended (null if still active)
@@ -38,9 +38,9 @@ class ActivationSession(BaseModel):
nullable=False,
index=True,
)
device_network_membership_id = db.Column(
network_access_request_id = db.Column(
db.String(36),
db.ForeignKey("device_network_memberships.id"),
db.ForeignKey("network_access_requests.id"),
nullable=False,
index=True,
)
@@ -75,14 +75,14 @@ class ActivationSession(BaseModel):
foreign_keys=[created_by],
backref="created_activation_sessions",
)
membership = db.relationship(
"DeviceNetworkMembership",
access_request = db.relationship(
"NetworkAccessRequest",
back_populates="activation_sessions",
)
def __repr__(self):
return (
f"<ActivationSession membership={self.device_network_membership_id} "
f"<ActivationSession request={self.network_access_request_id} "
f"expires={self.expires_at}>"
)
+5 -5
View File
@@ -2,7 +2,7 @@
from gatehouse_app.extensions import db
from gatehouse_app.models.base import BaseModel
from gatehouse_app.utils.constants import DeviceStatus
from gatehouse_app.utils.constants import ApprovalState, DeviceStatus
class Device(BaseModel):
@@ -55,8 +55,8 @@ class Device(BaseModel):
# Relationships
user = db.relationship("User", backref="devices")
organization = db.relationship("Organization", backref="devices")
memberships = db.relationship(
"DeviceNetworkMembership",
network_access_requests = db.relationship(
"NetworkAccessRequest",
back_populates="device",
cascade="all, delete-orphan",
)
@@ -73,7 +73,7 @@ class Device(BaseModel):
data = super().to_dict(exclude=exclude)
data["display_name"] = self.display_name
data["active_membership_count"] = sum(
1 for m in self.memberships
if m.state == "active_authorized" and m.deleted_at is None
1 for r in self.network_access_requests
if r.active and r.status == ApprovalState.APPROVED and r.deleted_at is None
)
return data
@@ -1,129 +0,0 @@
"""Device network membership — per-device, per-network workflow object."""
from gatehouse_app.extensions import db
from gatehouse_app.models.base import BaseModel
from gatehouse_app.utils.constants import MembershipState
class DeviceNetworkMembership(BaseModel):
"""The main per-device, per-network workflow record.
This binds a specific Device to a specific PortalNetwork through a
UserNetworkApproval. It tracks both the internal portal state and the
observed ZeroTier membership state.
States:
pending_device_registration — approval exists but no device registered yet
pending_request — user has requested access but not yet approved
pending_manager_approval — approval pending manager sign-off
approved_inactive — approved but not currently active
joined_deauthorized — device has joined ZT network but not authorized
active_authorized — authorized and actively connected
activation_expired — activation window ended (member still in ZT, deauth'd)
suspended — temporarily suspended
revoked — permanently revoked
rejected — request was rejected
"""
__tablename__ = "device_network_memberships"
organization_id = db.Column(
db.String(36),
db.ForeignKey("organizations.id"),
nullable=False,
index=True,
)
user_id = db.Column(
db.String(36),
db.ForeignKey("users.id"),
nullable=False,
index=True,
)
device_id = db.Column(
db.String(36),
db.ForeignKey("devices.id"),
nullable=False,
index=True,
)
portal_network_id = db.Column(
db.String(36),
db.ForeignKey("portal_networks.id"),
nullable=False,
index=True,
)
user_network_approval_id = db.Column(
db.String(36),
db.ForeignKey("user_network_approvals.id"),
nullable=True,
index=True,
)
state = db.Column(
db.Enum(MembershipState, name="membership_state", values_callable=lambda x: [e.value for e in x]),
default=MembershipState.PENDING_DEVICE_REGISTRATION,
nullable=False,
index=True,
)
join_seen = db.Column(db.Boolean, default=False, nullable=False)
currently_authorized = db.Column(db.Boolean, default=False, nullable=False)
approved_for_activation = db.Column(db.Boolean, default=True, nullable=False)
# Relationships
organization = db.relationship("Organization", backref="network_memberships")
user = db.relationship("User", backref="network_memberships")
device = db.relationship("Device", back_populates="memberships")
portal_network = db.relationship(
"PortalNetwork",
back_populates="memberships",
)
approval = db.relationship(
"UserNetworkApproval",
back_populates="memberships",
)
activation_sessions = db.relationship(
"ActivationSession",
back_populates="membership",
cascade="all, delete-orphan",
)
zerotier_membership = db.relationship(
"ZeroTierMembership",
back_populates="device_network_membership",
uselist=False,
cascade="all, delete-orphan",
)
__table_args__ = (
db.UniqueConstraint(
"device_id",
"portal_network_id",
"deleted_at",
name="uix_device_network",
),
)
def __repr__(self):
return (
f"<DeviceNetworkMembership device={self.device_id} "
f"network={self.portal_network_id} state={self.state}>"
)
@property
def active_session(self):
"""Return the current active ActivationSession, if any."""
for s in self.activation_sessions:
if s.ended_at is None and s.expires_at is not None:
from datetime import datetime, timezone
now = datetime.now(timezone.utc)
exp = s.expires_at
if exp.tzinfo is None:
exp = exp.replace(tzinfo=timezone.utc)
if exp > now:
return s
return None
def to_dict(self, exclude=None):
exclude = exclude or []
data = super().to_dict(exclude=exclude)
data["active_session"] = (
self.active_session.to_dict() if self.active_session else None
)
return data
@@ -0,0 +1,147 @@
"""Network access request model — unified per-device, per-network access record."""
from datetime import datetime, timezone
from gatehouse_app.extensions import db
from gatehouse_app.models.base import BaseModel
from gatehouse_app.utils.constants import ApprovalGrantType, ApprovalState
class NetworkAccessRequest(BaseModel):
"""A unified access record binding a user's device to a portal network.
Replaces the separate UserNetworkApproval and DeviceNetworkMembership
tables with a single per-device, per-network row. Each row tracks both
the business-level approval status and the device-level active/inactive
toggle.
Attributes:
organization_id: FK to the organization
user_id: FK to the requesting user
device_id: FK to the specific device
portal_network_id: FK to the portal network
granted_by_user_id: FK to the manager who approved (null for user-initiated)
grant_type: requested (user-initiated) or assigned (manager-initiated)
status: pending / approved / rejected / revoked / suspended
active: whether the device connection is currently live
justification: Business reason for the request
join_seen: Whether the device has been seen joining the ZeroTier network
"""
__tablename__ = "network_access_requests"
organization_id = db.Column(
db.String(36),
db.ForeignKey("organizations.id"),
nullable=False,
index=True,
)
user_id = db.Column(
db.String(36),
db.ForeignKey("users.id"),
nullable=False,
index=True,
)
device_id = db.Column(
db.String(36),
db.ForeignKey("devices.id"),
nullable=False,
index=True,
)
portal_network_id = db.Column(
db.String(36),
db.ForeignKey("portal_networks.id"),
nullable=False,
index=True,
)
granted_by_user_id = db.Column(
db.String(36),
db.ForeignKey("users.id"),
nullable=True,
)
grant_type = db.Column(
db.Enum(ApprovalGrantType, name="approval_grant_type", values_callable=lambda x: [e.value for e in x]),
default=ApprovalGrantType.REQUESTED,
nullable=False,
)
status = db.Column(
db.Enum(ApprovalState, name="approval_state", values_callable=lambda x: [e.value for e in x]),
default=ApprovalState.PENDING,
nullable=False,
index=True,
)
active = db.Column(
db.Boolean,
default=False,
nullable=False,
)
justification = db.Column(db.Text, nullable=True)
join_seen = db.Column(db.Boolean, default=False, nullable=False)
# Relationships
organization = db.relationship("Organization", backref="network_access_requests")
user = db.relationship(
"User",
foreign_keys=[user_id],
backref="network_access_requests",
)
granted_by = db.relationship(
"User",
foreign_keys=[granted_by_user_id],
backref="granted_network_requests",
)
device = db.relationship(
"Device",
back_populates="network_access_requests",
)
portal_network = db.relationship(
"PortalNetwork",
backref="access_requests",
)
activation_sessions = db.relationship(
"ActivationSession",
back_populates="access_request",
cascade="all, delete-orphan",
)
zerotier_membership = db.relationship(
"ZeroTierMembership",
back_populates="access_request",
uselist=False,
cascade="all, delete-orphan",
)
__table_args__ = (
db.UniqueConstraint(
"user_id",
"device_id",
"portal_network_id",
"deleted_at",
name="uix_user_device_network",
),
)
def __repr__(self):
return (
f"<NetworkAccessRequest user={self.user_id} "
f"device={self.device_id} network={self.portal_network_id} "
f"status={self.status}>"
)
@property
def active_session(self):
"""Return the current active ActivationSession, if any."""
for s in self.activation_sessions:
if s.ended_at is None and s.expires_at is not None:
now = datetime.now(timezone.utc)
exp = s.expires_at
if exp.tzinfo is None:
exp = exp.replace(tzinfo=timezone.utc)
if exp > now:
return s
return None
def to_dict(self, exclude=None):
exclude = exclude or []
data = super().to_dict(exclude=exclude)
session = self.active_session
data["active_session"] = session.to_dict() if session else None
return data
@@ -2,7 +2,7 @@
from gatehouse_app.extensions import db
from gatehouse_app.models.base import BaseModel
from gatehouse_app.utils.constants import NetworkEnvironment, NetworkRequestMode
from gatehouse_app.utils.constants import ApprovalState, NetworkEnvironment, NetworkRequestMode
class PortalNetwork(BaseModel):
@@ -65,16 +65,6 @@ class PortalNetwork(BaseModel):
# Relationships
organization = db.relationship("Organization", backref="portal_networks")
owner = db.relationship("User", backref="owned_networks")
approvals = db.relationship(
"UserNetworkApproval",
back_populates="portal_network",
cascade="all, delete-orphan",
)
memberships = db.relationship(
"DeviceNetworkMembership",
back_populates="portal_network",
cascade="all, delete-orphan",
)
__table_args__ = (
db.UniqueConstraint(
@@ -91,10 +81,11 @@ class PortalNetwork(BaseModel):
exclude = exclude or []
data = super().to_dict(exclude=exclude)
data["approved_user_count"] = sum(
1 for a in self.approvals if a.state == "approved" and a.deleted_at is None
1 for a in self.access_requests
if a.status == ApprovalState.APPROVED and a.deleted_at is None
)
data["active_membership_count"] = sum(
1 for m in self.memberships
if m.state == "active_authorized" and m.deleted_at is None
1 for r in self.access_requests
if r.active and r.status == ApprovalState.APPROVED and r.deleted_at is None
)
return data
@@ -1,106 +0,0 @@
"""User network approval model — durable manager approval for network access."""
from gatehouse_app.extensions import db
from gatehouse_app.models.base import BaseModel
from gatehouse_app.utils.constants import ApprovalGrantType, ApprovalState
class UserNetworkApproval(BaseModel):
"""A durable approval record binding a user to a portal network.
This is the business-level approval — separate from any device and separate
from activation sessions. Manager approval survives across days and only
needs to be issued once unless explicitly revoked.
Attributes:
organization_id: FK to the organization
user_id: FK to the approved user
portal_network_id: FK to the portal network
granted_by_user_id: FK to the manager who approved (null for system-assigned)
grant_type: requested (user-initiated) or assigned (manager-initiated)
state: pending / approved / rejected / revoked / suspended
justification: Business reason for the approval
"""
__tablename__ = "user_network_approvals"
organization_id = db.Column(
db.String(36),
db.ForeignKey("organizations.id"),
nullable=False,
index=True,
)
user_id = db.Column(
db.String(36),
db.ForeignKey("users.id"),
nullable=False,
index=True,
)
portal_network_id = db.Column(
db.String(36),
db.ForeignKey("portal_networks.id"),
nullable=False,
index=True,
)
granted_by_user_id = db.Column(
db.String(36),
db.ForeignKey("users.id"),
nullable=True,
)
grant_type = db.Column(
db.Enum(ApprovalGrantType, name="approval_grant_type", values_callable=lambda x: [e.value for e in x]),
default=ApprovalGrantType.REQUESTED,
nullable=False,
)
state = db.Column(
db.Enum(ApprovalState, name="approval_state", values_callable=lambda x: [e.value for e in x]),
default=ApprovalState.PENDING,
nullable=False,
index=True,
)
justification = db.Column(db.Text, nullable=True)
# Relationships
organization = db.relationship("Organization", backref="network_approvals")
user = db.relationship(
"User",
foreign_keys=[user_id],
backref="network_approvals",
)
granted_by = db.relationship(
"User",
foreign_keys=[granted_by_user_id],
backref="granted_approvals",
)
portal_network = db.relationship(
"PortalNetwork",
back_populates="approvals",
)
memberships = db.relationship(
"DeviceNetworkMembership",
back_populates="approval",
cascade="all, delete-orphan",
)
__table_args__ = (
db.UniqueConstraint(
"user_id",
"portal_network_id",
"deleted_at",
name="uix_user_network_approval",
),
)
def __repr__(self):
return (
f"<UserNetworkApproval user={self.user_id} "
f"network={self.portal_network_id} state={self.state}>"
)
def to_dict(self, exclude=None):
exclude = exclude or []
data = super().to_dict(exclude=exclude)
data["active_membership_count"] = sum(
1 for m in self.memberships if m.deleted_at is None
)
return data
@@ -15,7 +15,7 @@ class ZeroTierMembership(BaseModel):
Attributes:
organization_id: FK to the organization
device_network_membership_id: FK to the portal's membership record (nullable)
network_access_request_id: FK to the portal's access request record (nullable)
zerotier_network_id: The 16-char hex ZeroTier network ID
node_id: The 10-char hex ZeroTier node ID
member_seen: Whether the controller has ever seen this member
@@ -33,9 +33,9 @@ class ZeroTierMembership(BaseModel):
nullable=False,
index=True,
)
device_network_membership_id = db.Column(
network_access_request_id = db.Column(
db.String(36),
db.ForeignKey("device_network_memberships.id"),
db.ForeignKey("network_access_requests.id"),
nullable=True,
index=True,
)
@@ -57,8 +57,8 @@ class ZeroTierMembership(BaseModel):
# Relationships
organization = db.relationship("Organization", backref="zerotier_memberships")
device_network_membership = db.relationship(
"DeviceNetworkMembership",
access_request = db.relationship(
"NetworkAccessRequest",
back_populates="zerotier_membership",
)