mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-30 16:47:01 +09:00
Plan 76 milestone A, drift/bug row 2 — scheduled as a fix, not a refactor,
because it is a behaviour difference shipping today.
rbac.get_current_user() is the only lookup that knows about both auth
mechanisms: it returns g.api_key_user for an X-API-Key request and falls back
to the JWT. 73 call sites answered "who is calling" with
`User.query.get(get_jwt_identity())` instead, behind 19 near-identical private
helpers (12 named get_current_user(), shadowing the real door).
The audit that scheduled this row predicted those return None for API-key
callers. Measured, it is worse: auth_required() deliberately skips
verify_jwt_in_request() once the key middleware has authenticated, so there is
no JWT context and get_jwt_identity() *raises*. Nine handlers reached through
an API-key-capable policy decorator therefore returned 500 to every API-key
caller:
bandwidth.get_app_bandwidth @viewer_required
servers.set_server_workspace @developer_required
servers.create_server @developer_required
sso.get_sso_config / update_provider_config / update_general_settings
source_connections.update_{github,gitlab,bitbucket}_admin_config
source_connections.complete_github_app_manifest
databases.execute_{mysql,pg,sqlite,docker}_query (see below)
Three service helpers had the same lookup inside a blanket `except Exception`,
which converted the raise into a silent user_id=None — the write landed and
the audit trail lost the actor (shared_resource_service, cf_ops_change_service,
secrets_webhooks).
The four databases.execute_*_query sites were a second admin check *inside* an
already-@admin_required route: dead code that always passed for JWT callers and
crashed for API-key ones. They now call require_admin_user() rather than
re-deriving the answer.
Also folds in a sixth _require_admin() found in the cloudflare-ops extension
source (29 gates, the same inverted contract as jobs.py), and fixes
serverkit-gui's blueprint at its sibling-repo source rather than in this repo's
live copy.
Ratchet (second-wave rule 1 — a migration without one regrows):
tests/identity_door_census.py counts both bypass shapes by AST, direct and the
two-line `uid = get_jwt_identity(); User.query.get(uid)` variant. The ceiling is
87, all of them on @jwt_required()-only routes where reading the JWT is correct
today; the ratchet's third assertion holds the API-key-capable population at
exactly zero, so migrating one of those routes to a policy decorator without
fixing its lookup now fails. Verified non-vacuous: an injected bypass fails both
assertions and passes again on revert.
The first wave's controller-boundary baseline is regenerated — this removes 74
persistence crossings (593 -> 519), a debt reduction with no new entries.
Gate: tests/test_api_key_identity.py (7, each red before the fix),
test_identity_door_ratchet.py (10), test_api_controller_boundaries.py (4), plus
the 65 test files covering every touched module: 1024 passed, 10 skipped.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
236 lines
7.7 KiB
Python
236 lines
7.7 KiB
Python
from flask import Blueprint, request, jsonify
|
|
from flask_jwt_extended import jwt_required
|
|
from app.services.agent_plugin_service import AgentPluginService
|
|
from app.services.audit_service import AuditService
|
|
from app.models.audit_log import AuditLog
|
|
from app.middleware.rbac import get_current_user
|
|
|
|
agent_plugins_bp = Blueprint('agent_plugins', __name__)
|
|
|
|
|
|
@agent_plugins_bp.route('/', methods=['GET'])
|
|
@jwt_required()
|
|
def list_plugins():
|
|
status = request.args.get('status')
|
|
plugins = AgentPluginService.list_plugins(status=status)
|
|
return jsonify({'plugins': [p.to_dict() for p in plugins]})
|
|
|
|
|
|
@agent_plugins_bp.route('/<int:plugin_id>', methods=['GET'])
|
|
@jwt_required()
|
|
def get_plugin(plugin_id):
|
|
plugin = AgentPluginService.get_plugin(plugin_id)
|
|
if not plugin:
|
|
return jsonify({'error': 'Plugin not found'}), 404
|
|
return jsonify(plugin.to_dict())
|
|
|
|
|
|
@agent_plugins_bp.route('/', methods=['POST'])
|
|
@jwt_required()
|
|
def create_plugin():
|
|
user = get_current_user()
|
|
if not user or not user.is_admin:
|
|
return jsonify({'error': 'Admin access required'}), 403
|
|
|
|
data = request.get_json()
|
|
if not data:
|
|
return jsonify({'error': 'Request body required'}), 400
|
|
|
|
# Validate manifest
|
|
errors = AgentPluginService.validate_manifest(data)
|
|
if errors:
|
|
return jsonify({'error': 'Invalid manifest', 'details': errors}), 400
|
|
|
|
try:
|
|
plugin = AgentPluginService.create_plugin(data)
|
|
AuditService.log(
|
|
action=AuditLog.ACTION_RESOURCE_CREATE,
|
|
user_id=user.id,
|
|
target_type='agent_plugin',
|
|
target_id=plugin.id,
|
|
details={'name': plugin.name, 'version': plugin.version}
|
|
)
|
|
return jsonify(plugin.to_dict()), 201
|
|
except ValueError as e:
|
|
return jsonify({'error': str(e)}), 400
|
|
|
|
|
|
@agent_plugins_bp.route('/<int:plugin_id>', methods=['PUT'])
|
|
@jwt_required()
|
|
def update_plugin(plugin_id):
|
|
user = get_current_user()
|
|
if not user or not user.is_admin:
|
|
return jsonify({'error': 'Admin access required'}), 403
|
|
|
|
data = request.get_json()
|
|
plugin = AgentPluginService.update_plugin(plugin_id, data)
|
|
if not plugin:
|
|
return jsonify({'error': 'Plugin not found'}), 404
|
|
|
|
return jsonify(plugin.to_dict())
|
|
|
|
|
|
@agent_plugins_bp.route('/<int:plugin_id>', methods=['DELETE'])
|
|
@jwt_required()
|
|
def delete_plugin(plugin_id):
|
|
user = get_current_user()
|
|
if not user or not user.is_admin:
|
|
return jsonify({'error': 'Admin access required'}), 403
|
|
|
|
try:
|
|
result = AgentPluginService.delete_plugin(plugin_id)
|
|
if not result:
|
|
return jsonify({'error': 'Plugin not found'}), 404
|
|
AuditService.log(
|
|
action=AuditLog.ACTION_RESOURCE_DELETE,
|
|
user_id=user.id,
|
|
target_type='agent_plugin',
|
|
target_id=plugin_id,
|
|
details={}
|
|
)
|
|
return jsonify({'message': 'Plugin deleted'})
|
|
except ValueError as e:
|
|
return jsonify({'error': str(e)}), 400
|
|
|
|
|
|
@agent_plugins_bp.route('/validate', methods=['POST'])
|
|
@jwt_required()
|
|
def validate_manifest():
|
|
data = request.get_json()
|
|
if not data:
|
|
return jsonify({'error': 'Manifest data required'}), 400
|
|
errors = AgentPluginService.validate_manifest(data)
|
|
return jsonify({'valid': len(errors) == 0, 'errors': errors})
|
|
|
|
|
|
# --- Installation endpoints ---
|
|
|
|
@agent_plugins_bp.route('/<int:plugin_id>/install', methods=['POST'])
|
|
@jwt_required()
|
|
def install_plugin(plugin_id):
|
|
user = get_current_user()
|
|
if not user or not user.is_admin:
|
|
return jsonify({'error': 'Admin access required'}), 403
|
|
|
|
data = request.get_json() or {}
|
|
server_id = data.get('server_id')
|
|
if not server_id:
|
|
return jsonify({'error': 'server_id required'}), 400
|
|
|
|
try:
|
|
install = AgentPluginService.install_plugin(
|
|
plugin_id, server_id, config=data.get('config')
|
|
)
|
|
AuditService.log(
|
|
action=AuditLog.ACTION_RESOURCE_CREATE,
|
|
user_id=user.id,
|
|
target_type='agent_plugin_install',
|
|
target_id=install.id,
|
|
details={'plugin_id': plugin_id, 'server_id': server_id}
|
|
)
|
|
return jsonify(install.to_dict()), 201
|
|
except ValueError as e:
|
|
return jsonify({'error': str(e)}), 400
|
|
|
|
|
|
@agent_plugins_bp.route('/<int:plugin_id>/bulk-install', methods=['POST'])
|
|
@jwt_required()
|
|
def bulk_install_plugin(plugin_id):
|
|
user = get_current_user()
|
|
if not user or not user.is_admin:
|
|
return jsonify({'error': 'Admin access required'}), 403
|
|
|
|
data = request.get_json() or {}
|
|
server_ids = data.get('server_ids', [])
|
|
if not server_ids:
|
|
return jsonify({'error': 'server_ids required'}), 400
|
|
|
|
results = AgentPluginService.bulk_install(plugin_id, server_ids, config=data.get('config'))
|
|
return jsonify({'results': results})
|
|
|
|
|
|
@agent_plugins_bp.route('/<int:plugin_id>/installations', methods=['GET'])
|
|
@jwt_required()
|
|
def get_plugin_installations(plugin_id):
|
|
installs = AgentPluginService.get_plugin_installations(plugin_id)
|
|
return jsonify({'installations': [i.to_dict() for i in installs]})
|
|
|
|
|
|
@agent_plugins_bp.route('/installs/<int:install_id>', methods=['GET'])
|
|
@jwt_required()
|
|
def get_install(install_id):
|
|
install = AgentPluginService.get_install(install_id)
|
|
if not install:
|
|
return jsonify({'error': 'Installation not found'}), 404
|
|
return jsonify(install.to_dict())
|
|
|
|
|
|
@agent_plugins_bp.route('/installs/<int:install_id>/enable', methods=['POST'])
|
|
@jwt_required()
|
|
def enable_install(install_id):
|
|
user = get_current_user()
|
|
if not user or not user.is_admin:
|
|
return jsonify({'error': 'Admin access required'}), 403
|
|
|
|
install = AgentPluginService.enable_plugin(install_id)
|
|
if not install:
|
|
return jsonify({'error': 'Installation not found'}), 404
|
|
return jsonify(install.to_dict())
|
|
|
|
|
|
@agent_plugins_bp.route('/installs/<int:install_id>/disable', methods=['POST'])
|
|
@jwt_required()
|
|
def disable_install(install_id):
|
|
user = get_current_user()
|
|
if not user or not user.is_admin:
|
|
return jsonify({'error': 'Admin access required'}), 403
|
|
|
|
install = AgentPluginService.disable_plugin(install_id)
|
|
if not install:
|
|
return jsonify({'error': 'Installation not found'}), 404
|
|
return jsonify(install.to_dict())
|
|
|
|
|
|
@agent_plugins_bp.route('/installs/<int:install_id>', methods=['DELETE'])
|
|
@jwt_required()
|
|
def uninstall_plugin(install_id):
|
|
user = get_current_user()
|
|
if not user or not user.is_admin:
|
|
return jsonify({'error': 'Admin access required'}), 403
|
|
|
|
result = AgentPluginService.uninstall_plugin(install_id)
|
|
if not result:
|
|
return jsonify({'error': 'Installation not found'}), 404
|
|
return jsonify({'message': 'Plugin uninstall initiated'})
|
|
|
|
|
|
@agent_plugins_bp.route('/installs/<int:install_id>/config', methods=['PUT'])
|
|
@jwt_required()
|
|
def update_install_config(install_id):
|
|
user = get_current_user()
|
|
if not user or not user.is_admin:
|
|
return jsonify({'error': 'Admin access required'}), 403
|
|
|
|
data = request.get_json()
|
|
if not data or 'config' not in data:
|
|
return jsonify({'error': 'config required'}), 400
|
|
|
|
install = AgentPluginService.update_install_config(install_id, data['config'])
|
|
if not install:
|
|
return jsonify({'error': 'Installation not found'}), 404
|
|
return jsonify(install.to_dict())
|
|
|
|
|
|
@agent_plugins_bp.route('/server/<int:server_id>', methods=['GET'])
|
|
@jwt_required()
|
|
def get_server_plugins(server_id):
|
|
installs = AgentPluginService.get_server_plugins(server_id)
|
|
return jsonify({'plugins': [i.to_dict() for i in installs]})
|
|
|
|
|
|
@agent_plugins_bp.route('/spec', methods=['GET'])
|
|
@jwt_required()
|
|
def get_plugin_spec():
|
|
"""Return the plugin specification interface."""
|
|
return jsonify(AgentPluginService.PLUGIN_SPEC)
|