feat: allow admins to bypass approval flow when joining networks
This commit is contained in:
@@ -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}>"
|
||||
)
|
||||
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user