mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-23 03:27:43 +09:00
Comments, docstrings and a few strings cited plan numbers ("plan 29
#9", "plan 86 §B2") from a git-ignored folder, so they pointed at
nothing; one reached users in a bottleneck hint. Code and frontend
strings are unchanged apart from those citations (checked by AST and
token comparison). A vhost still carrying the old asset comment reads
as undrifted, and a guard test keeps new citations out.
247 lines
8.5 KiB
Python
247 lines
8.5 KiB
Python
"""Generic restore-point lifecycle API.
|
|
|
|
Application-owned scopes use the existing ResourceGrant seam. Host-level
|
|
surfaces are operational and require a developer role even when their local
|
|
``server_id`` is NULL. Persistence and adapter work stay in the service layer.
|
|
"""
|
|
|
|
from flask import Blueprint, jsonify, request
|
|
|
|
from app.exceptions import PermissionDeniedError, ValidationError
|
|
from app.middleware.rbac import auth_required, developer_required, get_current_user
|
|
from app.services import restore_point_service
|
|
from app.services.resource_grant_service import ResourceGrantService
|
|
|
|
|
|
restore_points_bp = Blueprint('restore_points', __name__)
|
|
|
|
_APP_SCOPE_TYPES = frozenset({'application', 'env'})
|
|
_LIST_QUERY_KEYS = frozenset({'scope_type', 'scope_id', 'server_id', 'limit'})
|
|
_CREATE_BODY_KEYS = frozenset({'scope_type', 'scope_id', 'label'})
|
|
|
|
|
|
def _require_active_user(user):
|
|
if user is None:
|
|
raise PermissionDeniedError('Authenticated user not found')
|
|
if not user.is_active:
|
|
raise PermissionDeniedError('Account is deactivated')
|
|
|
|
|
|
def _require_developer(user):
|
|
_require_active_user(user)
|
|
if user is None or not user.is_developer:
|
|
raise PermissionDeniedError(
|
|
'Developer access required for operational restore points',
|
|
)
|
|
|
|
|
|
def _authorize_scope(user, scope_type, scope_id, *, write=False):
|
|
"""Authorize one scope and return its Application when app-owned."""
|
|
_require_active_user(user)
|
|
if scope_type in _APP_SCOPE_TYPES:
|
|
application = restore_point_service.resolve_application_scope(
|
|
scope_type, scope_id,
|
|
)
|
|
allowed = (
|
|
ResourceGrantService.can_edit_app(user, application)
|
|
if write else
|
|
ResourceGrantService.can_access_app(user, application)
|
|
)
|
|
if not allowed:
|
|
raise PermissionDeniedError("You don't have access to this service")
|
|
return application
|
|
|
|
_require_developer(user)
|
|
return None
|
|
|
|
|
|
def _authorize_point(point, *, write=False):
|
|
_authorize_scope(
|
|
get_current_user(), point.scope_type, point.scope_id, write=write,
|
|
)
|
|
return point
|
|
|
|
|
|
def _point_for_caller(point_id, *, write=False):
|
|
point = restore_point_service.get(point_id)
|
|
return _authorize_point(point, write=write)
|
|
|
|
|
|
def _text_field(value, name, *, required=False, max_length=None):
|
|
if value is None:
|
|
if required:
|
|
raise ValidationError(f'{name} is required')
|
|
return None
|
|
if not isinstance(value, str):
|
|
raise ValidationError(f'{name} must be a string')
|
|
value = value.strip()
|
|
if required and not value:
|
|
raise ValidationError(f'{name} is required')
|
|
if max_length is not None and len(value) > max_length:
|
|
raise ValidationError(
|
|
f'{name} must not exceed {max_length} characters',
|
|
)
|
|
return value or None
|
|
|
|
|
|
def _limit_arg():
|
|
raw = request.args.get('limit', '50')
|
|
try:
|
|
value = int(raw)
|
|
except (TypeError, ValueError) as exc:
|
|
raise ValidationError('limit must be an integer') from exc
|
|
if value < 1 or value > 200:
|
|
raise ValidationError('limit must be between 1 and 200')
|
|
return value
|
|
|
|
|
|
def _serialize(point, *, include_payload):
|
|
data = point.to_dict()
|
|
if not include_payload:
|
|
data.pop('payload', None)
|
|
return data
|
|
|
|
|
|
@restore_points_bp.route('', methods=['GET'])
|
|
@auth_required()
|
|
def list_restore_points():
|
|
"""List a scope's linear restore-point timeline, newest first."""
|
|
unknown = set(request.args) - _LIST_QUERY_KEYS
|
|
if unknown:
|
|
raise ValidationError(
|
|
'Unknown query parameters',
|
|
details={'fields': sorted(unknown)},
|
|
)
|
|
|
|
scope_type = _text_field(
|
|
request.args.get('scope_type'), 'scope_type', max_length=32,
|
|
)
|
|
scope_id = _text_field(
|
|
request.args.get('scope_id'), 'scope_id', max_length=255,
|
|
)
|
|
server_id = _text_field(
|
|
request.args.get('server_id'), 'server_id', max_length=36,
|
|
)
|
|
if scope_id is not None and scope_type is None:
|
|
raise ValidationError('scope_type is required when scope_id is set')
|
|
user = get_current_user()
|
|
_require_active_user(user)
|
|
if scope_type is not None:
|
|
if scope_type in _APP_SCOPE_TYPES and scope_id is not None:
|
|
_authorize_scope(user, scope_type, scope_id)
|
|
elif scope_type not in _APP_SCOPE_TYPES:
|
|
_require_developer(user)
|
|
|
|
if server_id is not None:
|
|
_require_developer(user)
|
|
restore_point_service.resolve_server(server_id)
|
|
|
|
kwargs = {
|
|
'scope_type': scope_type,
|
|
'scope_id': scope_id,
|
|
'limit': _limit_arg(),
|
|
'allowed_application_scope_ids': (
|
|
restore_point_service.accessible_application_scope_ids(user)
|
|
),
|
|
'include_operational': bool(user.is_developer),
|
|
}
|
|
if server_id is not None:
|
|
kwargs['server_id'] = server_id
|
|
points = restore_point_service.list_points(**kwargs)
|
|
return jsonify({
|
|
'restore_points': [
|
|
_serialize(point, include_payload=False) for point in points
|
|
],
|
|
}), 200
|
|
|
|
|
|
@restore_points_bp.route('', methods=['POST'])
|
|
@developer_required
|
|
def create_restore_point():
|
|
"""Create a retained, manually labelled quicksave for one local scope."""
|
|
body = request.get_json(silent=True)
|
|
if not isinstance(body, dict):
|
|
raise ValidationError('Request body must be a JSON object')
|
|
unknown = set(body) - _CREATE_BODY_KEYS
|
|
if unknown:
|
|
raise ValidationError(
|
|
'Unknown request fields', details={'fields': sorted(unknown)},
|
|
)
|
|
|
|
scope_type = _text_field(
|
|
body.get('scope_type'), 'scope_type', required=True, max_length=32,
|
|
)
|
|
scope_id = _text_field(
|
|
body.get('scope_id'), 'scope_id', required=True, max_length=255,
|
|
)
|
|
label = _text_field(body.get('label'), 'label', max_length=255)
|
|
user = get_current_user()
|
|
application = _authorize_scope(user, scope_type, scope_id, write=True)
|
|
|
|
# Environment state is application-owned. Its server association is
|
|
# derived from that trusted row; callers cannot stamp arbitrary servers.
|
|
server_id = application.server_id if scope_type == 'env' else None
|
|
point = restore_point_service.capture_manual(
|
|
scope_type, scope_id, label=label, actor=user, server_id=server_id,
|
|
)
|
|
return jsonify({
|
|
'restore_point': _serialize(point, include_payload=True),
|
|
}), 201
|
|
|
|
|
|
@restore_points_bp.route('/<point_id>', methods=['GET'])
|
|
@auth_required()
|
|
def get_restore_point(point_id):
|
|
"""Fetch a restore point including its already-secret-safe payload."""
|
|
point = _point_for_caller(point_id)
|
|
return jsonify({
|
|
'restore_point': _serialize(point, include_payload=True),
|
|
}), 200
|
|
|
|
|
|
@restore_points_bp.route('/<point_id>/diff', methods=['GET'])
|
|
@auth_required()
|
|
def diff_restore_point(point_id):
|
|
"""Diff against the previous point or an explicit same-scope point."""
|
|
unknown = set(request.args) - {'against'}
|
|
if unknown:
|
|
raise ValidationError(
|
|
'Unknown query parameters',
|
|
details={'fields': sorted(unknown)},
|
|
)
|
|
_point_for_caller(point_id)
|
|
against = request.args.get('against', 'previous')
|
|
if against not in ('', 'previous'):
|
|
# Load and authorize before the service compares scopes, so a guessed
|
|
# point id can never become an authorization bypass.
|
|
_point_for_caller(against)
|
|
return jsonify(restore_point_service.diff(point_id, against=against)), 200
|
|
|
|
|
|
@restore_points_bp.route('/<point_id>/preview', methods=['POST'])
|
|
@developer_required
|
|
def preview_restore_point(point_id):
|
|
"""Dry-run a restore with coverage and refusal information."""
|
|
point = _point_for_caller(point_id, write=True)
|
|
return jsonify(restore_point_service.preview(
|
|
point.id, actor=get_current_user(),
|
|
)), 200
|
|
|
|
|
|
@restore_points_bp.route('/<point_id>/restore', methods=['POST'])
|
|
@developer_required
|
|
def restore_restore_point(point_id):
|
|
"""Re-converge a scope through its adapter's normal mutation doors."""
|
|
point = _point_for_caller(point_id, write=True)
|
|
result = restore_point_service.restore(
|
|
point.id, actor=get_current_user(),
|
|
)
|
|
if isinstance(result, dict) and result.get('success') is False:
|
|
message = result.get('error') or 'Restore adapter reported failure'
|
|
if result.get('refused'):
|
|
raise restore_point_service.RestorePointRefusedError([message])
|
|
raise restore_point_service.RestorePointAdapterError(
|
|
message, details=result,
|
|
)
|
|
return jsonify(result or {'success': True}), 200
|