Files
ServerKit/backend/app/api/python.py
T
Juan DenisandClaude Opus 4.8 6f70eb9ad1 feat(workspaces): make python.py grant-aware — every app blueprint now covered (#33)
Re-reading python.py showed every mutating endpoint is independently
@admin_required (runs before the shared get_app_or_404 gate), so that gate only
governs the 4 read GETs (packages/env/status/gunicorn-config). Pointing it at
can_access_app safely opens those to grantees while the admin-only mutations stay
untouched — no per-method split needed.

With this, every app-related blueprint is grant-aware. Also fixes the int-vs-str
identity comparison in the helper.

Verified: a python read-gate test (stranger 403, grantee passes), full suite
132 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 23:08:44 -04:00

473 lines
14 KiB
Python

from flask import Blueprint, request, jsonify
from flask_jwt_extended import jwt_required, get_jwt_identity
from app.models import User, Application
from app.services.python_service import PythonService
from app import db
python_bp = Blueprint('python', __name__)
def admin_required(fn):
"""Decorator to require admin role."""
from functools import wraps
@wraps(fn)
def wrapper(*args, **kwargs):
current_user_id = get_jwt_identity()
user = User.query.get(current_user_id)
if not user or user.role != 'admin':
return jsonify({'error': 'Admin access required'}), 403
return fn(*args, **kwargs)
return wrapper
def get_app_or_404(app_id, current_user_id):
"""Get application and verify read access. Read endpoints rely on this gate;
mutating endpoints additionally carry @admin_required, so honoring per-resource
grants here (#33) safely opens only the read GETs to grantees. Uses can_access_app
(user.id), which also fixes the int-vs-str identity comparison."""
from app.services.resource_grant_service import ResourceGrantService
user = User.query.get(current_user_id)
app = Application.query.get(app_id)
if not app:
return None, ({'error': 'Application not found'}, 404)
if not ResourceGrantService.can_access_app(user, app):
return None, ({'error': 'Access denied'}, 403)
if app.app_type not in ['flask', 'django']:
return None, ({'error': 'Application is not a Python app'}, 400)
return app, None
# Python version management
@python_bp.route('/versions', methods=['GET'])
@jwt_required()
def get_versions():
"""Get available Python versions."""
versions = PythonService.get_python_versions()
default = PythonService.get_default_python()
return jsonify({
'versions': versions,
'default': default
}), 200
# Application creation
@python_bp.route('/apps/flask', methods=['POST'])
@jwt_required()
@admin_required
def create_flask_app():
"""Create a new Flask application."""
data = request.get_json()
if not data:
return jsonify({'error': 'No data provided'}), 400
required_fields = ['name', 'path']
missing = [f for f in required_fields if f not in data]
if missing:
return jsonify({'error': f'Missing required fields: {", ".join(missing)}'}), 400
python_version = data.get('python_version', '3.11')
port = data.get('port', 8000)
# Create Flask app
result = PythonService.create_flask_app(
data['path'],
data['name'],
python_version
)
if not result['success']:
return jsonify(result), 400
# Create Gunicorn service
service_result = PythonService.create_gunicorn_service(
data['name'],
data['path'],
port=port,
workers=data.get('workers', 2)
)
if not service_result['success']:
return jsonify(service_result), 400
# Create application record
current_user_id = get_jwt_identity()
app = Application(
name=data['name'],
app_type='flask',
status='stopped',
python_version=python_version,
port=port,
root_path=data['path'],
user_id=current_user_id
)
db.session.add(app)
db.session.commit()
return jsonify({
'success': True,
'app_id': app.id,
'message': 'Flask application created successfully'
}), 201
@python_bp.route('/apps/django', methods=['POST'])
@jwt_required()
@admin_required
def create_django_app():
"""Create a new Django application."""
data = request.get_json()
if not data:
return jsonify({'error': 'No data provided'}), 400
required_fields = ['name', 'path']
missing = [f for f in required_fields if f not in data]
if missing:
return jsonify({'error': f'Missing required fields: {", ".join(missing)}'}), 400
python_version = data.get('python_version', '3.11')
port = data.get('port', 8000)
# Create Django app
result = PythonService.create_django_app(
data['path'],
data['name'],
python_version
)
if not result['success']:
return jsonify(result), 400
# Create Gunicorn service
service_result = PythonService.create_gunicorn_service(
data['name'],
data['path'],
port=port,
workers=data.get('workers', 2)
)
if not service_result['success']:
return jsonify(service_result), 400
# Create application record
current_user_id = get_jwt_identity()
app = Application(
name=data['name'],
app_type='django',
status='stopped',
python_version=python_version,
port=port,
root_path=data['path'],
user_id=current_user_id
)
db.session.add(app)
db.session.commit()
return jsonify({
'success': True,
'app_id': app.id,
'project_name': result.get('project_name'),
'message': 'Django application created successfully'
}), 201
# Virtual environment management
@python_bp.route('/apps/<int:app_id>/venv', methods=['POST'])
@jwt_required()
@admin_required
def create_venv(app_id):
"""Create or recreate virtual environment."""
current_user_id = get_jwt_identity()
app, error = get_app_or_404(app_id, current_user_id)
if error:
return jsonify(error[0]), error[1]
result = PythonService.create_virtualenv(app.root_path, app.python_version)
return jsonify(result), 200 if result['success'] else 400
@python_bp.route('/apps/<int:app_id>/packages', methods=['GET'])
@jwt_required()
def get_packages(app_id):
"""Get installed packages."""
current_user_id = get_jwt_identity()
app, error = get_app_or_404(app_id, current_user_id)
if error:
return jsonify(error[0]), error[1]
packages = PythonService.get_installed_packages(app.root_path)
return jsonify({'packages': packages}), 200
@python_bp.route('/apps/<int:app_id>/packages', methods=['POST'])
@jwt_required()
@admin_required
def install_packages(app_id):
"""Install packages."""
current_user_id = get_jwt_identity()
app, error = get_app_or_404(app_id, current_user_id)
if error:
return jsonify(error[0]), error[1]
data = request.get_json()
packages = data.get('packages') if data else None
result = PythonService.install_requirements(app.root_path, packages)
return jsonify(result), 200 if result['success'] else 400
@python_bp.route('/apps/<int:app_id>/requirements', methods=['POST'])
@jwt_required()
@admin_required
def freeze_requirements(app_id):
"""Freeze requirements to requirements.txt."""
current_user_id = get_jwt_identity()
app, error = get_app_or_404(app_id, current_user_id)
if error:
return jsonify(error[0]), error[1]
result = PythonService.freeze_requirements(app.root_path)
return jsonify(result), 200 if result['success'] else 400
# Environment variables
@python_bp.route('/apps/<int:app_id>/env', methods=['GET'])
@jwt_required()
def get_env_vars(app_id):
"""Get environment variables."""
current_user_id = get_jwt_identity()
app, error = get_app_or_404(app_id, current_user_id)
if error:
return jsonify(error[0]), error[1]
env_vars = PythonService.get_env_vars(app.root_path)
# Mask sensitive values
masked_vars = {}
sensitive_keys = ['SECRET', 'PASSWORD', 'KEY', 'TOKEN', 'PRIVATE']
for key, value in env_vars.items():
if any(s in key.upper() for s in sensitive_keys):
masked_vars[key] = '********'
else:
masked_vars[key] = value
return jsonify({'env_vars': masked_vars, 'count': len(env_vars)}), 200
@python_bp.route('/apps/<int:app_id>/env', methods=['PUT'])
@jwt_required()
@admin_required
def set_env_vars(app_id):
"""Set environment variables."""
current_user_id = get_jwt_identity()
app, error = get_app_or_404(app_id, current_user_id)
if error:
return jsonify(error[0]), error[1]
data = request.get_json()
if not data or 'env_vars' not in data:
return jsonify({'error': 'env_vars is required'}), 400
result = PythonService.set_env_vars(app.root_path, data['env_vars'])
return jsonify(result), 200 if result['success'] else 400
@python_bp.route('/apps/<int:app_id>/env/<key>', methods=['DELETE'])
@jwt_required()
@admin_required
def delete_env_var(app_id, key):
"""Delete an environment variable."""
current_user_id = get_jwt_identity()
app, error = get_app_or_404(app_id, current_user_id)
if error:
return jsonify(error[0]), error[1]
result = PythonService.delete_env_var(app.root_path, key)
return jsonify(result), 200 if result['success'] else 400
# Process control
@python_bp.route('/apps/<int:app_id>/start', methods=['POST'])
@jwt_required()
@admin_required
def start_app(app_id):
"""Start the application."""
current_user_id = get_jwt_identity()
app, error = get_app_or_404(app_id, current_user_id)
if error:
return jsonify(error[0]), error[1]
result = PythonService.start_app(app.name)
if result['success']:
app.status = 'running'
db.session.commit()
return jsonify(result), 200 if result['success'] else 400
@python_bp.route('/apps/<int:app_id>/stop', methods=['POST'])
@jwt_required()
@admin_required
def stop_app(app_id):
"""Stop the application."""
current_user_id = get_jwt_identity()
app, error = get_app_or_404(app_id, current_user_id)
if error:
return jsonify(error[0]), error[1]
result = PythonService.stop_app(app.name)
if result['success']:
app.status = 'stopped'
db.session.commit()
return jsonify(result), 200 if result['success'] else 400
@python_bp.route('/apps/<int:app_id>/restart', methods=['POST'])
@jwt_required()
@admin_required
def restart_app(app_id):
"""Restart the application."""
current_user_id = get_jwt_identity()
app, error = get_app_or_404(app_id, current_user_id)
if error:
return jsonify(error[0]), error[1]
result = PythonService.restart_app(app.name)
if result['success']:
app.status = 'running'
db.session.commit()
return jsonify(result), 200 if result['success'] else 400
@python_bp.route('/apps/<int:app_id>/status', methods=['GET'])
@jwt_required()
def get_status(app_id):
"""Get application status."""
current_user_id = get_jwt_identity()
app, error = get_app_or_404(app_id, current_user_id)
if error:
return jsonify(error[0]), error[1]
status = PythonService.get_app_status(app.name)
return jsonify(status), 200
# Gunicorn configuration
@python_bp.route('/apps/<int:app_id>/gunicorn', methods=['GET'])
@jwt_required()
def get_gunicorn_config(app_id):
"""Get Gunicorn configuration."""
current_user_id = get_jwt_identity()
app, error = get_app_or_404(app_id, current_user_id)
if error:
return jsonify(error[0]), error[1]
config = PythonService.get_gunicorn_config(app.root_path)
return jsonify(config), 200
@python_bp.route('/apps/<int:app_id>/gunicorn', methods=['PUT'])
@jwt_required()
@admin_required
def update_gunicorn_config(app_id):
"""Update Gunicorn configuration."""
current_user_id = get_jwt_identity()
app, error = get_app_or_404(app_id, current_user_id)
if error:
return jsonify(error[0]), error[1]
data = request.get_json()
if not data or 'content' not in data:
return jsonify({'error': 'content is required'}), 400
result = PythonService.save_gunicorn_config(app.root_path, data['content'])
return jsonify(result), 200 if result['success'] else 400
# Django-specific
@python_bp.route('/apps/<int:app_id>/migrate', methods=['POST'])
@jwt_required()
@admin_required
def run_migrations(app_id):
"""Run database migrations."""
current_user_id = get_jwt_identity()
app, error = get_app_or_404(app_id, current_user_id)
if error:
return jsonify(error[0]), error[1]
result = PythonService.run_migrations(app.root_path, app.app_type)
return jsonify(result), 200 if result['success'] else 400
@python_bp.route('/apps/<int:app_id>/collectstatic', methods=['POST'])
@jwt_required()
@admin_required
def collect_static(app_id):
"""Collect static files (Django only)."""
current_user_id = get_jwt_identity()
app, error = get_app_or_404(app_id, current_user_id)
if error:
return jsonify(error[0]), error[1]
if app.app_type != 'django':
return jsonify({'error': 'Only supported for Django apps'}), 400
result = PythonService.collect_static(app.root_path, app.app_type)
return jsonify(result), 200 if result['success'] else 400
# Command execution
@python_bp.route('/apps/<int:app_id>/run', methods=['POST'])
@jwt_required()
@admin_required
def run_command(app_id):
"""Run a command in the app context."""
current_user_id = get_jwt_identity()
app, error = get_app_or_404(app_id, current_user_id)
if error:
return jsonify(error[0]), error[1]
data = request.get_json()
if not data or 'command' not in data:
return jsonify({'error': 'command is required'}), 400
# Basic command validation - block dangerous commands
command = data['command']
blocked = ['rm -rf /', 'mkfs', 'dd if=', ':(){:|:&};:']
if any(b in command for b in blocked):
return jsonify({'error': 'Command not allowed'}), 403
result = PythonService.run_command(app.root_path, command)
return jsonify(result), 200
# Delete application
@python_bp.route('/apps/<int:app_id>', methods=['DELETE'])
@jwt_required()
@admin_required
def delete_app(app_id):
"""Delete a Python application."""
current_user_id = get_jwt_identity()
app, error = get_app_or_404(app_id, current_user_id)
if error:
return jsonify(error[0]), error[1]
data = request.get_json() or {}
remove_files = data.get('remove_files', False)
result = PythonService.delete_app(app.name, app.root_path, remove_files)
if result['success']:
db.session.delete(app)
db.session.commit()
return jsonify(result), 200 if result['success'] else 400