# role-edit v1.7 — Edit existing 1C role rights in place # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills [CmdletBinding(PositionalBinding=$false)] param( [Parameter(Mandatory)][Alias('Path','RightsPath')][string]$RolePath, [string]$DefinitionFile, [ValidateSet("add-rights","set-rights","remove-rights","deny-rights","set-rls","remove-rls", "add-template","set-template","remove-template","modify-property","set-synonym","set-comment")] [string]$Operation, [string]$Value, [switch]$NoValidate ) $ErrorActionPreference = "Stop" [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 # --- Разбор пользовательского JSON --- function ConvertFrom-JsonInput([string]$text, [string]$source, [string]$expected, [switch]$Inline) { try { # PS 5.1 на пустой строке отдаёт $null, а не ошибку — навык уходил дальше с $null, # тогда как py-порт падал. Проверяем сами, чтобы порты вели себя одинаково. if ([string]::IsNullOrWhiteSpace($text)) { throw 'input is empty' } $parsed = $text | ConvertFrom-Json } catch { $what = if ($expected) { "$source expects $expected" } else { "Invalid JSON in $source" } if ($Inline) { $got = ($text -replace '\s+', ' ').Trim() $label = 'got' if (-not $got) { $got = '(empty)' } elseif ($got.Length -gt 60) { $label = 'got (first 60 chars)'; $got = $got.Substring(0, 60) } $what = "${what}, ${label}: ${got}" } [Console]::Error.WriteLine("[ERROR] ${what} ($($_.Exception.Message))") exit 1 } Write-Output -NoEnumerate $parsed } # --- Чтение входного JSON-файла --- # Кодировку берём из BOM — это объявление самого файла, а не догадка. Без BOM ждём строгий UTF-8: # Get-Content -Encoding UTF8 на файле в cp1251 тихо меняет кириллицу на U+FFFD, JSON после этого # разбирается успешно, и в конфигурацию уезжает имя из «замен». Кодовую страницу не подбираем: # угаданное имя уйдёт в метаданные так же молча. function Read-JsonInputFile([string]$path) { # Проверка здесь, а не по навыкам: часть навыков проверяла путь сама, часть — нет, и один и тот # же промах давал то внятную строку, то дамп MethodInvocationException. Навыки со своей # проверкой срабатывают раньше и сохраняют свой текст. if (-not (Test-Path -LiteralPath $path)) { [Console]::Error.WriteLine("[ERROR] File not found: $path") exit 1 } if (Test-Path -LiteralPath $path -PathType Container) { [Console]::Error.WriteLine("[ERROR] Expected a JSON file, got a directory: $path") exit 1 } $bytes = [System.IO.File]::ReadAllBytes($path) if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) { return [System.Text.Encoding]::UTF8.GetString($bytes, 3, $bytes.Length - 3) } if ($bytes.Length -ge 2 -and $bytes[0] -eq 0xFF -and $bytes[1] -eq 0xFE) { return [System.Text.Encoding]::Unicode.GetString($bytes, 2, $bytes.Length - 2) } if ($bytes.Length -ge 2 -and $bytes[0] -eq 0xFE -and $bytes[1] -eq 0xFF) { return [System.Text.Encoding]::BigEndianUnicode.GetString($bytes, 2, $bytes.Length - 2) } try { return (New-Object System.Text.UTF8Encoding($false, $true)).GetString($bytes) } catch { $detail = if ($_.Exception.InnerException) { $_.Exception.InnerException.Message } else { $_.Exception.Message } [Console]::Error.WriteLine("[ERROR] ${path} is not valid UTF-8: ${detail} - save the file as UTF-8, or add a BOM if it is UTF-16") exit 1 } } # --- Support guard (Ext/ParentConfigurations.bin) --- # See docs/1c-support-state-spec.md. Blocks edits of vendor objects "на замке" / # read-only configs unless allowed. Trigger = bin present; reaction from # .v8-project.json editingAllowedCheck (deny|warn|off, default deny). Never # throws — guard errors degrade to allow. function Get-RootUuid([string]$xmlPath) { if (-not (Test-Path $xmlPath)) { return $null } try { [xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8 $el = $mx.DocumentElement.FirstChild while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling } if ($el) { $u = $el.GetAttribute("uuid"); if ($u) { return $u } } } catch {} return $null } function Test-ExternalObjectRoot([string]$xmlPath) { if (-not (Test-Path $xmlPath)) { return $false } try { [xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8 $el = $mx.DocumentElement.FirstChild while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling } if ($el) { return @('ExternalDataProcessor','ExternalReport') -contains $el.LocalName } } catch {} return $false } function Find-V8Project([string]$startDir) { $d = $startDir for ($i = 0; $i -lt 20 -and $d; $i++) { $pj = Join-Path $d ".v8-project.json" if (Test-Path $pj) { return $pj } $parent = [System.IO.Path]::GetDirectoryName($d) if ($parent -eq $d) { break } $d = $parent } return $null } function Get-EditMode([string]$cfgDir) { try { $pj = Find-V8Project (Get-Location).Path if (-not $pj) { $pj = Find-V8Project $cfgDir } if (-not $pj) { return 'deny' } $proj = Get-Content -Raw $pj | ConvertFrom-Json $cfgFull = [System.IO.Path]::GetFullPath($cfgDir).TrimEnd('\', '/') if ($proj.databases) { foreach ($db in $proj.databases) { if ($db.configSrc) { $src = [System.IO.Path]::GetFullPath($db.configSrc).TrimEnd('\', '/') if ($cfgFull -eq $src -or $cfgFull.StartsWith($src + [System.IO.Path]::DirectorySeparatorChar)) { if ($db.editingAllowedCheck) { return $db.editingAllowedCheck } } } } } if ($proj.editingAllowedCheck) { return $proj.editingAllowedCheck } return 'deny' } catch { return 'deny' } } function Assert-EditAllowed([string]$targetPath, [string]$require) { try { $rp = $targetPath try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {} # Autonomous external object (EPF/ERF): never part of a config on support (issue #39). if (Test-ExternalObjectRoot $rp) { return } $elemUuid = Get-RootUuid $rp $cfgDir = $null; $binPath = $null $d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) } for ($i = 0; $i -lt 12 -and $d; $i++) { if (Test-ExternalObjectRoot "$d.xml") { return } if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" } if (-not $cfgDir) { $cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin" if ((Test-Path $cand) -or (Test-Path (Join-Path $d "Configuration.xml"))) { $cfgDir = $d; $binPath = $cand } } if ($elemUuid -and $cfgDir) { break } $parent = [System.IO.Path]::GetDirectoryName($d) if ($parent -eq $d) { break } $d = $parent } # New object (no element file): fall back to config root uuid. if (-not $elemUuid -and $cfgDir) { $elemUuid = Get-RootUuid (Join-Path $cfgDir "Configuration.xml") } if (-not $binPath -or -not (Test-Path $binPath)) { return } $bytes = [System.IO.File]::ReadAllBytes($binPath) if ($bytes.Length -le 32) { return } $start = 0 if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) { $start = 3 } $text = [System.Text.Encoding]::UTF8.GetString($bytes, $start, $bytes.Length - $start) $hm = [regex]::Match($text, '^\{6,(\d+),(\d+),') if (-not $hm.Success) { return } $G = [int]$hm.Groups[1].Value $K = [int]$hm.Groups[2].Value if ($K -eq 0) { return } $best = $null if ($elemUuid) { $u = [regex]::Escape($elemUuid.ToLower()) foreach ($m in [regex]::Matches($text, "([0-2]),0,$u")) { $f1 = [int]$m.Groups[1].Value if ($null -eq $best -or $f1 -lt $best) { $best = $f1 } } } $blocked = $false; $code = ""; $reason = "" if ($G -eq 1) { $blocked = $true; $code = "capability-off"; $reason = "возможность изменения конфигурации выключена (вся конфигурация read-only)" } elseif ($require -eq 'removed') { if ($null -ne $best -and $best -ne 2) { $blocked = $true; $code = "not-removed"; $reason = "объект не снят с поддержки — удаление сломает обновления" } } else { if ($null -ne $best -and $best -eq 0) { $blocked = $true; $code = "locked"; $reason = "объект на замке — редактирование сломает обновления" } } if (-not $blocked) { return } $mode = Get-EditMode $cfgDir if ($mode -eq 'off') { return } # Use Console.Error (not Write-Error) — under ErrorActionPreference=Stop the # latter throws and would be swallowed by this function's own catch. if ($mode -eq 'warn') { [Console]::Error.WriteLine("[support-guard] ПРЕДУПРЕЖДЕНИЕ: $reason. Цель: $rp"); return } $head = "[support-guard] Редактирование отклонено: это объект типовой конфигурации на поддержке поставщика, прямое редактирование молча сломает будущие обновления." $cfe = "Рекомендуемый путь: внести доработку в расширение (навыки cfe-borrow / cfe-patch-method) — состояние поддержки менять не нужно, обновления вендора сохраняются." $offNote = "Снять проверку для этой базы: editingAllowedCheck = warn|off в .v8-project.json." if ($code -eq "capability-off") { $state = "Состояние: у всей конфигурации выключена возможность изменения (режим read-only «из коробки») — поэтому объект «$rp» редактировать нельзя." $fix = "Либо снять защиту явно (навык support-edit, два шага):`n 1. support-edit -Path ""$cfgDir"" -Capability on — включить возможность изменения (объекты пока остаются на замке);`n 2. support-edit -Path ""$rp"" -Set editable — открыть этот объект для редактирования.`n Изменение применяется в базу полной загрузкой выгрузки и обходит механизм обновлений вендора." } elseif ($code -eq "not-removed") { $state = "Состояние: объект «$rp» на поддержке (не снят с поддержки) — его удаление разорвёт обновления вендора." $fix = "Либо сначала снять объект с поддержки, затем удалять:`n support-edit -Path ""$rp"" -Set off-support — объект уходит из-под обновлений, после этого удаление безопасно." } else { $state = "Состояние: объект «$rp» на замке (возможность изменения конфигурации включена, но сам объект не редактируется)." $fix = "Либо разрешить редактирование этого объекта (навык support-edit, выбрать одно):`n support-edit -Path ""$rp"" -Set editable — редактировать и дальше получать обновления вендора (возможны конфликты слияния);`n support-edit -Path ""$rp"" -Set off-support — снять с поддержки: обновления по объекту больше не приходят." } [Console]::Error.WriteLine("$head`n$state`n$cfe`n$fix`n$offNote") exit 1 } catch { return } } # --- 3. Russian synonyms → canonical English names --- $script:typeAliases = @{ "Справочник" = "Catalog" "Документ" = "Document" "РегистрСведений" = "InformationRegister" "РегистрНакопления" = "AccumulationRegister" "РегистрБухгалтерии" = "AccountingRegister" "РегистрРасчета" = "CalculationRegister" "РегистрРасчёта" = "CalculationRegister" "Константа" = "Constant" "ПланСчетов" = "ChartOfAccounts" "ПланВидовХарактеристик" = "ChartOfCharacteristicTypes" "ПланВидовРасчета" = "ChartOfCalculationTypes" "ПланВидовРасчёта" = "ChartOfCalculationTypes" "ПланОбмена" = "ExchangePlan" "БизнесПроцесс" = "BusinessProcess" "Задача" = "Task" "Обработка" = "DataProcessor" "Отчет" = "Report" "Отчёт" = "Report" "ОбщаяФорма" = "CommonForm" "ОбщаяКоманда" = "CommonCommand" "Подсистема" = "Subsystem" "КритерийОтбора" = "FilterCriterion" "ЖурналДокументов" = "DocumentJournal" "Последовательность" = "Sequence" "ВебСервис" = "WebService" "HTTPСервис" = "HTTPService" "СервисИнтеграции" = "IntegrationService" "ПараметрСеанса" = "SessionParameter" "ОбщийРеквизит" = "CommonAttribute" "Конфигурация" = "Configuration" "ВнешнийИсточникДанных" = "ExternalDataSource" # Типы без прав в ролях: алиасы нужны не ради генерации, а ради отказа по делу — # иначе на русскую запись навык ответит «неизвестный тип 'ОбщийМодуль'». "Перечисление" = "Enum" "ОбщийМодуль" = "CommonModule" "ОпределяемыйТип" = "DefinedType" "ОбщаяКартинка" = "CommonPicture" "ОбщийМакет" = "CommonTemplate" "Язык" = "Language" "ФункциональнаяОпция" = "FunctionalOption" "ПараметрФункциональныхОпций" = "FunctionalOptionsParameter" "ПодпискаНаСобытие" = "EventSubscription" "РегламентноеЗадание" = "ScheduledJob" "ЭлементСтиля" = "StyleItem" "ХранилищеНастроек" = "SettingsStorage" "ПакетXDTO" = "XDTOPackage" "WSСсылка" = "WSReference" "Нумератор" = "DocumentNumerator" # Nested "Реквизит" = "Attribute" "СтандартныйРеквизит" = "StandardAttribute" "ТабличнаяЧасть" = "TabularSection" "Измерение" = "Dimension" "Ресурс" = "Resource" "Команда" = "Command" "РеквизитАдресации" = "AddressingAttribute" } $script:rightAliases = @{ "Чтение" = "Read" "Добавление" = "Insert" "Изменение" = "Update" "Удаление" = "Delete" "Просмотр" = "View" "Редактирование" = "Edit" "ВводПоСтроке" = "InputByString" "Проведение" = "Posting" "ОтменаПроведения" = "UndoPosting" "ИнтерактивноеДобавление" = "InteractiveInsert" "ИнтерактивнаяПометкаУдаления" = "InteractiveSetDeletionMark" "ИнтерактивноеСнятиеПометкиУдаления" = "InteractiveClearDeletionMark" "ИнтерактивноеУдаление" = "InteractiveDelete" "ИнтерактивноеУдалениеПомеченных" = "InteractiveDeleteMarked" "ИнтерактивноеПроведение" = "InteractivePosting" "ИнтерактивноеПроведениеНеоперативное" = "InteractivePostingRegular" "ИнтерактивнаяОтменаПроведения" = "InteractiveUndoPosting" "ИнтерактивноеИзменениеПроведенных" = "InteractiveChangeOfPosted" "Использование" = "Use" "Получение" = "Get" "Установка" = "Set" "Старт" = "Start" "ИнтерактивныйСтарт" = "InteractiveStart" "ИнтерактивнаяАктивация" = "InteractiveActivate" "Выполнение" = "Execute" "ИнтерактивноеВыполнение" = "InteractiveExecute" "УправлениеИтогами" = "TotalsControl" "Администрирование" = "Administration" "АдминистрированиеДанных" = "DataAdministration" "ТонкийКлиент" = "ThinClient" "ВебКлиент" = "WebClient" "ТолстыйКлиент" = "ThickClient" "ВнешнееСоединение" = "ExternalConnection" "Вывод" = "Output" "СохранениеДанныхПользователя" = "SaveUserData" "МобильныйКлиент" = "MobileClient" } # Translate Russian object name to English (e.g. "Справочник.Контрагенты" → "Catalog.Контрагенты") function Translate-ObjectName { param([string]$name) $parts = $name.Split(".") $result = @() foreach ($p in $parts) { if ($script:typeAliases.ContainsKey($p)) { $result += $script:typeAliases[$p] } else { $result += $p } } return $result -join "." } # Translate Russian right name to English (e.g. "Чтение" → "Read") function Translate-RightName { param([string]$name) if ($script:rightAliases.ContainsKey($name)) { return $script:rightAliases[$name] } return $name } # --- 4. Known rights per object type (source: docs/1c-role-spec.md) --- $script:knownRights = @{ "Configuration" = @( "Administration","DataAdministration","UpdateDataBaseConfiguration", "ConfigurationExtensionsAdministration","ActiveUsers","EventLog","ExclusiveMode", "ThinClient","ThickClient","WebClient","MobileClient","ExternalConnection", "Automation","Output","SaveUserData","TechnicalSpecialistMode", "InteractiveOpenExtDataProcessors","InteractiveOpenExtReports", "AnalyticsSystemClient","CollaborationSystemInfoBaseRegistration", "MainWindowModeNormal","MainWindowModeWorkplace", "MainWindowModeEmbeddedWorkplace","MainWindowModeFullscreenWorkplace","MainWindowModeKiosk" ) "Catalog" = @( "Read","Insert","Update","Delete","View","Edit","InputByString", "InteractiveInsert","InteractiveSetDeletionMark","InteractiveClearDeletionMark", "InteractiveDelete","InteractiveDeleteMarked", "InteractiveDeletePredefinedData","InteractiveSetDeletionMarkPredefinedData", "InteractiveClearDeletionMarkPredefinedData","InteractiveDeleteMarkedPredefinedData", "ReadDataHistory","ViewDataHistory","UpdateDataHistory", "UpdateDataHistoryOfMissingData","ReadDataHistoryOfMissingData", "UpdateDataHistorySettings","UpdateDataHistoryVersionComment", "EditDataHistoryVersionComment","SwitchToDataHistoryVersion" ) "Document" = @( "Read","Insert","Update","Delete","View","Edit","InputByString", "Posting","UndoPosting", "InteractiveInsert","InteractiveSetDeletionMark","InteractiveClearDeletionMark", "InteractiveDelete","InteractiveDeleteMarked", "InteractivePosting","InteractivePostingRegular","InteractiveUndoPosting", "InteractiveChangeOfPosted", "ReadDataHistory","ViewDataHistory","UpdateDataHistory", "UpdateDataHistoryOfMissingData","ReadDataHistoryOfMissingData", "UpdateDataHistorySettings","UpdateDataHistoryVersionComment", "EditDataHistoryVersionComment","SwitchToDataHistoryVersion" ) "InformationRegister" = @( "Read","Update","View","Edit","TotalsControl", "ReadDataHistory","ViewDataHistory","UpdateDataHistory", "UpdateDataHistoryOfMissingData","ReadDataHistoryOfMissingData", "UpdateDataHistorySettings","UpdateDataHistoryVersionComment", "EditDataHistoryVersionComment","SwitchToDataHistoryVersion" ) "AccumulationRegister" = @("Read","Update","View","Edit","TotalsControl") "AccountingRegister" = @("Read","Update","View","Edit","TotalsControl") "CalculationRegister" = @( "Read","Update","View","Edit" ) "Constant" = @( "Read","Update","View","Edit", "ReadDataHistory","ViewDataHistory","UpdateDataHistory", "UpdateDataHistorySettings","UpdateDataHistoryVersionComment", "EditDataHistoryVersionComment","SwitchToDataHistoryVersion" ) "ChartOfAccounts" = @( "Read","Insert","Update","Delete" "View","Edit","InputByString","InteractiveInsert" "InteractiveSetDeletionMark","InteractiveClearDeletionMark","InteractiveDelete","InteractiveDeleteMarked" "InteractiveDeletePredefinedData","InteractiveSetDeletionMarkPredefinedData","InteractiveClearDeletionMarkPredefinedData","InteractiveDeleteMarkedPredefinedData" "ReadDataHistory","ReadDataHistoryOfMissingData","UpdateDataHistory","UpdateDataHistoryOfMissingData" "UpdateDataHistorySettings","UpdateDataHistoryVersionComment","ViewDataHistory","EditDataHistoryVersionComment" "SwitchToDataHistoryVersion" ) "ChartOfCharacteristicTypes" = @( "Read","Insert","Update","Delete","View","Edit","InputByString", "InteractiveInsert","InteractiveSetDeletionMark","InteractiveClearDeletionMark", "InteractiveDelete","InteractiveDeleteMarked", "InteractiveDeletePredefinedData","InteractiveSetDeletionMarkPredefinedData", "InteractiveClearDeletionMarkPredefinedData","InteractiveDeleteMarkedPredefinedData", "ReadDataHistory","ViewDataHistory","UpdateDataHistory", "ReadDataHistoryOfMissingData","UpdateDataHistoryOfMissingData", "UpdateDataHistorySettings","UpdateDataHistoryVersionComment", "EditDataHistoryVersionComment","SwitchToDataHistoryVersion" ) "ChartOfCalculationTypes" = @( "Read","Insert","Update","Delete" "View","Edit","InputByString","InteractiveInsert" "InteractiveSetDeletionMark","InteractiveClearDeletionMark","InteractiveDelete","InteractiveDeleteMarked" "InteractiveDeletePredefinedData","InteractiveSetDeletionMarkPredefinedData","InteractiveClearDeletionMarkPredefinedData","InteractiveDeleteMarkedPredefinedData" "ReadDataHistory","ReadDataHistoryOfMissingData","UpdateDataHistory","UpdateDataHistoryOfMissingData" "UpdateDataHistorySettings","UpdateDataHistoryVersionComment","ViewDataHistory","EditDataHistoryVersionComment" "SwitchToDataHistoryVersion" ) "ExchangePlan" = @( "Read","Insert","Update","Delete","View","Edit","InputByString", "InteractiveInsert","InteractiveSetDeletionMark","InteractiveClearDeletionMark", "InteractiveDelete","InteractiveDeleteMarked", "ReadDataHistory","ViewDataHistory","UpdateDataHistory", "ReadDataHistoryOfMissingData","UpdateDataHistoryOfMissingData", "UpdateDataHistorySettings","UpdateDataHistoryVersionComment", "EditDataHistoryVersionComment","SwitchToDataHistoryVersion" ) "BusinessProcess" = @( "Read","Insert","Update","Delete" "View","Edit","InputByString","Start" "InteractiveInsert","InteractiveSetDeletionMark","InteractiveClearDeletionMark","InteractiveDelete" "InteractiveDeleteMarked","InteractiveActivate","InteractiveStart","ReadDataHistory" "ReadDataHistoryOfMissingData","UpdateDataHistory","UpdateDataHistoryOfMissingData","UpdateDataHistorySettings" "UpdateDataHistoryVersionComment","ViewDataHistory","EditDataHistoryVersionComment","SwitchToDataHistoryVersion" ) "Task" = @( "Read","Insert","Update","Delete" "View","Edit","InputByString","Execute" "InteractiveInsert","InteractiveSetDeletionMark","InteractiveClearDeletionMark","InteractiveDelete" "InteractiveDeleteMarked","InteractiveActivate","InteractiveExecute","ReadDataHistory" "ReadDataHistoryOfMissingData","UpdateDataHistory","UpdateDataHistoryOfMissingData","UpdateDataHistorySettings" "UpdateDataHistoryVersionComment","ViewDataHistory","EditDataHistoryVersionComment","SwitchToDataHistoryVersion" ) "DataProcessor" = @("Use","View") "Report" = @("Use","View") "CommonForm" = @("View") "CommonCommand" = @("View") "Subsystem" = @("View") "FilterCriterion" = @("View") "DocumentJournal" = @("Read","View") "Sequence" = @("Read","Update") "WebService" = @("Use") "HTTPService" = @("Use") "IntegrationService" = @("Use") "SessionParameter" = @("Get","Set") "CommonAttribute" = @("View","Edit") "ExternalDataSource" = @( "Use","Administration","StandardAuthenticationChange", "SessionStandardAuthenticationChange","SessionOSAuthenticationChange" ) } # Виды вложенности (предпоследний сегмент пути) → допустимые права. Списки сняты с корпуса # типовых конфигураций и с выгрузки роли, где права проставлены по всему дереву редактора: # догадкам тут не место — закрытый список превращает промах в ложный отказ. $script:nestedKindRights = @{ "Attribute" = @("View","Edit") "StandardAttribute" = @("View","Edit") "TabularSection" = @("View","Edit") "StandardTabularSection" = @("View","Edit") "Dimension" = @("View","Edit") "Resource" = @("View","Edit") "AccountingFlag" = @("View","Edit") "ExtDimensionAccountingFlag" = @("View","Edit") "AddressingAttribute" = @("View","Edit") "Field" = @("View","Edit") "Command" = @("View") "Subsystem" = @("View") "Operation" = @("Use") "Method" = @("Use") "IntegrationServiceChannel" = @("Use") "Recalculation" = @("Read","Update") "Cube" = @("Read","View") "DimensionTable" = @("Read","View") "Function" = @("Use","View") "Table" = @( "Read","Insert","Update","Delete","View","Edit","InputByString", "InteractiveInsert","InteractiveDelete" ) } # Виды, существующие только у одного типа-родителя: без этой привязки # `Catalog.Товары.Field.Цена` прошёл бы как валидный вложенный объект. $script:kindOwners = @{ "Table" = "ExternalDataSource" "Cube" = "ExternalDataSource" "Function" = "ExternalDataSource" "Field" = "ExternalDataSource" "DimensionTable" = "ExternalDataSource" "Recalculation" = "CalculationRegister" "Operation" = "WebService" "Method" = "HTTPService" "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 там, где платформа его не даёт. $script:nestedKindRightsByType = @{ "ExternalDataSource" = @{ "Dimension" = @("View") "Resource" = @("View") } } # Типы без прав в ролях (в дереве редактора ролей их нет). Таблица НЕ управляет поведением — # отказ даёт отсутствие типа в $knownRights; здесь только причина для сообщения. $script:noRightsTypes = @( "Enum","CommonModule","DefinedType","CommonPicture","CommonTemplate","Language", "FunctionalOption","FunctionalOptionsParameter","EventSubscription","ScheduledJob", "StyleItem","Style","SettingsStorage","XDTOPackage","WSReference","DocumentNumerator" ) # --- 4. Presets (@view, @edit) --- $script:presets = @{ "view" = @{ "Catalog" = @("Read","View","InputByString") "ExchangePlan" = @("Read","View","InputByString") "Document" = @("Read","View","InputByString") "ChartOfAccounts" = @("Read","View","InputByString") "ChartOfCharacteristicTypes" = @("Read","View","InputByString") "ChartOfCalculationTypes" = @("Read","View","InputByString") "BusinessProcess" = @("Read","View","InputByString") "Task" = @("Read","View","InputByString") "InformationRegister" = @("Read","View") "AccumulationRegister" = @("Read","View") "AccountingRegister" = @("Read","View") "CalculationRegister" = @("Read","View") "Constant" = @("Read","View") "DocumentJournal" = @("Read","View") "Sequence" = @("Read") "CommonForm" = @("View") "CommonCommand" = @("View") "Subsystem" = @("View") "FilterCriterion" = @("View") "SessionParameter" = @("Get") "CommonAttribute" = @("View") "DataProcessor" = @("Use","View") "Report" = @("Use","View") "Configuration" = @("ThinClient","WebClient","Output","SaveUserData","MainWindowModeNormal") } "edit" = @{ "Catalog" = @("Read","Insert","Update","Delete","View","Edit","InputByString","InteractiveInsert","InteractiveSetDeletionMark","InteractiveClearDeletionMark") "ExchangePlan" = @("Read","Insert","Update","Delete","View","Edit","InputByString","InteractiveInsert","InteractiveSetDeletionMark","InteractiveClearDeletionMark") "Document" = @("Read","Insert","Update","Delete","View","Edit","InputByString","Posting","UndoPosting","InteractiveInsert","InteractiveSetDeletionMark","InteractiveClearDeletionMark","InteractivePosting","InteractivePostingRegular","InteractiveUndoPosting","InteractiveChangeOfPosted") "ChartOfAccounts" = @("Read","Insert","Update","Delete","View","Edit","InputByString","InteractiveInsert","InteractiveSetDeletionMark","InteractiveClearDeletionMark") "ChartOfCharacteristicTypes" = @("Read","Insert","Update","Delete","View","Edit","InputByString","InteractiveInsert","InteractiveSetDeletionMark","InteractiveClearDeletionMark") "ChartOfCalculationTypes" = @("Read","Insert","Update","Delete","View","Edit","InputByString","InteractiveInsert","InteractiveSetDeletionMark","InteractiveClearDeletionMark") "BusinessProcess" = @("Read","Insert","Update","Delete","View","Edit","InputByString","Start","InteractiveInsert","InteractiveSetDeletionMark","InteractiveClearDeletionMark","InteractiveActivate","InteractiveStart") "Task" = @("Read","Insert","Update","Delete","View","Edit","InputByString","Execute","InteractiveInsert","InteractiveSetDeletionMark","InteractiveClearDeletionMark","InteractiveActivate","InteractiveExecute") "InformationRegister" = @("Read","Update","View","Edit") "AccumulationRegister" = @("Read","Update","View","Edit") "AccountingRegister" = @("Read","Update","View","Edit") "Constant" = @("Read","Update","View","Edit") "DocumentJournal" = @("Read","View") "Sequence" = @("Read","Update") "SessionParameter" = @("Get","Set") "CommonAttribute" = @("View","Edit") } } # --- 4a. Канонический порядок прав и узлов (замерено на платформе) --- # Платформа нормализует порядок внутри и порядок самих : # права идут в фиксированном для типа порядке, узлы — по uuid объекта метаданных. # Пишем сразу так же, иначе первая же выгрузка из Конфигуратора даст диф на ровном месте. $script:rightOrder = @{ "AccountingRegister" = @("Read","Update","View","Edit","TotalsControl") "AccumulationRegister" = @("Read","Update","View","Edit","TotalsControl") "BusinessProcess" = @( "Read","Insert","Update","Delete", "View","InteractiveInsert","Edit","InteractiveDelete", "InteractiveSetDeletionMark","InteractiveClearDeletionMark","InteractiveDeleteMarked","InputByString", "InteractiveActivate","Start","InteractiveStart","ReadDataHistory", "ReadDataHistoryOfMissingData","UpdateDataHistory","UpdateDataHistoryOfMissingData","UpdateDataHistorySettings", "UpdateDataHistoryVersionComment","ViewDataHistory","EditDataHistoryVersionComment","SwitchToDataHistoryVersion" ) "CalculationRegister" = @("Read","Update","View","Edit") "Catalog" = @( "Read","Insert","Update","Delete", "View","InteractiveInsert","Edit","InteractiveDelete", "InteractiveSetDeletionMark","InteractiveClearDeletionMark","InteractiveDeleteMarked","InputByString", "InteractiveDeletePredefinedData","InteractiveSetDeletionMarkPredefinedData","InteractiveClearDeletionMarkPredefinedData","InteractiveDeleteMarkedPredefinedData", "ReadDataHistory","ReadDataHistoryOfMissingData","UpdateDataHistory","UpdateDataHistoryOfMissingData", "UpdateDataHistorySettings","UpdateDataHistoryVersionComment","ViewDataHistory","EditDataHistoryVersionComment", "SwitchToDataHistoryVersion" ) "ChartOfAccounts" = @( "Read","Insert","Update","Delete", "View","InteractiveInsert","Edit","InteractiveDelete", "InteractiveSetDeletionMark","InteractiveClearDeletionMark","InteractiveDeleteMarked","InputByString", "InteractiveDeletePredefinedData","InteractiveSetDeletionMarkPredefinedData","InteractiveClearDeletionMarkPredefinedData","InteractiveDeleteMarkedPredefinedData", "ReadDataHistory","ReadDataHistoryOfMissingData","UpdateDataHistory","UpdateDataHistoryOfMissingData", "UpdateDataHistorySettings","UpdateDataHistoryVersionComment","ViewDataHistory","EditDataHistoryVersionComment", "SwitchToDataHistoryVersion" ) "ChartOfCalculationTypes" = @( "Read","Insert","Update","Delete", "View","InteractiveInsert","Edit","InteractiveDelete", "InteractiveSetDeletionMark","InteractiveClearDeletionMark","InteractiveDeleteMarked","InputByString", "InteractiveDeletePredefinedData","InteractiveSetDeletionMarkPredefinedData","InteractiveClearDeletionMarkPredefinedData","InteractiveDeleteMarkedPredefinedData", "ReadDataHistory","ReadDataHistoryOfMissingData","UpdateDataHistory","UpdateDataHistoryOfMissingData", "UpdateDataHistorySettings","UpdateDataHistoryVersionComment","ViewDataHistory","EditDataHistoryVersionComment", "SwitchToDataHistoryVersion" ) "ChartOfCharacteristicTypes" = @( "Read","Insert","Update","Delete", "View","InteractiveInsert","Edit","InteractiveDelete", "InteractiveSetDeletionMark","InteractiveClearDeletionMark","InteractiveDeleteMarked","InputByString", "InteractiveDeletePredefinedData","InteractiveSetDeletionMarkPredefinedData","InteractiveClearDeletionMarkPredefinedData","InteractiveDeleteMarkedPredefinedData", "ReadDataHistory","ReadDataHistoryOfMissingData","UpdateDataHistory","UpdateDataHistoryOfMissingData", "UpdateDataHistorySettings","UpdateDataHistoryVersionComment","ViewDataHistory","EditDataHistoryVersionComment", "SwitchToDataHistoryVersion" ) "CommonAttribute" = @("View","Edit") "CommonCommand" = @("View") "CommonForm" = @("View") "Configuration" = @( "Administration","DataAdministration","UpdateDataBaseConfiguration","ExclusiveMode", "ActiveUsers","EventLog","ThinClient","WebClient", "MobileClient","ThickClient","ExternalConnection","Automation", "TechnicalSpecialistMode","CollaborationSystemInfoBaseRegistration","MainWindowModeNormal","MainWindowModeWorkplace", "MainWindowModeEmbeddedWorkplace","MainWindowModeFullscreenWorkplace","MainWindowModeKiosk","AnalyticsSystemClient", "SaveUserData","ConfigurationExtensionsAdministration","InteractiveOpenExtDataProcessors","InteractiveOpenExtReports", "Output" ) "Constant" = @( "Read","Update","View","Edit", "ReadDataHistory","UpdateDataHistory","UpdateDataHistorySettings","UpdateDataHistoryVersionComment", "ViewDataHistory","EditDataHistoryVersionComment","SwitchToDataHistoryVersion" ) "DataProcessor" = @("Use","View") "Document" = @( "Read","Insert","Update","Delete", "Posting","UndoPosting","View","InteractiveInsert", "Edit","InteractiveDelete","InteractiveSetDeletionMark","InteractiveClearDeletionMark", "InteractiveDeleteMarked","InteractivePosting","InteractivePostingRegular","InteractiveUndoPosting", "InteractiveChangeOfPosted","InputByString","ReadDataHistory","ReadDataHistoryOfMissingData", "UpdateDataHistory","UpdateDataHistoryOfMissingData","UpdateDataHistorySettings","UpdateDataHistoryVersionComment", "ViewDataHistory","EditDataHistoryVersionComment","SwitchToDataHistoryVersion" ) "DocumentJournal" = @("Read","View") "ExchangePlan" = @( "Read","Insert","Update","Delete", "View","InteractiveInsert","Edit","InteractiveDelete", "InteractiveSetDeletionMark","InteractiveClearDeletionMark","InteractiveDeleteMarked","InputByString", "ReadDataHistory","ReadDataHistoryOfMissingData","UpdateDataHistory","UpdateDataHistoryOfMissingData", "UpdateDataHistorySettings","UpdateDataHistoryVersionComment","ViewDataHistory","EditDataHistoryVersionComment", "SwitchToDataHistoryVersion" ) "FilterCriterion" = @("View") "HTTPService" = @("Use") "InformationRegister" = @( "Read","Update","View","Edit", "TotalsControl","ReadDataHistory","ReadDataHistoryOfMissingData","UpdateDataHistory", "UpdateDataHistoryOfMissingData","UpdateDataHistorySettings","UpdateDataHistoryVersionComment","ViewDataHistory", "EditDataHistoryVersionComment","SwitchToDataHistoryVersion" ) "IntegrationService" = @("Use") "Report" = @("Use","View") "Sequence" = @("Read","Update") "SessionParameter" = @("Get","Set") "Subsystem" = @("View") "Task" = @( "Read","Insert","Update","Delete", "View","InteractiveInsert","Edit","InteractiveDelete", "InteractiveSetDeletionMark","InteractiveClearDeletionMark","InteractiveDeleteMarked","InputByString", "InteractiveActivate","Execute","InteractiveExecute","ReadDataHistory", "ReadDataHistoryOfMissingData","UpdateDataHistory","UpdateDataHistoryOfMissingData","UpdateDataHistorySettings", "UpdateDataHistoryVersionComment","ViewDataHistory","EditDataHistoryVersionComment","SwitchToDataHistoryVersion" ) "WebService" = @("Use") } $script:nestedRightOrder = @{ "AccountingFlag" = @("View","Edit") "AddressingAttribute" = @("View","Edit") "Attribute" = @("View","Edit") "Command" = @("View") "Dimension" = @("View","Edit") "ExtDimensionAccountingFlag" = @("View","Edit") "IntegrationServiceChannel" = @("Use") "Method" = @("Use") "Operation" = @("Use") "Recalculation" = @("Read","Update") "Resource" = @("View","Edit") "StandardAttribute" = @("View","Edit") "StandardTabularSection" = @("View","Edit") "Subsystem" = @("View") "TabularSection" = @("View","Edit") } # Каталоги объектов метаданных — нужны, чтобы прочитать uuid и расставить . $script:typeDirs = @{ "Catalog"="Catalogs"; "Document"="Documents"; "DocumentJournal"="DocumentJournals" "Sequence"="Sequences"; "Constant"="Constants"; "Report"="Reports"; "DataProcessor"="DataProcessors" "InformationRegister"="InformationRegisters"; "AccumulationRegister"="AccumulationRegisters" "AccountingRegister"="AccountingRegisters"; "CalculationRegister"="CalculationRegisters" "ChartOfAccounts"="ChartsOfAccounts"; "ChartOfCharacteristicTypes"="ChartsOfCharacteristicTypes" "ChartOfCalculationTypes"="ChartsOfCalculationTypes"; "ExchangePlan"="ExchangePlans" "BusinessProcess"="BusinessProcesses"; "Task"="Tasks"; "Subsystem"="Subsystems" "CommonForm"="CommonForms"; "CommonCommand"="CommonCommands"; "CommonAttribute"="CommonAttributes" "FilterCriterion"="FilterCriteria"; "SessionParameter"="SessionParameters" "WebService"="WebServices"; "HTTPService"="HTTPServices"; "IntegrationService"="IntegrationServices" "ExternalDataSource"="ExternalDataSources" } # Порядок прав объекта: известные — по таблице, незнакомые — следом, в порядке ввода. function Sort-RightsCanonical { param([string]$objName, $rights) $parts = $objName -split '\.' $order = if ($parts.Count -ge 3) { $script:nestedRightOrder[$parts[$parts.Count-2]] } else { $script:rightOrder[$parts[0]] } if (-not $order) { return $rights } $byName = @{} foreach ($r in $rights) { if (-not $byName.ContainsKey($r.Name)) { $byName[$r.Name] = $r } } $sorted = @() foreach ($name in $order) { if ($byName.ContainsKey($name)) { $sorted += ,$byName[$name]; $byName.Remove($name) } } foreach ($r in $rights) { if ($byName.ContainsKey($r.Name)) { $sorted += ,$r; $byName.Remove($r.Name) } } return $sorted } # У стандартных реквизитов и стандартных табличных частей uuid в выгрузке нет: они системные. # Отсутствие uuid для них — норма, а не потерянный объект. function Test-StandardKind { param([string]$objName) $parts = $objName -split '\\.' if ($parts.Count -lt 3) { return $false } return $parts[$parts.Count-2].StartsWith("Standard") } # uuid объекта прав: у верхнего уровня — из файла объекта, у вложенного — спуском по дереву. # Искать регуляркой по всему файлу нельзя: реквизит шапки и реквизит табличной части часто # называются одинаково, и поиск нашёл бы первый попавшийся. Дочерние подсистемы лежат # отдельными файлами, поэтому для них спуск идёт по каталогам. # У стандартных реквизитов uuid в выгрузке нет вовсе — для них возвращаем $null молча. function Get-RightsObjectUuid { param([string]$objName, [string]$configRoot) $parts = $objName -split '\.' if ($parts[0] -eq 'Configuration') { $cfgPath = Join-Path $configRoot "Configuration.xml" if (-not (Test-Path $cfgPath)) { return $null } $head = [System.IO.File]::ReadAllText($cfgPath) if ($head -match '; Added = <что дописано> }. # Платформа хранит только то, что ОТЛИЧАЕТСЯ от значения по умолчанию для роли: при # setForNewObjects=false на верхнем уровне живут разрешения, при true — запреты; у реквизитных # вложенных объектов ту же роль играет setForAttributesByDefault. Совпавшее с умолчанием # платформа выбрасывает при первой же загрузке, поэтому не пишем его и сами. $script:attributeKinds = @( "Attribute","StandardAttribute","TabularSection","StandardTabularSection", "Dimension","Resource","AccountingFlag","ExtDimensionAccountingFlag","AddressingAttribute" ) function Get-DefaultRightValue { param([string]$objName, [string]$setForNewObjects, [string]$setForAttributesByDefault) $parts = $objName -split '\.' if ($parts.Count -lt 3) { return $setForNewObjects } # Внешние источники данных под это правило не проверялись — трогаем только то, что замерено. if ($parts[0] -eq 'ExternalDataSource') { return "false" } $kind = $parts[$parts.Count-2] if ($script:attributeKinds -contains $kind) { return $setForAttributesByDefault } # Команды, подсистемы, операции сервисов флагами роли не управляются — там живут разрешения. return "false" } function Close-RightsDependencies { param([string]$objName, $rights, [int]$formatRank) $parts = $objName -split '\.' $nested = $parts.Count -ge 3 $objectType = $parts[0] $allowed = if ($nested) { Get-NestedRights -objectType $objectType -kind (Get-NestedKind $objName) } else { $script:knownRights[$objectType] } if (-not $allowed) { return @{ Rights = $rights; Added = @() } } $have = [ordered]@{} foreach ($r in $rights) { if (-not $have.Contains($r.Name)) { $have[$r.Name] = $r } } $byType = $script:rightDepsByType[$objectType] $added = @() # Вперёд — только от РАЗРЕШЁННЫХ прав: платформа замыкает выданное, а не запрещённое. $queue = @($have.Keys | Where-Object { $have[$_].Value -eq "true" }) while ($queue.Count -gt 0) { $name = $queue[0] $queue = @($queue | Select-Object -Skip 1) $need = if ($byType -and $byType.Contains($name)) { $byType[$name] } else { $script:rightDeps[$name] } if (-not $need) { continue } foreach ($dep in $need) { if ($allowed -notcontains $dep) { continue } if ($have.Contains($dep)) { # Разрешение перебивает запрет — так поступает и платформа при загрузке. if ($have[$dep].Value -ne "true") { $have[$dep].Value = "true"; $added += $dep; $queue += $dep } continue } $have[$dep] = @{ Name = $dep; Value = "true"; Condition = $null } $added += $dep $queue += $dep } } # Назад — от ЗАПРЕТОВ: право, которому запрещённое нужно, платформа запрещает следом. $denyQueue = @($have.Keys | Where-Object { $have[$_].Value -ne "true" }) while ($denyQueue.Count -gt 0) { $name = $denyQueue[0] $denyQueue = @($denyQueue | Select-Object -Skip 1) foreach ($candidate in $allowed) { if ($candidate -eq $name) { continue } $need = if ($byType -and $byType.Contains($candidate)) { $byType[$candidate] } else { $script:rightDeps[$candidate] } if (-not $need -or $need -notcontains $name) { continue } if ($have.Contains($candidate)) { continue } $have[$candidate] = @{ Name = $candidate; Value = "false"; Condition = $null } $added += $candidate $denyQueue += $candidate } } if ($objectType -eq 'Configuration' -and $formatRank -le $script:configurationLegacyRank -and $have.Count -gt 0) { foreach ($dep in $script:configurationLegacyDeps) { if ($have.Contains($dep)) { continue } $have[$dep] = @{ Name = $dep; Value = "true"; Condition = $null } $added += $dep } } $result = @() foreach ($k in $have.Keys) { $result += ,$have[$k] } return @{ Rights = $result; Added = $added } } # --- 5. Helpers --- function Get-ObjectType { param([string]$objectName) $dotIdx = $objectName.IndexOf(".") if ($dotIdx -lt 0) { return $objectName } return $objectName.Substring(0, $dotIdx) } function Is-NestedObject { param([string]$objectName) return ($objectName.Split(".").Count -ge 3) } # Вид вложенности — предпоследний сегмент: путь бывает и восьмисегментным # (ExternalDataSource.И.Cube.К.DimensionTable.Т.Field.П), считать от конца. function Get-NestedKind { param([string]$objectName) $parts = $objectName.Split(".") if ($parts.Count -lt 3) { return $null } return $parts[$parts.Count - 2] } function Get-NestedRights { param([string]$objectType, [string]$kind) if ($script:nestedKindRightsByType.ContainsKey($objectType) -and $script:nestedKindRightsByType[$objectType].ContainsKey($kind)) { return @($script:nestedKindRightsByType[$objectType][$kind]) } if ($script:nestedKindRights.ContainsKey($kind)) { return @($script:nestedKindRights[$kind]) } return $null } # Отказ копится, а не печатается сразу: роль пишется целиком, поэтому единственный # безопасный момент отказа — до первой записи, и показать надо все причины сразу. $script:validationErrors = @() function Add-ValidationError { param([string]$message) $script:validationErrors += $message } # Проверка имени объекта: тип по белому списку (всегда, включая вложенные пути) и вид # вложенности. Запрещённый и незнакомый тип — разные диагнозы. function Validate-ObjectName { param([string]$objectName) $objectType = Get-ObjectType $objectName if (-not $script:knownRights.ContainsKey($objectType)) { if ($script:noRightsTypes -contains $objectType) { Add-ValidationError "${objectName}: тип '$objectType' не имеет прав в роли — уберите объект из списка" } else { $similar = @($script:knownRights.Keys | Where-Object { $_ -like "*$objectType*" -or $objectType -like "*$_*" }) $sug = if ($similar.Count -gt 0) { " Возможно: $(($similar | Select-Object -First 3) -join ', ')?" } else { "" } Add-ValidationError "${objectName}: неизвестный тип объекта '$objectType'.$sug" } return $false } if (Is-NestedObject $objectName) { $kind = Get-NestedKind $objectName if ($script:kindOwners.ContainsKey($kind) -and $objectType -ne $script:kindOwners[$kind]) { Add-ValidationError "${objectName}: вид '$kind' бывает только у $($script:kindOwners[$kind])" return $false } if ($null -eq (Get-NestedRights $objectType $kind)) { Add-ValidationError "${objectName}: неизвестный вид вложенности '$kind'" return $false } } return $true } function Resolve-Preset { param([string]$objectType, [string]$presetName) $preset = $presetName.TrimStart('@') if (-not $script:presets.ContainsKey($preset)) { Write-Warning "Unknown preset '@$preset'. Known: @view, @edit" return @() } $typeMap = $script:presets[$preset] if (-not $typeMap.ContainsKey($objectType)) { $available = @() foreach ($k in $script:presets.Keys) { if ($script:presets[$k].ContainsKey($objectType)) { $available += "@$k" } } $availStr = if ($available.Count -gt 0) { $available -join ", " } else { "none" } Write-Warning "Preset '@$preset' not defined for type '$objectType'. Available: $availStr" return @() } return @($typeMap[$objectType]) } function Validate-RightName { param([string]$objectName, [string]$rightName) $objectType = Get-ObjectType $objectName # Тип уже проверен Validate-ObjectName — здесь только права, иначе про один # запрещённый тип напечатается столько строк, сколько у него перечислено прав. if (-not $script:knownRights.ContainsKey($objectType)) { return $false } if (Is-NestedObject $objectName) { $kind = Get-NestedKind $objectName $validNested = Get-NestedRights $objectType $kind if ($null -eq $validNested) { return $false } if ($rightName -notin $validNested) { Add-ValidationError "${objectName}: право '$rightName' недопустимо для вида '$kind' (допустимо: $($validNested -join ', '))" return $false } return $true } $validRights = $script:knownRights[$objectType] if ($rightName -notin $validRights) { $suggestions = @($validRights | Where-Object { $_ -like "*$rightName*" -or $rightName -like "*$_*" }) $sugStr = if ($suggestions.Count -gt 0) { " Возможно: $(($suggestions | Select-Object -First 3) -join ', ')?" } else { "" } Add-ValidationError "${objectName}: право '$rightName' не существует у типа '$objectType'.$sugStr" return $false } return $true } function Resolve-TextFromFile { param([string]$val, [string]$baseDir) if (-not $val.StartsWith("@")) { return $val } $filePath = $val.Substring(1) if ([System.IO.Path]::IsPathRooted($filePath)) { $candidates = @($filePath) } else { $candidates = @( (Join-Path $baseDir $filePath), (Join-Path (Get-Location).Path $filePath) ) } foreach ($c in $candidates) { if (Test-Path $c) { return (Get-Content -Raw -Encoding UTF8 $c).TrimEnd() } } Write-Error "Файл значения не найден: $filePath (искали: $($candidates -join ', '))" exit 1 } # --- 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 } # --- Detect format version --- function Detect-FormatVersion([string]$dir) { $d = $dir while ($d) { # Автономная внешняя обработка/отчёт: своего Configuration.xml у неё нет, версию несёт # корень самой обработки. Без этого форма и макет внутри обработки 2.21 писались бы 2.17. $extPath = "$d.xml" if (Test-Path $extPath) { $extText = [System.IO.File]::ReadAllText($extPath, [System.Text.Encoding]::UTF8) $extHead = $extText.Substring(0, [Math]::Min(2000, $extText.Length)) if ($extHead -match '<(ExternalDataProcessor|ExternalReport)[ >]' -and $extHead -match ']+version="(\d+\.\d+)"') { return $Matches[1] } } $cfgPath = Join-Path $d "Configuration.xml" if (Test-Path $cfgPath) { $cfgText = [System.IO.File]::ReadAllText($cfgPath, [System.Text.Encoding]::UTF8) # Длину среза берём по СТРОКЕ, а не по размеру файла: размер в БАЙТАХ, Substring считает # СИМВОЛЫ, и на кириллице байт больше — короткий Configuration.xml ронял навык исключением. $head = $cfgText.Substring(0, [Math]::Min(2000, $cfgText.Length)) if ($head -match ']+version="(\d+\.\d+)"') { return $Matches[1] } } $parent = Split-Path $d -Parent if ($parent -eq $d) { break } $d = $parent } return "2.17" } # Версия формата как число для сравнений: "2.20" → 220, "2.9" → 209. # Строковое сравнение здесь неверно ("2.9" > "2.17" лексикографически) — известная ловушка. function Get-FormatRank([string]$ver) { if ($ver -match '^(\d+)\.(\d+)$') { return [int]$Matches[1] * 100 + [int]$Matches[2] } return 0 } # --- XML manipulation helpers (from meta-edit pattern) --- function Esc-Xml { param([string]$s) # Эскейп ЗНАЧЕНИЯ АТРИБУТА: & < > и кавычка — внутри "..." литеральная " невалидна. return $s.Replace('&','&').Replace('<','<').Replace('>','>').Replace('"','"') } function Esc-XmlText { param([string]$s) # Эскейп ТЕКСТА элемента: только & < > — кавычку и апостроф платформа держит сырыми. return $s.Replace('&','&').Replace('<','<').Replace('>','>') } function New-Guid-String { return [System.Guid]::NewGuid().ToString() } function Write-ChildSubsystemStub([string]$childPath, [string]$childName, [string]$formatVersion, [System.Text.Encoding]$utf8Bom) { $childUuid = New-Guid-String $sb = New-Object System.Text.StringBuilder 2048 [void]$sb.AppendLine('') [void]$sb.AppendLine("") [void]$sb.AppendLine("`t") [void]$sb.AppendLine("`t`t") [void]$sb.AppendLine("`t`t`t$(Esc-XmlText $childName)") [void]$sb.AppendLine("`t`t`t") [void]$sb.AppendLine("`t`t`t") [void]$sb.AppendLine("`t`t`ttrue") [void]$sb.AppendLine("`t`t`ttrue") [void]$sb.AppendLine("`t`t`tfalse") [void]$sb.AppendLine("`t`t`t") [void]$sb.AppendLine("`t`t`t") [void]$sb.AppendLine("`t`t`t") [void]$sb.AppendLine("`t`t") [void]$sb.AppendLine("`t`t") [void]$sb.AppendLine("`t") [void]$sb.AppendLine('') [System.IO.File]::WriteAllText($childPath, $sb.ToString().TrimEnd("`r", "`n"), $utf8Bom) } function Import-Fragment([string]$xmlString) { $wrapper = "<_W xmlns=`"$($script:mdNs)`" xmlns:xsi=`"$($script:xsiNs)`" xmlns:v8=`"$($script:v8Ns)`" xmlns:xr=`"$($script:xrNs)`" xmlns:xs=`"http://www.w3.org/2001/XMLSchema`">$xmlString" $frag = New-Object System.Xml.XmlDocument $frag.PreserveWhitespace = $true $frag.LoadXml($wrapper) $nodes = @() foreach ($child in $frag.DocumentElement.ChildNodes) { if ($child.NodeType -eq 'Element') { $nodes += $script:xmlDoc.ImportNode($child, $true) } } return ,$nodes } function Get-ChildIndent($container) { foreach ($child in $container.ChildNodes) { if ($child.NodeType -eq 'Whitespace' -or $child.NodeType -eq 'SignificantWhitespace') { if ($child.Value -match '^\r?\n(\t+)$') { return $Matches[1] } if ($child.Value -match '^\r?\n(\t+)') { return $Matches[1] } } } $depth = 0; $current = $container while ($current -and $current -ne $script:xmlDoc.DocumentElement) { $depth++; $current = $current.ParentNode } return "`t" * ($depth + 1) } function Insert-BeforeElement($container, $newNode, $refNode, $childIndent) { $ws = $script:xmlDoc.CreateWhitespace("`r`n$childIndent") if ($refNode) { $container.InsertBefore($ws, $refNode) | Out-Null $container.InsertBefore($newNode, $ws) | Out-Null } else { $trailing = $container.LastChild if ($trailing -and ($trailing.NodeType -eq 'Whitespace' -or $trailing.NodeType -eq 'SignificantWhitespace')) { $container.InsertBefore($ws, $trailing) | Out-Null $container.InsertBefore($newNode, $trailing) | Out-Null } else { $container.AppendChild($ws) | Out-Null $container.AppendChild($newNode) | Out-Null $parentIndent = if ($childIndent.Length -gt 1) { $childIndent.Substring(0, $childIndent.Length - 1) } else { "" } $closeWs = $script:xmlDoc.CreateWhitespace("`r`n$parentIndent") $container.AppendChild($closeWs) | Out-Null } } } function Remove-NodeWithWhitespace($node) { $parent = $node.ParentNode $prev = $node.PreviousSibling $next = $node.NextSibling if ($prev -and ($prev.NodeType -eq 'Whitespace' -or $prev.NodeType -eq 'SignificantWhitespace')) { $parent.RemoveChild($prev) | Out-Null } elseif ($next -and ($next.NodeType -eq 'Whitespace' -or $next.NodeType -eq 'SignificantWhitespace')) { $parent.RemoveChild($next) | Out-Null } $parent.RemoveChild($node) | Out-Null } function Expand-SelfClosingElement($container, $parentIndent) { # If the element is self-closing (empty), add whitespace for children if (-not $container.HasChildNodes -or $container.IsEmpty) { $childIndent = "$parentIndent`t" # The element is self-closing; we need to add something to make it non-empty # Adding a whitespace node will force opening+closing tags $closeWs = $script:xmlDoc.CreateWhitespace("`r`n$parentIndent") $container.AppendChild($closeWs) | Out-Null } } function Detect-XmlStyle([string]$path) { if (-not (Test-Path -LiteralPath $path)) { return $null } $raw = [System.IO.File]::ReadAllBytes($path) $bom = ($raw.Length -ge 3 -and $raw[0] -eq 0xEF -and $raw[1] -eq 0xBB -and $raw[2] -eq 0xBF) $body = if ($bom) { [System.Text.Encoding]::UTF8.GetString($raw, 3, $raw.Length - 3) } else { [System.Text.Encoding]::UTF8.GetString($raw) } $head = if ($body.Length -gt 200) { $body.Substring(0, 200) } else { $body } $m = [regex]::Match($head, 'encoding="([^"]+)"') return @{ bom = $bom crlf = $body.Contains("`r`n") enc = $(if ($m.Success) { $m.Groups[1].Value } else { "utf-8" }) finalNl = $body.EndsWith("`n") } } # Привести текст XmlWriter к стилю оригинала; для НОВОГО файла ($null) — к канону выгрузки # Конфигуратора: encoding="UTF-8", CRLF, без перевода строки в конце. # Реестр семьи: tests/skills/check-inline-drift.mjs. function Finalize-XmlText([string]$text, $style) { if ($text.Length -gt 0 -and $text[0] -eq [char]0xFEFF) { $text = $text.Substring(1) } $encDecl = $(if ($style) { $style.enc } else { "UTF-8" }) $text = $text.Replace('encoding="utf-8"', 'encoding="' + $encDecl + '"') # Пустой элемент: XmlWriter отдаёт ``, Конфигуратор пишет ``. Внутри # CDATA/комментария ` />` может быть содержимым (там `>` не экранируется), # поэтому они идут первыми ветками альтернации и возвращаются как есть. $text = [regex]::Replace($text, '(?s)||(?<=\S) />', { param($m) if ($m.Value -eq ' />') { '/>' } else { $m.Value } }) $text = ($text -replace "`r`n", "`n").TrimEnd("`n") if ($style -and $style.finalNl) { $text += "`n" } if (-not $style -or $style.crlf) { $text = $text -replace "`n", "`r`n" } return $text } # --- Пространства имён (Import-Fragment собирает узлы в них) --- $script:mdNs = "http://v8.1c.ru/8.2/roles" $script:xsiNs = "http://www.w3.org/2001/XMLSchema-instance" $script:v8Ns = "http://v8.1c.ru/8.1/data/core" $script:xrNs = "http://v8.1c.ru/8.3/xcf/readable" $script:mdObjectNs = "http://v8.1c.ru/8.3/MDClasses" # --- Стандартные реквизиты в списке полей RLS платформа пишет по-английски --- $script:fieldAliases = @{ "Ссылка"="Ref"; "Код"="Code"; "Наименование"="Description"; "Родитель"="Parent" "Владелец"="Owner"; "Дата"="Date"; "Номер"="Number"; "ПометкаУдаления"="DeletionMark" "ЭтоГруппа"="IsFolder"; "Проведен"="Posted"; "Проведён"="Posted"; "ВерсияДанных"="DataVersion" "Предопределенный"="Predefined"; "Предопределённый"="Predefined" } function Translate-FieldName([string]$name) { foreach ($key in $script:fieldAliases.Keys) { if ([string]::Equals($key, $name, [System.StringComparison]::OrdinalIgnoreCase)) { return $script:fieldAliases[$key] } } return $name } # --- Резолв пути роли --- # Принимаем всё, чем роль называют в обиходе: каталог роли, файл метаданных, сам Rights.xml. function Resolve-RolePaths([string]$inputPath) { if (-not (Test-Path -LiteralPath $inputPath)) { [Console]::Error.WriteLine("[role-edit] Путь не найден: $inputPath") exit 1 } $full = (Resolve-Path -LiteralPath $inputPath).Path $rightsPath = $null if (Test-Path -LiteralPath $full -PathType Leaf) { $leaf = [System.IO.Path]::GetFileName($full) if ($leaf -eq 'Rights.xml') { $rightsPath = $full } else { # Roles/Имя.xml — рядом лежит каталог Имя/Ext/Rights.xml $dir = [System.IO.Path]::GetDirectoryName($full) $name = [System.IO.Path]::GetFileNameWithoutExtension($full) $rightsPath = Join-Path (Join-Path (Join-Path $dir $name) "Ext") "Rights.xml" } } else { foreach ($candidate in @((Join-Path (Join-Path $full "Ext") "Rights.xml"), (Join-Path $full "Rights.xml"))) { if (Test-Path -LiteralPath $candidate) { $rightsPath = $candidate; break } } } if (-not $rightsPath -or -not (Test-Path -LiteralPath $rightsPath)) { [Console]::Error.WriteLine("[role-edit] Rights.xml не найден для пути: $inputPath") [Console]::Error.WriteLine(" Ожидается каталог роли, Roles/Имя.xml или Roles/Имя/Ext/Rights.xml.") exit 1 } $rightsPath = (Resolve-Path -LiteralPath $rightsPath).Path # Rights.xml лежит в /<Имя>/Ext/, метаданные — в /<Имя>.xml $roleDir = [System.IO.Path]::GetDirectoryName([System.IO.Path]::GetDirectoryName($rightsPath)) $roleName = [System.IO.Path]::GetFileName($roleDir) $rolesDir = [System.IO.Path]::GetDirectoryName($roleDir) return @{ RightsPath = $rightsPath RoleXmlPath = Join-Path $rolesDir "$roleName.xml" RoleName = $roleName ConfigRoot = [System.IO.Path]::GetDirectoryName($rolesDir) } } $script:paths = Resolve-RolePaths $RolePath $script:rightsPath = $script:paths.RightsPath $script:roleXmlPath = $script:paths.RoleXmlPath $script:configRoot = $script:paths.ConfigRoot if ($DefinitionFile -and $Operation) { [Console]::Error.WriteLine("[role-edit] Укажите либо -DefinitionFile, либо -Operation, но не оба сразу") exit 1 } if (-not $DefinitionFile -and -not $Operation) { [Console]::Error.WriteLine("[role-edit] Укажите -Operation с -Value или -DefinitionFile") exit 1 } # База относительного пути @файла: каталог списка операций, иначе каталог самой роли. # Текущий каталог функция проверяет вторым кандидатом в любом случае. $script:textBaseDir = if ($DefinitionFile) { [System.IO.Path]::GetDirectoryName((Resolve-Path $DefinitionFile).Path) } else { [System.IO.Path]::GetDirectoryName($script:paths.RightsPath) } $targetForGuard = if (Test-Path -LiteralPath $script:roleXmlPath) { $script:roleXmlPath } else { $script:rightsPath } Assert-EditAllowed $targetForGuard 'editable' # --- Загрузка XML --- $script:xmlDoc = New-Object System.Xml.XmlDocument $script:xmlDoc.PreserveWhitespace = $true $script:xmlDoc.Load($script:rightsPath) $script:root = $script:xmlDoc.DocumentElement $script:ns = New-Object System.Xml.XmlNamespaceManager($script:xmlDoc.NameTable) $script:ns.AddNamespace("rt", $script:mdNs) $script:formatVersion = if ($script:root.HasAttribute("version")) { $script:root.GetAttribute("version") } else { "2.17" } $script:formatRank = Get-FormatRank $script:formatVersion # Умолчания роли решают, какие записи платформа хранит: совпавшее с умолчанием она выбрасывает. $script:roleSfno = $script:root.SelectSingleNode("rt:setForNewObjects", $script:ns).InnerText $script:roleSfab = $script:root.SelectSingleNode("rt:setForAttributesByDefault", $script:ns).InnerText $script:droppedByDefault = @() function Test-RightStored { param([string]$objName, [string]$rightName, [string]$value) $default = Get-DefaultRightValue $objName $script:roleSfno $script:roleSfab if ($value -ne $default) { return $true } $script:droppedByDefault += "$objName.$rightName" return $false } $script:addCount = 0 $script:removeCount = 0 $script:modifyCount = 0 $script:rightsDirty = $false $script:metaDirty = $false $script:notes = @() function Add-Note([string]$text) { $script:notes += $text } # --- Разбор значений операций --- function Parse-BatchValue([string]$val) { # Делим ДО чтения файлов, поэтому ';;' внутри условия из файла разделителем не становится. return @($val -split ';;' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) } # Адрес и значение разделяет первое ':' вне скобок: в адресе двоеточия не бывает, # а в условии RLS встречается и оно, и '['. function Split-AtTopLevelColon([string]$text, [char]$openChar, [char]$closeChar) { $depth = 0 for ($i = 0; $i -lt $text.Length; $i++) { $ch = $text[$i] if ($ch -eq $openChar) { $depth++ } elseif ($ch -eq $closeChar) { if ($depth -gt 0) { $depth-- } } elseif ($ch -eq ':' -and $depth -eq 0) { return @{ Left = $text.Substring(0, $i).Trim(); Right = $text.Substring($i + 1).Trim(); Found = $true } } } return @{ Left = $text.Trim(); Right = ""; Found = $false } } # "Тип.Имя: Право1, Право2" или "Тип.Имя: @пресет"; без двоеточия — только имя объекта. function Parse-RightsSpec([string]$text, [switch]$AllowNoRights) { $split = Split-AtTopLevelColon $text '[' ']' if (-not $split.Found) { if (-not $AllowNoRights) { Add-ValidationError "$text : ожидается 'Тип.Имя: Право1, Право2' или 'Тип.Имя: @пресет'" return $null } $objName = Translate-ObjectName $split.Left if (-not (Validate-ObjectName $objName)) { return $null } return @{ Name = $objName; Rights = @() } } $objName = Translate-ObjectName $split.Left if (-not (Validate-ObjectName $objName)) { return $null } $objectType = Get-ObjectType $objName $rightsStr = $split.Right $rightNames = @() if ($rightsStr.StartsWith('@')) { $rightNames = @(Resolve-Preset -objectType $objectType -presetName $rightsStr) } else { $rightNames = @($rightsStr -split ',\s*' | ForEach-Object { Translate-RightName $_.Trim() } | Where-Object { $_ }) } $valid = @() foreach ($r in $rightNames) { if (Validate-RightName -objectName $objName -rightName $r) { $valid += $r } } return @{ Name = $objName; Rights = $valid } } # "Тип.Имя.Право[Поле1, Поле2]: условие" — поля необязательны, условие может быть пустым. function Parse-RlsAddress([string]$text, [switch]$ConditionRequired) { $split = Split-AtTopLevelColon $text '[' ']' $address = $split.Left $condition = $split.Right if ($ConditionRequired -and -not $split.Found) { Add-ValidationError "$text : ожидается 'Тип.Имя.Право: условие' (условие может быть пустым)" return $null } $fields = @() if ($address.EndsWith(']')) { $open = $address.LastIndexOf('[') if ($open -lt 0) { Add-ValidationError "$text : не закрыта скобка списка полей" return $null } $fieldsPart = $address.Substring($open + 1, $address.Length - $open - 2) $address = $address.Substring(0, $open).Trim() $fields = @($fieldsPart -split ',' | ForEach-Object { Translate-FieldName $_.Trim() } | Where-Object { $_ }) if ($fields.Count -eq 0) { Add-ValidationError "$text : пустой список полей — уберите скобки, если ограничение на все поля" return $null } } $lastDot = $address.LastIndexOf('.') if ($lastDot -lt 1) { Add-ValidationError "$text : ожидается 'Тип.Имя.Право', последний сегмент — имя права" return $null } $objName = Translate-ObjectName $address.Substring(0, $lastDot) $rightName = Translate-RightName $address.Substring($lastDot + 1) if (-not (Validate-ObjectName $objName)) { return $null } if (-not (Validate-RightName -objectName $objName -rightName $rightName)) { # Показываем разбор: иначе непонятно, что навык откусил не тот сегмент. Add-ValidationError "$text : разобрано как объект '$objName' и право '$rightName'" return $null } return @{ Object = $objName; Right = $rightName; Fields = $fields; Condition = (Resolve-TextFromFile $condition $script:textBaseDir) } } # "Имя(Пар1, Пар2): условие" — скобки принадлежат имени шаблона, разделитель ищем вне них. function Parse-TemplateSpec([string]$text, [switch]$NameOnly) { $split = Split-AtTopLevelColon $text '(' ')' if ($NameOnly) { return @{ Name = $split.Left; Condition = $null } } if (-not $split.Found) { Add-ValidationError "$text : ожидается 'Имя(Параметры): условие'" return $null } return @{ Name = $split.Left; Condition = (Resolve-TextFromFile $split.Right $script:textBaseDir) } } # --- Доступ к дереву прав --- function Get-ObjectNodes() { return @($script:root.SelectNodes("rt:object", $script:ns)) } function Get-ObjectNodeName($objNode) { $nameNode = $objNode.SelectSingleNode("rt:name", $script:ns) if ($nameNode) { return $nameNode.InnerText } else { return "" } } function Find-ObjectNode([string]$name) { foreach ($node in Get-ObjectNodes) { if ([string]::Equals((Get-ObjectNodeName $node), $name, [System.StringComparison]::OrdinalIgnoreCase)) { return $node } } return $null } function Get-RightNodes($objNode) { return @($objNode.SelectNodes("rt:right", $script:ns)) } function Get-RightNodeName($rightNode) { $nameNode = $rightNode.SelectSingleNode("rt:name", $script:ns) if ($nameNode) { return $nameNode.InnerText } else { return "" } } function Get-RightNodeValue($rightNode) { $valueNode = $rightNode.SelectSingleNode("rt:value", $script:ns) if ($valueNode) { return $valueNode.InnerText } else { return "" } } function Find-RightNode($objNode, [string]$rightName) { foreach ($node in Get-RightNodes $objNode) { if ([string]::Equals((Get-RightNodeName $node), $rightName, [System.StringComparison]::OrdinalIgnoreCase)) { return $node } } return $null } function Get-TrueRightNames($objNode) { $names = @() foreach ($node in Get-RightNodes $objNode) { if ((Get-RightNodeValue $node) -eq 'true') { $names += (Get-RightNodeName $node) } } return $names } # Порядок прав внутри узла у платформы фиксирован для типа — новое право встаёт на своё место, # соседей не трогаем. function Insert-RightCanonical($objNode, $newNode, [string]$objName) { $parts = $objName -split '\.' $order = if ($parts.Count -ge 3) { $script:nestedRightOrder[$parts[$parts.Count-2]] } else { $script:rightOrder[$parts[0]] } $newName = Get-RightNodeName $newNode $refNode = $null if ($order) { $newIndex = [array]::IndexOf($order, $newName) if ($newIndex -ge 0) { foreach ($node in Get-RightNodes $objNode) { $idx = [array]::IndexOf($order, (Get-RightNodeName $node)) if ($idx -gt $newIndex) { $refNode = $node; break } } } } $indent = Get-ChildIndent $objNode Insert-BeforeElement $objNode $newNode $refNode $indent } function New-RightNode([string]$name, [string]$value, [string]$indent) { $xml = "`r`n$indent`t$(Esc-XmlText $name)`r`n$indent`t$value`r`n$indent" $nodes = Import-Fragment $xml return $nodes[0] } # Узлы платформа держит в порядке uuid объекта метаданных — вставляем на то же место. function Insert-ObjectNode($newNode, [string]$objName) { $indent = Get-ChildIndent $script:root $uuid = Get-RightsObjectUuid -objName $objName -configRoot $script:configRoot $refNode = $null if ($uuid) { foreach ($node in Get-ObjectNodes) { $otherUuid = Get-RightsObjectUuid -objName (Get-ObjectNodeName $node) -configRoot $script:configRoot if ($otherUuid -and [string]::CompareOrdinal($otherUuid, $uuid) -gt 0) { $refNode = $node; break } } } elseif (-not (Test-StandardKind $objName)) { Add-Note "[WARN] ${objName}: объект не найден в выгрузке, uuid неизвестен — узел записан перед шаблонами (платформа переставит его при первой выгрузке)" } if (-not $refNode) { $templates = @($script:root.SelectNodes("rt:restrictionTemplate", $script:ns)) if ($templates.Count -gt 0) { $refNode = $templates[0] } } Insert-BeforeElement $script:root $newNode $refNode $indent } function New-ObjectNode([string]$objName) { $indent = Get-ChildIndent $script:root $xml = "`r`n$indent`t$(Esc-XmlText $objName)`r`n$indent" $nodes = Import-Fragment $xml return $nodes[0] } # Пустых узлов платформа не производит. Узел с одними запретами — производит (так закрывают # реквизит), поэтому смотрим на наличие прав вообще, а не только разрешающих. function Remove-ObjectIfEmpty($objNode) { # @() на месте использования: return из функции разворачивает массив из одного элемента, # и .Count у него $null — узел с единственным правом считался бы пустым. if (@(Get-RightNodes $objNode).Count -gt 0) { return $false } $name = Get-ObjectNodeName $objNode Remove-NodeWithWhitespace $objNode Add-Note " ${name}: прав не осталось — узел объекта удалён" return $true } # --- Зависимости: прямое замыкание для выдачи, обратное — для снятия и запрета --- function Get-DirectDeps([string]$objectType, [string]$rightName) { $byType = $script:rightDepsByType[$objectType] if ($byType -and $byType.Contains($rightName)) { return $byType[$rightName] } $deps = $script:rightDeps[$rightName] if ($deps) { return $deps } return @() } function Get-AllowedRights([string]$objName) { $parts = $objName -split '\.' if ($parts.Count -ge 3) { return (Get-NestedRights -objectType $parts[0] -kind (Get-NestedKind $objName)) } return $script:knownRights[$parts[0]] } function Get-DependentRights([string]$objName, [string]$rightName) { # Кто требует это право: снимаем его — обязаны снять и их, иначе платформа вернёт снятое. # У вложенных объектов это работает и для запретов: View=false тянет Edit=false. $parts = $objName -split '\.' $objectType = $parts[0] $allowed = Get-AllowedRights $objName if (-not $allowed) { return @() } $result = @() $queue = @($rightName) while ($queue.Count -gt 0) { $current = $queue[0] $queue = @($queue | Select-Object -Skip 1) foreach ($candidate in $allowed) { if ($result -contains $candidate -or $candidate -eq $rightName) { continue } if ((Get-DirectDeps $objectType $candidate) -contains $current) { $result += $candidate $queue += $candidate } } } return $result } # --- Операции --- function Do-AddRights([string]$batchVal) { foreach ($item in (Parse-BatchValue $batchVal)) { $spec = Parse-RightsSpec $item if (-not $spec) { continue } foreach ($expanded in (Expand-ServiceEntry -parsed @{ Name = $spec.Name; Rights = @($spec.Rights | ForEach-Object { @{ Name = $_; Value = "true"; Condition = $null } }) } -configRoot $script:configRoot -name $script:paths.RoleName)) { $script:pending += ,@{ Kind = 'add-rights'; Spec = @{ Name = $expanded.Name; Rights = @($expanded.Rights | ForEach-Object { $_.Name }) } } } } } function Apply-AddRights($spec) { $objNode = Find-ObjectNode $spec.Name $created = $false if (-not $objNode) { $objNode = New-ObjectNode $spec.Name Insert-ObjectNode $objNode $spec.Name $created = $true } $existing = @() foreach ($node in Get-RightNodes $objNode) { $existing += (Get-RightNodeName $node) } $wanted = @($spec.Rights) # Платформа при загрузке всё равно доведёт набор до замыкания — пишем его сразу. $closure = Close-RightsDependencies -objName $spec.Name -rights @(($existing + $wanted | Select-Object -Unique) | ForEach-Object { @{ Name = $_; Value = "true"; Condition = $null } }) -formatRank $script:formatRank $final = @($closure.Rights | ForEach-Object { $_.Name }) $added = @() $indent = Get-ChildIndent $objNode foreach ($rightName in $final) { if (-not (Test-RightStored $spec.Name $rightName 'true')) { continue } $node = Find-RightNode $objNode $rightName if ($node) { if ((Get-RightNodeValue $node) -ne 'true') { $node.SelectSingleNode("rt:value", $script:ns).InnerText = 'true' $added += $rightName $script:modifyCount++ $script:rightsDirty = $true } continue } $new = New-RightNode $rightName 'true' $indent Insert-RightCanonical $objNode $new $spec.Name $added += $rightName $script:addCount++ $script:rightsDirty = $true } if ($created -and $added.Count -eq 0) { Remove-NodeWithWhitespace $objNode return } if ($added.Count -gt 0) { $extra = @($added | Where-Object { $wanted -notcontains $_ }) $note = " $($spec.Name): добавлено — $($added -join ', ')" if ($extra.Count -gt 0) { $note += " (по зависимости: $($extra -join ', '))" } Add-Note $note } else { Add-Note " $($spec.Name): права уже выданы, изменений нет" } } function Do-SetRights([string]$batchVal) { foreach ($item in (Parse-BatchValue $batchVal)) { $spec = Parse-RightsSpec $item if (-not $spec) { continue } $script:pending += ,@{ Kind = 'set-rights'; Spec = $spec } } } function Apply-SetRights($spec) { $objNode = Find-ObjectNode $spec.Name if (-not $objNode) { Apply-AddRights $spec return } $droppedRls = 0 foreach ($node in Get-RightNodes $objNode) { if ($node.SelectSingleNode("rt:restrictionByCondition", $script:ns)) { $droppedRls++ } Remove-NodeWithWhitespace $node $script:removeCount++ } $closure = Close-RightsDependencies -objName $spec.Name -rights @($spec.Rights | ForEach-Object { @{ Name = $_; Value = "true"; Condition = $null } }) -formatRank $script:formatRank $indent = Get-ChildIndent $objNode foreach ($rightName in @($closure.Rights | ForEach-Object { $_.Name })) { if (-not (Test-RightStored $spec.Name $rightName 'true')) { continue } $new = New-RightNode $rightName 'true' $indent Insert-RightCanonical $objNode $new $spec.Name $script:addCount++ } $script:rightsDirty = $true Add-Note " $($spec.Name): набор прав заменён" if ($droppedRls -gt 0) { Add-Note "[WARN] $($spec.Name): снято ограничений RLS: $droppedRls" } Remove-ObjectIfEmpty $objNode | Out-Null } function Do-RemoveRights([string]$batchVal) { foreach ($item in (Parse-BatchValue $batchVal)) { $spec = Parse-RightsSpec $item -AllowNoRights if (-not $spec) { continue } $script:pending += ,@{ Kind = 'remove-rights'; Spec = $spec } } } function Apply-RemoveRights($spec) { $objNode = Find-ObjectNode $spec.Name if (-not $objNode) { Add-Note " $($spec.Name): объекта нет в роли, пропуск" return } if ($spec.Rights.Count -eq 0) { Remove-NodeWithWhitespace $objNode $script:removeCount++ $script:rightsDirty = $true Add-Note " $($spec.Name): узел объекта удалён" return } # Каскад: право, которое требует снимаемое, платформа вернула бы обратно. $toRemove = @() foreach ($rightName in $spec.Rights) { $toRemove += $rightName foreach ($dependent in (Get-DependentRights $spec.Name $rightName)) { if ($toRemove -notcontains $dependent) { $toRemove += $dependent } } } $removed = @() foreach ($rightName in $toRemove) { $node = Find-RightNode $objNode $rightName if (-not $node) { continue } Remove-NodeWithWhitespace $node $removed += $rightName $script:removeCount++ $script:rightsDirty = $true } if ($removed.Count -eq 0) { Add-Note " $($spec.Name): перечисленных прав нет, изменений нет" return } $cascade = @($removed | Where-Object { $spec.Rights -notcontains $_ }) $note = " $($spec.Name): снято — $($removed -join ', ')" if ($cascade.Count -gt 0) { $note += " (каскадом: $($cascade -join ', '))" } Add-Note $note Remove-ObjectIfEmpty $objNode | Out-Null } function Do-DenyRights([string]$batchVal) { foreach ($item in (Parse-BatchValue $batchVal)) { $spec = Parse-RightsSpec $item if (-not $spec) { continue } $script:pending += ,@{ Kind = 'deny-rights'; Spec = $spec } } } function Apply-DenyRights($spec) { $objNode = Find-ObjectNode $spec.Name $created = $false if (-not $objNode) { $objNode = New-ObjectNode $spec.Name Insert-ObjectNode $objNode $spec.Name $created = $true } $toDeny = @() foreach ($rightName in $spec.Rights) { $toDeny += $rightName foreach ($dependent in (Get-DependentRights $spec.Name $rightName)) { if ($toDeny -notcontains $dependent) { $toDeny += $dependent } } } $denied = @() $indent = Get-ChildIndent $objNode foreach ($rightName in $toDeny) { if (-not (Test-RightStored $spec.Name $rightName 'false')) { continue } $node = Find-RightNode $objNode $rightName if ($node) { if ((Get-RightNodeValue $node) -eq 'false') { continue } $node.SelectSingleNode("rt:value", $script:ns).InnerText = 'false' $script:modifyCount++ } else { $new = New-RightNode $rightName 'false' $indent Insert-RightCanonical $objNode $new $spec.Name $script:addCount++ } $denied += $rightName $script:rightsDirty = $true } if ($denied.Count -eq 0) { if ($created) { Remove-NodeWithWhitespace $objNode } $reason = if (@($script:droppedByDefault | Where-Object { $_.StartsWith("$($spec.Name).") }).Count -gt 0) { "запрет совпадает с умолчанием роли и платформой не хранится" } else { "права уже запрещены" } Add-Note " $($spec.Name): $reason, изменений нет" return } $cascade = @($denied | Where-Object { $spec.Rights -notcontains $_ }) $note = " $($spec.Name): запрещено — $($denied -join ', ')" if ($cascade.Count -gt 0) { $note += " (каскадом: $($cascade -join ', '))" } Add-Note $note } # --- RLS --- function Get-RestrictionFields($restrictionNode) { $fields = @() foreach ($node in @($restrictionNode.SelectNodes("rt:field", $script:ns))) { $fields += $node.InnerText } return $fields } function Test-SameFieldSet($a, $b) { if ($a.Count -ne $b.Count) { return $false } $left = @($a | Sort-Object) $right = @($b | Sort-Object) for ($i = 0; $i -lt $left.Count; $i++) { if (-not [string]::Equals($left[$i], $right[$i], [System.StringComparison]::OrdinalIgnoreCase)) { return $false } } return $true } function New-RestrictionNode([string]$indent, $fields, [string]$condition) { # Поля платформа держит отсортированными ordinal, условие без полей идёт первой строкой. $sorted = @($fields | Sort-Object -Property @{ Expression = { $_ } }) if ($sorted.Count -gt 1) { $arr = [string[]]$sorted [Array]::Sort($arr, [System.StringComparer]::Ordinal) $sorted = $arr } $inner = "" foreach ($field in $sorted) { $inner += "$indent`t$(Esc-XmlText $field)`r`n" } if ($condition) { $inner += "$indent`t$(Esc-XmlText $condition)`r`n" } else { $inner += "$indent`t`r`n" } $xml = "`r`n$inner$indent" $nodes = Import-Fragment $xml return $nodes[0] } function Do-SetRls([string]$batchVal) { foreach ($item in (Parse-BatchValue $batchVal)) { $spec = Parse-RlsAddress $item -ConditionRequired if (-not $spec) { continue } $script:pending += ,@{ Kind = 'set-rls'; Spec = $spec } } } function Apply-SetRls($spec) { $objNode = Find-ObjectNode $spec.Object if (-not $objNode) { Add-ValidationError "$($spec.Object).$($spec.Right): право не выдано — сначала add-rights, ограничение без права платформа игнорирует" return } $rightNode = Find-RightNode $objNode $spec.Right if (-not $rightNode -or (Get-RightNodeValue $rightNode) -ne 'true') { Add-ValidationError "$($spec.Object).$($spec.Right): право не выдано — сначала add-rights, ограничение без права платформа игнорирует" return } $indent = (Get-ChildIndent $objNode) + "`t" $existing = @($rightNode.SelectNodes("rt:restrictionByCondition", $script:ns)) $target = $null foreach ($node in $existing) { if (Test-SameFieldSet (Get-RestrictionFields $node) $spec.Fields) { $target = $node; break } } # Ссылка на шаблон, которого в роли нет, — тихая ошибка в рантайме 1С. Отказывать нельзя: # шаблон могут добавить следующей операцией или следующим вызовом. foreach ($m in [regex]::Matches("$($spec.Condition)", '#([A-Za-zА-Яа-яЁё0-9_]+)\s*\(')) { $templateName = $m.Groups[1].Value if ($templateName -in @('Если', 'Тогда', 'Иначе', 'КонецЕсли')) { continue } if (-not (Find-TemplateNode $templateName)) { [Console]::Error.WriteLine("[role-edit] $($spec.Object).$($spec.Right): условие ссылается на шаблон '$templateName', которого в роли нет") } } $new = New-RestrictionNode $indent $spec.Fields $spec.Condition if ($target) { $rightNode.ReplaceChild($new, $target) | Out-Null $script:modifyCount++ Add-Note " $($spec.Object).$($spec.Right): ограничение заменено" } else { # Строка без полей («прочие поля») идёт первой, строки с полями — после неё. $refNode = $null if ($spec.Fields.Count -eq 0) { foreach ($node in $existing) { if (@(Get-RestrictionFields $node).Count -gt 0) { $refNode = $node; break } } } Insert-BeforeElement $rightNode $new $refNode $indent $script:addCount++ Add-Note " $($spec.Object).$($spec.Right): ограничение добавлено" } $script:rightsDirty = $true } function Do-RemoveRls([string]$batchVal) { foreach ($item in (Parse-BatchValue $batchVal)) { $spec = Parse-RlsAddress $item if (-not $spec) { continue } $script:pending += ,@{ Kind = 'remove-rls'; Spec = $spec } } } function Apply-RemoveRls($spec) { $objNode = Find-ObjectNode $spec.Object if (-not $objNode) { Add-Note " $($spec.Object): объекта нет в роли, пропуск" return } $rightNode = Find-RightNode $objNode $spec.Right if (-not $rightNode) { Add-Note " $($spec.Object).$($spec.Right): права нет в роли, пропуск" return } $removed = 0 foreach ($node in @($rightNode.SelectNodes("rt:restrictionByCondition", $script:ns))) { # Адрес без скобок снимает все ограничения права, со скобками — строку с этим набором полей. if (@($spec.Fields).Count -gt 0 -and -not (Test-SameFieldSet (Get-RestrictionFields $node) $spec.Fields)) { continue } Remove-NodeWithWhitespace $node $removed++ } if ($removed -eq 0) { Add-Note " $($spec.Object).$($spec.Right): ограничений нет, изменений нет" return } $script:removeCount += $removed $script:rightsDirty = $true Add-Note " $($spec.Object).$($spec.Right): снято ограничений — $removed" } # --- Шаблоны RLS --- function Get-TemplateNodes() { return @($script:root.SelectNodes("rt:restrictionTemplate", $script:ns)) } function Get-TemplateIdentifier([string]$name) { $paren = $name.IndexOf('(') if ($paren -gt 0) { return $name.Substring(0, $paren).Trim() } return $name.Trim() } function Find-TemplateNode([string]$name) { $wanted = Get-TemplateIdentifier $name foreach ($node in Get-TemplateNodes) { $nameNode = $node.SelectSingleNode("rt:name", $script:ns) if (-not $nameNode) { continue } if ([string]::Equals((Get-TemplateIdentifier $nameNode.InnerText), $wanted, [System.StringComparison]::OrdinalIgnoreCase)) { return $node } } return $null } function New-TemplateNode([string]$indent, [string]$name, [string]$condition) { $xml = "`r`n$indent`t$(Esc-XmlText $name)`r`n$indent`t$(Esc-XmlText $condition)`r`n$indent" $nodes = Import-Fragment $xml return $nodes[0] } function Do-AddTemplate([string]$batchVal) { foreach ($item in (Parse-BatchValue $batchVal)) { $spec = Parse-TemplateSpec $item if (-not $spec) { continue } $script:pending += ,@{ Kind = 'add-template'; Spec = $spec } } } function Apply-AddTemplate($spec, [switch]$AllowReplace) { $existing = Find-TemplateNode $spec.Name if ($existing -and -not $AllowReplace) { Add-ValidationError "$($spec.Name): шаблон с таким именем уже есть — используйте set-template" return } $indent = Get-ChildIndent $script:root $new = New-TemplateNode $indent $spec.Name $spec.Condition if ($existing) { $script:root.ReplaceChild($new, $existing) | Out-Null $script:modifyCount++ Add-Note " $($spec.Name): шаблон заменён" } else { Insert-BeforeElement $script:root $new $null $indent $script:addCount++ Add-Note " $($spec.Name): шаблон добавлен" } $script:rightsDirty = $true } function Do-SetTemplate([string]$batchVal) { foreach ($item in (Parse-BatchValue $batchVal)) { $spec = Parse-TemplateSpec $item if (-not $spec) { continue } $script:pending += ,@{ Kind = 'set-template'; Spec = $spec } } } function Do-RemoveTemplate([string]$batchVal) { foreach ($item in (Parse-BatchValue $batchVal)) { $spec = Parse-TemplateSpec $item -NameOnly $script:pending += ,@{ Kind = 'remove-template'; Spec = $spec } } } function Apply-RemoveTemplate($spec) { $node = Find-TemplateNode $spec.Name if (-not $node) { Add-Note " $($spec.Name): шаблона нет в роли, пропуск" return } # Ссылка на удалённый шаблон — тихая ошибка в рантайме, поэтому показываем, кто им пользуется. $identifier = Get-TemplateIdentifier $spec.Name $users = @() foreach ($objNode in Get-ObjectNodes) { foreach ($rightNode in Get-RightNodes $objNode) { foreach ($restriction in @($rightNode.SelectNodes("rt:restrictionByCondition", $script:ns))) { $conditionNode = $restriction.SelectSingleNode("rt:condition", $script:ns) if ($conditionNode -and $conditionNode.InnerText -match "#$([regex]::Escape($identifier))\s*\(") { $users += "$(Get-ObjectNodeName $objNode).$(Get-RightNodeName $rightNode)" } } } } Remove-NodeWithWhitespace $node $script:removeCount++ $script:rightsDirty = $true Add-Note " $($spec.Name): шаблон удалён" if ($users.Count -gt 0) { [Console]::Error.WriteLine("[role-edit] На шаблон '$identifier' ещё ссылаются: $($users -join ', ')") } } # --- Глобальные флаги --- function Do-ModifyProperty([string]$batchVal) { $allowed = @("setForNewObjects", "setForAttributesByDefault", "independentRightsOfChildObjects") foreach ($item in (Parse-BatchValue $batchVal)) { $eq = $item.IndexOf('=') if ($eq -lt 1) { Add-ValidationError "$item : ожидается 'свойство=true' или 'свойство=false'" continue } $name = $item.Substring(0, $eq).Trim() $value = $item.Substring($eq + 1).Trim().ToLower() $canonical = $allowed | Where-Object { [string]::Equals($_, $name, [System.StringComparison]::OrdinalIgnoreCase) } if (-not $canonical) { Add-ValidationError "$name : неизвестное свойство роли, допустимы $($allowed -join ', ')" continue } if ($value -ne 'true' -and $value -ne 'false') { Add-ValidationError "$item : значение должно быть true или false" continue } $script:pending += ,@{ Kind = 'modify-property'; Spec = @{ Name = $canonical; Value = $value } } } } function Apply-ModifyProperty($spec) { $node = $script:root.SelectSingleNode("rt:$($spec.Name)", $script:ns) if (-not $node) { Add-Note "[WARN] $($spec.Name): свойства нет в файле роли, пропуск" return } if ($node.InnerText -eq $spec.Value) { Add-Note " $($spec.Name): уже $($spec.Value), изменений нет" return } $node.InnerText = $spec.Value # Умолчания решают, какие записи вообще пишутся, — следующие операции должны видеть новое значение. if ($spec.Name -eq 'setForNewObjects') { $script:roleSfno = $spec.Value } if ($spec.Name -eq 'setForAttributesByDefault') { $script:roleSfab = $spec.Value } $script:modifyCount++ $script:rightsDirty = $true Add-Note " $($spec.Name) = $($spec.Value)" # Измерено: при setForNewObjects=true платформа перестаёт хранить права, совпадающие с # автоматически выдаваемыми, и переписывает файл роли целиком. if ($spec.Name -eq 'setForNewObjects' -and $spec.Value -eq 'true') { [Console]::Error.WriteLine("[role-edit] setForNewObjects=true: платформа пересчитает хранимые права роли при первой же загрузке — часть явных записей исчезнет") } } # --- Метаданные роли (Roles/Имя.xml) --- function Edit-RoleMetadata([string]$field, [string]$text) { if (-not (Test-Path -LiteralPath $script:roleXmlPath)) { Add-ValidationError "Файл метаданных роли не найден: $($script:roleXmlPath)" return } $doc = $script:metaDoc if (-not $doc) { $doc = New-Object System.Xml.XmlDocument $doc.PreserveWhitespace = $true $doc.Load($script:roleXmlPath) } $nsm = New-Object System.Xml.XmlNamespaceManager($doc.NameTable) $nsm.AddNamespace("md", $script:mdObjectNs) $nsm.AddNamespace("v8", $script:v8Ns) $props = $doc.SelectSingleNode("//md:Role/md:Properties", $nsm) if (-not $props) { Add-ValidationError "В метаданных роли нет блока : $($script:roleXmlPath)" return } $node = $props.SelectSingleNode("md:$field", $nsm) $indent = Get-ChildIndent $props if ($field -eq 'Synonym') { $xml = if ($text) { "`r`n$indent`t`r`n$indent`t`tru`r`n$indent`t`t$(Esc-XmlText $text)`r`n$indent`t`r`n$indent" } else { "" } } else { $xml = if ($text) { "$(Esc-XmlText $text)" } else { "" } } $wrapper = "<_W xmlns=`"$($script:mdObjectNs)`" xmlns:v8=`"$($script:v8Ns)`" xmlns:xsi=`"$($script:xsiNs)`">$xml" $frag = New-Object System.Xml.XmlDocument $frag.PreserveWhitespace = $true $frag.LoadXml($wrapper) $new = $doc.ImportNode($frag.DocumentElement.FirstChild, $true) if ($node) { $props.ReplaceChild($new, $node) | Out-Null } else { Insert-BeforeElement $props $new $null $indent } $script:metaDoc = $doc $script:metaDirty = $true $script:modifyCount++ Add-Note " $field обновлён в метаданных роли" } # --- Сбор и выполнение операций --- # Очередь одна: операции применяются в том порядке, в котором их перечислили. $script:pending = @() $operations = @() if ($DefinitionFile) { $json = ConvertFrom-JsonInput (Read-JsonInputFile $DefinitionFile) "-DefinitionFile '$DefinitionFile'" "a JSON object or array of operations" $items = if ($json -is [array]) { $json } else { @($json) } foreach ($item in $items) { $opName = if ($item.operation) { "$($item.operation)" } else { "$($item.op)" } $opValue = if ($null -ne $item.value) { "$($item.value)" } else { "" } $operations += ,@{ Operation = $opName; Value = $opValue } } } else { $operations += ,@{ Operation = $Operation; Value = $Value } } foreach ($op in $operations) { $opName = "$($op.Operation)".Trim() $opValue = "$($op.Value)" switch ($opName.ToLower()) { "add-rights" { Do-AddRights $opValue } "set-rights" { Do-SetRights $opValue } "remove-rights" { Do-RemoveRights $opValue } "deny-rights" { Do-DenyRights $opValue } "set-rls" { Do-SetRls $opValue } "remove-rls" { Do-RemoveRls $opValue } "add-template" { Do-AddTemplate $opValue } "set-template" { Do-SetTemplate $opValue } "remove-template" { Do-RemoveTemplate $opValue } "modify-property" { Do-ModifyProperty $opValue } "set-synonym" { $script:pending += ,@{ Kind = 'set-meta'; Spec = @{ Field = 'Synonym'; Text = (Resolve-TextFromFile $opValue $script:textBaseDir) } } } "set-comment" { $script:pending += ,@{ Kind = 'set-meta'; Spec = @{ Field = 'Comment'; Text = (Resolve-TextFromFile $opValue $script:textBaseDir) } } } default { Add-ValidationError "Неизвестная операция: $opName" } } } # Отказ до записи: правка роли — это несколько узлов сразу, и наполовину применённая правка # хуже неприменённой. Печатаем все причины разом. if ($script:validationErrors.Count -gt 0) { [Console]::Error.WriteLine("[role-edit] Правка не применена: $($script:validationErrors.Count) ошибок во входе.") foreach ($err in $script:validationErrors) { [Console]::Error.WriteLine(" ERROR: $err") } exit 1 } foreach ($item in $script:pending) { switch ($item.Kind) { 'add-rights' { Apply-AddRights $item.Spec } 'set-rights' { Apply-SetRights $item.Spec } 'deny-rights' { Apply-DenyRights $item.Spec } 'remove-rights' { Apply-RemoveRights $item.Spec } 'add-template' { Apply-AddTemplate $item.Spec } 'set-template' { Apply-AddTemplate $item.Spec -AllowReplace } 'remove-template' { Apply-RemoveTemplate $item.Spec } 'set-rls' { Apply-SetRls $item.Spec } 'remove-rls' { Apply-RemoveRls $item.Spec } 'modify-property' { Apply-ModifyProperty $item.Spec } 'set-meta' { Edit-RoleMetadata $item.Spec.Field $item.Spec.Text } } } # Ошибка могла всплыть и на применении (RLS без права) — файл в этом случае не трогаем. if ($script:validationErrors.Count -gt 0) { [Console]::Error.WriteLine("[role-edit] Правка не применена: $($script:validationErrors.Count) ошибок во входе.") foreach ($err in $script:validationErrors) { [Console]::Error.WriteLine(" ERROR: $err") } exit 1 } # --- Запись --- function Save-XmlPreservingStyle($doc, [string]$path) { $style = Detect-XmlStyle $path $settings = New-Object System.Xml.XmlWriterSettings $settings.Encoding = New-Object System.Text.UTF8Encoding($true) $settings.Indent = $false $settings.NewLineHandling = [System.Xml.NewLineHandling]::None $stream = New-Object System.IO.MemoryStream $writer = [System.Xml.XmlWriter]::Create($stream, $settings) $doc.Save($writer) $writer.Flush() $writer.Close() $text = [System.Text.Encoding]::UTF8.GetString($stream.ToArray()) $stream.Dispose() $text = Finalize-XmlText $text $style $utf8Bom = New-Object System.Text.UTF8Encoding($style.bom) [System.IO.File]::WriteAllText($path, $text, $utf8Bom) } if ($script:rightsDirty) { Save-XmlPreservingStyle $script:xmlDoc $script:rightsPath } if ($script:metaDirty) { Save-XmlPreservingStyle $script:metaDoc $script:roleXmlPath } # --- Итог --- Write-Host "[OK] Роль '$($script:paths.RoleName)' обновлена" Write-Host " Rights: $($script:rightsPath)" foreach ($note in $script:notes) { Write-Host $note } Write-Host " Added: $($script:addCount), Removed: $($script:removeCount), Modified: $($script:modifyCount)" if ($script:droppedByDefault.Count -gt 0) { [Console]::Error.WriteLine("[role-edit] Не записаны права, совпадающие с умолчанием роли (платформа их не хранит): $($script:droppedByDefault -join ', ')") [Console]::Error.WriteLine(" Запрет хранится у реквизитов и табличных частей (они наследуют права объекта) либо в роли с setForNewObjects=true; выдача прав — наоборот.") } if (-not $NoValidate) { $validateScript = Join-Path (Join-Path $PSScriptRoot "..\..\role-validate") "scripts\role-validate.ps1" $validateScript = [System.IO.Path]::GetFullPath($validateScript) if (Test-Path $validateScript) { Write-Host "" Write-Host "--- Running role-validate ---" & powershell.exe -NoProfile -File $validateScript -RightsPath $script:rightsPath } }