fix(auth): resolve the caller through one identity door, not the raw JWT

Plan 76 milestone A, drift/bug row 2 — scheduled as a fix, not a refactor,
because it is a behaviour difference shipping today.

rbac.get_current_user() is the only lookup that knows about both auth
mechanisms: it returns g.api_key_user for an X-API-Key request and falls back
to the JWT. 73 call sites answered "who is calling" with
`User.query.get(get_jwt_identity())` instead, behind 19 near-identical private
helpers (12 named get_current_user(), shadowing the real door).

The audit that scheduled this row predicted those return None for API-key
callers. Measured, it is worse: auth_required() deliberately skips
verify_jwt_in_request() once the key middleware has authenticated, so there is
no JWT context and get_jwt_identity() *raises*. Nine handlers reached through
an API-key-capable policy decorator therefore returned 500 to every API-key
caller:

  bandwidth.get_app_bandwidth          @viewer_required
  servers.set_server_workspace         @developer_required
  servers.create_server                @developer_required
  sso.get_sso_config / update_provider_config / update_general_settings
  source_connections.update_{github,gitlab,bitbucket}_admin_config
  source_connections.complete_github_app_manifest
  databases.execute_{mysql,pg,sqlite,docker}_query   (see below)

Three service helpers had the same lookup inside a blanket `except Exception`,
which converted the raise into a silent user_id=None — the write landed and
the audit trail lost the actor (shared_resource_service, cf_ops_change_service,
secrets_webhooks).

The four databases.execute_*_query sites were a second admin check *inside* an
already-@admin_required route: dead code that always passed for JWT callers and
crashed for API-key ones. They now call require_admin_user() rather than
re-deriving the answer.

Also folds in a sixth _require_admin() found in the cloudflare-ops extension
source (29 gates, the same inverted contract as jobs.py), and fixes
serverkit-gui's blueprint at its sibling-repo source rather than in this repo's
live copy.

Ratchet (second-wave rule 1 — a migration without one regrows):
tests/identity_door_census.py counts both bypass shapes by AST, direct and the
two-line `uid = get_jwt_identity(); User.query.get(uid)` variant. The ceiling is
87, all of them on @jwt_required()-only routes where reading the JWT is correct
today; the ratchet's third assertion holds the API-key-capable population at
exactly zero, so migrating one of those routes to a policy decorator without
fixing its lookup now fails. Verified non-vacuous: an injected bypass fails both
assertions and passes again on revert.

The first wave's controller-boundary baseline is regenerated — this removes 74
persistence crossings (593 -> 519), a debt reduction with no new entries.

Gate: tests/test_api_key_identity.py (7, each red before the fix),
test_identity_door_ratchet.py (10), test_api_controller_boundaries.py (4), plus
the 65 test files covering every touched module: 1024 passed, 10 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Juan DenisandClaude Opus 5 committed 2026-08-18 19:33:57 -04:00
1 parent 6cd2ee6b95
commit fe02ce8eeb
41 files changed
+694 -452

No files matched your search

+1 -6
View File
@@ -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():
+1 -6
View File
@@ -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():
+4 -3
View File
@@ -5,18 +5,19 @@ volumes for an application. Mounted under ``/api/v1/apps`` alongside the main
apps blueprint (routes are ``/<app_id>/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)
+24 -23
View File
@@ -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('/<int:app_id>', 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('/<int:app_id>/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('/<int:app_id>/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('/<int:app_id>/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('/<int:app_id>/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('/<int:app_id>/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('/<int:app_id>/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('/<int:app_id>/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('/<int:app_id>/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)
+2 -2
View File
@@ -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:
+3 -3
View File
@@ -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()
+7 -22
View File
@@ -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'))
+3 -3
View File
@@ -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
+4 -4
View File
@@ -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
+4 -3
View File
@@ -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)
+1 -6
View File
@@ -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():
+6 -10
View File
@@ -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('/<int:environment_id>', 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')
+4 -4
View File
@@ -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)
+1 -6
View File
@@ -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():
+5 -4
View File
@@ -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
+2 -7
View File
@@ -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('/<name>', 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
+1 -6
View File
@@ -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/<domain>', methods=['GET'])
@jwt_required()
def get_proxy_rules(domain):
+1 -6
View File
@@ -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():
+1 -6
View File
@@ -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()
+4 -3
View File
@@ -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/<app_id>/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:
+7 -11
View File
@@ -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('/<int:project_id>', 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
+4 -13
View File
@@ -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'
+4 -8
View File
@@ -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/<int:cid>', 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):
+3 -3
View File
@@ -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')
+9 -11
View File
@@ -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')
+1 -6
View File
@@ -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():
+8 -6
View File
@@ -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
+5 -6
View File
@@ -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,
+5 -5
View File
@@ -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']
+1 -7
View File
@@ -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():
@@ -6,12 +6,12 @@ which checks this plugin declared ``agent.command:<action>`` 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
@@ -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
@@ -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
+1
View File
@@ -0,0 +1 @@
87
@@ -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"
]
}
+175
View File
@@ -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 ``<UserModel>.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')
+190
View File
@@ -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
+145
View File
@@ -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
@@ -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'])
@@ -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/<int:zone_id>/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/<int:zone_id>/settings/<setting_id>', 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/<int:zone_id>/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/<int:zone_id>/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/<int:zone_id>/waf/presets/<preset_key>', 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/<int:zone_id>/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/<int:zone_id>/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/<int:zone_id>/workers/routes/<route_id>', 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/<int:zone_id>/workers/<name>', 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/<int:zone_id>/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/<int:zone_id>/tunnels/<tunnel_id>/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/<int:zone_id>/tunnels/<tunnel_id>/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/<int:zone_id>/tunnels/<tunnel_id>/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/<int:zone_id>/tunnels/<tunnel_id>', 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/<int:zone_id>/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/<int:zone_id>/storage/r2/<bucket>', 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/<int:zone_id>/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/<int:zone_id>/storage/kv/<namespace_id>', 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/<int:zone_id>/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/<int:zone_id>/storage/d1/<database_id>', 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/<int:zone_id>/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/<int:zone_id>/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/<int:zone_id>/rules/<slug>', 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/<int:zone_id>/rules/<slug>/presets/<preset_key>', 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:
@@ -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/<slug>', methods=['GET'])