Add superadmin routes to API
This commit is contained in:
@@ -0,0 +1,371 @@
|
||||
"""User management service for superadmin operations."""
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from gatehouse_app.models.user.user import User
|
||||
from gatehouse_app.models.user.session import Session
|
||||
from gatehouse_app.models.organization.organization import Organization
|
||||
from gatehouse_app.models.organization.organization_member import OrganizationMember
|
||||
from gatehouse_app.extensions import db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SuperadminUserService:
|
||||
"""Service for managing users across the platform."""
|
||||
|
||||
@staticmethod
|
||||
def list_users(
|
||||
page: int = 1,
|
||||
per_page: int = 20,
|
||||
org_id: str = None,
|
||||
status: str = None,
|
||||
search: str = None,
|
||||
) -> dict:
|
||||
"""List users with filters and pagination.
|
||||
|
||||
Args:
|
||||
page: Page number
|
||||
per_page: Items per page
|
||||
org_id: Filter by organization
|
||||
status: Filter by status (active/suspended)
|
||||
search: Search by email or name
|
||||
|
||||
Returns:
|
||||
Paginated user list with metadata
|
||||
"""
|
||||
query = User.query.filter(User.deleted_at.is_(None))
|
||||
|
||||
# Filter by organization
|
||||
if org_id:
|
||||
member_user_ids = db.session.query(OrganizationMember.user_id).filter(
|
||||
OrganizationMember.organization_id == org_id,
|
||||
OrganizationMember.deleted_at.is_(None),
|
||||
).all()
|
||||
user_ids = [m.user_id for m in member_user_ids]
|
||||
query = query.filter(User.id.in_(user_ids))
|
||||
|
||||
# Filter by status
|
||||
if status == "suspended":
|
||||
query = query.filter(User.status == "GLOBAL_SUSPENDED")
|
||||
elif status == "active":
|
||||
query = query.filter(User.status != "GLOBAL_SUSPENDED")
|
||||
|
||||
# Search
|
||||
if search:
|
||||
search_filter = f"%{search}%"
|
||||
query = query.filter(
|
||||
db.or_(
|
||||
User.email.ilike(search_filter),
|
||||
User.full_name.ilike(search_filter),
|
||||
)
|
||||
)
|
||||
|
||||
query = query.order_by(User.created_at.desc())
|
||||
|
||||
total = query.count()
|
||||
users = query.offset((page - 1) * per_page).limit(per_page).all()
|
||||
|
||||
items = []
|
||||
for user in users:
|
||||
# Get org memberships
|
||||
memberships = OrganizationMember.query.filter(
|
||||
OrganizationMember.user_id == user.id,
|
||||
OrganizationMember.deleted_at.is_(None),
|
||||
).all()
|
||||
|
||||
orgs = []
|
||||
for m in memberships:
|
||||
org = Organization.query.get(m.organization_id)
|
||||
if org:
|
||||
orgs.append({
|
||||
"org_id": org.id,
|
||||
"org_name": org.name,
|
||||
"role": m.role,
|
||||
})
|
||||
|
||||
# Get active sessions count
|
||||
active_sessions = Session.query.filter(
|
||||
Session.user_id == user.id,
|
||||
Session.deleted_at.is_(None),
|
||||
Session.status == "active",
|
||||
).count()
|
||||
|
||||
items.append({
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"full_name": user.full_name,
|
||||
"status": user.status,
|
||||
"org_count": len(orgs),
|
||||
"orgs": orgs,
|
||||
"active_sessions": active_sessions,
|
||||
"last_login_at": user.last_login_at.isoformat() + "Z" if user.last_login_at else None,
|
||||
"created_at": user.created_at.isoformat() + "Z" if user.created_at else None,
|
||||
})
|
||||
|
||||
return {
|
||||
"items": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"pages": (total + per_page - 1) // per_page if per_page > 0 else 0,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def get_user_detail(user_id: str) -> dict:
|
||||
"""Get detailed user information.
|
||||
|
||||
Args:
|
||||
user_id: User UUID
|
||||
|
||||
Returns:
|
||||
User detail with orgs, sessions, security methods
|
||||
"""
|
||||
user = User.query.get(user_id)
|
||||
if not user or user.deleted_at is not None:
|
||||
raise ValueError("User not found")
|
||||
|
||||
# Get org memberships
|
||||
memberships = OrganizationMember.query.filter(
|
||||
OrganizationMember.user_id == user_id,
|
||||
OrganizationMember.deleted_at.is_(None),
|
||||
).all()
|
||||
|
||||
orgs = []
|
||||
for m in memberships:
|
||||
org = Organization.query.get(m.organization_id)
|
||||
if org:
|
||||
orgs.append({
|
||||
"org_id": org.id,
|
||||
"org_name": org.name,
|
||||
"org_slug": org.slug,
|
||||
"role": m.role,
|
||||
"joined_at": m.created_at.isoformat() + "Z" if m.created_at else None,
|
||||
})
|
||||
|
||||
# Get active sessions
|
||||
sessions = Session.query.filter(
|
||||
Session.user_id == user_id,
|
||||
Session.deleted_at.is_(None),
|
||||
Session.status == "active",
|
||||
).all()
|
||||
|
||||
active_sessions = [{
|
||||
"id": s.id,
|
||||
"ip_address": s.ip_address,
|
||||
"user_agent": s.user_agent,
|
||||
"created_at": s.created_at.isoformat() + "Z" if s.created_at else None,
|
||||
} for s in sessions]
|
||||
|
||||
# Security methods
|
||||
security_methods = []
|
||||
if hasattr(user, 'totp_enabled') and user.totp_enabled:
|
||||
security_methods.append({"type": "totp", "enabled": True})
|
||||
if hasattr(user, 'webauthn_enabled') and user.webauthn_enabled:
|
||||
security_methods.append({"type": "webauthn", "enabled": True})
|
||||
|
||||
return {
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"full_name": user.full_name,
|
||||
"status": user.status,
|
||||
"mfa_enabled": user.mfa_enabled if hasattr(user, 'mfa_enabled') else False,
|
||||
"last_login_at": user.last_login_at.isoformat() + "Z" if user.last_login_at else None,
|
||||
"created_at": user.created_at.isoformat() + "Z" if user.created_at else None,
|
||||
},
|
||||
"organizations": orgs,
|
||||
"active_sessions": active_sessions,
|
||||
"security_methods": security_methods,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def suspend_user(user_id: str) -> dict:
|
||||
"""Globally suspend a user.
|
||||
|
||||
Args:
|
||||
user_id: User UUID
|
||||
|
||||
Returns:
|
||||
Updated user info and count of revoked sessions
|
||||
"""
|
||||
user = User.query.get(user_id)
|
||||
if not user or user.deleted_at is not None:
|
||||
raise ValueError("User not found")
|
||||
|
||||
if user.status == "GLOBAL_SUSPENDED":
|
||||
raise ValueError("User is already suspended")
|
||||
|
||||
user.status = "GLOBAL_SUSPENDED"
|
||||
db.session.commit()
|
||||
|
||||
# Revoke all sessions
|
||||
revoked_count = Session.query.filter(
|
||||
Session.user_id == user_id,
|
||||
Session.deleted_at.is_(None),
|
||||
).update({"status": "revoked", "deleted_at": db.func.now()})
|
||||
db.session.commit()
|
||||
|
||||
return {
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"status": user.status,
|
||||
},
|
||||
"sessions_revoked": revoked_count,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def unsuspend_user(user_id: str) -> dict:
|
||||
"""Remove global suspension from a user.
|
||||
|
||||
Args:
|
||||
user_id: User UUID
|
||||
|
||||
Returns:
|
||||
Updated user info
|
||||
"""
|
||||
user = User.query.get(user_id)
|
||||
if not user or user.deleted_at is not None:
|
||||
raise ValueError("User not found")
|
||||
|
||||
if user.status != "GLOBAL_SUSPENDED":
|
||||
raise ValueError("User is not suspended")
|
||||
|
||||
user.status = "active"
|
||||
db.session.commit()
|
||||
|
||||
return {
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"status": user.status,
|
||||
},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def reset_password(user_id: str) -> dict:
|
||||
"""Trigger password reset for user.
|
||||
|
||||
Args:
|
||||
user_id: User UUID
|
||||
|
||||
Returns:
|
||||
Email of user
|
||||
"""
|
||||
user = User.query.get(user_id)
|
||||
if not user or user.deleted_at is not None:
|
||||
raise ValueError("User not found")
|
||||
|
||||
# In production, this would call AuthService.send_password_reset_email
|
||||
logger.info(f"[SuperadminUserService] Password reset requested for {user.email}")
|
||||
|
||||
return {"email": user.email}
|
||||
|
||||
@staticmethod
|
||||
def revoke_all_sessions(user_id: str) -> dict:
|
||||
"""Revoke all sessions for a user.
|
||||
|
||||
Args:
|
||||
user_id: User UUID
|
||||
|
||||
Returns:
|
||||
Count of revoked sessions
|
||||
"""
|
||||
user = User.query.get(user_id)
|
||||
if not user or user.deleted_at is not None:
|
||||
raise ValueError("User not found")
|
||||
|
||||
result = Session.query.filter(
|
||||
Session.user_id == user_id,
|
||||
Session.deleted_at.is_(None),
|
||||
).update({"status": "revoked", "deleted_at": db.func.now()})
|
||||
db.session.commit()
|
||||
|
||||
return {
|
||||
"user_id": user_id,
|
||||
"count": result,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def add_to_org(user_id: str, org_id: str, role: str = "member") -> dict:
|
||||
"""Add a user to an organization.
|
||||
|
||||
Args:
|
||||
user_id: User UUID
|
||||
org_id: Organization UUID
|
||||
role: Membership role
|
||||
|
||||
Returns:
|
||||
Membership details
|
||||
"""
|
||||
user = User.query.get(user_id)
|
||||
if not user or user.deleted_at is not None:
|
||||
raise ValueError("User not found")
|
||||
|
||||
org = Organization.query.get(org_id)
|
||||
if not org or org.deleted_at is not None:
|
||||
raise ValueError("Organization not found")
|
||||
|
||||
# Check if already a member
|
||||
existing = OrganizationMember.query.filter(
|
||||
OrganizationMember.user_id == user_id,
|
||||
OrganizationMember.organization_id == org_id,
|
||||
OrganizationMember.deleted_at.is_(None),
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
raise ValueError("User is already a member of this organization")
|
||||
|
||||
membership = OrganizationMember(
|
||||
user_id=user_id,
|
||||
organization_id=org_id,
|
||||
role=role,
|
||||
)
|
||||
db.session.add(membership)
|
||||
db.session.commit()
|
||||
|
||||
return {
|
||||
"user_id": user_id,
|
||||
"organization_id": org_id,
|
||||
"role": role,
|
||||
"joined_at": membership.created_at.isoformat() + "Z" if membership.created_at else None,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def remove_from_org(user_id: str, org_id: str) -> dict:
|
||||
"""Remove a user from an organization.
|
||||
|
||||
Args:
|
||||
user_id: User UUID
|
||||
org_id: Organization UUID
|
||||
|
||||
Returns:
|
||||
Confirmation
|
||||
"""
|
||||
membership = OrganizationMember.query.filter(
|
||||
OrganizationMember.user_id == user_id,
|
||||
OrganizationMember.organization_id == org_id,
|
||||
OrganizationMember.deleted_at.is_(None),
|
||||
).first()
|
||||
|
||||
if not membership:
|
||||
raise ValueError("User is not a member of this organization")
|
||||
|
||||
# Check if user is the only owner
|
||||
if membership.role == "owner":
|
||||
owner_count = OrganizationMember.query.filter(
|
||||
OrganizationMember.organization_id == org_id,
|
||||
OrganizationMember.role == "owner",
|
||||
OrganizationMember.deleted_at.is_(None),
|
||||
).count()
|
||||
|
||||
if owner_count <= 1:
|
||||
raise ValueError("Cannot remove the only owner from an organization. Transfer ownership first.")
|
||||
|
||||
membership.deleted_at = db.func.now()
|
||||
db.session.commit()
|
||||
|
||||
return {
|
||||
"user_id": user_id,
|
||||
"organization_id": org_id,
|
||||
}
|
||||
Reference in New Issue
Block a user