mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-11-02 18:17:11 +09:00
Plan 76 milestone A, drift/bug row 3. Five API modules had grown a private _require_admin() with *three mutually incompatible* return contracts: jobs.py -> returns the user; None means DENIED dns_cutover.py -> returns None on success; a response tuple means denied waf.py -> same as dns_cutover manifests.py -> (user, None) on success, (None, response) on denial themes.py -> same as manifests jobs.py's is the exact inverse of dns_cutover.py's, so a guard copied between the two reads identically at the call site and fails open. Nothing in the code told you which contract you were looking at. Replace all five with rbac.require_admin_user(), which raises a typed PermissionDeniedError the global ApplicationError handler already renders. A gate that raises has no return contract to get backwards. Two convergences ride along, both toward the stricter shared behaviour: - manifests.py gated on `user.role != 'admin'` where the others used `is_admin`. These are the same predicate today (is_admin is defined as role == ROLE_ADMIN), so no route changes status, but there is now one truth source rather than two. - none of the five checked is_active, so a deactivated admin still passed them; @admin_required has always checked it. The shared door checks it, so these 25 call sites now reject deactivated admins like every other admin route. The unknown-user case keeps its 403 'Admin access required' rather than becoming a 401/404: a non-admin caller should not learn from the status code whether the account behind the token still exists. Gate: tests/test_jobs.py, test_themes.py, test_waf.py, test_dns_cutover_api.py (82 passed) and -k manifest (152 passed). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
114 lines
3.7 KiB
Python
114 lines
3.7 KiB
Python
# Bucket: PER-APP (plan 29 #9). Reads are scoped to callers who can access the
|
|
# app (can_access_app); mutations + install stay admin-only.
|
|
"""Per-application WAF (ModSecurity v3 + OWASP CRS) API.
|
|
|
|
Routes are mounted at ``/api/v1/waf``. Reads require access to the target app;
|
|
mutations and install require an admin user (mirrors ``app/api/dns_zones.py``).
|
|
Service ``ValueError``s map to HTTP 400.
|
|
"""
|
|
from flask import Blueprint, request, jsonify
|
|
from flask_jwt_extended import jwt_required
|
|
|
|
from app.models.application import Application
|
|
from app.services.waf_service import WafService
|
|
from app.services.resource_grant_service import ResourceGrantService
|
|
from app.middleware.rbac import get_current_user, require_admin_user
|
|
|
|
waf_bp = Blueprint('waf', __name__)
|
|
|
|
|
|
def _get_application_or_404(app_id):
|
|
"""Resolve the app and enforce read access (plan 29 #9): owner / admin / grant /
|
|
workspace member. A caller who can't access it gets the same 404 as a missing
|
|
app (sealed-from-open, no existence leak)."""
|
|
application = Application.query_active().filter_by(id=app_id).first()
|
|
if not application:
|
|
return None, (jsonify({'error': 'Application not found'}), 404)
|
|
if not ResourceGrantService.can_access_app(get_current_user(), application):
|
|
return None, (jsonify({'error': 'Application not found'}), 404)
|
|
return application, None
|
|
|
|
|
|
@waf_bp.route('/applications/<int:app_id>/policy', methods=['GET'])
|
|
@jwt_required()
|
|
def get_policy(app_id):
|
|
_, err = _get_application_or_404(app_id)
|
|
if err:
|
|
return err
|
|
policy = WafService.get_or_create_policy(app_id)
|
|
return jsonify(policy.to_dict())
|
|
|
|
|
|
@waf_bp.route('/applications/<int:app_id>/policy', methods=['PUT'])
|
|
@jwt_required()
|
|
def update_policy(app_id):
|
|
require_admin_user()
|
|
_, err = _get_application_or_404(app_id)
|
|
if err:
|
|
return err
|
|
|
|
data = request.get_json() or {}
|
|
try:
|
|
policy = WafService.set_policy(
|
|
app_id,
|
|
mode=data.get('mode'),
|
|
paranoia_level=data.get('paranoia_level'),
|
|
anomaly_threshold=data.get('anomaly_threshold'),
|
|
disabled_rule_ids=data.get('disabled_rule_ids'),
|
|
)
|
|
except ValueError as e:
|
|
return jsonify({'error': str(e)}), 400
|
|
|
|
# Best-effort enforcement: never fail the policy write because nginx /
|
|
# ModSecurity isn't present on the host.
|
|
try:
|
|
apply_result = WafService.apply(app_id)
|
|
except Exception as e: # pragma: no cover - defensive
|
|
apply_result = {'success': False, 'error': str(e)}
|
|
|
|
response = policy.to_dict()
|
|
response['apply'] = apply_result
|
|
return jsonify(response)
|
|
|
|
|
|
@waf_bp.route('/applications/<int:app_id>/apply', methods=['POST'])
|
|
@jwt_required()
|
|
def apply_policy(app_id):
|
|
require_admin_user()
|
|
_, err = _get_application_or_404(app_id)
|
|
if err:
|
|
return err
|
|
result = WafService.apply(app_id)
|
|
status = 200 if result.get('success') else 400
|
|
return jsonify(result), status
|
|
|
|
|
|
@waf_bp.route('/applications/<int:app_id>/events', methods=['GET'])
|
|
@jwt_required()
|
|
def get_events(app_id):
|
|
_, err = _get_application_or_404(app_id)
|
|
if err:
|
|
return err
|
|
try:
|
|
limit = int(request.args.get('limit', 50))
|
|
except (TypeError, ValueError):
|
|
limit = 50
|
|
limit = max(1, min(500, limit))
|
|
events = WafService.events(app_id, limit=limit)
|
|
return jsonify({'events': events, 'count': len(events)})
|
|
|
|
|
|
@waf_bp.route('/status', methods=['GET'])
|
|
@jwt_required()
|
|
def status():
|
|
return jsonify({'installed': WafService.modsecurity_installed()})
|
|
|
|
|
|
@waf_bp.route('/install', methods=['POST'])
|
|
@jwt_required()
|
|
def install():
|
|
require_admin_user()
|
|
result = WafService.install_modsecurity()
|
|
status_code = 200 if result.get('success') else 400
|
|
return jsonify(result), status_code
|