From 7d989fb529ef945e8837505c03eb83d800253b1a Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 9 Nov 2026 17:27:32 -0500 Subject: [PATCH] docs(security): script authoring and deploy are Administrator OR Designer (lmxopcua#779) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docs said script authoring was Admin-gated (VirtualTags.md "Known resource limits") and that a Designer "cannot publish" (security.md roles table). The code admits Designer to both: Scripts, ScriptEdit, EquipmentPage (virtual tags, scripted alarms), /api/script-analysis/* and Deployments all carry AdminUiPolicies.ConfigEditor = RequireRole("Administrator", "Designer"). That is a recorded decision, not drift: AdminUI-001 (98b27fc1) loosened the analysis backend from FleetAdmin to Administrator,Designer to match the page gate, and R2-05 converged the same pages on ConfigEditor "with the same semantics". So the docs follow the code: - VirtualTags.md: the primary control is ConfigEditor (Administrator or Designer), which also gates deploy. - security.md roles table: Designer = the whole ConfigEditor surface incl. scripts and deploy; Administrator = Designer + the FleetAdmin-only surface (role grants, API keys, certificate actions, manual failover) — the old "publish, cluster/node CRUD" were ConfigEditor all along. - 2026-06-09 Monaco plan: its FleetAdmin gate is marked superseded. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV --- docs/VirtualTags.md | 2 +- docs/plans/2026-06-09-monaco-script-editor.md | 2 ++ docs/security.md | 4 ++-- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/VirtualTags.md b/docs/VirtualTags.md index af2f0fcad..a51b20467 100644 --- a/docs/VirtualTags.md +++ b/docs/VirtualTags.md @@ -37,7 +37,7 @@ User scripts are compiled via `Microsoft.CodeAnalysis.CSharp` (regular compiler, #### Known resource limits (accepted trade-offs) -The sandbox cannot pre-empt a script. **Memory:** each run's own-thread allocation is sampled (`GC.GetAllocatedBytesForCurrentThread`) and a run over `Scripting:MaxAllocBytesPerRun` (default 64 MB) quarantines the script — but the sample is taken after the run returns, so a script that allocates fast enough (`new byte[int.MaxValue]` in a loop) can still OOM the process first. **CPU:** a tight loop keeps its pool thread after the timeout fires; `ScriptRunGuard` bounds that to one orphan per script by refusing further runs while it spins (Bad `"previous evaluation still running"`) and quarantines after three consecutive timeouts (a refusal is not a strike) — or immediately on a refusal behind an orphan that has run longer than `Scripting:StuckOrphanQuarantineFactor` (default 10) × the timeout, which is what quarantines a never-returning `while(true)`. Both are mitigations; pre-emptive CPU/memory limits need an out-of-process runner (→ #750). **Stack:** unbounded recursion used to terminate the whole server — a `StackOverflowException` cannot be caught, and lmxopcua#779 reproduced it out of process (local function, mutual recursion, self-referencing `Func<>`, anonymous self-application, recursion through LINQ: the child process died with "Stack overflow." / SIGABRT). `ScriptEvaluator.Compile` now rewrites the script so the body and every lambda, anonymous method and local function starts with `ScriptStackGuard.Check()` (`RuntimeHelpers.TryEnsureSufficientExecutionStack`), so the recursion fails the script with `ScriptStackExhaustedException` — Bad quality and a script-log entry like any throwing script — while there is still stack left. A runtime check rather than an analyzer rule because recursion is not decidable from syntax (anonymous self-application never names itself); bounded recursion keeps working. Limits: the depth reached depends on the evaluating thread's stack size; a recursion that never re-enters script code is not seen (the sandbox exposes no such API); a script can catch the exception, which only unwinds it. Pinned by `ScriptRecursionContainmentTests` (one child process per shape) and `ScriptStackGuardInjectionTests`. Script authoring is gated behind the Admin permission as the primary control. See `docs/ScriptEditor.md` §"Runtime safety". +The sandbox cannot pre-empt a script. **Memory:** each run's own-thread allocation is sampled (`GC.GetAllocatedBytesForCurrentThread`) and a run over `Scripting:MaxAllocBytesPerRun` (default 64 MB) quarantines the script — but the sample is taken after the run returns, so a script that allocates fast enough (`new byte[int.MaxValue]` in a loop) can still OOM the process first. **CPU:** a tight loop keeps its pool thread after the timeout fires; `ScriptRunGuard` bounds that to one orphan per script by refusing further runs while it spins (Bad `"previous evaluation still running"`) and quarantines after three consecutive timeouts (a refusal is not a strike) — or immediately on a refusal behind an orphan that has run longer than `Scripting:StuckOrphanQuarantineFactor` (default 10) × the timeout, which is what quarantines a never-returning `while(true)`. Both are mitigations; pre-emptive CPU/memory limits need an out-of-process runner (→ #750). **Stack:** unbounded recursion used to terminate the whole server — a `StackOverflowException` cannot be caught, and lmxopcua#779 reproduced it out of process (local function, mutual recursion, self-referencing `Func<>`, anonymous self-application, recursion through LINQ: the child process died with "Stack overflow." / SIGABRT). `ScriptEvaluator.Compile` now rewrites the script so the body and every lambda, anonymous method and local function starts with `ScriptStackGuard.Check()` (`RuntimeHelpers.TryEnsureSufficientExecutionStack`), so the recursion fails the script with `ScriptStackExhaustedException` — Bad quality and a script-log entry like any throwing script — while there is still stack left. A runtime check rather than an analyzer rule because recursion is not decidable from syntax (anonymous self-application never names itself); bounded recursion keeps working. Limits: the depth reached depends on the evaluating thread's stack size; a recursion that never re-enters script code is not seen (the sandbox exposes no such API); a script can catch the exception, which only unwinds it. Pinned by `ScriptRecursionContainmentTests` (one child process per shape) and `ScriptStackGuardInjectionTests`. Script authoring is gated by the AdminUI `ConfigEditor` policy — **`Administrator` or `Designer`**, not Administrator alone — and that same policy gates deploy, so a Designer can author a script and put it into production; that gate is the primary control (AdminUI-001 deliberately admitted Designers to the script pages and `/api/script-analysis/*`; lmxopcua#779 corrected this sentence, which used to say Admin-only). See `docs/ScriptEditor.md` §"Runtime safety". `System.Threading.Tasks` is denied (Core.Scripting-003), which prevents `Task.Run` / `Parallel` fan-out that would spawn background work outliving the timeout. diff --git a/docs/plans/2026-06-09-monaco-script-editor.md b/docs/plans/2026-06-09-monaco-script-editor.md index 821ca1afe..b6f776540 100644 --- a/docs/plans/2026-06-09-monaco-script-editor.md +++ b/docs/plans/2026-06-09-monaco-script-editor.md @@ -30,6 +30,8 @@ **Authorization:** New endpoints use `.RequireAuthorization("FleetAdmin")` (policy defined in `src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs:113` as `RequireRole("Administrator")` — script design is an admin action, same tier as deploy). +> **Superseded (AdminUI-001, `98b27fc1`; recorded again in lmxopcua#779):** the shipped gate is the `ConfigEditor` policy — `Administrator` **or `Designer`** — on the script pages, `/api/script-analysis/*` and `/deployments` alike. Designer was being 403'd by the analysis backend while the page admitted it, so the backend was loosened to match the page, not the reverse. + **Key fidelity notes (read once):** - The analysis return type is `object` (a `Script` is shared across virtual tags of differing `DataType`; per-tag return coercion stays a runtime concern). - The wrapper preamble (usings + `Run` signature + `var ctx = globals.ctx;`) precedes a `#line 1` directive, then the raw user source (column-aligned, not re-indented). Therefore: **diagnostics** — `tree.GetLineSpan(span)` / `d.Location.GetLineSpan()` return *mapped* (user-source) line numbers automatically; **completions/hover/signature** — use the *physical* offset `prefixLength + offsetWithinUserSource`. diff --git a/docs/security.md b/docs/security.md index 396b68f01..9c638e6bc 100644 --- a/docs/security.md +++ b/docs/security.md @@ -1016,8 +1016,8 @@ The `AdminRole` enum (`src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Enums/AdminRole. | Role | Capabilities | |---|---| | `Viewer` | Read-only access to drafts, generations, audit log, fleet status. (Was `ConfigViewer`.) | -| `Designer` | Viewer plus draft authoring (UNS, equipment, tags, ACLs, driver instances, reservations, CSV imports). Cannot publish. (Was `ConfigEditor`.) | -| `Administrator` | Designer plus publish, cluster/node CRUD, credential management, role-grant management. Satisfies both the `FleetAdmin` and `DriverOperator` authorization policies. (Was `FleetAdmin`.) | +| `Designer` | Viewer plus the whole `ConfigEditor` surface: authoring (UNS, equipment, tags, ACLs, driver instances, reservations, CSV imports, **scripts** — virtual tags, Calculation tags, scripted-alarm predicates) **and deploy** (`/deployments`). A Designer can therefore put operator-authored C# into production; scripts run in-process on driver nodes (see [VirtualTags.md](VirtualTags.md) §"Known resource limits"). (Was `ConfigEditor`.) | +| `Administrator` | Designer plus the `FleetAdmin`-only surface: role-grant management, API keys, certificate trust/untrust/delete and manual redundancy failover. Satisfies both the `FleetAdmin` and `DriverOperator` authorization policies. (Was `FleetAdmin`.) | `DriverOperator` is an **authorization policy name** (kept stable), not an `AdminRole` member. It gates **Reconnect** / **Restart** commands against live driver instances from the Admin UI `DriverStatusPanel` and requires the canonical role `Operator` or `Administrator` (`policy.RequireRole("Operator", "Administrator")` in `AddAuthorization`, `src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs`). `Operator` is an appsettings-only string role (not an `AdminRole` member); map an LDAP group to it via `GroupToRole`, e.g. `"ot-driver-operator": "Operator"`. The `FleetAdmin` policy requires the `Administrator` role.