Files
ServerKit/backend/app/api/source_connections.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

440 lines
15 KiB
Python

"""Source provider connection API."""
from flask import Blueprint, jsonify, request
from flask_jwt_extended import jwt_required, get_jwt_identity
from requests import HTTPError
from app.middleware.rbac import admin_required, get_current_user
from app.services.source_connection_service import SourceConnectionService
source_connections_bp = Blueprint('source_connections', __name__)
@source_connections_bp.route('/github/status', methods=['GET'])
@jwt_required()
def github_status():
user_id = int(get_jwt_identity())
return jsonify(SourceConnectionService.get_status(user_id)), 200
@source_connections_bp.route('/github/authorize', methods=['GET'])
@jwt_required()
def github_authorize():
redirect_uri = request.args.get('redirect_uri', '')
if not redirect_uri:
return jsonify({'error': 'redirect_uri is required'}), 400
try:
auth_url, state = SourceConnectionService.generate_github_authorize_url(redirect_uri)
return jsonify({'auth_url': auth_url, 'state': state}), 200
except ValueError as exc:
return jsonify({'error': str(exc)}), 400
@source_connections_bp.route('/github/callback', methods=['POST'])
@jwt_required()
def github_callback():
user_id = int(get_jwt_identity())
data = request.get_json() or {}
code = data.get('code', '')
state = data.get('state', '')
redirect_uri = data.get('redirect_uri', '')
if not code or not state or not redirect_uri:
return jsonify({'error': 'code, state, and redirect_uri are required'}), 400
try:
connection = SourceConnectionService.complete_github_callback(
user_id=user_id,
code=code,
state=state,
redirect_uri=redirect_uri,
)
return jsonify({'connection': connection.to_dict()}), 200
except ValueError as exc:
return jsonify({'error': str(exc)}), 400
except HTTPError as exc:
return jsonify({'error': _github_error(exc)}), 400
@source_connections_bp.route('/github/repos', methods=['GET'])
@jwt_required()
def github_repositories():
user_id = int(get_jwt_identity())
search = request.args.get('search', '')
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 50, type=int)
try:
repos = SourceConnectionService.list_github_repositories(user_id, search, page, per_page)
return jsonify({'repos': repos}), 200
except ValueError as exc:
return jsonify({'error': str(exc)}), 400
except HTTPError as exc:
return jsonify({'error': _github_error(exc)}), 400
@source_connections_bp.route('/github/repos/<path:full_name>/branches', methods=['GET'])
@jwt_required()
def github_branches(full_name):
user_id = int(get_jwt_identity())
try:
branches = SourceConnectionService.list_github_branches(user_id, full_name)
return jsonify({'branches': branches}), 200
except ValueError as exc:
return jsonify({'error': str(exc)}), 400
except HTTPError as exc:
return jsonify({'error': _github_error(exc)}), 400
@source_connections_bp.route('/github/repos/<path:full_name>/manifest', methods=['GET'])
@jwt_required()
def github_repository_manifest(full_name):
user_id = int(get_jwt_identity())
ref = request.args.get('ref') or None
try:
manifest = SourceConnectionService.get_github_repository_manifest(user_id, full_name, ref)
return jsonify({'manifest': manifest}), 200
except ValueError as exc:
return jsonify({'error': str(exc)}), 400
except HTTPError as exc:
return jsonify({'error': _github_error(exc)}), 400
@source_connections_bp.route('/github', methods=['DELETE'])
@jwt_required()
def disconnect_github():
user_id = int(get_jwt_identity())
try:
SourceConnectionService.disconnect(user_id, 'github')
return jsonify({'message': 'GitHub disconnected'}), 200
except ValueError as exc:
return jsonify({'error': str(exc)}), 404
@source_connections_bp.route('/admin/github', methods=['GET'])
@admin_required
def github_admin_config():
return jsonify({'config': SourceConnectionService.get_github_config(redacted=True)}), 200
@source_connections_bp.route('/admin/github', methods=['PUT'])
@admin_required
def update_github_admin_config():
user = get_current_user()
result = SourceConnectionService.update_github_config(
request.get_json() or {},
user_id=user.id if user else None,
)
return jsonify(result), 200
@source_connections_bp.route('/admin/github/app-manifest', methods=['GET'])
@admin_required
def github_app_manifest():
"""Build the GitHub App manifest for the one-click setup flow."""
redirect_uri = request.args.get('redirect_uri', '')
base_url = request.args.get('base_url', '') or request.host_url
try:
manifest, state, post_url = SourceConnectionService.build_github_app_manifest(
redirect_uri, base_url)
return jsonify({'manifest': manifest, 'state': state, 'post_url': post_url}), 200
except ValueError as exc:
return jsonify({'error': str(exc)}), 400
@source_connections_bp.route('/admin/github/app-manifest/complete', methods=['POST'])
@admin_required
def complete_github_app_manifest():
"""Convert the manifest code GitHub returned into a stored GitHub App."""
user = get_current_user()
data = request.get_json() or {}
try:
result = SourceConnectionService.complete_github_app_manifest(
code=data.get('code', ''),
state=data.get('state', ''),
user_id=user.id if user else None,
)
return jsonify(result), 200
except ValueError as exc:
return jsonify({'error': str(exc)}), 400
except HTTPError as exc:
return jsonify({'error': _github_error(exc)}), 400
def _github_error(exc):
response = getattr(exc, 'response', None)
if response is None:
return str(exc)
try:
data = response.json()
return data.get('message') or str(exc)
except Exception:
return response.text or str(exc)
@source_connections_bp.route('/gitlab/status', methods=['GET'])
@jwt_required()
def gitlab_status():
user_id = int(get_jwt_identity())
return jsonify(SourceConnectionService.get_gitlab_status(user_id)), 200
@source_connections_bp.route('/gitlab/authorize', methods=['GET'])
@jwt_required()
def gitlab_authorize():
redirect_uri = request.args.get('redirect_uri', '')
if not redirect_uri:
return jsonify({'error': 'redirect_uri is required'}), 400
try:
auth_url, state = SourceConnectionService.generate_gitlab_authorize_url(redirect_uri)
return jsonify({'auth_url': auth_url, 'state': state}), 200
except ValueError as exc:
return jsonify({'error': str(exc)}), 400
@source_connections_bp.route('/gitlab/callback', methods=['POST'])
@jwt_required()
def gitlab_callback():
user_id = int(get_jwt_identity())
data = request.get_json() or {}
code = data.get('code', '')
state = data.get('state', '')
redirect_uri = data.get('redirect_uri', '')
if not code or not state or not redirect_uri:
return jsonify({'error': 'code, state, and redirect_uri are required'}), 400
try:
connection = SourceConnectionService.complete_gitlab_callback(
user_id=user_id,
code=code,
state=state,
redirect_uri=redirect_uri,
)
return jsonify({'connection': connection.to_dict()}), 200
except ValueError as exc:
return jsonify({'error': str(exc)}), 400
except HTTPError as exc:
return jsonify({'error': _gitlab_error(exc)}), 400
@source_connections_bp.route('/gitlab/repos', methods=['GET'])
@jwt_required()
def gitlab_repositories():
user_id = int(get_jwt_identity())
search = request.args.get('search', '')
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 50, type=int)
try:
repos = SourceConnectionService.list_gitlab_repositories(user_id, search, page, per_page)
return jsonify({'repos': repos}), 200
except ValueError as exc:
return jsonify({'error': str(exc)}), 400
except HTTPError as exc:
return jsonify({'error': _gitlab_error(exc)}), 400
@source_connections_bp.route('/gitlab/repos/<path:full_name>/branches', methods=['GET'])
@jwt_required()
def gitlab_branches(full_name):
user_id = int(get_jwt_identity())
try:
branches = SourceConnectionService.list_gitlab_branches(user_id, full_name)
return jsonify({'branches': branches}), 200
except ValueError as exc:
return jsonify({'error': str(exc)}), 400
except HTTPError as exc:
return jsonify({'error': _gitlab_error(exc)}), 400
@source_connections_bp.route('/gitlab/repos/<path:full_name>/manifest', methods=['GET'])
@jwt_required()
def gitlab_repository_manifest(full_name):
user_id = int(get_jwt_identity())
ref = request.args.get('ref') or None
try:
manifest = SourceConnectionService.get_gitlab_repository_manifest(user_id, full_name, ref)
return jsonify({'manifest': manifest}), 200
except ValueError as exc:
return jsonify({'error': str(exc)}), 400
except HTTPError as exc:
return jsonify({'error': _gitlab_error(exc)}), 400
@source_connections_bp.route('/gitlab', methods=['DELETE'])
@jwt_required()
def disconnect_gitlab():
user_id = int(get_jwt_identity())
try:
SourceConnectionService.disconnect(user_id, 'gitlab')
return jsonify({'message': 'GitLab disconnected'}), 200
except ValueError as exc:
return jsonify({'error': str(exc)}), 404
@source_connections_bp.route('/admin/gitlab', methods=['GET'])
@admin_required
def gitlab_admin_config():
return jsonify({'config': SourceConnectionService.get_gitlab_config(redacted=True)}), 200
@source_connections_bp.route('/admin/gitlab', methods=['PUT'])
@admin_required
def update_gitlab_admin_config():
user = get_current_user()
result = SourceConnectionService.update_gitlab_config(
request.get_json() or {},
user_id=user.id if user else None,
)
return jsonify(result), 200
def _gitlab_error(exc):
response = getattr(exc, 'response', None)
if response is None:
return str(exc)
try:
data = response.json()
return data.get('message') or data.get('error_description') or data.get('error') or str(exc)
except Exception:
return response.text or str(exc)
@source_connections_bp.route('/bitbucket/status', methods=['GET'])
@jwt_required()
def bitbucket_status():
user_id = int(get_jwt_identity())
return jsonify(SourceConnectionService.get_bitbucket_status(user_id)), 200
@source_connections_bp.route('/bitbucket/authorize', methods=['GET'])
@jwt_required()
def bitbucket_authorize():
redirect_uri = request.args.get('redirect_uri', '')
if not redirect_uri:
return jsonify({'error': 'redirect_uri is required'}), 400
try:
auth_url, state = SourceConnectionService.generate_bitbucket_authorize_url(redirect_uri)
return jsonify({'auth_url': auth_url, 'state': state}), 200
except ValueError as exc:
return jsonify({'error': str(exc)}), 400
@source_connections_bp.route('/bitbucket/callback', methods=['POST'])
@jwt_required()
def bitbucket_callback():
user_id = int(get_jwt_identity())
data = request.get_json() or {}
code = data.get('code', '')
state = data.get('state', '')
redirect_uri = data.get('redirect_uri', '')
if not code or not state or not redirect_uri:
return jsonify({'error': 'code, state, and redirect_uri are required'}), 400
try:
connection = SourceConnectionService.complete_bitbucket_callback(
user_id=user_id,
code=code,
state=state,
redirect_uri=redirect_uri,
)
return jsonify({'connection': connection.to_dict()}), 200
except ValueError as exc:
return jsonify({'error': str(exc)}), 400
except HTTPError as exc:
return jsonify({'error': _bitbucket_error(exc)}), 400
@source_connections_bp.route('/bitbucket/repos', methods=['GET'])
@jwt_required()
def bitbucket_repositories():
user_id = int(get_jwt_identity())
search = request.args.get('search', '')
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 50, type=int)
try:
repos = SourceConnectionService.list_bitbucket_repositories(user_id, search, page, per_page)
return jsonify({'repos': repos}), 200
except ValueError as exc:
return jsonify({'error': str(exc)}), 400
except HTTPError as exc:
return jsonify({'error': _bitbucket_error(exc)}), 400
@source_connections_bp.route('/bitbucket/repos/<path:full_name>/branches', methods=['GET'])
@jwt_required()
def bitbucket_branches(full_name):
user_id = int(get_jwt_identity())
try:
branches = SourceConnectionService.list_bitbucket_branches(user_id, full_name)
return jsonify({'branches': branches}), 200
except ValueError as exc:
return jsonify({'error': str(exc)}), 400
except HTTPError as exc:
return jsonify({'error': _bitbucket_error(exc)}), 400
@source_connections_bp.route('/bitbucket/repos/<path:full_name>/manifest', methods=['GET'])
@jwt_required()
def bitbucket_repository_manifest(full_name):
user_id = int(get_jwt_identity())
ref = request.args.get('ref') or None
try:
manifest = SourceConnectionService.get_bitbucket_repository_manifest(user_id, full_name, ref)
return jsonify({'manifest': manifest}), 200
except ValueError as exc:
return jsonify({'error': str(exc)}), 400
except HTTPError as exc:
return jsonify({'error': _bitbucket_error(exc)}), 400
@source_connections_bp.route('/bitbucket', methods=['DELETE'])
@jwt_required()
def disconnect_bitbucket():
user_id = int(get_jwt_identity())
try:
SourceConnectionService.disconnect(user_id, 'bitbucket')
return jsonify({'message': 'Bitbucket disconnected'}), 200
except ValueError as exc:
return jsonify({'error': str(exc)}), 404
@source_connections_bp.route('/admin/bitbucket', methods=['GET'])
@admin_required
def bitbucket_admin_config():
return jsonify({'config': SourceConnectionService.get_bitbucket_config(redacted=True)}), 200
@source_connections_bp.route('/admin/bitbucket', methods=['PUT'])
@admin_required
def update_bitbucket_admin_config():
user = get_current_user()
result = SourceConnectionService.update_bitbucket_config(
request.get_json() or {},
user_id=user.id if user else None,
)
return jsonify(result), 200
def _bitbucket_error(exc):
response = getattr(exc, 'response', None)
if response is None:
return str(exc)
try:
data = response.json()
return data.get('error_description') or data.get('error', {}).get('message') or data.get('message') or str(exc)
except Exception:
return response.text or str(exc)