Files
ServerKit/backend/app/api/manifests.py
T
Juan DenisandClaude Opus 5 6cd2ee6b95 refactor(auth): one mid-route admin gate instead of three contracts
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>
2026-08-18 18:22:01 -04:00

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