mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-11-08 21:14:47 +09:00
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>
176 lines
6.1 KiB
Python
176 lines
6.1 KiB
Python
"""
|
|
Monitors API
|
|
|
|
Uptime/synthetic checks against URLs, hosts and managed sites. Core, not part of
|
|
the serverkit-status extension — watching a site must not require publishing a
|
|
status page. See app/services/monitor_service.py.
|
|
"""
|
|
|
|
from flask import Blueprint, request, jsonify
|
|
from flask_jwt_extended import jwt_required
|
|
|
|
from app.exceptions import NotFoundError
|
|
from app.middleware.rbac import permission_required
|
|
from app.services.monitor_service import MonitorService
|
|
|
|
monitors_bp = Blueprint('monitors', __name__)
|
|
|
|
|
|
@monitors_bp.route('', methods=['GET'])
|
|
@monitors_bp.route('/', methods=['GET'])
|
|
@jwt_required()
|
|
def list_monitors():
|
|
"""List monitors.
|
|
|
|
Query params: status, type, q, page_id.
|
|
"""
|
|
page_id = request.args.get('page_id', type=int)
|
|
monitors = MonitorService.list_monitors(
|
|
status=request.args.get('status') or None,
|
|
check_type=request.args.get('type') or None,
|
|
q=request.args.get('q') or None,
|
|
page_id=page_id,
|
|
)
|
|
# One extra query for every row's sparkline, not one per row.
|
|
sparks = MonitorService.recent_response_times([m.id for m in monitors])
|
|
payload = []
|
|
for monitor in monitors:
|
|
item = monitor.to_dict()
|
|
item['spark'] = sparks.get(monitor.id, [])
|
|
payload.append(item)
|
|
return jsonify({'monitors': payload})
|
|
|
|
|
|
@monitors_bp.route('/stats', methods=['GET'])
|
|
@jwt_required()
|
|
def get_stats():
|
|
"""KPI-band counts for the Monitors tab."""
|
|
return jsonify({'stats': MonitorService.stats()})
|
|
|
|
|
|
@monitors_bp.route('', methods=['POST'])
|
|
@monitors_bp.route('/', methods=['POST'])
|
|
@permission_required('monitoring', 'write')
|
|
def create_monitor():
|
|
data = request.get_json() or {}
|
|
monitor = MonitorService.create(data)
|
|
return jsonify(monitor.to_dict()), 201
|
|
|
|
|
|
@monitors_bp.route('/<int:monitor_id>', methods=['GET'])
|
|
@jwt_required()
|
|
def get_monitor(monitor_id):
|
|
monitor = MonitorService.get(monitor_id)
|
|
if not monitor:
|
|
raise NotFoundError('Monitor not found', code='monitor_not_found')
|
|
return jsonify(monitor.to_dict())
|
|
|
|
|
|
@monitors_bp.route('/<int:monitor_id>', methods=['PATCH', 'PUT'])
|
|
@permission_required('monitoring', 'write')
|
|
def update_monitor(monitor_id):
|
|
data = request.get_json() or {}
|
|
monitor = MonitorService.update(monitor_id, data)
|
|
if not monitor:
|
|
raise NotFoundError('Monitor not found', code='monitor_not_found')
|
|
return jsonify(monitor.to_dict())
|
|
|
|
|
|
@monitors_bp.route('/<int:monitor_id>', methods=['DELETE'])
|
|
@permission_required('monitoring', 'write')
|
|
def delete_monitor(monitor_id):
|
|
if not MonitorService.delete(monitor_id):
|
|
return {'error': 'Monitor not found'}, 404
|
|
return jsonify({'deleted': True})
|
|
|
|
|
|
@monitors_bp.route('/<int:monitor_id>/check', methods=['POST'])
|
|
@permission_required('monitoring', 'write')
|
|
def run_check(monitor_id):
|
|
"""Probe now, out of band from the scheduler."""
|
|
hc = MonitorService.run_check(monitor_id)
|
|
if not hc:
|
|
return {'error': 'Monitor not found'}, 404
|
|
monitor = MonitorService.get(monitor_id)
|
|
return jsonify({'check': hc.to_dict(), 'monitor': monitor.to_dict()})
|
|
|
|
|
|
@monitors_bp.route('/<int:monitor_id>/pause', methods=['POST'])
|
|
@permission_required('monitoring', 'write')
|
|
def set_paused(monitor_id):
|
|
data = request.get_json() or {}
|
|
monitor = MonitorService.set_paused(monitor_id, data.get('paused', True))
|
|
if not monitor:
|
|
return {'error': 'Monitor not found'}, 404
|
|
return jsonify(monitor.to_dict())
|
|
|
|
|
|
@monitors_bp.route('/<int:monitor_id>/history', methods=['GET'])
|
|
@jwt_required()
|
|
def get_history(monitor_id):
|
|
"""Recent check results. Query params: hours (default 24), limit."""
|
|
if not MonitorService.get(monitor_id):
|
|
return {'error': 'Monitor not found'}, 404
|
|
hours = request.args.get('hours', 24, type=int)
|
|
limit = request.args.get('limit', 200, type=int)
|
|
checks = MonitorService.get_check_history(monitor_id, hours=hours, limit=limit)
|
|
return jsonify({'checks': [c.to_dict() for c in checks]})
|
|
|
|
|
|
@monitors_bp.route('/<int:monitor_id>/uptime', methods=['GET'])
|
|
@jwt_required()
|
|
def get_uptime(monitor_id):
|
|
"""Per-day uptime buckets for the 90-day bar strip. Query param: days."""
|
|
monitor = MonitorService.get(monitor_id)
|
|
if not monitor:
|
|
return {'error': 'Monitor not found'}, 404
|
|
days = min(request.args.get('days', 90, type=int), 365)
|
|
return jsonify({
|
|
'days': MonitorService.uptime_days(monitor_id, days=days),
|
|
'uptime_24h': monitor.uptime_24h,
|
|
'uptime_7d': monitor.uptime_7d,
|
|
'uptime_30d': monitor.uptime_30d,
|
|
'uptime_90d': monitor.uptime_90d,
|
|
})
|
|
|
|
|
|
# --- Incidents -------------------------------------------------------------
|
|
# Incidents are core because the scheduler opens them automatically, with or
|
|
# without the status-page extension installed.
|
|
|
|
@monitors_bp.route('/incidents', methods=['GET'])
|
|
@jwt_required()
|
|
def list_incidents():
|
|
"""Query params: state (active|resolved|all), limit."""
|
|
state = request.args.get('state', 'all')
|
|
limit = request.args.get('limit', 100, type=int)
|
|
incidents = MonitorService.list_incidents(state=state, limit=limit)
|
|
return jsonify({'incidents': [i.to_dict() for i in incidents]})
|
|
|
|
|
|
@monitors_bp.route('/incidents', methods=['POST'])
|
|
@permission_required('monitoring', 'write')
|
|
def create_incident():
|
|
data = request.get_json() or {}
|
|
if not data.get('title'):
|
|
return {'error': 'Incident title is required'}, 400
|
|
incident = MonitorService.create_incident(data.get('page_id'), data)
|
|
return jsonify(incident.to_dict()), 201
|
|
|
|
|
|
@monitors_bp.route('/incidents/<int:incident_id>', methods=['PATCH', 'PUT'])
|
|
@permission_required('monitoring', 'write')
|
|
def update_incident(incident_id):
|
|
incident = MonitorService.update_incident(incident_id, request.get_json() or {})
|
|
if not incident:
|
|
return {'error': 'Incident not found'}, 404
|
|
return jsonify(incident.to_dict())
|
|
|
|
|
|
@monitors_bp.route('/incidents/<int:incident_id>', methods=['DELETE'])
|
|
@permission_required('monitoring', 'write')
|
|
def delete_incident(incident_id):
|
|
if not MonitorService.delete_incident(incident_id):
|
|
return {'error': 'Incident not found'}, 404
|
|
return jsonify({'deleted': True})
|