517 lines
18 KiB
Python
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",
|
|
)
|