Files
gatehouse-api/gatehouse_app/api/v1/superadmin/users.py
T

517 lines
18 KiB
Python

"""Superadmin user management endpoints."""
import logging
from flask import request, g
from gatehouse_app.api.v1.superadmin import superadmin_bp
from gatehouse_app.utils.response import api_response
from gatehouse_app.decorators.superadmin import superadmin_required, superadmin_audit_log
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__)
@superadmin_bp.route("/users", methods=["GET"])
@superadmin_required
def list_users():
"""Get paginated list of users with optional filters.
Query params:
page: Page number (default 1)
per_page: Items per page (default 20, max 100)
organization_id: Filter by organization
status: Filter by status (active/suspended)
search: Search by email or name
"""
try:
page = max(1, int(request.args.get("page", 1)))
per_page = min(100, max(1, int(request.args.get("per_page", 20))))
org_id = request.args.get("organization_id")
status = request.args.get("status")
search = request.args.get("search", "").strip()
# Base query
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 by email or name
if search:
search_filter = f"%{search}%"
query = query.filter(
db.or_(
User.email.ilike(search_filter),
User.full_name.ilike(search_filter),
)
)
# Order by created_at desc
query = query.order_by(User.created_at.desc())
# Paginate
total = query.count()
users = query.offset((page - 1) * per_page).limit(per_page).all()
# Get org memberships for each user
items = []
for user in users:
# Get organization memberships
memberships = db.session.query(OrganizationMember).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,
"joined_at": m.created_at.isoformat() + "Z" if m.created_at else None,
})
# Get active session 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,
"mfa_enabled": user.mfa_enabled if hasattr(user, 'mfa_enabled') else False,
"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 api_response(data={
"items": items,
"total": total,
"page": page,
"per_page": per_page,
"pages": (total + per_page - 1) // per_page if per_page > 0 else 0,
}, message="Users retrieved successfully")
except Exception as e:
logger.error(f"[SuperadminUsers] List users error: {e}")
return api_response(
success=False,
message="An error occurred",
status=500,
error_type="INTERNAL_ERROR",
)
@superadmin_bp.route("/users/<user_id>", methods=["GET"])
@superadmin_required
def get_user(user_id):
"""Get detailed user information.
Returns user + all org memberships + active sessions + security methods.
"""
try:
user = User.query.get(user_id)
if not user or user.deleted_at is not None:
return api_response(
success=False,
message="User not found",
status=404,
error_type="NOT_FOUND",
)
# Get organization memberships
memberships = db.session.query(OrganizationMember).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,
"last_active_at": s.last_active_at.isoformat() + "Z" if hasattr(s, 'last_active_at') and s.last_active_at else None,
} for s in sessions]
# Get security methods (simplified - would need UserSecurityMethod model)
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 api_response(data={
"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,
}, message="User retrieved successfully")
except Exception as e:
logger.error(f"[SuperadminUsers] Get user error: {e}")
return api_response(
success=False,
message="An error occurred",
status=500,
error_type="INTERNAL_ERROR",
)
@superadmin_bp.route("/users/<user_id>/suspend", methods=["POST"])
@superadmin_required
@superadmin_audit_log(action="user.suspend", resource_type="user")
def suspend_user(user_id):
"""Globally suspend a user (sets status=GLOBAL_SUSPENDED)."""
try:
user = User.query.get(user_id)
if not user or user.deleted_at is not None:
return api_response(
success=False,
message="User not found",
status=404,
error_type="NOT_FOUND",
)
if user.status == "GLOBAL_SUSPENDED":
return api_response(
success=False,
message="User is already suspended",
status=400,
error_type="VALIDATION_ERROR",
)
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()
logger.warning(f"[SuperadminUsers] User {user_id} globally suspended by {getattr(g, 'current_superadmin', {}).get('id', 'unknown')}")
return api_response(data={
"user": {
"id": user.id,
"email": user.email,
"status": user.status,
},
"sessions_revoked": revoked_count,
}, message="User suspended successfully")
except Exception as e:
db.session.rollback()
logger.error(f"[SuperadminUsers] Suspend user error: {e}")
return api_response(
success=False,
message="An error occurred",
status=500,
error_type="INTERNAL_ERROR",
)
@superadmin_bp.route("/users/<user_id>/unsuspend", methods=["POST"])
@superadmin_required
@superadmin_audit_log(action="user.unsuspend", resource_type="user")
def unsuspend_user(user_id):
"""Remove global suspension from a user."""
try:
user = User.query.get(user_id)
if not user or user.deleted_at is not None:
return api_response(
success=False,
message="User not found",
status=404,
error_type="NOT_FOUND",
)
if user.status != "GLOBAL_SUSPENDED":
return api_response(
success=False,
message="User is not suspended",
status=400,
error_type="VALIDATION_ERROR",
)
user.status = "active"
db.session.commit()
return api_response(data={
"user": {
"id": user.id,
"email": user.email,
"status": user.status,
},
}, message="User unsuspended successfully")
except Exception as e:
db.session.rollback()
logger.error(f"[SuperadminUsers] Unsuspend user error: {e}")
return api_response(
success=False,
message="An error occurred",
status=500,
error_type="INTERNAL_ERROR",
)
@superadmin_bp.route("/users/<user_id>/reset-password", methods=["POST"])
@superadmin_required
@superadmin_audit_log(action="user.reset_password", resource_type="user")
def reset_user_password(user_id):
"""Trigger password reset email flow for user."""
try:
user = User.query.get(user_id)
if not user or user.deleted_at is not None:
return api_response(
success=False,
message="User not found",
status=404,
error_type="NOT_FOUND",
)
# In production, this would call AuthService.send_password_reset_email(user.email)
# For now, just log and return success
logger.info(f"[SuperadminUsers] Password reset requested for {user.email} by superadmin")
return api_response(data={
"email": user.email,
}, message="Password reset email sent successfully")
except Exception as e:
logger.error(f"[SuperadminUsers] Reset password error: {e}")
return api_response(
success=False,
message="An error occurred",
status=500,
error_type="INTERNAL_ERROR",
)
@superadmin_bp.route("/users/<user_id>/sessions", methods=["DELETE"])
@superadmin_required
@superadmin_audit_log(action="user.revoke_sessions", resource_type="user")
def revoke_user_sessions(user_id):
"""Revoke all sessions for a user."""
try:
user = User.query.get(user_id)
if not user or user.deleted_at is not None:
return api_response(
success=False,
message="User not found",
status=404,
error_type="NOT_FOUND",
)
# Revoke all sessions
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 api_response(data={
"user_id": user_id,
"count": result,
}, message=f"All sessions revoked ({result} sessions)")
except Exception as e:
db.session.rollback()
logger.error(f"[SuperadminUsers] Revoke sessions error: {e}")
return api_response(
success=False,
message="An error occurred",
status=500,
error_type="INTERNAL_ERROR",
)
@superadmin_bp.route("/users/<user_id>/add-to-org/<org_id>", methods=["POST"])
@superadmin_required
@superadmin_audit_log(action="user.add_to_org", resource_type="user")
def add_user_to_org(user_id, org_id):
"""Add a user to an organization with specified role."""
try:
data = request.json or {}
role = data.get("role", "member")
valid_roles = ["member", "admin", "owner"]
if role not in valid_roles:
return api_response(
success=False,
message=f"Invalid role. Must be one of: {', '.join(valid_roles)}",
status=400,
error_type="VALIDATION_ERROR",
)
user = User.query.get(user_id)
if not user or user.deleted_at is not None:
return api_response(
success=False,
message="User not found",
status=404,
error_type="NOT_FOUND",
)
org = Organization.query.get(org_id)
if not org or org.deleted_at is not None:
return api_response(
success=False,
message="Organization not found",
status=404,
error_type="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:
return api_response(
success=False,
message="User is already a member of this organization",
status=400,
error_type="VALIDATION_ERROR",
)
# Create membership
membership = OrganizationMember(
user_id=user_id,
organization_id=org_id,
role=role,
)
db.session.add(membership)
db.session.commit()
logger.info(f"[SuperadminUsers] User {user_id} added to org {org_id} as {role} by superadmin")
return api_response(data={
"user_id": user_id,
"organization_id": org_id,
"role": role,
"joined_at": membership.created_at.isoformat() + "Z" if membership.created_at else None,
}, message="User added to organization successfully")
except Exception as e:
db.session.rollback()
logger.error(f"[SuperadminUsers] Add to org error: {e}")
return api_response(
success=False,
message="An error occurred",
status=500,
error_type="INTERNAL_ERROR",
)
@superadmin_bp.route("/users/<user_id>/orgs/<org_id>", methods=["DELETE"])
@superadmin_required
@superadmin_audit_log(action="user.remove_from_org", resource_type="user")
def remove_user_from_org(user_id, org_id):
"""Remove a user from an organization."""
try:
membership = OrganizationMember.query.filter(
OrganizationMember.user_id == user_id,
OrganizationMember.organization_id == org_id,
OrganizationMember.deleted_at.is_(None),
).first()
if not membership:
return api_response(
success=False,
message="User is not a member of this organization",
status=404,
error_type="NOT_FOUND",
)
# 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:
return api_response(
success=False,
message="Cannot remove the only owner from an organization. Transfer ownership first.",
status=400,
error_type="VALIDATION_ERROR",
)
# Soft delete membership
membership.deleted_at = db.func.now()
db.session.commit()
logger.info(f"[SuperadminUsers] User {user_id} removed from org {org_id} by superadmin")
return api_response(data={
"user_id": user_id,
"organization_id": org_id,
}, message="User removed from organization successfully")
except Exception as e:
db.session.rollback()
logger.error(f"[SuperadminUsers] Remove from org error: {e}")
return api_response(
success=False,
message="An error occurred",
status=500,
error_type="INTERNAL_ERROR",
)