diff --git a/.claude/skills/role-compile/SKILL.md b/.claude/skills/role-compile/SKILL.md index 8e7e8758..e4b302af 100644 --- a/.claude/skills/role-compile/SKILL.md +++ b/.claude/skills/role-compile/SKILL.md @@ -60,7 +60,18 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/role-compile.ps1" - `@` обязателен в shorthand. В объектной форме — `"preset": "view"` без `@`. -Для сервисов (WebService, HTTPService, IntegrationService) пресеты не определены — используй явные права: `"WebService.Имя: Use"`. +### Сервисы + +Платформа проверяет право на **вложенном объекте** сервиса — методе шаблона URL, операции, канале. Права на сервис целиком не существует. + +Весь сервис — короткая запись, навык раскроет её по метаданным сервиса в `OutputDir`: + +```json +"objects": ["HTTPService.ЭДО: Use"] +``` +→ `HTTPService.ЭДО.URLTemplate.ЕстьНовыеДокументы.Method.POST: Use`, и так по каждому методу каждого шаблона. + +Часть маршрутов — полным путём: `"HTTPService.ЭДО.URLTemplate.ЕстьНовыеДокументы.Method.POST: Use"`. ### Русские синонимы @@ -103,7 +114,9 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/role-compile.ps1" - Права имеют 27 типов объектов; тип или имя права вне списка — ошибка: роль не создаётся, файлы не пишутся, `Configuration.xml` не меняется. Права нельзя назначить на `Enum`, `CommonModule`, `DefinedType`, `CommonPicture`, `CommonTemplate`, `Language`, `FunctionalOption`, `EventSubscription`, `ScheduledJob`, `StyleItem`, `SettingsStorage` и подобные — в дереве редактора ролей их нет. -Права на части объекта задаются точечным путём: `Catalog.Контрагенты.Attribute.ИНН: View, Edit`, `WebService.Обмен.Operation.Загрузить: Use`. +Права на части объекта задаются точечным путём: `Catalog.Контрагенты.Attribute.ИНН: View, Edit`, `WebService.Обмен.Operation.Загрузить: Use`, `HTTPService.ЭДО.URLTemplate.ЕстьНовыеДокументы.Method.POST: Use`. + +Роль расширения, включённая в основные (`DefaultRoles`), прав на заимствованные объекты давать не может — платформа это запрещает. Такие права выноси в отдельную роль вне основных. Полные таблицы «тип → права», виды вложенности (включая внешние источники данных), список типов без прав, таблицы пресетов и дополнительные примеры — в `dsl-reference.md`. diff --git a/.claude/skills/role-compile/dsl-reference.md b/.claude/skills/role-compile/dsl-reference.md index fd6f8f83..3e3fecf4 100644 --- a/.claude/skills/role-compile/dsl-reference.md +++ b/.claude/skills/role-compile/dsl-reference.md @@ -22,7 +22,7 @@ | `DocumentJournal` | Read, View | | `Sequence` | Read, Update | | `DataProcessor`, `Report` | Use, View | -| `WebService`, `HTTPService`, `IntegrationService` | Use | +| `WebService`, `HTTPService`, `IntegrationService` | — права только на вложенных объектах, см. ниже | | `CommonForm`, `CommonCommand`, `Subsystem`, `FilterCriterion` | View | | `CommonAttribute` | View, Edit | | `SessionParameter` | Get, Set | @@ -50,7 +50,12 @@ Примеры: `Catalog.Контрагенты.Attribute.ИНН: View, Edit`, `Document.Реализация.TabularSection.Товары.Attribute.Цена: View`, `WebService.Обмен.Operation.Загрузить: Use`, -`HTTPService.API.URLTemplate.Файлы.Method.get: Use`. +`HTTPService.API.URLTemplate.Файлы.Method.Получить: Use`. + +Сегмент после `Method.` — **имя объекта Method в дереве метаданных**, а не HTTP-глагол: +встречаются и `Method.GET`, и `Method.Установить`, и `Method.СоздатьПодключение`. + +Про короткую запись `HTTPService.API: Use` — раздел «Сервисы» в `SKILL.md`. ### Вложенные объекты внешнего источника данных @@ -129,7 +134,7 @@ | CommonAttribute | View, Edit | Для сервисов (WebService, HTTPService, IntegrationService) и внешних источников данных -пресеты не определены — задавай права явно: `"WebService.Имя: Use"`. +пресеты не определены — задавай права явно: `"WebService.Обмен.Operation.Загрузить: Use"`. ## Шаблоны ограничений (RLS templates) diff --git a/.claude/skills/role-compile/scripts/role-compile.ps1 b/.claude/skills/role-compile/scripts/role-compile.ps1 index 3aab0568..ccb6bc76 100644 --- a/.claude/skills/role-compile/scripts/role-compile.ps1 +++ b/.claude/skills/role-compile/scripts/role-compile.ps1 @@ -1,4 +1,4 @@ -# role-compile v1.25 — Compile 1C role from JSON (+русские алиасы типов: формы с ё и без) +# role-compile v1.26 — Compile 1C role from JSON # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills param( [Parameter(Mandatory)] @@ -469,6 +469,16 @@ $script:kindOwners = @{ "IntegrationServiceChannel" = "IntegrationService" } +# Право на сервис живёт на ЛИСТЕ — методе шаблона URL, операции, канале, — а не на самом +# сервисе: корневого узла нет ни в одной типовой роли (907 записей корпуса — ноль), в +# Конфигураторе галки на корне нет вовсе. Короткая запись `HTTPService.X: Use` выражает +# намерение «открой сервис целиком» и раскрывается в листья по метаданным сервиса. +$script:serviceLeaves = @{ + "WebService" = @{ Dir = "WebServices"; Kinds = @("Operation") } + "HTTPService" = @{ Dir = "HTTPServices"; Kinds = @("URLTemplate", "Method") } + "IntegrationService" = @{ Dir = "IntegrationServices"; Kinds = @("IntegrationServiceChannel") } +} + # Один и тот же вид под разными родителями имеет разный набор: измерение регистра — # View + Edit, измерение куба внешнего источника — только View. Объединять нельзя, # объединение молча разрешило бы Edit там, где платформа его не даёт. @@ -669,6 +679,142 @@ function Validate-RightName { return $true } +# --- 5a. Service roots: expand to leaves --- + +# Метаданные сервиса читаются один раз на имя: раскрытие и проверка заимствования +# спрашивают один и тот же файл. +$script:serviceMetaCache = @{} + +function Get-ServiceMeta { + param([string]$objectType, [string]$serviceName, [string]$configRoot) + + $key = "$objectType.$serviceName" + if ($script:serviceMetaCache.ContainsKey($key)) { return $script:serviceMetaCache[$key] } + + $spec = $script:serviceLeaves[$objectType] + $xmlPath = Join-Path (Join-Path $configRoot $spec.Dir) "$serviceName.xml" + $result = @{ Path = $xmlPath; Found = $false; Adopted = $false; Leaves = @() } + + if (Test-Path $xmlPath) { + try { + $doc = New-Object System.Xml.XmlDocument + $doc.PreserveWhitespace = $true + $doc.Load($xmlPath) + $ns = New-Object System.Xml.XmlNamespaceManager($doc.NameTable) + $ns.AddNamespace("md", "http://v8.1c.ru/8.3/MDClasses") + $root = $doc.SelectSingleNode("/md:MetaDataObject/md:$objectType", $ns) + if ($root) { + $result.Found = $true + # ObjectBelonging=Adopted — сервис заимствован в расширение. + $ob = $root.SelectSingleNode("md:Properties/md:ObjectBelonging", $ns) + if ($ob -and $ob.InnerText -eq "Adopted") { $result.Adopted = $true } + + # Спуск по видам: у HTTP-сервиса лист лежит на два уровня ниже + # (URLTemplate → Method), у остальных — на один. + $level = @(@{ Node = $root; Name = "$objectType.$serviceName" }) + foreach ($kind in $spec.Kinds) { + $next = @() + foreach ($item in $level) { + foreach ($child in $item.Node.SelectNodes("md:ChildObjects/md:$kind", $ns)) { + # Имя берём SelectSingleNode: XML-адаптер PowerShell перекрывает + # .NET-члены атрибутами, $child.Name отдал бы не то и молча. + $nameNode = $child.SelectSingleNode("md:Properties/md:Name", $ns) + if (-not $nameNode) { continue } + $next += ,@{ Node = $child; Name = "$($item.Name).$kind.$($nameNode.InnerText)" } + } + } + $level = $next + } + $result.Leaves = @($level | ForEach-Object { $_.Name }) + } + } catch { + # Битый XML — не наша забота: раскрывать нечего, дальше отработает отказ + # «метаданные не найдены» с тем же путём в подсказке. + } + } + + $script:serviceMetaCache[$key] = $result + return $result +} + +# Подсказка формата: единственное, что отличается у трёх видов сервисов, — путь до листа. +function Get-ServiceLeafHint { + param([string]$objectType, [string]$serviceName) + switch ($objectType) { + "HTTPService" { return "$objectType.$serviceName.URLTemplate.<Шаблон>.Method.<Метод>: Use" } + "WebService" { return "$objectType.$serviceName.Operation.<Операция>: Use" } + default { return "$objectType.$serviceName.IntegrationServiceChannel.<Канал>: Use" } + } +} + +# Роль расширения, включённая в , прав на заимствованные объекты давать не +# может — платформа отвечает «Назначение прав доступа на заимствованные объекты основными +# ролями в расширениях недопустимо». Считаем один раз: имя роли за прогон не меняется. +$script:isDefaultRole = $null + +function Test-DefaultRole { + param([string]$configRoot, [string]$name) + + if ($null -ne $script:isDefaultRole) { return $script:isDefaultRole } + $script:isDefaultRole = $false + + $cfgPath = Join-Path $configRoot "Configuration.xml" + if (Test-Path $cfgPath) { + $text = [System.IO.File]::ReadAllText($cfgPath, [System.Text.Encoding]::UTF8) + # Только расширение: у обычной конфигурации DefaultRoles значит другое и запрета нет. + if ($text -match '' -and $text -match '(?s)(.*?)') { + # -cmatch, а не -match: -match регистронезависим и «Расш1_Роль1» совпал бы + # с «расш1_роль1», молча приняв роль за основную. + $script:isDefaultRole = ($Matches[1] -cmatch ([regex]::Escape("Role.$name") + '\s*<')) + } + } + return $script:isDefaultRole +} + +# Возвращает список записей на замену исходной: сервисный корень раскрывается в листья, +# всё остальное проходит как есть. +function Expand-ServiceEntry { + param($parsed, [string]$configRoot, [string]$name) + + $objName = $parsed.Name + $objectType = Get-ObjectType $objName + if (-not $script:serviceLeaves.ContainsKey($objectType)) { return @($parsed) } + + $parts = $objName.Split(".") + if ($parts.Count -lt 2) { return @($parsed) } + $serviceName = $parts[1] + $meta = Get-ServiceMeta -objectType $objectType -serviceName $serviceName -configRoot $configRoot + + if ($meta.Adopted -and (Test-DefaultRole -configRoot $configRoot -name $name)) { + Add-ValidationError ("${objName}: '$name' — основная роль расширения (входит в DefaultRoles), " + + "а $objectType.$serviceName заимствован; назначать права на заимствованные объекты " + + "основными ролями расширения платформа запрещает. Заведите отдельную роль и не включайте её в основные.") + return @() + } + + # Полный путь пользователь задал сам — раскрывать нечего. + if ($parts.Count -gt 2) { return @($parsed) } + + $hint = Get-ServiceLeafHint -objectType $objectType -serviceName $serviceName + if (-not $meta.Found) { + Add-ValidationError ("${objName}: метаданные сервиса не найдены ($($meta.Path)); " + + "право на сервис целиком платформа игнорирует — укажите листья явно: $hint") + return @() + } + if ($meta.Leaves.Count -eq 0) { + Add-ValidationError ("${objName}: у сервиса нет ни одного вложенного объекта, раскрывать нечего; " + + "право на сервис целиком платформа игнорирует. Для заимствованного сервиса заимствуйте нужные методы, затем: $hint") + return @() + } + + $expanded = @() + foreach ($leaf in $meta.Leaves) { + $expanded += ,@{ Name = $leaf; Rights = $parsed.Rights } + } + Write-Host " $objName -> раскрыт (вложенных объектов: $($expanded.Count))" + return $expanded +} + # --- 6. Parse object entries --- function Parse-ObjectEntry { @@ -776,12 +922,24 @@ function Parse-ObjectEntry { # Synonym: accept "rights" as alias for "objects" if (-not $def.objects -and $def.rights) { $def | Add-Member -NotePropertyName objects -NotePropertyValue $def.rights } +# Путь нужен уже здесь: раскрытие сервисного корня читает метаданные сервиса рядом с ролью. +$resolvedOutputDir = if ([System.IO.Path]::IsPathRooted($OutputDir)) { $OutputDir } else { Join-Path (Get-Location) $OutputDir } + $parsedObjects = @() +$seenObjectNames = @{} if ($def.objects) { foreach ($entry in $def.objects) { $parsed = Parse-ObjectEntry -entry $entry - if ($parsed) { - $parsedObjects += ,$parsed + if (-not $parsed) { continue } + foreach ($p in (Expand-ServiceEntry -parsed $parsed -configRoot $resolvedOutputDir -name $roleName)) { + # Дубль возникает штатно: раскрытый лист совпал с явно заданным. Права на листе + # одни и те же (Use), так что вторая запись — шум, а не конфликт. + if ($seenObjectNames.ContainsKey($p.Name)) { + Write-Warning "$($p.Name): объект указан дважды, вторая запись пропущена" + continue + } + $seenObjectNames[$p.Name] = $true + $parsedObjects += ,$p } } } @@ -831,7 +989,6 @@ function Get-FormatRank([string]$ver) { return 0 } -$resolvedOutputDir =if ([System.IO.Path]::IsPathRooted($OutputDir)) { $OutputDir } else { Join-Path (Get-Location) $OutputDir } Assert-EditAllowed $resolvedOutputDir 'editable' $formatVersion = Detect-FormatVersion $resolvedOutputDir diff --git a/.claude/skills/role-compile/scripts/role-compile.py b/.claude/skills/role-compile/scripts/role-compile.py index fed2b41b..4a1428b5 100644 --- a/.claude/skills/role-compile/scripts/role-compile.py +++ b/.claude/skills/role-compile/scripts/role-compile.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# role-compile v1.25 — Compile 1C role from JSON (+русские алиасы типов: формы с ё и без) +# role-compile v1.26 — Compile 1C role from JSON # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import argparse import json @@ -582,6 +582,16 @@ KIND_OWNERS = { 'IntegrationServiceChannel': 'IntegrationService', } +# Право на сервис живёт на ЛИСТЕ — методе шаблона URL, операции, канале, — а не на самом +# сервисе: корневого узла нет ни в одной типовой роли (907 записей корпуса — ноль), в +# Конфигураторе галки на корне нет вовсе. Короткая запись `HTTPService.X: Use` выражает +# намерение «открой сервис целиком» и раскрывается в листья по метаданным сервиса. +SERVICE_LEAVES = { + 'WebService': {'dir': 'WebServices', 'kinds': ['Operation']}, + 'HTTPService': {'dir': 'HTTPServices', 'kinds': ['URLTemplate', 'Method']}, + 'IntegrationService': {'dir': 'IntegrationServices', 'kinds': ['IntegrationServiceChannel']}, +} + # Один и тот же вид под разными родителями имеет разный набор: измерение регистра — # View + Edit, измерение куба внешнего источника — только View. Объединять нельзя, # объединение молча разрешило бы Edit там, где платформа его не даёт. @@ -768,6 +778,133 @@ def validate_right_name(object_name, right_name): return True +MD_NS = 'http://v8.1c.ru/8.3/MDClasses' + +# Метаданные сервиса читаются один раз на имя: раскрытие и проверка заимствования +# спрашивают один и тот же файл. +SERVICE_META_CACHE = {} + + +def get_service_meta(object_type, service_name, config_root): + key = f"{object_type}.{service_name}" + if key in SERVICE_META_CACHE: + return SERVICE_META_CACHE[key] + + spec = SERVICE_LEAVES[object_type] + xml_path = os.path.join(config_root, spec['dir'], f"{service_name}.xml") + result = {'path': xml_path, 'found': False, 'adopted': False, 'leaves': []} + + if os.path.isfile(xml_path): + try: + root = etree.parse(xml_path).getroot() + node = root.find(f"{{{MD_NS}}}{object_type}") + if node is not None: + result['found'] = True + # ObjectBelonging=Adopted — сервис заимствован в расширение. + ob = node.find(f"{{{MD_NS}}}Properties/{{{MD_NS}}}ObjectBelonging") + if ob is not None and (ob.text or '') == 'Adopted': + result['adopted'] = True + + # Спуск по видам: у HTTP-сервиса лист лежит на два уровня ниже + # (URLTemplate → Method), у остальных — на один. + level = [(node, f"{object_type}.{service_name}")] + for kind in spec['kinds']: + nxt = [] + for item_node, item_name in level: + for child in item_node.findall(f"{{{MD_NS}}}ChildObjects/{{{MD_NS}}}{kind}"): + name_node = child.find(f"{{{MD_NS}}}Properties/{{{MD_NS}}}Name") + if name_node is None: + continue + nxt.append((child, f"{item_name}.{kind}.{name_node.text}")) + level = nxt + result['leaves'] = [n for _, n in level] + except Exception: + # Битый XML — не наша забота: раскрывать нечего, дальше отработает отказ + # «метаданные не найдены» с тем же путём в подсказке. + pass + + SERVICE_META_CACHE[key] = result + return result + + +def get_service_leaf_hint(object_type, service_name): + """Подсказка формата: единственное, что отличается у трёх видов сервисов, — путь до листа.""" + if object_type == 'HTTPService': + return f"{object_type}.{service_name}.URLTemplate.<Шаблон>.Method.<Метод>: Use" + if object_type == 'WebService': + return f"{object_type}.{service_name}.Operation.<Операция>: Use" + return f"{object_type}.{service_name}.IntegrationServiceChannel.<Канал>: Use" + + +# Роль расширения, включённая в , прав на заимствованные объекты давать не +# может — платформа отвечает «Назначение прав доступа на заимствованные объекты основными +# ролями в расширениях недопустимо». Считаем один раз: имя роли за прогон не меняется. +IS_DEFAULT_ROLE = None + + +def test_default_role(config_root, name): + global IS_DEFAULT_ROLE + if IS_DEFAULT_ROLE is not None: + return IS_DEFAULT_ROLE + IS_DEFAULT_ROLE = False + + cfg_path = os.path.join(config_root, 'Configuration.xml') + if os.path.isfile(cfg_path): + with open(cfg_path, 'r', encoding='utf-8-sig') as f: + text = f.read() + # Только расширение: у обычной конфигурации DefaultRoles значит другое и запрета нет. + if '' in text: + m = re.search(r'(.*?)', text, re.S) + # Сравнение регистрозависимое — паритет с -cmatch в PS1, где регистронезависимый + # -match принял бы «расш1_роль1» за основную роль «Расш1_Роль1». + if m and re.search(re.escape(f"Role.{name}") + r'\s*<', m.group(1)): + IS_DEFAULT_ROLE = True + return IS_DEFAULT_ROLE + + +def expand_service_entry(parsed, config_root, name): + """Возвращает список записей на замену исходной: сервисный корень раскрывается в листья, + всё остальное проходит как есть.""" + obj_name = parsed['Name'] + object_type = get_object_type(obj_name) + if object_type not in SERVICE_LEAVES: + return [parsed] + + parts = obj_name.split('.') + if len(parts) < 2: + return [parsed] + service_name = parts[1] + meta = get_service_meta(object_type, service_name, config_root) + + if meta['adopted'] and test_default_role(config_root, name): + add_validation_error( + f"{obj_name}: '{name}' — основная роль расширения (входит в DefaultRoles), " + f"а {object_type}.{service_name} заимствован; назначать права на заимствованные объекты " + "основными ролями расширения платформа запрещает. Заведите отдельную роль и не включайте её в основные.") + return [] + + # Полный путь пользователь задал сам — раскрывать нечего. + if len(parts) > 2: + return [parsed] + + hint = get_service_leaf_hint(object_type, service_name) + if not meta['found']: + add_validation_error( + f"{obj_name}: метаданные сервиса не найдены ({meta['path']}); " + f"право на сервис целиком платформа игнорирует — укажите листья явно: {hint}") + return [] + if not meta['leaves']: + add_validation_error( + f"{obj_name}: у сервиса нет ни одного вложенного объекта, раскрывать нечего; " + "право на сервис целиком платформа игнорирует. " + f"Для заимствованного сервиса заимствуйте нужные методы, затем: {hint}") + return [] + + expanded = [{'Name': leaf, 'Rights': parsed['Rights']} for leaf in meta['leaves']] + print(f" {obj_name} -> раскрыт (вложенных объектов: {len(expanded)})") + return expanded + + def parse_object_entry(entry): # --- String shorthand --- if isinstance(entry, str): @@ -891,11 +1028,20 @@ def main(): # --- 2. Parse all object entries --- parsed_objects = [] + seen_object_names = set() if defn.get('objects'): for entry in defn['objects']: parsed = parse_object_entry(entry) - if parsed: - parsed_objects.append(parsed) + if not parsed: + continue + for p in expand_service_entry(parsed, out_dir_resolved, role_name): + # Дубль возникает штатно: раскрытый лист совпал с явно заданным. Права на + # листе одни и те же (Use), так что вторая запись — шум, а не конфликт. + if p['Name'] in seen_object_names: + print(f"WARNING: {p['Name']}: объект указан дважды, вторая запись пропущена", file=sys.stderr) + continue + seen_object_names.add(p['Name']) + parsed_objects.append(p) # Отказ ДО записи: роль пишется тремя файлами (метаданные, права, регистрация в # Configuration.xml), и частично записанная роль хуже отсутствующей. Печатаем все