mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-11-12 15:03:55 +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>
184 lines
7.1 KiB
Python
184 lines
7.1 KiB
Python
"""Declarative serverkit.yaml manifest API.
|
|
|
|
Phase 0: `scaffold` (render a v1 manifest from a live app).
|
|
Later phases add `get`, `plan` and `apply`. Admin-gated; applies are
|
|
audit-logged.
|
|
"""
|
|
|
|
from flask import Blueprint, jsonify, request, Response
|
|
from flask_jwt_extended import jwt_required
|
|
|
|
from app import db
|
|
from app.models.application import Application
|
|
from app.models.application_manifest import ApplicationManifest, STATUS_PENDING
|
|
from app.models.project import Project
|
|
from app.services.manifest_scaffold_service import ManifestScaffoldService
|
|
from app.services.manifest_spec_service import ManifestSpecService, ManifestError
|
|
from app.services.manifest_apply_service import ManifestApplyService
|
|
from app.services.manifest_persistence_service import ManifestPersistenceService
|
|
from app.middleware.rbac import require_admin_user
|
|
|
|
manifests_bp = Blueprint('manifests', __name__)
|
|
|
|
|
|
def _load_normalized(data):
|
|
"""Normalize from an inline body, or fall back to the project's stored row.
|
|
Returns (normalized, raw_text, error_response_or_None)."""
|
|
if 'content' in data:
|
|
try:
|
|
return ManifestSpecService.normalize_text(data['content']), data['content'], None
|
|
except ManifestError as exc:
|
|
return None, None, (jsonify({'error': 'Invalid manifest', 'errors': exc.errors}), 400)
|
|
if 'manifest' in data:
|
|
try:
|
|
return ManifestSpecService.normalize(data['manifest']), None, None
|
|
except ManifestError as exc:
|
|
return None, None, (jsonify({'error': 'Invalid manifest', 'errors': exc.errors}), 400)
|
|
# fall back to the stored manifest for the project
|
|
project_id = data.get('project_id')
|
|
row = ApplicationManifest.query.filter_by(project_id=project_id).first() if project_id else None
|
|
if row and row.get_normalized():
|
|
return row.get_normalized(), row.raw_text, None
|
|
return None, None, (jsonify({'error': 'Provide `content`/`manifest`, or store one first'}), 400)
|
|
|
|
|
|
@manifests_bp.route('/scaffold', methods=['GET'])
|
|
@jwt_required()
|
|
def scaffold_manifest():
|
|
"""GET /api/v1/manifests/scaffold?app_id=&format=json|yaml"""
|
|
user = require_admin_user()
|
|
|
|
app_id = request.args.get('app_id', type=int)
|
|
if not app_id:
|
|
return jsonify({'error': 'app_id is required'}), 400
|
|
|
|
app = Application.query_active().filter_by(id=app_id).first()
|
|
if not app:
|
|
return jsonify({'error': 'Application not found'}), 404
|
|
|
|
fmt = (request.args.get('format') or 'json').lower()
|
|
if fmt == 'yaml':
|
|
yaml_text = ManifestScaffoldService.scaffold_yaml(app)
|
|
return Response(yaml_text, mimetype='text/yaml')
|
|
|
|
manifest = ManifestScaffoldService.scaffold_for_app(app)
|
|
return jsonify({'manifest': manifest,
|
|
'yaml': ManifestScaffoldService.scaffold_yaml(app)}), 200
|
|
|
|
|
|
@manifests_bp.route('/validate', methods=['POST'])
|
|
@jwt_required()
|
|
def validate_manifest():
|
|
"""POST /api/v1/manifests/validate { content | manifest } -> normalized summary."""
|
|
user = require_admin_user()
|
|
|
|
data = request.get_json(silent=True) or {}
|
|
try:
|
|
if 'content' in data:
|
|
normalized = ManifestSpecService.normalize_text(data['content'])
|
|
elif 'manifest' in data:
|
|
normalized = ManifestSpecService.normalize(data['manifest'])
|
|
else:
|
|
return jsonify({'error': 'Provide `content` or `manifest`'}), 400
|
|
except ManifestError as exc:
|
|
return jsonify({'valid': False, 'errors': exc.errors}), 200
|
|
|
|
return jsonify({
|
|
'valid': True,
|
|
'summary': ManifestSpecService.summarize(normalized),
|
|
'normalized': normalized,
|
|
}), 200
|
|
|
|
|
|
@manifests_bp.route('', methods=['GET'])
|
|
@jwt_required()
|
|
def get_manifest():
|
|
"""GET /api/v1/manifests?project_id= -> the stored manifest for a project."""
|
|
user = require_admin_user()
|
|
project_id = request.args.get('project_id', type=int)
|
|
if not project_id:
|
|
return jsonify({'error': 'project_id is required'}), 400
|
|
row = ApplicationManifest.query.filter_by(project_id=project_id).first()
|
|
if not row:
|
|
return jsonify({'manifest': None}), 200
|
|
return jsonify({'manifest': row.to_dict(include_raw=True)}), 200
|
|
|
|
|
|
@manifests_bp.route('/plan', methods=['POST'])
|
|
@jwt_required()
|
|
def plan_manifest():
|
|
"""POST /api/v1/manifests/plan {project_id, content|manifest?} -> dry-run plan."""
|
|
user = require_admin_user()
|
|
data = request.get_json(silent=True) or {}
|
|
project = Project.query.get(data.get('project_id')) if data.get('project_id') else None
|
|
if not project:
|
|
return jsonify({'error': 'A valid project_id is required'}), 400
|
|
|
|
normalized, _raw, err_resp = _load_normalized(data)
|
|
if err_resp:
|
|
return err_resp
|
|
|
|
plan = ManifestApplyService.plan(project, normalized)
|
|
return jsonify({'plan': plan}), 200
|
|
|
|
|
|
@manifests_bp.route('/apply', methods=['POST'])
|
|
@jwt_required()
|
|
def apply_manifest():
|
|
"""POST /api/v1/manifests/apply {project_id, content|manifest?} -> apply."""
|
|
user = require_admin_user()
|
|
data = request.get_json(silent=True) or {}
|
|
project = Project.query.get(data.get('project_id')) if data.get('project_id') else None
|
|
if not project:
|
|
return jsonify({'error': 'A valid project_id is required'}), 400
|
|
|
|
normalized, raw, err_resp = _load_normalized(data)
|
|
if err_resp:
|
|
return err_resp
|
|
|
|
# persist/refresh the manifest row (pending) before applying
|
|
row = ManifestPersistenceService.store_manifest(
|
|
project_id=project.id, normalized=normalized, raw_text=raw, status=STATUS_PENDING)
|
|
db.session.commit()
|
|
|
|
result = ManifestApplyService.apply(project, normalized, user_id=user.id,
|
|
manifest_row=row)
|
|
|
|
# Appliance-tier blockers (plan 35): apply refused, nothing executed.
|
|
if result.get('refused'):
|
|
return jsonify(result), 400
|
|
|
|
try:
|
|
from app.services.audit_service import AuditService
|
|
AuditService.log('manifest.apply', user_id=user.id, target_type='project',
|
|
target_id=project.id,
|
|
details={'success': result['success'], 'applied': result.get('applied'),
|
|
'job_id': result.get('job_id')})
|
|
except Exception:
|
|
pass
|
|
|
|
status = 200 if result['success'] else 207
|
|
return jsonify(result), status
|
|
|
|
|
|
@manifests_bp.route('/bootstrap/reset', methods=['POST'])
|
|
@jwt_required()
|
|
def reset_bootstrap():
|
|
"""POST /api/v1/manifests/bootstrap/reset {app_id, confirm}
|
|
|
|
Re-arm a one-shot appliance bootstrap so the next apply runs it again.
|
|
Destructive-ish (the bootstrap may regenerate config), so it requires
|
|
typing the app name in `confirm`.
|
|
"""
|
|
user = require_admin_user()
|
|
data = request.get_json(silent=True) or {}
|
|
app = (Application.query_active().filter_by(id=data.get('app_id')).first()
|
|
if data.get('app_id') else None)
|
|
if not app:
|
|
return jsonify({'error': 'Application not found'}), 404
|
|
if (data.get('confirm') or '') != app.name:
|
|
return jsonify({'error': f'Type the app name "{app.name}" to confirm'}), 400
|
|
app.bootstrap_done = False
|
|
db.session.commit()
|
|
return jsonify({'success': True, 'app_id': app.id, 'bootstrap_done': False}), 200
|