fix: harden Polygon operation validation
This commit is contained in:
@@ -80,6 +80,9 @@ def create_instance():
|
||||
service_id = body.get("serviceId")
|
||||
display_name = body.get("displayName", "unnamed")
|
||||
|
||||
if not isinstance(display_name, str):
|
||||
return jsonify({"error": "displayName must be a string"}), 400
|
||||
|
||||
# Валидация: serviceId обязателен
|
||||
if service_id is None:
|
||||
return jsonify({"error": "serviceId is required"}), 400
|
||||
|
||||
@@ -9,6 +9,7 @@ Blueprint "mock" с префиксом /api/v1/svc/_mock.
|
||||
"""
|
||||
|
||||
import os
|
||||
import math
|
||||
|
||||
from flask import Blueprint, g, jsonify, request, abort
|
||||
from werkzeug.local import LocalProxy
|
||||
@@ -21,9 +22,7 @@ SERVICES = LocalProxy(lambda: _cfg.get_services(g.stand_id))
|
||||
|
||||
bp = Blueprint("mock", __name__, url_prefix="/api/v1/svc/_mock")
|
||||
|
||||
# Токен для защиты _mock/* эндпоинтов (из env, опционально).
|
||||
# Дублирует глобальную проверку в app.py, но оставлен для обратной совместимости
|
||||
# и ясности — _mock/* исторически имели отдельную защиту.
|
||||
# Токен для защиты _mock/* эндпоинтов (опционален для локального dev-режима).
|
||||
_MOCK_TOKEN = os.getenv("MOCK_AUTH_TOKEN", "")
|
||||
|
||||
|
||||
@@ -35,8 +34,8 @@ def _check_mock_auth():
|
||||
Если токен не задан в env — пропускаем всех (dev-режим).
|
||||
Если задан — требуем совпадения с заголовком.
|
||||
|
||||
Примечание: app.before_request уже проверяет Authorization: Bearer;
|
||||
этот хук — дополнительная защита для _mock/* эндпоинтов."""
|
||||
Основные API-маршруты не используют эту проверку. Если токен не настроен,
|
||||
локальные тесты могут вызывать служебные endpoints без заголовка."""
|
||||
if _MOCK_TOKEN and request.headers.get("X-Mock-Auth") != _MOCK_TOKEN:
|
||||
abort(403)
|
||||
|
||||
@@ -130,6 +129,8 @@ def mock_set_delay(seconds):
|
||||
val = float(seconds)
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({"error": f"invalid delay: {seconds}"}), 400
|
||||
if not math.isfinite(val):
|
||||
return jsonify({"error": f"delay must be a finite number: {seconds}"}), 400
|
||||
if val < 0:
|
||||
return jsonify({"error": "delay must be >= 0"}), 400
|
||||
if val > _MAX_DELAY:
|
||||
|
||||
+23
-26
@@ -49,11 +49,16 @@ def _build_spec():
|
||||
"""Собрать OpenAPI 3.1.0 спецификацию.
|
||||
|
||||
Включает:
|
||||
- Все 17 эндпоинтов (REST API + _mock)
|
||||
- Bearer-авторизацию
|
||||
- REST API и служебные _mock endpoints
|
||||
- Условную авторизацию _mock/* через X-Mock-Auth
|
||||
- Схемы: Service, Instance, Operation, CfsParam, Stage
|
||||
- Примеры запросов и ответов
|
||||
"""
|
||||
stand_descriptions = "\n".join(
|
||||
f"- **{stand_id}** — {len(stand['SERVICES'])} сервисов"
|
||||
for stand_id, stand in _cfg.STANDS.items()
|
||||
)
|
||||
|
||||
return {
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
@@ -65,10 +70,8 @@ def _build_spec():
|
||||
"операции, параметры, валидацию — но работает **без реальной инфраструктуры**, "
|
||||
"в памяти, с мгновенным откликом.\n\n"
|
||||
"### Стенды\n"
|
||||
"**3 изолированных стенда** — переключайте в выпадающем списке серверов выше:\n"
|
||||
"- **dev** — 37 сервисов (разработка)\n"
|
||||
"- **test** — 37 сервисов (тестирование)\n"
|
||||
"- **prod** — 35 сервисов (продуктив)\n"
|
||||
f"**{len(_cfg.STANDS)} изолированных стенда** — переключайте в выпадающем списке серверов выше:\n"
|
||||
f"{stand_descriptions}\n"
|
||||
"У каждого стенда **свои инстансы, свои операции, своё состояние**.\n\n"
|
||||
"### Особенности\n"
|
||||
"- Все операции синхронные (настраиваемая задержка через `/_mock/delay`)\n"
|
||||
@@ -78,8 +81,8 @@ def _build_spec():
|
||||
"- Симуляция ошибок через `/_mock/fail-next`\n\n"
|
||||
"### Авторизация\n"
|
||||
"- Основные эндпоинты — без авторизации\n"
|
||||
"- `_mock/*` — заголовок `X-Mock-Auth: test-token-123` "
|
||||
"(настроен через `MOCK_AUTH_TOKEN`)"
|
||||
"- `_mock/*` требуют заголовок `X-Mock-Auth`, только если на сервере "
|
||||
"задан `MOCK_AUTH_TOKEN`"
|
||||
),
|
||||
"contact": {
|
||||
"name": "Nubes Platform",
|
||||
@@ -101,7 +104,7 @@ def _build_spec():
|
||||
"type": "apiKey",
|
||||
"in": "header",
|
||||
"name": "X-Mock-Auth",
|
||||
"description": "Токен для _mock/* эндпоинтов (по умолчанию: test-token-123)",
|
||||
"description": "Нужен для _mock/* только если MOCK_AUTH_TOKEN задан на сервере.",
|
||||
},
|
||||
},
|
||||
"schemas": {
|
||||
@@ -170,7 +173,7 @@ def _build_spec():
|
||||
"serviceId": {"type": "integer"},
|
||||
"displayName": {"type": "string"},
|
||||
"descr": {"type": "string"},
|
||||
"status": {"type": "string", "description": "creating | running | modifying | deleting | suspended"},
|
||||
"status": {"type": "string", "description": "creating | running | suspended"},
|
||||
"explainedStatus": {"type": "string"},
|
||||
"svc": {"type": "string"},
|
||||
"dtCreate": {"type": "string", "format": "date-time"},
|
||||
@@ -209,20 +212,14 @@ def _build_spec():
|
||||
"svcOperationCfsParamId": {"type": "integer", "description": "ID параметра"},
|
||||
"svcOperationCfsParam": {"type": "string", "description": "Название параметра"},
|
||||
"dataType": {"type": "string", "description": "string | integer | map | map-fixed | boolean"},
|
||||
"dataDescriptor": {"type": "string", "description": "JSON-описание подполей (для map-типов)"},
|
||||
"dataDescriptor": {"type": "object", "description": "Описание подполей (для map-типов)"},
|
||||
"isRequired": {"type": "boolean"},
|
||||
"isModifiable": {"type": "boolean"},
|
||||
"defaultValue": {"type": "string"},
|
||||
"paramValue": {"type": "string", "description": "Текущее значение (если задано)"},
|
||||
"valueList": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"value": {"type": "string"},
|
||||
"label": {"type": "string"},
|
||||
},
|
||||
},
|
||||
"items": {"type": "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -249,7 +246,7 @@ def _build_spec():
|
||||
"instanceUid": {"type": "string", "format": "uuid"},
|
||||
"svcOperationId": {"type": "integer"},
|
||||
"operation": {"type": "string"},
|
||||
"kind": {"type": "string", "description": "instance | access"},
|
||||
"kind": {"type": "string", "description": "instance | subresource"},
|
||||
"action": {"type": "string", "description": "create | modify | delete | …"},
|
||||
"dtStart": {"type": ["string", "null"], "format": "date-time"},
|
||||
"dtFinish": {"type": ["string", "null"], "format": "date-time"},
|
||||
@@ -406,8 +403,8 @@ def _build_paths():
|
||||
{
|
||||
"name": "pageSize",
|
||||
"in": "query",
|
||||
"schema": {"type": "integer", "default": 200},
|
||||
"description": "Размер страницы (значения > 200 обрезаются до 200)",
|
||||
"schema": {"type": "integer", "default": 200, "minimum": 1, "maximum": 200},
|
||||
"description": "Размер страницы (значения ограничиваются диапазоном 1…200)",
|
||||
},
|
||||
{
|
||||
"name": "page",
|
||||
@@ -720,7 +717,7 @@ def _build_paths():
|
||||
"summary": "Сброс состояния",
|
||||
"description": "Удаляет **все** инстансы, операции и параметры. Используется в тестах для изоляции.",
|
||||
"operationId": "mockReset",
|
||||
"security": [{"mockAuth": []}],
|
||||
"security": [{"mockAuth": []}, {}],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Состояние сброшено",
|
||||
@@ -735,7 +732,7 @@ def _build_paths():
|
||||
"summary": "Дамп состояния",
|
||||
"description": "Возвращает все инстансы и операции для отладки.",
|
||||
"operationId": "mockState",
|
||||
"security": [{"mockAuth": []}],
|
||||
"security": [{"mockAuth": []}, {}],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Текущее состояние",
|
||||
@@ -750,7 +747,7 @@ def _build_paths():
|
||||
"summary": "Список загруженных сервисов",
|
||||
"description": "Возвращает все загруженные сервисы с количеством операций и параметров.",
|
||||
"operationId": "mockServices",
|
||||
"security": [{"mockAuth": []}],
|
||||
"security": [{"mockAuth": []}, {}],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Список сервисов",
|
||||
@@ -768,7 +765,7 @@ def _build_paths():
|
||||
"Минимум: 0, максимум: 5 (защита /health)."
|
||||
),
|
||||
"operationId": "mockSetDelay",
|
||||
"security": [{"mockAuth": []}],
|
||||
"security": [{"mockAuth": []}, {}],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "seconds",
|
||||
@@ -799,7 +796,7 @@ def _build_paths():
|
||||
"Позволяет тестировать негативные сценарии."
|
||||
),
|
||||
"operationId": "mockFailNext",
|
||||
"security": [{"mockAuth": []}],
|
||||
"security": [{"mockAuth": []}, {}],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Флаг установлен",
|
||||
|
||||
@@ -130,44 +130,48 @@ def create_operation():
|
||||
Также заполняет kind и action операции из конфига сервиса — они нужны
|
||||
state_machine.apply_effect() чтобы понять что делать при run.
|
||||
"""
|
||||
body = request.get_json(silent=True) or {}
|
||||
body = request.get_json(silent=True)
|
||||
if not isinstance(body, dict):
|
||||
return jsonify({"error": "request body must be a JSON object"}), 400
|
||||
|
||||
instance_uid = body.get("instanceUid")
|
||||
operation = body.get("operation", "")
|
||||
svc_op_id = body.get("svcOperationId")
|
||||
|
||||
# Валидация: instanceUid обязателен
|
||||
if not instance_uid:
|
||||
if not isinstance(instance_uid, str) or not instance_uid:
|
||||
return jsonify({"error": "instanceUid is required"}), 400
|
||||
if not isinstance(operation, str) or not operation:
|
||||
return jsonify({"error": "operation must be a non-empty string"}), 400
|
||||
|
||||
# Проверка что инстанс существует
|
||||
inst = state.get_instance(instance_uid)
|
||||
if not inst:
|
||||
return jsonify({"error": "instance not found"}), 404
|
||||
|
||||
# Если svcOperationId не передан — найти по имени операции
|
||||
# (например app-autotest при create не знает ID, передаёт только "create")
|
||||
svc_id = inst.get("serviceId")
|
||||
svc_def = SERVICES.get(svc_id, {})
|
||||
service_operations = svc_def.get("operations", [])
|
||||
|
||||
# При create app-autotest может передать только имя операции.
|
||||
if svc_op_id is None:
|
||||
svc_id = inst.get("serviceId")
|
||||
svc_def = SERVICES.get(svc_id, {})
|
||||
for op in svc_def.get("operations", []):
|
||||
for op in service_operations:
|
||||
if op["operation"] == operation:
|
||||
svc_op_id = op["svcOperationId"]
|
||||
break
|
||||
elif not isinstance(svc_op_id, int) or isinstance(svc_op_id, bool):
|
||||
return jsonify({"error": "svcOperationId must be an integer"}), 400
|
||||
|
||||
# Если и после поиска не нашли — операция не поддерживается сервисом
|
||||
if svc_op_id is None:
|
||||
# ID должен принадлежать именно сервису инстанса, не только глобальному индексу.
|
||||
target_op = next(
|
||||
(op for op in service_operations if op["svcOperationId"] == svc_op_id),
|
||||
None,
|
||||
)
|
||||
if target_op is None:
|
||||
return jsonify({"error": f"operation '{operation}' not found for service"}), 404
|
||||
|
||||
# Определить kind и action операции из конфига
|
||||
# (один проход — поиск по svcOperationId, не по имени)
|
||||
svc_def = OPS_INDEX.get(svc_op_id, {})
|
||||
kind = "instance"
|
||||
action = operation
|
||||
for op in svc_def.get("operations", []):
|
||||
if op["svcOperationId"] == svc_op_id:
|
||||
kind = op.get("kind", "instance")
|
||||
action = op.get("action", operation)
|
||||
break
|
||||
kind = target_op.get("kind", "instance")
|
||||
action = target_op.get("action", operation)
|
||||
|
||||
# Создать операцию в MockState (dtFinish=None — ещё не выполнена)
|
||||
op_uid = state.create_operation(instance_uid, svc_op_id, operation, kind, action)
|
||||
@@ -228,7 +232,10 @@ def set_operation_param():
|
||||
Значения сохраняются в mock_state.op_params[opUid][paramId] = value.
|
||||
При apply_effect (modify) значения мержатся в state.params инстанса.
|
||||
"""
|
||||
body = request.get_json(silent=True) or {}
|
||||
body = request.get_json(silent=True)
|
||||
if not isinstance(body, dict):
|
||||
return jsonify({"error": "request body must be a JSON object"}), 400
|
||||
|
||||
op_uid = body.get("instanceOperationUid")
|
||||
param_id = body.get("svcOperationCfsParamId")
|
||||
value = body.get("paramValue", "")
|
||||
@@ -241,9 +248,23 @@ def set_operation_param():
|
||||
if not isinstance(param_id, int) or isinstance(param_id, bool):
|
||||
return jsonify({"error": "svcOperationCfsParamId must be an integer"}), 400
|
||||
|
||||
# Проверка что операция существует
|
||||
if not state.get_operation(op_uid):
|
||||
# Проверка что операция существует и ещё не завершена.
|
||||
op = state.get_operation(op_uid)
|
||||
if not op:
|
||||
return jsonify({"error": "operation not found"}), 404
|
||||
if op.get("dtFinish") is not None:
|
||||
return jsonify({"error": "operation already completed"}), 409
|
||||
|
||||
if not isinstance(value, str):
|
||||
return jsonify({"error": "paramValue must be a string"}), 400
|
||||
|
||||
inst = state.get_instance(op.get("instanceUid"))
|
||||
if not inst:
|
||||
return jsonify({"error": "instance not found"}), 404
|
||||
svc_def = SERVICES.get(inst.get("serviceId"), {})
|
||||
valid_param_ids = svc_def.get("cfsParamsByOp", {}).get(op.get("svcOperationId"), [])
|
||||
if param_id not in valid_param_ids:
|
||||
return jsonify({"error": "parameter not found for operation"}), 400
|
||||
|
||||
# Сохранить параметр (param_id — int, value — str)
|
||||
state.set_param(op_uid, param_id, value)
|
||||
|
||||
+1
-5
@@ -18,9 +18,6 @@ SERVICES = LocalProxy(lambda: _cfg.get_services(g.stand_id))
|
||||
|
||||
VERSION = _cfg.VERSION
|
||||
|
||||
# Токен для авторизации в Swagger UI (предзаполняется)
|
||||
_MOCK_AUTH_TOKEN = os.getenv("MOCK_AUTH_TOKEN", "test-token-123")
|
||||
|
||||
# URL для OpenAPI-спеке (сервер)
|
||||
_POLYGON_ENDPOINT = os.getenv("POLYGON_ENDPOINT", "https://polygon.pythonk8s.dev.nubes.ru")
|
||||
|
||||
@@ -90,7 +87,7 @@ def swagger():
|
||||
"""Swagger UI — интерактивная документация API.
|
||||
|
||||
Спек OpenAPI встроена прямо в HTML — без отдельного HTTP-запроса.
|
||||
Токен авторизации предзаполняется автоматически (MOCK_AUTH_TOKEN).
|
||||
Секрет MOCK_AUTH_TOKEN никогда не передаётся в браузер.
|
||||
"""
|
||||
from routes.openapi import _build_spec
|
||||
import json as _json
|
||||
@@ -99,5 +96,4 @@ def swagger():
|
||||
"swagger.html",
|
||||
version=_cfg.VERSION,
|
||||
spec_json=spec_json,
|
||||
auth_token=_MOCK_AUTH_TOKEN,
|
||||
)
|
||||
|
||||
@@ -50,6 +50,9 @@ def run_operation(uid):
|
||||
if op.get("dtFinish") is not None:
|
||||
return jsonify({"error": "operation already completed"}), 409
|
||||
|
||||
if not state.get_instance(op.get("instanceUid")):
|
||||
return jsonify({"error": "instance not found"}), 404
|
||||
|
||||
# Фиксируем время старта
|
||||
op["dtStart"] = _now()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user