mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-30 16:47:01 +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>
154 lines
6.1 KiB
Python
154 lines
6.1 KiB
Python
"""Project endpoints (Workspace -> Project -> Environment -> Applications).
|
|
|
|
Projects are workspace-scoped. The active workspace is resolved the same way the
|
|
rest of the app resolves it: the X-Workspace-Id header (or ?workspace_id=), via
|
|
WorkspaceService.resolve_workspace_id (membership-checked, lenient). With no
|
|
active context we fall back to the workspaces the caller can access, so the list
|
|
always reflects the user's own projects without needing a header.
|
|
"""
|
|
from flask import Blueprint, request, jsonify
|
|
from flask_jwt_extended import jwt_required
|
|
|
|
from app.models.project import Project
|
|
from app.services.project_service import ProjectService
|
|
from app.services.workspace_service import WorkspaceService
|
|
from app.middleware.rbac import get_current_user
|
|
|
|
projects_bp = Blueprint('projects', __name__)
|
|
|
|
|
|
def _active_workspace_id(user):
|
|
"""Resolve the active workspace for stamping a new project, defaulting to the
|
|
user's Default workspace when no valid context is supplied."""
|
|
ws_id = WorkspaceService.resolve_workspace_id(
|
|
user, request.headers.get('X-Workspace-Id') or request.args.get('workspace_id'))
|
|
if ws_id is None:
|
|
ws_id = WorkspaceService.ensure_default_workspace().id
|
|
return ws_id
|
|
|
|
|
|
def _accessible_workspace_ids(user):
|
|
"""Workspace ids the caller may see projects for. With an explicit, valid
|
|
workspace context, just that one; otherwise all the user's workspaces (admins
|
|
see all)."""
|
|
ws_id = WorkspaceService.resolve_workspace_id(
|
|
user, request.headers.get('X-Workspace-Id') or request.args.get('workspace_id'))
|
|
if ws_id is not None:
|
|
return [ws_id]
|
|
if user.is_admin:
|
|
from app.models.workspace import Workspace
|
|
return [w.id for w in Workspace.query.all()]
|
|
return [w.id for w in WorkspaceService.list_workspaces(user_id=user.id)]
|
|
|
|
|
|
def _can_view_project(user, project):
|
|
if user.is_admin:
|
|
return True
|
|
return project.workspace_id in set(_accessible_workspace_ids(user))
|
|
|
|
|
|
@projects_bp.route('', methods=['GET'])
|
|
@projects_bp.route('/', methods=['GET'])
|
|
@jwt_required()
|
|
def list_projects():
|
|
"""List projects in the accessible workspace(s), each with resource counts."""
|
|
user = get_current_user()
|
|
ws_ids = _accessible_workspace_ids(user)
|
|
projects = []
|
|
for ws_id in ws_ids:
|
|
projects.extend(ProjectService.list_projects(ws_id))
|
|
return jsonify({'projects': [p.to_dict(include_counts=True) for p in projects]}), 200
|
|
|
|
|
|
@projects_bp.route('', methods=['POST'])
|
|
@projects_bp.route('/', methods=['POST'])
|
|
@jwt_required()
|
|
def create_project():
|
|
"""Create a project (auto-creates a default environment)."""
|
|
user = get_current_user()
|
|
data = request.get_json() or {}
|
|
name = (data.get('name') or '').strip()
|
|
if not name:
|
|
return jsonify({'error': 'Project name is required'}), 400
|
|
|
|
ws_id = _active_workspace_id(user)
|
|
if not WorkspaceService.can_write_in_workspace(user, ws_id):
|
|
return jsonify({'error': 'You have read-only access to this workspace'}), 403
|
|
|
|
try:
|
|
project = ProjectService.create_project(
|
|
workspace_id=ws_id,
|
|
name=name,
|
|
description=data.get('description'),
|
|
metadata=data.get('metadata'),
|
|
default_environment=data.get('default_environment') or 'production',
|
|
)
|
|
except ValueError as exc:
|
|
return jsonify({'error': str(exc)}), 400
|
|
|
|
result = project.to_dict(include_counts=True)
|
|
result['environments'] = [e.to_dict(include_counts=True)
|
|
for e in ProjectService.list_environments(project.id)]
|
|
return jsonify({'message': 'Project created', 'project': result}), 201
|
|
|
|
|
|
@projects_bp.route('/<int:project_id>', methods=['GET'])
|
|
@jwt_required()
|
|
def get_project(project_id):
|
|
"""Get a project with its environments and resource counts."""
|
|
user = get_current_user()
|
|
project = ProjectService.get_project(project_id)
|
|
if not project:
|
|
return jsonify({'error': 'Project not found'}), 404
|
|
if not _can_view_project(user, project):
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
|
|
result = project.to_dict(include_counts=True)
|
|
result['environments'] = [e.to_dict(include_counts=True)
|
|
for e in ProjectService.list_environments(project_id)]
|
|
return jsonify({'project': result}), 200
|
|
|
|
|
|
@projects_bp.route('/<int:project_id>', methods=['PUT'])
|
|
@jwt_required()
|
|
def update_project(project_id):
|
|
user = get_current_user()
|
|
project = ProjectService.get_project(project_id)
|
|
if not project:
|
|
return jsonify({'error': 'Project not found'}), 404
|
|
if not _can_view_project(user, project):
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
if not WorkspaceService.can_write_in_workspace(user, project.workspace_id):
|
|
return jsonify({'error': 'You have read-only access to this workspace'}), 403
|
|
|
|
data = request.get_json() or {}
|
|
updated = ProjectService.update_project(
|
|
project_id,
|
|
name=data.get('name'),
|
|
description=data.get('description'),
|
|
metadata=data.get('metadata'),
|
|
)
|
|
return jsonify({'message': 'Project updated', 'project': updated.to_dict(include_counts=True)}), 200
|
|
|
|
|
|
@projects_bp.route('/<int:project_id>', methods=['DELETE'])
|
|
@jwt_required()
|
|
def delete_project(project_id):
|
|
"""Delete a project. Refuses (409) if it still has applications assigned."""
|
|
user = get_current_user()
|
|
project = ProjectService.get_project(project_id)
|
|
if not project:
|
|
return jsonify({'error': 'Project not found'}), 404
|
|
if not _can_view_project(user, project):
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
if not WorkspaceService.can_write_in_workspace(user, project.workspace_id):
|
|
return jsonify({'error': 'You have read-only access to this workspace'}), 403
|
|
|
|
result = ProjectService.delete_project(project_id)
|
|
if result == 'has_apps':
|
|
return jsonify({
|
|
'error': 'Project still has applications assigned. Reassign or remove them first.',
|
|
'app_count': ProjectService.count_project_apps(project_id),
|
|
}), 409
|
|
return jsonify({'message': 'Project deleted'}), 200
|