Files
Juan Denis 9b85952b5f Enforce workspace access on every server route
Only three routes checked can_access_server; the rest loaded the server
by id, so a developer outside a workspace could run cron, write files,
install packages or widen agent permissions on its servers, and the
list/overview/docker picker showed every server. Every <server_id>
route now resolves through the workspace check, lists filter in SQL,
and PUT /servers/<id> validates allowed_ips like PUT /allowed-ips.
A sweep test walks the URL map so a new unguarded route fails CI.
2026-10-26 00:35:55 -04:00

82 lines
2.8 KiB
Python

"""
Fleet API
Cross-server endpoints that don't belong to any single server's resource
tree — primarily the target-picker support that every agent-aware
feature page consumes.
The "target" abstraction:
target = {"kind": "local"}
| {"kind": "agent", "server_id": "...", "server_name": "..."}
Pages render a picker; clicks send `target` in the request body of
feature CRUD calls; the backend dispatches against `target.kind`. See
`backend/app/services/fleet_targeting.py` (Phase 1) for the dispatch
helper.
This module is read-only; it does not write any state.
"""
from flask import Blueprint, jsonify, request
from flask_jwt_extended import jwt_required
from app.services.agent_registry import agent_registry
from app.services.workspace_service import WorkspaceService
from app.middleware.rbac import get_current_user
fleet_bp = Blueprint('fleet', __name__)
@fleet_bp.route('/targets', methods=['GET'])
@jwt_required()
def list_targets():
"""List runnable targets, optionally filtered by feature.
Query params:
feature — capability name (cron, docker, systemd, …). When
present, only agents whose advertised capability
map has feature=true are returned. When absent,
every connected agent is returned regardless of
capabilities.
The local panel host is always included as the first entry. Even
if no agent has the requested feature the caller still gets the
local target back, which matches the behaviour the UI expects:
"the local target is always available, agents are additive."
"""
feature = (request.args.get('feature') or '').strip() or None
targets = [{'kind': 'local'}]
user = get_current_user()
# Walk the in-memory registry of connected agents. We intentionally
# do NOT pull from the Server table — capabilities are per-session
# state, and a server with status=offline can't take a command no
# matter what it last reported.
for server_id in agent_registry.get_connected_servers():
if feature and not agent_registry.has_capability(server_id, feature):
continue
# Look up the display name. We could cache this on
# ConnectedAgent, but the table is small and the lookup is
# cheap; not worth the staleness risk.
server = WorkspaceService.get_accessible_server(user, server_id)
if not server:
# Connected agent without a Server row would be a bug
# elsewhere; skip rather than 500.
continue
caps = agent_registry.get_capabilities(server_id) or {}
targets.append({
'kind': 'agent',
'server_id': server.id,
'server_name': server.name,
'hostname': server.hostname,
'capabilities': caps,
})
return jsonify({'targets': targets})