Files
Juan Denis 55d8fb1e44 Read route bodies through json_object()
328 more routes used a JSON body as a dict straight from
request.get_json(), so a body of null, [] or a bare string crashed them
into a 500. Bare reads become json_object(); the
'get_json(silent=True) or {}' reads of action routes become
json_object(optional=True), which still treats a missing body as {}.
Routes that take arrays or pass the body through untouched are left
alone. The full backend suite shows no new failures.

Also carries three files' bare section citations (apps, backups,
notifications) and the extended citation guard, which needed them.
2026-10-10 20:53:43 -04:00

168 lines
6.2 KiB
Python

"""Server survey API — read-only "flights" over a paired agent.
Mounted at /api/v1/servers (see app/__init__.py). All routes are admin-only: a
survey enumerates what's running on a host, and the catalog index is the honest
"here is what we look at" surface shown verbatim in the UI.
"""
from flask import Blueprint, jsonify, request
from app.middleware.rbac import admin_required, get_current_user
from app.models.server import Server
from app.services import survey_service
from app.exceptions import not_found
from app.api._request import json_object
survey_bp = Blueprint('survey', __name__)
@survey_bp.route('/survey/catalog', methods=['GET'])
@admin_required
def get_catalog():
"""The operator-facing probe index — exactly what the survey reads."""
try:
return jsonify(survey_service.probe_index()), 200
except survey_service.SurveyError as exc:
return jsonify({'error': f'Invalid probe catalog: {exc}'}), 500
@survey_bp.route('/<server_id>/survey', methods=['POST'])
@admin_required
def run_survey(server_id):
"""Fly a read-only survey against the server's agent and store the map."""
server = Server.query.get(server_id)
if server is None:
raise not_found('server')
user = get_current_user()
survey, error = survey_service.run_survey(server_id, user_id=getattr(user, 'id', None))
if error:
return jsonify({'error': error['error'], 'code': error['code']}), error['status']
return jsonify(survey), 201
@survey_bp.route('/<server_id>/management-mode', methods=['POST'])
@admin_required
def set_management_mode(server_id):
"""Switch a server between 'managed' and 'observed' (Decision 1/4).
An operator confirms mode changes; detection only ever SUGGESTS. Existing
managed boxes are never auto-downgraded — this is the explicit, audited flip.
"""
from app import db
from app.services.audit_service import AuditService
server = Server.query.get(server_id)
if server is None:
raise not_found('server')
data = json_object(optional=True)
mode = (data.get('mode') or '').strip().lower()
if mode not in Server.MANAGEMENT_MODES:
return jsonify({'error': f'mode must be one of {list(Server.MANAGEMENT_MODES)}'}), 400
previous = server.management_mode or 'managed'
server.management_mode = mode
# Optional break-glass toggle: permit agent:update
# while observing. Only meaningful for observed boxes but always storable.
details = {'from': previous, 'to': mode}
if 'allow_agent_update_observed' in data:
override = bool(data.get('allow_agent_update_observed'))
server.allow_agent_update_observed = override
details['allow_agent_update_observed'] = override
db.session.commit()
user = get_current_user()
try:
AuditService.log(
action='server.management_mode',
user_id=getattr(user, 'id', None),
target_type='server',
target_id=server_id,
details=details,
)
except Exception: # audit is best-effort, never blocks the flip
pass
result = server.to_dict()
from app.services.agent_registry import agent_registry
result['observed_blocked_count'] = agent_registry.observed_blocked_count(server_id)
return jsonify(result), 200
@survey_bp.route('/<server_id>/observed-status', methods=['GET'])
@admin_required
def observed_status(server_id):
"""Observe-mode status for a server.
Returns the current mode, the agent:update break-glass flag, and how many
commands the Observe guard has blocked — the "blocked N commands" counter.
"""
from app.services.agent_registry import agent_registry
server = Server.query.get(server_id)
if server is None:
raise not_found('server')
return jsonify({
'management_mode': server.management_mode or 'managed',
'allow_agent_update_observed': bool(server.allow_agent_update_observed),
'observed_blocked_count': agent_registry.observed_blocked_count(server_id),
}), 200
@survey_bp.route('/<server_id>/surveys', methods=['GET'])
@admin_required
def list_surveys(server_id):
"""List survey snapshots for a server (newest first, without the map blob)."""
server = Server.query.get(server_id)
if server is None:
raise not_found('server')
surveys = survey_service.list_surveys(server_id)
return jsonify({'surveys': [s.to_dict(include_map=False) for s in surveys]}), 200
@survey_bp.route('/<server_id>/surveys/<int:survey_id>', methods=['GET'])
@admin_required
def get_survey(server_id, survey_id):
"""Fetch one survey snapshot including its full Server Map."""
from app.models.server_survey import ServerSurvey
survey = ServerSurvey.query.filter_by(id=survey_id, server_id=server_id).first()
if survey is None:
return jsonify({'error': 'Survey not found'}), 404
return jsonify(survey.to_dict()), 200
@survey_bp.route('/<server_id>/surveys/diff', methods=['GET'])
@admin_required
def diff_surveys(server_id):
"""Diff two survey snapshots (``?from=<id>&to=<id>``).
Defaults ``to`` to the latest snapshot and ``from`` to the one before it, so
a bare call answers "what changed since the last flight".
"""
from app.models.server_survey import ServerSurvey
surveys = survey_service.list_surveys(server_id)
if len(surveys) < 2 and not (request.args.get('from') and request.args.get('to')):
return jsonify({'error': 'Need at least two surveys to diff'}), 400
def _load(arg, default):
raw = request.args.get(arg)
if raw is None:
return default
s = ServerSurvey.query.filter_by(id=int(raw), server_id=server_id).first()
if s is None:
return None
return s
new_survey = _load('to', surveys[0] if surveys else None)
old_survey = _load('from', surveys[1] if len(surveys) > 1 else None)
if new_survey is None or old_survey is None:
return jsonify({'error': 'Survey not found'}), 404
diff = survey_service.diff_maps(old_survey.get_map(), new_survey.get_map())
return jsonify({
'from': old_survey.to_dict(include_map=False),
'to': new_survey.to_dict(include_map=False),
'diff': diff,
}), 200