Files
ServerKit/backend/app/api/admin.py
T
Juan Denis b7fab1152b Add invitations, email models, admin APIs
Introduce team invitation support and a revamped email subsystem plus admin features. Adds Invitation model and API endpoints (create, list, revoke, resend, validate) and integrates invitation validation into user registration. Replaces old email account model with richer email models (EmailDomain, EmailAccount, EmailAlias, EmailForwardingRule, DNSProviderConfig) and updates models __init__. Refactors email API to manage domains, accounts, aliases, forwarding, DNS providers, spamassassin, Roundcube/Postfix services and queue/log endpoints. Adds several backend services (invitation, DNS provider, DKIM, Dovecot, Postfix, Roundcube, SpamAssassin, permission service) and a DB migration for permissions/invitations. Enhances admin API with permission management and activity dashboard endpoints, extends audit log actions, and adds a permission_required RBAC decorator. Includes frontend additions for settings/invitations UI and miscellaneous config/service updates and tests.
2026-03-04 02:34:49 -05:00

587 lines
18 KiB
Python

"""Admin API endpoints for user management, settings, and audit logs."""
from datetime import datetime, timedelta
from flask import Blueprint, request, jsonify
from flask_jwt_extended import get_jwt_identity
from sqlalchemy import func
from app import db
from app.models import User, AuditLog
from app.middleware.rbac import admin_required, get_current_user
from app.services.audit_service import AuditService
from app.services.settings_service import SettingsService
from app.services.permission_service import PermissionService
admin_bp = Blueprint('admin', __name__)
# ============================================
# User Management Endpoints
# ============================================
@admin_bp.route('/users', methods=['GET'])
@admin_required
def list_users():
"""List all users."""
users = User.query.order_by(User.created_at.desc()).all()
return jsonify({
'users': [user.to_dict() for user in users]
}), 200
@admin_bp.route('/users', methods=['POST'])
@admin_required
def create_user():
"""Create a new user."""
data = request.get_json()
current_user_id = get_jwt_identity()
if not data:
return jsonify({'error': 'No data provided'}), 400
email = data.get('email')
username = data.get('username')
password = data.get('password')
role = data.get('role', User.ROLE_DEVELOPER)
if not all([email, username, password]):
return jsonify({'error': 'Missing required fields: email, username, password'}), 400
if role not in User.VALID_ROLES:
return jsonify({'error': f'Invalid role. Must be one of: {", ".join(User.VALID_ROLES)}'}), 400
if User.query.filter_by(email=email).first():
return jsonify({'error': 'Email already registered'}), 409
if User.query.filter_by(username=username).first():
return jsonify({'error': 'Username already taken'}), 409
if len(password) < 8:
return jsonify({'error': 'Password must be at least 8 characters'}), 400
user = User(
email=email,
username=username,
role=role,
created_by=current_user_id,
is_active=data.get('is_active', True)
)
user.set_password(password)
db.session.add(user)
db.session.commit()
# Log the action
AuditService.log_user_action(
action=AuditLog.ACTION_USER_CREATE,
user_id=current_user_id,
target_user_id=user.id,
details={'username': username, 'role': role}
)
db.session.commit()
return jsonify({
'message': 'User created successfully',
'user': user.to_dict()
}), 201
@admin_bp.route('/users/<int:user_id>', methods=['GET'])
@admin_required
def get_user(user_id):
"""Get a specific user."""
user = User.query.get(user_id)
if not user:
return jsonify({'error': 'User not found'}), 404
return jsonify({'user': user.to_dict()}), 200
@admin_bp.route('/users/<int:user_id>', methods=['PUT'])
@admin_required
def update_user(user_id):
"""Update a user."""
data = request.get_json()
current_user_id = get_jwt_identity()
user = User.query.get(user_id)
if not user:
return jsonify({'error': 'User not found'}), 404
changes = {}
# Update email
if 'email' in data and data['email'] != user.email:
existing = User.query.filter_by(email=data['email']).first()
if existing and existing.id != user.id:
return jsonify({'error': 'Email already registered'}), 409
changes['email'] = {'old': user.email, 'new': data['email']}
user.email = data['email']
# Update username
if 'username' in data and data['username'] != user.username:
existing = User.query.filter_by(username=data['username']).first()
if existing and existing.id != user.id:
return jsonify({'error': 'Username already taken'}), 409
changes['username'] = {'old': user.username, 'new': data['username']}
user.username = data['username']
# Update role
if 'role' in data and data['role'] != user.role:
if data['role'] not in User.VALID_ROLES:
return jsonify({'error': f'Invalid role. Must be one of: {", ".join(User.VALID_ROLES)}'}), 400
# Prevent demoting the last admin
if user.role == User.ROLE_ADMIN and data['role'] != User.ROLE_ADMIN:
admin_count = User.query.filter_by(role=User.ROLE_ADMIN).count()
if admin_count <= 1:
return jsonify({'error': 'Cannot demote the last admin'}), 400
changes['role'] = {'old': user.role, 'new': data['role']}
user.role = data['role']
# Update password
if 'password' in data and data['password']:
if len(data['password']) < 8:
return jsonify({'error': 'Password must be at least 8 characters'}), 400
user.set_password(data['password'])
changes['password'] = 'changed'
# Update custom permissions (inline with user update)
if 'permissions' in data and user.role != User.ROLE_ADMIN:
from app.services.permission_service import PermissionService
perm_error = PermissionService.validate_permissions(data['permissions'])
if perm_error:
return jsonify({'error': perm_error}), 400
user.set_permissions(data['permissions'])
changes['permissions'] = 'updated'
# Update active status
if 'is_active' in data and data['is_active'] != user.is_active:
# Prevent deactivating self
if user.id == current_user_id and not data['is_active']:
return jsonify({'error': 'Cannot deactivate your own account'}), 400
changes['is_active'] = {'old': user.is_active, 'new': data['is_active']}
user.is_active = data['is_active']
# Log enable/disable separately
action = AuditLog.ACTION_USER_ENABLE if data['is_active'] else AuditLog.ACTION_USER_DISABLE
AuditService.log_user_action(
action=action,
user_id=current_user_id,
target_user_id=user.id,
details={'username': user.username}
)
db.session.commit()
# Log the update
if changes:
AuditService.log_user_action(
action=AuditLog.ACTION_USER_UPDATE,
user_id=current_user_id,
target_user_id=user.id,
details={'changes': changes}
)
db.session.commit()
return jsonify({
'message': 'User updated successfully',
'user': user.to_dict()
}), 200
@admin_bp.route('/users/<int:user_id>', methods=['DELETE'])
@admin_required
def delete_user(user_id):
"""Delete a user."""
current_user_id = get_jwt_identity()
user = User.query.get(user_id)
if not user:
return jsonify({'error': 'User not found'}), 404
# Prevent self-deletion
if user.id == current_user_id:
return jsonify({'error': 'Cannot delete your own account'}), 400
# Prevent deleting the last admin
if user.role == User.ROLE_ADMIN:
admin_count = User.query.filter_by(role=User.ROLE_ADMIN).count()
if admin_count <= 1:
return jsonify({'error': 'Cannot delete the last admin'}), 400
username = user.username
user_id_deleted = user.id
db.session.delete(user)
db.session.commit()
# Log the action
AuditService.log_user_action(
action=AuditLog.ACTION_USER_DELETE,
user_id=current_user_id,
target_user_id=user_id_deleted,
details={'username': username}
)
db.session.commit()
return jsonify({'message': 'User deleted successfully'}), 200
# ============================================
# Audit Log Endpoints
# ============================================
@admin_bp.route('/audit-logs', methods=['GET'])
@admin_required
def get_audit_logs():
"""Get audit logs with pagination and filtering."""
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 50, type=int)
action = request.args.get('action')
user_id = request.args.get('user_id', type=int)
target_type = request.args.get('target_type')
# Limit per_page to reasonable values
per_page = min(per_page, 100)
pagination = AuditService.get_logs(
page=page,
per_page=per_page,
action=action,
user_id=user_id,
target_type=target_type
)
return jsonify({
'logs': [log.to_dict() for log in pagination.items],
'pagination': {
'page': pagination.page,
'per_page': pagination.per_page,
'total': pagination.total,
'pages': pagination.pages,
'has_next': pagination.has_next,
'has_prev': pagination.has_prev
}
}), 200
@admin_bp.route('/audit-logs/actions', methods=['GET'])
@admin_required
def get_audit_log_actions():
"""Get list of unique action types in audit logs."""
actions = db.session.query(AuditLog.action).distinct().all()
return jsonify({
'actions': [a[0] for a in actions]
}), 200
# ============================================
# System Settings Endpoints
# ============================================
@admin_bp.route('/settings', methods=['GET'])
@admin_required
def get_settings():
"""Get all system settings."""
settings = SettingsService.get_all_with_metadata()
return jsonify({'settings': settings}), 200
@admin_bp.route('/settings', methods=['PUT'])
@admin_required
def update_settings():
"""Update system settings."""
data = request.get_json()
current_user_id = get_jwt_identity()
if not data:
return jsonify({'error': 'No data provided'}), 400
updated = []
for key, value in data.items():
old_value = SettingsService.get(key)
SettingsService.set(key, value, user_id=current_user_id)
updated.append(key)
# Log the change
AuditService.log_settings_change(
user_id=current_user_id,
key=key,
old_value=old_value,
new_value=value
)
db.session.commit()
return jsonify({
'message': 'Settings updated successfully',
'updated': updated
}), 200
@admin_bp.route('/settings/<key>', methods=['GET'])
@admin_required
def get_setting(key):
"""Get a specific setting."""
value = SettingsService.get(key)
if value is None:
return jsonify({'error': 'Setting not found'}), 404
return jsonify({'key': key, 'value': value}), 200
@admin_bp.route('/settings/<key>', methods=['PUT'])
@admin_required
def update_setting(key):
"""Update a specific setting."""
data = request.get_json()
current_user_id = get_jwt_identity()
if 'value' not in data:
return jsonify({'error': 'No value provided'}), 400
old_value = SettingsService.get(key)
SettingsService.set(key, data['value'], user_id=current_user_id)
# Log the change
AuditService.log_settings_change(
user_id=current_user_id,
key=key,
old_value=old_value,
new_value=data['value']
)
db.session.commit()
return jsonify({
'message': 'Setting updated successfully',
'key': key,
'value': data['value']
}), 200
# ============================================
# Admin Statistics Endpoints
# ============================================
@admin_bp.route('/stats', methods=['GET'])
@admin_required
def get_admin_stats():
"""Get admin statistics."""
user_count = User.query.count()
active_users = User.query.filter_by(is_active=True).count()
admin_count = User.query.filter_by(role=User.ROLE_ADMIN).count()
developer_count = User.query.filter_by(role=User.ROLE_DEVELOPER).count()
viewer_count = User.query.filter_by(role=User.ROLE_VIEWER).count()
# Recent logins
recent_logins = AuditLog.query.filter_by(
action=AuditLog.ACTION_LOGIN
).order_by(AuditLog.created_at.desc()).limit(10).all()
return jsonify({
'users': {
'total': user_count,
'active': active_users,
'by_role': {
'admin': admin_count,
'developer': developer_count,
'viewer': viewer_count
}
},
'recent_logins': [log.to_dict() for log in recent_logins]
}), 200
# ============================================
# Permission Endpoints
# ============================================
@admin_bp.route('/users/<int:user_id>/permissions', methods=['GET'])
@admin_required
def get_user_permissions(user_id):
"""Get a user's resolved permissions."""
perms = PermissionService.get_user_permissions(user_id)
if perms is None:
return jsonify({'error': 'User not found'}), 404
return jsonify({'permissions': perms}), 200
@admin_bp.route('/users/<int:user_id>/permissions', methods=['PUT'])
@admin_required
def update_user_permissions(user_id):
"""Update a user's custom permissions."""
data = request.get_json()
current_user_id = get_jwt_identity()
if not data or 'permissions' not in data:
return jsonify({'error': 'No permissions provided'}), 400
result = PermissionService.update_user_permissions(user_id, data['permissions'])
if not result['success']:
return jsonify({'error': result['error']}), 400
AuditService.log_user_action(
action=AuditLog.ACTION_USER_PERMISSIONS_UPDATE,
user_id=current_user_id,
target_user_id=user_id,
details={'permissions': data['permissions']}
)
db.session.commit()
return jsonify({
'message': 'Permissions updated',
'permissions': result['permissions']
}), 200
@admin_bp.route('/users/<int:user_id>/permissions/reset', methods=['POST'])
@admin_required
def reset_user_permissions(user_id):
"""Reset user permissions to role defaults."""
current_user_id = get_jwt_identity()
result = PermissionService.reset_to_role_defaults(user_id)
if not result['success']:
return jsonify({'error': result['error']}), 400
AuditService.log_user_action(
action=AuditLog.ACTION_USER_PERMISSIONS_RESET,
user_id=current_user_id,
target_user_id=user_id,
)
db.session.commit()
return jsonify({
'message': 'Permissions reset to role defaults',
'permissions': result['permissions']
}), 200
@admin_bp.route('/permissions/templates', methods=['GET'])
@admin_required
def get_permission_templates():
"""Get role template definitions and feature list."""
return jsonify({
'features': User.PERMISSION_FEATURES,
'templates': User.ROLE_PERMISSION_TEMPLATES,
}), 200
# ============================================
# Activity Dashboard Endpoints
# ============================================
@admin_bp.route('/activity/summary', methods=['GET'])
@admin_required
def get_activity_summary():
"""Get activity summary: active users today, actions this week, top users."""
now = datetime.utcnow()
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
week_start = today_start - timedelta(days=7)
# Active users today (distinct users with audit log entries)
active_today = db.session.query(
func.count(func.distinct(AuditLog.user_id))
).filter(
AuditLog.created_at >= today_start,
AuditLog.user_id.isnot(None)
).scalar() or 0
# Actions this week
actions_this_week = db.session.query(
func.count(AuditLog.id)
).filter(
AuditLog.created_at >= week_start
).scalar() or 0
# Total users
total_users = User.query.filter_by(is_active=True).count()
# Top 5 most active users this week
top_users_query = db.session.query(
AuditLog.user_id,
func.count(AuditLog.id).label('action_count')
).filter(
AuditLog.created_at >= week_start,
AuditLog.user_id.isnot(None)
).group_by(AuditLog.user_id).order_by(
func.count(AuditLog.id).desc()
).limit(5).all()
top_users = []
for user_id, count in top_users_query:
user = User.query.get(user_id)
if user:
top_users.append({
'user_id': user_id,
'username': user.username,
'action_count': count
})
# Daily action counts for the past 7 days
daily_counts = []
for i in range(7):
day_start = today_start - timedelta(days=6 - i)
day_end = day_start + timedelta(days=1)
count = db.session.query(func.count(AuditLog.id)).filter(
AuditLog.created_at >= day_start,
AuditLog.created_at < day_end
).scalar() or 0
daily_counts.append({
'date': day_start.strftime('%Y-%m-%d'),
'count': count
})
return jsonify({
'active_users_today': active_today,
'actions_this_week': actions_this_week,
'total_users': total_users,
'top_users': top_users,
'daily_counts': daily_counts,
}), 200
@admin_bp.route('/activity/feed', methods=['GET'])
@admin_required
def get_activity_feed():
"""Get paginated activity feed with filters."""
page = request.args.get('page', 1, type=int)
per_page = min(request.args.get('per_page', 50, type=int), 100)
user_id = request.args.get('user_id', type=int)
action = request.args.get('action')
start_date = request.args.get('start_date')
end_date = request.args.get('end_date')
kwargs = {
'page': page,
'per_page': per_page,
'user_id': user_id,
'action': action,
}
if start_date:
try:
kwargs['start_date'] = datetime.fromisoformat(start_date)
except ValueError:
pass
if end_date:
try:
kwargs['end_date'] = datetime.fromisoformat(end_date)
except ValueError:
pass
pagination = AuditService.get_logs(**kwargs)
return jsonify({
'logs': [log.to_dict() for log in pagination.items],
'pagination': {
'page': pagination.page,
'per_page': pagination.per_page,
'total': pagination.total,
'pages': pagination.pages,
'has_next': pagination.has_next,
'has_prev': pagination.has_prev
}
}), 200