diff --git a/backend/app/api/advanced_ssl.py b/backend/app/api/advanced_ssl.py index 64a2ee6c..8596f6aa 100644 --- a/backend/app/api/advanced_ssl.py +++ b/backend/app/api/advanced_ssl.py @@ -3,16 +3,11 @@ from flask_jwt_extended import jwt_required from app.services.advanced_ssl_service import AdvancedSSLService from app.services.audit_service import AuditService from app.models.audit_log import AuditLog +from app.middleware.rbac import get_current_user advanced_ssl_bp = Blueprint('advanced_ssl', __name__) -def get_current_user(): - from flask_jwt_extended import get_jwt_identity - from app.models.user import User - return User.query.get(get_jwt_identity()) - - @advanced_ssl_bp.route('/profiles', methods=['GET']) @jwt_required() def get_profiles(): diff --git a/backend/app/api/agent_plugins.py b/backend/app/api/agent_plugins.py index aee04f72..435631f6 100644 --- a/backend/app/api/agent_plugins.py +++ b/backend/app/api/agent_plugins.py @@ -3,16 +3,11 @@ from flask_jwt_extended import jwt_required from app.services.agent_plugin_service import AgentPluginService from app.services.audit_service import AuditService from app.models.audit_log import AuditLog +from app.middleware.rbac import get_current_user agent_plugins_bp = Blueprint('agent_plugins', __name__) -def get_current_user(): - from flask_jwt_extended import get_jwt_identity - from app.models.user import User - return User.query.get(get_jwt_identity()) - - @agent_plugins_bp.route('/', methods=['GET']) @jwt_required() def list_plugins(): diff --git a/backend/app/api/app_volumes.py b/backend/app/api/app_volumes.py index b2f559d9..621b7157 100644 --- a/backend/app/api/app_volumes.py +++ b/backend/app/api/app_volumes.py @@ -5,18 +5,19 @@ volumes for an application. Mounted under ``/api/v1/apps`` alongside the main apps blueprint (routes are ``//volumes*``). """ from flask import Blueprint, request, jsonify -from flask_jwt_extended import jwt_required, get_jwt_identity +from flask_jwt_extended import jwt_required -from app.models import Application, User +from app.models import Application from app.services.resource_grant_service import ResourceGrantService from app.services.volume_service import VolumeService, VolumeError +from app.middleware.rbac import get_current_user app_volumes_bp = Blueprint('app_volumes', __name__) def _load_app_for(app_id, *, write): """Resolve (user, app) with the right ACL, or an (error_response, status).""" - user = User.query.get(get_jwt_identity()) + user = get_current_user() app = Application.query_active().filter_by(id=app_id).first() if not app: return None, None, (jsonify({'error': 'Application not found'}), 404) diff --git a/backend/app/api/apps.py b/backend/app/api/apps.py index 275e451e..8f67289b 100644 --- a/backend/app/api/apps.py +++ b/backend/app/api/apps.py @@ -43,6 +43,7 @@ from app.services.upload_service import ( get_current_version, ) from app import paths +from app.middleware.rbac import get_current_user apps_bp = Blueprint('apps', __name__) @@ -499,7 +500,7 @@ def set_app_workspace(app_id): null/'default' target moves it back to the default workspace.""" from app.models.workspace import Workspace from app.services.workspace_service import WorkspaceService - user = User.query.get(get_jwt_identity()) + user = get_current_user() app = Application.query_active().filter_by(id=app_id).first() if not app: return jsonify({'error': 'Application not found'}), 404 @@ -547,7 +548,7 @@ def move_apps_to_project(): from app.models.project import Project from app.models.environment import Environment - user = User.query.get(get_jwt_identity()) + user = get_current_user() data = request.get_json() or {} raw_ids = data.get('app_ids') @@ -684,7 +685,7 @@ def _can_edit_app(user, app): @apps_bp.route('/', methods=['GET']) @jwt_required() def get_app(app_id): - user = User.query.get(get_jwt_identity()) + user = get_current_user() app = Application.query_active().filter_by(id=app_id).first() if not app: @@ -705,7 +706,7 @@ def get_compose_services(app_id): Lets the env-var editor offer a per-service targeting choice. Empty for non-compose apps or when the base compose can't be read. """ - user = User.query.get(get_jwt_identity()) + user = get_current_user() app = Application.query_active().filter_by(id=app_id).first() if not app: return jsonify({'error': 'Application not found'}), 404 @@ -723,7 +724,7 @@ def get_compose_services(app_id): def list_app_grants(app_id): """List who has been granted access to this app (owner-or-admin).""" from app.services.resource_grant_service import ResourceGrantService - user = User.query.get(get_jwt_identity()) + user = get_current_user() app = Application.query_active().filter_by(id=app_id).first() if not app: return jsonify({'error': 'Application not found'}), 404 @@ -738,7 +739,7 @@ def list_app_grants(app_id): def grant_app_access(app_id): """Grant a user access to this app (owner-or-admin). Body: {user_id, role?}.""" from app.services.resource_grant_service import ResourceGrantService - user = User.query.get(get_jwt_identity()) + user = get_current_user() app = Application.query_active().filter_by(id=app_id).first() if not app: return jsonify({'error': 'Application not found'}), 404 @@ -766,7 +767,7 @@ def grant_app_access(app_id): def revoke_app_access(app_id, grant_id): """Revoke a grant on this app (owner-or-admin).""" from app.services.resource_grant_service import ResourceGrantService - user = User.query.get(get_jwt_identity()) + user = get_current_user() app = Application.query_active().filter_by(id=app_id).first() if not app: return jsonify({'error': 'Application not found'}), 404 @@ -1614,7 +1615,7 @@ def apply_image_update(app_id): @apps_bp.route('//sleep-policy', methods=['GET']) @jwt_required() def get_sleep_policy(app_id): - user = User.query.get(get_jwt_identity()) + user = get_current_user() app = Application.query_active().filter_by(id=app_id).first() if not app: return jsonify({'error': 'Application not found'}), 404 @@ -1626,7 +1627,7 @@ def get_sleep_policy(app_id): @apps_bp.route('//sleep-policy', methods=['PUT']) @jwt_required() def update_sleep_policy(app_id): - user = User.query.get(get_jwt_identity()) + user = get_current_user() app = Application.query_active().filter_by(id=app_id).first() if not app: return jsonify({'error': 'Application not found'}), 404 @@ -1641,7 +1642,7 @@ def update_sleep_policy(app_id): @apps_bp.route('//sleep', methods=['POST']) @jwt_required() def sleep_app(app_id): - user = User.query.get(get_jwt_identity()) + user = get_current_user() app = Application.query_active().filter_by(id=app_id).first() if not app: return jsonify({'error': 'Application not found'}), 404 @@ -1656,7 +1657,7 @@ def sleep_app(app_id): @apps_bp.route('//wake', methods=['POST']) @jwt_required() def wake_app(app_id): - user = User.query.get(get_jwt_identity()) + user = get_current_user() app = Application.query_active().filter_by(id=app_id).first() if not app: return jsonify({'error': 'Application not found'}), 404 @@ -1673,7 +1674,7 @@ def wake_app(app_id): def sweep_idle_apps(): """Sleep all enabled apps that have been idle past their timeout. Intended to be hit periodically (cron or a scheduler).""" - user = User.query.get(get_jwt_identity()) + user = get_current_user() if not (user and user.is_admin): return jsonify({'error': 'Admin access required'}), 403 return jsonify(ContainerSleepService.sweep_idle()) @@ -1714,7 +1715,7 @@ def _parse_docker_stats(stats): @jwt_required() def get_app_resources(app_id): """The app's configured CPU/memory limits plus best-effort live usage.""" - user = User.query.get(get_jwt_identity()) + user = get_current_user() app = Application.query_active().filter_by(id=app_id).first() if not app: return jsonify({'error': 'Application not found'}), 404 @@ -1747,7 +1748,7 @@ def update_app_resources(app_id): the limits) and recreates changed containers; otherwise the limits are saved and take effect on the next restart/redeploy. """ - user = User.query.get(get_jwt_identity()) + user = get_current_user() app = Application.query_active().filter_by(id=app_id).first() if not app: return jsonify({'error': 'Application not found'}), 404 @@ -1804,7 +1805,7 @@ def set_micro_cache(app_id): config drift. When the app has no domains yet, the flag is save-only and takes effect on first publish. """ - user = User.query.get(get_jwt_identity()) + user = get_current_user() app = Application.query_active().filter_by(id=app_id).first() if not app: return jsonify({'error': 'Application not found'}), 404 @@ -1853,7 +1854,7 @@ def purge_micro_cache(app_id): wipes cached entries for every opted-in site. With the 10-second TTL this is near-harmless, and no nginx reload is needed. """ - user = User.query.get(get_jwt_identity()) + user = get_current_user() app = Application.query_active().filter_by(id=app_id).first() if not app: return jsonify({'error': 'Application not found'}), 404 @@ -1874,7 +1875,7 @@ def purge_micro_cache(app_id): @apps_bp.route('//scale-policy', methods=['GET']) @jwt_required() def get_scale_policy(app_id): - user = User.query.get(get_jwt_identity()) + user = get_current_user() app = Application.query_active().filter_by(id=app_id).first() if not app: return jsonify({'error': 'Application not found'}), 404 @@ -1886,7 +1887,7 @@ def get_scale_policy(app_id): @apps_bp.route('//scale-policy', methods=['PUT']) @jwt_required() def update_scale_policy(app_id): - user = User.query.get(get_jwt_identity()) + user = get_current_user() app = Application.query_active().filter_by(id=app_id).first() if not app: return jsonify({'error': 'Application not found'}), 404 @@ -1900,7 +1901,7 @@ def update_scale_policy(app_id): @apps_bp.route('//scale', methods=['POST']) @jwt_required() def scale_app(app_id): - user = User.query.get(get_jwt_identity()) + user = get_current_user() app = Application.query_active().filter_by(id=app_id).first() if not app: return jsonify({'error': 'Application not found'}), 404 @@ -1918,7 +1919,7 @@ def scale_app(app_id): @apps_bp.route('//scale/evaluate', methods=['POST']) @jwt_required() def evaluate_scale(app_id): - user = User.query.get(get_jwt_identity()) + user = get_current_user() app = Application.query_active().filter_by(id=app_id).first() if not app: return jsonify({'error': 'Application not found'}), 404 @@ -1934,7 +1935,7 @@ def evaluate_scale(app_id): @jwt_required() def scale_sweep(): """Evaluate every enabled auto-scaling policy. Intended for cron/scheduler.""" - user = User.query.get(get_jwt_identity()) + user = get_current_user() if not (user and user.is_admin): return jsonify({'error': 'Admin access required'}), 403 return jsonify(ContainerScaleService.sweep()) @@ -2029,7 +2030,7 @@ def get_app_related(app_id): disabled feature degrades that section to empty rather than failing the whole card. """ - user = User.query.get(get_jwt_identity()) + user = get_current_user() app = Application.query_active().filter_by(id=app_id).first() if not app: return jsonify({'error': 'Application not found'}), 404 @@ -2360,7 +2361,7 @@ def get_app_status(app_id): def _load_app_for_backup(app_id, edit=False): """Load an app the current user may access (or edit). Returns (app, error).""" - user = User.query.get(get_jwt_identity()) + user = get_current_user() app = Application.query_active().filter_by(id=app_id).first() if not app: return None, (jsonify({'error': 'Application not found'}), 404) diff --git a/backend/app/api/bandwidth.py b/backend/app/api/bandwidth.py index ebc84e71..5a756963 100644 --- a/backend/app/api/bandwidth.py +++ b/backend/app/api/bandwidth.py @@ -5,7 +5,7 @@ Mounted at /api/v1/bandwidth (registered in app/__init__.py). """ from flask import Blueprint, jsonify, request -from flask_jwt_extended import get_jwt_identity +from app.middleware.rbac import get_current_user from ..middleware.rbac import admin_required, viewer_required from ..models import User, Application @@ -34,7 +34,7 @@ def get_app_bandwidth(app_id): """Full daily series (default 90 days) + current-month total for one app, scoped to the app's workspace visibility (plan 29 #9 — foreign caller 404).""" app = Application.query_active().filter_by(id=app_id).first() - user = User.query.get(get_jwt_identity()) + user = get_current_user() if app is None or not ResourceGrantService.can_access_app(user, app): return jsonify({'error': 'Not found'}), 404 try: diff --git a/backend/app/api/database_engines.py b/backend/app/api/database_engines.py index 85366379..c96bbccf 100644 --- a/backend/app/api/database_engines.py +++ b/backend/app/api/database_engines.py @@ -23,8 +23,8 @@ 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 -from app.models import Application, User +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 @@ -134,7 +134,7 @@ def _require_app_access(app_id): 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 = User.query.get(get_jwt_identity()) + user = get_current_user() if not user: return {'error': 'Unauthorized'}, 401 app = Application.query_active().filter_by(id=app_id).first() diff --git a/backend/app/api/databases.py b/backend/app/api/databases.py index f754e3e4..5b61df52 100644 --- a/backend/app/api/databases.py +++ b/backend/app/api/databases.py @@ -13,7 +13,7 @@ from app.models import User, Application from app.services.database_service import DatabaseService from app.services.db_process_service import DbProcessService from app.services.managed_database_service import ManagedDatabaseService -from app.middleware.rbac import admin_required +from app.middleware.rbac import admin_required, get_current_user, require_admin_user from app.services.resource_grant_service import ResourceGrantService logger = logging.getLogger(__name__) @@ -26,7 +26,7 @@ def _persist_provisioned(engine, name, result, data): additive — a persistence failure never fails the provisioning that succeeded.""" try: from app.services.workspace_service import WorkspaceService - user = User.query.get(get_jwt_identity()) + user = get_current_user() ws_id = WorkspaceService.resolve_workspace_id( user, request.headers.get('X-Workspace-Id') or request.args.get('workspace_id')) ManagedDatabaseService.record_provisioned( @@ -460,11 +460,7 @@ def execute_mysql_query(name): # Only admins can disable readonly mode if not readonly: - current_user_id = get_jwt_identity() - from app.models import User - user = User.query.get(current_user_id) - if not user or user.role != 'admin': - return jsonify({'error': 'Admin access required to execute write queries'}), 403 + require_admin_user() result = DatabaseService.mysql_execute_query( database=name, @@ -499,11 +495,7 @@ def execute_pg_query(name): # Only admins can disable readonly mode if not readonly: - current_user_id = get_jwt_identity() - from app.models import User - user = User.query.get(current_user_id) - if not user or user.role != 'admin': - return jsonify({'error': 'Admin access required to execute write queries'}), 403 + require_admin_user() result = DatabaseService.pg_execute_query( database=name, @@ -539,11 +531,7 @@ def execute_sqlite_query(): # Only admins can disable readonly mode if not readonly: - current_user_id = get_jwt_identity() - from app.models import User - user = User.query.get(current_user_id) - if not user or user.role != 'admin': - return jsonify({'error': 'Admin access required to execute write queries'}), 403 + require_admin_user() result = DatabaseService.sqlite_execute_query( db_path=db_path, @@ -746,10 +734,7 @@ def execute_docker_query(container, database): # Only admins can disable readonly mode if not readonly: - current_user_id = get_jwt_identity() - user_obj = User.query.get(current_user_id) - if not user_obj or user_obj.role != 'admin': - return jsonify({'error': 'Admin access required to execute write queries'}), 403 + require_admin_user() result = DatabaseService.docker_mysql_execute_query( container_name=container, @@ -771,7 +756,7 @@ def execute_docker_query(container, database): def _workspace_id(): from app.services.workspace_service import WorkspaceService - user = User.query.get(get_jwt_identity()) + user = get_current_user() return WorkspaceService.resolve_workspace_id( user, request.headers.get('X-Workspace-Id') or request.args.get('workspace_id')) diff --git a/backend/app/api/ddns.py b/backend/app/api/ddns.py index f0812229..73ee7c81 100644 --- a/backend/app/api/ddns.py +++ b/backend/app/api/ddns.py @@ -1,13 +1,13 @@ from flask import Blueprint, request, jsonify -from flask_jwt_extended import jwt_required, get_jwt_identity -from app.models.user import User +from flask_jwt_extended import jwt_required from app.services.ddns_service import DdnsService +from app.middleware.rbac import get_current_user ddns_bp = Blueprint('ddns', __name__) def _admin(): - user = User.query.get(get_jwt_identity()) + user = get_current_user() return user if user and user.is_admin else None diff --git a/backend/app/api/deployment_jobs.py b/backend/app/api/deployment_jobs.py index 410cf469..1a5f5169 100644 --- a/backend/app/api/deployment_jobs.py +++ b/backend/app/api/deployment_jobs.py @@ -7,7 +7,7 @@ from flask import Blueprint, current_app, request, jsonify from flask_jwt_extended import jwt_required, get_jwt_identity -from app.middleware.rbac import app_access_tier +from app.middleware.rbac import app_access_tier, get_current_user from app.models import User, Application from app.models.deployment_job import DeploymentJob, DeploymentJobLog from app.services.deployment_job_service import DeploymentJobService @@ -111,7 +111,7 @@ def get_deployment_job(job_id): job = DeploymentJob.query.get(job_id) if not job: return jsonify({'error': 'Deployment job not found'}), 404 - user = User.query.get(get_jwt_identity()) + user = get_current_user() if not user or not _job_visible_to(user, job): return jsonify({'error': 'Access denied'}), 403 return jsonify({'job': job.to_dict(include_logs=include_logs, @@ -126,7 +126,7 @@ def retry_deployment_job(job_id): job = DeploymentJob.query.get(job_id) if not job: return jsonify({'error': 'Deployment job not found'}), 404 - user = User.query.get(get_jwt_identity()) + user = get_current_user() if not user or not _job_operable_by(user, job): return jsonify({'error': 'Access denied'}), 403 result = DeploymentJobService.retry_job(job_id, user_id=get_jwt_identity()) @@ -143,7 +143,7 @@ def get_deployment_job_logs(job_id): job = DeploymentJob.query.get(job_id) if not job: return jsonify({'error': 'Deployment job not found'}), 404 - user = User.query.get(get_jwt_identity()) + user = get_current_user() if not user or not _job_visible_to(user, job): return jsonify({'error': 'Access denied'}), 403 diff --git a/backend/app/api/deployments.py b/backend/app/api/deployments.py index 340e8d51..ca425d06 100644 --- a/backend/app/api/deployments.py +++ b/backend/app/api/deployments.py @@ -10,18 +10,19 @@ are unchanged; this is an additive, federated view so the frontend can read one history instead of merging two sources. """ from flask import Blueprint, request, jsonify -from flask_jwt_extended import jwt_required, get_jwt_identity +from flask_jwt_extended import jwt_required -from app.models import Application, User +from app.models import Application from app.models.deployment import Deployment from app.models.webhook import GitDeployment +from app.middleware.rbac import get_current_user deployments_bp = Blueprint('deployments', __name__) def _access(app_id): from app.services.resource_grant_service import ResourceGrantService - user = User.query.get(get_jwt_identity()) + user = get_current_user() app = Application.query_active().filter_by(id=app_id).first() if not app: return None, (jsonify({'error': 'Application not found'}), 404) diff --git a/backend/app/api/dns_zones.py b/backend/app/api/dns_zones.py index 6ef36563..4653db84 100644 --- a/backend/app/api/dns_zones.py +++ b/backend/app/api/dns_zones.py @@ -1,16 +1,11 @@ from flask import Blueprint, request, jsonify from flask_jwt_extended import jwt_required from app.services.dns_zone_service import DNSZoneService +from app.middleware.rbac import get_current_user dns_zones_bp = Blueprint('dns_zones', __name__) -def get_current_user(): - from flask_jwt_extended import get_jwt_identity - from app.models.user import User - return User.query.get(get_jwt_identity()) - - @dns_zones_bp.route('/', methods=['GET']) @jwt_required() def list_zones(): diff --git a/backend/app/api/environments.py b/backend/app/api/environments.py index 63c51b8d..d91af8a6 100644 --- a/backend/app/api/environments.py +++ b/backend/app/api/environments.py @@ -4,19 +4,15 @@ Environments are children of a project; access is derived from the parent project's workspace, mirroring projects.py. """ from flask import Blueprint, request, jsonify -from flask_jwt_extended import jwt_required, get_jwt_identity +from flask_jwt_extended import jwt_required -from app.models.user import User from app.services.project_service import ProjectService from app.services.workspace_service import WorkspaceService +from app.middleware.rbac import get_current_user environments_bp = Blueprint('environments', __name__) -def _current_user(): - return User.query.get(get_jwt_identity()) - - def _accessible_workspace_ids(user): ws_id = WorkspaceService.resolve_workspace_id( user, request.headers.get('X-Workspace-Id') or request.args.get('workspace_id')) @@ -46,7 +42,7 @@ def _project_for_write(user, project_id): @jwt_required() def create_environment(): """Create an environment under a project. Body: {project_id, name, is_default?}.""" - user = _current_user() + user = get_current_user() data = request.get_json() or {} project_id = data.get('project_id') if not project_id: @@ -72,7 +68,7 @@ def create_environment(): @environments_bp.route('/', methods=['PUT']) @jwt_required() def update_environment(environment_id): - user = _current_user() + user = get_current_user() env = ProjectService.get_environment(environment_id) if not env: return jsonify({'error': 'Environment not found'}), 404 @@ -91,7 +87,7 @@ def update_environment(environment_id): def delete_environment(environment_id): """Delete an environment. Refuses (409) if it's the project's only one; detaches assigned apps (environment_id -> NULL) otherwise.""" - user = _current_user() + user = get_current_user() env = ProjectService.get_environment(environment_id) if not env: return jsonify({'error': 'Environment not found'}), 404 @@ -109,7 +105,7 @@ def delete_environment(environment_id): @jwt_required() def reorder_environments(): """Reorder a project's environments. Body: {project_id, ordered_ids:[...]}""" - user = _current_user() + user = get_current_user() data = request.get_json() or {} project_id = data.get('project_id') ordered_ids = data.get('ordered_ids') diff --git a/backend/app/api/image_updates.py b/backend/app/api/image_updates.py index 3c361dc8..fa6fa157 100644 --- a/backend/app/api/image_updates.py +++ b/backend/app/api/image_updates.py @@ -1,17 +1,17 @@ # Bucket: PER-APP (plan 29 #9). The registry-check trigger stays admin; the # latest-check read is scoped to callers who can access the app (can_access_app). from flask import Blueprint, jsonify -from flask_jwt_extended import jwt_required, get_jwt_identity -from app.models.user import User +from flask_jwt_extended import jwt_required from app.models.application import Application from app.services.image_update_service import ImageUpdateService from app.services.resource_grant_service import ResourceGrantService +from app.middleware.rbac import get_current_user image_updates_bp = Blueprint('image_updates', __name__) def _admin(): - user = User.query.get(get_jwt_identity()) + user = get_current_user() return user if user and user.is_admin else None @@ -36,7 +36,7 @@ def latest(app_id): Scoped to the app's workspace visibility (plan 29 #9) — a foreign caller gets a sealed 404.""" app = Application.query_active().filter_by(id=app_id).first() - user = User.query.get(get_jwt_identity()) + user = get_current_user() if app is None or not ResourceGrantService.can_access_app(user, app): return jsonify({'error': 'Not found'}), 404 check_row = ImageUpdateService.latest_check(app_id) diff --git a/backend/app/api/marketplace.py b/backend/app/api/marketplace.py index 75a9c4d8..bb257bcd 100644 --- a/backend/app/api/marketplace.py +++ b/backend/app/api/marketplace.py @@ -10,16 +10,11 @@ from flask import Blueprint, jsonify, request from flask_jwt_extended import jwt_required from app.services.audit_service import AuditService from app.models.audit_log import AuditLog +from app.middleware.rbac import get_current_user marketplace_bp = Blueprint('marketplace', __name__) -def get_current_user(): - from flask_jwt_extended import get_jwt_identity - from app.models.user import User - return User.query.get(get_jwt_identity()) - - @marketplace_bp.route('/registry', methods=['GET']) @jwt_required() def list_registry(): diff --git a/backend/app/api/migrations.py b/backend/app/api/migrations.py index a5aea12c..58ba5d25 100644 --- a/backend/app/api/migrations.py +++ b/backend/app/api/migrations.py @@ -1,8 +1,9 @@ from flask import Blueprint, jsonify, current_app -from flask_jwt_extended import jwt_required, get_jwt_identity +from flask_jwt_extended import jwt_required from app.models import User from app.services.migration_service import MigrationService +from app.middleware.rbac import get_current_user migrations_bp = Blueprint('migrations', __name__) @@ -18,7 +19,7 @@ def get_migration_status(): @jwt_required() def create_backup(): """Create a database backup before applying migrations. Admin only.""" - user = User.query.get(get_jwt_identity()) + user = get_current_user() if not user or user.role != User.ROLE_ADMIN: return jsonify({'error': 'Admin access required'}), 403 @@ -32,7 +33,7 @@ def create_backup(): @jwt_required() def apply_migrations(): """Apply all pending migrations. Admin only.""" - user = User.query.get(get_jwt_identity()) + user = get_current_user() if not user or user.role != User.ROLE_ADMIN: return jsonify({'error': 'Admin access required'}), 403 @@ -46,7 +47,7 @@ def apply_migrations(): @jwt_required() def get_migration_history(): """Return all migration revisions. Admin only.""" - user = User.query.get(get_jwt_identity()) + user = get_current_user() if not user or user.role != User.ROLE_ADMIN: return jsonify({'error': 'Admin access required'}), 403 diff --git a/backend/app/api/modules.py b/backend/app/api/modules.py index 49e28611..6ae59ab8 100644 --- a/backend/app/api/modules.py +++ b/backend/app/api/modules.py @@ -9,16 +9,11 @@ from flask_jwt_extended import jwt_required from app.services import module_service from app.services.audit_service import AuditService from app.models.audit_log import AuditLog +from app.middleware.rbac import get_current_user modules_bp = Blueprint('modules', __name__) -def _current_user(): - from flask_jwt_extended import get_jwt_identity - from app.models.user import User - return User.query.get(get_jwt_identity()) - - @modules_bp.route('', methods=['GET']) @modules_bp.route('/', methods=['GET']) @jwt_required() @@ -29,7 +24,7 @@ def list_modules(): @modules_bp.route('/', methods=['PUT']) @jwt_required() def set_module(name): - user = _current_user() + user = get_current_user() if not user or not user.is_admin: return jsonify({'error': 'Admin access required'}), 403 diff --git a/backend/app/api/nginx_advanced.py b/backend/app/api/nginx_advanced.py index d0731a24..67010785 100644 --- a/backend/app/api/nginx_advanced.py +++ b/backend/app/api/nginx_advanced.py @@ -1,16 +1,11 @@ from flask import Blueprint, request, jsonify from flask_jwt_extended import jwt_required from app.services.nginx_advanced_service import NginxAdvancedService +from app.middleware.rbac import get_current_user nginx_advanced_bp = Blueprint('nginx_advanced', __name__) -def get_current_user(): - from flask_jwt_extended import get_jwt_identity - from app.models.user import User - return User.query.get(get_jwt_identity()) - - @nginx_advanced_bp.route('/proxy/', methods=['GET']) @jwt_required() def get_proxy_rules(domain): diff --git a/backend/app/api/performance.py b/backend/app/api/performance.py index 623f6b5b..df62bf0c 100644 --- a/backend/app/api/performance.py +++ b/backend/app/api/performance.py @@ -2,16 +2,11 @@ from flask import Blueprint, request, jsonify from flask_jwt_extended import jwt_required from app.services.cache_service import CacheService from app.jobs.service import JobService +from app.middleware.rbac import get_current_user performance_bp = Blueprint('performance', __name__) -def get_current_user(): - from flask_jwt_extended import get_jwt_identity - from app.models.user import User - return User.query.get(get_jwt_identity()) - - @performance_bp.route('/cache/stats', methods=['GET']) @jwt_required() def cache_stats(): diff --git a/backend/app/api/plugins.py b/backend/app/api/plugins.py index 5a29467d..8fd60cef 100644 --- a/backend/app/api/plugins.py +++ b/backend/app/api/plugins.py @@ -10,16 +10,11 @@ from flask import Blueprint, request, jsonify from flask_jwt_extended import jwt_required from app.services.audit_service import AuditService from app.models.audit_log import AuditLog +from app.middleware.rbac import get_current_user plugins_bp = Blueprint('plugins', __name__) -def get_current_user(): - from flask_jwt_extended import get_jwt_identity - from app.models.user import User - return User.query.get(get_jwt_identity()) - - @plugins_bp.route('', methods=['GET']) @plugins_bp.route('/', methods=['GET']) @jwt_required() diff --git a/backend/app/api/previews.py b/backend/app/api/previews.py index 35f04171..74eb1cb3 100644 --- a/backend/app/api/previews.py +++ b/backend/app/api/previews.py @@ -7,12 +7,13 @@ Mounted by the host at ``/api/v1/apps`` so every route here is nested under an application id, e.g. ``GET /api/v1/apps//previews``. """ from flask import Blueprint, request, jsonify -from flask_jwt_extended import jwt_required, get_jwt_identity +from flask_jwt_extended import jwt_required -from app.models import Application, User +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__) @@ -24,7 +25,7 @@ def _get_app_or_404(app_id, write=False): app = Application.query_active().filter_by(id=app_id).first() if not app: return None, (jsonify({'error': 'Application not found'}), 404) - user = User.query.get(get_jwt_identity()) + user = get_current_user() ok = ResourceGrantService.can_edit_app(user, app) if write \ else ResourceGrantService.can_access_app(user, app) if not ok: diff --git a/backend/app/api/projects.py b/backend/app/api/projects.py index 0502532d..216b36cf 100644 --- a/backend/app/api/projects.py +++ b/backend/app/api/projects.py @@ -7,20 +7,16 @@ 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, get_jwt_identity +from flask_jwt_extended import jwt_required -from app.models.user import User 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 _current_user(): - return User.query.get(get_jwt_identity()) - - 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.""" @@ -56,7 +52,7 @@ def _can_view_project(user, project): @jwt_required() def list_projects(): """List projects in the accessible workspace(s), each with resource counts.""" - user = _current_user() + user = get_current_user() ws_ids = _accessible_workspace_ids(user) projects = [] for ws_id in ws_ids: @@ -69,7 +65,7 @@ def list_projects(): @jwt_required() def create_project(): """Create a project (auto-creates a default environment).""" - user = _current_user() + user = get_current_user() data = request.get_json() or {} name = (data.get('name') or '').strip() if not name: @@ -100,7 +96,7 @@ def create_project(): @jwt_required() def get_project(project_id): """Get a project with its environments and resource counts.""" - user = _current_user() + user = get_current_user() project = ProjectService.get_project(project_id) if not project: return jsonify({'error': 'Project not found'}), 404 @@ -116,7 +112,7 @@ def get_project(project_id): @projects_bp.route('/', methods=['PUT']) @jwt_required() def update_project(project_id): - user = _current_user() + user = get_current_user() project = ProjectService.get_project(project_id) if not project: return jsonify({'error': 'Project not found'}), 404 @@ -139,7 +135,7 @@ def update_project(project_id): @jwt_required() def delete_project(project_id): """Delete a project. Refuses (409) if it still has applications assigned.""" - user = _current_user() + user = get_current_user() project = ProjectService.get_project(project_id) if not project: return jsonify({'error': 'Project not found'}), 404 diff --git a/backend/app/api/queue_bus.py b/backend/app/api/queue_bus.py index 70e0c715..ff971364 100644 --- a/backend/app/api/queue_bus.py +++ b/backend/app/api/queue_bus.py @@ -1,23 +1,14 @@ """REST API for the ServerKit Queue Bus.""" from flask import Blueprint, request, jsonify -from flask_jwt_extended import jwt_required, get_jwt_identity +from flask_jwt_extended import jwt_required from app.queue_bus.service import QueueBusService, QueueBusError from app.models.user import User +from app.middleware.rbac import get_current_user queue_bus_bp = Blueprint('queue_bus', __name__) -def _current_user(): - uid = get_jwt_identity() - if uid is None: - return None - try: - return User.query.get(int(uid)) - except (TypeError, ValueError): - return None - - def _is_admin(user): return user is not None and getattr(user, 'role', None) == 'admin' @@ -47,7 +38,7 @@ def _ensure_group_accessible(group_slug): group = QueueBusService.get_group(group_slug) if not group: return # missing group — the service call below raises the 404 - user = _current_user() + user = get_current_user() if _is_admin(user): return if (group.get('owner_type') == 'user' and user is not None @@ -100,7 +91,7 @@ def create_group(): if not slug and not name: return jsonify({'error': 'name or slug is required'}), 400 - user = _current_user() + user = get_current_user() if not _is_admin(user): # Non-admins create under their own user ownership. owner_type = 'user' diff --git a/backend/app/api/registrars.py b/backend/app/api/registrars.py index 0175153e..dcf6317b 100644 --- a/backend/app/api/registrars.py +++ b/backend/app/api/registrars.py @@ -6,18 +6,14 @@ can show "what we own and when it lapses". """ from flask import Blueprint, request, jsonify -from flask_jwt_extended import jwt_required, get_jwt_identity +from flask_jwt_extended import jwt_required -from app.models.user import User from app.services.registrar_service import RegistrarService +from app.middleware.rbac import get_current_user registrars_bp = Blueprint('registrars', __name__) -def _current_user(): - return User.query.get(get_jwt_identity()) - - @registrars_bp.route('/connections', methods=['GET']) @jwt_required() def list_connections(): @@ -28,7 +24,7 @@ def list_connections(): @registrars_bp.route('/connections', methods=['POST']) @jwt_required() def add_connection(): - user = _current_user() + user = get_current_user() if not user or not user.is_admin: return jsonify({'error': 'Admin access required'}), 403 data = request.get_json() or {} @@ -49,7 +45,7 @@ def add_connection(): @registrars_bp.route('/connections/', methods=['DELETE']) @jwt_required() def delete_connection(cid): - user = _current_user() + user = get_current_user() if not user or not user.is_admin: return jsonify({'error': 'Admin access required'}), 403 if not RegistrarService.delete_connection(cid): diff --git a/backend/app/api/search.py b/backend/app/api/search.py index 1847b1c6..41dd3ff2 100644 --- a/backend/app/api/search.py +++ b/backend/app/api/search.py @@ -6,13 +6,13 @@ jobs, extensions, vaults). Business logic lives in SearchService; this blueprint just validates the term, resolves the user, and shapes the JSON. """ from flask import Blueprint, request -from flask_jwt_extended import jwt_required, get_jwt_identity +from flask_jwt_extended import jwt_required from app.api.contracts import api_contract from app.api.responses import list_response from app.api.schemas.search import SearchQuerySchema, SearchResponseSchema -from app.models.user import User from app.services.search_service import SearchService +from app.middleware.rbac import get_current_user search_bp = Blueprint('search', __name__) @@ -26,7 +26,7 @@ def search(query): if len(q) < 2: return list_response([], legacy_key='results') - user = User.query.get(get_jwt_identity()) + user = get_current_user() workspace = request.headers.get('X-Workspace-Id') or query['workspace_id'] rows = SearchService.search(user, q, workspace) return list_response(rows, legacy_key='results') diff --git a/backend/app/api/secrets_webhooks.py b/backend/app/api/secrets_webhooks.py index 44e96795..c603231b 100644 --- a/backend/app/api/secrets_webhooks.py +++ b/backend/app/api/secrets_webhooks.py @@ -2,9 +2,9 @@ from datetime import datetime from flask import Blueprint, request -from flask_jwt_extended import get_jwt_identity, jwt_required +from flask_jwt_extended import jwt_required -from app.middleware.rbac import require_workspace_access, require_workspace_role +from app.middleware.rbac import get_current_user, require_workspace_access, require_workspace_role from app.services.secret_vault_service import SecretService, SecretVaultService from app.services.webhook_gateway_service import WebhookGatewayService from app.services.workspace_service import WorkspaceService @@ -14,8 +14,10 @@ bp = Blueprint('secrets_webhooks', __name__) def _current_user_id() -> int: - identity = get_jwt_identity() - return int(identity) if identity else None + """The acting user's id, via the one identity door so an API-key + caller is attributed to the key's owner rather than to nobody.""" + user = get_current_user() + return user.id if user else None def _resolve_ws_value(): @@ -30,7 +32,7 @@ def _resolve_ws(): None rather than erroring.""" from app.models import User from app.services.workspace_service import WorkspaceService - user = User.query.get(get_jwt_identity()) + user = get_current_user() return WorkspaceService.resolve_workspace_id(user, _resolve_ws_value()) @@ -41,10 +43,6 @@ def _json_or_form() -> dict: return {} -def _current_user(): - return User.query.get(get_jwt_identity()) - - # Workspace roles allowed to mutate vault/webhook resources and to expose secret # VALUES (reveal / regenerate-secret) — mirroring the admin-gated connection-URI # reveal in databases.py. @@ -54,7 +52,7 @@ _WS_WRITE_ROLES = ('owner', 'admin') def _ws_visible(workspace_id, roles=None): """True when the caller is a panel admin or a workspace member (any role for reads; owner/admin when `roles` is given).""" - user = _current_user() + user = get_current_user() guard = (require_workspace_role(workspace_id, user, roles) if roles else require_workspace_access(workspace_id, user)) return guard is None @@ -115,7 +113,7 @@ def create_vault(): name = data.get('name') if not name: return {'error': 'name is required'}, 400 - user = User.query.get(get_jwt_identity()) + user = get_current_user() # Explicit workspace_id in the body takes precedence over the active context, # but the caller must be a member of that workspace. explicit_ws = data.get('workspace_id') diff --git a/backend/app/api/server_templates.py b/backend/app/api/server_templates.py index 56f0becf..8c7d0ed4 100644 --- a/backend/app/api/server_templates.py +++ b/backend/app/api/server_templates.py @@ -3,16 +3,11 @@ from flask_jwt_extended import jwt_required from app.services.server_template_service import ServerTemplateService from app.services.audit_service import AuditService from app.models.audit_log import AuditLog +from app.middleware.rbac import get_current_user server_templates_bp = Blueprint('server_templates', __name__) -def get_current_user(): - from flask_jwt_extended import get_jwt_identity - from app.models.user import User - return User.query.get(get_jwt_identity()) - - @server_templates_bp.route('/', methods=['GET']) @jwt_required() def list_templates(): diff --git a/backend/app/api/servers.py b/backend/app/api/servers.py index af2ac09e..7df662fd 100644 --- a/backend/app/api/servers.py +++ b/backend/app/api/servers.py @@ -22,7 +22,7 @@ from app.services.agent_registry import agent_registry from app.services.agent_fleet_service import fleet_service from app.services.discovery_service import discovery_service from app.services import connection_string as connection_string_codec -from app.middleware.rbac import admin_required, developer_required +from app.middleware.rbac import admin_required, developer_required, get_current_user # Default token lifetime when the caller doesn't specify one. 7 days is @@ -267,7 +267,7 @@ def list_servers(): # context this stays unfiltered; with a workspace context it filters to it. from app.models import User from app.services.workspace_service import WorkspaceService - user = User.query.get(get_jwt_identity()) + user = get_current_user() ws_id = WorkspaceService.resolve_workspace_id( user, request.headers.get('X-Workspace-Id') or request.args.get('workspace_id')) query = WorkspaceService.scope_query(Server.query, Server, user, @@ -348,7 +348,10 @@ def create_server(): allowed_ips: passed through unchanged. """ data = request.get_json() or {} - user_id = get_jwt_identity() + # Through the one identity door: @developer_required admits API-key + # callers, for whom reading the JWT directly raises. + creator = get_current_user() + user_id = creator.id if creator else None # Generate registration token registration_token = Server.generate_registration_token() @@ -368,9 +371,8 @@ def create_server(): expires_at = _resolve_token_expiry(data.get('expires_in')) # Stamp the workspace (#33): the requested one (membership-checked) or the default. - from app.models import User from app.services.workspace_service import WorkspaceService - user = User.query.get(user_id) + user = creator ws_id = WorkspaceService.resolve_workspace_id( user, request.headers.get('X-Workspace-Id') or request.args.get('workspace_id')) # Role reconciliation (#33): a workspace 'viewer' member has read-only access to @@ -437,7 +439,7 @@ def set_server_workspace(server_id): from app.models import User from app.models.workspace import Workspace from app.services.workspace_service import WorkspaceService - user = User.query.get(get_jwt_identity()) + user = get_current_user() server = Server.query.get(server_id) if not server: return jsonify({'error': 'Server not found'}), 404 diff --git a/backend/app/api/source_connections.py b/backend/app/api/source_connections.py index a02eb1b3..0e9ddc93 100644 --- a/backend/app/api/source_connections.py +++ b/backend/app/api/source_connections.py @@ -4,8 +4,7 @@ from flask import Blueprint, jsonify, request from flask_jwt_extended import jwt_required, get_jwt_identity from requests import HTTPError -from app.models import User -from app.middleware.rbac import admin_required +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__) @@ -124,7 +123,7 @@ def github_admin_config(): @source_connections_bp.route('/admin/github', methods=['PUT']) @admin_required def update_github_admin_config(): - user = User.query.get(get_jwt_identity()) + user = get_current_user() result = SourceConnectionService.update_github_config( request.get_json() or {}, user_id=user.id if user else None, @@ -150,7 +149,7 @@ def github_app_manifest(): @admin_required def complete_github_app_manifest(): """Convert the manifest code GitHub returned into a stored GitHub App.""" - user = User.query.get(get_jwt_identity()) + user = get_current_user() data = request.get_json() or {} try: result = SourceConnectionService.complete_github_app_manifest( @@ -289,7 +288,7 @@ def gitlab_admin_config(): @source_connections_bp.route('/admin/gitlab', methods=['PUT']) @admin_required def update_gitlab_admin_config(): - user = User.query.get(get_jwt_identity()) + user = get_current_user() result = SourceConnectionService.update_gitlab_config( request.get_json() or {}, user_id=user.id if user else None, @@ -421,7 +420,7 @@ def bitbucket_admin_config(): @source_connections_bp.route('/admin/bitbucket', methods=['PUT']) @admin_required def update_bitbucket_admin_config(): - user = User.query.get(get_jwt_identity()) + user = get_current_user() result = SourceConnectionService.update_bitbucket_config( request.get_json() or {}, user_id=user.id if user else None, diff --git a/backend/app/api/sso.py b/backend/app/api/sso.py index 48dd85f9..ae1c5d95 100644 --- a/backend/app/api/sso.py +++ b/backend/app/api/sso.py @@ -5,12 +5,12 @@ from flask_jwt_extended import ( create_access_token, create_refresh_token, jwt_required, get_jwt_identity ) from app import db -from app.models import User, AuditLog +from app.models import AuditLog from app.models.oauth_identity import OAuthIdentity from app.services import sso_service from app.services.settings_service import SettingsService from app.services.audit_service import AuditService -from app.middleware.rbac import admin_required +from app.middleware.rbac import admin_required, get_current_user sso_bp = Blueprint('sso', __name__) @@ -199,7 +199,7 @@ def unlink_provider(provider): @admin_required def get_sso_config(): """All SSO settings (secrets redacted).""" - user = User.query.get(get_jwt_identity()) + user = get_current_user() config = {} for key in SettingsService.DEFAULT_SETTINGS: @@ -218,7 +218,7 @@ def get_sso_config(): @admin_required def update_provider_config(provider): """Update a provider's SSO config.""" - user = User.query.get(get_jwt_identity()) + user = get_current_user() if provider not in VALID_PROVIDERS: return jsonify({'error': f'Invalid provider: {provider}'}), 400 @@ -257,7 +257,7 @@ def test_provider(provider): @admin_required def update_general_settings(): """Update general SSO settings (auto_provision, force_sso, etc.).""" - user = User.query.get(get_jwt_identity()) + user = get_current_user() data = request.get_json() or {} general_keys = ['sso_auto_provision', 'sso_default_role', 'sso_force_sso', 'sso_allowed_domains'] diff --git a/backend/app/api/workspaces.py b/backend/app/api/workspaces.py index 091c276e..af439cae 100644 --- a/backend/app/api/workspaces.py +++ b/backend/app/api/workspaces.py @@ -5,17 +5,11 @@ from app.services.audit_service import AuditService from app.models.audit_log import AuditLog # The workspace guards now live in middleware/rbac.py so any resource blueprint # can reuse them (plan 19 Decision 2). Re-exported here for existing callers. -from app.middleware.rbac import require_workspace_access, require_workspace_role +from app.middleware.rbac import get_current_user, require_workspace_access, require_workspace_role workspaces_bp = Blueprint('workspaces', __name__) -def get_current_user(): - from flask_jwt_extended import get_jwt_identity - from app.models.user import User - return User.query.get(get_jwt_identity()) - - @workspaces_bp.route('/', methods=['GET']) @jwt_required() def list_workspaces(): diff --git a/backend/app/plugins/serverkit-gui/blueprint.py b/backend/app/plugins/serverkit-gui/blueprint.py index d3297554..80cbc007 100644 --- a/backend/app/plugins/serverkit-gui/blueprint.py +++ b/backend/app/plugins/serverkit-gui/blueprint.py @@ -6,12 +6,12 @@ which checks this plugin declared ``agent.command:`` before dispatching and turns a failed command into an exception carrying the reason. """ from flask import Blueprint, jsonify, request -from flask_jwt_extended import jwt_required, get_jwt_identity +from flask_jwt_extended import jwt_required +from app.middleware.rbac import get_current_user from app.plugins_sdk import agents, logger from app.plugins_sdk.permissions import PermissionDenied from app.models.server import Server -from app.models.user import User gui_bp = Blueprint("server_gui", __name__) log = logger(__name__) @@ -22,10 +22,6 @@ DEFAULT_FRAME_TIMEOUT = 8.0 MAX_FRAME_TIMEOUT = 15.0 -def _current_user(): - return User.query.get(get_jwt_identity()) - - def _fleet(): return agents.for_plugin(PLUGIN_SLUG) @@ -41,7 +37,7 @@ def _server_or_404(server_id: str): @jwt_required() def capabilities(server_id): """Ask the agent what it can capture (display server, resolution, fps cap).""" - user = _current_user() + user = get_current_user() server, err = _server_or_404(server_id) if err: return err @@ -84,7 +80,7 @@ def frame(server_id): quality int 10..95 JPEG quality (PNG ignores this) format png|jpeg encoding hint """ - user = _current_user() + user = get_current_user() server, err = _server_or_404(server_id) if err: return err @@ -134,7 +130,7 @@ def synthetic(server_id): No new agent action — we reuse data the agent already exposes via existing actions. Cheap and always available. """ - user = _current_user() + user = get_current_user() server, err = _server_or_404(server_id) if err: return err diff --git a/backend/app/services/cf_ops_change_service.py b/backend/app/services/cf_ops_change_service.py index ca1173dd..b7efd8cb 100644 --- a/backend/app/services/cf_ops_change_service.py +++ b/backend/app/services/cf_ops_change_service.py @@ -19,10 +19,15 @@ class CfOpsChangeService: @staticmethod def _current_user_id(): - """Best-effort current user id (None outside a request/JWT context).""" + """Best-effort current user id (None outside a request context). + + Via rbac.get_current_user() so an API-key caller is attributed to the + key's owner; reading the JWT directly raises for those requests and + the blanket except turned that into a silent None.""" try: - from flask_jwt_extended import get_jwt_identity - return get_jwt_identity() + from app.middleware.rbac import get_current_user + user = get_current_user() + return user.id if user else None except Exception: return None diff --git a/backend/app/services/shared_resource_service.py b/backend/app/services/shared_resource_service.py index 5cf5f2e0..3889a26b 100644 --- a/backend/app/services/shared_resource_service.py +++ b/backend/app/services/shared_resource_service.py @@ -49,10 +49,16 @@ def _audit(action, **kwargs): def _current_user_id(): - """Resolve the acting user id from the JWT, or None outside a request.""" + """Resolve the acting user id, or None outside a request. + + Goes through rbac.get_current_user() rather than get_jwt_identity() so an + API-key caller is attributed to the key's owner. Reading the JWT directly + *raises* for those requests, and the blanket except below turned that into + a silent user_id=None on every row written here.""" try: - from flask_jwt_extended import get_jwt_identity - return get_jwt_identity() + from app.middleware.rbac import get_current_user + user = get_current_user() + return user.id if user else None except Exception: return None diff --git a/backend/tests/IDENTITY_DOOR_CEILING b/backend/tests/IDENTITY_DOOR_CEILING new file mode 100644 index 00000000..84df3526 --- /dev/null +++ b/backend/tests/IDENTITY_DOOR_CEILING @@ -0,0 +1 @@ +87 diff --git a/backend/tests/api_controller_boundary_baseline.json b/backend/tests/api_controller_boundary_baseline.json index 316bc933..22517e4e 100644 --- a/backend/tests/api_controller_boundary_baseline.json +++ b/backend/tests/api_controller_boundary_baseline.json @@ -3,21 +3,13 @@ "policy": "Exact reviewed inventory of direct persistence, subprocess, and filesystem mutation calls in Flask API controllers. New calls are forbidden; remove entries as controllers delegate to services.", "counts": { "filesystem": 11, - "persistence": 595 + "persistence": 519 }, "owners": { "api/admin.py": { "owner": "ServerKit backend / admin domain", "rationale": "Legacy admin controllers still cross a persistence or host-operation boundary; move each call into an app.services use case before removing it." }, - "api/advanced_ssl.py": { - "owner": "ServerKit backend / advanced ssl domain", - "rationale": "Legacy advanced ssl controllers still cross a persistence or host-operation boundary; move each call into an app.services use case before removing it." - }, - "api/agent_plugins.py": { - "owner": "ServerKit backend / agent plugins domain", - "rationale": "Legacy agent plugins controllers still cross a persistence or host-operation boundary; move each call into an app.services use case before removing it." - }, "api/ai.py": { "owner": "ServerKit backend / ai domain", "rationale": "Legacy ai controllers still cross a persistence or host-operation boundary; move each call into an app.services use case before removing it." @@ -62,10 +54,6 @@ "owner": "ServerKit backend / databases domain", "rationale": "Legacy databases controllers still cross a persistence or host-operation boundary; move each call into an app.services use case before removing it." }, - "api/ddns.py": { - "owner": "ServerKit backend / ddns domain", - "rationale": "Legacy ddns controllers still cross a persistence or host-operation boundary; move each call into an app.services use case before removing it." - }, "api/deploy.py": { "owner": "ServerKit backend / deploy domain", "rationale": "Legacy deploy controllers still cross a persistence or host-operation boundary; move each call into an app.services use case before removing it." @@ -78,10 +66,6 @@ "owner": "ServerKit backend / deployments domain", "rationale": "Legacy deployments controllers still cross a persistence or host-operation boundary; move each call into an app.services use case before removing it." }, - "api/dns_cutover.py": { - "owner": "ServerKit backend / dns cutover domain", - "rationale": "Legacy dns cutover controllers still cross a persistence or host-operation boundary; move each call into an app.services use case before removing it." - }, "api/dns_zones.py": { "owner": "ServerKit backend / dns zones domain", "rationale": "Legacy dns zones controllers still cross a persistence or host-operation boundary; move each call into an app.services use case before removing it." @@ -118,10 +102,6 @@ "owner": "ServerKit backend / image updates domain", "rationale": "Legacy image updates controllers still cross a persistence or host-operation boundary; move each call into an app.services use case before removing it." }, - "api/jobs.py": { - "owner": "ServerKit backend / jobs domain", - "rationale": "Legacy jobs controllers still cross a persistence or host-operation boundary; move each call into an app.services use case before removing it." - }, "api/managed_db_users.py": { "owner": "ServerKit backend / managed db users domain", "rationale": "Legacy managed db users controllers still cross a persistence or host-operation boundary; move each call into an app.services use case before removing it." @@ -130,38 +110,14 @@ "owner": "ServerKit backend / manifests domain", "rationale": "Legacy manifests controllers still cross a persistence or host-operation boundary; move each call into an app.services use case before removing it." }, - "api/marketplace.py": { - "owner": "ServerKit backend / marketplace domain", - "rationale": "Legacy marketplace controllers still cross a persistence or host-operation boundary; move each call into an app.services use case before removing it." - }, - "api/migrations.py": { - "owner": "ServerKit backend / migrations domain", - "rationale": "Legacy migrations controllers still cross a persistence or host-operation boundary; move each call into an app.services use case before removing it." - }, "api/mobile.py": { "owner": "ServerKit backend / mobile domain", "rationale": "Legacy mobile controllers still cross a persistence or host-operation boundary; move each call into an app.services use case before removing it." }, - "api/modules.py": { - "owner": "ServerKit backend / modules domain", - "rationale": "Legacy modules controllers still cross a persistence or host-operation boundary; move each call into an app.services use case before removing it." - }, - "api/nginx_advanced.py": { - "owner": "ServerKit backend / nginx advanced domain", - "rationale": "Legacy nginx advanced controllers still cross a persistence or host-operation boundary; move each call into an app.services use case before removing it." - }, "api/notifications.py": { "owner": "ServerKit backend / notifications domain", "rationale": "Legacy notifications controllers still cross a persistence or host-operation boundary; move each call into an app.services use case before removing it." }, - "api/performance.py": { - "owner": "ServerKit backend / performance domain", - "rationale": "Legacy performance controllers still cross a persistence or host-operation boundary; move each call into an app.services use case before removing it." - }, - "api/plugins.py": { - "owner": "ServerKit backend / plugins domain", - "rationale": "Legacy plugins controllers still cross a persistence or host-operation boundary; move each call into an app.services use case before removing it." - }, "api/previews.py": { "owner": "ServerKit backend / previews domain", "rationale": "Legacy previews controllers still cross a persistence or host-operation boundary; move each call into an app.services use case before removing it." @@ -182,18 +138,6 @@ "owner": "ServerKit backend / python domain", "rationale": "Legacy python controllers still cross a persistence or host-operation boundary; move each call into an app.services use case before removing it." }, - "api/queue_bus.py": { - "owner": "ServerKit backend / queue bus domain", - "rationale": "Legacy queue bus controllers still cross a persistence or host-operation boundary; move each call into an app.services use case before removing it." - }, - "api/registrars.py": { - "owner": "ServerKit backend / registrars domain", - "rationale": "Legacy registrars controllers still cross a persistence or host-operation boundary; move each call into an app.services use case before removing it." - }, - "api/search.py": { - "owner": "ServerKit backend / search domain", - "rationale": "Legacy search controllers still cross a persistence or host-operation boundary; move each call into an app.services use case before removing it." - }, "api/secrets_webhooks.py": { "owner": "ServerKit backend / secrets webhooks domain", "rationale": "Legacy secrets webhooks controllers still cross a persistence or host-operation boundary; move each call into an app.services use case before removing it." @@ -202,10 +146,6 @@ "owner": "ServerKit backend / security domain", "rationale": "Legacy security controllers still cross a persistence or host-operation boundary; move each call into an app.services use case before removing it." }, - "api/server_templates.py": { - "owner": "ServerKit backend / server templates domain", - "rationale": "Legacy server templates controllers still cross a persistence or host-operation boundary; move each call into an app.services use case before removing it." - }, "api/servers.py": { "owner": "ServerKit backend / servers domain", "rationale": "Legacy servers controllers still cross a persistence or host-operation boundary; move each call into an app.services use case before removing it." @@ -222,10 +162,6 @@ "owner": "ServerKit backend / snapshots domain", "rationale": "Legacy snapshots controllers still cross a persistence or host-operation boundary; move each call into an app.services use case before removing it." }, - "api/source_connections.py": { - "owner": "ServerKit backend / source connections domain", - "rationale": "Legacy source connections controllers still cross a persistence or host-operation boundary; move each call into an app.services use case before removing it." - }, "api/sso.py": { "owner": "ServerKit backend / sso domain", "rationale": "Legacy sso controllers still cross a persistence or host-operation boundary; move each call into an app.services use case before removing it." @@ -246,10 +182,6 @@ "owner": "ServerKit backend / templates domain", "rationale": "Legacy templates controllers still cross a persistence or host-operation boundary; move each call into an app.services use case before removing it." }, - "api/themes.py": { - "owner": "ServerKit backend / themes domain", - "rationale": "Legacy themes controllers still cross a persistence or host-operation boundary; move each call into an app.services use case before removing it." - }, "api/two_factor.py": { "owner": "ServerKit backend / two factor domain", "rationale": "Legacy two factor controllers still cross a persistence or host-operation boundary; move each call into an app.services use case before removing it." @@ -261,10 +193,6 @@ "api/webhooks.py": { "owner": "ServerKit backend / webhooks domain", "rationale": "Legacy webhooks controllers still cross a persistence or host-operation boundary; move each call into an app.services use case before removing it." - }, - "api/workspaces.py": { - "owner": "ServerKit backend / workspaces domain", - "rationale": "Legacy workspaces controllers still cross a persistence or host-operation boundary; move each call into an app.services use case before removing it." } }, "violations": [ @@ -305,8 +233,6 @@ "api/admin.py::update_user::persistence::db.session.commit()::1", "api/admin.py::update_user::persistence::db.session.commit()::2", "api/admin.py::update_user_permissions::persistence::db.session.commit()::1", - "api/advanced_ssl.py::get_current_user::persistence::User.query.get(get_jwt_identity())::1", - "api/agent_plugins.py::get_current_user::persistence::User.query.get(get_jwt_identity())::1", "api/ai.py::_load_or_create::persistence::db.session.add(row)::1", "api/ai.py::_load_or_create::persistence::db.session.commit()::1", "api/ai.py::_owned_conversation::persistence::db.session.get(AiConversation, conversation_id)::1", @@ -327,12 +253,10 @@ "api/ai.py::list_conversations::persistence::AiConversation.query.filter_by(user_id=user.id).order_by(AiConversation.updated_at.desc()).limit(100).all()::1", "api/ai.py::rename_conversation::persistence::db.session.commit()::1", "api/app_volumes.py::_load_app_for::persistence::Application.query_active().filter_by(id=app_id).first()::1", - "api/app_volumes.py::_load_app_for::persistence::User.query.get(get_jwt_identity())::1", "api/apps.py::_ensure_local_image_compose::filesystem::open(os.path.join(app_path, 'docker-compose.yml'), 'w')::1", "api/apps.py::_ensure_local_image_compose::filesystem::os.makedirs(app_path, exist_ok=True)::1", "api/apps.py::_ensure_local_image_compose::persistence::db.session.commit()::1", "api/apps.py::_load_app_for_backup::persistence::Application.query_active().filter_by(id=app_id).first()::1", - "api/apps.py::_load_app_for_backup::persistence::User.query.get(get_jwt_identity())::1", "api/apps.py::_resolve_project_env::persistence::Environment.query.get(environment_id)::1", "api/apps.py::_resolve_project_env::persistence::Project.query.get(project_id)::1", "api/apps.py::_sync_manual_app_status::persistence::db.session.commit()::1", @@ -364,9 +288,7 @@ "api/apps.py::delete_app_db_snapshot::persistence::db.session.commit()::1", "api/apps.py::delete_app_db_snapshot::persistence::db.session.delete(snapshot)::1", "api/apps.py::evaluate_scale::persistence::Application.query_active().filter_by(id=app_id).first()::1", - "api/apps.py::evaluate_scale::persistence::User.query.get(get_jwt_identity())::1", "api/apps.py::get_app::persistence::Application.query_active().filter_by(id=app_id).first()::1", - "api/apps.py::get_app::persistence::User.query.get(get_jwt_identity())::1", "api/apps.py::get_app_containers::persistence::Application.query_active().filter_by(id=app_id).first()::1", "api/apps.py::get_app_containers::persistence::User.query.get(current_user_id)::1", "api/apps.py::get_app_logs::persistence::Application.query_active().filter_by(id=app_id).first()::1", @@ -376,9 +298,7 @@ "api/apps.py::get_app_related::persistence::Deployment.query.filter_by(app_id=app.id).count()::1", "api/apps.py::get_app_related::persistence::Domain.query_active().filter_by(application_id=app.id).all()::1", "api/apps.py::get_app_related::persistence::ManagedDatabase.query.filter_by(owner_application_id=app.id).all()::1", - "api/apps.py::get_app_related::persistence::User.query.get(get_jwt_identity())::1", "api/apps.py::get_app_resources::persistence::Application.query_active().filter_by(id=app_id).first()::1", - "api/apps.py::get_app_resources::persistence::User.query.get(get_jwt_identity())::1", "api/apps.py::get_app_status::persistence::Application.query_active().filter_by(id=app_id).first()::1", "api/apps.py::get_app_status::persistence::User.query.get(current_user_id)::1", "api/apps.py::get_app_status::persistence::db.session.commit()::1", @@ -387,17 +307,13 @@ "api/apps.py::get_apps::persistence::Application.query_active()::1", "api/apps.py::get_apps::persistence::User.query.get(current_user_id)::1", "api/apps.py::get_compose_services::persistence::Application.query_active().filter_by(id=app_id).first()::1", - "api/apps.py::get_compose_services::persistence::User.query.get(get_jwt_identity())::1", "api/apps.py::get_container_logs::persistence::Application.query_active().filter_by(id=app_id).first()::1", "api/apps.py::get_container_logs::persistence::User.query.get(current_user_id)::1", "api/apps.py::get_linked_apps::persistence::Application.query_active().filter_by(id=app_id).first()::1", "api/apps.py::get_linked_apps::persistence::User.query.get(current_user_id)::1", "api/apps.py::get_scale_policy::persistence::Application.query_active().filter_by(id=app_id).first()::1", - "api/apps.py::get_scale_policy::persistence::User.query.get(get_jwt_identity())::1", "api/apps.py::get_sleep_policy::persistence::Application.query_active().filter_by(id=app_id).first()::1", - "api/apps.py::get_sleep_policy::persistence::User.query.get(get_jwt_identity())::1", "api/apps.py::grant_app_access::persistence::Application.query_active().filter_by(id=app_id).first()::1", - "api/apps.py::grant_app_access::persistence::User.query.get(get_jwt_identity())::1", "api/apps.py::grant_app_access::persistence::User.query.get(grantee_id)::1", "api/apps.py::link_apps::persistence::Application.query_active().filter_by(id=app_id).first()::1", "api/apps.py::link_apps::persistence::Application.query_active().filter_by(id=target_app_id).first()::1", @@ -405,41 +321,31 @@ "api/apps.py::link_apps::persistence::db.session.commit()::1", "api/apps.py::list_app_db_snapshots::persistence::DatabaseSnapshot.query.filter_by(site_id=site.id).order_by(DatabaseSnapshot.created_at.desc()).all()::1", "api/apps.py::list_app_grants::persistence::Application.query_active().filter_by(id=app_id).first()::1", - "api/apps.py::list_app_grants::persistence::User.query.get(get_jwt_identity())::1", "api/apps.py::move_apps_to_project::persistence::Application.query_active().filter_by(id=app_id).first()::1", "api/apps.py::move_apps_to_project::persistence::Environment.query.get(environment_id)::1", "api/apps.py::move_apps_to_project::persistence::Project.query.get(req_project_id)::1", - "api/apps.py::move_apps_to_project::persistence::User.query.get(get_jwt_identity())::1", "api/apps.py::move_apps_to_project::persistence::db.session.commit()::1", "api/apps.py::purge_micro_cache::persistence::Application.query_active().filter_by(id=app_id).first()::1", - "api/apps.py::purge_micro_cache::persistence::User.query.get(get_jwt_identity())::1", "api/apps.py::restart_app::persistence::Application.query_active().filter_by(id=app_id).first()::1", "api/apps.py::restart_app::persistence::User.query.get(current_user_id)::1", "api/apps.py::restart_app::persistence::db.session.commit()::1", "api/apps.py::revoke_app_access::persistence::Application.query_active().filter_by(id=app_id).first()::1", - "api/apps.py::revoke_app_access::persistence::User.query.get(get_jwt_identity())::1", "api/apps.py::rollback_app_version::persistence::Application.query_active().filter_by(id=app_id).first()::1", "api/apps.py::rollback_app_version::persistence::User.query.get(current_user_id)::1", "api/apps.py::rollback_app_version::persistence::db.session.commit()::1", "api/apps.py::scale_app::persistence::Application.query_active().filter_by(id=app_id).first()::1", - "api/apps.py::scale_app::persistence::User.query.get(get_jwt_identity())::1", - "api/apps.py::scale_sweep::persistence::User.query.get(get_jwt_identity())::1", "api/apps.py::set_app_workspace::persistence::Application.query_active().filter_by(id=app_id).first()::1", - "api/apps.py::set_app_workspace::persistence::User.query.get(get_jwt_identity())::1", "api/apps.py::set_app_workspace::persistence::Workspace.query.get(target)::1", "api/apps.py::set_app_workspace::persistence::db.session.commit()::1", "api/apps.py::set_micro_cache::persistence::Application.query_active().filter_by(id=app_id).first()::1", - "api/apps.py::set_micro_cache::persistence::User.query.get(get_jwt_identity())::1", "api/apps.py::set_micro_cache::persistence::db.session.commit()::1", "api/apps.py::sleep_app::persistence::Application.query_active().filter_by(id=app_id).first()::1", - "api/apps.py::sleep_app::persistence::User.query.get(get_jwt_identity())::1", "api/apps.py::start_app::persistence::Application.query_active().filter_by(id=app_id).first()::1", "api/apps.py::start_app::persistence::User.query.get(current_user_id)::1", "api/apps.py::start_app::persistence::db.session.commit()::1", "api/apps.py::stop_app::persistence::Application.query_active().filter_by(id=app_id).first()::1", "api/apps.py::stop_app::persistence::User.query.get(current_user_id)::1", "api/apps.py::stop_app::persistence::db.session.commit()::1", - "api/apps.py::sweep_idle_apps::persistence::User.query.get(get_jwt_identity())::1", "api/apps.py::unlink_apps::persistence::Application.query.get(app.linked_app_id)::1", "api/apps.py::unlink_apps::persistence::Application.query_active().filter_by(id=app_id).first()::1", "api/apps.py::unlink_apps::persistence::User.query.get(current_user_id)::1", @@ -448,15 +354,12 @@ "api/apps.py::update_app::persistence::User.query.get(current_user_id)::1", "api/apps.py::update_app::persistence::db.session.commit()::1", "api/apps.py::update_app_resources::persistence::Application.query_active().filter_by(id=app_id).first()::1", - "api/apps.py::update_app_resources::persistence::User.query.get(get_jwt_identity())::1", "api/apps.py::update_app_resources::persistence::db.session.commit()::1", "api/apps.py::update_environment::persistence::Application.query_active().filter_by(id=app_id).first()::1", "api/apps.py::update_environment::persistence::User.query.get(current_user_id)::1", "api/apps.py::update_environment::persistence::db.session.commit()::1", "api/apps.py::update_scale_policy::persistence::Application.query_active().filter_by(id=app_id).first()::1", - "api/apps.py::update_scale_policy::persistence::User.query.get(get_jwt_identity())::1", "api/apps.py::update_sleep_policy::persistence::Application.query_active().filter_by(id=app_id).first()::1", - "api/apps.py::update_sleep_policy::persistence::User.query.get(get_jwt_identity())::1", "api/apps.py::upload_app_archive::filesystem::os.makedirs(temp_dir, exist_ok=True)::1", "api/apps.py::upload_app_archive::filesystem::os.remove(zippath)::1", "api/apps.py::upload_app_archive::filesystem::os.remove(zippath)::2", @@ -469,7 +372,6 @@ "api/apps.py::upload_app_archive::persistence::db.session.commit()::1", "api/apps.py::upload_app_archive::persistence::db.session.commit()::2", "api/apps.py::wake_app::persistence::Application.query_active().filter_by(id=app_id).first()::1", - "api/apps.py::wake_app::persistence::User.query.get(get_jwt_identity())::1", "api/auth.py::complete_onboarding::persistence::User.query.get(current_user_id)::1", "api/auth.py::create_login_link::persistence::User.query.get(target_id)::1", "api/auth.py::create_login_link::persistence::db.session.commit()::1", @@ -511,7 +413,6 @@ "api/backups.py::cost_summary::persistence::db.session.query(func.coalesce(func.sum(BackupRun.size_local), 0), func.coalesce(func.sum(BackupRun.size_remote), 0), func.count(BackupRun.id))::1", "api/backups.py::list_runs::persistence::db.session.query(BackupRun, BackupPolicy)::1", "api/bandwidth.py::get_app_bandwidth::persistence::Application.query_active().filter_by(id=app_id).first()::1", - "api/bandwidth.py::get_app_bandwidth::persistence::User.query.get(get_jwt_identity())::1", "api/buildpacks.py::detect::filesystem::shutil.rmtree(workdir, ignore_errors=True)::1", "api/builds.py::configure_build::persistence::Application.query_active().filter_by(id=app_id).first()::1", "api/builds.py::deploy_app::persistence::Application.query_active().filter_by(id=app_id).first()::1", @@ -546,18 +447,10 @@ "api/cron.py::_notify_run_transition::persistence::Workspace.query.get(app.workspace_id)::1", "api/cron.py::_validate_application_id::persistence::Application.query_active().filter_by(id=aid).first()::1", "api/database_engines.py::_require_app_access::persistence::Application.query_active().filter_by(id=app_id).first()::1", - "api/database_engines.py::_require_app_access::persistence::User.query.get(get_jwt_identity())::1", - "api/databases.py::_persist_provisioned::persistence::User.query.get(get_jwt_identity())::1", - "api/databases.py::_workspace_id::persistence::User.query.get(get_jwt_identity())::1", - "api/databases.py::execute_docker_query::persistence::User.query.get(current_user_id)::1", - "api/databases.py::execute_mysql_query::persistence::User.query.get(current_user_id)::1", - "api/databases.py::execute_pg_query::persistence::User.query.get(current_user_id)::1", - "api/databases.py::execute_sqlite_query::persistence::User.query.get(current_user_id)::1", "api/databases.py::get_app_databases::persistence::Application.query_active().filter_by(id=app_id).first()::1", "api/databases.py::get_app_databases::persistence::User.query.get(current_user_id)::1", "api/databases.py::list_all_docker_databases::persistence::Application.query_active().filter_by(app_type='docker')::1", "api/databases.py::list_all_docker_databases::persistence::User.query.get(current_user_id)::1", - "api/ddns.py::_admin::persistence::User.query.get(get_jwt_identity())::1", "api/deploy.py::configure_deployment::persistence::Application.query_active().filter_by(id=app_id).first()::1", "api/deploy.py::get_branches::persistence::Application.query_active().filter_by(id=app_id).first()::1", "api/deploy.py::get_branches::persistence::User.query.get(current_user_id)::1", @@ -578,21 +471,15 @@ "api/deployment_jobs.py::_job_operable_by::persistence::Application.query_active().filter_by(id=job.app_id).first()::1", "api/deployment_jobs.py::_job_visible_to::persistence::Application.query_active().filter_by(id=job.app_id).first()::1", "api/deployment_jobs.py::get_deployment_job::persistence::DeploymentJob.query.get(job_id)::1", - "api/deployment_jobs.py::get_deployment_job::persistence::User.query.get(get_jwt_identity())::1", "api/deployment_jobs.py::get_deployment_job_logs::persistence::DeploymentJob.query.get(job_id)::1", "api/deployment_jobs.py::get_deployment_job_logs::persistence::DeploymentJobLog.query.filter_by(job_id=job_id)::1", - "api/deployment_jobs.py::get_deployment_job_logs::persistence::User.query.get(get_jwt_identity())::1", "api/deployment_jobs.py::list_deployment_jobs::persistence::Application.query_active().filter_by(id=app_id).first()::1", "api/deployment_jobs.py::list_deployment_jobs::persistence::User.query.get(current_user_id)::1", "api/deployment_jobs.py::retry_deployment_job::persistence::DeploymentJob.query.get(job_id)::1", - "api/deployment_jobs.py::retry_deployment_job::persistence::User.query.get(get_jwt_identity())::1", "api/deployments.py::_access::persistence::Application.query_active().filter_by(id=app_id).first()::1", - "api/deployments.py::_access::persistence::User.query.get(get_jwt_identity())::1", "api/deployments.py::app_history::persistence::Deployment.query.filter_by(app_id=app_id).order_by(Deployment.created_at.desc()).limit(limit).all()::1", "api/deployments.py::app_history::persistence::GitDeployment.query.filter_by(app_id=app_id).order_by(GitDeployment.created_at.desc()).limit(limit).all()::1", "api/deployments.py::get_deployment::persistence::Deployment.query.get(deployment_id)::1", - "api/dns_cutover.py::_current_user::persistence::User.query.get(get_jwt_identity())::1", - "api/dns_zones.py::get_current_user::persistence::User.query.get(get_jwt_identity())::1", "api/dns_zones.py::list_managed_records::persistence::Application.query.all()::1", "api/docker.py::cleanup_all_apps::filesystem::shutil.rmtree(app.root_path)::1", "api/docker.py::cleanup_all_apps::persistence::Application.query_active().filter_by(app_type='docker').all()::1", @@ -649,7 +536,6 @@ "api/env_vars.py::check_app_access::persistence::User.query.get(user_id)::1", "api/env_vars.py::update_env_var::persistence::db.session.commit()::1", "api/environments.py::_accessible_workspace_ids::persistence::Workspace.query.all()::1", - "api/environments.py::_current_user::persistence::User.query.get(get_jwt_identity())::1", "api/environments.py::create_environment::persistence::db.session.commit()::1", "api/files.py::upload_file::filesystem::os.makedirs(parent)::1", "api/fleet.py::list_targets::persistence::Server.query.get(server_id)::1", @@ -661,13 +547,9 @@ "api/git.py::get_webhook_deployments::persistence::GitWebhook.query.get(webhook_id)::1", "api/git.py::get_webhook_logs::persistence::GitWebhook.query.get(webhook_id)::1", "api/git.py::list_webhooks::persistence::GitWebhook.query.get(w.get('id'))::1", - "api/image_updates.py::_admin::persistence::User.query.get(get_jwt_identity())::1", "api/image_updates.py::latest::persistence::Application.query_active().filter_by(id=app_id).first()::1", - "api/image_updates.py::latest::persistence::User.query.get(get_jwt_identity())::1", - "api/jobs.py::_require_admin::persistence::User.query.get(get_jwt_identity())::1", "api/managed_db_users.py::delete_managed_db_user::persistence::ManagedDatabaseUser.query.filter_by(id=user_id, managed_database_id=managed.id).first()::1", "api/manifests.py::_load_normalized::persistence::ApplicationManifest.query.filter_by(project_id=project_id).first()::1", - "api/manifests.py::_require_admin::persistence::User.query.get(get_jwt_identity())::1", "api/manifests.py::apply_manifest::persistence::Project.query.get(data.get('project_id'))::1", "api/manifests.py::apply_manifest::persistence::db.session.commit()::1", "api/manifests.py::get_manifest::persistence::ApplicationManifest.query.filter_by(project_id=project_id).first()::1", @@ -675,26 +557,17 @@ "api/manifests.py::reset_bootstrap::persistence::Application.query_active().filter_by(id=data.get('app_id')).first()::1", "api/manifests.py::reset_bootstrap::persistence::db.session.commit()::1", "api/manifests.py::scaffold_manifest::persistence::Application.query_active().filter_by(id=app_id).first()::1", - "api/marketplace.py::get_current_user::persistence::User.query.get(get_jwt_identity())::1", - "api/migrations.py::apply_migrations::persistence::User.query.get(get_jwt_identity())::1", - "api/migrations.py::create_backup::persistence::User.query.get(get_jwt_identity())::1", - "api/migrations.py::get_migration_history::persistence::User.query.get(get_jwt_identity())::1", "api/mobile.py::get_mobile_summary::persistence::SecurityAlert.query.filter_by(resolved=False).count()::1", "api/mobile.py::get_mobile_summary::persistence::Server.query.count()::1", "api/mobile.py::get_offline_data::persistence::Server.query.limit(50).all()::1", "api/mobile.py::register_push::persistence::User.query.get(user_id)::1", "api/mobile.py::register_push::persistence::db.session.commit()::1", - "api/modules.py::_current_user::persistence::User.query.get(get_jwt_identity())::1", - "api/nginx_advanced.py::get_current_user::persistence::User.query.get(get_jwt_identity())::1", "api/notifications.py::get_user_preferences::persistence::User.query.get(current_user_id)::1", "api/notifications.py::test_user_notification::persistence::User.query.get(current_user_id)::1", "api/notifications.py::unmute_own_email::persistence::User.query.get(current_user_id)::1", "api/notifications.py::update_user_preferences::persistence::db.session.commit()::1", "api/notifications.py::update_user_preferences::persistence::db.session.rollback()::1", - "api/performance.py::get_current_user::persistence::User.query.get(get_jwt_identity())::1", - "api/plugins.py::get_current_user::persistence::User.query.get(get_jwt_identity())::1", "api/previews.py::_get_app_or_404::persistence::Application.query_active().filter_by(id=app_id).first()::1", - "api/previews.py::_get_app_or_404::persistence::User.query.get(get_jwt_identity())::1", "api/previews.py::destroy_preview::persistence::ApplicationPreview.query.filter_by(id=preview_id, application_id=app_id).first()::1", "api/previews.py::list_previews::persistence::ApplicationPreview.query.filter(ApplicationPreview.application_id == app_id, ApplicationPreview.status != ApplicationPreview.STATUS_DESTROYED).order_by(ApplicationPreview.pr_number.desc()).all()::1", "api/previews.py::redeploy_preview::persistence::ApplicationPreview.query.filter_by(id=preview_id, application_id=app_id).first()::1", @@ -714,7 +587,6 @@ "api/private_urls.py::update_private_url::persistence::User.query.get(current_user_id)::1", "api/private_urls.py::update_private_url::persistence::db.session.commit()::1", "api/projects.py::_accessible_workspace_ids::persistence::Workspace.query.all()::1", - "api/projects.py::_current_user::persistence::User.query.get(get_jwt_identity())::1", "api/proxy.py::_require_server::persistence::Server.query.get(server_id)::1", "api/python.py::create_django_app::persistence::db.session.add(app)::1", "api/python.py::create_django_app::persistence::db.session.commit()::1", @@ -727,17 +599,10 @@ "api/python.py::restart_app::persistence::db.session.commit()::1", "api/python.py::start_app::persistence::db.session.commit()::1", "api/python.py::stop_app::persistence::db.session.commit()::1", - "api/queue_bus.py::_current_user::persistence::User.query.get(int(uid))::1", - "api/registrars.py::_current_user::persistence::User.query.get(get_jwt_identity())::1", - "api/search.py::search::persistence::User.query.get(get_jwt_identity())::1", - "api/secrets_webhooks.py::_current_user::persistence::User.query.get(get_jwt_identity())::1", - "api/secrets_webhooks.py::_resolve_ws::persistence::User.query.get(get_jwt_identity())::1", - "api/secrets_webhooks.py::create_vault::persistence::User.query.get(get_jwt_identity())::1", "api/secrets_webhooks.py::create_vault::persistence::Workspace.query.get(explicit_ws)::1", "api/security.py::get_application_sboms::persistence::SbomArtifact.query.filter_by(application_id=application_id).order_by(SbomArtifact.created_at.desc()).limit(50).all()::1", "api/security.py::get_image_scan::persistence::ImageVulnerabilityScan.query.get(scan_id)::1", "api/security.py::get_sbom::persistence::SbomArtifact.query.get(sbom_id)::1", - "api/server_templates.py::get_current_user::persistence::User.query.get(get_jwt_identity())::1", "api/servers.py::acknowledge_alert::persistence::SecurityAlert.query.get(alert_id)::1", "api/servers.py::add_agent_version::persistence::db.session.add(version)::1", "api/servers.py::add_agent_version::persistence::db.session.commit()::1", @@ -746,7 +611,6 @@ "api/servers.py::create_group::persistence::db.session.add(group)::1", "api/servers.py::create_group::persistence::db.session.commit()::1", "api/servers.py::create_server::persistence::Server.query.filter_by(workspace_id=ws_id).count()::1", - "api/servers.py::create_server::persistence::User.query.get(user_id)::1", "api/servers.py::create_server::persistence::db.session.add(server)::1", "api/servers.py::create_server::persistence::db.session.commit()::1", "api/servers.py::create_server::persistence::db.session.commit()::2", @@ -775,7 +639,6 @@ "api/servers.py::list_agent_versions::persistence::AgentVersion.query.order_by(AgentVersion.version.desc()).all()::1", "api/servers.py::list_groups::persistence::ServerGroup.query.all()::1", "api/servers.py::list_servers::persistence::ServerMetrics.query.filter(ServerMetrics.id.in_(newest)).all()::1", - "api/servers.py::list_servers::persistence::User.query.get(get_jwt_identity())::1", "api/servers.py::list_servers::persistence::db.session.query(db.func.max(ServerMetrics.id))::1", "api/servers.py::ping_server::persistence::Server.query.get(server_id)::1", "api/servers.py::regenerate_token::persistence::Server.query.get(server_id)::1", @@ -788,7 +651,6 @@ "api/servers.py::rotate_api_key::persistence::db.session.commit()::1", "api/servers.py::set_server_workspace::persistence::Server.query.filter_by(workspace_id=ws.id).count()::1", "api/servers.py::set_server_workspace::persistence::Server.query.get(server_id)::1", - "api/servers.py::set_server_workspace::persistence::User.query.get(get_jwt_identity())::1", "api/servers.py::set_server_workspace::persistence::Workspace.query.get(target)::1", "api/servers.py::set_server_workspace::persistence::db.session.commit()::1", "api/servers.py::start_server_onboarding::persistence::Server.query.get(server_id)::1", @@ -816,17 +678,10 @@ "api/snapshots.py::_get_snapshot_or_404::persistence::DeploymentSnapshot.query.filter_by(id=snap_id, application_id=app_id).first()::1", "api/snapshots.py::diff_snapshot::persistence::DeploymentSnapshot.query.filter(DeploymentSnapshot.application_id == app_id, DeploymentSnapshot.created_at < snap.created_at).order_by(DeploymentSnapshot.created_at.desc(), DeploymentSnapshot.id.desc()).first()::1", "api/snapshots.py::list_snapshots::persistence::DeploymentSnapshot.query.filter_by(application_id=app_id).order_by(DeploymentSnapshot.created_at.desc(), DeploymentSnapshot.id.desc()).limit(limit).all()::1", - "api/source_connections.py::complete_github_app_manifest::persistence::User.query.get(get_jwt_identity())::1", - "api/source_connections.py::update_bitbucket_admin_config::persistence::User.query.get(get_jwt_identity())::1", - "api/source_connections.py::update_github_admin_config::persistence::User.query.get(get_jwt_identity())::1", - "api/source_connections.py::update_gitlab_admin_config::persistence::User.query.get(get_jwt_identity())::1", "api/sso.py::_complete_sso_login::persistence::db.session.commit()::1", "api/sso.py::_complete_sso_login::persistence::db.session.commit()::2", - "api/sso.py::get_sso_config::persistence::User.query.get(get_jwt_identity())::1", "api/sso.py::link_provider::persistence::OAuthIdentity.query.filter_by(provider=provider, provider_user_id=profile['provider_user_id']).first()::1", "api/sso.py::list_identities::persistence::OAuthIdentity.query.filter_by(user_id=user_id).all()::1", - "api/sso.py::update_general_settings::persistence::User.query.get(get_jwt_identity())::1", - "api/sso.py::update_provider_config::persistence::User.query.get(get_jwt_identity())::1", "api/survey.py::diff_surveys._load::persistence::ServerSurvey.query.filter_by(id=int(raw), server_id=server_id).first()::1", "api/survey.py::get_survey::persistence::ServerSurvey.query.filter_by(id=survey_id, server_id=server_id).first()::1", "api/survey.py::list_surveys::persistence::Server.query.get(server_id)::1", @@ -859,7 +714,6 @@ "api/templates.py::get_app_template_info::persistence::User.query.get(current_user_id)::1", "api/templates.py::update_app::persistence::Application.query_active().filter_by(id=app_id).first()::1", "api/templates.py::validate_installation::persistence::Application.query.filter_by(name=app_name).first()::1", - "api/themes.py::_current_user::persistence::User.query.get(get_jwt_identity())::1", "api/two_factor.py::confirm_2fa_setup::persistence::User.query.get(current_user_id)::1", "api/two_factor.py::disable_2fa::persistence::User.query.get(current_user_id)::1", "api/two_factor.py::get_2fa_status::persistence::User.query.get(current_user_id)::1", @@ -870,9 +724,7 @@ "api/two_factor.py::verify_2fa_code::persistence::db.session.commit()::2", "api/two_factor.py::verify_2fa_code::persistence::db.session.commit()::3", "api/waf.py::_get_application_or_404::persistence::Application.query_active().filter_by(id=app_id).first()::1", - "api/waf.py::get_current_user::persistence::User.query.get(get_jwt_identity())::1", "api/webhooks.py::receive_pull_request::persistence::ApplicationPreview.query.filter_by(application_id=webhook.app_id, pr_number=pr.get('pr_number')).first()::1", - "api/webhooks.py::receive_pull_request::persistence::GitWebhook.query.filter_by(webhook_token=token).first()::1", - "api/workspaces.py::get_current_user::persistence::User.query.get(get_jwt_identity())::1" + "api/webhooks.py::receive_pull_request::persistence::GitWebhook.query.filter_by(webhook_token=token).first()::1" ] } diff --git a/backend/tests/identity_door_census.py b/backend/tests/identity_door_census.py new file mode 100644 index 00000000..797174dd --- /dev/null +++ b/backend/tests/identity_door_census.py @@ -0,0 +1,175 @@ +"""Count identity lookups that bypass ``rbac.get_current_user()`` (plan 76, A). + +``app/middleware/rbac.get_current_user()`` is the one door that knows about +both authentication mechanisms: it returns ``g.api_key_user`` when the request +carried an ``X-API-Key``, and falls back to the JWT otherwise. Anything that +reaches for the JWT directly to answer "who is calling" is wrong for API-key +requests, and wrong in a way that does not look like a bug at the call site:: + + user = User.query.get(get_jwt_identity()) # RuntimeError under an API key + +``auth_required()`` deliberately skips ``verify_jwt_in_request()`` once the API +key middleware has authenticated, so there is no JWT context to read and +``get_jwt_identity()`` *raises*. Behind a policy decorator that raise surfaces +as a 500; inside the blanket ``try/except`` that service helpers wrap it in, it +silently degrades to ``user_id=None`` and the audit trail loses the actor. + +Two shapes are counted, because fixing only the first just moves the bug: + + direct User.query.get(get_jwt_identity()) + indirect uid = get_jwt_identity() ... User.query.get(uid) + +Deliberately NOT counted: + + * ``app/middleware/rbac.py`` itself — it is the door. + * ``get_jwt_identity()`` used as a token/identity value rather than to load a + user (issuing a token, comparing ids, logging). Those are not identity + *lookups* and converging them is a different question. + * ``app/api/auth.py`` — the token endpoints are JWT-only by construction; an + API key cannot mint or refresh a JWT session. They are listed as a checked + exception below so that a NEW bypass there still shows up. + +Usage (from ``backend/``):: + + python tests/identity_door_census.py # print the count + report + python tests/identity_door_census.py --update # write the count as the ceiling +""" + +import ast +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +BACKEND = os.path.dirname(HERE) +APP_DIR = os.path.join(BACKEND, 'app') +CEILING_FILE = os.path.join(HERE, 'IDENTITY_DOOR_CEILING') + +SKIP_DIRS = {'__pycache__', 'node_modules', '.venv', 'venv'} + +# The door itself, and the JWT-only token endpoints it cannot serve. +EXEMPT = { + 'app/middleware/rbac.py', + 'app/api/auth.py', +} + +# Model classes whose `.query.get(...)` means "load the calling user". +USER_MODELS = {'User'} + + +def _rel(path): + return os.path.relpath(path, BACKEND).replace(os.sep, '/') + + +def _iter_py_files(root): + for dirpath, dirnames, filenames in os.walk(root): + dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS] + for name in sorted(filenames): + if name.endswith('.py'): + yield os.path.join(dirpath, name) + + +def _is_jwt_identity_call(node): + return (isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == 'get_jwt_identity') + + +def _is_user_query_get(node): + """Match ``.query.get(...)`` and return the Call node.""" + if not (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)): + return False + if node.func.attr != 'get': + return False + query = node.func.value + if not (isinstance(query, ast.Attribute) and query.attr == 'query'): + return False + model = query.value + return isinstance(model, ast.Name) and model.id in USER_MODELS + + +def count_file(path): + """Return a list of (lineno, shape) bypasses in one file.""" + try: + with open(path, encoding='utf-8') as f: + tree = ast.parse(f.read()) + except (SyntaxError, UnicodeDecodeError): + return [] + + found = [] + + # Names bound to get_jwt_identity() anywhere in the module. Scoping this + # per-function would be more precise, but a name that holds the JWT + # identity in one function and something else in another is a trap of its + # own; treating the whole module as one namespace errs toward reporting. + jwt_names = set() + for node in ast.walk(tree): + if isinstance(node, ast.Assign) and _is_jwt_identity_call(node.value): + for target in node.targets: + if isinstance(target, ast.Name): + jwt_names.add(target.id) + elif isinstance(node, ast.AnnAssign) and node.value is not None: + if _is_jwt_identity_call(node.value) and isinstance(node.target, ast.Name): + jwt_names.add(node.target.id) + + for node in ast.walk(tree): + if not _is_user_query_get(node) or not node.args: + continue + arg = node.args[0] + if _is_jwt_identity_call(arg): + found.append((node.lineno, 'direct')) + elif isinstance(arg, ast.Name) and arg.id in jwt_names: + found.append((node.lineno, 'indirect')) + elif (isinstance(arg, ast.Call) and isinstance(arg.func, ast.Name) + and arg.func.id in ('int', 'str') and arg.args + and (_is_jwt_identity_call(arg.args[0]) + or (isinstance(arg.args[0], ast.Name) and arg.args[0].id in jwt_names))): + found.append((node.lineno, 'indirect')) + + return sorted(found) + + +def census(): + """Return {relative_path: [(lineno, shape), ...]} for non-exempt files.""" + found = {} + for path in _iter_py_files(APP_DIR): + rel = _rel(path) + if rel in EXEMPT: + continue + hits = count_file(path) + if hits: + found[rel] = hits + return found + + +def total(found=None): + if found is None: + found = census() + return sum(len(v) for v in found.values()) + + +def read_ceiling(): + with open(CEILING_FILE, encoding='utf-8') as f: + return int(f.read().strip()) + + +def _write_ceiling(value): + with open(CEILING_FILE, 'w', encoding='utf-8') as f: + f.write('%d\n' % value) + + +if __name__ == '__main__': + found = census() + count = total(found) + for rel in sorted(found, key=lambda p: (-len(found[p]), p)): + shapes = found[rel] + print('%-55s %d (%s)' % ( + rel, len(shapes), ', '.join('L%d %s' % (l, s) for l, s in shapes[:6]))) + print('\ntotal: %d' % count) + if '--update' in sys.argv: + _write_ceiling(count) + print('ceiling updated to %d' % count) + else: + try: + print('ceiling: %d' % read_ceiling()) + except FileNotFoundError: + print('no ceiling file yet; run with --update') diff --git a/backend/tests/test_api_key_identity.py b/backend/tests/test_api_key_identity.py new file mode 100644 index 00000000..49b292c9 --- /dev/null +++ b/backend/tests/test_api_key_identity.py @@ -0,0 +1,190 @@ +"""API-key callers must resolve to a real user inside the handler (plan 76, A). + +`app/middleware/api_key_auth.py` authenticates an ``X-API-Key`` request by +stashing the key's owner on ``g.api_key_user``; ``rbac.get_current_user()`` is +the only identity lookup that reads it. A handler that instead resolves the +caller inline with ``User.query.get(get_jwt_identity())`` gets ``None`` for +those requests — the *policy decorator already let them in*, so the failure +lands in the middle of the handler rather than at the boundary. + +That combination is not hypothetical. Every route exercised below is reached +through an API-key-capable policy decorator (``@admin_required``, +``@developer_required``, ``@viewer_required``), all of which wrap +``auth_required()``. The three failure modes each of these produced: + + * ``None.is_admin`` / ``None.id`` -> AttributeError, a 500 + * ``user_id=user.id if user else None`` -> the change is written, attributed + to nobody, and the audit trail loses the actor + * a bound-but-unused ``user`` -> harmless, but it is the same bug + one edit away from mattering + +These are behavioural tests, not census tests; the census lives in +``tests/test_identity_door_ratchet.py``. +""" + +import pytest +from werkzeug.security import generate_password_hash + + +def _api_key_for(db, role, suffix, scopes=('*',)): + """Create a user with `role` and return (user, raw API key).""" + from app.models import User + from app.services.api_key_service import ApiKeyService + + user = User( + email=f'identity-{suffix}@test.local', + username=f'identity_{suffix}', + password_hash=generate_password_hash('testpass'), + role=role, + is_active=True, + ) + db.session.add(user) + db.session.commit() + _, raw_key = ApiKeyService.create_key( + user.id, name=f'identity-{suffix}', scopes=list(scopes)) + return user, raw_key + + +class TestApiKeyCallersReachTheHandlerWithAnIdentity: + """A 500 here means the handler resolved the caller as None.""" + + def test_developer_api_key_can_reassign_a_server_workspace(self, app, client, db_session): + """servers.py set_server_workspace() reads `user.is_admin` directly.""" + from app.models import User + from app.models.server import Server + + _, raw_key = _api_key_for(db_session, User.ROLE_DEVELOPER, 'srv-dev') + server = Server(name='identity-probe', hostname='10.10.10.10') + db_session.session.add(server) + db_session.session.commit() + + response = client.put( + f'/api/v1/servers/{server.id}/workspace', + json={'workspace_id': 'default'}, + headers={'X-API-Key': raw_key}, + ) + + assert response.status_code != 500, ( + 'the handler resolved the API-key caller as None and crashed on ' + f'None.is_admin; body={response.get_data(as_text=True)[:300]}') + assert response.status_code == 200 + + def test_admin_api_key_can_read_bandwidth_for_an_app(self, app, client, db_session): + """bandwidth.py passes the caller straight into can_access_app().""" + from app.models import User + from app.models.application import Application + + user, raw_key = _api_key_for(db_session, User.ROLE_ADMIN, 'bw-admin') + application = Application(name='identity-bw', app_type='static', user_id=user.id) + db_session.session.add(application) + db_session.session.commit() + + response = client.get( + f'/api/v1/bandwidth/apps/{application.id}', + headers={'X-API-Key': raw_key}, + ) + + assert response.status_code != 500, ( + 'can_access_app(None, app) raised on None.is_admin; ' + f'body={response.get_data(as_text=True)[:300]}') + assert response.status_code == 200 + + def test_admin_api_key_can_update_general_sso_settings(self, app, client, db_session): + """sso.py update_general_settings() reads `user.id` unconditionally.""" + from app.models import User + + _, raw_key = _api_key_for(db_session, User.ROLE_ADMIN, 'sso-admin') + + response = client.put( + '/api/v1/sso/admin/general', + json={'sso_auto_provision': True}, + headers={'X-API-Key': raw_key}, + ) + + assert response.status_code != 500, ( + 'the handler crashed on None.id for an API-key admin; ' + f'body={response.get_data(as_text=True)[:300]}') + assert response.status_code == 200 + + + def test_developer_api_key_can_create_a_server(self, app, client, db_session): + """servers.py create_server() read the JWT to stamp ownership.""" + from app.models import User + + user, raw_key = _api_key_for(db_session, User.ROLE_DEVELOPER, 'srv-create') + + response = client.post( + '/api/v1/servers', + json={'description': 'identity probe'}, + headers={'X-API-Key': raw_key}, + ) + + assert response.status_code != 500, ( + 'create_server crashed reading the JWT for an API-key caller; ' + f'body={response.get_data(as_text=True)[:300]}') + assert response.status_code in (200, 201) + + +class TestApiKeyActionsAreAttributedToTheKeyOwner: + """Silent misattribution: the write succeeds, the actor is lost.""" + + def test_sso_settings_update_records_the_key_owner_not_none(self, app, client, db_session): + from app.models import User + from app.models.audit_log import AuditLog + + user, raw_key = _api_key_for(db_session, User.ROLE_ADMIN, 'sso-audit') + + response = client.put( + '/api/v1/sso/admin/general', + json={'sso_auto_provision': True}, + headers={'X-API-Key': raw_key}, + ) + assert response.status_code == 200 + + entry = (AuditLog.query + .filter_by(action=AuditLog.ACTION_SETTINGS_UPDATE) + .order_by(AuditLog.id.desc()) + .first()) + assert entry is not None, 'the settings update wrote no audit entry' + assert entry.user_id == user.id, ( + f'audit entry attributed to user_id={entry.user_id!r}, expected the ' + f'API key owner {user.id}') + + def test_github_admin_config_update_records_the_key_owner(self, app, client, db_session, monkeypatch): + """source_connections.py used `user.id if user else None` — the write + lands, the actor silently becomes None.""" + from app.models import User + from app.services import source_connection_service + + user, raw_key = _api_key_for(db_session, User.ROLE_ADMIN, 'gh-admin') + + seen = {} + + def _record(payload, user_id=None): + seen['user_id'] = user_id + return {'ok': True} + + monkeypatch.setattr( + source_connection_service.SourceConnectionService, + 'update_github_config', staticmethod(_record)) + + response = client.put( + '/api/v1/source-connections/admin/github', + json={'client_id': 'abc'}, + headers={'X-API-Key': raw_key}, + ) + + assert response.status_code == 200 + assert seen.get('user_id') == user.id, ( + f'update attributed to user_id={seen.get("user_id")!r}, expected the ' + f'API key owner {user.id}') + + +def test_jwt_callers_are_unaffected(client, auth_headers, db_session): + """The migration must not regress the JWT path it shares.""" + response = client.put( + '/api/v1/sso/admin/general', + json={'sso_auto_provision': True}, + headers=auth_headers, + ) + assert response.status_code == 200 diff --git a/backend/tests/test_identity_door_ratchet.py b/backend/tests/test_identity_door_ratchet.py new file mode 100644 index 00000000..24c77e96 --- /dev/null +++ b/backend/tests/test_identity_door_ratchet.py @@ -0,0 +1,145 @@ +"""The identity-door ratchet (plan 76, milestone A). + +Plan 76's second-wave rule 1: every door gets a ratchet *before* its migration +starts, because a migration without one regrows. This is milestone A's. + +The count it holds is the number of places that answer "who is calling" by +reading the JWT directly instead of going through +``app/middleware/rbac.get_current_user()``. It is a test rather than a CI shell +step so it runs on a dev box before the commit, and the counting is AST-based — +a grep for ``get_jwt_identity`` also matches token issuance, an id comparison, +and a docstring, none of which are identity lookups. + +The remaining population is entirely on ``@jwt_required()``-only routes, where +reading the JWT is correct *today*. It is counted anyway because it is exactly +what breaks the moment such a route is moved to a policy decorator — which is +the rest of milestone A. The ceiling is what stops that migration from +converting a correct route into a 500. +""" + +from identity_door_census import census, count_file, read_ceiling, total + + +def test_the_count_is_at_or_below_the_ceiling(): + found = census() + count = total(found) + ceiling = read_ceiling() + worst = sorted(found, key=lambda p: -len(found[p]))[:5] + assert count <= ceiling, ( + f'{count} identity lookups bypass rbac.get_current_user(), ceiling is ' + f'{ceiling}.\n' + f'Resolve the caller with rbac.get_current_user() — it returns ' + f'g.api_key_user for X-API-Key requests, where get_jwt_identity() ' + f'raises.\n' + f'If a lookup genuinely must be JWT-only, add the file to EXEMPT in ' + f'tests/identity_door_census.py in the same commit and say why.\n' + f'Heaviest files: ' + ', '.join(f'{p} ({len(found[p])})' for p in worst) + ) + + +def test_the_ceiling_is_not_stale(): + """A ceiling above reality silently re-authorises what a migration removed.""" + count, ceiling = total(), read_ceiling() + assert ceiling - count <= 10, ( + f'ceiling {ceiling} is {ceiling - count} above the actual {count}; ' + f'run `python tests/identity_door_census.py --update`' + ) + + +def test_no_bypass_survives_behind_an_api_key_capable_decorator(): + """The live-bug half of the ratchet. + + A bypass under ``@jwt_required()`` is merely legacy. The same bypass under a + policy decorator is a 500 for every API-key caller, because + ``auth_required()`` skips ``verify_jwt_in_request()`` once the key + middleware has authenticated and ``get_jwt_identity()`` then raises. That + population is zero and must stay zero. + """ + import ast + + POLICY = {'admin_required', 'developer_required', 'viewer_required', + 'require_role', 'permission_required', 'require_app_member', + 'auth_required'} + + def _name(dec): + while isinstance(dec, ast.Call): + dec = dec.func + return getattr(dec, 'attr', getattr(dec, 'id', '')) + + offenders = [] + for rel, hits in census().items(): + tree = ast.parse(open(rel, encoding='utf-8').read()) + lines = {line for line, _ in hits} + for node in ast.walk(tree): + if not isinstance(node, ast.FunctionDef): + continue + spans = [n.lineno for n in ast.walk(node) if hasattr(n, 'lineno')] + inside = lines & set(range(node.lineno, max(spans or [node.lineno]) + 1)) + if not inside: + continue + policies = [_name(d) for d in node.decorator_list if _name(d) in POLICY] + if policies: + offenders.append(f'{rel}:{sorted(inside)[0]} {node.name}() ' + f'<- @{", @".join(policies)}') + + assert not offenders, ( + 'these handlers admit API-key callers but resolve identity from the ' + 'JWT, which raises for exactly those requests:\n ' + + '\n '.join(offenders) + ) + + +class TestTheCensusCountsTheRightThings: + """A miscounting ratchet is worse than none: it reads green either way.""" + + def _count(self, tmp_path, source): + path = tmp_path / 'sample.py' + path.write_text(source, encoding='utf-8') + return len(count_file(str(path))) + + def test_counts_the_direct_shape(self, tmp_path): + assert self._count(tmp_path, ( + 'user = User.query.get(get_jwt_identity())\n' + )) == 1 + + def test_counts_the_indirect_shape(self, tmp_path): + """The two-line variant is the same bug with a temporary name.""" + assert self._count(tmp_path, ( + 'def view():\n' + ' uid = get_jwt_identity()\n' + ' return User.query.get(uid)\n' + )) == 1 + + def test_counts_a_coerced_identity(self, tmp_path): + assert self._count(tmp_path, ( + 'def view():\n' + ' uid = get_jwt_identity()\n' + ' return User.query.get(int(uid))\n' + )) == 1 + + def test_does_not_count_the_door_itself(self, tmp_path): + """get_current_user() is the fix, not an instance of the problem.""" + assert self._count(tmp_path, ( + 'def view():\n' + ' return get_current_user()\n' + )) == 0 + + def test_does_not_count_a_lookup_by_an_unrelated_id(self, tmp_path): + """Loading some *other* user is not an identity lookup.""" + assert self._count(tmp_path, ( + 'def view(target_id):\n' + ' return User.query.get(target_id)\n' + )) == 0 + + def test_does_not_count_the_identity_used_as_a_value(self, tmp_path): + """Comparing or logging the id never touches the API-key path.""" + assert self._count(tmp_path, ( + 'def view(app):\n' + ' return app.user_id == get_jwt_identity()\n' + )) == 0 + + def test_does_not_count_a_lookup_on_another_model(self, tmp_path): + assert self._count(tmp_path, ( + 'def view():\n' + ' return Server.query.get(get_jwt_identity())\n' + )) == 0 diff --git a/builtin-extensions/serverkit-cloud-provision/backend/cloud_provisioning.py b/builtin-extensions/serverkit-cloud-provision/backend/cloud_provisioning.py index bc3029c6..6ffafa42 100644 --- a/builtin-extensions/serverkit-cloud-provision/backend/cloud_provisioning.py +++ b/builtin-extensions/serverkit-cloud-provision/backend/cloud_provisioning.py @@ -1,16 +1,11 @@ from flask import Blueprint, request, jsonify from flask_jwt_extended import jwt_required +from app.middleware.rbac import get_current_user from .cloud_provisioning_service import CloudProvisioningService cloud_provisioning_bp = Blueprint('cloud_provisioning', __name__) -def get_current_user(): - from flask_jwt_extended import get_jwt_identity - from app.models.user import User - return User.query.get(get_jwt_identity()) - - # --- Providers --- @cloud_provisioning_bp.route('/providers', methods=['GET']) diff --git a/builtin-extensions/serverkit-cloudflare-ops/backend/cloudflare.py b/builtin-extensions/serverkit-cloudflare-ops/backend/cloudflare.py index b2a579e4..f7ef9996 100644 --- a/builtin-extensions/serverkit-cloudflare-ops/backend/cloudflare.py +++ b/builtin-extensions/serverkit-cloudflare-ops/backend/cloudflare.py @@ -7,20 +7,14 @@ available to any authenticated user; writes require admin. Every mutating call i captured by the global audit fallback (method, route args, sanitized body). """ from flask import Blueprint, request, jsonify -from flask_jwt_extended import jwt_required, get_jwt_identity +from flask_jwt_extended import jwt_required +from app.middleware.rbac import require_admin_user from .cloudflare_service import CloudflareService, CloudflareError cloudflare_bp = Blueprint('cloudflare', __name__) -def _require_admin(): - """Return the current user if admin, else ``None``.""" - from app.models.user import User - user = User.query.get(get_jwt_identity()) - return user if (user and user.is_admin) else None - - def _service_response(res): """Map a service result dict to JSON. A failed *provider* call (reached Cloudflare, got an error back) is a 502; resolution errors are raised as @@ -45,8 +39,7 @@ def get_zone_settings(zone_id): @cloudflare_bp.route('/zones//settings/apply-preset', methods=['POST']) @jwt_required() def apply_preset(zone_id): - if not _require_admin(): - return jsonify({'error': 'Admin access required'}), 403 + require_admin_user() try: res = CloudflareService.apply_recommended(zone_id) except CloudflareError as e: @@ -69,8 +62,7 @@ def get_zone_setting(zone_id, setting_id): @cloudflare_bp.route('/zones//settings/', methods=['PATCH']) @jwt_required() def update_zone_setting(zone_id, setting_id): - if not _require_admin(): - return jsonify({'error': 'Admin access required'}), 403 + require_admin_user() data = request.get_json(silent=True) or {} if 'value' not in data: return jsonify({'error': 'A "value" field is required'}), 400 @@ -86,8 +78,7 @@ def update_zone_setting(zone_id, setting_id): @cloudflare_bp.route('/zones//purge-cache', methods=['POST']) @jwt_required() def purge_cache(zone_id): - if not _require_admin(): - return jsonify({'error': 'Admin access required'}), 403 + require_admin_user() data = request.get_json(silent=True) or {} try: res = CloudflareService.purge_cache( @@ -115,8 +106,7 @@ def list_waf_rules(zone_id): @cloudflare_bp.route('/zones//waf/rules', methods=['POST']) @jwt_required() def add_waf_rule(zone_id): - if not _require_admin(): - return jsonify({'error': 'Admin access required'}), 403 + require_admin_user() data = request.get_json(silent=True) or {} try: res = CloudflareService.add_waf_rule( @@ -133,8 +123,7 @@ def add_waf_rule(zone_id): @cloudflare_bp.route('/zones//waf/presets/', methods=['POST']) @jwt_required() def apply_waf_preset(zone_id, preset_key): - if not _require_admin(): - return jsonify({'error': 'Admin access required'}), 403 + require_admin_user() data = request.get_json(silent=True) or {} try: res = CloudflareService.apply_waf_preset(zone_id, preset_key, data.get('params') or {}) @@ -147,8 +136,7 @@ def apply_waf_preset(zone_id, preset_key): methods=['PATCH']) @jwt_required() def update_waf_rule(zone_id, ruleset_id, rule_id): - if not _require_admin(): - return jsonify({'error': 'Admin access required'}), 403 + require_admin_user() data = request.get_json(silent=True) or {} try: res = CloudflareService.update_waf_rule(zone_id, ruleset_id, rule_id, data) @@ -161,8 +149,7 @@ def update_waf_rule(zone_id, ruleset_id, rule_id): methods=['DELETE']) @jwt_required() def delete_waf_rule(zone_id, ruleset_id, rule_id): - if not _require_admin(): - return jsonify({'error': 'Admin access required'}), 403 + require_admin_user() try: res = CloudflareService.delete_waf_rule(zone_id, ruleset_id, rule_id) except CloudflareError as e: @@ -185,8 +172,7 @@ def list_workers(zone_id): @cloudflare_bp.route('/zones//workers', methods=['POST']) @jwt_required() def deploy_worker(zone_id): - if not _require_admin(): - return jsonify({'error': 'Admin access required'}), 403 + require_admin_user() data = request.get_json(silent=True) or {} try: res = CloudflareService.deploy_worker( @@ -203,8 +189,7 @@ def deploy_worker(zone_id): @cloudflare_bp.route('/zones//workers/routes', methods=['POST']) @jwt_required() def add_worker_route(zone_id): - if not _require_admin(): - return jsonify({'error': 'Admin access required'}), 403 + require_admin_user() data = request.get_json(silent=True) or {} try: res = CloudflareService.add_worker_route(zone_id, data.get('pattern'), data.get('script')) @@ -216,8 +201,7 @@ def add_worker_route(zone_id): @cloudflare_bp.route('/zones//workers/routes/', methods=['DELETE']) @jwt_required() def delete_worker_route(zone_id, route_id): - if not _require_admin(): - return jsonify({'error': 'Admin access required'}), 403 + require_admin_user() try: res = CloudflareService.delete_worker_route(zone_id, route_id) except CloudflareError as e: @@ -230,8 +214,7 @@ def delete_worker_route(zone_id, route_id): @cloudflare_bp.route('/zones//workers/', methods=['DELETE']) @jwt_required() def delete_worker(zone_id, name): - if not _require_admin(): - return jsonify({'error': 'Admin access required'}), 403 + require_admin_user() try: res = CloudflareService.delete_worker(zone_id, name) except CloudflareError as e: @@ -254,8 +237,7 @@ def list_tunnels(zone_id): @cloudflare_bp.route('/zones//tunnels', methods=['POST']) @jwt_required() def create_tunnel(zone_id): - if not _require_admin(): - return jsonify({'error': 'Admin access required'}), 403 + require_admin_user() data = request.get_json(silent=True) or {} try: res = CloudflareService.create_tunnel(zone_id, data.get('name')) @@ -267,8 +249,7 @@ def create_tunnel(zone_id): @cloudflare_bp.route('/zones//tunnels//install', methods=['GET']) @jwt_required() def tunnel_install(zone_id, tunnel_id): - if not _require_admin(): - return jsonify({'error': 'Admin access required'}), 403 + require_admin_user() try: res = CloudflareService.get_tunnel_install(zone_id, tunnel_id) except CloudflareError as e: @@ -289,8 +270,7 @@ def tunnel_hostnames(zone_id, tunnel_id): @cloudflare_bp.route('/zones//tunnels//hostnames', methods=['POST']) @jwt_required() def add_tunnel_hostname(zone_id, tunnel_id): - if not _require_admin(): - return jsonify({'error': 'Admin access required'}), 403 + require_admin_user() data = request.get_json(silent=True) or {} try: res = CloudflareService.add_tunnel_hostname( @@ -303,8 +283,7 @@ def add_tunnel_hostname(zone_id, tunnel_id): @cloudflare_bp.route('/zones//tunnels//hostnames', methods=['DELETE']) @jwt_required() def remove_tunnel_hostname(zone_id, tunnel_id): - if not _require_admin(): - return jsonify({'error': 'Admin access required'}), 403 + require_admin_user() data = request.get_json(silent=True) or {} try: res = CloudflareService.remove_tunnel_hostname(zone_id, tunnel_id, data.get('hostname')) @@ -316,8 +295,7 @@ def remove_tunnel_hostname(zone_id, tunnel_id): @cloudflare_bp.route('/zones//tunnels/', methods=['DELETE']) @jwt_required() def delete_tunnel(zone_id, tunnel_id): - if not _require_admin(): - return jsonify({'error': 'Admin access required'}), 403 + require_admin_user() try: res = CloudflareService.delete_tunnel(zone_id, tunnel_id) except CloudflareError as e: @@ -340,8 +318,7 @@ def list_storage(zone_id): @cloudflare_bp.route('/zones//storage/r2', methods=['POST']) @jwt_required() def create_r2_bucket(zone_id): - if not _require_admin(): - return jsonify({'error': 'Admin access required'}), 403 + require_admin_user() data = request.get_json(silent=True) or {} try: res = CloudflareService.create_r2_bucket(zone_id, data.get('name')) @@ -353,8 +330,7 @@ def create_r2_bucket(zone_id): @cloudflare_bp.route('/zones//storage/r2/', methods=['DELETE']) @jwt_required() def delete_r2_bucket(zone_id, bucket): - if not _require_admin(): - return jsonify({'error': 'Admin access required'}), 403 + require_admin_user() try: res = CloudflareService.delete_r2_bucket(zone_id, bucket) except CloudflareError as e: @@ -365,8 +341,7 @@ def delete_r2_bucket(zone_id, bucket): @cloudflare_bp.route('/zones//storage/kv', methods=['POST']) @jwt_required() def create_kv_namespace(zone_id): - if not _require_admin(): - return jsonify({'error': 'Admin access required'}), 403 + require_admin_user() data = request.get_json(silent=True) or {} try: res = CloudflareService.create_kv_namespace(zone_id, data.get('title')) @@ -378,8 +353,7 @@ def create_kv_namespace(zone_id): @cloudflare_bp.route('/zones//storage/kv/', methods=['DELETE']) @jwt_required() def delete_kv_namespace(zone_id, namespace_id): - if not _require_admin(): - return jsonify({'error': 'Admin access required'}), 403 + require_admin_user() try: res = CloudflareService.delete_kv_namespace(zone_id, namespace_id) except CloudflareError as e: @@ -390,8 +364,7 @@ def delete_kv_namespace(zone_id, namespace_id): @cloudflare_bp.route('/zones//storage/d1', methods=['POST']) @jwt_required() def create_d1_database(zone_id): - if not _require_admin(): - return jsonify({'error': 'Admin access required'}), 403 + require_admin_user() data = request.get_json(silent=True) or {} try: res = CloudflareService.create_d1_database(zone_id, data.get('name')) @@ -403,8 +376,7 @@ def create_d1_database(zone_id): @cloudflare_bp.route('/zones//storage/d1/', methods=['DELETE']) @jwt_required() def delete_d1_database(zone_id, database_id): - if not _require_admin(): - return jsonify({'error': 'Admin access required'}), 403 + require_admin_user() try: res = CloudflareService.delete_d1_database(zone_id, database_id) except CloudflareError as e: @@ -427,8 +399,7 @@ def get_dnssec(zone_id): @cloudflare_bp.route('/zones//dnssec', methods=['PATCH']) @jwt_required() def set_dnssec(zone_id): - if not _require_admin(): - return jsonify({'error': 'Admin access required'}), 403 + require_admin_user() data = request.get_json(silent=True) or {} if 'enabled' not in data: return jsonify({'error': 'An "enabled" boolean is required'}), 400 @@ -454,8 +425,7 @@ def list_origin_certificates(zone_id): @cloudflare_bp.route('/zones//origin-certificates', methods=['POST']) @jwt_required() def issue_origin_certificate(zone_id): - if not _require_admin(): - return jsonify({'error': 'Admin access required'}), 403 + require_admin_user() data = request.get_json(silent=True) or {} try: res = CloudflareService.issue_origin_certificate( @@ -474,8 +444,7 @@ def issue_origin_certificate(zone_id): methods=['DELETE']) @jwt_required() def revoke_origin_certificate(zone_id, certificate_id): - if not _require_admin(): - return jsonify({'error': 'Admin access required'}), 403 + require_admin_user() try: res = CloudflareService.revoke_origin_certificate(zone_id, certificate_id) except CloudflareError as e: @@ -498,8 +467,7 @@ def list_rules(zone_id, slug): @cloudflare_bp.route('/zones//rules/', methods=['POST']) @jwt_required() def add_rule(zone_id, slug): - if not _require_admin(): - return jsonify({'error': 'Admin access required'}), 403 + require_admin_user() data = request.get_json(silent=True) or {} try: res = CloudflareService.add_rule( @@ -517,8 +485,7 @@ def add_rule(zone_id, slug): @cloudflare_bp.route('/zones//rules//presets/', methods=['POST']) @jwt_required() def apply_rule_preset(zone_id, slug, preset_key): - if not _require_admin(): - return jsonify({'error': 'Admin access required'}), 403 + require_admin_user() data = request.get_json(silent=True) or {} try: res = CloudflareService.apply_rule_preset(zone_id, slug, preset_key, data.get('params') or {}) @@ -531,8 +498,7 @@ def apply_rule_preset(zone_id, slug, preset_key): methods=['PATCH']) @jwt_required() def update_rule(zone_id, slug, ruleset_id, rule_id): - if not _require_admin(): - return jsonify({'error': 'Admin access required'}), 403 + require_admin_user() data = request.get_json(silent=True) or {} try: res = CloudflareService.update_rule(zone_id, slug, ruleset_id, rule_id, data) @@ -545,8 +511,7 @@ def update_rule(zone_id, slug, ruleset_id, rule_id): methods=['DELETE']) @jwt_required() def delete_rule(zone_id, slug, ruleset_id, rule_id): - if not _require_admin(): - return jsonify({'error': 'Admin access required'}), 403 + require_admin_user() try: res = CloudflareService.delete_rule(zone_id, slug, ruleset_id, rule_id) except CloudflareError as e: diff --git a/builtin-extensions/serverkit-status/backend/status_pages.py b/builtin-extensions/serverkit-status/backend/status_pages.py index 491aecf1..a71540d0 100644 --- a/builtin-extensions/serverkit-status/backend/status_pages.py +++ b/builtin-extensions/serverkit-status/backend/status_pages.py @@ -1,16 +1,11 @@ from flask import Blueprint, request, jsonify from flask_jwt_extended import jwt_required +from app.middleware.rbac import get_current_user from .status_page_service import StatusPageService status_pages_bp = Blueprint('status_pages', __name__) -def get_current_user(): - from flask_jwt_extended import get_jwt_identity - from app.models.user import User - return User.query.get(get_jwt_identity()) - - # --- Public endpoints (no auth) --- @status_pages_bp.route('/public/', methods=['GET'])