mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-11-02 10:07:29 +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>
190 lines
8.0 KiB
Python
190 lines
8.0 KiB
Python
# Bucket: PER-APP (plan 29 #9). The per-app extension routes
|
|
# (/<app_id>/extensions) address one installed engine, so they gate on
|
|
# can_access_app; the catalog reads above them are system-level and carry no
|
|
# app linkage, and every mutation is admin-only.
|
|
"""Database engine catalog + install, prefix ``/api/v1/databases/engines``.
|
|
|
|
Deliberately thin. There is no engine model and no engine install mechanism
|
|
here: the catalog is composed from templates carrying an ``engine:`` block, and
|
|
an install delegates to the same deployment-job pipeline every other template
|
|
install uses -- so the UI can navigate straight to the deploy console.
|
|
|
|
Lifecycle (start / stop / uninstall) is app lifecycle. Use
|
|
``POST /api/v1/apps/<id>/start``, ``/stop`` and ``DELETE /api/v1/apps/<id>``
|
|
(which preserves an engine's data volumes unless ``?remove_data=true``).
|
|
|
|
Extensions live under the same prefix but are a different shape: they get no
|
|
container, so installing one executes a statement against an engine that is
|
|
already running rather than creating a deployment job. See
|
|
``app/services/database_engine_extension_service.py``.
|
|
"""
|
|
import logging
|
|
|
|
from flask import Blueprint, jsonify, request
|
|
from flask_jwt_extended import get_jwt_identity, jwt_required
|
|
|
|
from app.middleware.rbac import admin_required, get_current_user
|
|
from app.models import Application
|
|
from app.services.resource_grant_service import ResourceGrantService
|
|
from app.services import database_engine_extension_service as extensions
|
|
from app.services import database_engine_service as engines
|
|
from app.services.deployment_job_service import DeploymentJobService
|
|
from app.services.template_service import TemplateService
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
database_engines_bp = Blueprint('database_engines', __name__)
|
|
|
|
|
|
@database_engines_bp.route('', methods=['GET'])
|
|
@jwt_required()
|
|
def list_database_engines():
|
|
"""The installable engine catalog plus every engine already installed.
|
|
|
|
``catalog`` is every available template declaring an ``engine:`` block --
|
|
bundled, operator-authored, or pulled in by a synced template repository.
|
|
Nothing here is hardcoded: dropping a new engine YAML into the templates
|
|
directory is enough to make it appear.
|
|
"""
|
|
live = request.args.get('live', 'true').lower() != 'false'
|
|
catalog = engines.engine_catalog()
|
|
extension_catalog = extensions.extension_catalog()
|
|
return jsonify({
|
|
'catalog': catalog,
|
|
'installed': engines.installed_engines(live_status=live),
|
|
# The drawer renders engines and extensions together; one round trip.
|
|
# Every entry carries family 'Extension' -- deliberately NOT merged into
|
|
# `families`, which stays the engine-family filter it has always been.
|
|
'extensions': extension_catalog,
|
|
'extension_family': TemplateService.EXTENSION_FAMILY if extension_catalog else None,
|
|
# Derived from the catalog so the UI never carries its own family list.
|
|
'families': engines.catalog_families(catalog),
|
|
}), 200
|
|
|
|
|
|
@database_engines_bp.route('', methods=['POST'])
|
|
@admin_required
|
|
def install_database_engine():
|
|
"""Install an engine by installing its template.
|
|
|
|
Answers with whatever the template pipeline returns (``job_id`` + the job),
|
|
so the caller navigates to the deploy console exactly as it would for any
|
|
other template install.
|
|
"""
|
|
data = request.get_json(silent=True) or {}
|
|
|
|
plan = engines.plan_install(
|
|
template_id=data.get('template_id'),
|
|
version=data.get('version'),
|
|
instance_name=data.get('instance_name'),
|
|
port=data.get('port'),
|
|
expose_public=bool(data.get('expose_public', False)),
|
|
initial_database=data.get('initial_database'),
|
|
variables=data.get('variables') or {},
|
|
)
|
|
if 'error' in plan:
|
|
status = plan.pop('status_code', 400)
|
|
return jsonify(plan), status
|
|
|
|
result = DeploymentJobService.install_template(
|
|
template_id=data.get('template_id'),
|
|
app_name=plan['app_name'],
|
|
user_variables=plan['variables'],
|
|
user_id=get_jwt_identity(),
|
|
server_id=data.get('server_id') or data.get('target_server_id'),
|
|
wait=bool(data.get('wait', False)),
|
|
)
|
|
if not result.get('success'):
|
|
return jsonify(result), 400
|
|
|
|
result['engine'] = plan['engine']
|
|
result['app_name'] = plan['app_name']
|
|
if plan.get('warning'):
|
|
result['warning'] = plan['warning']
|
|
|
|
status = (result.get('job') or {}).get('status')
|
|
return jsonify(result), 201 if status == 'succeeded' else 202
|
|
|
|
|
|
# ── extensions ───────────────────────────────────────────────────────────────
|
|
# An extension has no container: it is a statement run against an engine that
|
|
# is already up. These routes sit under the engines prefix because that is where
|
|
# the catalog is entered from, but nothing here creates a deployment job.
|
|
|
|
@database_engines_bp.route('/extensions', methods=['GET'])
|
|
@jwt_required()
|
|
def list_engine_extensions():
|
|
"""Every declared extension, with who can host it.
|
|
|
|
Composed from ``<templates>/extensions/*.yaml``: dropping one in is enough
|
|
to make it appear, exactly as for an engine. Each entry carries ``hosts``
|
|
(engine templates that speak its protocol), ``provided_by`` (those whose
|
|
image actually ships it) and the installed instance ids split into
|
|
``compatible_instances`` / ``incompatible_instances``.
|
|
"""
|
|
return jsonify({'extensions': extensions.extension_catalog(),
|
|
'family': TemplateService.EXTENSION_FAMILY}), 200
|
|
|
|
|
|
def _require_app_access(app_id):
|
|
"""Ensure the caller may see this app, mirroring the per-app docker DB read.
|
|
|
|
Returns an (error_body, status) tuple to return, or None when allowed. The
|
|
extension listing names an instance's image, its databases and what it can
|
|
load — all facts about someone's application, so a bare `@jwt_required()`
|
|
would have let any signed-in user enumerate them across the whole panel.
|
|
"""
|
|
user = get_current_user()
|
|
if not user:
|
|
return {'error': 'Unauthorized'}, 401
|
|
app = Application.query_active().filter_by(id=app_id).first()
|
|
if not app:
|
|
return {'error': 'Application not found'}, 404
|
|
if not ResourceGrantService.can_access_app(user, app):
|
|
return {'error': 'Access denied'}, 403
|
|
return None
|
|
|
|
|
|
@database_engines_bp.route('/<int:app_id>/extensions', methods=['GET'])
|
|
@jwt_required()
|
|
def list_instance_extensions(app_id):
|
|
"""Every extension judged against ONE installed engine.
|
|
|
|
``?probe=true`` asks the engine itself what it can load instead of
|
|
inferring it from the image name -- slower (a docker exec per extension),
|
|
authoritative, and the only way to see what is already installed.
|
|
"""
|
|
denied = _require_app_access(app_id)
|
|
if denied:
|
|
return jsonify(denied[0]), denied[1]
|
|
|
|
probe = request.args.get('probe', 'false').lower() == 'true'
|
|
result = extensions.instance_extensions(app_id, probe=probe)
|
|
if 'error' in result:
|
|
# Popped BEFORE serializing: the transport code is not part of the body.
|
|
status = result.pop('status_code', 400)
|
|
return jsonify(result), status
|
|
return jsonify(result), 200
|
|
|
|
|
|
@database_engines_bp.route('/<int:app_id>/extensions', methods=['POST'])
|
|
@admin_required
|
|
def install_engine_extension(app_id):
|
|
"""Install an extension into a database on this engine.
|
|
|
|
Answers 409 with a ``remedy`` when the engine's image cannot ship the
|
|
extension -- the statement is never attempted in that case, so the operator
|
|
gets "your image does not carry this, install X instead" rather than a raw
|
|
``could not open extension control file``.
|
|
"""
|
|
data = request.get_json(silent=True) or {}
|
|
result = extensions.install_extension(
|
|
app_id,
|
|
extension_id=data.get('extension_id') or data.get('id'),
|
|
database=data.get('database'),
|
|
)
|
|
if 'error' in result:
|
|
status = result.pop('status_code', 400)
|
|
return jsonify(result), status
|
|
return jsonify(result), 201
|