Files
ServerKit/backend/app/api/fleet_monitor.py
Juan DenisandClaude Opus 5 031142111d fix(authz): give every mutating route an explicit authorization decision
GHSA-r4q7-x795-vr4j was one forgotten check, but the audit behind it found
the same shape across the surface: `@jwt_required()` authenticates, it does
not authorize, and ServerKit has no default-deny to catch the difference.
This closes the routes that audit left open — fleet-monitor alert
acknowledge/resolve, recycle-bin restore, nginx test/diff, registrar
sync/test, server-template drift checks, uptime tracking on/off, server ping
and remote compose ps/logs, refresh-capabilities, template
test-db-connection, deploy branches, buildpack detect — plus three inline
gaps: a viewer could widen their own API key's scopes, the demo-deploy flag
was doing double duty as an authorization one, and tagging a shared resource
had no write check at all while attaching one did.

`_ensure_group_mutable` only ever blocked *system* queue groups, so every
queue_bus mutation now also calls `_ensure_group_accessible` (owner-or-admin)
— renaming, draining or deleting another user's group was open to any
authenticated caller.

The rbac policy decorators authenticate through `auth_required()`, so they
replace `@jwt_required()` rather than stack under it; stacking rejects
API-key callers before the policy can authorize them
(tests/test_auth_decorator_boundaries.py enforces this). Where a route then
needed the caller it reads `get_current_user()` — `get_jwt_identity()` raises
for an API-key request. The decorators also carry a `_sk_authz` marker so a
static test can see the decision from the source.

`migrations.get_migration_history` moves to `require_admin_user()` for the
same reason its siblings did: the inline `User.ROLE_ADMIN` check it kept was
already reading two names this module no longer imports, so the route raised
NameError for every caller, admin included.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 21:44:17 -04:00

200 lines
6.6 KiB
Python

"""
Fleet Monitor API
Endpoints for fleet-wide monitoring: heatmaps, comparisons,
alert thresholds, anomaly detection, capacity forecasting,
fleet search, and metrics export.
"""
import os
from flask import Blueprint, request, jsonify, Response
from flask_jwt_extended import jwt_required
from app.services.fleet_monitor_service import fleet_monitor_service
from app.middleware.rbac import admin_required, get_current_user
fleet_monitor_bp = Blueprint('fleet_monitor', __name__)
# ==================== Heatmap ====================
@fleet_monitor_bp.route('/heatmap', methods=['GET'])
@jwt_required()
def get_fleet_heatmap():
"""Get latest metrics for all servers in heatmap format."""
group_id = request.args.get('group_id')
return jsonify(fleet_monitor_service.get_fleet_heatmap(group_id))
# ==================== Comparison ====================
@fleet_monitor_bp.route('/comparison', methods=['GET'])
@jwt_required()
def get_comparison():
"""Get multi-server comparison time-series."""
ids_param = request.args.get('ids', '')
server_ids = [s.strip() for s in ids_param.split(',') if s.strip()]
metric = request.args.get('metric', 'cpu')
period = request.args.get('period', '24h')
if not server_ids:
return jsonify({'error': 'ids parameter is required'}), 400
return jsonify(fleet_monitor_service.get_comparison_timeseries(server_ids, metric, period))
# ==================== Alerts ====================
@fleet_monitor_bp.route('/alerts', methods=['GET'])
@jwt_required()
def get_alerts():
"""Get metric alerts."""
status = request.args.get('status')
severity = request.args.get('severity')
server_id = request.args.get('server_id')
limit = request.args.get('limit', 50, type=int)
return jsonify(fleet_monitor_service.get_alerts(status, severity, server_id, limit))
@fleet_monitor_bp.route('/alerts/<alert_id>/acknowledge', methods=['POST'])
@admin_required
def acknowledge_alert(alert_id):
"""Acknowledge an active alert."""
# Through the one identity door: @admin_required admits API-key callers,
# for whom reading the JWT directly raises.
caller = get_current_user()
user_id = caller.id if caller else None
success = fleet_monitor_service.acknowledge_alert(alert_id, user_id)
if not success:
return jsonify({'error': 'Cannot acknowledge alert'}), 400
return jsonify({'success': True})
@fleet_monitor_bp.route('/alerts/<alert_id>/resolve', methods=['POST'])
@admin_required
def resolve_alert(alert_id):
"""Resolve an alert."""
success = fleet_monitor_service.resolve_alert(alert_id)
if not success:
return jsonify({'error': 'Cannot resolve alert'}), 400
return jsonify({'success': True})
# ==================== Thresholds ====================
@fleet_monitor_bp.route('/thresholds', methods=['GET'])
@jwt_required()
def get_thresholds():
"""Get alert thresholds."""
server_id = request.args.get('server_id')
return jsonify(fleet_monitor_service.get_thresholds(server_id))
@fleet_monitor_bp.route('/thresholds', methods=['POST'])
@admin_required
def create_threshold():
"""Create or update an alert threshold."""
data = request.get_json()
result = fleet_monitor_service.upsert_threshold(data)
if 'error' in result:
return jsonify(result), 400
return jsonify(result), 201
@fleet_monitor_bp.route('/thresholds/<threshold_id>', methods=['DELETE'])
@admin_required
def delete_threshold(threshold_id):
"""Delete an alert threshold."""
success = fleet_monitor_service.delete_threshold(threshold_id)
if not success:
return jsonify({'error': 'Threshold not found'}), 404
return jsonify({'success': True})
# ==================== Anomalies ====================
@fleet_monitor_bp.route('/anomalies', methods=['GET'])
@jwt_required()
def get_anomalies():
"""Get anomaly detection results."""
server_id = request.args.get('server_id')
return jsonify(fleet_monitor_service.detect_anomalies(server_id))
# ==================== Capacity Forecast ====================
@fleet_monitor_bp.route('/forecast/<server_id>', methods=['GET'])
@jwt_required()
def get_forecast(server_id):
"""Get capacity forecast for a server."""
metric = request.args.get('metric', 'disk')
return jsonify(fleet_monitor_service.forecast_capacity(server_id, metric))
# ==================== Fleet Search ====================
@fleet_monitor_bp.route('/search', methods=['GET'])
@jwt_required()
def search_fleet():
"""Search across fleet for servers, containers, services, ports."""
query = request.args.get('q', '')
search_type = request.args.get('type', 'any')
if not query or len(query) < 2:
return jsonify({'error': 'Query must be at least 2 characters'}), 400
return jsonify(fleet_monitor_service.search_fleet(query, search_type))
# ==================== Export ====================
@fleet_monitor_bp.route('/export/csv', methods=['GET'])
@jwt_required()
def export_csv():
"""Export metrics as CSV download."""
ids_param = request.args.get('ids', '')
server_ids = [s.strip() for s in ids_param.split(',') if s.strip()]
metric = request.args.get('metric', 'cpu')
period = request.args.get('period', '24h')
if not server_ids:
return jsonify({'error': 'ids parameter is required'}), 400
csv_data = fleet_monitor_service.export_metrics_csv(server_ids, metric, period)
return Response(
csv_data,
mimetype='text/csv',
headers={'Content-Disposition': f'attachment; filename=fleet_{metric}_{period}.csv'}
)
@fleet_monitor_bp.route('/export/json', methods=['GET'])
@jwt_required()
def export_json():
"""Export metrics as JSON."""
ids_param = request.args.get('ids', '')
server_ids = [s.strip() for s in ids_param.split(',') if s.strip()]
metric = request.args.get('metric', 'cpu')
period = request.args.get('period', '24h')
if not server_ids:
return jsonify({'error': 'ids parameter is required'}), 400
return jsonify(fleet_monitor_service.get_comparison_timeseries(server_ids, metric, period))
# ==================== Prometheus ====================
@fleet_monitor_bp.route('/prometheus', methods=['GET'])
def prometheus_metrics():
"""Prometheus-compatible metrics endpoint (no JWT, uses token param)."""
token = request.args.get('token') or request.headers.get('X-Prometheus-Token')
expected = os.environ.get('PROMETHEUS_TOKEN')
if not expected or token != expected:
return Response('Unauthorized', status=401)
metrics = fleet_monitor_service.get_prometheus_metrics()
return Response(metrics, mimetype='text/plain; version=0.0.4; charset=utf-8')