mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-11-12 23:12:54 +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>
139 lines
5.5 KiB
Python
139 lines
5.5 KiB
Python
"""
|
|
Build Packs API — zero-Dockerfile detection + Dockerfile/compose generation.
|
|
|
|
Mounted at /api/v1/buildpacks (registered in app/__init__.py).
|
|
|
|
Two endpoints:
|
|
* POST /detect — clone a repo to a throwaway workspace, run BuildpackService
|
|
.detect(), return the plan + a generated Dockerfile preview.
|
|
Results are cached by (repo_url, commit) so repeated detects
|
|
on an unchanged repo are cheap. Accepts an already-cloned
|
|
``path`` to skip cloning (used by tests / advanced callers).
|
|
* POST /generate — pure: given a plan dict, return Dockerfile + compose.
|
|
"""
|
|
|
|
import os
|
|
import shutil
|
|
import tempfile
|
|
|
|
from flask import Blueprint, request, jsonify
|
|
from flask_jwt_extended import jwt_required
|
|
|
|
from app.services.buildpack_service import BuildpackService
|
|
from app.services.git_service import GitService
|
|
from app.services.source_connection_service import SourceConnectionService
|
|
from app.middleware.rbac import developer_required, get_current_user
|
|
|
|
buildpacks_bp = Blueprint('buildpacks', __name__)
|
|
|
|
|
|
def _build_response(plan: dict, app_name: str = 'app') -> dict:
|
|
"""Attach generated artifacts to a plan for the client."""
|
|
return {
|
|
'plan': plan,
|
|
'dockerfile': BuildpackService.generate_dockerfile(plan),
|
|
'compose': BuildpackService.generate_compose(plan, app_name),
|
|
}
|
|
|
|
|
|
@buildpacks_bp.route('/detect', methods=['POST'])
|
|
@developer_required
|
|
def detect():
|
|
"""Detect the build pack for a repository.
|
|
|
|
Body: {repo_url?, branch?, source_connection_id?, repository_full_name?, path?}
|
|
|
|
If ``path`` is supplied it is used directly (no clone). Otherwise the repo is
|
|
cloned shallowly to a temp workspace, inspected, and the workspace removed.
|
|
"""
|
|
data = request.get_json() or {}
|
|
# Through the one identity door: @developer_required admits API-key
|
|
# callers, for whom reading the JWT directly raises.
|
|
caller = get_current_user()
|
|
current_user_id = caller.id if caller else None
|
|
|
|
explicit_path = (data.get('path') or '').strip() or None
|
|
repo_url = (data.get('repo_url') or '').strip()
|
|
branch = (data.get('branch') or '').strip() or None
|
|
source_connection_id = data.get('source_connection_id')
|
|
repository_full_name = (data.get('repository_full_name') or '').strip()
|
|
app_name = (data.get('name') or 'app').strip() or 'app'
|
|
|
|
# ---- Path supplied: detect in place, no clone. --------------------------
|
|
if explicit_path:
|
|
if not os.path.isdir(explicit_path):
|
|
return jsonify({'error': f'Path does not exist: {explicit_path}'}), 400
|
|
plan = BuildpackService.detect(explicit_path)
|
|
return jsonify(_build_response(plan, app_name)), 200
|
|
|
|
# ---- Resolve the clone URL (optionally via a stored source connection). --
|
|
clone_url = repo_url
|
|
display_url = repo_url
|
|
if source_connection_id:
|
|
try:
|
|
source_repo = SourceConnectionService.get_authenticated_clone_url(
|
|
user_id=current_user_id,
|
|
connection_id=int(source_connection_id),
|
|
full_name=repository_full_name,
|
|
)
|
|
clone_url = source_repo['clone_url']
|
|
display_url = source_repo['public_url']
|
|
except (TypeError, ValueError) as exc:
|
|
return jsonify({'error': str(exc)}), 400
|
|
except Exception as exc: # pragma: no cover - defensive
|
|
return jsonify({'error': f'Failed to resolve source connection: {exc}'}), 400
|
|
|
|
if not clone_url:
|
|
return jsonify({'error': 'repo_url or path is required'}), 400
|
|
|
|
# ---- Cache hit on (display_url, branch)? --------------------------------
|
|
cache_key_url = display_url or clone_url
|
|
cached = BuildpackService.get_cached_plan(cache_key_url, branch)
|
|
if cached:
|
|
return jsonify({**_build_response(cached, app_name), 'cached': True}), 200
|
|
|
|
# ---- Clone shallowly to a throwaway workspace, detect, clean up. --------
|
|
workdir = tempfile.mkdtemp(prefix='serverkit-buildpack-')
|
|
clone_target = os.path.join(workdir, 'repo')
|
|
try:
|
|
clone_result = GitService.clone_repository(clone_target, clone_url, branch)
|
|
if not clone_result.get('success'):
|
|
err = clone_result.get('error') or 'Failed to clone repository'
|
|
# Never leak the authenticated clone URL (it may embed a token).
|
|
if clone_url != display_url and display_url:
|
|
err = err.replace(clone_url, display_url)
|
|
return jsonify({'error': err}), 400
|
|
|
|
commit = None
|
|
info = GitService.get_commit_info(clone_target)
|
|
if info:
|
|
commit = info.get('hash') or info.get('commit')
|
|
|
|
plan = BuildpackService.detect(clone_target)
|
|
BuildpackService.cache_plan(cache_key_url, plan, commit or branch)
|
|
return jsonify(_build_response(plan, app_name)), 200
|
|
except Exception as exc: # pragma: no cover - defensive
|
|
return jsonify({'error': str(exc)}), 400
|
|
finally:
|
|
shutil.rmtree(workdir, ignore_errors=True)
|
|
|
|
|
|
@buildpacks_bp.route('/generate', methods=['POST'])
|
|
@jwt_required()
|
|
def generate():
|
|
"""Generate a Dockerfile + compose from a plan (pure, no clone).
|
|
|
|
Body: {plan, overrides?, name?}
|
|
"""
|
|
data = request.get_json() or {}
|
|
plan = data.get('plan')
|
|
if not isinstance(plan, dict):
|
|
return jsonify({'error': 'plan (object) is required'}), 400
|
|
|
|
overrides = data.get('overrides')
|
|
if overrides:
|
|
plan = BuildpackService.apply_overrides(plan, overrides)
|
|
|
|
app_name = (data.get('name') or 'app').strip() or 'app'
|
|
return jsonify(_build_response(plan, app_name)), 200
|