mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-28 07:37:56 +09:00
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:
1 parent
6cd2ee6b95
commit
fe02ce8eeb
41 files changed
+694
-452
No files matched your search
@@ -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():
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'))
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,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():
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,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):
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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']
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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
|
||||
@@ -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'])
|
||||
|
||||
Reference in new issue
Block a user