Files
lmxopcua/docs/v2/acl-design.md
Joseph Doherty 4903a19ec9 Add data-path ACL design (acl-design.md, closes corrections B1) + dev-environment inventory and setup plan (dev-environment.md), and remove consumer cutover from OtOpcUa v2 scope.
ACL design defines NodePermissions bitmask flags covering Browse / Read / Subscribe / HistoryRead / WriteOperate / WriteTune / WriteConfigure / AlarmRead / AlarmAcknowledge / AlarmConfirm / AlarmShelve / MethodCall plus common bundles (ReadOnly / Operator / Engineer / Admin); 6-level scope hierarchy (Cluster / Namespace / UnsArea / UnsLine / Equipment / Tag) with default-deny + additive grants and Browse-implication on ancestors; per-LDAP-group grants in a new generation-versioned NodeAcl table edited via the same draft → diff → publish → rollback boundary as every other content table; per-session permission-trie evaluator with O(depth × group-count) cost cached for the lifetime of the session and rebuilt on generation-apply or LDAP group cache expiry; cluster-create workflow seeds a default ACL set matching the v1 LmxOpcUa LDAP-role-to-permission map for v1 → v2 consumer migration parity; Admin UI ACL tab with two views (by LDAP group, by scope), bulk-grant flow, and permission simulator that lets operators preview "as user X" effective permissions across the cluster's UNS tree before publishing; explicit Deny deferred to v2.1 since verbose grants suffice at v2.0 fleet sizes; only denied OPC UA operations are audit-logged (not allowed ones — would dwarf the audit log). Schema doc gains the NodeAcl table with cross-cluster invariant enforcement and same-generation FK validation; admin-ui.md gains the ACLs tab; phase-1 doc gains Task E.9 wiring this through Stream E plus a NodeAcl entry in Task B.1's DbContext list.

Dev-environment doc inventories every external resource the v2 build needs across two tiers per decision #99 — inner-loop (in-process simulators on developer machines: SQL Server local or container, GLAuth at C:\publish\glauth\, local dev Galaxy) and integration (one dedicated Windows host with Docker Desktop on WSL2 backend so TwinCAT XAR VM can run in Hyper-V alongside containerized oitc/modbus-server, plus WSL2-hosted Snap7 and ab_server, plus OPC Foundation reference server, plus FOCAS TestStub and FaultShim) — with concrete container images, ports, default dev credentials (clearly marked dev-only since production uses Integrated Security / gMSA per decision #46), bootstrap order for both tiers, network topology diagram, test data seed locations, and operational risks (TwinCAT trial expiry automation, Docker pricing, integration host SPOF mitigation, per-developer GLAuth config sync, Aveva license scoping that keeps Galaxy tests on developer machines and off the shared host).

Removes consumer cutover (ScadaBridge / Ignition / System Platform IO) from OtOpcUa v2 scope per decision #136 — owned by a separate integration / operations team, tracked in 3-year-plan handoff §"Rollout Posture" and corrections §C5; OtOpcUa team's scope ends at Phase 5. Updates implementation/overview.md phase index to drop the "6+" row and add an explicit "OUT of v2 scope" callout; updates phase-1 and phase-2 docs to reframe cutover as integration-team-owned rather than future-phase numbered.

Decisions #129–137 added: ACL model (#129), NodeAcl generation-versioned (#130), v1-compatibility seed (#131), denied-only audit logging (#132), two-tier dev environment (#133), Docker WSL2 backend for TwinCAT VM coexistence (#134), TwinCAT VM centrally managed / Galaxy on dev machines only (#135), cutover out of v2 scope (#136), dev credentials documented openly (#137).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 11:58:33 -04:00

380 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# OPC UA Client Authorization (ACL Design) — OtOpcUa v2
> **Status**: DRAFT — closes corrections-doc finding B1 (namespace / equipment-subtree ACLs not yet modeled in the data path).
>
> **Branch**: `v2`
> **Created**: 2026-04-17
## Scope
This document defines the **OPC UA client data-path authorization model** — who can read, write, subscribe, browse, ack alarms, etc. on which nodes when connecting to the OtOpcUa server endpoint. It is distinct from:
- **Admin UI authorization** (`admin-ui.md`) — who can edit configuration. That layer has FleetAdmin / ConfigEditor / ReadOnly roles + cluster-scoped grants per decisions #88, #105.
- **DB principal authorization** (`config-db-schema.md` §"Authorization Model") — who can call which stored procedures on the central config DB. That layer is per-NodeId for cluster nodes and per-Admin for Admin app users.
The data-path ACL layer covers OPC UA clients (ScadaBridge, Ignition, System Platform IO, third-party tools) that connect to the OPC UA endpoint to read or modify equipment data.
## Permission Model
Every node operation requires an explicit permission. Permissions are bitmask flags on the v2 schema; the OPC UA NodeManager checks them on every browse, read, write, subscribe, history read, alarm event, and method call.
### Permission flags
```csharp
[Flags]
public enum NodePermissions : uint
{
None = 0,
// Read-side
Browse = 1 << 0, // See node in BrowseRequest results
Read = 1 << 1, // ReadRequest current value
Subscribe = 1 << 2, // CreateMonitoredItems
HistoryRead = 1 << 3, // HistoryReadRaw / HistoryReadProcessed
// Write-side (mirrors v1 SecurityClassification model — see config-db-schema.md Equipment ACL)
WriteOperate = 1 << 4, // Write attrs with FreeAccess/Operate classification
WriteTune = 1 << 5, // Write attrs with Tune classification
WriteConfigure = 1 << 6, // Write attrs with Configure classification
// Alarm-side
AlarmRead = 1 << 7, // Receive alarm events for this node
AlarmAcknowledge = 1 << 8, // Ack alarms (separate from Confirm — OPC UA Part 9 distinction)
AlarmConfirm = 1 << 9, // Confirm alarms
AlarmShelve = 1 << 10, // Shelve / unshelve alarms
// Method invocation (OPC UA Part 4 §5.11)
MethodCall = 1 << 11, // Invoke methods on the node
// Common bundles (also exposed in Admin UI as one-click selections)
ReadOnly = Browse | Read | Subscribe | HistoryRead | AlarmRead,
Operator = ReadOnly | WriteOperate | AlarmAcknowledge | AlarmConfirm,
Engineer = Operator | WriteTune | AlarmShelve,
Admin = Engineer | WriteConfigure | MethodCall,
}
```
The bundles (`ReadOnly` / `Operator` / `Engineer` / `Admin`) are derived from production patterns at sites running v1 LmxOpcUa — they're the common grant shapes operators reach for. Granular per-flag grants stay supported for unusual cases.
### Why three Write tiers (Operate / Tune / Configure)
Mirrors v1's `SecurityClassification` mapping (`docs/DataTypeMapping.md`). Galaxy attributes carry a security classification; v1 maps `FreeAccess`/`Operate` to writable, `SecuredWrite`/`VerifiedWrite`/`ViewOnly` to read-only. The v2 model preserves this for Galaxy and extends it to all drivers via `Tag.SecurityClassification`:
| Classification | Permission needed to write |
|----------------|---------------------------|
| FreeAccess | `WriteOperate` |
| Operate | `WriteOperate` |
| Tune | `WriteTune` |
| Configure | `WriteConfigure` |
| SecuredWrite / VerifiedWrite / ViewOnly | (not writable from OPC UA — v1 behavior preserved) |
A user with `WriteTune` can write Operate-classified attrs too (Tune is more privileged). The check is `requestedClassification ≤ grantedTier`.
### Why AlarmRead is separate from Read
In OPC UA Part 9 alarm subscriptions are a distinct subscription type — a client can subscribe to events on a node without reading its value. Granting Read alone does not let a client see alarm events; AlarmRead is required separately. The `ReadOnly` bundle includes both.
### Why MethodCall is separate
OPC UA methods (Part 4 §5.11) are arbitrary procedure invocations on a node. v1 LmxOpcUa exposes very few; future drivers (especially OPC UA Client gateway) will surface more. MethodCall is gated explicitly because side-effects can be unbounded — analogous to executing a stored procedure rather than reading a column.
## Scope Hierarchy
ACL grants attach to one of six scope levels. Granting at higher level cascades to lower (with browse implication for ancestors); explicit Deny at lower level is **deferred to v2.1** (decision below).
```
Cluster ← cluster-wide grant (highest scope)
└── Namespace ← per-namespace grant (Equipment vs SystemPlatform vs Simulated)
└── UnsArea ← per-area grant (Equipment-namespace only)
└── UnsLine ← per-line grant
└── Equipment ← per-equipment grant
└── Tag ← per-tag grant (lowest scope; rarely used)
```
For SystemPlatform-namespace tags (no Equipment row, no UNS structure), the chain shortens to:
```
Cluster
└── Namespace
└── (Tag's FolderPath segments — treated as opaque hierarchy)
└── Tag
```
### Inheritance and evaluation
For each operation on a node:
1. Walk the node's scope chain from leaf to root (`Tag → Equipment → UnsLine → UnsArea → Namespace → Cluster`)
2. At each level, look up `NodeAcl` rows where `LdapGroup ∈ user.Groups` and `(ScopeKind, ScopeId)` matches
3. Union the `PermissionFlags` from every matching row
4. Required permission must be set in the union → allow; else → deny
5. Browse is implied at every ancestor of any node where the user has any non-Browse permission — otherwise the user can't navigate to it
### Default-deny
If the union is empty (no group of the user's has any grant matching the node's chain), the operation is **denied**:
- Browse → node hidden from results
- Read / Subscribe / HistoryRead → `BadUserAccessDenied`
- Write → `BadUserAccessDenied`
- AlarmAck / AlarmConfirm / AlarmShelve → `BadUserAccessDenied`
- MethodCall → `BadUserAccessDenied`
### Why no explicit Deny in v2.0
Two patterns can express "X group can write everywhere except production line 3":
- **(a)** Verbose: grant Engineering on every line except line 3 — many rows but unambiguous
- **(b)** Explicit Deny that overrides Grant — fewer rows but evaluation logic must distinguish "no grant" from "explicit deny"
For v2.0 fleets (≤50 clusters, ≤20 lines per cluster typical) approach (a) is workable — operators use the bulk-grant Admin UI flow to apply grants across many lines minus exceptions. Explicit Deny adds non-trivial complexity to the evaluator and the Admin UI; defer to v2.1 unless a deployment demonstrates a real need.
## Schema — `NodeAcl` Table
Generation-versioned (decision #105 pattern — ACLs are content, travel through draft → diff → publish like every other consumer-visible config):
```sql
CREATE TABLE dbo.NodeAcl (
NodeAclRowId uniqueidentifier NOT NULL PRIMARY KEY DEFAULT NEWSEQUENTIALID(),
GenerationId bigint NOT NULL FOREIGN KEY REFERENCES dbo.ConfigGeneration(GenerationId),
NodeAclId nvarchar(64) NOT NULL, -- stable logical ID across generations
ClusterId nvarchar(64) NOT NULL FOREIGN KEY REFERENCES dbo.ServerCluster(ClusterId),
LdapGroup nvarchar(256) NOT NULL, -- LDAP group name (e.g. "OtOpcUaOperators-LINE3")
ScopeKind nvarchar(16) NOT NULL CHECK (ScopeKind IN ('Cluster', 'Namespace', 'UnsArea', 'UnsLine', 'Equipment', 'Tag')),
ScopeId nvarchar(64) NULL, -- NULL when ScopeKind='Cluster'; otherwise the logical ID of the scoped entity
PermissionFlags int NOT NULL, -- bitmask of NodePermissions
Notes nvarchar(512) NULL
);
CREATE INDEX IX_NodeAcl_Generation_Cluster
ON dbo.NodeAcl (GenerationId, ClusterId);
CREATE INDEX IX_NodeAcl_Generation_Group
ON dbo.NodeAcl (GenerationId, LdapGroup);
CREATE INDEX IX_NodeAcl_Generation_Scope
ON dbo.NodeAcl (GenerationId, ScopeKind, ScopeId) WHERE ScopeId IS NOT NULL;
CREATE UNIQUE INDEX UX_NodeAcl_Generation_LogicalId
ON dbo.NodeAcl (GenerationId, NodeAclId);
-- Within a generation, a (Group, Scope) pair has at most one row (additive grants would be confusing
-- in the audit trail; use a single row with the union of intended permissions instead)
CREATE UNIQUE INDEX UX_NodeAcl_Generation_GroupScope
ON dbo.NodeAcl (GenerationId, ClusterId, LdapGroup, ScopeKind, ScopeId);
```
### Cross-generation invariant
Same pattern as Equipment / Namespace: `NodeAclId` is append-only per cluster — once published, the logical ID stays bound to its `(LdapGroup, ScopeKind, ScopeId)` triple. Renaming an LDAP group is forbidden — disable the old grant and create a new one. This protects the audit trail.
### Validation in `sp_ValidateDraft`
Adds these checks beyond the existing schema rules:
- **ScopeId resolution**: when `ScopeKind ∈ {Namespace, UnsArea, UnsLine, Equipment, Tag}`, `ScopeId` must resolve to the corresponding entity in the same generation
- **Cluster cohesion**: the resolved scope must belong to the same `ClusterId` as the ACL row
- **PermissionFlags validity**: bitmask must only contain bits defined in `NodePermissions` enum (no future-bit speculation)
- **LdapGroup format**: non-empty, ≤256 chars, no characters that would break LDAP DN escaping (allowlist)
- **No identity drift**: `NodeAclId` once published with `(LdapGroup, ScopeKind, ScopeId)` cannot have any of those four columns change in a future generation
## Evaluation Algorithm
### At session establishment
```
on AcceptSession(user):
user.Groups = LdapAuth.ResolveGroups(user.Token)
user.PermissionMap = BuildEffectivePermissionMap(currentGeneration, user.Groups)
cache user.PermissionMap on the session
```
`BuildEffectivePermissionMap` produces a sparse trie keyed by node-path-prefix:
```
PermissionMap structure (per session):
/ → grant union from Cluster + Namespace-level rows
/Equipment-NS/UnsArea-A/ → adds UnsArea-level grants
/Equipment-NS/UnsArea-A/UnsLine-1/ → adds UnsLine-level grants
/Equipment-NS/UnsArea-A/UnsLine-1/Equipment-X/ → adds Equipment-level grants
/Equipment-NS/UnsArea-A/UnsLine-1/Equipment-X/Tag-Y → adds Tag-level grants (rare)
```
Lookup for a node at path P: walk the trie from `/` to P, OR-ing PermissionFlags at each visited level. Result = effective permissions for P. O(depth) — typically 6 or fewer hops.
### Per-operation check
```csharp
bool Authorize(SessionContext ctx, NodePath path, NodePermissions required)
{
var effective = ctx.PermissionMap.Lookup(path);
return (effective & required) == required;
}
```
- Browse: `Authorize(ctx, path, Browse)` — falsy → omit from results
- Read: `Authorize(ctx, path, Read)` → falsy → `BadUserAccessDenied`
- Write: `Authorize(ctx, path, requiredWriteFlag)` where `requiredWriteFlag` is derived from the target attribute's `SecurityClassification`
- Subscribe: `Authorize(ctx, path, Subscribe)` — also implies Browse on the path
- HistoryRead: `Authorize(ctx, path, HistoryRead)`
- Alarm event: `Authorize(ctx, path, AlarmRead)` — events for unauthorized nodes are filtered out before delivery
- AlarmAck/Confirm/Shelve: corresponding flag check
- MethodCall: `Authorize(ctx, methodNode.path, MethodCall)`
### Cache invalidation
The session's `PermissionMap` is rebuilt when:
- A new config generation is applied locally (the path-trie may have changed structure due to UNS reorg or new equipment)
- The LDAP group cache for the user expires (default: 15 min — driven by the LDAP layer, separate from this design)
- The user's session is re-established
For unattended consumer connections (ScadaBridge, Ignition) that hold long sessions, the per-generation rebuild keeps permissions current without forcing reconnects.
## Performance
Worst-case per-operation cost: O(depth × group-count). For typical fleet sizes (10 LDAP groups per user, 6-deep UNS path), that's ~60 trie lookups per operation — sub-microsecond on modern hardware. The session-scoped cache means the per-operation hot path is array indexing, not DB queries.
Build cost (at session establish or generation reapply): O(N_acl × M_groups) for N_acl rows and M_groups in user's claim set. For 1000 ACL rows × 10 groups = 10k joins; sub-second on a sane DB.
Memory cost: per-session trie ~4 KB for typical scopes; bounded by O(N_acl) worst case. Sessions hold their own trie — no shared state to invalidate.
## Default Permissions for Existing v1 LDAP Groups
To preserve v1 LmxOpcUa behavior on first migration, the v2 default ACL set on cluster creation maps the existing v1 LDAP-role-to-permission grants:
| v1 LDAP role (per `Security.md`) | v2 NodePermissions bundle | Scope |
|----------------------------------|---------------------------|-------|
| `ReadOnly` (group: `OtOpcUaReadOnly`) | `ReadOnly` bundle | Cluster |
| `WriteOperate` (group: `OtOpcUaWriteOperate`) | `Operator` bundle | Cluster |
| `WriteTune` (group: `OtOpcUaWriteTune`) | `Engineer` bundle | Cluster |
| `WriteConfigure` (group: `OtOpcUaWriteConfigure`) | `Admin` bundle | Cluster |
| `AlarmAck` (group: `OtOpcUaAlarmAck`) | adds `AlarmAcknowledge \| AlarmConfirm` to user's existing grants | Cluster |
These are seeded by the cluster-create workflow into the initial draft generation (per decision #123 — namespaces and ACLs both travel through publish boundary). Operators can then refine to per-Equipment scopes as needed.
## Admin UI
### New tab: ACLs (under Cluster Detail)
```
/clusters/{ClusterId} Cluster detail (tabs: Overview / Namespaces / UNS Structure / Drivers / Devices / Equipment / Tags / **ACLs** / Generations / Audit)
```
Two views, toggle at the top:
#### View 1 — By LDAP group
| LDAP Group | Scopes | Permissions | Notes |
|------------|--------|-------------|-------|
| `OtOpcUaOperators` | Cluster | Operator bundle | Default operators (seeded) |
| `OtOpcUaOperators-LINE3` | UnsArea bldg-3 | Engineer bundle | Line 3 supervisors |
| `OtOpcUaScadaBridge` | Cluster | ReadOnly | Tier 1 consumer (added before cutover) |
Click a row → edit grant: change scope, change permission set (one-click bundles or per-flag), edit notes.
#### View 2 — By scope (UNS tree)
Tree view of UnsArea → UnsLine → Equipment with permission badges per node showing which groups have what:
```
bldg-3/ [Operators: Operator, ScadaBridge: ReadOnly]
├── line-2/ [+ LINE3-Supervisors: Engineer]
│ ├── cnc-mill-05 [+ CNC-Maintenance: WriteTune]
│ ├── cnc-mill-06
│ └── injection-molder-02
└── line-3/
```
Click a node → see effective permissions per group, edit grants at that scope.
### Bulk grant flow
"Bulk grant" button on either view:
1. Pick LDAP group(s)
2. Pick permission bundle or per-flag
3. Pick scope set: pattern (e.g. all UnsArea matching `bldg-*`), or multi-select from tree
4. Preview: list of `NodeAcl` rows that will be created
5. Confirm → adds rows to current draft
### Permission simulator
"Simulate as user" panel: enter username + LDAP groups → UI shows the effective permission map across the cluster's UNS tree. Useful before publishing — operators verify "after this change, ScadaBridge can still read everything it needs" without actually deploying.
### Operator workflows added to admin-ui.md
Three new workflows:
1. **Grant ACL** — usual draft → diff → publish, scoped to ACLs tab
2. **Bulk grant** — multi-select scope + group + permission, preview, publish
3. **Simulate as user** — preview-only, no publish required
### v1 deviation log
For each cluster, the Admin UI shows a banner if its NodeAcl set diverges from the v1-default seed (per the table above). This makes intentional tightening or loosening visible at a glance — important for compliance review during the long v1 → v2 coexistence period.
## Audit
Every NodeAcl change is in `ConfigAuditLog` automatically (per the publish boundary — same as any other content edit). Plus the OPC UA NodeManager logs every **denied** operation:
```
EventType = 'OpcUaAccessDenied'
DetailsJson = { user, groups, requestedOperation, nodePath, requiredPermission, effectivePermissions }
```
Allowed operations are NOT logged at this layer (would dwarf the audit log; OPC UA SDK has its own session/operation diagnostics for high-frequency telemetry). The choice to log denials only mirrors typical authorization-audit practice and can be tightened per-deployment if a customer requires full positive-action logging.
## Test Strategy
Unit tests for the evaluator:
- Empty ACL set → all operations denied (default-deny invariant)
- Single Cluster-scope grant → operation allowed at every node in the cluster
- Single Equipment-scope grant → allowed at the equipment + its tags; denied at sibling equipment
- Multiple grants for same group → union (additive)
- Multiple groups for same user → union of all groups' grants
- Browse implication: granting Read on a deep equipment auto-allows Browse at every ancestor
- Permission bundle expansion: granting `Operator` bundle = granting `Browse | Read | Subscribe | HistoryRead | AlarmRead | WriteOperate | AlarmAcknowledge | AlarmConfirm`
- v1-compatibility seed: a fresh cluster with the default ACL set behaves identically to v1 LmxOpcUa for users in the v1 LDAP groups
Integration test (Phase 1+):
- Create cluster + equipment + tags + ACL grants
- Connect OPC UA client as a `ReadOnly`-mapped user → browse and read succeed; write fails
- Re-publish with a tighter ACL → existing session's permission map rebuilds; subsequent writes that were allowed are now denied
- Verify `OpcUaAccessDenied` audit log entries for the denied operations
Adversarial review checks (run during exit gate):
- Can a client connect with no LDAP group at all and read anything? (must be no — default deny)
- Can a client see a node in browse but not read its value? (yes, if Browse granted but not Read — unusual but valid)
- Does a UnsArea rename cascade ACL grants correctly? (the grant references UnsAreaId not name, so rename is transparent)
- Does an Equipment merge (Admin operator flow) preserve ACL grants on the surviving equipment? (must yes; merge flow updates references)
- Does generation rollback restore the prior ACL state? (must yes; ACLs are generation-versioned)
## Implementation Plan
ACL design enters the implementation pipeline as follows:
### Phase 1 (Configuration + Admin scaffold)
- Schema: add `NodeAcl` table to the Phase 1 migration
- Validation: add NodeAcl rules to `sp_ValidateDraft`
- Admin UI: scaffold the ACLs tab with view + edit + bulk grant + simulator
- Default seed: cluster-create workflow seeds the v1-compatibility ACL set
- Generation diff: include NodeAcl in `sp_ComputeGenerationDiff`
### Phase 2+ (every driver phase)
- Wire the ACL evaluator into `GenericDriverNodeManager` so every Browse / Read / Write / Subscribe / HistoryRead / AlarmRead / AlarmAck / MethodCall consults the per-session permission map
- Per-driver tests: assert that a default-deny user cannot read or subscribe to that driver's namespace; assert that a `ReadOnly`-bundled user can; assert that the appropriate Write tier is needed for each `SecurityClassification`
### Pre-tier-1-cutover (before Phase 6 / consumer cutover)
- Verify ScadaBridge's effective permissions in the Admin UI simulator before any cutover
- Adversarial review of the per-cluster ACL set with a fresh pair of eyes
## Decisions to Add to plan.md
(Will be appended to the decision log on the next plan.md edit.)
| # | Decision | Rationale |
|---|----------|-----------|
| 129 | OPC UA client data-path authorization model = bitmask `NodePermissions` flags + per-LDAP-group grants on a 6-level scope hierarchy (Cluster / Namespace / UnsArea / UnsLine / Equipment / Tag) | Closes corrections-doc finding B1. Mirrors v1 SecurityClassification model for Write tiers; adds explicit AlarmRead/Ack/Confirm/Shelve and MethodCall flags. Default-deny; additive grants; explicit Deny deferred to v2.1. See `acl-design.md` |
| 130 | `NodeAcl` table generation-versioned, edited via draft → diff → publish like every other content table | Same pattern as Namespace (decision #123) and Equipment (decision #109). ACL changes are content, not topology — they affect what consumers see at the OPC UA endpoint. Rollback restores the prior ACL state |
| 131 | Cluster-create workflow seeds default ACL set matching v1 LmxOpcUa LDAP-role-to-permission map | Preserves behavioral parity for v1 → v2 consumer migration. Operators tighten or loosen from there. Admin UI flags any cluster whose ACL set diverges from the seed |
| 132 | OPC UA NodeManager logs denied operations only; allowed operations rely on SDK session/operation diagnostics | Logging every allowed op would dwarf the audit log. Denied-only mirrors typical authorization audit practice. Per-deployment policy can tighten if compliance requires positive-action logging |
## Open Questions
- **OPC UA Method support scope**: how many methods does v1 expose? Need to enumerate before tier 3 cutover (System Platform IO is the most likely consumer of methods). The MethodCall permission is defined defensively but may not be exercised in v2.0.
- **Group claim source latency**: LDAP group cache TTL (default 15 min above) is taken from the v1 LDAP layer. If the OPC UA session's group claims need to be refreshed faster (e.g. for emergency revoke), we need a shorter TTL or an explicit revoke channel. Decide per operational risk appetite.
- **AlarmConfirm vs AlarmAcknowledge** semantics: OPC UA Part 9 distinguishes them (Ack = "I've seen this"; Confirm = "I've taken action"). Some sites only use Ack; the v2.0 model exposes both but a deployment-level policy can collapse them in practice.