feat: add admin and user session listing endpoints with enriched device/network details

This commit is contained in:
2026-05-29 05:30:44 +00:00
parent f869f6c06d
commit fed72f8bcd
2 changed files with 332 additions and 9 deletions
+113 -8
View File
@@ -1,8 +1,11 @@
"""ZeroTier network governance API endpoints."""
from datetime import datetime, timezone
from flask import g, request
from marshmallow import Schema, fields, validate, ValidationError
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import joinedload
from gatehouse_app.api.v1 import api_v1_bp
from gatehouse_app.extensions import db
@@ -808,6 +811,62 @@ def delete_membership(org_id, membership_id):
return api_response(success=False, message=str(e), status=404, error_type=e.error_type)
# ── Session helpers ──────────────────────────────────────────────────────────
def _session_to_dict(session, include_user=False):
"""Build a rich session dict with device, network, and timing details."""
now = datetime.now(timezone.utc)
exp = session.expires_at
if exp.tzinfo is None:
exp = exp.replace(tzinfo=timezone.utc)
remaining = (exp - now).total_seconds() if exp > now else 0
duration = (session.expires_at - session.authenticated_at).total_seconds()
auth_at = session.authenticated_at
if auth_at.tzinfo is None:
auth_at = auth_at.replace(tzinfo=timezone.utc)
exp_at = session.expires_at
if exp_at.tzinfo is None:
exp_at = exp_at.replace(tzinfo=timezone.utc)
d = {
"id": session.id,
"authenticated_at": auth_at.isoformat(),
"expires_at": exp_at.isoformat(),
"duration_seconds": int(duration),
"remaining_seconds": max(0, int(remaining)),
"is_active": session.is_active,
"is_expired": session.is_expired,
"ended_at": session.ended_at.isoformat() if session.ended_at else None,
"end_reason": session.end_reason.value if session.end_reason else None,
}
if session.access_request:
if session.access_request.device:
dev = session.access_request.device
d["device"] = {
"id": dev.id,
"node_id": dev.node_id,
"name": dev.display_name,
}
if session.access_request.portal_network:
net = session.access_request.portal_network
d["network"] = {
"id": net.id,
"name": net.name,
}
if include_user:
d["user"] = {
"id": session.user.id,
"full_name": session.user.full_name,
"email": session.user.email,
}
return d
# ── Sessions ─────────────────────────────────────────────────────────────────
@@ -820,15 +879,27 @@ def list_sessions(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()
sessions = (
ActivationSession.query.options(
joinedload(ActivationSession.access_request)
.joinedload(NetworkAccessRequest.device),
joinedload(ActivationSession.access_request)
.joinedload(NetworkAccessRequest.portal_network),
)
.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)},
data={
"sessions": [_session_to_dict(s) for s in sessions],
"count": len(sessions),
},
message="Sessions retrieved successfully",
)
@@ -856,7 +927,6 @@ def end_session(org_id, session_id):
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)
@@ -866,6 +936,41 @@ def end_session(org_id, session_id):
return api_response(message="Session ended successfully")
@api_v1_bp.route("/organizations/<org_id>/admin/sessions", methods=["GET"])
@login_required
@require_admin
@full_access_required
def admin_list_sessions(org_id):
"""List all active activation sessions across all users (admin only)."""
org, err = _org_check(org_id)
if err:
return err
sessions = (
ActivationSession.query.options(
joinedload(ActivationSession.user),
joinedload(ActivationSession.access_request)
.joinedload(NetworkAccessRequest.device),
joinedload(ActivationSession.access_request)
.joinedload(NetworkAccessRequest.portal_network),
)
.filter(
ActivationSession.organization_id == org_id,
ActivationSession.ended_at.is_(None),
ActivationSession.deleted_at.is_(None),
)
.all()
)
return api_response(
data={
"sessions": [_session_to_dict(s, include_user=True) for s in sessions],
"count": len(sessions),
},
message="Admin sessions retrieved successfully",
)
# ── Kill Switch ───────────────────────────────────────────────────────────────
+219 -1
View File
@@ -3,11 +3,13 @@
Covers network CRUD, device registration, access requests, approvals,
and membership activation. External ZeroTier API calls are mocked.
"""
from datetime import datetime, timedelta, timezone
import pytest
from unittest.mock import patch, MagicMock
from tests.integration.client.base import ApiError
from gatehouse_app.utils.constants import OrganizationRole
from gatehouse_app.utils.constants import OrganizationRole, ActivationEndReason
def assert_success(response: dict, message_contains: str = "") -> dict:
@@ -1080,3 +1082,219 @@ class TestAdminForceDeleteMembership:
f"/organizations/{org['id']}/admin/memberships/{uuid.uuid4()}",
)
assert exc_info.value.status_code == 403
class TestSessions:
"""Test user and admin session listing endpoints."""
def test_list_sessions_positive(
self, integration_client, create_test_user, create_test_org,
create_test_membership, integration_app,
):
"""ZT-SESS-01: User lists their own active sessions.
WHAT: GET /organizations/<id>/sessions for a user with an active session.
EXPECTED: 200 OK with session containing device, network, and timing fields.
"""
from gatehouse_app.models.zerotier.device import Device
from gatehouse_app.models.zerotier.portal_network import PortalNetwork
from gatehouse_app.models.zerotier.network_access_request import NetworkAccessRequest
from gatehouse_app.models.zerotier.activation_session import ActivationSession
from gatehouse_app.extensions import db as _db
from gatehouse_app.utils.constants import ApprovalState, ApprovalGrantType, NetworkRequestMode
user = create_test_user(password="Pass1234!")
org = create_test_org()
create_test_membership(user["id"], org["id"], OrganizationRole.MEMBER)
now = datetime.now(timezone.utc)
with integration_app.app_context():
network = PortalNetwork(
organization_id=org["id"], name="Test Net",
owner_user_id=user["id"],
zerotier_network_id="aabbccddee11",
request_mode=NetworkRequestMode.OPEN,
)
_db.session.add(network)
_db.session.flush()
device = Device(
user_id=user["id"], organization_id=org["id"],
node_id="deadbeef01", device_nickname="My Laptop",
hostname="my-laptop",
)
_db.session.add(device)
_db.session.flush()
req = NetworkAccessRequest(
organization_id=org["id"], user_id=user["id"],
device_id=device.id, portal_network_id=network.id,
status=ApprovalState.APPROVED, active=True,
grant_type=ApprovalGrantType.REQUESTED,
)
_db.session.add(req)
_db.session.flush()
session = ActivationSession(
organization_id=org["id"], user_id=user["id"],
network_access_request_id=req.id,
authenticated_at=now - timedelta(hours=1),
expires_at=now + timedelta(hours=7),
created_by=user["id"],
)
_db.session.add(session)
_db.session.commit()
saved_session_id = session.id
integration_client.auth.login(email=user["email"], password="Pass1234!")
result = integration_client.get(f"/organizations/{org['id']}/sessions")
data = result.get("data", {})
assert data["count"] == 1
s = data["sessions"][0]
assert s["id"] == saved_session_id
assert s["is_active"] is True
assert s["is_expired"] is False
assert s["duration_seconds"] == 28800
assert s["remaining_seconds"] > 0
assert s["device"]["node_id"] == "deadbeef01"
assert s["device"]["name"] == "My Laptop"
assert s["network"]["name"] == "Test Net"
assert "user" not in s
def test_list_sessions_empty(
self, integration_client, create_test_user, create_test_org,
create_test_membership,
):
"""ZT-SESS-02: User with no sessions gets empty array."""
user = create_test_user(password="Pass1234!")
org = create_test_org()
create_test_membership(user["id"], org["id"], OrganizationRole.MEMBER)
integration_client.auth.login(email=user["email"], password="Pass1234!")
result = integration_client.get(f"/organizations/{org['id']}/sessions")
data = result.get("data", {})
assert data["count"] == 0
assert data["sessions"] == []
def test_list_sessions_unauth_negative(
self, integration_client, create_test_org,
):
"""ZT-SESS-03: Unauthenticated user gets 401."""
org = create_test_org()
with pytest.raises(ApiError) as exc_info:
integration_client.get(f"/organizations/{org['id']}/sessions")
assert exc_info.value.status_code == 401
def test_admin_list_sessions_positive(
self, integration_client, create_test_user, create_test_org,
create_test_membership, integration_app,
):
"""ZT-SESS-04: Admin lists all sessions across users.
WHAT: GET /organizations/<id>/admin/sessions.
EXPECTED: 200 OK with all users' sessions including user details.
"""
from gatehouse_app.models.zerotier.device import Device
from gatehouse_app.models.zerotier.portal_network import PortalNetwork
from gatehouse_app.models.zerotier.network_access_request import NetworkAccessRequest
from gatehouse_app.models.zerotier.activation_session import ActivationSession
from gatehouse_app.extensions import db as _db
from gatehouse_app.utils.constants import ApprovalState, ApprovalGrantType, NetworkRequestMode
admin = create_test_user(password="AdminPass123!")
member1 = create_test_user(password="Mem1Pass123!", full_name="Alice")
member2 = create_test_user(password="Mem2Pass123!", full_name="Bob")
org = create_test_org()
create_test_membership(admin["id"], org["id"], OrganizationRole.ADMIN)
create_test_membership(member1["id"], org["id"], OrganizationRole.MEMBER)
create_test_membership(member2["id"], org["id"], OrganizationRole.MEMBER)
now = datetime.now(timezone.utc)
with integration_app.app_context():
network = PortalNetwork(
organization_id=org["id"], name="Shared Net",
owner_user_id=admin["id"],
zerotier_network_id="ffgg00112233",
request_mode=NetworkRequestMode.OPEN,
)
_db.session.add(network)
_db.session.flush()
for member, node_id, nick, full_name in [
(member1, "aaaa01", "Alice Mac", None),
(member2, "bbbb02", None, "bob-pc"),
]:
device = Device(
user_id=member["id"], organization_id=org["id"],
node_id=node_id, device_nickname=nick,
hostname="host",
)
_db.session.add(device)
_db.session.flush()
req = NetworkAccessRequest(
organization_id=org["id"], user_id=member["id"],
device_id=device.id, portal_network_id=network.id,
status=ApprovalState.APPROVED, active=True,
grant_type=ApprovalGrantType.REQUESTED,
)
_db.session.add(req)
_db.session.flush()
session = ActivationSession(
organization_id=org["id"], user_id=member["id"],
network_access_request_id=req.id,
authenticated_at=now - timedelta(hours=2),
expires_at=now + timedelta(hours=6),
created_by=member["id"],
)
_db.session.add(session)
_db.session.commit()
integration_client.auth.login(email=admin["email"], password="AdminPass123!")
result = integration_client.get(f"/organizations/{org['id']}/admin/sessions")
data = result.get("data", {})
assert data["count"] == 2
user_names = {s["user"]["full_name"] for s in data["sessions"]}
assert "Alice" in user_names
assert "Bob" in user_names
for s in data["sessions"]:
assert s["is_active"] is True
assert "device" in s
assert "node_id" in s["device"]
assert "network" in s
assert s["network"]["name"] == "Shared Net"
def test_admin_list_sessions_non_admin_negative(
self, integration_client, create_test_user, create_test_org,
create_test_membership,
):
"""ZT-SESS-05: Non-admin gets 403 on admin sessions endpoint."""
member = create_test_user(password="Pass1234!")
org = create_test_org()
create_test_membership(member["id"], org["id"], OrganizationRole.MEMBER)
integration_client.auth.login(email=member["email"], password="Pass1234!")
with pytest.raises(ApiError) as exc_info:
integration_client.get(f"/organizations/{org['id']}/admin/sessions")
assert exc_info.value.status_code == 403
def test_admin_list_sessions_unauth_negative(
self, integration_client, create_test_org,
):
"""ZT-SESS-06: Unauthenticated user gets 401 on admin endpoint."""
org = create_test_org()
with pytest.raises(ApiError) as exc_info:
integration_client.get(f"/organizations/{org['id']}/admin/sessions")
assert exc_info.value.status_code == 401