From 29bbe1dca64e6db19f206fd932ed8eedecea6738 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Thu, 21 May 2026 19:22:02 +0300 Subject: [PATCH] Dry run publish 1C access profiles --- integrations/1c/sfera-extension/README.md | 2 +- .../HTTPServices/BridgeHTTP/Ext/Module.bsl | 41 ++++++++++++++++++- services/api-server/src/api_server/main.py | 26 ++++++++++++ services/api-server/tests/test_api.py | 27 +++++++++++- 4 files changed, 92 insertions(+), 4 deletions(-) diff --git a/integrations/1c/sfera-extension/README.md b/integrations/1c/sfera-extension/README.md index 35056cb..2a1654b 100644 --- a/integrations/1c/sfera-extension/README.md +++ b/integrations/1c/sfera-extension/README.md @@ -29,6 +29,7 @@ http://server/base/hs/sfera/v1/metadata/apply - `data.read` - чтение данных через ограниченный запрос или менеджер объекта. - `data.write` - изменение данных только при явном `allow_mutation`. - `metadata.apply` - изменение структуры не выполняется из HTTP runtime. Возвращает план установки `.cfe`; применение делает Windows Agent через Designer. +- `access.profile.apply` - dry-run проверки плана профиля доступа через `/v1/metadata/apply`. Универсальный мост подтверждает профиль, роли и операции, но реальную запись профилей доступа выполняет только отдельный адаптер под конкретную конфигурацию/БСП или Windows Agent. ## Безопасность @@ -41,4 +42,3 @@ http://server/base/hs/sfera/v1/metadata/apply - `dry_run=false`. Без этого операции изменения возвращают блокировку. - diff --git a/integrations/1c/sfera-extension/xml/HTTPServices/BridgeHTTP/Ext/Module.bsl b/integrations/1c/sfera-extension/xml/HTTPServices/BridgeHTTP/Ext/Module.bsl index 4b02d90..ae9f93e 100644 --- a/integrations/1c/sfera-extension/xml/HTTPServices/BridgeHTTP/Ext/Module.bsl +++ b/integrations/1c/sfera-extension/xml/HTTPServices/BridgeHTTP/Ext/Module.bsl @@ -49,7 +49,8 @@ КонецЕсли; Возврат ОтветJSON(BridgeMetadataApply( ПолучитьПоле(Контекст, "payload", Новый Структура), - ПолучитьПоле(Контекст, "dry_run", Истина))); + ПолучитьПоле(Контекст, "dry_run", Истина), + ПолучитьПоле(Контекст, "allow_mutation", Ложь))); КонецФункции #КонецОбласти @@ -64,6 +65,7 @@ Результат.Вставить("timestamp", ТекущаяДата()); Результат.Вставить("mutation_supported", Истина); Результат.Вставить("metadata_apply_supported", Ложь); + Результат.Вставить("access_profile_apply_supported", Истина); Возврат Результат; КонецФункции @@ -153,7 +155,11 @@ Возврат ОшибкаSFERA("Generic write adapter is intentionally not enabled yet. Implement object-specific handlers first."); КонецФункции -Функция BridgeMetadataApply(Параметры, DryRun) +Функция BridgeMetadataApply(Параметры, DryRun, AllowMutation) + Операция = Строка(ПолучитьПоле(Параметры, "operation", "")); + Если Операция = "access.profile.apply" Тогда + Возврат BridgeAccessProfileApply(Параметры, DryRun, AllowMutation); + КонецЕсли; Результат = Новый Структура; Результат.Вставить("status", "planned"); Результат.Вставить("message", "Changing configuration structure is performed by SFERA Windows Agent through Designer and .cfe update, not by runtime HTTP."); @@ -162,6 +168,27 @@ Возврат Результат; КонецФункции +Функция BridgeAccessProfileApply(Параметры, DryRun, AllowMutation) + Профиль = ПолучитьПоле(Параметры, "profile", Новый Структура); + Операции = ПолучитьПоле(Параметры, "operations", Новый Массив); + ИмяПрофиля = Строка(ПолучитьПоле(Профиль, "qualified_name", ПолучитьПоле(Профиль, "name", ""))); + Если ПустаяСтрока(ИмяПрофиля) Тогда + Возврат ОшибкаSFERA("profile.name or profile.qualified_name is required for access.profile.apply"); + КонецЕсли; + Если Не DryRun И Не AllowMutation Тогда + Возврат ОшибкаSFERA("Access profile mutation is blocked. Use dry_run=true or allow_mutation=true with project mutation guard enabled."); + КонецЕсли; + Результат = Новый Структура; + Результат.Вставить("status", ?(DryRun, "dry_run", "planned")); + Результат.Вставить("operation", "access.profile.apply"); + Результат.Вставить("profile", ИмяПрофиля); + Результат.Вставить("operations_count", КоличествоЭлементовSFERA(Операции)); + Результат.Вставить("operations", Операции); + Результат.Вставить("message", "Access profile plan was accepted by SFERA extension. Runtime mutation is not executed by the generic bridge; apply through a configuration-specific adapter or Windows Agent."); + Результат.Вставить("dry_run", DryRun); + Возврат Результат; +КонецФункции + #КонецОбласти #Область СлужебныеПроцедурыИФункции @@ -210,6 +237,16 @@ Возврат ЗначениеПоУмолчанию; КонецФункции +Функция КоличествоЭлементовSFERA(Значение) + Если Значение = Неопределено Тогда + Возврат 0; + КонецЕсли; + Если ТипЗнч(Значение) = Тип("Массив") Или ТипЗнч(Значение) = Тип("Структура") Или ТипЗнч(Значение) = Тип("Соответствие") Тогда + Возврат Значение.Количество(); + КонецЕсли; + Возврат 0; +КонецФункции + Процедура ДобавитьКоллекциюМетаданных(Коллекции, ИмяКоллекции, КоллекцияМетаданных) Объекты = Новый Массив; Для Каждого ОбъектМетаданных Из КоллекцияМетаданных Цикл diff --git a/services/api-server/src/api_server/main.py b/services/api-server/src/api_server/main.py index 1d87986..56788b7 100644 --- a/services/api-server/src/api_server/main.py +++ b/services/api-server/src/api_server/main.py @@ -3015,6 +3015,30 @@ async def get_project_access_profile_publish_plan(project_id: str, profile_name: return _build_access_profile_publish_plan(normalized, profile) +@app.post("/projects/{project_id}/access/profiles/{profile_name}/publish-dry-run", response_model=SferaExtensionCallResponse) +async def dry_run_project_access_profile_publish(project_id: str, profile_name: str) -> SferaExtensionCallResponse: + settings = _project_settings_or_404(project_id) + normalized = _load_normalized_project(project_id) + if normalized is None: + raise HTTPException(status_code=404, detail="NormalizedProject not found") + profile = _access_profile_by_name(normalized, profile_name) + if profile is None: + raise HTTPException(status_code=404, detail="Access profile not found") + plan = _build_access_profile_publish_plan(normalized, profile) + if not plan.ready_for_extension: + raise HTTPException(status_code=409, detail="Access profile publish plan is not ready for extension") + return _call_sfera_extension( + project_id, + settings, + SferaExtensionCallRequest( + operation="access.profile.apply", + payload=plan.extension_payload, + dry_run=True, + allow_mutation=False, + ), + ) + + @app.get("/projects/{project_id}/imports/quality", response_model=ImportQualityResponse) async def get_import_quality(project_id: str) -> ImportQualityResponse: return _import_quality_response(project_id) @@ -6568,6 +6592,7 @@ _SFERA_EXTENSION_MUTATION_OPERATIONS = { "data.write", "data.delete", "metadata.apply", + "access.profile.apply", "admin.command", } @@ -6601,6 +6626,7 @@ def _sfera_extension_operation_path(operation: str) -> str: "data.write": "v1/data/write", "query.execute": "v1/query", "metadata.apply": "v1/metadata/apply", + "access.profile.apply": "v1/metadata/apply", "admin.command": "v1/admin/command", } return mapping.get(operation, "v1/call") diff --git a/services/api-server/tests/test_api.py b/services/api-server/tests/test_api.py index c1eeff6..0da4721 100644 --- a/services/api-server/tests/test_api.py +++ b/services/api-server/tests/test_api.py @@ -1466,7 +1466,7 @@ def test_runtime_required_import_does_not_index_cf_file_directly(monkeypatch, tm assert payload["normalized_summary"]["object_count"] >= 1 -def test_import_supports_structure_only_indexing(tmp_path: Path): +def test_import_supports_structure_only_indexing(monkeypatch, tmp_path: Path): (tmp_path / "metadata.xml").write_text( """ @@ -1585,6 +1585,31 @@ def test_import_supports_structure_only_indexing(tmp_path: Path): assert any(item["action"] == "ADD_ROLE_TO_PROFILE" and item["role"] == "Роль.Менеджер" for item in plan_payload["operations"]) assert plan_payload["extension_payload"]["operation"] == "access.profile.apply" + captured_extension_call = {} + + def fake_extension_call(project_id_arg, settings_arg, request_arg): + captured_extension_call["project_id"] = project_id_arg + captured_extension_call["request"] = request_arg + return main.SferaExtensionCallResponse( + project_id=project_id_arg, + operation=request_arg.operation, + status="READY", + ready=True, + dry_run=request_arg.dry_run, + extension_url="http://example.test/hs/sfera/v1/metadata/apply", + result={"status": "dry_run", "operation": request_arg.operation}, + ) + + monkeypatch.setattr(main, "_call_sfera_extension", fake_extension_call) + publish_dry_run = client.post( + f"/projects/{project_id}/access/profiles/{quote('НовыйПрофильHTTP')}/publish-dry-run" + ) + assert publish_dry_run.status_code == 200 + assert publish_dry_run.json()["operation"] == "access.profile.apply" + assert captured_extension_call["project_id"] == project_id + assert captured_extension_call["request"].dry_run is True + assert captured_extension_call["request"].payload["profile"]["roles"] == ["Роль.Менеджер"] + tree = client.get(f"/projects/{project_id}/metadata/tree") assert tree.status_code == 200 root = tree.json()["root"]