Files
ServerKit/backend/app/api/previews.py
Juan DenisandClaude Opus 5 fe02ce8eeb fix(auth): resolve the caller through one identity door, not the raw JWT
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>
2026-08-18 19:33:57 -04:00

123 lines
4.7 KiB
Python

# Bucket: PER-APP (plan 29 #9). Reads require app access (can_access_app); the
# enable/sync/redeploy/destroy mutations require write access (can_edit_app),
# replacing the old panel-wide @developer_required.
"""PR Preview Environments API.
Mounted by the host at ``/api/v1/apps`` so every route here is nested under an
application id, e.g. ``GET /api/v1/apps/<app_id>/previews``.
"""
from flask import Blueprint, request, jsonify
from flask_jwt_extended import jwt_required
from app.models import Application
from app.models.application_preview import ApplicationPreview
from app.services.preview_service import PreviewService
from app.services.resource_grant_service import ResourceGrantService
from app.middleware.rbac import get_current_user
previews_bp = Blueprint('previews', __name__)
def _get_app_or_404(app_id, write=False):
"""Resolve the app and enforce per-app access (plan 29 #9). ``write`` requires
can_edit_app (owner/admin/editor); otherwise can_access_app (adds workspace
members). A caller without the needed tier gets a sealed 404 (no leak)."""
app = Application.query_active().filter_by(id=app_id).first()
if not app:
return None, (jsonify({'error': 'Application not found'}), 404)
user = get_current_user()
ok = ResourceGrantService.can_edit_app(user, app) if write \
else ResourceGrantService.can_access_app(user, app)
if not ok:
return None, (jsonify({'error': 'Application not found'}), 404)
return app, None
@previews_bp.route('/<int:app_id>/previews', methods=['GET'])
@jwt_required()
def list_previews(app_id):
"""List the non-destroyed previews for an application (newest first)."""
app, err = _get_app_or_404(app_id)
if err:
return err
previews = ApplicationPreview.query.filter(
ApplicationPreview.application_id == app_id,
ApplicationPreview.status != ApplicationPreview.STATUS_DESTROYED,
).order_by(ApplicationPreview.pr_number.desc()).all()
return jsonify({'previews': [p.to_dict() for p in previews]}), 200
@previews_bp.route('/<int:app_id>/previews/settings', methods=['GET'])
@jwt_required()
def get_preview_settings(app_id):
"""Return the per-app preview settings (defaults when never configured)."""
app, err = _get_app_or_404(app_id)
if err:
return err
settings = PreviewService.get_settings(app_id)
return jsonify(settings.to_dict()), 200
@previews_bp.route('/<int:app_id>/previews/settings', methods=['PUT'])
@jwt_required()
def update_preview_settings(app_id):
"""Enable/disable previews and set the domain template + TTL for an app."""
app, err = _get_app_or_404(app_id, write=True)
if err:
return err
data = request.get_json(silent=True) or {}
try:
result = PreviewService.enable_previews(app_id, data)
except Exception as exc: # pragma: no cover - defensive
return jsonify({'error': str(exc)}), 400
return jsonify(result), 200
@previews_bp.route('/<int:app_id>/previews/sync', methods=['POST'])
@jwt_required()
def sync_previews(app_id):
"""Reconcile this app's previews against its open PRs (best-effort)."""
app, err = _get_app_or_404(app_id, write=True)
if err:
return err
result = PreviewService.sync_previews(app_id)
status = 400 if result.get('error') else 200
return jsonify(result), status
@previews_bp.route('/<int:app_id>/previews/<int:preview_id>/redeploy', methods=['POST'])
@jwt_required()
def redeploy_preview(app_id, preview_id):
"""Re-provision a preview for the same PR (idempotent update-in-place)."""
app, err = _get_app_or_404(app_id, write=True)
if err:
return err
preview = ApplicationPreview.query.filter_by(
id=preview_id, application_id=app_id).first()
if not preview:
return jsonify({'error': 'Preview not found'}), 404
result = PreviewService.create_preview(app, {
'pr_number': preview.pr_number,
'branch': preview.branch,
'pr_title': preview.pr_title,
'commit_sha': preview.commit_sha,
})
status = 400 if result.get('error') else 200
return jsonify(result), status
@previews_bp.route('/<int:app_id>/previews/<int:preview_id>', methods=['DELETE'])
@jwt_required()
def destroy_preview(app_id, preview_id):
"""Tear down and mark a preview destroyed."""
app, err = _get_app_or_404(app_id, write=True)
if err:
return err
preview = ApplicationPreview.query.filter_by(
id=preview_id, application_id=app_id).first()
if not preview:
return jsonify({'error': 'Preview not found'}), 404
result = PreviewService.destroy_preview(preview_id)
status = 400 if result.get('error') else 200
return jsonify(result), status