fix(role-compile): права на сервис — на вложенных объектах, а не на корне

Короткая запись `HTTPService.X: Use` писала узел, которого платформа не видит:
роль проходила role-validate и cfe-validate, а маршруты отвечали 403. В корпусе
(acc/erp/ut/unf, 907 записей прав на сервисы) корневого узла нет ни разу — право
живёт на методе шаблона URL, операции, канале; в Конфигураторе галки на корне нет.

Короткая запись теперь раскрывается во все вложенные объекты сервиса по его
метаданным в OutputDir. Заимствованный сервис несёт заимствованные шаблоны и
методы, поэтому раскрытие даёт ровно тот набор, на который права дать можно.
Нет метаданных или вложенных объектов — отказ с подсказкой формата.

Плюс отказ, когда основная роль расширения (из DefaultRoles) даёт права на
заимствованный сервис: платформа такое расширение не принимает.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nick Shirokov
2026-08-17 16:19:43 +03:00
co-authored by Claude Opus 5
parent 4967b752d7
commit 756bd857dd
4 changed files with 333 additions and 12 deletions
+15 -2
View File
@@ -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`.
+8 -3
View File
@@ -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)
@@ -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" }
}
}
# Роль расширения, включённая в <DefaultRoles>, прав на заимствованные объекты давать не
# может — платформа отвечает «Назначение прав доступа на заимствованные объекты основными
# ролями в расширениях недопустимо». Считаем один раз: имя роли за прогон не меняется.
$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 '<ConfigurationExtensionPurpose>' -and $text -match '(?s)<DefaultRoles>(.*?)</DefaultRoles>') {
# -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
@@ -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"
# Роль расширения, включённая в <DefaultRoles>, прав на заимствованные объекты давать не
# может — платформа отвечает «Назначение прав доступа на заимствованные объекты основными
# ролями в расширениях недопустимо». Считаем один раз: имя роли за прогон не меняется.
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 '<ConfigurationExtensionPurpose>' in text:
m = re.search(r'<DefaultRoles>(.*?)</DefaultRoles>', 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), и частично записанная роль хуже отсутствующей. Печатаем все